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
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&¤t===owner)publish({status:'ready',query,items:Object.freeze([...items]),message:''});
}catch(error){
if(!disposed&¤t===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
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。
继续阅读
- 排错
- 语言与交互
- 语言与交互