【译】终极指南:变量提升、作用域和闭包

3,503 阅读12分钟

说出来可能吓你一跳,在我看来,理解Javascript的最重要最基本的思路就是理解执行上下文。吃透了执行上下文,你就能更好地学习诸如变量提升、作用域链和闭包等进阶知识。说到这个,到底什么是“执行上下文”?为了更好理解,我们先来看一看我们是怎么写代码的。

编程的一个策略就是把代码拆分开。虽然那些拆开的“零件”有不同的名字(函数、方法、包等等),它们都是为了一个目的而存在——降低应用的复杂度,便于管理。现在,抛开开发者的思维,设想你是解析代码的Javascript引擎,这种情景下,我们能像写代码时候那样,用相同的策略拆分代码来解析代码吗?事实证明我们可以,这些“零件”就叫做执行上下文。就像函数/模块/包等能帮你进行复杂的开发,执行上下文帮助Javascript引擎管理整个解析和运行代码的复杂过程。那么现在我们了解了执行上下文的存在目的,下一个问题就是执行上下文是怎么创建的?它们由什么组成?

当Javascript引擎运行代码,第一个被创建的执行上下文叫做“全局执行上下文”。最初,这个全局上下文由这二位组成:一个全局对象和一个this变量。this引用的是全局对象,如果在浏览器中运行Javascript,那么这个全局对象就是window对象,如果在Node环境中运行,这个全局对象就是global对象。

从上图可以看出,即使没有任何代码,全局执行上下文中仍然有windowthis。这就是最基本的全局执行上下文。

让我们看看添加了代码会怎么样:

能看出上面两张图的区别吗?关键在于每个执行上下文有两个独立的阶段,一个是创建阶段,一个是执行阶段,每个阶段都有其各自职责。

在全局执行上下文的创建阶段,Javascript引擎会:

1. 创建一个全局对象;
2. 创建this对象;
3. 给变量和函数分配内存;
4. 给变量赋默认值undefined,把所有函数声明放进内存。

直到执行阶段,Javascript引擎才会一行一行地运行你的代码并执行它们。

通过下面的动图我们可以看到从创建阶段到执行阶段的流程:

在创建阶段,windowthis被创建出来,变量声明被设为默认值undefined,所有函数声明都被存入内存。一旦进入执行阶段,Javascript引擎就开始一行行执行代码,把内存中已经存在的变量赋予真实值。

动图确实很炫酷,但也不如你手敲一遍,亲自体会这个处理过程。你需要一个工具,所以我创建了Javascript Visualizer。如果你想过一遍例子中的代码,可以用这个链接

为了切实巩固创建阶段和执行阶段的概念,让我们在控制台打印一些处于创建之后执行之前的值来看看:

console.log('name: ', name)
console.log('handle: ', handle)
console.log('getUser :', getUser)

var name = 'Tyler'
var handle = '@tylermcginnis'

function getUser () {
  return {
    name: name,
    handle: handle
  }
}

在上面的代码中,你觉得控制台会打印出什么结果?当Javascript引擎开始逐行执行代码并调用console.log(),创建阶段就已经发生了。这意味着正如我们之前所见,变量声明早已被赋予了默认值undefined,同时函数声明已经在内存中就绪。在例子中,namehandle的值是undefinedgetUser也正是内存中的函数的引用。

console.log('name: ', name) // name: undefined
console.log('handle: ', handle) // handle: undefined
console.log('getUser :', getUser) // getUser: ƒ getUser () {}

var name = 'Tyler'
var handle = '@tylermcginnis'

function getUser () {
  return {
    name: name,
    handle: handle
  }
}

译者注:本人实操代码的结果与原作者的结果有出入,见下图:

且将变量name改为其他字符串,打印的结果如下

在创建阶段将变量声明赋予默认值的过程就叫做变量提升

是不是有恍然大悟的感觉?可能之前对变量提升的理解不是很清晰。关于变量提升让你困惑之处在于,没有谁真的被“提升”或者移动了。现在你理解了执行上下文,理解了变量声明在创建阶段被赋予默认值,那你就理解了“提升”,因为那完全就是字面意思。


