阅读视图

发现新文章,点击刷新页面。

闭包:从「能跑」到「可控」——用执行上下文把它讲透,再把泄漏风险压住

引言:为什么团队里闭包总是「会用但讲不清」

闭包几乎是每个前端都“用过”的能力:回调、事件处理、节流防抖、柯里化、状态缓存……到处都是它。但一到排查线上内存飙升、解释“为什么变量没被回收”、或者评审里讨论“这个写法会不会泄漏”,就容易陷入两种极端:

  • 把闭包当玄学:只记住“函数套函数 + 引用外部变量”,但不知道底层到底发生了什么。
  • 把闭包当洪水猛兽:遇到闭包就怕泄漏,动不动“全局变量/单例就是坏”。

这篇文章的目标很明确:用“执行上下文 + AO/GO + 可达性”把闭包拆开讲清楚,再落到工程实践:哪些写法会导致内存常驻、怎么定位、怎么释放、怎么验证。很多内容需要配合内存图理解(图片必须保留),建议边看边对照图。


目录

    1. 脉络:为什么闭包在 JS 里如此关键
    1. 闭包到底是什么:定义、自由变量与词法绑定
    1. 从调用栈看闭包:高阶函数的执行过程(GO / AO / FEC)
    1. 闭包形成的关键:[[scope]] / parentScope 为什么能“跨出上下文”
    1. 内存视角:普通函数 vs 闭包函数,变量为什么回收/不回收
    1. 闭包与内存泄漏:什么时候是“正常驻留”,什么时候是“泄漏”
    1. 如何释放:最小化作用域、解除引用、弱引用思路
    1. 性能与排查:用浏览器工具定位闭包导致的内存/耗时
    1. 引擎优化:闭包里“没用到的变量”会怎样(V8 优化)
    1. 进阶边界:多个闭包实例彼此独立;为什么 null 能断引用而 undefined 不行
    1. 实战建议:团队落地清单 & 指标验证
    1. 总结:关键结论 + 下一步建议

f 脉络探索:闭包为什么值得被「认真对待」

闭包是 JavaScript 中一个非常容易让人迷惑的知识点:它既是语言表达力的源泉,也可能成为内存与可维护性的风险点。很多经典资料对它评价极高——因为它背后牵扯的是一整套“词法作用域 + 执行上下文 + 垃圾回收”的体系。

** 图7-1 《你不知道的JavaScript》上卷中对闭包的评语**

把这张图放在开头的意义是:闭包不是一个“语法点”,而是理解 JS 运行机制的入口。当你能把闭包讲清楚,很多“JS 为什么这样设计”的问题都会连起来。

本章小结(可迁移的经验)

  • 闭包不是“函数套函数”的表象,而是 词法作用域如何在运行时被保留
  • 闭包相关的争论,往往不是“对错”,而是 讨论口径(广义/狭义)不同
  • 真正工程风险来自:闭包让某些对象变成“长期可达” ,从而影响 GC。

一、闭包到底是什么:定义、自由变量与词法绑定

1.1 闭包的概念定义:把“感觉”变成“可证明”

闭包并不是 JavaScript 独有。计算机科学中,闭包(Closure)又称词法闭包(Lexical Closure),是一种在支持头等函数的语言中实现词法绑定的技术:闭包在实现上可以理解为“函数 + 关联环境(自由变量的绑定) ”的组合结构。

在 JavaScript 语境下,可以用更工程化的表达:

闭包 = 一个函数 + 该函数在定义时可访问的外层作用域引用(自由变量所在的词法环境)。

这里最关键的是两个词:

  • 自由变量:跨出自己作用域、来自外层作用域的变量(“不是我家里的变量,但我能用”)。
  • 词法解析阶段确定:函数“能访问哪些变量”,在**代码写出来那一刻(定义时)**就决定了,而不是调用时随机决定。

一个常用的工作记忆法: “函数定义时就把外层环境‘锁’住了” 。后续你把函数拿到哪里调用,它都沿着当初锁定的链去找变量。

1.2 广义 vs 狭义:团队沟通时要先统一口径

社区对“什么算闭包”常见两种口径:

  • 广义:JS 里“函数”几乎都带着词法作用域信息,因此都可以叫闭包。
  • 狭义(更严谨) :只有当函数实际捕获并使用外层变量,才讨论“闭包带来的效果”(比如变量驻留)。

下面这段代码就体现了差异点:它能访问 name,但是否把它视为“闭包”(严格意义)看你站哪种口径:

// 可以访问 name:test 算闭包(广义)
// 有访问到 name:test 算闭包(更严谨,讨论“闭包效果”更有意义)
var name = "放寒假了";
function test() {
  console.log(name);
}
test();

