深刻JavaScript系列(三):闭包

词法环境执行上下文不太了解的朋友,建议先阅读系列文章的前两篇,有助于理解本文,连接 -> 深刻ECMAScript系列目录地址(持续更新中...)git

1、词法做用域

首先咱们来看一个例子(来自冴羽大大的博客JavaScript深刻之词法做用域和动态做用域):github

var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
    return f()
}
checkscope()
复制代码
var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
    return f
}
checkscope()()
复制代码

这里就不卖关子了,两段代码的运行结果都是local scope。这是JavaScript做用域机制决定的。闭包

做用域:指程序源代码中定义变量的区域。是规定代码对变量访问权限的规则。ecmascript

你们可能据说过JavaScript采用的是词法做用域(静态做用域),没据说过也没有关系,很好理解,意思就是函数的做用域在函数定义的时候就肯定了,也就是说函数的做用域取决于函数在哪里定义,和函数在哪里调用并没有关系。异步

由以前的文章深刻ECMAScript系列(二):执行上下文咱们可知:任意的JavaScript可执行代码(包括函数)被执行时,会建立新的执行上下文及其词法环境函数

既然词法环境是在代码块运行时才建立的,那为何又说函数的做用域在函数定义的时候就肯定了呢?这就牵扯到了函数的声明及调用了。post

2、函数的声明及调用

在以前的文章深刻ECMAScript系列(二):执行上下文中说过,代码块内的函数声明在标识符实例化及初始化阶段就会被初始化并分配相应的函数体。ui

在这个阶段还会会给函数设置一个内置属性[[Environment]],指向函数声明时所在的执行上下文的词法环境。this

当声明过的函数被调用时,会建立新的执行上下文和新的词法环境,这个新建立的词法环境的对外部词法环境的引用outer属性将会指向函数的[[Environment]]内置属性,也就是函数声明时所在的执行上下文的词法环境。spa

而变量的查找又是经过词法环境及其外部引用进行的,因此说函数的做用域取决于函数在哪里定义,和函数在哪里调用并没有关系。

总结一下,两个关键点:

  1. 函数声明时会被赋予一个内置属性[[Environment]],指向函数声明时所在的执行上下文的词法环境。
  2. 函数不管在什么时候何地调用,建立的词法环境的外部词法环境引用outer都指向函数的内置属性[[Environment]]

因此说函数的做用域取决于函数在哪里定义,和函数在哪里调用并没有关系。

咱们回头看文章开头的两个例子:

var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
}

// function f
f: {
    [[ECMAScriptCode]]: ..., // 函数体代码
    [[Environment]]: { // 函数f 定义时所在执行上下文的词法环境,也就是函数checkscope运行时建立的词法环境
        EnvironmentRecord: { // 环境记录上绑定了变量scope和函数f
            scope: 'local scope',
            f: Function f
        },
        outer: { // 外部词法环境引用指向全局词法环境
            EnvironmentRecord: { // 全局环境记录上绑定了变量scope和函数checkscope
                scope: 'global scope',
                checkscope: Function checkscope
            },
            outer: null // 全局词法环境无外部词法环境引用
        }
    },
    ... // 其余属性
}
复制代码

函数f定义在函数checkscope内部,因此函数f不论在函数checkscope的内部调用,仍是做为返回值返回后在外部调用,其词法环境的外部引用永远是函数checkscope运行时建立的词法环境,变量scope也只用往外寻找一层词法环境,在函数checkscope运行时建立的词法环境中找到,值为'local scope',不用再往外查找。因此上面两个例子的运行结果都是local scope

3、闭包

首先看看MDN上对闭包的定义:

闭包:闭包是函数和声明该函数的词法环境的组合。

从理论角度来讲:全部的JavaScript函数都是闭包。 由于函数声明时会设置一个内置属性[[Environment]]来记录当前执行上下文的词法环境。

从实践角度来讲: 咱们平时所说的闭包应该叫“有意义的闭包”:

Dmitry Soshnikov的文章中描述具备如下特色的函数叫作闭包:

  1. 函数建立时所在的上下文销毁后,该函数仍然存在
  2. 函数内引用自由变量

