likes
comments
collection
share

《You Dont Know JS上卷》(五) ---闭包

作者站长头像
站长
· 阅读数 2

闭包

如果了解了之前关于词法作用域的讨论,那么闭包的概念就不言自明了.仔细想想确实是这样,所以如果对词法作用域不是很清楚,请回到第二章回顾一下吧~!

闭包是基于词法作用域书写代码产生的自然结果,JavaScript中闭包无处不在,如果不能察觉到它的身影,那所缺少的就是根据自己的意愿来识别,拥有和影响闭包

最后恍然大悟: 原来我的代码中已经到处是闭包了!

什么是闭包?

闭包其实就是一种引用:当函数在定义的作用域之外被调用的时候就产生了闭包,函数持有对定义作用域的引用,这个引用就是闭包

这个引用使定义作用域无法被垃圾回收,从而提供了一种外部作用域通过这个引用访问内部作用域的一种方式.

function foo(){
    let a = 0;
    function bar(){
        console.log(a); //函数引用定义时所在作用域的变量
    }
    return bar;
}
const fn = foo() //此时foo作用域执行完毕理应被回收机制销毁,但由于bar拥有涵盖foo()内部作用域的闭包,因此bar依然持有foo作用域的引用,导致foo作用域未能被垃圾回收
fn() //bar在定义时作用域之外被执行,输出了foo作用域下的变量a的值,说明在全局作用域访问函数作用域成功了,如果没有闭包,这是万万不能实现的

函数bar的词法作用域可以访问函数foo的内部作用域,然后我们将bar所引用的函数本身当作返回值传递到外层,foo执行后,其返回值赋值给了fn并调用了fn,实际上是通过不同标识符的引用调用了内部的函数bar,

foo执行完毕的时候,其理应被回收机制回收,但由于bar一直在引用foo的作用域,导致foo不会被回收,这就是闭包做的事情.

一些例子

判断是否存在闭包的最好方法就是: 观察函数是否在其定义的作用域之外被调用

本质上无论何时,如果将可以访问它们各自作用域的函数,当作第一级的值类型并到处传递,就能看到闭包在这些函数中的应用,让我们来看一些闭包例子:

定时器: timer函数定义在wait作用域中,但其执行确实在其他地方执行
function wait(message){
    setTimeot(function timer(){
        console.log(message)
    })
}
wait("test")

事件监听器: active函数定义在addClick作用域内,其被调用的地方虽然不在这个作用域,但依然能访问到addClick作用域
function addClick(name,selector){
    $(selector).click(function active(){
        console.log(name)
    })
}
addClick('test','#div_1')

除了上述的例子还有: Ajax请求,web Workers,跨窗口通信,只要使用了回调函数实际上就是在使用闭包

循环和闭包

每间隔一秒输出一次的代码,依次输出0,1,2,3...共五次,我们可能会这样写:

for(var i = 0; i < 5;i++){
    setTimeout(function timer(){
        console.log(i)
    },i*1000)
}

但执行的时候发现每次都会输出5,我们也许会很诧异,但仔细想想也都对: 循环完毕i===5,之后执行宏任务,找到i并打印,发现当前作用域声明的有i,于是就进行打印.而此时i已经是5

那如何才能实现我们的需求呢?

深究一下连续打印5的原因,很容易发现: 当i等于1时,我们理应进行打印的但是由于同步代码总要比异步代码先执行完,还没等到去打印值为1的i,i就已经赋值为2了,就这样一直重复到循环结束,那么问题根因就在于i无法等到被打印就被重新赋值了,无法保存i的状态,

而创建一个闭包是可以保存本应销毁的状态的,如果当i等于1时,将这个值存在一个作用域中,由于timer函数一直引用,那么这个作用域就一直不会被销毁,直到timer函数执行完毕,听起来不错,也许可以试试,为了方便我们直接使用IIFE:

for (var i = 0; i < 5; i++) {
  (function wrap() { //给timer外添加一层函数作用域用来形成闭包
    setTimeout(function timer() {
      console.log(i);
    }, i * 1000);
  })();
}

执行后发现,还是每次都输出5,仔细观察代码,哦~原来timer函数并没有持有wrap函数的作用域,因为wrap函数作用域中空空如也,一个变量也没有,timer想引用其变量也引用不了;知道原因了那么来修改一下代码,将每一次的i传递进去:

for (var i = 0; i < 5; i++) {
  (function wrap(i) { //由于timer一直引用wrap的i,导致这个i不会被回收,传进来是几,就会一直保存着,知道没有其他地方引用它之后,才被销毁
    setTimeout(function timer() {
      console.log(i);
    }, i * 1000);
  })(i); //将当前的i传递到函数中,
}

