顺序执行
关于JavaScript 的代码执行,我们都知道它是顺序执行 的,那么先看看下面的代码
say() //“hello”
var say = function () {
console.log("hi")
}
say() //"hi"
function say() {
console.log("hello")
}
say() //“hi”
如果按照顺序执行,那完全不能解释上面的现象呢,为什么第一个say() 会打印出值,为什么第三个say() 不是打印的hello,这哪里是顺序执行了 。
所以有请JavaScript 的特性—变量提升,函数提升:JavaScript 引擎会将变量和函数声明提升至作用域内的顶部,然后再按照顺序执行,所以也还是顺序执行。
可是为什么JavaScript 引擎会知道将变量和函数声明进行提升呢?
这是因为JavaScript 引擎并非一行一行地分析执行,而是一段一段地分析执行,按照‘“段落”顺序执行。“段落”内又分为“小节”,会进行一个“准备工作”,比如上面的变量提升和函数提升,“准备工作”完成后,就顺序执行这段重排后的代码。
可是这个“段落”是怎么划分的呢?怎么进行“准备工作”呢?
作用域
作用域是指程序源代码中定义变量可用,有效的区域,确定当前执行代码对变量的访问权限。
作用域分两种:静态作用域与动态作用域。
静态作用域表示函数的作用域在函数定义的时候就确定;
动态作用域表示函数的作用域在函数调用的时候才决定的。
那么JavaScript 是哪种呢?
var value = 1
function say() {
console.log(value)
}
function toSay() {
var value = 2
say()
}
toSay() //1
从这个结果来看,打印的结果是1 。而say() 执行时,即在toSay() 内部,value 被赋值为2。如果是动态作用域,按照定义,打印的结果应该是2 ,而这个结果却是1,所以JavaScript 采用的是静态作用域 。
那么接下来,看一看《JavaScript》权威指南 中的例子
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope(); //local scope
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()(); //local scope
JavaScript函数的执行用到了作用域链 ,这个作用域链是在函数定义的时候创建的。嵌套的函数f()定义在这个作用域链里,其中的变量scope一定是局部变量,不管何时何地执行函数f(),这种绑定在执行f()时依然有效。
这是犀牛书上给出的解释,好像是这样,但也没说清楚上面两段代码为什么是一样的结果?其中提到了作用域链,函数的执行是基于作用域链的,那么这个作用域链到底是怎么创建的呢,其中是什么?
执行上下文栈
我们知道了JavaScript 是“一段一段”顺序执行,而且其采用的是静态作用域,函数作用域是在函数定义时确定的。
所谓“段落”其实就是按照作用域划分,将所有可执行代码按照作用域嵌套和隔断,划分出一个个“段落”,而所用作用域中有一个最顶级的,那就是全局作用域。按照这个划分,对应的“段落”就形成了全局代码,函数代码(通过函数进行作用域的划分)。
我们通过函数来进行作用域的划分,而函数的执行依赖作用域链,这个好像有点矛盾呢,怎么既通过函数进行作用域的划分,而它的执行又要依赖作用域链,到底是先有鸡还是先有蛋啊?
前面我们说过,“段落”执行时会先有一个“准备工作”,然后才是执行代码。作用域链就是在“”准备工作“中完成的,专业一点,这里的”准备工作“是创建执行上下文 。
那么一个“段落“即函数就会有一个执行上下文。函数那么多,有的还层层嵌套,JavaScript 引擎怎么识别和管理这些执行上下文呢?因此JavaScript 引擎创建了执行上下文栈(ECS) 来管理这些执行上下文。
执行上下文栈ECS
当JavaScript 引擎解释执行代码过程中,首先必然是全局代码,所以首先就会向ECS栈压入全局执行上下文 ,直到整个代码全部执行完毕,才会弹出。所以在代码运行过程中,栈低永远有一个全局执行上下文。
在全局代码中,经过变量提升和函数提升,所有的代码已经按顺序排排坐好,等待引擎执行的到来。
当执行到一个函数时,就会创建一个执行上下文,并且压入ECS栈,当函数执行完毕后,再将执行上下文从栈中弹出。
因为作用域是通过函数进行划分的,所有,执行上下文通过函数执行去创建。我们知道了ECS栈是如何处理执行上下文的了,我们在看看前文引用犀牛书上的例子
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f();
}
checkscope();
var scope = "global scope";
function checkscope(){
var scope = "local scope";
function f(){
return scope;
}
return f;
}
checkscope()();
两段代码最主要的区别就是在checkscope 的执行,简单的看,一个只执行了一次,一个执行了2次,但真的是这样吗?
其实第2段代码中checkscope 并不是执行了两次,是因为其返回的是一个f函数,其并不是真正想要的返回值,所以再执行了一次f 函数。为什么第一段不需要呢,因为第一段代码中checkscope 的返回值是f 的结果,而不是f本身 。说到这里,前后两段代码主要的区别就是checkscope 和f 的执行顺序。
联系到执行上下栈,第一段代码应该是:
ECS.push(checkscope_context)
ECS.push(f_context)
ECS.pop()
ECS.pop()
第二段代码应该是:
ECS.push(checkscope_context)
ECS.pop()
ECS.push(f_context)
ECS.pop()
执行上下文
在解释执行上下文之前,我将函数的分为两种情景:定义和执行
- 定义 :函数提升后,进行函数的定义,创建函数上下文,但并不涉及函数内部的运行
- 执行 :调用函数并执行,分为三个阶段
- 分析 : 基于函数上下文创建执行上下文,并将其压入ECS栈
- 执行 :基于执行上下文执行代码
- 结束 :从ECS栈中弹出执行上下文
其实函数上下文和执行上下文说的是一回事,只不过是不同阶段解读。、
函数的上下文在定义时创建叫做函数上下文,执行时基于函数上下文创建的叫做执行上下文。它们都有三个重要属性:变量对象 ,作用域链 ,this
变量对象
变量对象是该函数内所有形参,变量,函数声明的集合,存储了在上下文中定义的变量和函数声明:
- 形参:由函数的
arguments属性初始化,如果没有实参,属性值设为undefined - 函数声明:函数内部声明的内层函数,如果变量对象已经存在相同名称的属性则替换
- 变量声明:函数内部的私有变量声明,如果变量对象已经存在相同名称的属性则忽略
变量对象在函数执行的过程中也是在不断产生和变化的。
- 分析阶段:初始化形参,函数声明,变量声明的属性值
- 代码执行:根据表达式执行结果修改内部
举个例子
var m = 1;
function add(x, y) {
var result = 0;
function count() {};
result = x + y;
return result;
}
add(1, 2);
//定义时 只有arguments对象
VO = {
arguments: {
0: undefined,
1: undefined,
length: 2
}
}
//执行期 解析时 初始化内部变量,函数声明
AO = {
arguments: {
0: undefined,
1: undefined,
length: 2
},
count: function count() {}
result: undefined
}
//执行
AO = {
arguments: {
0: 1,
1: 2,
length: 2
},
count: function count() {}
result: 3
}
作用域链
作用域链是保存所有父变量对象的层级链,因为它有关父变量对象的部分,是在函数定义时就确定的,所以函数的作用域也就是静态作用域。
作用域链在函数的不同阶段也有变化:
- 定义:函数创建时,函数有一个内部属性scope,它保存所有父变量对象在其中,这个时期的作用域链等同于这个内部属性scope。
- 执行:将初始化后执行上下文的变量对象添加到作用域链,然后代码执行过程中,优先查找执行上下文中的变量对象,如果没有,则再沿着这条作用域链向上查找
function a() {
return function b() {
return function c() {
//code
}
}
}
a.scope = [GLOBAL]
b.scope = [a.VO, GLOBAL]
c.scope = [b.VO, a.VO, GLOBAL]
a()
a.scope = [a.AO, GLOBAL]
b.scope = [b.AO, a.VO, GLOBAL]
c.scope = [c.AO, b.VO, a.VO, GLOBAL]
this
在JavaScript 世界中,this 真的是一个善变的东西。通俗的说,它跟作用域链不一样,它反而依赖函数执行时的环境,而不是函数定义时的环境。
let o = {
get: function () {
console.log(this)
}
}
let copyGet = o.get
o.get() // o
copyGet() // window
上面的说法好像是对的,但这种说法是最终的真相吗?我们回到规范去看看this 到底是怎么定义的。
11.1.1 this关键字
this 关键字执行为当前执行环境的 ThisBinding
11.2.3 函数调用
- 令 ref 为解释执行 MemberExpression 的结果 .
- 令 func 为 GetValue(ref).
- 令 argList 为解释执行 Arguments 的结果 , 产生参数值们的内部列表 (see 11.2.4).
- 如果 Type(func) is not Object ,抛出一个 TypeError 异常 .
- 如果 IsCallable(func) is false ,抛出一个 TypeError 异常 .
- 如果 Type(ref) 为 Reference,那么 如果 IsPropertyReference(ref) 为 true,那么 令 thisValue 为 GetBase(ref). 否则 , ref 的基值是一个环境记录项 令 thisValue 为调用 GetBase(ref) 的 ImplicitThisValue 具体方法的结果
- 否则 , 假如 Type(ref) 不是 Reference. 令 thisValue 为 undefined.
- 返回调用 func 的 Call 内置方法的结果 , 传入 thisValue 作为 this 值和列表 argList 作为参数列表
要搞清楚this的指向,首先要了解Reference 类型
Reference
ECMAScript 的类型分为语言类型和规范类型,语言类型是指常用的,我们可操作的Undefined , Null, Boolen, String, Number, Obejct 等。规范类型指底层用来描述ECMAScript 语言底层行为逻辑的。Reference 是其中一种。
Reference 是用来解释delete, typeof以及赋值等操作的,主要关注属性的状态
- base value: 属性所在的对象或
EnvironmentRecord - referenced name:属性的名称
获取Reference 两种方法
- GetBase:返回
reference的base value - isPropertyReference:如果
base value是一个对象,返回true,否则false
获取Reference 类型属性对应值的方法
- GetValue:返回属性对应真正的值
//伪代码
Reference = {
base: undefined, an Object, a Boolean, a String, a Number, or an environment record,
name: "name",
strict: false
}
function GetBase(Reference) {
return Reference.base;
}
function isPropertyReference(Reference) {
return Reference.base === "object";
}
了解了Reference 后,我们再回来跟着规范去判断this
我们最主要的需要得出MemberExpression 的结果,那么MemberExpression 又是什么呢?
MemberExpression 即表达式,其主要由以下六种表达式,通过查阅规范,分别得出每种表达式返回值的类型
| 类型 | 表达式 | Reference |
|---|---|---|
| 原始表达式 | true, false, null ... | × |
| 初始化表达式 | var x = 1 | × |
| 函数表达式 | function a() {} | × |
| 属性访问表达式 | a.b | √ |
| 对象创建表达式 | new Object() | √` |
跟着规范转了半天,好像没什么用哦,还是要进入实际应用,回头看看前面的例子
let o = {
get: function () {
console.log(this)
}
}
let copyGet = o.get
o.get()
copyGet()
首先看看o.get():
-
o.get属于属性访问表达式,ref返回一个baseReference -
baseReference = { base: o, name: "get" } -
isPropertyReference(baseReference) => true -
GetBase(baseReference) => o -
this = o
bingo!,正确
再看看copyGet()
-
copyGet为函数定义表达式,ref为一个函数对象,而不是Reference -
ref !== Reference -
this = undefined
这个结果和前面浏览器运行的结果不一样啊,浏览器的运行结果是window ,即全局对象。
根据规范来看,无论怎么判断,this 的指向都只可能是undefined 或 实际对象,不会是全局对象的。
其实,这里是undefined 的隐式类型转换,转换成了全局对象。
虽然这里根据规范寻找this 的脉络,没什么太大的意思,但其实提供了更加深入理解this 和执行上下文的方法
从执行上下文看闭包
闭包是指那些能够访问自由变量的函数。
自由变量是指在函数中使用,但既不是函数参数也不是函数的局部变量的变量。
直接看代码来捋一捋
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}
这里打印的都是1,这个毫无疑问,那么我们从执行上下文来解释一下为什么
data[0]()执行之前,此时的ECS 栈底为全局执行上下文
ECS = [GLOBAL]
GLOBAL = {
VO: {
data: [func, func, func],
i: 3
}
}
执行data[0]函数,将data[0]的执行上下文压入栈
ECS = [data_0_context, GLOBAL]
data_0_context = {
VO: {
arguments: {
length: 0
}
}
scope: [VO, GLOBAL.VO]
}
执行data[0] 时,其执行上下文的VO 并没有关于i 的信息,所以去作用域链查找,其上一级就是全局执行上下文的变量对象,i=3 ,所以直接打印为3
那么,我们一般的闭包解决方案呢
var data = [];
for (var i = 0; i < 3; i++) {
data[i] = (function (i) {
return function(){
console.log(i);
}
})(i);
}
在这个闭包程序中,for 循环每次执行时,都执行过一次匿名函数,所以在data[0] 执行时,执行环节如下
ECS = [data_0_context, GLOBAL]
data_0_context = {
VO: {
arguments: {
length: 0
}
}
scope: [VO, Anonymous.VO, GLOBAL.VO]
}
Anonymous = {
VO: {
arguments: {
0: 0,
length: 1
},
i: 0
},
scope: [GLOBAL.VO]
}
执行data[0] 时,其执行上下文的VO 仍然并没有关于i 的信息,所以去作用域链查找,但这次其执行上下文是Anonymous, i 是作为形参传入的,所以其变量对象上是有记录的i: 0 ,所以直接返回i = 0 。
由上面两个例子可以看出,所谓闭包,主要是由于执行上下文自动维护的作用域链,由于作用域链的存在,data[0] 依然可以读取到匿名函数的中的变量对象的值,即虽然匿名函数已经执行完毕了,匿名函数的指针被销毁了,但其变量对象依然存在内存中,所以data[0] 等依然可以依靠作用域链找到它。