JavaScript 中的作用域

JavaScript 中的作用域

在深入了解作用域之前,我们先来看看什么是变量提升

变量提升(Hoisting)

我们通过一个例子来进行理解,如下

1
2
3
4
5
6
7
8
9
10
var a = 1

function foo () {
if (!a) {
var a = 2
}
alert(a)
}

foo() // 输出 2

之所以输出 2,这就是所谓的变量提升了,所谓声明变量

1
var a

而定义变量

1
var a = 1
  • 声明,是指你声称某样东西的存在,比如一个变量或一个函数,但你没有说明这样东西到底是什么,仅仅是告诉解释器这样东西存在而已
  • 定义,是指你指明了某样东西的具体实现,比如一个变量的值是多少,一个函数的函数体是什么,确切的表达了这样东西的意义

总结下来就是

1
2
3
var a      // 这是声明
a = 1 // 这是定义(赋值)
var a = 1 // 合二为一,声明变量的存在并赋值给它

当你以为你只做了一件事情的时候(var a = 1),实际上解释器把这件事情分解成了两个步骤,一个是声明(var a),另一个是定义(a = 1),可以把之前的例子稍微转换一下,就成了如下

1
2
3
4
5
6
7
8
9
10
11
12
var a
a = 1

function foo() {
var a // 关键在这里
if (!a) {
a = 2
}
alert(a) // 此时的 a 并非函数体外的那个全局变量
}

foo()

如代码所示,在进入函数体后解释器声明了新的变量 a,所以当 !a 的时候,将为新的变量 a 赋值为 2,我们再来看一下函数当中的提升,同样我们也是通过一个例子来理解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// test1
function test () {
foo()
function foo() {
alert('出现')
}
}

test()

// test2
function test () {
foo()
var foo = function() {
alert('不会出现')
}
}

test()

在第一个例子里,函数 foo 是一个声明,既然是声明就会被提升(特意包裹了一个外层作用域,因为全局作用域需要你的想象,不是那么直观,但是道理是一样的),所以在执行 foo() 之前,作用域就知道函数 foo 的存在了,这被称为函数声明(Function Declaration),函数声明会连通命名和函数体一起被提升至作用域顶部

然而在第二个例子里,被提升的仅仅是变量名 foo,至于它的定义依然停留在原处,因此在执行 foo() 之前,作用域只知道 foo 的命名,不知道它到底是什么,所以执行会报错(通常会是 foo is not a function),这被称为函数表达式(Function Expression),函数表达式只有命名会被提升,定义的函数体则不会

作用域(Scoping)

JavaScriptES6 之前是没有块级作用域的(Block Scoping),只有函数作用域(Function Scoping),并且函数的作用域在函数定义的时候就决定了,当解析器读到一个变量声明和赋值的时候,解析器会将其声明提升至当前作用域的顶部(这是默认行为,并且无法更改),这个行为就叫做 Hoisting

ES6 之前,JavaScript 只有函数作用域

比如下面这个例子,若是想要 alert(a) 弹出那个 1,也可以创建一个新的作用域,就是利用 IIFE

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var a = 1

function foo() {
// 这个就是 IIFE,它会创建一个新的函数作用域
// 并且该作用域在 foo() 的内部,所以 alert 访问不到
// 不过这个作用域可以访问上层作用域,这就叫 闭包
if (!a) {
(function() {
var a = 2
}())
}
alert(a) // 1
}

foo()

请始终保持作用域内所有变量的声明放置在作用域的顶部

因为这样可以避免 Hoisting 特性给你带来的困扰,也可以很明确的告诉所有阅读代码的人(包括你自己)在当前作用域内有哪些变量可以访问,但是,变量声明的提升并非 Hoisting 的全部,在 JavaScript 中,有四种方式可以让命名进入到作用域中(按优先级)

  1. 语言定义的命名,比如 this 或者 arguments,它们在所有作用域内都有效且优先级最高,所以在任何地方你都不能把变量命名为 this 之类的,这样是没有意义的
  2. 形式参数,函数定义时声明的形式参数会作为变量被 hoisting 至该函数的作用域内,所以形式参数是本地的,不是外部的或者全局的,当然你可以在执行函数的时候把外部变量传进来,但是传进来之后就是本地的了
  3. 函数声明,函数体内部还可以声明函数,不过它们也都是本地的了
  4. 变量声明,这个优先级其实还是最低的,不过它们也都是最常用的

