前端核心知识3:闭包(1)




作用域

作用域其实就是一套规则:这个规则用于确定在特定场景下如何查找变量。任何语言都有作用域的概念,同一种语言在演进过程中也会不断完善其作用域规则。比如,在 JavaScript 中,ES6 出现之前只有函数作用域和全局作用域之分。



函数作用域和全局作用域

大家应该非常熟悉函数作用域了
function foo() { var a = 'bar'; console.log(a);}
foo(); // bar

执行 foo 函数时,变量 a 在函数 foo 作用域内,函数体内可以正常访问,并输出 bar。
而当:
var b = 'bar';function foo() {  console.log(b);}foo(); // bar
执行这段代码时,foo 函数在自身函数作用域内并未查找到 b 变量,但是它会继续向外扩大查找范围,因此可以在全局作用域中找到变量 b,输出 bar。
如果我们稍加改动:
function bar() {    var b = 'bar';}
function foo() { console.log(b);}
foo();

执行这段代码时,foo 和 bar 分属于两个彼此独立的函数作用域,foo 函数无法访问 bar 函数中定义的变量 b,且其作用域链内(上层全局作用域中)也不存在相应的变量,因此报错:Uncaught ReferenceError: b is not defined。

总结一下:在 JavaScript 执行一段函数时,遇见变量读取其值,这时候会「就近」先在函数内部找该变量的声明或者赋值情况。这里涉及「变量声明方式」以及「变量提升」的知识点,我们后面会涉及到。如果在函数内无法找到该变量,就要跳出函数作用域,到更上层作用域中查找。这里的「更上层作用域」可能也是一个函数作用域,例如:
function bar() { var b = 'bar'; function foo() { console.log(b); } foo();}
bar(); // bar

在 foo 函数执行时,对于变量 b 的声明或读值情况是在其上层函数 bar 作用域中获取的。
同时「更上层作用域」也可以顺着作用域范围向外扩散,一直找到全局作用域:
var b = 'bar';function bar() { function foo() { console.log(b); } foo();}
bar(); // bar
块级作用域和暂时性死区

作用域概念不断演进,ES6 增加了 let 和 const 声明变量的块级作用域,使得 JavaScript 中作用域范围更加丰富。块级作用域,顾名思义,作用域范围限制在代码块中,这个概念在其他语言里也普遍存在。当然这些新特性的添加,也增加了一定的复杂度,带来了新的概念,比如暂时性死区。这里有必要稍作展开:说到暂时性死区,还需要从「变量提升」说起,参看以下代码:
function foo() { console.log(bar); var bar = 3;}foo(); // undefined
会输出:undefined,原因是变量 bar 在函数内进行了提升。相当于:
function foo() { var bar; console.log(bar); bar = 3;}foo(); // undefined
但在使用 let 声明时:
function foo() { console.log(bar); let bar = 3;}foo(); // 报错
会报错:Uncaught ReferenceError: bar is not defined。

我们知道使用 let 或 const 声明变量,会针对这个变量形成一个封闭的块级作用域,在这个块级作用域当中,如果在声明变量前访问该变量,就会报 referenceError 错误;如果在声明变量后访问,则可以正常获取变量值:
function foo() { let bar = 3; console.log(bar);}foo(); // 3
正常输出 3。因此在相应花括号形成的作用域中,存在一个「死区」,起始于函数开头,终止于相关变量声明的一行。在这个范围内无法访问 letconst 声明的变量。这个「死区」的专业名称为:TDZ(Temporal Dead Zone)



执行上下文和调用栈

很多读者可能无法准确定义执行上下文和调用栈,其实,从我们接触 JavaScript 开始,这两个概念便常伴左右。我们写出的每一行代码,每一个函数都和它们息息相关,但它们却是「隐形」的,藏在代码背后,出现在 JavaScript 引擎里。这一小节,我们来剖析一下这两个熟悉但又经常被忽视的概念。

执行上下文就是当前代码的执行环境/作用域,和前文介绍的作用域链相辅相成,但又是完全不同的两个概念。直观上看,执行上下文包含了作用域链,同时它们又像是一条河的上下游:有了作用域链,才有了执行上下文的一部分。

代码执行的两个阶段

