JavaScript 作用域链与闭包

作用域链与闭包

了解作用域链之前需要先了解下作用域是什么。

作用域

几乎所有的语言都有作用域的概念。这是因为它们都有变量这一概念。而程序代码中所用到的变量并不总是有效或者可用的,因此需要限定变量的可用性范围,这就是作用域。

也就是说作用域规定了当前执行代码对变量的访问权限,在变量作用域之外是没有访问权限的。

静态作用域与动态作用域

不同的语言在设计的时候规定的作用域是不同的,一般分为静态作用域和动态作用域。

  1. 静态作用域

静态作用域是指声明的作用域是根据程序正文在编译时就确定的,有时也称为词法作用域。

静态作用域关注函数在何处声明

  1. 动态作用域

动态作用域是在运行时根据程序的流程信息来动态确定的。

动态作用域关注函数从何处调用,其作用域链是基于运行时的调用栈的。

事实上大部分语言都是基于静态作用域的,JavaScript 也是这样。比如下面的例子。

var value = 1;

function foo() {
    console.log(value);
}

function bar() {
    var value = 2;
    foo();
}

bar();

// 结果是 ???

分下一下执行过程。

  1. 执行 bar 函数
  2. 进入到 bar 函数体内
  3. 调用 foo 函数
  4. 进入 foo 函数体内
  5. 打印 value 的值的时候要先去查找 value 的值
  6. foo 函数体内部没有 value 变量
  7. 因此需要去上一级代码查找,由于 JavaScript 是静态作用域,需要去 foo 声明的地方查找
  8. 找到了 value 的值等于 1
  9. 输出 1

总结一下就是作用域范围是在函数定义的时候就确定下来的,理解这一点非常重要。

另外这个结论可以帮助你快速确定作用域,但是要彻底了解原理还需要知道接下来的内容。

作用域链

在上篇文章 《执行上下文和执行栈》中讲到函数执行的时候会创建执行上下文,在执行上下文生成的过程中,会分别确定变量对象,作用域链,以及this的值。

而且我们知道了,函数的执行上下文在创建的时候会扫描当前上下文中声明的变量和函数,并将其初始化并保存到上下文对应的 VO 变量对象中。

那么当函数中访问到当前执行上下文中没有声明的变量的时候该怎么办呢?

这时候就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

我们通过下面代码从函数的创建和激活两个时期来讲解作用域链是如何构建出来的。

function foo() {
    function bar() {
        ...
    }
}

函数创建阶段

在介绍作用域的时候我们知道了函数的作用域在它定义的时候就决定了。这是因为函数也是对象,它有一个属性 [[scope]](内部属性,只有 JS 引擎可以访问, 但 FireFox 的几个引擎提供了私有属性 __parent__ 来访问它)。

函数在创建的时候会将 [[scope]] 属性链接到它父级(词法层面上的父级)执行上下文的变量对象组成的链表中。

foo 函数创建阶段

[[scope]] = [
    globalContext.VO
]

bar 函数创建阶段

[[scope]] = [
    fooContext.AO,
    globalContext.VO
]

注意:

  1. [[scope]] 内保存的是词法层面父级执行上下文的作用域链
  2. [[scope]] 并不代表作用域链,它是函数的一个属性
  3. bar 函数的 [[scope]] 中是 fooContext.AO 而不是 VO 是因为在 bar 函数创建的时候 foo 函数已经处于激活阶段了

函数激活阶段

函数激活的时候会创建上下文,先去创建 VO/AO 对象。然后就会将 AO 对象插入到 [[scope]] 属性的链表的底部,组成新的链表。这个链表就是当前上下文的作用域链。

foo 函数激活阶段

[[scopeChain]] = [
    fooContext.AO,
    globalContext.VO
]

bar 函数激活阶段

[[scopeChain]] = [
    barContext.AO,
    fooContext.AO,
    globalContext.VO
]

可以用下面的公式来总结

scope chain = VO + All Parent VOs

注意:

  1. scopeChain 是执行上下文的一个属性
  2. 在发生标识符解析的时候, 就会逆向查询当前 scope chain 列表的每一个活动对象的属性,如果找到同名的就返回。找不到,那就是这个标识符没有被定义。

步骤分解

我们通过下面的例子结合执行上下文来解析下具体的过程。

var a = 1 
function foo() {
    var b = 2
    function bar() {
        console.log(a + b)
    }
    bar()
}
foo()

1. 代码开始执行,创建全局上下文

1.1 创建 VO 对象

全局上下文在创建的时候会先创建 VO 对象。全局上下文比较特殊,这个变量对象其实就是全局对象 window。

1.2 扫描上下文

扫描上下文中变量发现了变量 a,然后将其赋值给 VO ,也就是 window。因此 a 就是 window 的一个属性。

扫描到 foo 函数,将其指针存入 VO 变量。

1.3 确定作用域链

由于没有父级执行上下文,因此全局上下文的 scopeChain 只有自己的变量对象。

globalContext = {
    VO: {
        a: undefined,
        foo: pointer to function foo()
    },
    scopeChain: [
        globalContext.VO
    ]
}

2. 全局上下文激活

全局上下文激活后变成如下状态