此刻你应该对全局执行上下文和它的两个阶段一点都不感觉别扭了。好消息是,你只需再学习另一个执行上下文就够了,而且它和全局执行上下文几乎一样。它叫做函数执行上下文,当函数被调用,它就被创建出来了。

再重申一遍关键:仅当Javascript引擎首次开始解析代码(对应全局执行上下文)或当一个函数被调用时,才会创建执行上下文。

现在我们需要搞清楚的主要问题就是,全局执行上下文和函数执行上下文有什么区别。回想一下,我们之前学到过,在全局创建阶段,Javascript引擎会:

1. 创建一个全局对象;
2. 创建this对象;
3. 给变量和函数分配内存;
4. 给变量赋默认值undefined,把所有函数声明放进内存。

现在换成函数执行上下文,想想看,哪个步骤就对不上号了呢?对,就是第一步。我们有一个全局对象就够了,那就是在全局执行上下文的创建阶段所创建的那个,而不是每次函数调用都创建一个。函数执行上下文中应该创建的应该是arguments对象,所以当创建函数执行上下文时,Javascript引擎会:

1.创建一个全局对象

1.创建一个arguments对象;
2. 创建this对象;;
3. 给变量和函数分配内存;;
4. 给变量赋默认值undefined,把所有函数声明放进内存。

让我们回过头看看之前的代码,但这次我们不仅仅定义getUser,还要调用一次,看看实际效果是什么。

点这里实操

正如我们所说,当调用了getUser,就创建了新的执行上下文。在getUser执行上下文的创建阶段的创建阶段,Javascript引擎创建了this对象和arguments对象。getUser没有任何变量,所以Javascript引擎不需要再次分配内存或进行“提升”。

你可能注意到了,当getUser函数执行完毕,它就从视图中消失了。事实上,Javascript引擎创建了一个叫“执行栈”(也叫调用栈)的东西。每当函数被调用,就创建一个新的执行上下文并把它加入到调用栈;每当一个函数运行完毕,就被从调用栈中弹出来。因为Javascript是单线程的,通过Javascript Visualizer能看到,每一个新的执行上下文都嵌套在另一个中,形成了调用栈。


现在我们知道了函数调用是如何创建它们各自的执行上下文并放到调用栈中的。但我们没有看到局部变量是如何作用的,那就让我们来改写之前的代码,让函数拥有局部变量。

点这里实操

这里有几处重要细节需要注意。首先,传入函数的所有参数都作为局部变量存在于该函数的执行上下文中。在例子中,handle同时存在与全局执行上下文和getURL执行上下文中,因为我们把它传入了getURL函数做为参数。其次,在函数中声明的变量存在于函数的执行上下文中。所以当我们创建twitterURL,它就会存于getURL执行上下文中。这看起来显而易见,但却是我们下一个话题——作用域——的基础。


可能你以前就听说过作用域的定义“变量可访问之处”。不管当时你是如何理解的,现在结合你新学的知识和Javascript Visualizer工具,作用域这个概念会在你脑海里更清晰。MDN把作用域定义为“执行的当前上下文”。是不是耳熟?我们可以把作用域看作是“变量可访问之处”,正如我们理解执行上下文那样。

这里有一个小测试。下面代码中,打印出来的bar将会是什么?

function foo(){
    var bar='Declared in foo';
}
foo();
console.log(bar);

让我们到Javascript Visualizer中看看:

Javascript Visualizer

当我们调用了foo,就在调用栈中新增了一个执行上下文。在其创建阶段,产生了thisargumentsbar被设为undefined。然后到了执行阶段,把字符串'Declare in foo'赋予bar。到这里执行阶段就结束了,foo执行上下文从调用栈弹出。foo弹出后,代码就运行到了打印bar到控制台的部分。此刻,根据Javascript Visualizer所展示的状态,bar似乎根本不存在,因此我们得到的是undefined。(译者注:实际上运行这个例子会报错:Uncaught ReferenceError: bar is not defined)这告诉我们,在函数中创建的变量,它的作用域是局部的。这意味着(通常如此,后面会讲例外)一旦函数的执行上下文从调用栈弹出,该函数中声明的变量就访问不到了。

