要了解js的执行机制,那么首先需要明白执行上下文、执行栈以及作用域和作用域链的概念。
执行上下文
执行上下文(Execution Context),缩写为EC。js代码在执行之前需要做一些准备,类比我们在上课之前需要在教室中准备好粉笔,黑板课桌等等。js代码在执行之前也需要做一些准备,给代码的执行创建一个环境 —— 执行上下文。
执行上下文的类型
- 全局执行上下文:js代码在在执行前首先会创建一个全局执行上下文,并且一个程序只有一个。
- 函数执行上下文:当一个函数被调用时,就会为该函数创建一个新的执行上下文,函数执行上下文可以有多个。
eval函数执行上下文
现在了解了执行上下文,但是js代码在执行过程中怎么去找到不同的执行上下文呢?那么就需要一个存放这些不同执行上下文的地方 —— 执行栈。执行栈,顾名思义是一个栈的数据结构,有先入后出的特点。执行栈栈顶的执行上下文就是当前的执行上下文
现在两个重要的概念都了解之后,我们学习一下执行上下文到底是什么,它运作的过程是什么样的?
执行上下文的结构
可以看到执行上下文中主要包含四个部分:
- 变量环境用来存储所有的
var声明的变量以及函数声明; - 词法环境用来存储
let/const/class声明的变量和全局对象; this绑定对于全局执行上下文,如果是浏览器绑定的是window,如果是node.js指向的就是global,函数执行上下文中的this指向取决于函数的调用方法;- 外部引用其实就是作用域链,全局执行上下文引用为
null,函数执行上下文指向函数定义时的词法环境。
了解了执行上下文的结构,我们来用一个小的代码片段说明一下执行上下文的创建和执行。
创建和执行步骤
- 创建全局执行上下文,并加入执行栈顶
- 分析:
- 找到所有的非函数中的var声明,在变量环境中创建绑定
- 找到所有的顶级函数声明,在变量环境中创建绑定
- 找到顶级let、const、class声明,在词法环境中创建绑定
- 块级作用域中的变量声明(let/const)创建新的词法环境放入其中,函数声明特殊,类似于let
- 变量名重复处理:
let const class声明的变量名不能重复,他们与var function的名字也不能重复;若var和function名字重复,function声明的函数名优先 - 创建绑定:
- 变量环境绑定:
var初始化为undefined,函数初始化为函数对象,并且会把函数定义时的词法环境保存到函数对象中。 - 词法环境绑定:
let/const/class创建但未初始化 —— 暂时性死区
- 变量环境绑定:
- 执行语句
var a = 10
function foo(){
console.log(a)
let a
}
foo()
以上面的代码块为例
- 创建阶段,创建全局的执行上下文,在变量环境中存放
a变量,并初始化为undefined,存放函数名为foo,初始化为函数对象,并且保存函数定义时的词法环境(全局执行上下文的词法环境)。 - 执行阶段,变量a赋值为10,
foo函数被调用,会创建一个foo函数的执行上下文压入栈顶,这个执行上下文的词法环境的outer会指向foo函数初始化时保存在体内的那个词法环境,也就是指向函数定义时的执行上下文的词法环境(全局执行上下文的词法环境)。- 函数foo中又继续上述的创建步骤,在词法环境中存放变量
a,未初始化。 - 继续执行,
console.log(a),当前的执行上下文就是foo函数的执行上下文,所以会在foo函数的执行上下文中去查找变量a,找到为未初始化的状态,所以最终会报错。
- 函数foo中又继续上述的创建步骤,在词法环境中存放变量
到这里就说完了这个简单代码块的执行上下文的创建和执行,那么会不会有一个疑问,我在上面的描述中着重强调函数执行上下文的词法环境的一个指向,但是似乎也没有起到什么作用。
那么如果把函数foo中的let声明去掉我们就可以看出这个指向的用处了!如果去掉的话,函数执行上下文中就找不到这个变量a,那么就会沿着outer指向找到父级的执行上下文查看其中是否有变量a,最后会输出10。
这也就引出了作用域链!
作用域
那么在介绍作用域链之前,我们先了解什么是作用域
作用域是解析(查找)变量名的一个集合,规定了变量和函数的可访问范围,也就是它定义了在哪里可以访问什么变量。作用域就类比规则,当前执行上下文的词法环境就类比实现规则的数据结构。
作用域的类型
- 全局作用域:在代码任何地方都可访问的作用域,对应全局执行上下文。
- 函数作用域:函数内部创建的作用域,只在函数内部可访问。
- 块级作用域:ES6 引入
let和const后新增的作用域,由{}代码块创建。
我们再举一个例子来说明
function foo(){
console.log(a)
}
funtion bar(){
var a = 3
foo()
}
var a = 2
bar()
以上的代码最终会输出2。
- 创建阶段,将函数
foo、函数bar以及变量a存放在全局执行上下文的变量环境中,并且初始化。 - 执行阶段,首先给全局执行上下文中的
a赋值2,遇到bar(),创建一个新的bar函数执行上下文,将该执行上下文的outer指向全局执行上下文的词法环境,然后运行函数内部的代码。- 创建阶段,函数内部的变量
a存放在bar函数执行上下文的词法环境中。 - 执行阶段,变量
a赋值为3,调用foo函数。这时候会继续创建一个foo函数的执行上下文,并且它的outer指向也是全局执行上下文的词法环境,然后foo函数中就一句代码,输出a,就会先去当前执行上下文中查找有没有变量a,发现没有;那么!就会沿着outer指向继续往父级查找到全局执行上下文,发现有变量a,值为2。
- 创建阶段,函数内部的变量
这就是作用域链!经过以上的分析,我们也发现函数的作用域是函数定义时的作用域决定的,和函数调用时的作用域没有关系,否则输出就应该是3。
现在对于函数作用域有了一定的了解后,我们继续看块级作用域。
遇到块级作用域,也有以下几个步骤:
- 创建新的记录环境(词法环境),连接在原来记录之前
- 分析:
- 所有的顶级函数声明
let/const声明
- 名字重复处理
- 创建绑定:
- 登记
function,初始化为函数对象 - 登记
let const,未初始化
- 登记
- 执行语句
细心的同学肯定已经发现,遇到块级作用域{},我们的处理方式其实之前类似,但是不会创建新的执行上下文了,而是改成了创建新的一个记录指向原来的记录。
用以下代码块来说明:
let inIf = 'out of statement'
if(true){
let inIf = 'in of statement'
console.log(inIf)
}
console.log(inIf)
最后会先输出in of statement,再输出 out of statement
如上图,执行完块级作用域后,这个记录就会销毁,然后把原先的记录重新接回。
那么我们再看下面这个例子,体会闭包的作用
var liList = []
for(var i = 0; i < 5; i++){
liList[i] = function(){
console.log(i)
}
}
liList[0]()
liList[1]()
liList[2]()
liList[3]()
liList[4]()
以上代码我们执行会发现,五个函数调用后输出的结果都是5。这是因为var没有块级作用域,会直接把变量i存放在全局执行上下文中,后面块级作用域中定义的所有函数的environment属性都指向全局执行上下文的词法环境,所以循环结束 i为5,每个函数调用创建的新的函数执行上下文中的outer都指向全局执行上下文,也会去全局中找i输出都为5。
将上述代码改为:
var liList = []
for(let i = 0; i < 5; i++){
liList[i] = function(){
console.log(i)
}
}
liList[0]()
liList[1]()
liList[2]()
liList[3]()
liList[4]()
就可以利用闭包,让调用输出的i不再是共享的值。因为let声明创建了块级作用域,每次循环都会创建一个新的词法环境存储变量i,定义的五个函数的enviroment属性也会指向不同的块级作用域。最后调用沿着作用域链找到的也是不同的i值。
闭包
这个例子清晰地展示了闭包的核心机制:函数能够记住并访问其定义时所处的词法作用域,即使该函数在其词法作用域之外被调用。在上面的例子中,每个函数都通过闭包"记住"了定义时所在的块级作用域(及其中的 i值)。
闭包的主要用途
- 数据封装与私有变量
- 函数工厂与柯里化
- 事件处理与异步编程
闭包的注意事项
- 内存泄漏风险
- 性能考虑