如何手写 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 返回的新函数引用了外层的 called 和 result。即使外层函数执行完,这两个变量也不会销毁,因为内部函数还在使用它们。
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 是否需要失败重试。