再看一个例子。代码执行完毕后控制台会打印出什么?

function first(){
    var name='Jordyn';
    console.log(name);
}
	
function second(){
    var name='Jake';
	console.log(name);
}
	
console.log(name);
	
var name='Tyler';
first();
second();
console.log(name);

点这里实操

控制台会依次打印出undefinedJordynJakeTyler。你可以这么想:每个新的执行上下文都有它自己的变量环境。就算另有其他执行上下文包含变量name,Javascript引擎仍会先从当前执行上下文里找起。

这就带来一个问题,要是当前执行上下文里没有要找的变量呢?Javascript会就此罢手吗?下面的例子里有答案。

var name='Tyler';
function logName(){
	console.log(name);
}
logName();

点这里实操

你的直觉可能会是:既然在logName的执行上下文中找不到name变量,那肯定打印出undefined。其实不然。如果Javascript引擎在函数执行上下文找不到匹配的局部变量,它会到最接近的父级上下文中查找。这条查找链会一直延伸到全局执行上下文。如果此时仍然找不到该变量,Javascript引擎就会抛出一个引用错误。

每逢当前执行上下文中找不到所需变量,Javascript引擎就向上逐级查找,这个处理过程就是作用域链。Javascript Visualizer通过把每个执行上下文表示为不同颜色的区域并按层级缩进,来描述作用域链。你能直观体会到,子级执行上下文可以引用父级执行上下文中声明的变量,但反过来就不行。


之前我们了解到函数中创建的变量仅局部有效,一旦函数执行上下文从调用栈弹出,这些变量就访问不到了(通常如此)。现在是时候研究一下不在“通常如此”范围的情况了。如果你在一个函数中嵌入了另一个函数,例外情况就产生了。这种函数套函数的情况下,即使父级函数的执行上下文从调用栈弹出了,子级函数仍然能够访问父级函数的作用域。啰嗦了一堆,我们还是用Javascript Visualizer看看吧。

var count=0;
function makeAdder(x){
    return function inner(y){
        return x+y;
    }
}
var add5=makeAdder(5);
count+=add5(2);

点这里实操

注意,makeAdder执行上下文从调用栈弹出后,Javascript Visualizer创建了一个Closure Scope(闭包作用域)Closure Scope中的变量环境和makeAdder执行上下文中的变量环境相同。这是因为我们在函数中嵌入了另一个函数。在本例中,inner函数嵌在makeAdder中,所以innermakeAdder变量环境的基础上创建了一个闭包。因为闭包作用域的存在,即使makeAdder已经从调用栈弹出了,inner仍然能够访问到x变量(通过作用域链)。

你可能已经猜到了,这种子函数在其父级函数的变量环境上“关闭”(译者注:原文为a child function “closing” over the variable environment of its parent function)的概念,就叫做闭包


福利部分

下面是一些相关话题,我知道如果我不提及的话,肯定会有人揪我出来补充。

全局变量

在浏览器中,你在全局执行上下文中(不被任何函数包裹)创建的变量,都会成为window对象的属性。

在浏览器和Node环境中,如果你不声明(比如使用var/let/const)就直接创建了一个变量,这个变量同样会成为全局对象的属性。

// In the browser
var name = 'Tyler'

function foo () {
  bar = 'Created in foo without declaration'
}

foo()

console.log(window.name) // Tyler
console.log(window.bar) // Created in foo without declaration

let和const

戳这里看视频

this

在本文中,我们了解到,每个执行上下文的创建阶段中,Javascript引擎都会创建一个叫做this的对象。如果你想深入学习关于this的知识,我建议你读读WTF is this - Understanding the this keyword, call, apply, and bind in JavaScript