OK,这次就可以按照预想的打印了,来复盘一下闭包在其中的作用:

  1. 第一次循环时: i=1,将i作为参数传递给IIFE,IIFE执行,将参数i作为当前作用域的变量进行存储,当IIFE执行完毕,回收机制想要回收IIFE创建的作用域,但发现引用了timer函数引用IIFE作用域的变量,因此IIFE的作用域没有回收,依然保存在内存中
  2. 第二次循环时: i=2,......因此IIFE的作用域没有回收,依然保存在内存中
  3. 第二次循环时: i=3,......因此IIFE的作用域没有回收,依然保存在内存中
  4. 第二次循环时: i=4,......因此IIFE的作用域没有回收,依然保存在内存中
  5. 第二次循环时: i=5,退出循环.当定时器完成时,开始执行内部的函数,于是第一个timer在未回收的IIFE作用域找到当时保存的i进行打印,然后第二个.....

整个过程中,利用timer产生的闭包,来使了循环时创建的IIFE作用域不被销毁,从而在打印的时候成功打印了我们预期的结果

但是! 这就完了嘛,其实还更简单的方法: 块作用域

for(let i = 0; i < 5;i++){
    setTimeout(function timer(){
        console.log(i)
    },i*1000)
}

for循环头部的let有一个特殊行为: 变量在循环过程中不止被声明一次,每次迭代都会声明,并且都会用上一次迭代结束的值来初始化这个变量

模块

什么是模块?

模块是指将代码组织成可重用,可独立管理的单元

为什么要有模块?

  1. 管理和维护: 项目规模越来越大的时候,可以利用模块将代码划分为一个个单元,有利于代码管理和维护
  2. 命名空间: 每个模块都创建独立的作用域,减少作用域中命名冲突的发生
  3. 复用性: 对于可以复用的功能,划分为模块,更容易在不容的地方使用

模块和闭包有什么关系?

综上所述,模块应具有两个主要特点:

  1. 具有作用域,可以保存私有变量
  2. 可以提供外部访问私有变量的方式,以供模块之间的配合和交互

欸~ 这不就是闭包干的事情嘛!

实现模块

搞清楚了上面的问题,接下来我们用闭包实现一个模块模式:

function createModule(){
    let a = 0;
    let b = 1;
    
    function getA(){
        console.log(a)
    }
    function getB(){
        console.log(b)
    }
    return {
        getA,
        getB
    }
}

const module = createModule()

module.getA() // 0
module.getB() //1

createModule只是一个函数,调用这个函数可以创建一个模块,该函数的返回值是一个对象,这个对象中包含对内部函数而不是内部变量的引用,可以将返回的对象看作是这个模块的公共API

简单总结,模块模式需要具备两个条件:

  1. 必须有外部的封闭函数(createModule),该函数必须被调用一次
  2. 封闭函数必须至少返回一个内部函数,这样内部函数才能在私有作用域中形成闭包

利用IIFE来实现以下单例模式:

const module = (function createModule(){
    let a = 0;
    let b = 1;
    
    function getA(){
        console.log(a)
    }
    function getB(){
        console.log(b)
    }
    return {
        getA,
        getB
    }
})()

module.getA() // 0
module.getB() //1

模块模式另一个简单但重要的用法: 命名将要返回的公共API对象,从而提供修改模块的途径:

const foo = (function createModule() {
  let a = 0;
  let b = 1;

  function change() {
    pubilcApi.get = getB; //修改返回的对象
  }
  function getA() {
    console.log(a);
  }
  function getB() {
    console.log(b);
  }
  const pubilcApi = {
    //对返回的对象命名
    get: getA,
    change,
  };
  return pubilcApi;
})();

foo.get(); // 0
foo.change();
foo.get(); //1

通过在模块内部保留公共API的应用,可以从内部对公共API进行动态修改

现代的模块机制

ps: 现在看这种模块方式已经很老旧了,但这本书毕竟在2015年才印第一版

大多数模块管理器都是将这种模块定义封装进一个友好的API里,下面是其模块管理器实现的核心思路:

/**
 * @message: 使用单例模式创建唯一的模块管理器,用于存储,定义,获取模块
 */
const MyModules = (function Manager() {
  const module = {};
  /**
   * @message: 定义模块
   * @param {*} name 模块名
   * @param {*} deps 依赖模块数组
   * @param {*} impl 模块
   */
  const define = (name, deps, impl) => {
    deps = deps.map((dep) => module[dep]); //拿出引用的模块
    module[name] = impl.call(impl, ...deps);
  };
  const get = (name) => {
    return module[name];
  };
  return {
    define,
    get,
  };
})();

MyModules.define('bar', [], function () {
  function hello() {
    return 'hello';
  }
  return { hello };
});

MyModules.define('foo', ['bar'], function (bar) {
  const say = () => {
    return bar.hello();
  };
  return { hello };
});

const bar = MyModules.get('bar');
const foo = MyModules.get('foo');
console.log(foo.say());
});

未来的模块机制

ES6为模块增加了一级支持,在通过模块系统加载时,ES6会将文件当作独立的模块来处理

ES6的模块必须被定义在一个文件中,一个文件一个模块,"模块加载器"可以在导入模块的时候同步加载模块文件,

  • 基于函数的模块,并不能被编译器静态识别,他们的APi只能在运行才能知道,因为可能在运行时修改一个模块的API
  • ES6的模块是静态的,不会在运行时改变,因此编译器可以在编译期对导入模块的API成员是否存在,从而提前发现错误