自由变量: 在函数中使用,但既不是函数参数也不是函数的局部变量的变量。

我本身的理解是如下两点:

  1. 函数建立时的词法环境已不存在于当前执行上下文的词法环境链上。(换句话说,函数建立时的词法环境内的变量已没法在当前执行上下文内直接访问)
  2. 函数内存在对函数建立时的词法环境内的变量的访问。

最简单的闭包就是父函数内返回一个函数,返回函数内引用了父函数内变量:

var scope = 'global scope'
function checkscope(){
    var scope = 'local scope'
    function f(){
        return scope
    }
    return f
}

var closure = checkscope()
closure()
复制代码

将开头的第二个例子稍微变一下,调用checkscope会返回一个函数,咱们将其赋值给closure,此时closure函数就是一个闭包,因为它是在调用checkscope时建立的,内置属性[[Environment]]指向调用checkscope时建立的词法环境,所以不管在何处调用closure函数,返回结果是'local scope'

4、闭包的应用

我理解闭包的本质做用就两点,任何闭包的应用都离不开这两点:

  1. 建立私有变量
  2. 延长变量的生命周期

关于延长变量的生命周期,本质实际上是延长词法环境的生命周期,通常函数的词法环境在函数返回后就被销毁,可是闭包会保存对建立时所在词法环境的引用,即使建立时所在的执行上下文被销毁,但建立时所在词法环境依然存在,以达到延长变量的生命周期的目的。

1. 模拟块级做用域

经过闭包能够模拟块级做用域,很经典的例子就是for循环中使用定时器延迟打印的问题。

// ES6以前无块级做用域,多个定时器内的回调函数引用同一个i
// for循环为同步,定时器内函数为异步,循环结束后i已经变为4
// 定时期内函数触发时访问变量i都是4
// 理解的关键在于for循环内代码是同步的,包括setTimtout自己
// 可是setTimeout定时器内的回调函数是异步的
for (var i = 1; i <= 3; i++) {
	setTimeout(function() {
		console.log(i)
	}, i * 1000)
}
复制代码
// 使用当即执行函数,将i做为参数传入,可保存变量i的实时值
for(var i = 1; i <= 3; i++){
    (i => {
        setTimeout(() => {
            console.log(i)
        }, i * 1000)
    })(i)
}
// 如下代码可达到相同效果
for(var i = 1; i <= 3; i++){
    (() => {
        var j = i
        setTimeout(() => {
            console.log(j)
        }, j * 1000)
    })()
}
// 如下代码也可达到相同效果
for(var i = 1; i <= 3; i++){
    var closure = (function() {
        var j = i
        return () => {
            console.log(j)
        }
    })()
    setTimeout(closure, i * 1000)
}
复制代码

闭包模拟块级做用域了解便可,毕竟ES6以后咱们有了let来实现块级做用域,实现块级做用域的具体原理详见深刻ECMAScript系列(二):执行上下文

2. 实现JS模块模式

模块模式是指将全部的数据和功能都封装在一个函数内部(私有的),只向外暴露一个包含多个属性方法的对象或函数。

var counter = (function() {
    var privateCounter = 0
    function changeBy(val) {
        privateCounter += val
    }
    return {
        increment: function() {
            changeBy(1)
        },
        decrement: function() {
            changeBy(-1)
        },
        value: function() {
            return privateCounter;
        }
    }
})()
复制代码

另外例如underscore等一些js库的实现也使用到了闭包。

(function(){
    var root = this;

    var _ = {};

    root._ = _;
    
    // 外部不可访问的方法
    function tool() {
        // ...
    }
    
    // 外部可访问的方法
    _.xxx = function() {
        tool()
        // ...
    }
})()
复制代码

3. 函数的柯里化

柯里化的目的在于避免频繁调用具备相同参数函数的同时,又可以轻松的重用。

// 假设咱们有一个求长方形面积的函数
function getArea(width, height) {
    return width * height
}
// 若是咱们碰到的长方形的宽总是10
const area1 = getArea(10, 20)
const area2 = getArea(10, 30)
const area3 = getArea(10, 40)

