前言
作用域的定义相信大家都已经非常清楚,但作用域链那块的底层你了解吗,本文对作用域链的底层原理进行深入解析。本文先从作用域和执行上下文说起,再到作用域链,层层深入,最后补充闭包相关内容。
作用域的分类
作用域分为三种:
- 全局作用域
- 函数作用域
- 块级作用域(ES6新增)
其中A是全局作用域,B和C都是函数作用域。
全局作用域
我的理解是全局属性的作用域,也是全局方法(函数)的作用域。全局作用域没有用{ }包裹起来。
函数作用域
每创建一个函数,就相当于创建了一个对应的函数作用域。它的访问就是用{ }包裹起来的区域
块级作用域
ES6新增。简单理解也可以是用{ }包裹起来的区域。在if和for语句里面就很常见。
在ES6之前没有块级作用域的概念,变量都是用var来声明。举个例子:
var a = 666
console.log(a) // 666
复制代码
{
var a = 666
}
console.log(a) // 666
复制代码
变量a的声明和赋值放在{ }里面和外面是没有区别的。我的理解就是把{ }看成是透明的,所以两者是等价的。也就是说,无论你嵌套多少层,各种花式嵌套都一样,都视为无
ES6新增了块级作用域,同时新增let、const两种变量声明方式,对应着有块级作用域。举个例子:
{
let a = 666
}
console.log(a) // 报错:Uncaught ReferenceError: a is not defined
复制代码
但反过来是成立的:
let a = 666
{
console.log(a) // 666
}
复制代码
具体为什么这样是可以的,跟作用域链有关,见下方。
值得注意的是
var obj = {
a: 6
}
复制代码
obj也有对应的{ },但它是对象,跟块级作用域是两个完全不同的概念,不要混淆。
执行上下文(看成一块集合)
上面说到执行上下文我们举个例子形象了解一下执行上下文环境,其实很多同学(包括我哈哈哈)都混淆了作用域和执行上下文的概念,参考 JS作用域和执行上下文的区别 我对两者的分界有了初步的认识:
- 作用域:是
静态
的,在函数定义的时候就确定好的 - 执行上下文环境:是
动态
的,是在被调用时才确定的,每调用一次函数就会创建函数执行时对应的执行上下文环境。看个简单例子:
即使是同一个函数的多次调用,也会对应创建多个执行上下文环境
1 let a = 10 // 1、进入全局上下文环境
2 let bar = function(x) {
3 let b = 5
4 fn(x + b) // 3、进入fn函数上下文环境
5 }
6 let fn = function(y) {
7 let c = 5
8 console.log(y + c)
9 }
10
11 bar(10) // 2、进入bar函数上下文环境
复制代码
可以这么理解:执行上下文环境是一个键值对式的集合,各个变量和this组成执行上下文环境。我们细分代码执行的全过程:
(1)代码执行到第1行,创建了一个全局上下文环境,从第1行执行到第10行,将所有变量全都赋值(注意:此时变量a、函数bar、函数fn、this 4个就组成了“一家人”,绑定在一块了,无论bar函数、fn函数在哪个地方被调用
,绑定的变量a的值都是10,绑定的this都是window(除非this值被改变))【可以参考下面一道面试题】
// 全局上下文环境
a: undefined —> 10
bar: undefined —> function
fn: undefined —> function
this: window —> window
复制代码
(2)代码执行到11行时,调用bar函数。跳转到bar函数内部,执行函数体语句之前,会创建一个新的执行上下文环境
// bar函数执行上下文环境
b: undefined —> 5
x: 10 —> 10
arguments: [10] —> [10]
this: window —> window
复制代码
然后将bar执行上下文环境压栈,并设置为活动状态(当前唯一)(栈顶)
(3)然后执行到第4行,调用fn函数。
调到fn函数内部,执行函数体语句之前,会创建一个新的执行上下文环境
// fn函数执行上下文环境
c: undefined —> 5
y: 15 —> 15
arguments: [15] —> [15]
this: window —> window
复制代码
然后将fn执行上下文环境压栈,设置为活动状态(当前唯一)(栈顶)
(4)fn执行完毕后,调用fn函数生成的fn上下文环境出栈,被销毁。
然后bar执行完毕后,调用bar函数生成的上下文环境出栈,被销毁。然后剩下全局上下文环境,出栈销毁
当函数调用完成时,这个上下文环境以及其中的数据都会被消除(当然闭包并不会乖乖就范)
上面这样代码执行全过程对应的执行上下文环境的“入栈”、“出栈”对应的“栈”就是“执行上下文栈”:
从上面的例子可以看出:处于活跃状态的环境就是对应处于栈顶的执行上下文环境。而刚刚说作用域链的时候是说处于活跃状态的对象对应处于链头的对象,而一个执行上下文环境对应一个对象,这就对上了。
在这里我的理解是:一个栈的对应一条不同的作用域链,栈顶的活跃执行上下文环境对应着处于链头的活跃对象。往下走依次一一对应。
PS:this指向也一直没有变,从头到尾所有环境中都是window。
(注意:这里只是针对主线程的同步任务,其他线程的异步任务会通过队列的方式执行,详细见 JavaScript系列 — event loop 事件轮询)
作用域链
在JavaScript中,函数也是对象,实际上,JavaScript里一切都是对象。函数对象和其它对象一样,拥有可以通过代码访问的属性和一系列仅供JavaScript引擎访问的内部属性。
其中一个内部属性是[[Scope]]
,根据官方定义,该内部属性包含了函数被创建的作用域中对象的集合,这个集合被称为函数的作用域链,它决定了哪些数据能被函数访问
当一个函数创建后,它的作用域链会被创建此函数的作用域中可访问的数据对象填充。例如定义下面这样一个函数:
function add(num1,num2) {
var sum = num1 + num2;
return sum;
}
复制代码
在函数add创建时,它的作用域链中会填入一个全局对象,该全局对象包含了所有全局变量,如下图所示(注意:图片只例举了全部变量中的一部分):
对应控制台:
(这里看到其实上面所说的Global object
其实就是window或者说是window.global(===window))
函数add的作用域将会在执行时用到。例如执行如下代码:
var total = add(5,10);
复制代码
执行此函数时会创建一个称为“执行上下文”的内部对象,执行上下文定义了函数执行时的环境。每个执行上下文都有自己的作用域链,用于标识符解析,当执行上下文被创建时,而它的作用域链初始化为当前运行函数的[[Scope]]所包含的对象。
这些值按照它们出现在函数中的顺序被复制到执行上下文的作用域链中。它们共同组成了一个新的对象,叫“活动对象”,该对象包含了函数的所有局部变量、命名参数、参数集合以及this,然后此对象会被推入作用域链的链头
,当执行上下文被销毁,活动对象也随之销毁。新的作用域链如下图所示:
对应控制台:
过程:在全局执行上下文中搜索变量:对应的作用域链[[Scopes]]是只有一个元素的Scopes数组,这个元素Scopes[0]就是Global object
,在这个对象里面找想要的变量;
add函数的执行上下文环境对应的作用域链[[Scopes]]是有两个元素的Scopes数组,从链头出发,第一个元素Scopes[0]是当前的活跃对象Activation object
,如果找不到,再继续沿着作用域链往下走,来到第二个元素Scopes[1]是Global object
,在这个对象里面找想要的变量
注意:这里的this指向不会由于执行上下文环境的切换而改变指向。this指向在函数被调用时就决定了,此后除非使用call/apply/bind方法否则不会改变
在前面执行上下文那块的例子中,我们把执行上下文环境的关系比喻为栈,而上面又说一个执行上下文环境对应一条作用域链。而处于活跃状态的环境对应于栈顶的执行上下文环境;处于活跃状态的对象对应处于链头的对象。前者是形象地描述,后者是底层原理上的描述。
而我对于这块的理解是这样的:不同状态的栈对应一条作用域链,把作用域链比作是“链表”,而处于栈顶的上下文环境就是指向这个“链表”的“指针”。
总结:每个函数有一个执行环境,一个执行环境关联一个变量对象,变量对象的集合叫做作用域链
在函数执行过程中,每遇到一个变量,都会经历一次标识符解析过程以决定从哪里获取和存储数据。该过程从作用域链头部
,也就是从活动对象
开始搜索,查找同名
的标识符,如果找到了就使用这个标识符对应的变量,如果没找到继续搜索作用域链中的下一个对象
,如果搜索完所有对象都未找到
,则认为该标识符未定义
。函数执行过程中,每个标识符都要经历这样的搜索过程。
这种机制保证了同级函数之间、上下级函数之间的变量/方法不会互相干扰。(如果需要想要突破这种机制就需要闭包)
代码执行完毕后,所在的环境会被销毁,web中全局执行环境是window对象,全局环境会在应用程序退出时被销毁
作用域链的代码性能优化
从作用域链的结构、原理可以看出,在执行上下文的作用域链中,标识符所在的位置越深
,读写速度就会越慢
。如上图所示,因为全局变量总是存在于执行上下文作用域链的最末端,因此在标识符解析的时候,查找全局变量是最慢的
。所以,在编写代码的时候应尽量少使用全局变量,尽可能使用局部变量
一个好的经验法则是:如果一个跨作用域的对象被引用了一次
以上,则先把它存储到局部变量里再使用。例如下面的代码:
function changeColor(){
document.getElementById("btnChange").onclick=function(){
document.getElementById("targetCanvas").style.backgroundColor="red";
};
}
复制代码
function changeColor(){
var doc = document; // 先把下面要用到超过1次的document全局变量存到局部变量doc中,减少读取次数
doc.getElementById("btnChange").onclick=function(){
doc.getElementById("targetCanvas").style.backgroundColor="red";
};
}
复制代码
避免使用 with语句
function initUI(){
with(document){ // 使用with语句
var bd = body,
links = getElementsByTagName("a"),
i = 0,
len = links.length;
while(i < len){
update(links[i++]);
}
getElementById("btnInit").onclick = function(){
doSomething();
};
}
}
复制代码
这里因为需要多次用到document,所以使用with语句看起来会比较简洁高效,但实际引发了性能问题。
当代码运行到with语句时,运行期上下文的作用域链临时被改变
了。一个新的可变对象被创建,它包含了参数指定的对象的所有属性。这个对象将被推入作用域链的头部
,这意味着函数的所有局部变量现在处于第二个作用域链对象中,因此访问代价更高了。如下图所示:
因此在程序中应避免使用with语句,在这个例子中,只要简单的把document存储在一个局部变量中就可以提升性能。
try…catch 语句
另外一个会改变作用域链的是try-catch语句中的catch语句。当try代码块中发生错误时,执行过程会跳转到catch语句,然后把异常对象
推入一个可变对象并置于作用域链的头部。在catch代码块内部,函数的所有局部变量将会被放在第二个
作用域链对象中。示例代码:
try{
doSomething();
}catch(ex){
alert(ex.message); //作用域链在此处改变
}
复制代码
try-catch语句在代码调试和异常处理中非常有用,因此不建议完全避免。你可以通过优化代码来减少catch语句对性能的影响。一个很好的方法是将错误交给一个函数处理,例如:
try{
doSomething();
}catch(ex){
handleError(ex); // 交给handleError函数处理
}
复制代码
优化后的代码,handleError方法是catch子句中唯一执行的代码。该函数接收异常对象作为参数
,这样你可以更加灵活和统一的处理错误。由于只执行一条语句,且没有局部变量的访问,作用域链的临时改变就不会影响代码性能了
补充几道常见的面试题
- 块级作用域相关:
for(var i=0;i<5;i++){
console.log(window.i) // 0 1 2 3 4
}
for(let i=0;i<5;i++){
console.log(window.i) // undefined * 5
}
复制代码
这个输出不同的原因是:window里面没有{ }里面的i,for循环看成的5个{ }
- 执行上下文
var a = 10
function foo(){
console.log(a)
}
function sum() {
var a = 20
foo()
}
sum() // 10
复制代码
这个地方输出10的原因是:虽然在sum()函数里面调用foo()函数,但foo()函数和全局变量window.a = 10是绑定在一个上下文环境中的,所以不是局部变量a = 20。显然window.a和a是不一样的,即使名称一样。而且foo()函数在哪个区域被调用无关,因为只要foo()函数被调用就一定会进入全局执行上下文环境,就一定使用的是a变量
- 块级作用域、执行上下文、this指向
var obj = {
a: 5,
get1: function(){
console.log(this.a)
},
get2: function(){
setTimeout(this.get1,0)
}
}
obj.get1() // 5 ----------------- 1
const fn = obj.get1
fn() // undefined ----------------- 2
obj.get2() // undefined ----------------- 3
复制代码
1和2不一样的原因:obj.get1()是obj对象调用get1()方法,所以this指向obj对象,所以this.a就是5;fn()虽然也是调用get1()方法,但是在全局中调用的,所以this默认指向window,所以this.a是undefined。
3输出undefined的原因是:同1,this指向obj,但是在setTimeout里面this是指向window的,因为它是脱离主线程另外在全局中开启的线程(但也不是绝对,需要具体看情况,详见JavaScript系列 — this关键字)。所以setTimeout里面的this.get1会把this(===window)传给get1,所以输出undefined。
闭包
闭包的相关内容见 JavaScript系列 — 垃圾回收机制、闭包