JS闭包的理解与实践

今天来总结一下 JS 中的函数闭包问题,也是我们需要去了解掌握的知识点。当然闭包并不是由 JS 提出来的,在其他的语言中也存在闭包的现象。

方向

我将从一下几个方面去讲解闭包:

  1. 什么是闭包
  2. 为什么使用闭包
  3. 如何使用闭包
  4. 闭包存在的问题

什么是闭包

定义

简单的说:闭包就是能够读取其他函数内部变量的函数。在 JS 中只有函数内部的子函数才能读取局部变量,所以闭包可以理解成”一个函数内部的函数”,本质上,闭包是将函数内部和函数外部连接起来的桥梁。
详细的说:一般地,在 JS 中闭包产生于一个由函数包裹的子函数,并且子函数使用了外部的变量,同时在外部的函数 return 此子函数,内部变量得不到释放,此为构成闭包。
专业的回答:函数与对其状态即词法环境(lexical environment)的引用共同构成闭包(closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。在 JavaScript,函数在每次创建时生成闭包。

先来一个简单的例子:

  1. function A() {
  2. const name = "jack";
  3. function B() {
  4. console.log(name);
  5. }
  6. return B;
  7. }
  8. const C = A();
  9. C();

13.JS闭包的理解与实践 - 图1

在这里,我们可以看见 B 函数嵌套在 A 函数内部,同时 B 在执行前,会被 A(外部函数)返回。这段代码比较与众不同的地方在于:正常在 A 函数执行完毕后,它的变量应该会被垃圾回收机制,会被销毁,但是使用这种结构,它的变量会被保存下来而不被销毁。这种由函数以及创建该函数的词法环境组合而成。这个在 JS 中叫做闭包。

特点

  1. 函数嵌套函数
  2. 内部函数可以访问外部函数的变量
  3. 参数和变量不会被回收

为什么使用闭包

在了解为什么使用闭包之前,我想先了解一下JS 为什么会有闭包
说到 JS 为什么会有闭包,我们需要去了解一下 JS 引擎都干了什么事情:

简单来说,js 引擎的工作分两个阶段,一个是语法检查阶段,一个是运行阶段。而运行阶段又分预解析和执行两个阶段。

在预解析阶段,先创建执行上下文,执行上下文包括变量对象、作用域链和 this 值。
活动对象:var 声明的变量、function 声明的函数,及当前函数的形参
作用域链:当前变量对象+所有父级作用域 [[scope]]
this 值:在进入执行上下文后不再改变

PS:作用域链其实就是一个变量对象的链,函数的变量对象称之为 call object。函数创建后就有静态的[[scope]]属性,直到函数销毁)

  1. 创建执行上下文后,会对变量对象的属性进行填充。所谓属性,就是 varfunction 声明的标志符及函数形参名;至于属性对应的值:变量值为 undefined,函数值为函数定义,形参值为实参,没有传入实参则为 undefined
  2. 预解析阶段结束后,进入执行代码阶段,此时执行上下文有个 Scope 属性(区别于函数的[[scope]]属性)。
  3. js 解析器逐行读取并执行代码,变量对象中的属性值可能因赋值语句而改变。当我们查询外部作用域的变量时,其实就是沿着作用域链,依次在这些变量对象里遍历标志符,直到最后的全局变量对象。

结合解释理解上述的闭包函数
那么回到最开始的 A 函数和 B 函数,同时结合引入的几个概念:函数的执行环境(execution context),活动对象(call object)、作用域(scope)、作用域链(scope chain)。我们进行如下的分析:

  1. 定义 A 函数的时候,js解释器会将函数 A 的作用域链设置为定义 AA 所在的环境,如果A是一个全局函数,则作用域链中只有window 对象。
  2. 当执行 A 的时候,A会进入相应的执行环境(execution context)。
  3. 在创建执行环境的过程中,首先会为 A 添加一个 scope 属性,即是A的作用域,它的值就是作用域链(A.scope = A)。
  4. 然后执行环境会创建一个活动对象(call object)。活动对象也是一个拥有属性的对象,但是它不具有原型而且不能通过 JS 代码直接访问。创建完活动对象,把活动对象添加到 A 的作用域链的最顶端。此时 A 的作用域链就包含了两个对象:A 的活动对象,window 对象。
  5. 然后,在活动对象上添加一个 arguments 属性,他保存调用函数 A 时所传递的参数。
  6. 最后最后把所有函数 A 的形参和内部的函数 B 的引用也添加到 A 的活动对象上面。在这一步中,完成了函数 B 的定义,因此如同第 3 步,函数 B 的作用域链被设置为 B 所定义的环境,即 A 的作用域。

