js闭包的高级用法和工程实践示例

回复 群友 1434 工程里用闭包,核心就一件事:把“状态”和“操作状态的函数”绑在一起,对外只暴露受控接口,隐藏内部细节。下面直接给几个在实际项目(前端基建、SDK、ca 工具链)里最常用的高级模式和代码。 1. 异步并发控制与请求合并(Singleflight / 竞态锁) 工程痛点:多个组件同时挂载,触发同一个获取配置或刷新 Token 的接口,导致重复请求甚至 Token 互踢。用闭包把進行中的 Promise 藏起来,实现同参请求复用。 function createSingleflight(asyncFn) { let inflight = new Map; return function (...args) { const key = JSON.stringify(args); if (inflight.has(key)) { return inflight.get(key); } const promise = Promise.resolve(asyncFn.apply(this args)).finally( => { inflight.delete(key); }); inflight.set(key promise); return promise; }; } // 用法:无论多少处同时调 getUserProfile(1),网络层只发一次,返回后自动清理引用 const getUserProfile = createSingleflight((id) => fetch('/api/user/' + id)); 2. 洋葱模型中间件组合(Compose / Pipeline) 工程痛点:在做埋点 SDK、请求拦截器或者 Agent 工具调用的 Hook 链路时,需要在动作前后插入鉴权、日志、重试逻辑。用闭包把 next 节点一层层包起来。 function compose(middlewares) { return function (context next) { let index = -1; function dispatch(i) { if (i <= index) return Promise.reject(new Error('next called multiple times')); index = i; let fn = middlewares[i] || next; if (!fn) return Promise.resolve; try { return Promise.resolve(fn(context => dispatch(i + 1))); } catch (err) { return Promise.reject(err); } } return dispatch(0); }; } // 这里的 index 是每次执行时的私有游标,用来防止同一个中间件里手滑调两次 next 3. 惰性求值与环境嗅探固化(Lazy Function Definition) 工程痛点:兼容多端环境(浏览器 / Node / WebWorker)或嗅探特性时,每次调用都跑一遍 if-else 很蠢。利用闭包或函数重写,只在第一次调用时做嗅探,之后直接走最短路径。 const createStorage = => { let impl = null; return { get(key) { if (!impl) { // 只在真正第一次用的时候初始化,避免 SSR 阶段直接访问 window 报错 impl = typeof window!== 'undefined' && window.localStorage? (k) => window.localStorage.getItem(k): (k) => globalMemoryMap.get(k); } return impl(key); } }; }; 4. 资源清理与取消令牌(Teardown / Unsubscribe 模式) 工程痛点:事件监听、定时器、观察者模式里,把解绑逻辑直接作为返回值交出去,调用方不需要记 listener 引用,也不会误删别人的回调。React 的 useEffect cleanup 和 Zustand 的 subscribe 全是这个路数。 function createEventBus { const listeners = new Set; return { on(fn) { listeners.add(fn); let active = true; // 返回专属的销毁闭包,幂等,调多次也不会报错 return => { if (!active) return; active = false; listeners.delete(fn); }; } emit(data) { listeners.forEach((fn) => fn(data)); } }; } 5. 带容量上限与过期的记忆化(Bounded Memoize) 工程痛点:普通 memoize 用个空对象无限往里塞,长周期运行必内存泄漏。工程上一定要在闭包里加上容量淘汰或 TTL。 function memoizeWithTTL(fn ttlMs = 5000 maxSize = 100) { const cache = new Map; return function (...args) { const key = JSON.stringify(args); const now = Date.now; if (cache.has(key)) { const entry = cache.get(key); if (now < entry.expireAt) return entry.value; cache.delete(key); } const value = fn.apply(this args); if (cache.size >= maxSize) { // Map 的 keys 按插入顺序迭代,直接删最早的一个 const oldestKey = cache.keys.next.value; cache.delete(oldestKey); } cache.set(key { value expireAt: now + ttlMs }); return value; }; } 最后提两个工程里最容易踩的闭包坑: 一是 V8 的共享词法环境(Context Retention)。同一个父函数里只要有一个内部函数被外部长期持有,整个父作用域里被任何子函数引用过的变量都会留在内存里。如果闭包里顺手拿了大对象(比如 DOM 节点、几兆的 AST 或上下文数组)的一个小属性,记得先把小属性解构出来,再把大对象置空,别让闭包把整个大对象钉死在内存里。 二是异步循环里的过期闭包(Stale Closure)。在 React Hooks 或长轮询定时器里最常见:定时器闭包捕获的是第一次渲染时的 state 快照。解法要么是用对象引用(useRef)绕过值捕获,要么在状态更新时通过上面第 4 种模式把旧闭包销毁重绑。