闭包封装私有状态还要不要坚持:和 class 私有字段、ES Module 的取舍

闭包是什么、作用域链怎么工作、var 循环变量绑定的坑——这几块基础在之前写摸底那篇的时候已经掰开揉碎讲过了。这篇不重复这些,只谈一个更实际的问题:闭包用来封装私有状态,这套写法在工程里到底还值不值。

团队里最近在推 TypeScript 4.x,一位同事在内部工具库的 PR 里第一次用了 # 开头的私有字段,评审的时候这行代码引起了一点讨论——不是语法看不懂,是大家在犹豫这东西能不能替掉项目里到处都是的闭包私有变量写法。这个问题值得认真过一遍,因为两种方案背后的权衡完全不是"新语法更好看"这么简单。

这批讨论最后没有停在"选哪个"上,而是牵出了闭包在工程里真正会碰到的几类代价:方法体能不能在多个实例之间共享、状态被闭包捕获之后什么时候能被回收、循环体和异步回调结合时闭包会不会悄悄捕获到错误的值、以及工具函数返回的闭包要不要支持取消或者立即执行这类反向控制。这几类问题分开看都不难,放在一起对比着讲,才能看出闭包这个语言机制在不同场景下要付出的具体成本是什么。

闭包私有状态的老写法,成本在哪

先把闭包封装私有状态的标准写法摆出来,项目里请求模块基本都是这个骨架:

1function createRequestClient(baseURL) {
2  let pendingCount = 0;
3  const interceptors = [];
4
5  function get(url, config) {
6    pendingCount += 1;
7    return fetch(baseURL + url, config).finally(() => {
8      pendingCount -= 1;
9    });
10  }
11
12  function addInterceptor(fn) {
13    interceptors.push(fn);
14  }
15
16  return { get, addInterceptor };
17}
18
19const client = createRequestClient('/api');
20client.get('/users');
21console.log(client.pendingCount); // undefined,外部拿不到

pendingCountinterceptors 对外完全不可见,只能通过暴露出去的方法间接操作,这一点没什么疑问。真正的成本在另一处:每调用一次 createRequestClient,就要重新创建一遍 getaddInterceptor 这两个函数。如果这个工厂函数被大量实例化——比如给列表里每一行都建一个 client、或者每个组件实例都各自 new 一份——每个实例都背着自己独立的一份方法体,没法在多个实例之间共享。

这不是闭包本身的错,是工厂函数模式的固有代价。用 class 写同样的逻辑,方法是挂在原型上的,所有实例共享同一份函数对象:

1class RequestClient {
2  pendingCount = 0;
3  interceptors = [];
4
5  constructor(baseURL) {
6    this.baseURL = baseURL;
7  }
8
9  get(url, config) {
10    this.pendingCount += 1;
11    return fetch(this.baseURL + url, config).finally(() => {
12      this.pendingCount -= 1;
13    });
14  }
15
16  addInterceptor(fn) {
17    this.interceptors.push(fn);
18  }
19}

但这样一来,pendingCountinterceptors 又变成了公开属性,外部可以随便改,封装性直接倒退回闭包方案要解决的那个问题。这就是这道题真正的矛盾:用闭包换来私有性,要付出方法体不共享的代价;用 class 换来方法共享,又要放弃私有性。团队里做技术选型的时候,这条权衡链条比"哪个语法更新"重要得多。

#field 能不能顶上来

TypeScript 4.x 已经支持编译 # 开头的私有字段语法(Babel 也支持),这条口子能不能把上面那道矛盾题解开:

1class RequestClient {
2  #pendingCount = 0;
3  #interceptors: Array<(cfg: unknown) => void> = [];
4
5  constructor(private baseURL: string) {}
6
7  get(url: string, config?: RequestInit) {
8    this.#pendingCount += 1;
9    return fetch(this.baseURL + url, config).finally(() => {
10      this.#pendingCount -= 1;
11    });
12  }
13
14  addInterceptor(fn: (cfg: unknown) => void) {
15    this.#interceptors.push(fn);
16  }
17}

方法挂在原型上、字段真正私有,this.#pendingCount 在类外部访问会直接报语法错误,而不是像闭包那样运行时悄悄拿到 undefined。这个语法看着确实能同时解决两边的问题。

