深入理解JavaScript之执行环境和作用域链

引言

在Java中,被花括号包起的代码具有独立的作用域,这一点与大部分语言都差不多,在理解和使用上都很简单,而JS中的作用域却相对较为复杂,例如

if (true) {
    a = 0
}
console.log(a)

在非严格模式下,以上代码没有报错,打印出了数字0,即使在严格模式下,将a = 0改为var a = 0之后也能正确执行;而在类似的Java代码中肯定是编译不过的

上篇博客深入理解JavaScript之作用域闭包已经详细分析了闭包,而闭包是基于JS作用域的一种特殊现象,博客中也介绍了作用域的核心原理,本文将更深入全面地分析作用域这一概念

先来看个简单的例子

var color = "blue"

function fun1() {
    var color = "red"
    console.log(color)
    return function fun2() {
        var color = "purple"
        console.log(color)
    }
}

console.log(color)
var foo = fun1()
foo()

这里会先后打印出blue、red、purple,对于稍有JS基础的人来讲这是显而易见的。本文的内容即是分析产生这样执行结果背后的原理

1. 执行环境

要理解作用域,需要先理解执行环境(execution context,上下文),只需要记住以下几点

  • 执行环境定义了变量或函数能访问的数据,每个执行环境都有一个与之关联的变量对象,环境中定义的所有变量和方法都保存在这个变量中
  • 全局执行环境是所有执行环境中最外围的环境,在web浏览器中,它拥有全局变量window,所有的全局变量和函数都是window的属性
  • 每个函数都有自己的执行环境;当代码执行到一个函数时,其执行环境就会被压入环境栈,执行完毕后出栈销毁,保存在其中的变量和函数也随之销毁(闭包是个例外)

2.作用域

通常来说,一段程序代码中所用到的名字并不总是有效/可用的,而限定这个名字的可用性的代码范围就是这个名字的作用域

也可以这么说:程序需要具备在变量中存储值和取出值的能力,而变量存取的范围拥有一套规则,我们可以称这组规则为作用域

3.作用域链

当创建一个函数时,会自动创建一个作用域链,并且其中预先包含所有外部的变量对象(链的最底层为全局变量),这个作用域链被保存在函数的[[Scope]]属性中

当调用该函数时,会为函数创建一个执行环境(同时创建了函数对应的活动对象,执行环境的范围也就是函数的作用域),并且复制[[Scope]]属性中的对象,作为该执行环境的作用域链;然后将函数对应的活动对象push到作用域链的前端

前面说了,作用域定义的是变量存取的规则或者可用性范围,下面通过例子来分析在代码执行时,是如何通过这个规则来查找变量的

var color = "blue"

function fun1() {
    var color = "red"
    console.log(color)
    return function fun2() {
        var color = "purple"
        console.log(color)
    }
}

console.log(color)
var foo = fun1()
foo()

是的,就是文章开始时的那个例子,来画一个图:

<img src = "https://raw.githubusercontent.com/smartzheng/images/master/blog/作用域链.png" width="60%"/>

首先需要谨记,函数执行在它被定义所在的执行环境中,而非它自己的执行环境中

在上图中,作用域链1和作用域链2分别对应于fun1和fun2的作用域链,这里以fun2为例:

当fun2被创建时,会创建一个作用域链,它默认包含了全局变量window和外部fun1的执行环境中作用域链中的活动对象1(每一个活动对象或全局变量中都保存有自己环境中的所有变量)

当执行fun2时,又创建了一个活动对象2,其中保存了fun2中定义的color(“red”)和默认参数arguments

在执行fun2的console.log(color)时,会在对应的作用域链2的活动对象2中去寻找color变量,如果找不到,则去外层环境的活动对象1中找...直到window

这里还有一个疑问,当在window中也找不到需要的变量时,会怎么样呢?看两个例子:

function foo() {
     a = 1
    console.log(a)
}
foo()//非严格模式下打印出 1
function foo() {
    console.log(a)
}
foo()//ReferenceError

在上面的两段代码中,都需要使用到变量a,但是运行的结果却不一致。这里需要理解一下RHS查询和LHS查询:

