FP5:What is a Closure?

November 19, 2017 by Sylvenas

要讲闭包,就必须先理解JavaScript中的词法环境的概念;词法环境简单来说就是包括环境记录和对外部词法环境的引用。

环境记录初始化

一段JS代码执行之前,会对环境记录进行初始化(声明提前),即将函数的形参、函数声明和变量先放入函数的环境记录中,特别需要注意的是:

形参会在初始化的时候定义值,但是函数内部定义的变量只声明不不赋值;

以下面这段代码为例,解析环境记录初始化和代码执行的过程:

var x = 10;
function foo(y) {
    var z  = 30;
    function bar(q) {
        return x + y + z + q;
    }
    return bar;
}
var bar = foo(20);
bar(40);
  • 初始化全局环境
全局环境
环境记录(record) foo: <function>
x: undefined(声明变量而非定义变量)
bar: undefined(声明变量而非定义变量)
外部环境(outer) null
  • 执行 x = 10
全局环境
环境记录(record) foo: <function>
x: 10()
bar: undefined(声明变量而非定义变量)
外部环境(outer) null
  • 执行var bar = foo(20)语句之前,将foo函数的环境记录初始化
foo 环境
环境记录(record) y: 20(定义形参)
bar: <function>
z: undefined(声明变量而非定义变量)
外部环境(outer) 全局环境
  • 执行var bar = foo(20)语句,变量bar接收foo函数中返回的bar函数
foo 环境
环境记录(record) y: 20
bar: <function>
z: 30(定义z)
外部环境(outer) 全局环境
  • 执行bar函数之前,初始化bar的词法环境
bar环境
环境记录(record) q: 40(定义形参q)
外部环境(outer) foo环境
  • 在foo函数内执行bar函数

x + y + z + q = 10 + 20 + 30 + 40 = 100

声明提升

在上面词法分析的过程中我们可以看到任何的变量和函数总是会被提前声明但不赋值,赋值的代码还是留在代码编写的原地,等待赋值!

在一段代码中如果有同名的函数和变量,那么函数优先,函数声明会覆盖掉变量声明。

切记,函数表达式的中的函数不会提升

词法分析的过程也就是变量、函数声明提升的过程。

词法作用域

上面进行环境记录的过程,就是相当于确定了词法作用域的过程,也就说词法作用域(静态作用域)是在书写代码或者说定义时确定的,后期是无法修改的,也就说词法作用域关注代码直接的位置关系,例如:互相嵌套,定义先后顺序等等;所以词法作用域也被成为静态作用域。

在JavaScript内部,作用域确实和对象很类似,可见的标识符都是它的属性,但是作用域“对象”无法通过JavaScript代码访问,它仅存在于JavaScript引擎内部。

举个例子来说明一下:

function foo(a) {
    var b = a * 2;
    function bar(c) {
        console.log( a, b, c );
    }
    bar(b * 3);
}
foo(2); // 2 4 12

词法作用域

作用域气泡由其对应的作用域块代码写在哪里决定,它们是逐级包含的:

  • 气泡1 包含着整个全局作用域,其中只有一个标识符:foo

  • 气泡2 包含着foo所创建的作用域,其中有三个标识符:a、bar和b

  • 气泡3 包含着bar所创建的作用域,其中只有一个标识符:c

作用域气泡的结构和互相之间的位置关系给引擎提供了足够的位置信息,引擎用这些信息来查找标识符的位置

在代码片段中,引擎执行console.log(...)声明,并查找a、b和c三个变量的引用。它首先从最内部的作用域,也就是bar(...)函数的作用域开始查找。引擎无法在这里找到a,因此会去上一级到所嵌套的foo(...)的作用域中继续查找。在这里找到了a,因此引擎使用了这个引用。对b来讲也一样。而对c来说,引擎在bar(...)中找到了它。

遮蔽
作用域查找从运行时所处的最内部作用域开始,逐级向外或者说向上进行,直到遇见第一个匹配的标识符为止

在多层的嵌套作用域中可以定义同名的标识符,这叫作“遮蔽效应”,内部的标识符“遮蔽”了外部的标识符

var a = 0;
function test(){
    var a = 1;
    console.log(a);//1
}
test();

全局变量会自动成为全局对象的属性,因此可以不直接通过全局对象的词法名称,而是间接地通过对全局对象属性的引用来对其进行访问

var a = 0;
function test(){
    var a = 1;
    console.log(window.a);//0
}
test();

通过这种技术可以访问那些被同名变量所遮蔽的全局变量。但非全局的变量如果被遮蔽了,无论如何都无法被访问到。

动态作用域

javascript使用的是词法作用域,它最重要的特征是它的定义过程发生在代码的书写阶段

那为什么要介绍动态作用域呢?实际上动态作用域是javascript另一个重要机制this的表亲。作用域混乱多数是因为词法作用域和this机制相混淆,傻傻分不清楚

动态作用域并不关心函数和作用域是如何声明以及在任何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是代码中的作用域嵌套