但这里要说清楚一件容易夸大的事:#field 目前只是 TC39 的一个提案,2021 年这个阶段推进到了 Stage 4,离写进正式的 ES 规范还差一步,浏览器原生实现也不整齐——Chrome 在早前的版本里就跟进了,Safari、Firefox 的支持进度不完全一致。团队里能用它,靠的是 TypeScript 或者 Babel 把这段语法编译降级成兼容写法(常见做法是编译成 WeakMap 存储),并不是"写了就等于浏览器原生认识这行代码"。换句话说,#field 目前是一个编译期特性,不是运行时原生能力,这条边界必须分清楚,不然升级构建工具链的时候容易踩坑——比如某个旧的 Babel preset 版本没跟上这个提案的最终语法(提案中途改过一次 in 操作符的行为),编译出来的产物和预期不一致。

工程上更现实的判断是:class 私有字段这一年在团队的 TypeScript 项目里已经能用,写起来也顺手,但还没有成为大家默认的写法,多数人写私有状态的第一反应仍然是闭包或者约定俗成的下划线前缀。愿意在新模块里尝试 #field 的人不多,主要顾虑是老项目的构建链路还在 webpack 4 往 5 迁移的窗口期,编译配置没有完全对齐,贸然全面切换容易出现某几个页面构建产物不一致的问题。

模块模式:ES Module 普及之后还有没有必要

除了 class 内部的私有字段,闭包还有另一个经典应用场景——IIFE 包一层,模拟出"模块"的私有作用域:

1const counterModule = (function () {
2  let count = 0;
3
4  function increment() {
5    count += 1;
6    return count;
7  }
8
9  function reset() {
10    count = 0;
11  }
12
13  return { increment, reset };
14})();

这套写法在 ES Module 之前几乎是唯一能实现"模块内部变量不泄漏到全局"的手段。现在项目全面用 webpack + ES Module 组织代码之后,这个问题已经被语言和构建工具从根上解决了——每个 .js 文件本身就是一个独立的模块作用域,文件内部声明的变量默认就不会挂到全局对象上,完全不需要手动包一层 IIFE:

1// counter.js
2let count = 0;
3
4export function increment() {
5  count += 1;
6  return count;
7}
8
9export function reset() {
10  count = 0;
11}

count 天然只在这个模块文件内可见,别的文件哪怕 import * as counter from './counter.js' 也拿不到它,只能拿到显式 export 出去的两个函数。这意味着"用闭包模拟模块隔离"这件事,在项目普遍切到 ES Module 之后已经没有必要继续手写——模块化这个问题的答案从"语言技巧"变成了"语言机制本身"。

不过模块模式没有完全退场,它还留在两类场景里没被替换掉:一类是没有走构建工具的老页面和第三方 SDK,一堆通过 <script> 标签直接引入、彼此共享同一个全局作用域的脚本,IIFE 仍然是隔离它们互相污染变量的现实手段;另一类是模块内部需要维护"跨多次调用的状态"而不是"跨文件的命名空间",这种时候即便文件本身已经是 ES Module,内部该用闭包封装的状态还是要用闭包封装,因为这时候要解决的问题从来不是"文件间隔离",而是"这个状态该不该被随便改"。分清这两类需求,就不会纠结"是不是该把所有闭包都换成 ES Module"这种伪命题。

闭包让本该被回收的对象活得太久

上面说的都是设计取舍,另一个更容易被忽视的成本是内存。闭包让变量的生命周期跟着回调走,这件事本身没问题,出问题的是"跟着走的东西比想象中大"。

排查过一次比较典型的例子:一个图片懒加载工具函数,为了兼容旧代码保留了一份完整的原始图片列表数据,实际只用得上其中的 URL:

1function createLazyLoader(imageList) {
2  // imageList 里每一项还挂着完整的接口返回数据,包含大段描述文字、多档 CDN 地址等字段
3  const observer = new IntersectionObserver((entries) => {
4    entries.forEach((entry) => {
5      if (entry.isIntersecting) {
6        const item = imageList.find((i) => i.el === entry.target);
7        entry.target.src = item.url;
8        observer.unobserve(entry.target);
9      }
10    });
11  });
12
13  return observer;
14}

这个闭包捕获的是整个 imageList,而不是每张图需要的那个字段。页面切换的时候,即便所有 <img> 元素都已经从 DOM 上移除,只要这个 observer 对象还被外部某处引用着(比如页面级别的一个数组收集了所有 observer 实例方便统一 disconnect),闭包作用域链上的 imageList 就跟着一起活下来,而它可能是几百条带大段文本的接口数据。用 Chrome DevTools 的 Memory 面板做过两次 heap snapshot 对比,Retainers 那一栏能直接看到这批数据的持有链条最终指向了某个闭包函数,顺着链条网上找就能定位到具体是哪个变量该早点释放。

