JavaScript中的作用域、执行上下文与闭包

JavaScript 代码的整个执行过程,分为两个阶段,代码编译阶段与代码执行阶段。编译阶段由编译器完成,将代码翻译成可执行代码,这个阶段作用域规则会确定。执行阶段由引擎完成,主要任务是执行可执行代码,执行上下文在这个阶段创建。

作用域 (Scope)

什么是作用域

作用域是定义变量的区域。

它规定了执行代码时查找变量的范围,也就是变量的作用范围。

JavaScript 采用词法作用域(lexical scoping),也就是静态作用域。

因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了

var a = 1
function foo1() {
console.log(a)
}
function foo2() {
var a = 2
foo1()
}

foo2() // 1

在 JavaScript 中无块级作用域(一对儿 { } 包含的区域 ),只有全局作用域函数作用域

if (true) {
var name = 'abc'
}
console.log(name) // 'abc'

同一个页面中所有的 <script> 标签中的没在函数体内的变量,都在同一个全局作用域中。

执行上下文 (Execution context)

什么是执行上下文

执行上下文可以抽象成一个对象,当一个函数执行时就会创建一个执行上下文

每个执行上下文,都有三个属性:

  • 变量对象(Variable object, VO)
  • 作用域链(Scope chain)
  • this

既然每执行一个函数就会创建一个执行上下文,我们写的函数可不止一个,那么如何管理众多的上下文呢?

JavaScript 引擎创建了执行上下文栈(Execution context stack, ECS) 来管理执行上下文

JavaScript 引擎在执行代码时,最先遇到的是全局代码,会创建一个全局上下文(globalContext)并将之压入执行上下文栈(ECS)中,当执行一个函数时,创建一个函数上下文并压入 ECS 中。函数执行完毕时,这个函数上下文会出栈并被清理。当整个程序执行完毕时,globalContext 也会出栈并被清理。

看一个例子:

function fun1() {
fun2()
}

function fun2() {
console.log('fun2')
}

fun1()

当 JavaScript 引擎执行代码时,先创建一个全局上下文(globalContext) 压入执行上下文栈(ECS)中。

当执行到 fun1() 时,创建一个函数上下文(EC_fun1)并压入栈中。

然后执行 fun1 函数体里的 fun2(),创建 EC_fun2并压入栈中。

执行 fun2 函数里的 console.log() 函数,创建 EC_consolelog 压入栈中。

然后 console.log 执行完毕,EC_consolelog 出栈并销毁。

然后 fun2 执行完毕,EC_fun2 出栈并销毁。

fun1 执行完毕,EC_fun1 出栈并销毁。

变量对象 (Variable object, VO)

变量对象是与执行上下文相关的数据作用域,它存储了在该上下文定义的变量和函数声明。变量对象是在创建函数上下文时创建的,它通过函数的 arguments 属性初识化。

变量对象包括:

  1. 函数的所有形参(如果是函数上下文)
    • 由名称和对应值组成一个变量对象(VO)的属性
  2. 函数声明
    • 由名称和对应值(函数对象)组成一个变量对象(VO)的属性
    • 如果 VO 已存在相同的名称属性,则替换这个属性
  3. 变量声明
    • 由名称和 undefined 组成一个 VO 的属性
    • 如果名称和已经声明的形参或函数相同,变量声明不会干扰已存在的这类属性

举个例子:

function foo(a) {
var b = 2
var a = 10
function c() {}
var d = function() {}
}

foo(11)

在执行 foo(11)时,创建一个执行上下文,此时执行上下文的变量对象时:

VO = {
arguments: {
0: 11,
length: 1
},
a: 11,
b: undefined,
c: reference to function c() {},
d: undefined
}

全局对象

  • 全局上下文的变量对象就是全局对象。
  • 全局上下文的 this 指向的是全局对象。
  • 在浏览器环境下全局对象是 window ,Node.js 环境下全局对象是 global

活动对象 (Activation object,AO)

活动对象与变量对象其实是同一个东西,只有当进入一个执行上下文中(这个执行上下文处在执行上下文栈的栈顶),这个执行上下文的变量对象(VO)才会被激活,此时这个变量对象叫做活动对象(AO)。只有活动对象上的各种属性才能被访问。

作用域链 (Scope chain)

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

函数定义时,有一个内部属性 [[scope]] 保存了所有父变量对象。当函数执行时会创建一个作用域链,这个作用域链包含了函数的 [[scope]] 属性和执行上下文的活动对象(AO)。

var a = 1
var b = 1
function foo() {
var b = 2
return a + b
}
foo() // 3

执行过程如下:

  1. foo 函数被创建,foo 会将父变量对象(其实是当前活动对象也是全局对象)保存在 [[scope]] 属性中。

    foo.[[scope]] = [globalContext.VO]
  2. 执行 foo 函数,创建 foo 函数执行上下文并压入执行上下文栈(ECS)中。

    ECS = [ fooContext,
    globalContext]
  3. foo 函数并不立刻执行,需要一些准备工作,第一步:复制函数的 [[scope]] 属性到创建的作用域链(Scope)中

    fooContext = {
    Scope: foo.[[scope]]
    }
  4. 第二步:创建活动对象并用函数的 arguments 属性初始化

    fooContext = {
    AO: {
    arguments: {
    length: 0
    },
    b: undefined
    },
    Scope: [
    foo.[[scope]]
    ]
    }
  5. 第三步:将活动对象压入 作用域链顶端

    fooContext = {
    AO: {
    arguments: {
    length: 0
    },
    b: undefined
    },
    Scope: [
    AO,
    foo.[[scope]]
    ]
    }
  6. 准备工作做完,开始执行 foo 函数里的代码,更新 AO 的属性值

    AO:{
    arguments: {
    length: 0
    },
    b: 2
    }
  7. foo 执行完毕,foo 函数上下文出栈并销毁