globalContext = {
    AO: {
        a: 1,
        foo: pointer to function foo()
    },
    scopeChain: [
        globalContext.AO
    ]
}

与此同时 foo 函数被创建,将保存其所在的词法层面的父级执行上下文的作用域链

foo.[[scope]] = globalContext.scopeChain

等同于

foo.[[scope]] = [
    globalContext.AO
]

3. foo 函数执行上下文创建阶段

3.1 创建 VO 对象

先创建 VO 对象。

3.2 扫描上下文

扫描上下文中变量发现了变量 b,然后将其赋值给 VO 。因此 b 就是 fooContext.VO 的一个属性。

扫描到 bar 函数,将其指针存入 VO 变量。

3.3 确定作用域链

找到语义层面的父级执行上下文 globalContext ,并向 globalContext.scopeChain 中加入当前的变量对象。

fooContext = {
    VO: {
        b: undefined,
        bar: pointer to function bar()
    },
    scopeChain: [
        fooContext.VO,
        globalContext.AO
    ]
}

4. foo 函数执行上下文激活阶段

fooContext = {
    AO: {
        b: 2,
        bar: pointer to function bar()
    },
    scopeChain: [
        fooContext.AO,
        globalContext.AO
    ]
}

同时 bar 函数被创建

bar.[[scope]] = foo.scopeChain

等同于

bar.[[scope]] = [
    fooContext.AO,
    globalContext.AO
]

5. bar 函数执行上下文创建阶段

5.1 创建 VO 对象

先创建 VO 对象。

5.2 扫描上下文

扫描上下文中变量未发现变量声明

5.3 确定作用域链

找到语义层面的父级执行上下文 fooContext ,并向 fooContext.scopeChain 中加入当前的变量对象。

barContext = {
    VO: {},
    scopeChain: [
        barContext.VO,
        fooContext.AO,
        globalContext.AO
    ]
}

6. bar 函数执行上下文激活阶段

fooContext = {
    AO: {},
    scopeChain: [
        barContext.AO,
        fooContext.AO,
        globalContext.AO
    ]
}

执行到输出语句的时候

console.log(a + b)
  1. 先去当前 VO/AO 对象中查找,没有找到
  2. 顺着 scopeChain 查找
  3. fooContext.AO 中找到 b = 2,继续往上找
  4. globalContext.AO 中找到 a = 1
  5. 计算并输出结果 3

闭包

闭包的定义是:

闭包是指那些能够访问自由变量的函数。

什么是自由变量呢?

自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。

因此闭包就是由函数和自由变量组成的。

理论上所有的函数都是闭包。这是因为函数在创建的时候会将上层父级上下文中的数据保存到它的 [[scope]] 参数中。因此从这个角度来说函数都捕获了自由变量。

但是在实践中我们只把满足下面条件的函数称为闭包:

  1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
  2. 在代码中引用了自由变量

我们看以下的例子

function foo() {
    var name = "Mozilla"
    function f() {
        alert(name)
    }
    return f
}
var bar = foo()
bar()

这里 bar 函数就是一个闭包。

首先:bar 执行的时候它的父级执行上下文 bar 函数的上下文已经从执行栈中出栈了。

其次:bar 中引用了自由变量 name

这里估计你会有疑问:name 所在的 foo 函数的执行上下文已经出栈了,为什么还能访问呢?

这其实就是作用域链的作用,经过分析可以知道 bar 函数执行上下文的作用域链如下

scopeChain: [
    barContext.AO,
    fooContext.AO,
    globalContext.AO
]

虽然 fooContext 已经出栈销毁了,但是 fooContext.AO 依然存在。因此依然可以通过作用域链来访问。

必刷题

这是一个闭包面试的必刷题

var data = [];

for (var i = 0; i < 3; i++) {
  data[i] = function () {
    console.log(i);
  };
}

data[0]();
data[1]();
data[2]();

答案是都是 3,我们来详细分析一下

1. 全局作用域创建阶段

globalContext = {
    VO: {
        data: undefined,
        i: undefined
    },
    scopeChain: [
        globalContext.VO
    ]
}

2. 全局作用域激活阶段

执行到 data[0]() 的时候

globalContext = {
    VO: {
        data: [...],
        i: 3
    },
    scopeChain: [
        globalContext.VO
    ]
}

同时 data[0] 所指的函数被创建,作用域如下

data[0].[[scope]]= [
    globalContext.VO
]

3. data[0] 函数执行上下文创建阶段

data[0]Context = {
    VO: { },
    scopeChain: [
        data[0]Context.VO,
        globalContext.AO
    ]
}

4. data[0] 函数执行上下文激活阶段

data[0]Context = {
    AO: { },
    scopeChain: [
        data[0]Context.AO,
        globalContext.AO
    ]
}

data[0]Context.AO 里没有 i 的值,因此会沿着作用域链往上查找,然后在 globalContext.AO 中找到了 i = 3

data[1]data[2],是一样的道理。

总结

  1. JavaScript 是静态作用域,作用域范围是在函数定义的时候就确定下来的。
  2. 作用域链是执行上下文的一个属性,作用域链让闭包访问自由变量成为可能。

参考

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

推荐阅读更多精彩内容