修法很直接:闭包只捕获真正需要的字段,而不是整个原始对象:

1function createLazyLoader(imageList) {
2  const urlMap = new WeakMap(imageList.map((item) => [item.el, item.url]));
3
4  const observer = new IntersectionObserver((entries) => {
5    entries.forEach((entry) => {
6      if (entry.isIntersecting) {
7        entry.target.src = urlMap.get(entry.target);
8        observer.unobserve(entry.target);
9        urlMap.delete(entry.target);
10      }
11    });
12  });
13
14  return observer;
15}

这里用 WeakMap 而不是普通 Map,原因正是本文讨论的主题:Map 对键是强引用。如果某个 <img> 元素在滚动进视口之前,所在的页面或组件就被销毁了,isIntersecting 永远不会触发,delete 也不会被调用,Map 就会永远持有这个 DOM 节点的引用,让它无法被垃圾回收——一篇解决内存泄漏的文章,"修复方案"本身又引入了内存泄漏,完全自相矛盾。WeakMap 的键是弱引用,DOM 节点一旦脱离文档树且没有其他强引用,垃圾回收器可以直接回收它,WeakMap 里对应的条目也随之消失,不需要手动清理。urlMap 只留下必要的键值对,原始的 imageList 在函数执行完之后不再被任何闭包引用,可以正常回收;WeakMap 同时保证了即便图片未曾进入视口也不会造成 DOM 节点滞留。这条经验可以归纳成一句可操作的判断:闭包要捕获外部变量时,尽量只捕获用得到的最小数据,而不是图省事直接把整个对象扔进去。

排查这类问题时还有一个比逐条翻 Retainers 更快的入口:Memory 面板里可以直接筛选 Detached HTMLElement,专门列出那些已经从页面上移除、却因为还被某处引用而没能被回收的 DOM 节点。闭包捕获 DOM 元素引用(不只是数据对象)是另一种常见泄漏源,典型场景是给某个节点绑的事件回调闭包里顺带存了这个节点自身的引用,节点被移除之后这个引用还挂在回调里,导致节点变成一块既不在页面上、又没法被回收的僵尸内存。定位思路和上面这次是一致的:找到持有这块内存的闭包,判断这次捕获是不是必须要捕获这么多。

WeakMap 是另一种私有状态方案

除了闭包和 #field,还有一种在类库代码里能看到的私有状态实现方式:用模块级的 WeakMap 把实例和它的私有数据关联起来。

1const privateData = new WeakMap();
2
3class Counter {
4  constructor() {
5    privateData.set(this, { count: 0 });
6  }
7
8  increment() {
9    const data = privateData.get(this);
10    data.count += 1;
11    return data.count;
12  }
13}

这个写法的关键在于 WeakMap 的键是弱引用——如果某个 Counter 实例不再被任何地方持有,垃圾回收器可以正常回收它,privateData 里对应的那条记录也会跟着一起消失,不会造成额外的内存占用。这一点和普通 Map 不一样,普通 Map 的键是强引用,只要这条记录还在 Map 里,键对象就永远不会被回收,反而成了新的内存泄漏源头。

这种方案某种程度上是闭包方案和 class 方案的折中:方法照样挂在原型上可以共享,私有数据又存在实例外部够不到的地方。它的代价是每次读写私有字段都要经过一次 Map 的查找,比闭包变量的直接访问、或者 #field 编译后的访问都要慢一些,而且这种写法目前基本只在框架和工具库的源码里能看到,业务代码里很少有人这么写,主要是因为闭包已经够用,多绕一层 WeakMap 收益不明显。倒是这一年一起进入 ES2021 的 WeakRefFinalizationRegistry 提案值得关注——它们提供了更底层的弱引用能力,理论上可以用来观察某个对象什么时候被回收,但规范里明确写了 FinalizationRegistry 的回调触发时机不保证、不能用来做业务逻辑的依赖,目前顶多是在排查内存问题时拿来做临时诊断用,不该指望它成为正式的内存管理手段。

防抖节流工具函数里,闭包状态怎么暴露成可控的 API