Hosting 只提升了命名,没有提升定义

这里顺便简单的提及一下什么是 IIFE,也就是所谓的『立即执行函数』,那么为什么需要 IIFE

  • 传统的方法啰嗦,定义和执行分开写
  • 传统的方法直接污染全局命名空间(浏览器里的 global 对象,如 window

转变表达式的办法有很多,最常见的办法是把函数声明用一对 () 包裹起来,于是就变成了立即执行函数,一个简单的 IIFE 如下

1
2
3
// 这里是故意换行,实际上可以和下面的括号连起来
(function foo() {...})
()

这就等价于

1
2
3
4
// 这就不是定义,而是表达式了
var foo = function () {...}

foo()

但是之前我们说不行的那个写法,其实也可以直接用括号包起来,这也是一种等价的表达式

1
(function foo(){...}())

另外,刚才说过转变表达式的方式很多,的确还有很多别的写法,比如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
!function foo() {
// ...
}()

// or

+function foo() {
// ...
}()

// or

void function () {
// ...
}()

所谓不去污染全局命名空间,是因为 IIFE 创建了一个新的函数作用域,你真正的业务代码被封装在其中,自然就不会触碰到全局对象了,如果你需要全局对象,可以传递给 IIFE

1
2
3
void function (global) {
// 在这里,global 就是全局对象了
}(this) // 在浏览器里,this 就是 window 对象

执行上下文环境

简单来说,函数『每被』调用一次,都会产生一个新的执行上下文环境,因为不同的调用可能就会有不同的参数,需要注意一点,函数体内部自由变量在函数在定义的时候(不是调用的时候)就已经确定了

JavaScript 在执行一个代码段之前,都会进行这些『准备工作』来生成执行上下文,其中又分三种情况,分别是全局代码,函数体 和 Eval(不推荐使用这个,所以我们主要介绍前两种)

全局执行上下文环境

在产生执行全局上下文时,浏览器通常会做以下三个准备工作

  • 提取 var 声明的变量,并赋值(默认)为 undefined(变量提升)
  • 提取声明式函数(function foo () {..}
  • this 赋值(指向 window 或当前对象)

函数体上下文环境(也就是所谓的局部)

会在以上三个的基础上增加以下三条

  • 给函数参数赋值
  • arguments 赋值(是一个实参副本,与实参保持一致)
  • 自由变量的取值作用域,查找并赋值

所以总结来说就是

在执行代码之前,把将要用到的所有的变量都事先拿出来,有的直接赋值了,有的先用 undefined 占个空

而在执行 JavaScript 代码时,会有数不清的函数调用次数,自然就会产生许多个上下文环境,那么这么多的上下文环境该如何管理,以及如何销毁而释放内存呢?其实这个就主要依靠我们下面将会介绍到的『执行上下文栈』,不过在此之前,我们先来针对上面提到过的上下文环境在深入的了解一下

变量对象

变量对象是与执行上下文相关的数据作用域,存储了在上下文中定义的变量和函数声明,JavaScript 解释器之所以可以找到我们定义的函数和变量,全部依靠的变量对象(VO),变量对象(Variable Object,缩写为 VO)是一个抽象概念中的对象,它用于存储执行上下文中的

  1. 变量
  2. 函数声明
  3. 函数参数

VO 一般是按照如下顺序填充的

  1. 函数参数(若为传入,初始化该参数值为 undefined
  2. 函数声明(若发生命名冲突,会覆盖)
  3. 变量声明(初始化变量值为 undefined,若发生命名冲突,会忽略)

来看看以下几个实例,可以加深我们理解 VO

1
2
3
4
5
6
function foo (x, y, z) {
alert(x) // function x () {}
function x () {}
}

foo(100)

在初始化阶段,先初始化函数的参数,参数 x 即为传进来的参数,为 100,但是在处理函数声明的时候,发生冲突,x 会被覆盖,所以返回的是一个函数对象,我们将上面的示例稍微调整一下,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// var fn 的时候,发现 fn 已经在函数声明的时候定义过了,所以会忽略
function foo(x, y, z) {
function fn() { }
var fn
console.log(fn) // function fn () {}
}
foo(100)

// 跟上例是一样的,但是在代码执行阶段,fn 会被执行赋值操作
function foo(x, y, z) {
function fn() { }
var fn = 1
console.log(fn) // 1
}
foo(100)

函数表达式不会影响 VO,比如 var a = function foo() { }

这里的 foo 是函数表达式的名称,这个是不会记录到 VO 中的,这也是为什么我们不能在外部通过 foo 来获取到这个函数对象,最后我们再来看一个比较容易出错的,巩固一下上面所介绍的,如下

1
2
3
4
5
6
7
8
9
var num = 0

function a() {
num = 100
console.log(num)
}

a()
console.log(num)

很明显,在执行了函数 a() 以后,将全局当中的 num 修改成了 100,所以两次输出均为 100,但是如果我们给函数 a() 添加一个参数呢,如下

1
2
3
4
5
6
7
8
9
var num = 0

function a(num) {
num = 100
console.log(num)
}

a()
console.log(num) // ?

这里就需要注意一下了,如果调整成这样的话,最后的结果依次为 1000,而不再是两个 100

活动对象

在函数上下文中,我们用活动对象(Activation ObjectAO)来表示变量对象,活动对象和变量对象其实是一个东西,只是变量对象是规范上的或者说是引擎实现上的,不可在 JavaScript 环境中访问

只有到当进入一个执行上下文中,这个执行上下文的变量对象才会被激活,所以才叫 Activation Object,而只有被激活的变量对象,也就是活动对象上的各种属性才能被访问,活动对象是在进入函数上下文时刻被创建的,它通过函数的 arguments 属性初始化,arguments 属性值是 Arguments 对象,举个例子

1
2
3
4
5
6
7
8
function foo(a) {
var b = 2
function c() { }
var d = function () { }
b = 3
}

foo(1)

在进入执行上下文后,这时候的 AO

1
2
3
4
5
6
7
8
9
10
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
c: reference to function c(){ },
d: undefined
}

在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值,当代码执行完后,这时候的 AO

1
2
3
4
5
6
7
8
9
10
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 3,
c: reference to function c(){ },
d: reference to FunctionExpression 'd'
}

我们来简单总结一下变量对象的创建过程

  1. 全局上下文的变量对象初始化是全局对象
  2. 函数上下文的变量对象初始化只包括 Arguments 对象
  3. 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
  4. 在代码执行阶段,会再次修改变量对象的属性值

在看个例子加深一下

1
2
3
4
5
6
7
8
9
10
11
function foo() {
console.log(a)
a = 1
}
foo() // ???

function bar() {
a = 1
console.log(a)
}
bar() // ???

第一段会报错 Uncaught ReferenceError: a is not defined,第二段会打印 1,这是因为函数中的 a 并没有通过 var 等关键字声明,所有不会被存放在 AO 中,第一段执行 console 的时候,AO 的值是

1
2
3
4
5
AO = {
arguments: {
length: 0
}
}

没有 a 的值,然后就会到全局去找,全局也没有,所以会报错,当第二段执行 console 的时候,全局对象已经被赋予了 a 属性,这时候就可以从全局找到 a 的值,所以会打印 1

执行上下文栈

我们都知道在代码执行的时候,JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行,当执行一段代码的时候,会进行一个准备工作,就比如之前我们提到过的的变量提升和函数提升,这其实就是所谓的执行上下文

执行全局代码时,会产生一个执行上下文环境,每次调用函数都又会产生执行上下文环境,当函数调用完成时,这个上下文环境以及其中的数据都会被消除,再重新回到全局上下文环境,处于活动状态的执行上下文环境只有『一个』,其实简单来说,就是一个『压栈』==>『出栈』的过程,如下图所示

  • 压栈,函数未调用时只有全局上下文在执行,每次调用函数时会产生局部上下文,这就是压栈,也就是进栈
  • 出栈,函数调用完成后,就会出栈,会销毁本次调用的局部上下文环境

注意,若函数里面是多层函数嵌套,也会出现多层执行上下文的嵌套(压栈和出栈也是嵌套产生的),上面这种只是较为理想的情况,另外还存在一种情况是无法做到这样干净利落的说销毁就销毁,而这种情况就是闭包,但是关于闭包的内容我们会在后面另起篇幅来进行介绍,在代码执行过程中,JavaScript 引擎会创建了执行上下文栈(Execution context stackECS)来管理执行上下文,我们可以通过数组来进行模拟

1
ECStack = []

JavaScript 开始要解释执行代码的时候,最先遇到的就是全局代码,所以初始化的时候首先就会向执行上下文栈压入一个全局执行上下文,我们用 globalContext 表示它,并且只有当整个应用程序结束的时候,ECStack 才会被清空,所以程序结束之前,ECStack 最底部永远有个 globalContext

1
2
3
ECStack = [
globalContext
]

假设遇到下面这段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
function fun3() {
console.log('fun3')
}

function fun2() {
fun3()
}

function fun1() {
fun2()
}

fun1()

当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出,所以我们可以尝试使用伪代码来描述上述过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
ECStack.push(<fun1> functionContext)

// fun1 中调用了 fun2,创建 fun2 的执行上下文
ECStack.push(<fun2> functionContext)

// fun2 还调用了 fun3
ECStack.push(<fun3> functionContext)

// fun3 执行完毕
ECStack.pop()

// fun2 执行完毕
ECStack.pop()

// fun1 执行完毕
ECStack.pop()

但是需要注意的是,JavaScript 虽然执行完毕了,但是 ECStack 底层永远有个 globalContext

作用域链

在上面我们介绍 AO 部分概念的时候,我们曾经提到过,在查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象,这样由多个执行上下文的变量对象构成的链表就叫做『作用域链』

又因为函数有一个内部属性 [[scope]],当函数创建的时候,就会保存所有父变量对象到其中,你可以理解 [[scope]] 就是所有父变量对象的层级链,比如下面这个例子,函数创建时,各自的 [[scope]]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function foo() {
function bar() {
// ...
}
}

// ==>

foo.[[scope]] = [
globalContext.VO
]

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

当函数激活时,进入函数上下文,创建 VO/AO 后,就会将活动对象添加到作用链的前端,这时候执行上下文的作用域链,我们命名为 Scope

1
Scope = [AO].concat([[Scope]])

至此,作用域链创建完毕,下面我们便以一个函数的创建和激活两个时期来讲解作用域链是如何创建和变化的,例子如下

1
2
3
4
5
6
var scope = 'global scope'
function checkscope() {
var scope2 = 'local scope'
return scope2
}
checkscope()
  1. checkscope 函数被创建,保存作用域链到内部属性 [[scope]]
1
2
3
checkscope.[[scope]] = [
globalContext.VO
]
  1. 执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数执行上下文被压入执行上下文栈
1
2
3
4
ECStack = [
checkscopeContext,
globalContext
]
  1. checkscope 函数并不立刻执行,开始做准备工作,第一步,复制函数 [[scope]] 属性创建作用域链
1
2
3
checkscopeContext = {
Scope: checkscope.[[scope]],
}
  1. 第二步,用 arguments 创建活动对象,随后初始化活动对象,加入形参、函数声明、变量声明
1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: checkscope.[[scope]],
}
  1. 第三步,将活动对象压入 checkscope 作用域链顶端
1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: undefined
},
Scope: [AO, [[Scope]]]
}
  1. 准备工作做完,开始执行函数,随着函数的执行,修改 AO 的属性值
1
2
3
4
5
6
7
8
9
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope2: 'local scope'
},
Scope: [AO, [[Scope]]]
}
  1. 查找到 scope2 的值,返回后函数执行完毕,函数上下文从执行上下文栈中弹出
1
2
3
ECStack = [
globalContext
]

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×