本章小结(落地清单)

  • 团队讨论闭包前,先明确口径:讨论“闭包机制”还是“闭包效果(变量驻留)”
  • 记住闭包核心:定义时锁定词法环境,不是调用时决定。
  • 所谓“自由变量”,本质就是:跨作用域访问的变量

二、从调用栈看闭包:高阶函数的执行过程(GO / AO / FEC)

闭包很容易被讲成“概念”,但工程上要做到“可控”,一定要落到执行过程:调用栈如何创建执行上下文?AO/GO 什么时候创建?引用链怎样形成可达性?

2.1 先看一个最小高阶函数:返回函数指针发生了什么

function foo() {
  // bar 预解析,前面有讲过
  function bar() {
    console.log("小吴");
  }
  return bar;
}

var fn = foo();

fn();
// 小吴

关键点:调用函数会创建执行上下文(Execution Context) 。执行上下文创建前,会先创建对应的 AO(Activation Object) :用于存放形参、局部变量、函数声明等。

2.2 内存图:从“栈/堆”视角理解 fn = foo() 的意义

建议把这一段当成闭包全篇的“底座”,后面所有闭包/泄漏/回收都在重复这个结构。

** 图7-2 高阶函数执行前**

** 图7-3 foo函数调用阶段内存图**

** 图7-4 foo函数中的bar函数调用阶段内存图**

把图 7-2 ~ 7-4 用一句话串起来:

  • foo() 调用时创建 foo 的执行上下文与 AO;
  • bar 函数对象存在于堆上,foo 的 AO 里保存了它的引用;
  • return bar 让全局 fn 指向 bar 的函数对象地址;
  • 后续 fn() 本质是在执行 bar()

你可以把它想象成:return 返回的不是函数体,而是“函数对象的指针”fn 接住这个指针后,就和 bar 的生命周期绑定了。

本章小结(落地清单)

  • AO 是“函数即将执行前”创建的,不是定义函数时就创建(避免无谓开销)。
  • return function 的本质是:返回堆上函数对象的引用
  • 后续 fn() 执行的是“那块函数对象”,而不是重新生成一份。

三、闭包形成的关键:为什么 [[scope]] / parentScope 能让变量跨出上下文

上一章只是“返回了函数”。闭包真正“神奇”的点在于:外层函数执行完了,内层函数还能访问外层变量

来看这个例子

function foo() {
  var name = "why";
  function bar() {
    console.log("小吴", name);
  }
  return bar;
}

var fn = foo();

fn();
// 小吴 why

