要讲清楚作用域链,我们首先要从JS中的一个重要概念讲起——执行环境
执行环境与变量对象
执行环境定义了变量或者函数有权访问的其它数据。这是JavaScript高级程序设计中的定义。它是从执行环境的作用来定义执行环境这个概念的,也就是说:正是因为有了执行环境的存在,才能保证一个变量或者函数有权访问哪些数据,而无权访问哪些数据。
执行环境分两种:全局执行环境和局部执行环境。全局执行环境不用说了,在web浏览器端就是window对象。而局部执行环境就是当执行流进入到函数内部时,就会创建一个当前函数的执行环境,这个执行环境就是局部执行环境,也叫函数执行环境。
执行环境存储在一个堆栈数据结构中,这个堆栈,我们可以叫它环境栈,每当执行流创建一个执行环境时,就会将该执行环境推入到环境栈中。当当前执行环境中所有的代码都执行完毕,就会从环境栈中弹出该执行环境,把控制权返回给之前的执行环境,也就是环境栈中下一个执行环境。
如上就是执行环境的环境栈,当当前函数的执行环境被pop弹出时,指向当前执行环境的指针就会下移,将控制权交给上一个(前一个)执行环境,在图中就是指向函数X的执行环境。
执行环境有一个与之关联的对象叫变量对象,执行环境中定义的变量和函数都会挂载到这个变量对象上。创建函数执行环境时,会将函数的活动对象作为函数环境的变量对象,这个变量对象一开始只包含一个变量,即arguments对象。
当执行环境被销毁时,也就是前面说过的从环境栈中弹出时,与之关联的变量对象也会被销毁,因此在这个执行环境中申明的所有变量和函数都会被销毁,从而释放内存。
有了前面的执行环境的概念,接下来我们看看作用域链。
作用域链
作用域链用来保证对执行环境有权访问的所有变量和函数的有序访问。这是作用域链的定义。那么执行环境有权访问哪些变量和函数,作用域链又是怎么保证有序访问的呢,我们接下来看看:
首先,当代码在一个环境中执行时,就会创建变量对象的一个作用域链。我的理解就是,它的本质就是变量对象上的一个属性([[Scopes]] ? ),它是一个指针,指向上一个执行环境的变量对象,也就是说作用域链是由环境栈中所有执行环境的变量对象线性链接而成的一个线性表,线性表的第一个元素是当前执行环境的变量对象,下一个元素来自前一个执行环境(包含执行环境)的变量对象,再下一次元素来自再下一个执行环境的变量对象。。。。。。,依次类推,直到最后一个元素是来自全局执行环境的变量对象。这样就线性的组成了作用域链。(当然这一块是我个人的理解,如有理解有误的地方,欢迎大佬指正)
现在我们知道了作用域链是由变量对象按照创建顺序串联起来的,而变量对象上又保存着对应的执行环境中声明的变量和函数。标识符解析正式利用作用域链来查找变量的。
标识符解析的过程:沿着作用域链,从作用域链的前端开始依次查找变量对象,如果变量对象中存在该标识符,则找到,反之查看作用域链中下一个变量对象。。。直到全局变量对象,如果全局变量对象上也没有找到,说明该变量或函数没有申明。
现在让我们反过来理解作用域链的定义:保证对执行环境有权访问的所有变量和函数的有序访问
执行环境有权访问的变量和函数就存储在当前执行环境的作用域链上的每一个变量对象上,只有存在在这条作用域链上的变量和函数,当前执行环境才能访问到,因为标识符解析就是沿着这条作用域链进行查找的。
同时,通过沿着作用域链依次查找,找到就返回,没找到就接着往下找,正是这种策略保证了对变量和函数的有序访问。假设作用域链中存在多个同名变量或函数,如果没有这种策略,那么应该解析到哪一个同名变量呢?所以说这种有序访问的策略就是就近原则(就近 执行环境中定义的变量/函数)
块级作用域
最后我们来简单说一下js中的块级作用域,因为这个关系到标识符的解析以及合理的变量申明。
我们都知道在es6之前是没有块级作用域的,声明在大花括号中的变量,在花括号外边也可以访问,只有函数比较特殊,声明在函数中的变量在函数外部是无法访问的。如下:
if (true) {
var color = 'red';
}
console.log(color) // 'red' 可以访问,因为js没有会计作用域
for(var i = 0; i < 10; i++){
doSomething(i);
}
console.log(i); // 10 可以访问,因为js没有会计作用域
function block() {
var color = 'red';
}
console.log(color); // 报错,不可以访问,因为函数有块级作用域
function block() {
color = 'red';
}
console.log(color); // 'red' 可以访问
上面的例子中,最后一个例子比较特殊,这是因为在js中,如果一个变量没有声明而直接初始化,那么这个变量就会被自动添加到全局变量对象上去,而不论这个变量是不是在函数体中进行初始化的,这个其实是相当于下面这种写法:
var color;
function block(){
color = 'red';
}
console.log(color);
所以,在js中不声明而直接初始化变量是一个常见的错误做法,不建议这样做,因为这会导致意外。在js的严格模式下初始化未经声明的变量会导致错误。
所以综上可以看出:
使用var关键字声明的变量会被自动添加到最接近的执行环境中
- 在函数内部,最接近的执行环境就是该函数的执行环境;
- 在with语句,最接近的环境是函数的执行环境
- 如果初始化未声明的变量,该变量会被自动添加到全局执行环境
然而es6引入了let/const两个声明变量的关键字,来弥补js在声明变量时没有块级作用域的不足,也就是说这两个关键字声明的变量是有块级作用域的:
if(true) {
let color = 'red';
}
console.log(color); // 报错,因为let声明的变量是有块级作用域的,在作用域外部无法访问
for(let i = 0; i < 10; i++) {
doSomething(i);
}
console.log(i); // 报错,因为let声明的变量是有块级作用域的,在作用域外部无法访问
有了let/const的块级作用域之后,我们可以将一个变量的作用局限在一个给定的局部范围内,不会再污染全局变量,也不会导致一些意外,并且随着块级作用域的执行结束,位于块级作用域内的变量会被销毁,从而释放内存。因此尽量使用let/const来声明变量,少用var声明变量。
本文是根据《JavaScript高级程序设计》结合自己的理解来写的,如果有不对的地方,欢迎大佬指正,谢谢。
后续有空了会写个人对于垃圾回收机制以及闭包的理解,感兴趣的同学可以点个关注留意一下,谢谢。