简单来讲RHS查询与简单地查找某个变量的值别无二致,而LHS查询则是试图找到变量的容器本身,从而可以对其赋值,当RHS查询不到变量时,会直接报错,而RHS查询不到值时,非严格模式的情况下,会自动在全局变量中创建一个(严格模式下也会报ReferenceError),很明显在上面的代码中,前者属于LHS,后者属于RHS。关于自动在全局变量中创建变量,这点可以通过下面几段代码做个实验:

function foo() {
     a = 1
}
console.log(a)//ReferenceError
function foo() {
     a = 1
}
foo()
console.log(a)//非严格模式下打印出 1
function foo() {
    var a = 1
}
foo()
console.log(a)//ReferenceError

4.eval、with、try catch

除了函数,这里还有几个能够改变作用域的方法(块级作用域后面专门分析),虽然eval、with并不被推荐使用,但是最好还是有所了解

function foo(str, a) {
    eval(str); // 欺骗!
    console.log( a, b );
}
var b = 2;
foo("var b = 3;", 1); // 1, 3

以上代码通过eval传入"var b = 3;",使得b成为foo作用域内的变量

function foo(obj) {
    with (obj) {
        a = 1
        var b = 0
        c = 3
    }
    console.log(b)//"0"
}
var obj = { a: 2 }
foo(obj)
console.log(obj.a)//"1"
console.log(c)//"3"
console.log(a)//"ReferenceError"

with新建了一个作用域,似乎可以说它的参数成为该作用域的活动变量;

值得注意的是,在with语句中通过var声明的变量对应的作用域其实是外部方法作用域,所以例子中在with外部打印b时为3;

其次在上述代码中,传入with的obj没有c属性,所以在进行LHS查询时,直到全局变量都没有找到c,导致在全局变量中声明了一个c,所以console.log(c)的结果为3

with和eval虽然使用简洁,但是对性能的影响却很大:在编译器进行词法分析时,会对每个变量和方法进行静态处理优化,即通过设置一个标识符预先确定它们的位置,以便在运行时能快速找到它们的位置;但是一旦使用到with和eval,由于它们会在运行期改变作用域,所以编译器无法通过静态分析来预先确定作用域,无法进行这种静态处理优化,“引擎多聪明,试图将这些悲观情况的副作用限制在最小范围内,也无法避免如果没有这些优化,代码会运行得更慢这个事实。”

catch语句则比较简单,它会创建一个新的变量对象,其中包含的是被抛出的错误对象的声明

5.块级作用域

在ES6之前,JS中没有块级作用域;而在其他类C语言中,由花括号封闭的代码块都有自己的作用域(如果用ECMAScript的话来讲,就是它们自己的执行环境),因而支持根据条件来定义变量。例如,下面的代码在 JavaScript中并不会得到想象中的结果:

if (true) {
    var color = "blue";
}
alert(color);    //"blue"

同样的现象还存在于for循环中:

for (var i = 0; i < 10; i++){
    doSomething(i);
}
alert(i);      //10

在其他类C语言中,if语句和for循环中的i只作用于于花括号范围内,而在JS中括号内的语句则是在其外部环境中执行,定义的变量会被绑定在外部作用域(函数或全局)中,这点在我刚开始接触JS时也产生了很大的困惑,很难理解为什么要这样设计

不过ES6新增了let和const,解决了这个问题,可能也说明设计者也认为这算是一个缺陷吧:

for (let i=0; i < 10; i++){
    doSomething(i);
}
alert(i); //ReferenceError

let关键字可以将变量绑定到所在的任意作用域中,通常是在{ }中;任意位置都可以使用 { } ,这类似Java中的代码块,并且其中的let(或const)声明的变量都会只作用在这段括号中:

{
    let bar = 2;
}
console.log(bar); // ReferenceError! 

关于let、const还有一个很有趣的现象,正常情况下,typeof使用在一个未声明的变量会返回undefinde:

console.log(typeof a)//undefinde

但是修改以上代码为:

console.log(typeof a)//ReferenceError
let a = 1

这里却打印了ReferenceError,而使用var则没有问题,原因是在上面这种情况下,当使用let声明a时,在声明代码之前,a被放入了一个被称为暂时性死区的地方从而无法被应用到

参考:
《JavaScript高级程序设计》
《你不知道的JavaScript》上卷
《深入理解ES6》

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,198评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,663评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,985评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,673评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,994评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,399评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,717评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,407评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,112评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,371评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,891评论 1 256
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,255评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,881评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,010评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,764评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,412评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,299评论 2 260

推荐阅读更多精彩内容