var a = 2;
function foo() {
    console.log( a );
}
function bar() {
    var a = 3;
    foo();
}
bar();
  • 如果处于词法作用域,也就是现在的javascript环境。变量a首先在foo()函数中查找,没有找到。于是顺着作用域链到全局作用域中查找,找到并赋值为2。所以控制台输出2

  • 如果处于动态作用域,同样地,变量a首先在foo()中查找,没有找到。这里会顺着调用栈在调用foo()函数的地方,也就是bar()函数中查找,找到并赋值为3。所以控制台输出3

两种作用域的区别,简而言之,词法作用域是在定义时确定的,而动态作用域是在运行时确定的;词法作用域规则查找一个变量声明时依赖的是源程序中块之间的静态关系;而动态作用域规则依赖的是程序执行时的函数调用顺序。

闭包

看下面这个代码的例子:

function makeFunc() {
    var name = "Mozilla";
    function displayName() {
        alert(name);
    }
    return displayName;
}

var myFunc = makeFunc();
myFunc();

第一眼看上去,也许不能直观的看出这段代码能够正常运行。在一些编程语言中,函数中的局部变量仅在函数的执行期间可用。一旦makeFunc()执行完毕,我们会认为name变量将不能被访问。然而,因为代码运行得没问题,所以很显然在JavaScript中并不是这样的。

这个谜题的答案是,JavaScript中的函数会形成闭包。**闭包是由函数以及创建该函数的词法环境组合而成**。这个环境包含了这个闭包创建时所能访问的所有局部变量。在我们的例子中,myFunc是执行makeFunc时创建的displayName函数实例的引用,而displayName实例仍可访问其词法作用域中的变量,即可以访问到name。由此,当myFunc被调用时,name仍可被访问,其值Mozilla就被传递到alert中。

简而言之,闭包 是函数和声明该函数的词法环境(lexical environment)的组合;

  • 这个环境包含了这个闭包创建时所能访问的所有局部变量;
  • 闭包就是允许一个内部函数有权限访问外部函数作用域中的变量(又称为自由变量),即使外部函数已执行完毕;
  • 在JavaScript中,在任意一个函数被创建的时候,都会形成闭包。

“在JavaScript中,在任意一个函数被创建的时候,都会形成闭包” 这句话有部分人不认同,他们的观点是只有函数在脱离当前词法环境运行的时候,才会形成闭包。这种观点我也是赞同的,只不过是更严格的说法。

要使用闭包,只需要简单的把一个函数定义在另一个函数内部,并将它暴露出来。要暴露一个函数,可以将它返回或者传递给其他函数。

Using Closures (Examples)

  • 创建私有变量

在JavaScript中,闭包是用来实现数据私有的原生机制。当你使用闭包来实现数据私有时,被封装的变量只能在闭包容器函数(外部函数)作用域中使用。你不能在外部作用域中访问这些被包装的变量,除非你使用对象的特权方法。 任何定义在闭包作用域病暴露到外部的函数,都属于特权方法。例如:

const getSecret = function (data) {
    let secret = data;
    return {
        get: function () {
            return secret;
        }
    }
}
const obj = getSecret('hello world');
obj.get(); // hello world

在上面的例子里,get()方法定义在getSecret()作用域下,这让它可以访问到任何getSecret方法内的变量,于是get()就是一个被授权的方法。在这个例子里,它可以访问参数secret

上面的例子还有一个常见的陷阱,在上面例子的基础上追加一些代码:

obj.addData = function (newData){
    return secret += newData
}
obj.addData('nihao');

这个时候,很多人会误以为,obj.addData('nihao')表达式的返回值会是'hello world nihao',但是实际上,这个表达式根本不能正确的执行下去,反而会报错(secret is not defined),有些人会很奇怪为何啊,我不是明明创建闭包的时候,已经添加了对secret的引用了吗?

事实并非如此,get函数在创建的时候,添加了对secret变量的引用(或者说对自由变量的捕获是在闭包创建的时候),而addData函数在创建的时候,完全没有添加对secret的引用。仔细考虑一下,对不对!

闭包还有一个常见的陷阱,看个简单的例子:

function showObject(obj) {
    return function () {
        return obj
    }
}

var o = { a: 1 };
var showO = showObject(o);
showO()  // {a:1}

上面的代码看上去没有任何问题,对不对?考虑到我们添加如下代码:

o.a = 6;
showO()  // {a:6}

这个时候,再次调用showO()函数的时候,返回值竟然发生了变化,上面还明明在说闭包可以创建私有变量的啊!

这是因为o的引用同时存在与闭包的内部和外部,它的变化可以跨越看似私有的界限。这很容易导致混乱,所以通常情况下我们应当最大限度的减少捕获全局作用域的变量,来作为闭包的私有变量。

  • 在函数式编程中,闭包经常用于偏应用和柯里化

为了说明这个,我们先定义一些概念:

函数应用:将参数传给一个函数,并获得函数的返回值的过程。

偏应用:先传递给某个函数一部分的参数,然后返回一个新函数,该函数等待接收剩余的全部参数。

偏应用就是通过闭包作用于来进行提前赋予参数。关于偏应用和柯里化可以查看 partial application and curry