此时,A 的定义到执行就完成了。此时 A 返回函数 B 的引用给 C,又函数 B 的作用域链包含了对函数 A 的活动对象的引用,也就是说 B 可以访问到 A 中定义的所有变量和函数。函数 BC 引用,函数 B 又依赖函数 A,因此函数 A 在返回后不会被 GC 回收。

当函数 B 执行的时候亦会像以上步骤一样。因此,执行时 B 的作用域链包含了 3 个对象:B 的活动对象、B 的活动对象和 window 对象

当在函数 B 中访问一个变量的时候,搜索顺序是先搜索自身的活动对象,如果存在则返回,如果不存在将继续搜索函数 A 的活动对象,依次查找,直到找到为止。如果整个作用域链上都无法找到,则返回 undefined。如果函数 B 存在 prototype 原型对象,则在查找完自身的活动对象 后先查找自身的原型对象,再继续查找。这就是 Javascript 中的变量查找机制。

了解了为什么 JS 会有闭包,再来了解为什么使用闭包

  1. 保护函数内部变量的安全。比如函数 A 中的 name 属性只有 B 能够访问到,而无法通过其他途径访问到,因此保护了外部函数中变量的安全性;
  2. 内存中会维持一个变量不被销毁。因为存在闭包,函数 A 会一直存在于内存中,因此每次执行 Cname 的变量都会被调用;
  3. 方便调用上下文中的局部变量,利于代码的封装。

如何使用闭包

我们可以根据为什么使用闭包来举例说明几个比较经典的闭包应用场景:

setTimeout

  1. // 原生中setTimeout的第一个函数不能携带参数
  2. setTimeout(param => {
  3. console.log(param);
  4. });
  5. function transportParams(param) {
  6. return function() {
  7. console.log(param);
  8. };
  9. }
  10. const foo = transportParams("this is params");
  11. setTimeout(foo);

函数方法的重复调用

  1. function print(number) {
  2. return function() {
  3. console.log(number);
  4. };
  5. }
  6. const print1 = print(1);
  7. const print2 = print(2);
  8. const print3 = print(3);
  9. const print4 = print(4);

如果我们需要执行相同的方法很多次,这个时候可以考虑闭包。

使用闭包模拟私有方法 — 封装

JS 中是没有这种将方法声明称私有的,即他们只能被同一个类中的其他方法所调用。但是我们可以使用闭包来模拟。私有方法不仅仅是有利于限制对代码的访问;还提供了管理全局命名空间的强大能力,避免非核心代码弄乱了代码的公共接口部分。

下面的示例展现了如何使用闭包来定义公共函数,并令其可以访问私有函数和变量。这个方式也称为 模块模式(module pattern)

  1. const Counter = () => {
  2. const _num = 0; // private私有变量一般使用下划线表示
  3. function changeBy(num) {
  4. _num += num;
  5. }
  6. return {
  7. increment: function() {
  8. changeBy(1);
  9. },
  10. decrement: function() {
  11. changeBy(-1);
  12. },
  13. value: function() {
  14. return _num;
  15. }
  16. };
  17. };
  18. console.log(Counter.value()); /* 0 */
  19. Counter.increment();
  20. Counter.increment();
  21. console.log(Counter.value()); /* 2 */
  22. Counter.decrement();
  23. console.log(Counter.value()); /* 1 */

本示例中,我们可以看见三个方法:increment,decrement,value 共享同一个词法环境。共享的环境创建于液体个立即执行的匿名函数中,其中的私有项:_num 变量和 changeBy 函数。这三个方法共享这个匿名函数创建的闭包。

同时结合闭包的上一个用途,我们创建多个计数器

  1. const counter1 = Counter();
  2. const counter2 = Counter();
  3. console.log(counter1.value()); /* 0 */
  4. counter1.increment();
  5. counter1.increment();
  6. console.log(counter1.value()); /* 2 */
  7. counter1.decrement();
  8. console.log(counter1.value()); /* 1 */
  9. console.log(counter2.value()); /* 0 */

上面的 counter1counter2 都在自己的比保重,他们之间没有任何关系,改变当前闭包中的变量和调用当前闭包中的方法都不会影响其他函数的使用。这种方式使用闭包,就体现了数据隐藏和封装。

闭包在循环中的使用