理解这两个概念,要从 JavaScript 代码的执行过程说起,这在平时开发中并不会涉及,但对于我们理解 JavaScript 语言和运行机制非常重要,请各位细心阅读。JavaScript 执行主要分为两个阶段:
  • 代码预编译阶段
  • 代码执行阶段

预编译阶段是前置阶段,这个时候由编译器将 JavaScript 代码编译成可执行的代码。 注意,这里的预编译和传统的编译并不一样,传统的编译非常复杂,涉及分词、解析、代码生成等过程 。这里的预编译是 JavaScript 中独特的概念,虽然 JavaScript 是解释型语言,编译一行,执行一行。但是在代码执行前,JavaScript 引擎确实会做一些「预先准备工作」。

执行阶段主要任务是执行代码,执行上下文在这个阶段全部创建完成。

在通过语法分析,确认语法无误之后,JavaScript 代码在预编译阶段对变量的内存空间进行分配,我们熟悉的变量提升过程便是在此阶段完成的。

经过预编译过程,我们应该注意三点:
  • 预编译阶段进行变量声明;
  • 预编译阶段变量声明进行提升,但是值为 undefined;
  • 预编译阶段所有非表达式的函数声明进行提升。

请看下面这道题目:

function bar() { console.log('bar1');}
var bar = function () { console.log('bar2');};
bar(); // bar2
输出:bar2,我们调换顺序:
var bar = function () { console.log('bar2');};function bar() { console.log('bar1');}
bar(); // bar2

仍然输出:bar2,因为在预编译阶段变量 bar 进行声明,但是不会赋值;函数 bar 则进行创建并提升。在代码执行时,变量 bar 才进行(表达式)赋值,值内容是函数体为 console.log('bar2') 的函数,输出结果 bar2。
请再思考这道题:
foo(10);function foo(num) { console.log(foo); foo = num; console.log(foo); var foo;}
console.log(foo);foo = 1;console.log(foo);
// undefined// 10// ƒ foo (num) {// console.log(foo)// foo = num // console.log(foo)// var foo// }// 1

在 foo(10) 执行时,函数体内进行变量提升后,函数体内第一行输出 undefined,函数体内第三行输出 foo。接着运行代码,到了整体第 8 行,console.log(foo) 输出 foo 函数内容(因为 foo 函数内的 foo = num,将 num 赋值给的是函数作用域内的 foo 变量。)

结论:作用域在预编译阶段确定,但是作用域链是在执行上下文的创建阶段完全生成的。因为函数在调用时,才会开始创建对应的执行上下文。执行上下文包括了:变量对象、作用域链以及 this 的指向

代码执行的整个过程说起来就像一条生产流水线。第一道工序是在预编译阶段创建变量对象(Variable Object),此时只是创建,而未赋值。到了下一道工序代码执行阶段,变量对象转为激活对象(Active Object),即完成 VO → AO。此时,作用域链也将被确定,它由当前执行环境的变量对象和所有外层已经完成的激活对象组成。这道工序保证了变量和函数的有序访问,即如果当前作用域中未找到变量,则继续向上查找直到全局作用域。

这样的工序在流水线上串成一个整体,这便是 JavaScript 引擎执行机制的最基本道理。



调用栈

了解了上面的内容,函数调用栈便很好理解了。我们在执行一个函数时,如果这个函数又调用了另外一个函数,而这个「另外一个函数」也调用了「另外一个函数」,便形成了一系列的调用栈。如下代码:
function foo1() { foo2();}
function foo2() { foo3();}
function foo3() { foo4();}
function foo4() { console.log('foo4');}
foo1();
调用关系:foo1 → foo2 → foo3 → foo4。这个过程是 foo1 先入栈,紧接着 foo1 调用 foo2,foo2入栈,以此类推,foo3、foo4,直到 foo4 执行完 —— foo4 先出栈,foo3 再出栈,接着是 foo2 出栈,最后是 foo1 出栈。这个过程「先进后出」(「后进先出」),因此称为调用栈。

注意:正常来讲,在函数执行完毕并出栈时,函数内局部变量在下一个垃圾回收节点会被回收,该函数对应的执行上下文将会被销毁,这也正是我们在外界无法访问函数内定义的变量的原因。也就是说,只有在函数执行时,相关函数可以访问该变量,该变量在预编译阶段进行创建,在执行阶段进行激活,在函数执行完毕后,相关上下文被销毁。