🔥码云GVP开源项目 12k star Uniapp+ElementUI 功能强大 支持多语言、二开方便! 广告
[TOC] # 理解 JavaScript 作用域 ECMAScript 规范描述了所有JavaScript 代码都运行在一个执行上下文(execution context)中。 执行上下文在 JavaScript 中不是可访问的实体,但是了解它们对于全面理解函数和闭包的工作原理是至关重要的。 规范中说:“当控制权(control)转移至 ECMAScript 的可执行代码时,控制权进入一个执行上下文。活动的执行上下文逻辑上形成一个栈。栈顶的执行上下文是当前正在运行的执行上下文。” 在运行任何代码之前,JavaScript 引擎会创建一个全局对象,其初始化属性除了用户定义的属性外,还包含 ECMAScript 定义的内建对象(builts-ins),比如 Object、String、Array 和其他。浏览器的 JavaScript 实现提供了全局对象的一个属性,其本身也是全局对象,即 `window === window.window`。 每当一个函数被调用时,控制权会进入一个新的执行上下文。即使对一个函数的递归调用也是这样。 # 介绍 在解释过程中,JavaScript 引擎是严格按着作用域机制(scope)来执行的。JavaScript语法采用的是词法作用域(lexcical scope),也就是说 **JavaScript 的变量和函数作用域是在定义时决定的,而不是执行时决定的**,**由于词法作用域取决于源代码结构,所以 JavaScript解释器只需要通过静态分析就能确定每个变量、函数的作用域,这种作用域也称为静态作用域**(static scope)。补充:但需要注意,`with` 和 `eval` 的语义无法仅通过静态技术实现,实际上,只能说JS的作用域机制非常接近词法作用域。 # 静态作用域与动态作用域 作用域是指程序源代码中定义变量的区域。 作用域规定了如何查找变量,也就是确定当前执行代码对变量的访问权限。 **JavaScript 采用词法作用域(lexical scoping),也就是静态作用域**。 ## 词法作用域 因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了。 而与词法作用域相对的是动态作用域,函数的作用域是在函数调用的时候才决定的。 让我们认真看个例子就能明白之间的区别: ``` var value = 1; function foo() { console.log(value); // 在该作用域中使用的变量 value,没有在该作用域中声明(即在其他作用域中声明的),对于该作用域来说,value 就是一个**自由变量**。 } function bar() { var value = 2; foo(); } bar(); // 结果是 ??? ``` 假设JavaScript采用静态作用域,让我们分析下执行过程: 执行 foo 函数,先从 foo 函数内部查找是否有局部变量 value,如果没有,就根据书写的位置,查找上面一层的代码 ,(沿着作用域链到全局作用域中查找),也就是 value 等于 1,所以结果会打印 1。 假设JavaScript采用动态作用域,让我们分析下执行过程: 执行 foo 函数,依然是从 foo 函数内部查找是否有局部变量 value。如果没有,就从调用函数的位置所处作用域,也就是 bar 函数内部查找 value 变量,所以结果会打印 2。 前面我们已经说了,**JavaScript采用的是静态作用域,所以这个例子的结果是 1。** ## 动态作用域 也许你会好奇什么语言是动态作用域? bash就是动态作用域,不信的话,把下面的脚本存成例如 `scope.bash`,然后进入相应的目录,用命令行执行 `bash ./scope.bash`,看看打印的值是多少 ``` value=1 function foo () { echo $value; } function bar () { local value=2; foo; } bar ``` 这个文件也可以在 `demos/scope/` 中找到。 最后,让我们看一个《JavaScript权威指南》中的例子: ```js 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`。 # 闭包 **闭包包含了在创建函数时的作用域里面的所有变量**。任何时候你声明一个新的函数并将它赋值给一个变量,你存储了函数定义,同时存储了闭包。闭包它就像一个背包。一个函数定义时附带了一个小背包。在这个包里保存了 **所有在这个函数定义创建时的作用域中的拥有的变量**。 要记住的关键点就是当 **一个函数被声明时,它会同时包含一个函数定义和一个闭包**。 这个闭包是指在**这个函数创建出来时的作用域中的所有变量的集合**。 你可能会问,任何函数都有闭包吗,甚至在全局作用域创建的函数? 答案是有。在全局作用域创建的函数也会创建一个闭包。但是因为这些函数是在全局作用域被创建的,它们拥有所有全局作用域的变量的访问权限。这种情况下闭包的概念并没有什么意义。 当一个函数返回一个函数时,这才是让闭包概念变得有意义的时候。这个返回的函数拥有并不在全局作用域中的变量的访问权限,但他们是完全存在于闭包内的。 ## 闭包示例 有时候闭包在你完全没有注意到它的情况下出现。你可能在偏函数应用中已经看到过例子。就像下面的代码。 ```js let c = 4 const addX = x => n => n + x const addThree = addX(3) let d = addThree(c) console.log('example partial application', d) ``` 如果箭头函数让你难以理解,下面是等价的代码。 ```js let c = 4 function addX(x) { return function(n) { return n + x } } const addThree = addX(3) let d = addThree(c) console.log('example partial application', d) ``` 我们声明了一个常规的加法函数addX,它包含一个参数(x)并返回另一个函数。 返回的函数仍然有一个参数,并且将它加到变量x上。 变量x是闭包的一部分。当变量addTree在本地上下文中声明时,它被赋值了一个函数定义和一个闭包。这个闭包中含有变量x。 于是现在当addThree被调用并执行,它拥有对它的闭包中变量x的访问权限,并且将变量n作为参数传递进去给予它返回和值的能力。 在这个例子中,控制台会打印出数字7。 ## 结语 让我一直记住闭包的方式是通过把它比喻成**背包**。当一个函数创建、传递或从其他函数返回时。它会随身携带一个背包。所有在这个函数声明时的作用域中的变量都在这个背包里面。 # 作用域链(Scope Chain) 在JavaScript中,函数也是对象,实际上,JavaScript里一切都是对象。函数对象和其它对象一样,拥有可以通过代码访问的属性和一系列仅供JavaScript引擎访问的内部属性。其中一个内部属性是[[Scope]],由ECMA-262标准第三版定义,该内部属性包含了函数被创建的作用域中对象的集合,这个集合被称为函数的作用域链,它决定了哪些数据能被函数访问。 当一个函数创建后,它的作用域链会被创建此函数的作用域中可访问的数据对象填充。例如定义下面这样一个函数: ~~~ function add(num1,num2) { var sum = num1 + num2; return sum; } ~~~ 在函数`add`创建时,它的作用域链中会填入一个全局对象,该全局对象包含了所有全局变量,如下图所示(注意:图片只例举了全部变量中的一部分): ![](https://box.kancloud.cn/ae9c79ca193b8944c58b6c135204198b_696x193.png)   这些值按照它们出现在函数中的顺序被复制到运行期上下文的作用域链中。它们共同组成了一个新的对象,叫“活动对象(activation object)”,该对象包含了函数的所有局部变量、命名参数、参数集合以及this,**然后此对象会被推入作用域链的前端,当运行期上下文被销毁,活动对象也随之销毁**。新的作用域链如下图所示: ![](https://box.kancloud.cn/2d0c36e5959864a8b549ddbf01204fd5_696x413.png)   **函数执行过程中,每个标识符都要经历这样的搜索过程。过程从作用域链头部,也就是从活动对象开始搜索,查找同名的标识符。** ## 作用域链和代码优化 如上图所示,因为全局变量总是存在于运行期上下文作用域链的最末端,因此在标识符解析的时候,查找全局变量是最慢的。所以,在编写代码的时候应尽量少使用全局变量,尽可能使用局部变量。一个好的经验法则是:如果一个跨作用域的对象被引用了一次以上,则先把它存储到局部变量里再使用。 例如下面的代码,这个函数引用了两次全局变量`document`,查找该变量必须遍历整个作用域链,直到最后在全局对象中才能找到。这段代码可以重写如下: ~~~ function changeColor(){ var doc=document; doc.getElementById("btnChange").onclick=function(){ doc.getElementById("targetCanvas").style.backgroundColor="red"; }; } ~~~   这段代码比较简单,重写后不会显示出巨大的性能提升,但是如果程序中有大量的全局变量被从反复访问,那么重写后的代码性能会有显著改善。 ## 改变作用域链 函数每次执行时对应的运行期上下文都是独一无二的,所以多次调用同一个函数就会导致创建多个运行期上下文,当函数执行完毕,执行上下文会被销毁。每一个运行期上下文都和一个作用域链关联。**一般情况下,在运行期上下文运行的过程中,其作用域链只会被 with 语句和 catch 语句影响。** `with`语句是对象的快捷应用方式,用来避免书写重复代码。例如: ~~~ function initUI(){ with(document){ var bd=body, links=getElementsByTagName("a"), i=0, len=links.length; while(i < len){ update(links[i++]); } getElementById("btnInit").onclick=function(){ doSomething(); }; } } ~~~ 这里使用 `width` 语句来避免多次书写`document`,看上去更高效,实际上产生了性能问题。 当代码运行到`with`语句时,运行期上下文的作用域链临时被改变了。一个新的可变对象被创建,它包含了参数指定的对象的所有属性。这个对象将被推入作用域链的头部,这意味着函数的所有局部变量现在处于第二个作用域链对象中,因此访问代价更高了。如下图所示: ![`width` 语句](https://box.kancloud.cn/f4a045803d7b656fe2903ed49ccd519d_697x593.png) 因此在程序中应避免使用`with`语句,在这个例子中,只要简单的把`document`存储在一个局部变量中就可以提升性能。 另外一个会改变作用域链的是`try-catch`语句中的`catch`语句。当`try`代码块中发生错误时,执行过程会跳转到`catch`语句,然后把异常对象推入一个可变对象并置于作用域的头部。在`catch`代码块内部,函数的所有局部变量将会被放在第二个作用域链对象中。示例代码: ~~~ try{ doSomething(); }catch(ex){ alert(ex.message); //作用域链在此处改变 } ~~~ 请注意,一旦`catch`语句执行完毕,作用域链机会返回到之前的状态。`try-catch`语句在代码调试和异常处理中非常有用,因此不建议完全避免。你可以通过优化代码来减少`catch`语句对性能的影响。一个很好的模式是将错误委托给一个函数处理,例如: ~~~ try{ doSomething(); }catch(ex){ handleError(ex); //委托给处理器方法 } ~~~ 优化后的代码,`handleError`方法是`catch`子句中唯一执行的代码。该函数接收异常对象作为参数,这样你可以更加灵活和统一的处理错误。由于只执行一条语句,且没有局部变量的访问,作用域链的临时改变就不会影响代码性能了。 # 参考 https://github.com/mqyqingfeng/Blog/issues/17 [深入理解javascript作用域第二篇之词法作用域和动态作用域](https://www.jb51.net/article/89146.htm)