// 咱们可使用闭包柯里化这个计算面积的函数
function getArea(width) {
    return height => {
        return width * height
    }
}

const getTenWidthArea = getArea(10)
// 以后碰到宽度为10的长方形就能够这样计算面积
const area1 = getTenWidthArea(20)

// 并且若是遇到宽度偶尔变化也能够轻松复用
const getTwentyWidthArea = getArea(20)
复制代码

其余例如计数器、延迟调用、回调等闭包的应用这里就不作过多讲解,其核心思想仍是建立私有变量延长变量的生命周期

5、总结

  1. ECMAScript采用词法做用域(也称静态做用域),函数的做用域取决于函数在哪里定义,和函数在哪里调用并没有关系。
  2. 闭包是函数和声明该函数的词法环境的组合。
  3. 理论角度来讲全部JavaScript函数都是闭包,由于函数会记录其定义时所处执行上下文的词法环境。
  4. 实践角度来讲,引用了定义时所处词法环境的变量,而且可以在除了定义时所在上下文的其余上下文被调用的函数,才叫闭包。
  5. 闭包的做用总结为两点,一是建立私有变量,二是延长变量的生命周期

6、小练习

function fun(n,o){
  console.log(o);
  return {
    fun: function(m){
      return fun(m,n);
    }
  };
}

var a = fun(0);                       // ?
a.fun(1);                             // ? 
a.fun(2);                             // ?
a.fun(3);                             // ?

var b = fun(0).fun(1).fun(2).fun(3);  // ?

var c = fun(0).fun(1);                // ?
c.fun(2);                             // ?
c.fun(3);                             // ?
复制代码

运用咱们以前总结的知识来分析一下:

function fun(n,o){
  console.log(o);
  return {
    fun: function(m){
      return fun(m,n);
    }
  };
}

// 运行fun(0),未传入第二个参数,故打印undefined,最后返回一个对象,内有一个fun方法
// (注意此方法与外部fun函数不一样,下同)
var a = fun(0);                       // undefined
// 对象内fun方法为闭包,记录对fun(0)执行时的词法环境,内部绑定一个参数n,值为0
// 将返回对象赋值于a,执行a.fun(x)时,无论传入的第一个参数是什么
// 第二个参数n都将在以前fun(0)执行时的词法环境内找到,值为0
a.fun(1);                             // 0 
a.fun(2);                             // 0
a.fun(3);                             // 0

// 每次调用fun函数都会返回一个对象
// 对象内又一个fun方法,为闭包,记录建立该对象及对象方法时的词法环境
// 故每次调用对象的fun方法,内部执行fun函数时的第二个参数总会在建立该对象时的词法环境内找到
// 值即为建立该对象的函数的第一个参数
// 因此除了第一次打印值为undefined,其他皆为上次调用fun时传入的第一个参数
var b = fun(0).fun(1).fun(2).fun(3);  // undefined
                                      // 0
                                      // 1
                                      // 2

// 相似上面的分析,c为一个对象,有一个fun方法,为闭包
// 该闭包记录了建立它时的词法环境,上面有两个绑定,{n: 1, o: 0}
// 因此c.fun(x)相似调用时,不论传参是什么,都将打印1
// 须要注意fun(0)调用时打印了undefined,fun(0).fun(1)调用时打印了0
var c = fun(0).fun(1);                // undefined
                                      // 0
c.fun(2);                             // 1
c.fun(3);                             // 1
复制代码

OK,本篇文章就写到这里,相信你们对于闭包也有了必定本身的理解。关于深刻ECMAScript系列文章以后的主题你们也能够在评论区留言讨论。

系列文章

深刻ECMAScript系列目录地址(持续更新中...)

欢迎前往阅读系列文章,若是喜欢或者有所启发,欢迎 star,对做者也是一种鼓励。

菜鸟一枚,若是有疑问或者发现错误,能够在相应的 issues 进行提问或勘误,与你们共同进步。

相关文章
相关标签/搜索