← 返回题目列表

如何手写 once 函数?它适合哪些场景?

中等 第 20 / 27 题 更新于 2026/07/29
手写代码闭包函数工具once

简化版

once 用来保证某个函数最多只执行一次,后续调用直接返回第一次的结果。实现依赖闭包保存是否执行过、第一次结果和调用上下文。常用于初始化、事件只绑定一次、SDK 启动、防止重复提交等场景。关键边界是保留 this、透传参数、缓存返回值,以及第一次抛错后是否允许再次执行。

详细版

基础写法很短,但要把语义说清楚。

细节说明
called记录是否执行过
result缓存第一次返回值
this使用 fn.apply(this, args)
错误可选择抛错后仍算执行过,或允许重试
function once<T extends (...args: any[]) => any>(fn: T): T {
  let called = false
  let result: ReturnType<T>
  return function (this: unknown, ...args: Parameters<T>) {
    if (!called) {
      called = true
      result = fn.apply(this, args)
    }
    return result
  } as T
}

once 的本质是闭包保存状态,让函数从“每次调用都执行”变成“第一次调用才执行”。

完整版教学

一、once 考什么

这道题考闭包、函数上下文、参数透传、返回值缓存和边界设计。代码不难,但面试官常追问“如果第一次执行失败怎么办”。

手写题不要只写最短版本,最好主动解释语义。

二、闭包为什么能保存状态

once 返回的新函数引用了外层的 calledresult。即使外层函数执行完,这两个变量也不会销毁,因为内部函数还在使用它们。

const initOnce = once(initApp)
initOnce()
initOnce()

第二次调用时,闭包里的 called 已经是 true,所以不会再执行原函数。

三、为什么要缓存返回值

如果后续调用直接返回 undefined,调用者会困惑。更常见语义是所有调用都得到第一次执行的结果。

const createConfig = once(() => ({ env: 'prod' }))

const a = createConfig()
const b = createConfig()
console.log(a === b) // true

四、为什么要保留 this

如果包装对象方法,原函数内部可能依赖 this。

const obj = {
  count: 0,
  inc: once(function () {
    this.count++
    return this.count
  })
}

fn.apply(this, args) 可以让调用时的上下文传进去。

五、第一次抛错怎么办

有两种设计:

设计行为适合场景
抛错也算执行过后续不再执行防止副作用重复
抛错不算执行过后续可重试初始化可能暂时失败

如果希望失败可重试,就应该在成功后再设置 called = true

六、异步 once 怎么处理

如果原函数返回 Promise,基础 once 会缓存第一次 Promise。并发多次调用会拿到同一个 Promise,这通常正是我们想要的。

const loadConfigOnce = once(() => fetch('/config').then(r => r.json()))

如果 Promise reject 后是否重试,要按业务重新设计。

七、常见误区与追问

  • 误区:once 只要一个布尔值就够。 还要缓存返回值,否则后续调用语义不一致。
  • 误区:包装后 this 不重要。 对象方法和类方法可能依赖 this,必须保留。
  • 误区:异步 once 会等 Promise 成功后才防重复。 基础实现会立即缓存 Promise,可以合并并发请求。
  • 追问:第一次执行抛错还算执行过吗? 两种语义都可以,关键是说明适用场景。
  • 追问:once 和防抖有什么区别? once 是生命周期内最多一次,防抖是高频触发后延迟执行。
  • 追问:如何重置 once? 可以返回带 reset 方法的包装器,清空 called 和 result。

八、加强记忆

once 记成“三个保存”:保存是否调用过、保存第一次结果、保存调用时 this。边界重点是抛错语义和异步 Promise 是否需要失败重试。