闭包除了用来封装私有状态,还有一类工程问题经常被忽略:闭包内部藏起来的那份状态,什么时候需要重新开一道口子让外部有限度地干预。团队里用了很久的 debounce 实现只支持"触发、等待、执行"这条单一路径,最近在业务里遇到一个具体场景:搜索框输入触发防抖请求,但用户点击"立即搜索"按钮的时候,需要跳过等待、把已经攒着的那次调用立刻执行掉。老版本的闭包实现完全做不到这件事,因为 timer 被死死锁在闭包内部,外部连查询的接口都没有:

1function debounce(fn, delay) {
2  let timer = null;
3  return function (...args) {
4    clearTimeout(timer);
5    timer = setTimeout(() => fn.apply(this, args), delay);
6  };
7}

这个版本返回的就是一个裸函数,没有任何附加能力。要支持"立即执行"和"彻底取消",得把闭包里那部分状态通过挂在返回函数上的属性重新开放出来,这也是 Lodash 这类工具库长期采用的做法——返回的不是一个纯函数,而是一个"函数 + 附加方法"的复合对象:

1function debounce(fn, delay) {
2  let timer = null;
3  let lastArgs = null;
4  let lastThis = null;
5
6  function invoke() {
7    fn.apply(lastThis, lastArgs);
8    timer = null;
9    lastArgs = null;
10    lastThis = null;
11  }
12
13  function debounced(...args) {
14    lastArgs = args;
15    lastThis = this;
16    clearTimeout(timer);
17    timer = setTimeout(invoke, delay);
18  }
19
20  debounced.cancel = function () {
21    clearTimeout(timer);
22    timer = null;
23    lastArgs = null;
24    lastThis = null;
25  };
26
27  debounced.flush = function () {
28    if (timer) {
29      clearTimeout(timer);
30      invoke();
31    }
32  };
33
34  return debounced;
35}
36
37const onSearch = debounce((value) => fetchSuggestions(value), 300);
38input.addEventListener('input', (e) => onSearch(e.target.value));
39
40immediateBtn.addEventListener('click', () => onSearch.flush()); // 跳过等待立即执行
41input.addEventListener('blur', () => onSearch.cancel()); // 失焦时丢弃还没触发的那次调用

cancelflush 这两个方法之所以能实现,靠的正是它们和 debounced 函数共享同一个闭包作用域——timerlastArgslastThis 这几个变量对三个函数来说是同一份,谁改了外面两个都能看到。这其实是闭包一个不太被强调的能力:闭包捕获的不是"一对一"的私有状态,而是可以被同一作用域内多个函数共同持有和操作的共享状态,只要把这些函数都放在同一层作用域里定义、再把其中几个作为方法挂到主函数上导出,就得到了一个"看起来是函数、其实是个小型状态机"的 API。这条思路后来在实现请求库的取消令牌、动画库的 pause/resume 这类场景里反复用到,本质都是同一个套路:用闭包共享内部状态,再挑几个操作作为外部入口暴露出去,而不是把状态直接暴露成公开字段。

code review 里最容易被忽略的一类闭包问题

评审那次改动的时候还顺带发现一个更隐蔽的问题,属于闭包和循环结合时的另一种坑,跟经典的 var/let 计时器题目原理一样,但出现的位置不一样,容易被漏掉。代码大意是给一批异步任务分别注册成功回调,每个回调都要引用当次循环里对应的任务对象:

1const tasks = [taskA, taskB, taskC];
2const handlers = [];
3
4for (var i = 0; i < tasks.length; i++) {
5  tasks[i].run().then(function (result) {
6    handlers[i].onDone(result); // i 到这里已经是 tasks.length 了
7  });
8}

这里的问题和 setTimeout 那道经典题一模一样:then 回调是异步执行的,等它真正跑起来的时候,for 循环早就结束了,i 已经变成了 tasks.lengthhandlers[i] 直接访问越界拿到 undefined。奇怪的是这段代码在评审时差点被放过,原因是本地联调时任务数量只有一个,i 恰好从 0 变到 1 这个过程不容易被注意到索引错位,等联调数据变成三条以上,线上才暴露出偶发的 Cannot read property 'onDone' of undefined

这类问题在 review 里比手写的 setTimeout 面试题更难发现,因为它裹着一层 .then 异步调用,肉眼看代码结构不像是"循环 + 闭包"的经典模板,容易被当成普通的业务逻辑一眼扫过去。把 var 换成 let 同样能解决:

1for (let i = 0; i < tasks.length; i++) {
2  tasks[i].run().then(function (result) {
3    handlers[i].onDone(result); // 每轮循环有独立的 i,值正确
4  });
5}

