Skip to content

JavaScript 深入:闭包、任务所有者与不可变快照 ​

对象、闭包与模块生存期 ​

先掌握值、函数、对象和ES模块。对象赋值传递引用值;两个变量可指向同一对象,修改属性会被另一方观察到。闭包捕获词法环境中的绑定,不能简单理解为创建时复制全部值。createLatest中的current、snapshot、listeners由方法闭包共享;每次调用createLatest得到独立环境。把模型提升到模块顶层会在该模块实例的所有消费者间共享,在SSR中可能跨请求泄露状态,因此本项目由每个应用/组件所有者创建实例。

属性读取会沿原型链查找,in也能命中继承属性;边界解析若只允许自有字段应使用Object.hasOwn。本模型加载器返回数组字符串,不将对象键合并进原型对象。对象字面量返回的方法捕获变量而不依赖this,才能直接传给useSyncExternalStore。普通方法若读this,脱离接收者调用会改变含义;箭头函数词法this不能通过call重新绑定。这些是接口设计选择,不是所有函数都应改箭头。

Object.freeze冻结的是对象自身属性,不递归冻结成员。本项目每次发布新快照,并为items复制数组再freeze,元素为原始字符串,因此该两层足够;若数组元素改成对象,必须重新定义不可变边界。稳定getSnapshot在未更新时返回同一引用,既支持订阅协议也避免外部Store无限渲染。

任务调度与失败边界 ​

JavaScript旧响应晚到时不能覆盖新查询:取消负责通知资源停止,AbortSignal与finally的所有者判断共同保证当前状态只由当前任务发布。

async函数总返回Promise,await只暂停当前异步流程;异步I/O不会自动让CPU密集JavaScript并行。同步栈结束后处理已入队Promise反应;不断新增微任务也可能饿死后续任务。Promise.all遇拒绝不取消其他工作,race也不取消输家。真正停止工作需要协议和清理,超时包装本身不能保证底层任务已结束。

本模型一次只允许当前所有者发布;开始新任务时abort旧控制器,再用新控制器的对象身份作为令牌。旧任务即使忽略信号、稍后成功或失败,也无法改写快照。finally只在自己仍是current时清空,避免旧任务结束后误清除新任务。验证输入在取消当前任务之前,非法调用不会破坏正在进行的合法查询。

cancel使当前请求失去所有权并发布idle;dispose取消且清空订阅,此后禁止run/subscribe;dispose幂等。若加载器完全忽略信号,旧run的Promise仍须等待其结束,本模块只保证不再发布,不能承诺强制终止任意代码。findLessons通过清除timer和移除abort监听器实现协作取消,成功/失败同样移除监听器,避免每次调用都遗留闭包。

完整模块与测试 ​

保存为examples/web/system-lab/model.mjs与model.test.mjs。只需Node24,无npm依赖;浏览器直接import同一模块,完整界面见Web深入项目。字符串查询修剪后1–80码点,结果最多100个且每个不超过80码点;error与slow是可复现实验夹具,不是网络接口。

model.mjs
javascript
const lessons=['JavaScript 事件循环','Node.js 流与背压','React Effect 清理','Vue 响应式','TypeScript 判别联合'];
export function findLessons(query,signal){
 return new Promise((resolve,reject)=>{
  const abort=()=>{clearTimeout(timer);signal.removeEventListener('abort',abort);reject(new DOMException('cancelled','AbortError'));};
  const timer=setTimeout(()=>{
   signal.removeEventListener('abort',abort);
   if(query==='error')reject(new Error('fixture failure'));
   else resolve(lessons.filter(item=>item.toLowerCase().includes(query.toLowerCase())));
  },query==='slow'?800:30);
  signal.addEventListener('abort',abort,{once:true});
  if(signal.aborted)abort();
 });
}
export function createLatest(load=findLessons){
 let snapshot=Object.freeze({status:'idle',query:'',items:Object.freeze([]),message:''});
 let current=null,disposed=false;
 const listeners=new Set();
 const publish=value=>{snapshot=Object.freeze(value);for(const listener of [...listeners])listener();};
 const ensureLive=()=>{if(disposed)throw new Error('model disposed');};
 return {
  getSnapshot:()=>snapshot,
  subscribe(listener){ensureLive();if(typeof listener!=='function')throw new TypeError('listener must be a function');listeners.add(listener);return ()=>listeners.delete(listener);},
  async run(input){
   ensureLive();
   if(typeof input!=='string'||![...input.trim()].length||[...input.trim()].length>80)throw new Error('query length must be 1..80');
   const query=input.trim();current?.abort();const owner=new AbortController();current=owner;
   publish({status:'loading',query,items:Object.freeze([]),message:''});
   try {
    const items=await load(query,owner.signal);
    if(!Array.isArray(items)||items.length>100||items.some(x=>typeof x!=='string'||[...x].length>80))throw new Error('invalid lesson result');
    if(!disposed&&current===owner)publish({status:'ready',query,items:Object.freeze([...items]),message:''});
   }catch(error){
    if(!disposed&&current===owner)publish({status:'error',query,items:Object.freeze([]),message:error instanceof Error?error.message:'unknown failure'});
   }finally{if(current===owner)current=null;}
   return snapshot;
  },
  cancel(){ensureLive();if(!current)return;current.abort();current=null;publish({...snapshot,status:'idle',message:'cancelled'});},
  dispose(){if(disposed)return;disposed=true;current?.abort();current=null;listeners.clear();},
 };
}
model.test.mjs
javascript
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {createLatest,findLessons} from './model.mjs';