ECS = [
globalContext
]

闭包

什么是闭包

MDN 对闭包的定义为:

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

那什么是自由变量呢?

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

自由变量其实是指函数执行上下文的作用域链中非活动对象的那部分属性(也就是外层作用域的变量 函数.[[scope]])

所以在《JavaScript高级程序设计中》是这样描述的:

闭包是指有权访问另一个函数作用域中的变量的函数。

var a = 1
function foo() {
console.log(a)
}
foo()

foo 函数可以访问变量a,但 a 既不是函数参数也不是函数的局部变量,a 就是自由变量。
那么,foo 函数就是一个闭包。

到这里,你或许会有疑问

这怎么和我们平时知道的闭包不是同一个,不是什么函数中嵌套一个函数,里面的函数才是一个闭包,这里怎么没有嵌套函数了?

其实是站的角度不同:

  1. 从理论角度:所有函数都是闭包。因为他们都在创建时就将上层执行上下文的数据保存起来了(函数的 [[scope]] 属性)。所以在函数中可以作用域链访问到外层作用域的变量,也就是可以访问自由变量,它就是一个闭包。
  2. 从实际角度:以下才是闭包:
    • 即使创建它的上下文已经销毁,但它依然存在(比如,内部函数从父函数中返回)
    • 在代码中引用了自由变量

举个例子:

function foo() {
var a = 0
return function add() {
console.log(a++)
}
}
var add1 = foo()
var add2 = foo()

add1() // 0
add1() // 1
add1() // 2
add2() // 0
add2() // 1

foo() 函数执行完毕,foo函数执行上下文已经被销毁,那么上下文的变量对象中的 a 变量应该也被销毁了啊,问啥还能访问到?

因为 add 函数在定义时就存在一个 [[scope]] 属性,它保存了 foo 函数执行上下文的变量对象,在执行 add 函数时,会创建一个执行上下文链并将 add.[[scope]] 复制到该执行上下文的作用域链中,所以在 add 函数中可以通过作用域链访问到 a 属性。

你可能还有一个问题,为什么 add1()add2() 访问的不是同一个 a
因为每执行一次函数就会创建一个函数执行上下文,所以执行 add1 = foo()add2 = foo() 产生的是不同的执行上下文(对象),他们的 a 属性当然不同了。

闭包的面试题

经典面试题,使用闭包解决 for 循环中 var 异步打印 i 值的问题

for (var i = 0; i < 5 ; i++) {
setTimeout(function foo() {
console.log(i)
},1000 * i)
}

以上代码执行结果是:5 5 5 5 5

这里面涉及到事件循环机制,这里就不多赘述,简单的说就是等 for 循环结束后,才开始依次执行这几个 setTimeout() 里面的 foo 函数。

JS 没有块级作用域,此时的 i 值为 5 ,console.log(i) 访问的就是全局变量 i ,所以打印 5。

我们要做就是使用闭包的特性,让 console.log(i) 访问的不是全局变量 i

for (var i = 0; i < 5 ; i++) {
;(function(i) {
setTimeout(function foo() {
console.log(i)
},1000 * i)
})(i)
}

或者这样:

for (var i = 0; i < 5 ; i++) {
setTimeout((function foo(i) {
return function() {
console.log(i)
}
})(i),1000 * i)
}

也可以使用 ES6 中的 let 换掉 var,使得 for 循环中 i 成为一个块级作用域的本地变量。

for (let i = 0; i < 5 ; i++) {
setTimeout(function foo() {
console.log(i)
},1000 * i)
}

闭包的优缺点

闭包可以创建私有属性和方法。

var singel = (function () {
// 私有属性,外部访问不到
var age = 20
function foo() {
console.log('foo')
}
return {
// 公有属性
name: 'Tom',
getAge: function() {
return age
},
setAge: function(n) {
age = n
}
}
})()

console.log(singel.age) // undefined
singel.foo() // Uncaught TypeError: singel.foo is not a function
console.log(singel.getAge()) // 20
singel.setAge(10)
console.log(singel.getAge()) // 10

单例:指的是只有一个实例的对象。JavaScript 一般以字面量的方式来创建单例。

匿名函数最大的用途就是创建闭包。并且还可以构建命名空间,减少全局变量的污染。

通过匿名函数实现一个闭包计数器:

var numberCounter = (function() {
var num = 0
return function() {
return ++num
}
})()

闭包的缺陷:

  • 闭包所访问的自由变量会常驻内存增大内存的使用量,因此闭包滥用会造成网页性能问题。在老版本浏览器中由于垃圾回收有问题导致内存泄漏。正常使用闭包不会导致内存泄漏。

总结

  • 作用域是定义变量的区域,规定了变量的访问范围,它在函数定义时确定。
  • 执行上下文是一个对象,在函数执行时创建,它有变量对象、作用域链、this 三个属性。
  • 函数执行时,变量对象通过函数的 arguments 属性初始化,它包含函数的参数、函数体里声明的变量。
  • 函数执行时,作用域链是由函数的 [[scope]] 属性中的变量 + 活动对象中的变量组成的。
  • 闭包是访问外部作用域的变量的函数。
  • 闭包可以创建私有属性和方法。
  • 闭包滥用会影响页面性能。
© 2019 墨夜 All Rights Reserved.
Theme by hiero