我们可能会见到一个面试题:

  1. for (var i = 1; i <= 5; i++) {
  2. setTimeout(function timer() {
  3. console.log(i);
  4. }, i * 100);
  5. } // 6 6 6 6 6
  6. // 解决方法一:
  7. for (var k = 1; k <= 5; k++) {
  8. (function(j) {
  9. setTimeout(function fa1() {
  10. console.log(j);
  11. }, j * 1000);
  12. })(k);
  13. }
  14. // 方法二使用let
  15. // 方法三setTimeout传递第三个参数

或者循环给 dom 绑定点击事件:

  1. const elements = document.getElementsByTagName("li");
  2. const length = elements.length;
  3. for (var i = 0; i < length; i++) {
  4. elements[i].onclick = (function(a) {
  5. return function() {
  6. console.log(a);
  7. };
  8. })(i);
  9. }

函数节流和防抖

我们在实际的场景中,有时候会遇到频繁的提交表单,页面滚动,不断的调用方法会比较吃性能。

  1. 函数节流:一段时间内 js 只执行一次。
  2. 函数防抖:在频繁的执行中,如果有足够空闲的时间才执行一次。
  1. // 函数节流
  2. function throttle(fn, delay = 1000, immediate = true) {
  3. let running = false;
  4. return function() {
  5. if (running) {
  6. return;
  7. }
  8. running = true;
  9. if (immediate === false) {
  10. fn();
  11. }
  12. setTimeout(function() {
  13. if (immediate) {
  14. fn();
  15. }
  16. running = false;
  17. }, delay);
  18. };
  19. }
  1. // 函数防抖
  2. function debounce(fn, delay = 1000) {
  3. let timer = false;
  4. return function() {
  5. clearTimeout(timer);
  6. timer = setTimeout(function() {
  7. fn();
  8. }, delay);
  9. };
  10. }

这个里面都存在闭包,也算是闭包的经典范例。

函数柯里化(Currying)

函数柯里化: 把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。
看个例子:

  1. function(x, y) {
  2. return x + y
  3. }
  4. function curryingAdd(x) {
  5. return function(y) {
  6. return x + y
  7. }
  8. }
  9. add(1, 2) // 3
  10. curryingAdd(1)(2) // 3

那么函数柯里化的好处有哪些:

  1. 方法重复使用,创建多个闭包(就是上述闭包的性质之一)
  2. 提前确认,很多时候我们需要使用 if 判断再执行相应的方法,使用这种方式可以在函数调用之前就确定使用哪个方法,避免每次都判断
  1. var on = function(isSupport, element, event, handler) {
  2. isSupport = isSupport || document.addEventListener;
  3. if (isSupport) {
  4. return element.addEventListener(event, handler, false);
  5. } else {
  6. return element.attachEvent("on" + event, handler);
  7. }
  8. };
  1. 延迟运行
  1. Function.prototype.bind = function(context) {
  2. var _this = this;
  3. var args = Array.prototype.slice.call(arguments, 1);
  4. return function() {
  5. return _this.apply(context, args);
  6. };
  7. };

拓展补充面试题:实现一个 add 方法,使计算结果能够满足如下预期:

add(1)(2)(3) = 6;
add(1, 2, 3)(4) = 10;
add(1)(2)(3)(4)(5) = 15;

  1. function add() {
  2. // 创建一个数组保存参数
  3. const _args = Array.prototype.slice.call(arguments);
  4. // 在内部声明一个函数,利用闭包的特性保存_args并收集所有的参数值
  5. const _add = function() {
  6. _args.push(...arguments);
  7. return _add;
  8. };
  9. // 利用toString隐式转换的特性,当最后执行时隐式转换,并计算最终的值返回
  10. _add.toString = function() {
  11. return _args.reduce((a, b) => a + b);
  12. };
  13. return _add;
  14. }
  15. console.log(add(1)(2)(3)); // 6
  16. console.log(add(1, 2, 3)(4)); // 10
  17. console.log(add(2, 6)(1)); // 9

闭包存在的问题

内存泄漏

闭包会使得外部函数的变量无法得到自动回收,会一直存在,这样就会占用一定的内存。所以当闭包不再使用的时候,需要手动释放,将变量设置为 null。现在的计算机性能都是过剩的,其实也大可不必小心使用。合理使用即可。


【参考资料】

  1. 为什么 js 会有闭包
  2. 百度百科 - 闭包
  3. MDN - 闭包
  4. 详解 JS 函数柯里化