如果只背概念会说:“bar 引用了 foo 的变量 name,所以形成闭包”。但工程上更重要的是 “它凭什么引用得到?” ——答案是:函数对象内部会保存定义时的外层作用域引用(常被描述为 [[scope]] / parentScope

3.1 发生了什么:把“访问外层变量”写成一条可执行的查找链

bar() 执行时要查 name

  1. 先查自己的 VO/AO(bar 的活动对象)——没有。
  2. 沿着函数对象记录的 parentScope(也就是 foo 的 AO)继续查——找到了。
  3. 输出 why

也就是说,闭包并不是“让变量不销毁”的魔法,而是:

bar 的函数对象握住了 foo 的 AO 引用,使得这块 AO 对 GC 来说一直是“可达的”。

** 图7-5 bar函数中的name形成闭包内存图**

3.2 常见误区:闭包 ≠ 执行上下文永远不销毁

一个容易混淆的点:执行上下文(FEC)会销毁,但 AO 是否可回收 取决于有没有被外界引用链保持可达。

  • foo() 的执行上下文从调用栈弹出,这是必然的;
  • foo 的 AO 如果被 barparentScope 引用着,并且 bar 又被 fn 引用着,那么它就仍然可达,无法回收;
  • “闭包效果”来自这条引用链,而不是来自“执行上下文不销毁”。

本章小结(落地清单)

  • 闭包的底层抓手是:函数对象持有 parentScope(词法环境引用)
  • “变量没被回收”不是因为执行上下文不弹栈,而是因为 对象仍可达
  • 解释闭包时,把“查找链”讲出来,团队沟通会更一致。

四、内存视角:普通函数 vs 闭包函数,变量为什么回收/不回收

4.1 普通函数:执行完就“自由变量不自由”了

function foo() {
  var name = "xiaowu";
  var age = 20;
}

function test() {
  console.log("test");
}

foo();
test();

** 图7-6 foo与test函数执行前的初始化表现**

这张图强调:全局 GO 中保存的是函数对象引用;函数对象里保存了 parentScope 指向 GO;调用时创建执行上下文与 AO。

** 图7-7 foo函数和test函数的内存图执行过程**

** 图7-8 foo的执行上下文销毁前后对比**

关键结论在图 7-8:foo 执行结束,AO 里 name/age 没有被任何外部引用链持有,于是变为不可达,被回收。这就是“自由变量没能真的自由”。

4.2 闭包函数:AO 被外部引用链锁住,变量驻留

function foo() {
  var name = "xiaowu";
  var age = 20;

  function bar() {
    // 引用了外层变量,形成闭包
    console.log("这是我的名字", name);
    console.log("这是我的年龄", age);
  }

  return bar;
}

var fn = foo();
fn();
// 这是我的名字 xiaowu
// 这是我的年龄 20

** 图7-9 闭包执行前内存图**

** 图7-10 foo函数执行内存图**

** 图7-11 bar的函数执行上下文**

** 图7-12 bar脱离捕捉时的上下文,自由变量依旧存在**

图 7-12 是闭包“可解释”的关键画面:

  • fn -> bar函数对象(全局根对象可达)
  • bar函数对象 -> parentScope -> foo 的 AO
  • 因为这条链存在,所以 foo AO 仍可达,name/age 仍可达

本章小结(落地清单)

  • 普通函数执行完:AO 通常不可达 → 回收。
  • 闭包能驻留变量:本质是 AO 被函数对象的 parentScope 引用,并且函数对象又被根对象引用
  • 是否回收,归根到底看:从根对象出发是否可达(标记清除的核心判断)。

五、闭包与内存泄漏:什么时候是“正常驻留”,什么时候是“泄漏”

闭包会让变量驻留,但驻留 ≠ 泄漏。工程上判断泄漏的标准非常朴素:

本该释放、却因为不必要的引用链而长期可达的内存,占用不断增长或长时间不下降。

在闭包语境里,常见泄漏模式就是:返回的函数被长期保存(全局数组/缓存/事件回调/定时器),导致其捕获的外层 AO 一直可达


六、如何释放:最小化闭包作用域、解除引用、弱引用思路

6.1 解决策略三件套

  1. 最小化闭包作用域:只捕获必要数据(不要把整坨对象/大数组/DOM 节点顺手闭包进去)。
  2. 解除引用:用完就断开引用链,让对象从根不可达。
  3. 弱引用(WeakMap/WeakSet) :对“缓存类”场景非常有效(不会阻止 GC)。

6.2 “解除引用”的标准写法:把 fn 指向 null

// 内存泄漏解决方法
function foo() {
  var name = "xiaowu";
  var age = 20;

  function test() {
    console.log("这是我的名字", name);
    console.log("这是我的年龄", age);
  }

  return test;
}

var fn = foo();
fn();

// 解除引用:断开 root -> fn -> 函数对象 -> AO 的链
fn = null; // 注意:置 null 不会立刻回收,会在后续 GC 周期中回收

** 图7-13 fn指向bar的指针**

** 图7-14 fn指向bar的指针置为null**

图 7-14 的“孤岛”是你需要在脑子里形成的肌肉记忆:
只要根对象到不了这块内存,它迟早会被回收(标记清除的可达性判断)。

本章小结(落地清单)

  • 释放闭包的核心动作:断开根对象到函数对象的引用链(常见是置 null / 移除监听 / 清理数组缓存)。
  • 设计闭包时先问一句: “我真的需要捕获整个对象/大数组/DOM 吗?”
  • 缓存场景优先考虑:WeakMap/WeakSet(避免“缓存越用越大”)。

七、闭包泄漏案例:大对象被闭包捕获,内存与耗时如何爆炸

为了把问题讲“刺痛”,用一个极端但很真实的例子:闭包捕获一个大数组。

function createFnArray() {
  // 创建一个长度为1024*1024的数组,往里面每个位置填充1.观察占了多少的内存空间(int类型,整数1占4个字节byte)
  // 4byte*1024=4kb,再*1024为4mb,占据的空间是4M × 100 + 其他的内存 = 400M+
  // 在js里面不管是整数类型还是浮点数类型,看起来都是数字类型,这个时候占据的都是8字节,但是js引擎为了提高空间的利用率,对很多小的数字是用不到8个字节(byte)的,8字节 = 2的64次方,所以8字节是很大的,现在的js引擎大多数都会进行优化,对小的数字类型,在V8中称为Smi,小数字 2的32次方
  var arr = new Array(1024 * 1024).fill(1);

  return function () {
    console.log(arr.length);
  };
}

var arrayFn = createFnArray();

** 图7-15 闭包泄露案例**

如果你把 createFnArray() 创建出来的函数持续保存(比如 push 进数组),引用链会不断叠加:

** 图7-16 引用叠加,闭包无法释放**

7.1 用性能工具看“泄漏”长什么样

在浏览器 Performance 面板勾选 Memory,刷新/执行后,你会看到脚本耗时显著升高:

** 图7-17 闭包的性能检测**

7.2 释放后的对比:不一定立刻回收,但趋势会回来

function createFnArray() {
  var arr = new Array(1024 * 1024).fill(1);

  return function () {
    console.log(arr.length);
  };
}

var arrayFns = [];
for (var i = 0; i < 100; i++) {
  // createFnArray() // 不接收就会很快变成不可达
  arrayFns.push(createFnArray());
}

setTimeout(() => {
  arrayFns = null; // 关键:断开引用链
}, 2000);

** 图7-18 性能提升效果**

你还能通过调用树看到耗时主要来源于闭包相关逻辑:

** 图7-19 闭包耗时来源**

本章小结(落地清单)

  • 闭包泄漏常见触发器:闭包捕获大对象 + 长期保存闭包引用(数组/缓存/事件/定时器)。
  • Performance 勾选 Memory:关注 脚本耗时 + 内存曲线是否持续上升
  • “置 null”不保证立刻回收,但能保证:后续 GC 周期具备回收条件

八、引擎优化:闭包里“没用到的变量”会怎样(V8)

一个很实用的问题:闭包让外层 AO 不回收,那 AO 里没用到的属性会不会也一直占着?

例子:闭包只用 name,没有用 age

function foo() {
  var name = "why";
  var age = 18;

  function bar() {
    debugger;
    console.log(name);
  }

  return bar;
}

var fn = foo();
fn();

** 图7-20 V8引擎优化效果(未使用变量被销毁)**

继续在 debugger 暂停时验证:name 能访问,age 可能因为未使用被优化掉:

** 图7-21 debugger检测未使用的age变量是否真被回收**

这点对工程实践的启示非常直接:

  • 规范上你可以认为“闭包会保留整个 AO”;
  • 但引擎实现会做逃逸分析/变量提升优化等,减少无用变量占用
  • 不要依赖这种优化写代码:它是实现细节,不是稳定契约(尤其跨引擎/跨版本)。

本章小结(落地清单)

  • V8 可能回收闭包外层 AO 中“未被使用的变量”(实现优化)。
  • 工程判断别靠“引擎可能帮我优化”,仍以 引用链是否可达 为主。
  • 评审时更关注:闭包是否捕获了不必要的大对象/DOM/业务上下文

九、进阶边界:多个闭包实例彼此独立;为什么 null 能断引用而 undefined 不行

9.1 多个闭包实例:互不影响,释放也只释放自己的那份

同一个 foo() 调两次,得到的是两套独立的 AO 与函数对象:

function foo() {
  var name = "小吴";
  var age = 18;

  function bar() {
    console.log(name);
    console.log(age);
  }

  return bar;
}

var fn = foo();
fn();

var baz = foo();

fn = null; // 只会释放 fn 对应的那一套引用链
baz();

这条结论在工程里特别重要:你清理了一个引用,不代表全局都释放了。如果你把闭包存进多个地方(例如多个数组、多个事件回调、多个缓存),就需要逐个断链。

9.2 为什么 null 可以解除引用,而 undefined 不行?

这里有一个值得思考的问题:为什么 null 可以解除引用,而 undefined 不行?

从“引用链”的角度看:

  • null 是一个明确的“空值”,把变量指向空处,等价于 把这条引用边砍掉
  • undefined 更多表达“未初始化/缺省值”,它依然是一个值;更关键的是,在很多语义下它并不被用作“主动断链”的表达(团队代码规范也通常不推荐用 undefined 表达释放)。

工程建议:释放引用请用 null(语义清晰、团队共识强、便于 code review 与静态检查)。

本章小结(落地清单)

  • 每次调用外层函数,都会创建一套新的 AO/函数对象:闭包实例彼此独立。
  • 释放引用只影响对应那条链:你清理一个,不会自动清理所有
  • 断链用 null:表达“我主动释放”,比 undefined 更清晰。

十、实战建议:把“闭包可控”落到团队工程规范里

下面给一份可以直接放进团队“代码评审 checklist / 性能排查 SOP”的清单。

10.1 评审 Checklist(闭包相关)

  • 捕获内容最小化:闭包里只引用必要字段,避免把整个 props/state/context/大对象 捕获进去。
  • 避免捕获 DOM 节点:尤其是长生命周期的闭包(事件回调/单例缓存)捕获 DOM,会让节点难以回收。
  • 长生命周期容器要可清理:全局数组、Map 缓存、事件总线、定时器回调——都要有对应的清理路径。
  • 组件/页面卸载必须断链:移除事件监听、取消订阅、清理定时器、清空缓存引用(= null)。
  • 缓存优先 WeakMap:key 是对象的缓存(如 DOM 节点、组件实例)优先 WeakMap,减少“缓存常驻”。

10.2 排查 SOP(内存/性能)

  1. Performance 勾选 Memory:复现操作,观察内存曲线是否持续上升(不回落)。
  2. 录制并看调用树:定位高耗时函数是否来自闭包创建/大对象捕获。
  3. 缩小复现:把闭包引用容器(数组/缓存)逐步置 null,观察趋势变化(不是立刻回收,但趋势会变)。
  4. 检查引用链:谁在持有闭包?(全局变量、单例模块、事件总线、定时器、DOM 监听器最常见)

10.3 指标验证(建议团队共用)

  • 内存指标:关键页面操作 5 分钟后,JS Heap 是否可稳定回落到阈值区间
  • 性能指标:关键交互的 Long Task 次数/总耗时是否下降
  • 回归验证:增加“卸载/切页/重复进入”压测脚本,验证引用链不会累积

总结:关键结论 + 团队落地建议

关键结论(背下来就够用)

  • 闭包的本质是:函数对象持有定义时的外层作用域引用(parentScope/词法环境) ,从而让外层 AO 继续可达。
  • 是否回收不看“函数执行没执行完”,只看 从根对象出发是否可达(标记清除的核心)。
  • 闭包造成的风险不是“用了闭包”,而是:闭包捕获了不该长期驻留的对象,并且闭包引用被长期持有
  • 释放闭包的关键动作是:断开引用链(置 null、移除监听、清空容器、取消订阅等)。
  • 引擎可能优化未使用变量(如 V8),但工程上不要依赖实现细节,仍以引用链分析为准。

下一步建议(怎么在团队里真正落地)

  1. 把“闭包评审 checklist”加入 PR 模板:涉及事件、缓存、定时器、订阅时必须勾选清理项。
  2. 建立 1~2 个“典型泄漏 demo”用于 onboarding:让新人用 Performance/Memory 亲手看见“可达性”是什么。
  3. 在关键业务页引入定期压测(重复进入/退出/滚动/筛选等),用指标验证“内存可回落”。
  4. 对缓存策略做统一约束:对象 key 的缓存优先 WeakMap;全局数组缓存必须提供清理 API。

只要团队能把闭包从“语法点”升级成“引用链与可达性”的共识,闭包就会从“玄学”变成“可控工具”。

函数为何能“统治”JavaScript:一等公民与高阶函数的工程化用法

引言:为什么团队里“会用函数”和“用好函数”差距这么大

在业务迭代快、多人协作密集的前端项目里,你一定见过两类代码:

  • 一类是“能跑就行”的循环 + if + push:逻辑散在各处,重复多、难复用、改动风险大。
  • 另一类是“把规则抽成函数”的写法:筛选、映射、查找、聚合清晰可读,功能像积木一样组合。

差距的根源,不是你记不记得 API,而是你是否真正把函数当成语言的核心抽象单位来使用:能传、能返回、能组合,进而让代码更模块化、更可维护、更适合协作。

这篇文章会用五个最常用的数组高阶函数(filter/map/forEach/find/reduce)串起一条清晰主线:函数为什么是一等公民 → 高阶函数如何替代手写循环 → 工程上怎么选、怎么读文档、怎么避免坑 → 如何在团队落地


一、函数为什么是一等公民

1.1 什么是“一等公民”

在编程语言里,“一等公民(First-class Citizen)”不是“更高级”,而是地位与能力等同于语言里的其他基础类型(数字、字符串、布尔等)。换句话说:它能像普通数据一样被存储、传递、返回、赋值。

更工程化的判定方式是看它是否具备这些能力:

  1. 可以被存储在数据结构中(数组、对象、Map…)
  2. 可以作为参数传递给另一个函数
  3. 可以作为返回值返回
  4. 可以赋值给变量/常量/属性

满足这些特性,就称之为“一等公民”。在 JavaScript 里,函数是典型代表。


1.2 为什么说它重要

把函数当一等公民,会直接带来两类能力:

  • 抽象能力:把“变化的部分”抽成函数,把“稳定的框架”固化下来。你会写出更少重复、更易扩展的代码。
  • 组合能力:高阶函数与闭包等特性,使函数天然适合模块化与函数式编程;在现代框架(例如组件化、Hooks、状态管理)里,这种能力几乎无处不在。

一句话总结:团队协作里最怕“写死流程”,最需要“抽离规则”。函数一等公民就是这套抽离机制的基础。


1.3 用代码把抽象变具体

1.3.1 参数传递 + 返回函数 + 赋值:三件事连起来

下面的例子同时覆盖:函数可以作为返回值、可以赋值给标识符、也展示了函数内嵌定义的灵活性(很多语言并不允许这样自由嵌套)。

// 作为另一个函数的参数,JS 语法允许函数内部再定义函数
function foo() {
  function bar() {
    console.log("小吴的bar");
  }
  // 返回值:返回的是函数本身(引用),不是执行结果
  return bar;
}

// 赋值给其他标识符
var fn = foo();
fn();

// 小吴的bar

这里有个非常“工程化”的隐喻:

  • foo() 返回 bar 的引用,就像 new Class() 返回实例对象。
    你拿到的是一个“可调用/可使用的实体”,而不是一次性的结果。

1.3.2 作为参数传递:把“行为”塞进另一个函数

直接把函数传入另一个函数,在业务里并不少见(例如事件回调、拦截器、策略模式)。

// 也可以作为另外一个函数的返回值来使用
function foo(aaaa) {
  console.log(aaaa);
}

foo(123);

// 也可以参数传递
function bar(bbbb) {
  return bbbb + "刚吃完午饭";
}

// 将函数作为参数传达进去调用(这里传的是 bar 的执行结果)
foo(bar("小吴"));
// 123
// 小吴刚吃完午饭

注意区分两种传法:

  • 传“函数本身”:foo(bar)(把行为交给 foo 决定何时执行)
  • 传“函数执行结果”:foo(bar("小吴"))(先执行 bar,再把结果交给 foo)

很多 Bug 就出在把这两者弄混。


1.3.3 封装小案例:把“算法框架”固定,把“策略”注入

这是最值得在团队内推广的写法之一:把“可变的计算逻辑”抽成参数传入。

// 封装小案例:calc 固定流程,calcFn 注入策略
function calc(num1, num2, calcFn) {
  console.log(calcFn(num1, num2));
}

function add(num1, num2) {
  return num1 + num2;
}

function sub(num1, num2) {
  return num1 - num2;
}

function mul(num1, num2) {
  return num1 * num2;
}

calc(10, 10, add);
calc(10, 10, sub);
calc(10, 10, mul);
// 20
// 0
// 100

这类写法在工程里有很多“马甲”:

  • 表单校验:把规则函数注入校验器
  • 权限控制:把策略函数注入拦截器
  • 数据转换:把转换函数注入 pipeline
  • UI 渲染:把 render 函数/回调注入组件

核心思想:把“变化”关在函数里,把“框架”稳定下来。


本章小结(一)

  • 函数是一等公民,关键不在“厉害”,而在能像数据一样被传递与组合
  • 工程化抽象的第一步:固定流程(框架)+ 注入策略(函数)
  • 传参时要区分:传“函数引用” vs 传“函数执行结果”,这是常见坑。
  • 这套能力是高阶函数、闭包、以及现代框架设计(Hooks/中间件/拦截器)的基础。

二、高阶函数:把“步骤”交给框架,把“规则”留给你

2.1 什么是高阶函数

在 JavaScript 里,高阶函数指至少满足以下一个条件的函数:

  1. 接收一个或多个函数作为参数
  2. 返回一个函数

你刚才看到的 calc(num1, num2, calcFn),已经是高阶函数:它接收 calcFn 作为参数。


2.2 同一个需求的三种写法:挑选偶数

2.2.1 普通方式:手写四步走

思路很直白,但通用逻辑(遍历、收集)全部你自己写,容易重复与出错。

// 普通使用
var nums = [2, 4, 5, 8, 12, 45, 23];

var newNums = [];
for (var i = 0; i < nums.length; i++) {
  var num = nums[i];
  if (num % 2 === 0) {
    newNums.push(num);
  }
}
console.log(newNums);
// [ 2, 4, 8, 12 ]

2.2.2 filter:你只写“规则”,遍历与收集由它完成

filter 的含义非常明确:过滤。回调返回 true 表示保留,false 表示丢弃。

  • 语法:array.filter(callback(item[, index[, array]])[, thisArg])
  • 回调参数(常用前两个):item, index
  • 返回:新数组(不会修改原数组)
// filter:对数组进行过滤,返回新数组
var nums = [2, 4, 5, 8, 12, 45, 23];

// 方式1:明显的函数特征(匿名函数)
var evenNumbers = nums.filter(function (number) {
  return number % 2 === 0;
});

// 方式2:箭头函数(保留完整参数,便于理解)
var newNums = nums.filter((item, index, array) => {
  return item % 2 === 0;
});

console.log(newNums);
// [ 2, 4, 8, 12 ]

当回调只有一个参数、且函数体只有一行表达式时,可以进一步精简:

var nums = [1, 2, 3, 4, 5, 6];
// 方式3:省略小括号 + 省略大括号与 return
var newNums = nums.filter((n) => n % 2 === 0);
console.log(newNums); // 输出: [2, 4, 6]

这里有个很关键的“可读性权衡”:

  • 精简写法适合表达式很短且语义直观的场景
  • 一旦逻辑复杂(多分支、多行),就别硬省略 {},可读性优先

2.2.3 map:把每个元素“映射”为新值

map映射:输入一个数组,输出一个等长的新数组。它关心的是“把元素变成什么”。

// map:映射
var nums = [1, 2, 3, 4, 5, 6];
var newNums2 = nums.map((i) => (i % 2 === 0 ? "偶数是女生" : "基数是男生"));
console.log(newNums2);
// [ '基数是男生', '偶数是女生', '基数是男生', '偶数是女生', '基数是男生', '偶数是女生' ]

工程上最常见的用途:

  • 接口数据 → UI 需要的结构(字段重命名、格式化、补默认值)
  • 列表渲染前的展示层转换(但注意别在 render 中做重计算)

2.2.4 forEach:只遍历、不返回,用于副作用

forEach 更像“命令式遍历”:做日志、打点、埋点、累计写入外部变量等。

// forEach:迭代,没有返回值,通常就用来打印一些东西
var nums = [2, 4, 5, 8, 12, 45, 23];
nums.forEach((i) => console.log(i));
// 2
// 4
// 5
// 8
// 12
// 45
// 23

一个常见误区:

  • 想用 forEach 生成新数组 —— 不对,它没有返回值
    要生成新数组,优先考虑 map/filter/reduce

2.2.5 find:找“第一个满足条件”的元素

find 的语义是查找:返回第一个满足条件的元素;找不到是 undefined

// find:查找,有返回值
var nums = [2, 4, 5, 8, "小吴", 12, 45, 23];

// 找到内容则返回内容
var item = nums.find((i) => i === "小吴");
console.log(item);
// 小吴

// 找不到则返回 undefined
var item2 = nums.find((i) => i === "coderwhy");
console.log(item2);
// undefined

在对象数组中,find/findIndex 非常好用:

// 模拟数据
var friend = [
  { name: "小吴", age: 18 },
  { name: "coderwhy", age: 35 },
];

const findFriend = friend.find((i) => i.name === "小吴");
console.log(findFriend);
// { name: '小吴', age: 18 }

// findIndex:找到对象在数组中的索引
const findFriend2 = friend.findIndex((i) => i.name === "小吴");
console.log(findFriend2);
// 0

2.2.6 reduce:把一组元素“聚合”为一个结果

reduce聚合/归约:把数组变成一个值(数字、对象、Map、甚至另一个数组都可以)。

先看普通实现:

// 普通实现方式
var nums = [2, 4, 5, 8, 12, 45, 23];
var total = 0;
for (var i = 0; i < nums.length; i++) {
  total += nums[i];
}
console.log(total);
// 99

reduce

// reduce:对数组进行累加或统计等操作
var nums = [2, 4, 5, 8, 12, 45, 23];

// preValue:上一次累加结果;item:当前值;0 是初始值
var num = nums.reduce((preValue, item) => preValue + item, 0);
console.log(num);
// 99

工程上 reduce 的典型用途:

  • 列表求和/计数/求最大最小
  • 把数组转成对象:按 key 分组、构建索引表(id -> item
  • 复杂 pipeline 的聚合处理(但要注意可读性,别写成“谜语”)

2.4 Function 与 Method:名字不同,边界要清楚

这点在 code review 里经常要统一口径:

  • 函数(Function) :独立存在的函数
  • 方法(Method) :当一个函数挂在对象上,作为对象的成员时,称为方法
obj = {
  foo: function () {},
};

// 这个 foo 就是一个属于 obj 对象的方法
obj.foo();

// 函数调用(不属于某个对象)
foo();

为什么数组高阶函数常被称为“方法”?因为它们挂在 Array.prototype 上,通过 arr.xxx() 调用。


本章小结(二)

  • 高阶函数的价值:你只写规则(回调),通用步骤(遍历/收集/聚合)交给 API
  • filter:筛选;map:映射;forEach:副作用遍历;find:找第一个;reduce:聚合成一个结果。
  • 精简写法要守住底线:逻辑复杂就别省略 {},可读性优先。
  • forEach 不返回新数组,生成新数组请用 map/filter/reduce

三、怎么系统学习与记忆高阶函数

3.1 别背 API:按“数据流”建立心智模型

高阶函数看起来很多,但本质都绕不开“对数据做事”。更好用的记忆方式是把它们按数据流形态归类:

  • 筛选型filter(输入 N 个,输出 ≤N 个)
  • 变换型map(输入 N 个,输出 N 个)
  • 查找型find/findIndex(输入 N 个,输出 1 个或索引)
  • 遍历副作用forEach(输入 N 个,输出无)
  • 聚合型reduce(输入 N 个,输出 1 个“聚合结果”)

再进一步,你可以用“增删改查”的视角去理解:

  • 查:find
  • 删(过滤掉):filter
  • 改(映射成新结构):map
  • 聚合(统计/汇总):reduce

这样你遇到新 API(例如 some/every/flatMap)也能快速定位它属于哪一类,应该在什么场景用。


3.2 高阶函数的好处与代价

好处(工程收益)

  • 少写重复代码:遍历/收集/累计这些“通用步骤”被封装起来了
  • 降低冲突风险:中间变量更少,作用域更小,命名冲突概率降低
  • 更强的可组合性:回调就是“规则模块”,能复用、能拼装

代价(需要主动管理)

  • 初学时不直观:你看不到循环过程,容易“脑补出错”
  • 过度链式会变难读:arr.filter(...).map(...).reduce(...) 一长串要谨慎
  • 性能不是绝对优势:链式会产生中间数组;超大数据量时要考虑优化策略(合并步骤、或用单次 reduce)

工程上建议的取舍:

  • 优先可读性与正确性,其次才是微优化
  • 当性能成为问题,用数据与指标说话(见结尾“下一步建议”)

本章小结(三)

  • 记忆高阶函数别靠背,靠“数据流分类”:筛选/变换/查找/副作用/聚合。
  • 高阶函数让“规则”模块化,减少重复与冲突,更适合团队协作。
  • 链式调用要节制:可读性是第一生产力;性能优化要用指标驱动。

四、读懂文档里的语法:方括号与逗号到底在表达什么

很多同学看文档时会被这种写法劝退:

array.find(callback(element[, index[, array]])[, thisArg])

其实核心就两点:方括号 []逗号 ,


4.1 单层方括号与嵌套方括号

单层方括号 []

表示可选参数:可以传,也可以不传。

比如 [index] 表示 index 可选。

嵌套方括号 [[]]

表示可选参数的依赖关系:外层出现了,内层才可能出现。

比如 element[, index[, array]] 的含义是:

  • element 必须有
  • 如果你要传 index,必须先有 element
  • 如果你要传 array,必须先有 index(以及 element)

4.2 逗号在方括号里:前置条件的表达

当你看到 [, thisArg] 这种形式,逗号出现在方括号里,意思是:

  • thisArg 是可选的
  • 但它依赖前一个参数存在(也就是必须先传 callback,才可能有 thisArg)

换句话说:逗号前面是前置条件,逗号后面才轮得到


4.3 拆解示例:以 find 为例

我们把它拆成两段看:

array.find(callback(element[, index[, array]])[, thisArg])

  1. callback(...):必传(因为外层是小括号)
  2. [, thisArg]:可选(方括号),但依赖 callback 存在(逗号表达前置条件)

然后回调内部也同理:

  • element 必传
  • index 可选,但依赖 element
  • array 可选,但依赖 index(以及 element)

当你形成这种拆解习惯,看任何 API 都会很稳。

** 图6-1 API语法参数分类**

这张图可以当作团队内的“文档阅读速查表”:看到 [] 先判断可选,看到嵌套 [] 再判断依赖关系。


本章小结(四)

  • [] 表示可选;嵌套 [] 表示“可选但有依赖链”。
  • [, x] 的逗号表达:x 的出现以“前一个参数存在”为前提。
  • 拆 API 时优先拆外层,再拆回调参数,顺序能让理解稳定下来。
  • 熟悉这种语法后,看 MDN / IDE 提示都会更快、更不容易误解。

五、复盘与下一步:如何在团队里落地

5.1 关键结论复盘

  • 函数是一等公民:能像数据一样被传递与返回,让“抽象与复用”成为可能。
  • 高阶函数的核心价值:把通用步骤封装,把可变规则显式化
  • 五个高频函数的工程语义要记住:
    filter(筛)/ map(变)/ find(找)/ forEach(做副作用)/ reduce(聚合)
  • 读文档的关键:[](可选)+ 嵌套(依赖)+ 逗号(前置条件)。

5.2 团队落地建议(可直接执行)

建议 1:统一“选择指南”,减少风格争论

  • 生成新数组:优先 map/filter
  • 查第一个:用 find,查索引用 findIndex
  • 聚合统计:用 reduce(必要时拆解变量提升可读性)
  • 副作用(日志/埋点/外部写入):用 forEach

建议 2:在 Code Review 里抓两个点

  • 是否把“变化逻辑”抽成回调/策略函数(减少重复)
  • 链式调用是否过长导致可读性下降(超过 2~3 段就考虑拆开)

建议 3:用指标验证“可维护性”收益

  • 重复代码段数量(循环模板是否减少)
  • 单测覆盖的可测单元数量(策略函数更易测)
  • 代码变更影响范围(抽象后改动是否更局部)

5.3 下一步建议:把能力延伸到闭包

高阶函数带来的“灵活”往往伴随着“难度”的上升,而最关键的那块难度通常来自闭包:

  • 函数返回函数时,外层变量如何被捕获?
  • 为什么闭包可能造成内存驻留?
  • 什么时候它是利器,什么时候是隐患?

把闭包掌握住,你对函数的理解会从“会用 API”跃迁到“会设计抽象”。


❌