const deferred=()=>{let resolve,reject;const promise=new Promise((a,b)=>{resolve=a;reject=b;});return {promise,resolve,reject};};
test('旧操作即使忽略取消也不能覆盖新结果',async()=>{
 const requests=[];const model=createLatest((query,signal)=>{const d=deferred();requests.push({query,signal,...d});return d.promise;});
 const first=model.run('old');const second=model.run('new');
 assert.equal(requests[0].signal.aborted,true);
 requests[1].resolve(['new']);await second;
 const snapshot=model.getSnapshot();requests[0].resolve(['old']);await first;
 assert.equal(model.getSnapshot(),snapshot);assert.deepEqual(snapshot.items,['new']);assert.ok(Object.isFrozen(snapshot.items));
});
test('取消、退订、销毁及失败各有明确状态',async()=>{
 const d=deferred();const model=createLatest(()=>d.promise);let changes=0;
 const off=model.subscribe(()=>changes++);const operation=model.run('slow');model.cancel();off();off();
 assert.equal(changes,2);assert.equal(model.getSnapshot().status,'idle');
 model.dispose();d.reject(new Error('late'));await operation;assert.equal(changes,2);
 await assert.rejects(model.run('after'),/disposed/);
 const failed=createLatest(async()=>{throw new Error('fixture failure');});await failed.run('error');assert.equal(failed.getSnapshot().status,'error');
 assert.equal(failed.getSnapshot().message,'fixture failure');
});
test('空白和超长查询拒绝,微任务结果与订阅快照稳定',async()=>{
 const model=createLatest(async q=>[q]);await assert.rejects(model.run(' '),/1..80/);await assert.rejects(model.run('x'.repeat(81)),/1..80/);
 const before=model.getSnapshot();assert.equal(model.getSnapshot(),before);await model.run(' js ');assert.deepEqual(model.getSnapshot().items,['js']);
 model.dispose();assert.throws(()=>model.subscribe(()=>{}),/disposed/);
});
test('完整fixture实际响应、失败及AbortError',async()=>{
 assert.deepEqual(await findLessons('vue',new AbortController().signal),['Vue 响应式']);
 await assert.rejects(findLessons('error',new AbortController().signal),/fixture failure/);
 const abort=new AbortController();const job=findLessons('slow',abort.signal);abort.abort();await assert.rejects(job,{name:'AbortError'});
});

从仓库根执行node --test examples/web/system-lab/model.test.mjs,预期4组通过、0失败;不靠固定sleep证明竞争条件。第一组可控Promise先完成新请求,再放行旧请求,断言快照引用和内容都保持;第二组覆盖取消、退订、销毁后旧失败及后续调用;第三组检查边界和快照稳定;第四组实际运行fixture及AbortError。

测试通过说明已覆盖的协议成立,不说明所有外部加载器自动支持取消。自定义subscribe回调不得抛异常;真实框架适配器负责及时退订。本模块是教学范围内的外部状态源,未实现缓存、重试、服务端hydration和跨窗口同步,不能以状态模型冒充完整数据框架。

内存与性能取舍 ​

WeakMap不保证对象立即回收,也不能保证进程RSS随某次GC立刻下降;RSS还包括运行时、外部缓冲和其他常驻内存,应观察可达对象与多轮采样。

长生命周期根持有监听器,监听器闭包再持有大对象,会使这些对象继续可达;泄漏往往是可达关系错误,不是“闭包天生泄漏”。先找谁注册、谁退订、谁关闭timer和谁持有Promise。WeakMap适合让键不被容器强持有的关联元数据,不保证值立即回收;FinalizationRegistry不能承担必须发生的资源关闭。

在浏览器Performance记录500次合法查询,再取消等待全部fixture结束,比较不同批次的分配与存活对象;不要只看单次耗时,也不要在正式交互中写500次DOM日志。模型测试用确定性令牌保证正确,性能采样另衡量开销;用memo或缓存前先确认语义和键的生存期,否则优化会变成长驻内存。CPU工作需要分块让出事件循环或Worker,await一个已兑现Promise未必给浏览器绘制机会。

排错与练习 ​

Cannot use import statement先查文件模块类型;未处理拒绝先查调用run的catch和加载器错误路径;取消后仍展示旧结果先查所有者比较,而不是盲目延长延迟。1. 删除finally中的身份判断,画出旧任务晚结束如何破坏新任务。2. 结果改为对象数组时如何保持不可变边界?3. 为什么仅Promise.race超时不能回收监听器?

参考解法

旧请求先进入finally并将current=null,新请求随后结果因身份不等被丢弃,甚至无法被cancel取消;只有当前所有者可清空。对象数组需复制并冻结所需对象层,或明确只读契约并防止外部突变,不能宣称浅freeze等于深不可变。race只决定组合Promise结果,底层仍可能持有timer、连接和闭包;必须把AbortSignal传下去并在每条终止路径移除监听器。

依据:MDN闭包、Promise指南、内存管理、React外部Store协议。核对2026-10-17。

继续阅读