这次评审之后,团队在代码规范里加了一条具体条款,而不是空泛地写"注意闭包问题":循环体内出现任何异步回调(.thensetTimeout、事件监听),循环变量必须使用 let 声明,禁止使用 var。这条规则比笼统地讲"闭包会有坑"更容易在评审时机械执行——评审者只需要扫一眼循环变量的声明关键字加上循环体内是否有异步调用,两条同时命中就该提问,不需要每次都重新在脑子里推演一遍作用域细节。

Composition API 里的组合函数,本质也是闭包

团队这一年在评估 Vue 3 的 Composition API 要不要用到新项目里,评估过程里发现一个很有意思的对应关系:setup 函数里写的组合函数(比如社区里常见命名的 useCounteruseFetch 这类),骨架和前面闭包封装私有状态的写法几乎一模一样:

1import { ref } from 'vue';
2
3function useCounter(initial = 0) {
4  const count = ref(initial);
5
6  function increment() {
7    count.value += 1;
8  }
9
10  function decrement() {
11    count.value -= 1;
12  }
13
14  return { count, increment, decrement };
15}
16
17export default {
18  setup() {
19    const { count, increment } = useCounter(10);
20    return { count, increment };
21  },
22};

useCounter 就是一个工厂函数,count 这个响应式引用被 incrementdecrement 两个函数闭包捕获,外部只能拿到暴露出去的这几样东西——这和最前面 createRequestClient 那个例子的结构完全同源,只是这里内部状态换成了 ref 包一层,多了响应式追踪的能力。团队里有人一开始担心 Composition API 是不是引入了什么全新的状态管理机制,摸清楚这层关系之后发现顾虑基本可以打消:组合函数没有创造新的封装原理,它只是把"闭包封装私有状态"这套已经很成熟的模式,套上了响应式系统的外壳,学习成本主要在 refreactive 这些响应式 API 本身,闭包这部分的心智负担是团队已经具备的。

这层对应关系也带出了一个实际的取舍问题:每次调用 useCounter 都会创建一份新的 incrementdecrement 函数,和之前讨论的工厂函数一样,不共享方法体。在 setup 里这通常不是问题,因为一个组件实例本来就只调用一次 setup,不存在"频繁实例化导致方法体重复创建"的顾虑;但如果哪天需要在渲染函数或者列表项内部反复调用某个组合函数(比如给列表每一行单独 useXxx 一次),这条前面提过的"闭包换私有性、要付出方法体不共享"的代价依然成立,不会因为换了个 Composition API 的马甲就消失。这也是这次评估里达成的一个共识:新工具带来的是写法上的组织方式变化,背后这些关于闭包、内存、共享状态的基本权衡还是老一套,不会因为框架升级就自动被解决掉。

闭包和缓存函数结合时,容易被忽视的一处失效

工具库里另一个常见的闭包应用是给纯函数加缓存,也就是常说的记忆化(memoization)——把计算过的结果存进闭包里的一个容器,同样的入参下次直接返回缓存值:

1function memoize(fn) {
2  const cache = new Map();
3  return function (...args) {
4    const key = JSON.stringify(args);
5    if (cache.has(key)) {
6      return cache.get(key);
7    }
8    const result = fn.apply(this, args);
9    cache.set(key, result);
10    return result;
11  };
12}
13
14const getFullList = memoize((filters) => computeExpensiveList(filters));

cache 这个 Map 藏在闭包里,外部没法直接清空它,这本来是符合预期的封装。但业务里用了一段时间之后暴露出一个问题:filters 这个入参对象如果是从组件里动态生成的,每次调用传入的对象引用即使内容一样,JSON.stringify 生成的 key 也确实相同没错,但如果调用方传入的是一个体积很大、结构很深的对象,每次调用都要做一次完整的序列化才能算出 key,这个开销有时候比重新算一次业务逻辑还大,等于缓存这一层反而拖慢了整体速度。

排查这个问题的时候用 Performance 面板录了一段火焰图,发现耗时最长的调用栈落在 JSON.stringify 上而不是原本以为的 computeExpensiveList。这提醒了一件容易被绕过去的事:闭包能帮你把缓存这件事从"要不要做"的决策里省下来,但缓存本身的开销依然要认真核算,闭包只是提供了存放缓存的地方,不会替你把缓存变得免费。后来把 key 的生成方式换成只挑几个真正影响计算结果的字段手动拼接,而不是无脑对整个对象做序列化,这处调用的耗时才降下来。这类问题在闭包相关的讨论里经常被跳过,因为大家的注意力都放在"状态有没有正确共享"上,反而忽略了闭包所包裹的这个容器本身的读写成本。

