对于 x 年教训的前端仔来说,我的项目也做了好些个了,各个场景也接触过一些。然而假如真的要跟面试官敞开来撕原理,还是有点慌的。看到很多大神都在手撕各种框架原理还是有点艳羡他们的技术实力,艳羡不如口头,先踏踏实实啃根底。嗯 … 明天来聊聊闭包!
讲闭包的文章可能大家都看了几十篇了吧,而且也能发现,一些文章(我没说全副)行文都是一个套路,基本上都在关注两个点,什么是闭包,闭包举例,很有搬运工的嫌疑。我看了这些文章之后,一个很大的感触是:如果让我给他人解说闭包这个知识点,我能说得分明吗?我的根据是什么?可信度有多大?我感觉我是狐疑我本人的,否定三连预计是妥了。
不同的阶段做不同的事,当有一些根底后,咱们还是能够适当地钻研下原理,不要浮在问题外表!那么技术水平个别的咱们,应该怎么办,怎么从这些芜杂的文章中解围?我感觉一个方法是从一些比拟权威的文档下来找线索,比方 ES 标准,MDN,维基百科等。
对于 闭包(closure),总是有着不同的解释。
第一种说法是,闭包是由 函数 以及申明该函数的 词法环境 组合而成的。这个说法来源于 MDN- 闭包。
另外一种说法是,闭包是指有权拜访另外一个函数作用域中的变量的函数。
从我的了解来看,我认为第一个说法是正确的,闭包不是一个函数,而是函数和词法环境组成的。那么第二种说法对不对呢?我感觉它说对了一半,在闭包场景下,的确存在一个函数有权拜访另外一个函数作用域中的变量,但闭包不是函数。
这就完了吗?显然不是!解读闭包,这次咱们刨根究底(吹下牛逼)!
本文会间接从 ECMAScript5 标准 动手解读 JS 引擎的局部外部实现逻辑,基于这些认知再来从新扫视 闭包。
回到主题,上文提到的 词法环境(Lexical Environment)到底是什么?
词法环境
咱们能够看看 ES5 标准第十章(可执行代码和执行上下文)中的第二节词法环境是怎么说的。
A Lexical Environment is a specification type used to define the association of Identifiers to specific variables and functions based upon the lexical nesting structure of ECMAScript code.
词法环境是一种标准类型(specification type),它定义了标识符和 ECMAScript 代码中的特定变量及函数之间的分割。
问题来了,标准类型(specification type)又是什么?specification type 是 Type 的一种。从 ES5 标准中能够看到 Type 分为 language types 和specification types两大类。
language types 是语言类型,咱们熟知的类型,也就是应用 ECMAScript 的程序员们能够操作的数据类型,包含 Undefined
, Null
, Number
, String
, Boolean
和Object
。
而标准类型(specification type)是一种更形象的 元值(meta-values),用于在算法中形容 ECMAScript 的语言构造和语言类型的具体语义。
A specification type corresponds to meta-values that are used within algorithms to describe the semantics of ECMAScript language constructs and ECMAScript language types.
至于元值是什么,我感觉能够了解为 元数据,而元数据是什么意思,能够简略看看这篇知乎什么是元数据?为何须要元数据?
总的来说,元数据是用来形容数据的数据。这一点就能够类比于,高级语言总要用一个更底层的语言和数据结构来形容和表白。这也就是 JS 引擎干的事件。
大抵了解了标准类型是什么后,咱们未免要问下:标准类型(specification type)蕴含什么?
The specification types are Reference, List, Completion, Property Descriptor, Property Identifier, Lexical Environment, and Environment Record.
看到这里我好似明确了些什么,原来 词法环境 (Lexical Environment)和 环境记录 (Environment Record)都是一种 标准类型(specification type),果然是更底层的概念。
先抛开 List
, Completion
, Property Descriptor
, Property Identifier
等标准类型不说,咱们接着看词法环境(Lexical Environment)这种标准类型。
上面这句解释了词法环境到底蕴含了什么内容:
A Lexical Environment consists of an Environment Record and a possibly null reference to an outer Lexical Environment.
词法环境蕴含了一个 环境记录 (Environment Record)和一个 指向内部词法环境的援用,而这个援用的值可能为 null。
一个词法环境的构造如下:
Lexical Environment
+ Outer Reference
+ Environment Record
Outer Reference 指向内部词法环境,这也阐明了词法环境是一个链表构造。简略画个结构图帮忙了解下!
Usually a Lexical Environment is associated with some specific syntactic structure of ECMAScript code such as a FunctionDeclaration, a WithStatement, or a Catch clause of a TryStatement and a new Lexical Environment is created each time such code is evaluated.
通常,词法环境与 ECMAScript 代码的某些特定语法结构(如 FunctionDeclaration,WithStatement 或TryStatement的 Catch 子句)相关联,并且每次评估此类代码时都会创立一个新的词法环境。
PS:evaluated 是 evaluate 的过去分词,从字面上解释就是评估,而评估代码我感觉不是很好了解。我集体的了解是,评估代码代表着JS 引擎在解释执行 javascript 代码。
咱们晓得,执行函数会创立新的词法环境。
咱们也认同,with 语句会“缩短”作用域(实际上是调用了 NewObjectEnvironment,创立了一个新的词法环境,词法环境的环境记录是一个对象环境记录)。
以上这些是咱们比拟好了解的。那么 catch 子句对词法环境做了什么?尽管 try-catch 平时用得还比拟多,然而对于词法环境的细节很多人都不会留神到,包含我!
咱们晓得,catch 子句会有一个谬误对象e
function test(value) {
var a = value;
try {console.log(b);
// 间接援用一个不存在的变量,会报 ReferenceError
} catch(e) {console.log(e, arguments, this)
}
}
test(1);
在 catch
子句中打印 arguments
,只是为了证实catch
子句不是一个函数。因为如果 catch
是一个函数,显然这里打印的 arguments
就不应该是 test
函数的 arguments
。既然catch
不是一个函数,那么凭什么能够有一个仅限在 catch
子句中被拜访的谬误对象e
?
答案就是 catch
子句应用 NewDeclarativeEnvironment 创立了一个新的词法环境(catch 子句中词法环境的内部词法环境援用指向函数 test 的词法环境),而后通过 CreateMutableBinding 和 SetMutableBinding 将标识符 e 与新的词法环境的环境记录关联上。
有人会说,for
循环中的 initialization
局部也能够通过 var
定义变量,和 catch
子句有什么本质区别吗?要留神的是,在 ES6 之前是没有块级作用域的。在 for
循环中通过 var
定义的变量原则上归属于所在函数的词法环境。如果 for
语句不是用在函数中,那么其中通过 var
定义的变量就是属于全局环境(The Global Environment)。
with 语句和 catch 子句中建设了新的词法环境 这一论断,证据来源于上文中一句话“a new Lexical Environment is created each time such code is evaluated.”具体细节也能够看看 12.10 The with Statement 和 12.14 The try Statement。
Environment Record
理解了词法环境(Lexical Environment),接下来就说说词法环境中的 环境记录 (Environment Record) 吧。环境记录与咱们应用的变量,函数非亲非故,能够说环境记录是它们的底层实现。
标准形容环境记录的内容太长,这儿就不全副复制了,请间接关上 ES5 标准第 10.2.1 节浏览。
There are two kinds of Environment Record values used in this specification: declarative environment records and object environment records. // 省略一大段
从标准中咱们能够看到环境记录 (Environment Record) 分为两种:
- declarative environment records 申明式环境记录
- object environment records 对象环境记录
ECMAScript 标准束缚了申明式环境记录和对象环境记录都必须实现环境记录类的一些公共的形象办法,即使他们在具体实现算法上可能不同。
这些公共的形象办法有:
- HasBinding(N)
- CreateMutableBinding(N, D)
- SetMutableBinding(N,V, S)
- GetBindingValue(N,S)
- DeleteBinding(N)
- ImplicitThisValue()
申明式环境记录还应该实现两个特有的办法:
- CreateImmutableBinding(N)
- InitializeImmutableBinding(N,V)
对于不可变绑定(ImmutableBinding),在标准中有这么一段比拟粗疏的场景形容:
If strict is true, then Call env’s CreateImmutableBinding concrete method passing the String “arguments” as the argument.
Call env’s InitializeImmutableBinding concrete method passing “arguments” and argsObj as arguments.
Else,Call env’s CreateMutableBinding concrete method passing the String “arguments” as the argument.
Call env’s SetMutableBinding concrete method passing “arguments”, argsObj, and false as arguments.
也就是说,只有严格模式下,才会对函数的 arguments 对象应用不可变绑定。利用了不可变绑定(ImmutableBinding)的变量意味着不能再被从新赋值,举个例子:
非严格模式下能够扭转 arguments 的指向:
function test(a, b) {arguments = [3, 4];
console.log(arguments, a, b)
}
test(1, 2)
// [3, 4] 1 2
而在严格模式下,扭转 arguments 的指向会间接报错:
"use strict";
function test(a, b) {arguments = [3, 4];
console.log(arguments, a, b)
}
test(1, 2)
// Uncaught SyntaxError: Unexpected eval or arguments in strict mode
要留神,我这里说的是 扭转 arguments 的指向 ,而不是 批改 arguments。arguments[2] = 3
这种操作在严格模式下是不会报错的。
所以不可变绑定(ImmutableBinding)束缚的是援用不可变,而不是束缚援用指向的对象不可变。
declarative environment records
在咱们应用 变量申明 , 函数申明 ,catch 子句 时,就会在 JS 引擎中建设对应的申明式环境记录,它们间接将 identifier bindings 与 ECMAScript 的 language values 关联到一起。
object environment records
对象环境记录(object environment records),蕴含 Program, WithStatement,以及前面说到的全局环境的环境记录。它们将 identifier bindings 与某些对象的属性关联到一起。
看到这里,我本人就想问下:identifier bindings是啥?
看了 ES5 标准中提到的环境记录 (Environment Record) 的形象办法后,我有了一个大抵的答案。
先简略看一下 javascript 变量取值和赋值的过程:
var a = 1;
console.log(a);
咱们在给变量 a
初始化并赋值 1
的这样一个步骤,其实体现在 JS 引擎中,是执行了CreateMutableBinding(创立可变绑定)和SetMutableBinding(设置可变绑定的值)。
而在对变量 a
取值时,体现在 JS 引擎中,是执行了GetBindingValue(获取绑定的值),这些执行过程中会有一些断言和判断,也会牵涉到严格模式的判断,具体见 10.2.1.1 Declarative Environment Records。
这里也省略了一些步骤,比如说GetIdentifierReference, GetValue(V), PutValue(V) 等。
按我的了解,identifier bindings 就是 JS 引擎中保护的一组 绑定关系 ,能够与 javascript 中的 标识符 关联起来。
The Global Environment
全局环境(The Global Environment)是一个非凡的词法环境,在 ECMAScript 代码执行之前就被创立。全局环境中的环境记录 (Environment Record) 是一个对象环境记录(object environment record),它被绑定到一个 全局对象 (Global Object)上,体现在 浏览器环境 中,与 Global Object 关联的就是window 对象。
全局环境是一个 顶层的词法环境,因而全局环境不再有内部词法环境,或者说它的内部词法环境的援用是 null。
在 15.1 The Global Object 一节也解释了 Global Object 的一些细节,比方为什么不能new Window()
,为什么在不同的宿主环境中全局对象会有很大区别 ……
执行上下文
看了这些咱们还是没有一个全盘的把握去解读 闭包 ,不如接着看看 执行上下文。在我之前的了解中,上下文应该是一个环境,蕴含了代码可拜访的变量。当然,这显然还不够全面。那么上下文到底是什么?
When control is transferred to ECMAScript executable code, control is entering an execution context. Active execution contexts logically form a stack. The top execution context on this logical stack is the running execution context.
当程序控制转移到 ECMAScript 可执行代码(executable code)时,就进入了一个执行上下文(execution context),执行上下文是一个逻辑上的 堆栈构造(Stack)。堆栈中最顶层的执行上下文就是正在运行的执行上下文。
很多人对 可执行代码 可能又有纳闷了,javascript 不都是可执行代码吗?不是的,比方 正文 (Comment), 空白符(White Space)就不是可执行代码。
An execution context contains whatever state is necessary to track the execution progress of its associated code.
执行上下文蕴含了一些状态(state),这些状态用于跟踪与之关联的代码的执行过程。每个执行上下文都有这些 状态组件(Execution Context State Components)。
- LexicalEnvironment:词法环境
- VariableEnvironment:变量环境
- ThisBinding:与执行上下文间接关联的 this 关键字
执行上下文的创立
咱们晓得,解释执行 global code 或应用 eval function,调用函数都会创立一个新的执行上下文,执行上下文是堆栈构造。
When control enters an execution context, the execution context’s ThisBinding is set, its VariableEnvironment and initial LexicalEnvironment are defined, and declaration binding instantiation (10.5) is performed. The exact manner in which these actions occur depend on the type of code being entered.
当控制程序进入执行上下文时,会产生上面这 3 个动作:
- this 关键字的值被设置。
- 同时 VariableEnvironment(不变的)和 initial LexicalEnvironment(可能会变,所以这里说的是 initial)被定义。
- 而后执行申明式绑定初始化操作。
以上这些动作的执行细节取决于代码类型(分为 global code, eval code, function code 三类)。
PS:通常状况下,VariableEnvironment 和 LexicalEnvironment 在初始化时是统一的,VariableEnvironment 不会再发生变化,而 LexicalEnvironment 在代码执行的过程中可能会变动。
那么进入 global code,eval code,function code 时,执行上下文会产生什么不同的变动呢?感兴趣的能够仔细阅读下 10.4 Establishing an Execution Context。
词法环境的链表构造
回顾一下上文,上文中提到,词法环境是一个链表构造。
家喻户晓,在了解闭包的时候,很多人都会提到 作用域链(Scope Chain)这么一个概念,同时会引出VO(变量对象)和AO(流动对象)这些概念。然而我在浏览 ECMAScript 标准时,通篇没有找到这些关键词。我就在想,词法环境的链表构造是不是他们说的作用域链?VO,AO 是不是曾经过期的概念?然而这些概念又如同成了“权威”,一搜相干的文章,都在说 VO, AO,我真的也要这样去了解吗?
在 ECMAScript 中,找到 8.6.2 Object Internal Properties and Methods 一节中的 Table 9 Internal Properties Only Defined for Some Objects,确实存在[[Scope]] 这么一个外部属性,依照 Scope 单词的意思,[[Scope]]不就是函数作用域嘛!
在这个 Table 中,咱们能够明确看到 [[Scope]] 的 Value Type Domain 一列的值是 Lexical Environment,这阐明[[Scope]] 就是一种 词法环境。咱们接着看看 Description:
A lexical environment that defines the environment in which a Function object is executed. Of the standard built-in ECMAScript objects, only Function objects implement [[Scope]].
认真看下,[[Scope]]是 函数对象被执行时所在的环境 ,而且只有函数实现了[[Scope]] 属性,这意味着 [[Scope]] 是函数特有的属性。
所以,我是不是能够了解为:作用域链(Scope Chain)就是 函数执行时能拜访的词法环境链。而狭义上的词法环境链表不仅蕴含了作用域链,还包含 WithStatement 和 Catch 子句中的词法环境,甚至蕴含 ES6 的 Block-Level 词法环境。这么看来,ECMAScript 是十分谨严的!
而 VO,AO 这两个绝对古老的概念,因为没有官网的解释,所以基本上是“一千个读者,一千个哈姆雷特”了,我感觉可能这样了解也行:
- VO 是词法剖析(Lexical Parsing)阶段的产物
- AO 是代码执行(Execution)阶段的产物
ES5 及 ES6 标准中是没有这样的字眼的,所以罗唆忘掉 VO, AO 吧!
闭包
什么是闭包?
文章最开始提到了 闭包是由函数和词法环境组成。这里再援用一段维基百科的闭包解释佐证下。
在计算机科学中,闭包(英语:Closure),又称词法闭包(Lexical Closure)或函数闭包(function closures),是在反对头等函数的编程语言中实现词法绑定的一种技术。闭包在实现上是一个构造体,它存储了一个函数(通常是其入口地址)和一个关联的环境(相当于一个符号查找表)。环境里是若干对符号和值的对应关系,它既要包含束缚变量(该函数外部绑定的符号),也要包含自在变量(在函数内部定义但在函数内被援用),有些函数也可能没有自在变量。闭包跟函数最大的不同在于,当捕获闭包的时候,它的自在变量会在捕获时被确定,这样即使脱离了捕获时的上下文,它也能照常运行。
这是站在计算机科学的角度解释什么是闭包,当然这同样实用于 javascript!
外面提到了一个词“自在变量”,也就是闭包词法环境中咱们重点关注的变量。
Chrome 如何定义闭包?
Chrome 浏览器仿佛曾经成为了前端的规范,那么在 Chrome 浏览器中,是如何断定闭包的呢?无妨来摸索下!
function test() {
var a = 1;
function increase() {
debugger;
var b = 2;
a++;
return a;
};
increase();}
test();
我把 debugger 置于外部函数 increase
中,调试时咱们间接看右侧的高亮局部,能够发现,Scope 中存在一个 Closure(闭包),Closure 的名称是内部函数 test
的函数名,闭包中的变量 a
是在函数 test
中定义的,而变量 b
是作为本地变量处于 Local
中。
PS: 对于本地变量,能够参见 localEnv。
假如我在内部函数 test
中再定义一个变量 c
,然而在外部函数increase
中不援用它,会怎么样呢?
function test() {
var a = 1;
var c = 3; // c 不在闭包中
function increase() {
debugger;
var b = 2;
a++;
return a;
};
increase();}
test();
教训证,外部函数 increase
执行时,变量 c
没有在闭包中。
咱们还能够验证,如果外部函数 increase
不援用任何内部函数 test
中的变量,就不会产生闭包。
所以到这里,咱们能够下这样一个论断,闭包产生的必要条件 是:
- 存在函数嵌套;
- 嵌套的外部函数必须援用在内部函数中定义的变量;
- 嵌套的外部函数必须被执行。
面试官最喜爱问的闭包
在面试过程中,咱们通常被问到的闭包场景是:外部函数援用了内部函数的变量,并且作为内部函数的返回值。这是一种非凡的闭包,举个例子看下:
function test() {
var a = 1;
function increase() {a++;};
function getValue() {return a;}
return {
increase,
getValue
}
}
var adder = test();
adder.increase(); // 自增 1
adder.getValue(); // 2
adder.increase();
adder.getValue(); // 3
在这个例子中,咱们发现,每调用一次 adder.increase()
办法后,a
的值会就会比上一次减少 1
,也就是说,变量a
被放弃在内存中没有被开释。
那么这种景象背地到底是怎么回事呢?
闭包剖析
既然闭包波及到内存问题,那么不得不提一嘴 V8 的 GC(垃圾回收)机制。
咱们从书本上理解最多的 GC 策略就是援用计数,然而古代支流 VM(包含 V8, JVM 等)都不采纳援用计数的回收策略,而是采纳可达性算法。
援用计数让人比拟容易了解,所以常见于教材中,然而可能存在对象互相援用而无奈开释其内存的问题。而可达性算法是从 GC Roots 对象(比方全局对象 window)开始进行搜寻存活(可达)对象,不可达对象会被回收,存活对象会经验一系列的解决。
对于 V8 GC 的一些算法细节,有一篇文章讲得特地好,作者是洗影,十分倡议去看看,已附在文末的参考资料中。
而在咱们关注的这种非凡闭包场景下,之所以闭包变量会放弃在内存中,是因为闭包的词法环境没有被开释。咱们先来剖析下执行过程。
function test() {
var a = 1;
function increase() {a++;};
function getValue() {return a;}
return {
increase,
getValue
}
}
var adder = test();
adder.increase();
adder.getValue();
- 初始执行 global code,创立全局执行上下文,随之设置
this
关键词的值为window
对象,创立全局环境(Global Environment)。全局对象下有adder
,test
等变量和函数申明。
- 开始执行
test
函数,进入test
函数执行上下文。在test
函数执行过程中,申明了变量a
,函数increase
和getValue
。最终返回一个对象,该对象的两个属性别离援用了函数increase
和getValue
。
- 退出
test
函数执行上下文,test
函数的执行后果赋值给变量adder
,以后执行上下文复原成全局执行上下文。
- 调用
adder
的increase
办法,进入increase
函数的执行上下文,执行代码使变量a
自增1
。
- 退出
increase
函数的执行上下文。 - 调用
adder
的getValue
办法,其过程与调用increase
办法的过程相似。
对整个执行过程有了肯定意识后,咱们仿佛也很难解释为什么闭包中的变量 a
不会被 GC 回收。只有一个事实是很分明的,那就是每次执行 increase
和getValue
办法时,都依赖函数 test
中定义的变量a
,但仅凭这个事实作为理由显然也是不具备说服力。
这里无妨抛出一个问题,代码是如何解析 a
这个标识符的呢?
通过浏览标准,咱们能够晓得,解析标识符是通过 GetIdentifierReference(lex, name, strict)
,其中lex
是词法环境,name
是标识符名称,strict
是严格模式的布尔型标记。
那么在执行函数 increase
时,是怎么解析标识符 a
的呢?咱们来剖析下!
- 首先,让
lex
的值为函数increase
的localEnv
(函数的本地环境),通过GetIdentifierReference(lex, name, strict)
在localEnv
中解析标识符a
。 - 依据 GetIdentifierReference 的执行逻辑,在
localEnv
并不能解析到标识符a
(因为a
不是在函数increase
中申明的,这很显著),所以会转到localEnv
的内部词法环境持续查找,而这个内部词法环境其实就是increase
函数的外部属性 [[Scope]](这一点我是从认真看了多遍标准定义得出的),也就是test
函数的localEnv
的“阉割版”。 - 回到执行函数
test
那一步,执行完函数test
后,函数test
中localEnv
中的其余变量的 binding 都能在后续 GC 的过程中被开释,唯独a
的 binding 不能被开释,因为还有其余词法环境(increase
函数的外部属性[[Scope]])会援用a
。 - 闭包的词法环境和函数
test
执行时的localEnv
是不一样的。函数test
执行时,其localEnv
会完完整整地从新初始化一遍,而退出函数test
的执行上下文后,闭包词法环境只保留了其环境记录中的一部分 bindings,这部分 bindings 会被其余词法环境援用,所以我称之为“阉割版”。
这里可能会有敌人提出一个疑难(我也这样问过我本人),为什么 adder.increase()
是在全局执行上下文中被调用,它执行时的内部词法环境依然是 test
函数的 localEnv
的“阉割版”?
这就要回到内部词法环境援用的定义了,内部词法环境援用指向的是 逻辑上突围外部词法环境的词法环境!
The outer reference of a (inner) Lexical Environment is a reference to the Lexical Environment that logically surrounds the inner Lexical Environment.
闭包的优缺点
网上的文章对于这一块还是讲得挺具体的,本文就不再举例了。总的来说,闭包有这么一些长处:
- 变量常驻内存,对于实现某些业务很有帮忙,比方计数器之类的。
- 架起了一座桥梁,让函数内部拜访函数外部变量成为可能。
- 私有化,肯定程序上解决命名抵触问题,能够实现公有变量。
闭包是双刃剑,也存在这么一个比拟显著的毛病:
- 存在这样的可能,变量常驻在内存中,其占用内存无奈被 GC 回收,导致内存溢出。
小结
本文从 ECMAScript 标准动手,一步一步揭开了闭包的神秘面纱。首先从闭包的定义理解到词法环境,从词法环境又引出环境记录,内部词法环境援用和执行上下文等概念。在对 VO, AO 等旧概念产生狐疑后,我抉择了从标准中寻找线索,最终有了脉络。解读闭包时,我寻找了多方材料,从计算机科学的闭包通用定义动手,将一些要害概念映射到 javascript 中,联合 GC 的一些知识点,算是有了答案。
写这篇文章花了不少工夫,因为波及到 ECMAScript 标准,一些形容必须主观谨严。解读过程必然存在主观成分,如有谬误之处,还望指出!
最初,十分倡议大家在有空的时候多多浏览 ECMAScript 标准。浏览语言标准是一个很好的解惑形式,能让咱们更好地了解一门语言的基本原理。就比方假如咱们不分明某个运算符的执行逻辑,那么间接看语言标准是最稳当的!
结尾附上一张能够帮忙你了解 ECMAScript 标准的图片。
如果不便的话,帮我点个赞哟,谢谢!欢送加我微信 laobaife
交换,技术会友,闲聊亦可。
参考资料
- ECMAScript 标准
- 维基百科:一等对象(First-class object)
- 维基百科:头等函数(first-class function)
- 维基百科:闭包)
- 解读 V8 GC Log(二): 堆内外内存的划分与 GC 算法
- 支流的垃圾回收机制都有哪些?
- V8 内存浅析
- 垃圾回收机制中,援用计数法是如何保护所有对象援用的?
- A tour of V8: Garbage Collection