除了 key 的生成开销,memoize 这类写法还有一个容易被漏掉的问题:闭包里的 cache 只要没人清理,会随着入参的种类越来越多一直增长,本质上和前面讲的内存泄漏是同一类风险,只是它藏得更隐蔽——大家默认"缓存"这个词自带"划算"的暗示,很少有人会主动去想缓存本身占的内存要不要设上限。给这类记忆化工具函数加缓存容量上限,是实际项目里补上的一个环节:

1function memoize(fn, maxSize = 100) {
2  const cache = new Map();
3  return function (...args) {
4    const key = JSON.stringify(args);
5    if (cache.has(key)) {
6      return cache.get(key);
7    }
8    const result = fn.apply(this, args);
9    if (cache.size >= maxSize) {
10      const oldestKey = cache.keys().next().value;
11      cache.delete(oldestKey);
12    }
13    cache.set(key, result);
14    return result;
15  };
16}

Map 会按插入顺序保留键的迭代次序,所以 cache.keys().next().value 拿到的正好是最早插入的那个 key,达到容量上限时淘汰它,就得到了一个简易的先进先出缓存。这不是什么复杂的算法,但如果闭包里包的这份状态会无限增长,加一道容量上限几乎是不能省略的步骤,尤其是这种工具函数一旦被别的模块拿去复用,使用者往往不会去看内部实现,容量失控的风险就转嫁给了以后排查内存问题的人。

三种方案怎么选

把这几种方案摆在一起,选择的依据其实很清楚。纯函数式的工具场景,比如防抖节流、请求重试计数这类只需要一份状态、不会被大量实例化的逻辑,闭包依然是最省心的写法,不需要引入 class 或者额外的数据结构。需要大量实例化、方法体要共享、又想要真正私有性的场景,比如一个业务里会 new 出上百个的数据模型类,#field 在已经使用 TypeScript 编译的项目里是更合适的选择,只是要清楚这是编译期特性,团队里如果还有一部分代码没接入 TS 编译链路,暂时没法统一用这个语法。至于 WeakMap 方案,留给那些需要兼顾"实例可以被正常回收"又不想暴露私有字段访问语法糖的框架级代码就够了,业务代码里没必要为了显得"高级"去套用它。

闭包也好,#field 也好,WeakMap 也好,解决的都是同一个问题——把不该被外部乱改的状态挡在外面。选哪一种,取决于这个状态被多少个实例共享、要不要跟着 class 语义走、以及团队现有的构建链路能不能撑住这个语法编译降级之后的产物。技术选型这件事,很多时候比的不是新写法本身多先进,而是它和现有工程环境能不能对上。

落到团队规范里的几条具体判断

这几周把上面这些取舍聊清楚之后,团队内部工具库的贡献指南里加了几条可以直接照着执行的判断,而不是停留在"看情况"这种含糊的说法上。

工具函数、只会被创建少数几次的场景,优先用闭包,不为了赶时髦引入 class;如果这个工具函数暴露出去的不只是一个纯函数,还需要支持取消、立即执行、重置这类附加操作,参照 debouncecancel/flush 思路,把这些操作作为方法挂在返回的函数对象上,而不是让调用方自己在外部维护一份额外的状态去间接控制内部行为。数据模型类、需要大量实例化又要求字段私有的场景,允许在 TypeScript 文件里使用 #field,但要求同一个模块内部保持风格统一,不要一半字段用 #、一半字段用下划线前缀这种混着写的过渡状态,这种不一致比单纯选哪种方案都更影响后来维护的人读代码的效率。涉及循环体内的异步回调,不论是定时器、then、事件监听,强制使用 let 或者 const 声明循环变量,代码审查时把这一条当作可以机械核对的硬性检查项,不依赖评审人临场想起闭包的作用域细节。涉及闭包捕获大对象的场景,尤其是长期存活的监听器、observer、定时器持有的闭包,要求只捕获真正用到的字段,捕获整个对象之前得说得清楚理由。

这几条规则单独看都不复杂,放在一起其实是同一件事的不同侧面:闭包这个语言机制本身没有变化,变化的是团队对"这份状态该被谁看到、该活多久、该怎么控制"这几个问题的判断越来越具体,不再停留在"闭包有点绕,写的时候小心点"这种笼统的提醒上。