一. 前言
对于一门编程语言来说,变量的存储和访问,是最基本的功能之一。今天,将向大家深入介绍一下,JS 程序在运行的时候,如何找到变量,这需要一套良好的规范来规范,这套规则,就成为了作用域
二. V8 JS 代码运行的步骤
在介绍作用域的知识点之前,还需要先介绍一下,V8 JS 代码的运行流程。网上有较多的 V8 工作运行流程图,举例如下:
其中后面的几步骤,像 Ignition 的字节码、TurboFan 优化编译器,主要是 V8 为了优化性能而做的,在本文不做过多展开。本文主要专注于前面几步骤,可以看到,V8 在拿到 JS 的 source code 后,会首先解析生成 AST 抽象语法树,然后后面再经过若干编译步骤,生成机器码并被运行。其中的作用域规则确定,是在解析成 AST 这一步骤的时候,就已经被确定了
下面以 V8 源码内的 hello-world 程序为例,对流程进行讲解:
github.com/v8/v8/samples/hello-world.cc
在介绍上图的代码流程之前,先介绍一些概念:
isolate:
- 一个 Isolate 是一个独立的虚拟机。对应一个或多个线程。但同一时刻 只能被一个线程进入。
- 所有的 Isolate 彼此之间是完全隔离的, 它们不能够有任何共享的资源。
- 如果不显式创建 Isolate, 会自动创建一个默认的 Isolate
handleScope:
- 表示JS对象的生命周期的范围。
- 在 V8 中,内存分配都是在 V8 的 Heap 中进行分配的,JavaScript 的值和对象也都存放在 V8 的 Heap 中。而 Handle 即是对 Heap 中对象的引用。V8 为了对内存分配进行管理,GC 需要对 V8 中的所有对象进行跟踪。HandleScope 就是用来管理 Handle 的
- Handle 分为 Local 和 Persistent 两种。Local 是局部的,创建一个指向 JS 对象的本地引用,它同时被 HandleScope 进行管理。 persistent,创建一个指向 JS 变量的持久引用,类似与全局的,不受 HandleScope 的影响,其作用域可以延伸到不同的函数。
context:
- 可以理解为「执行上下文」或者「 执行环境」
- 每当程序的执行流进入到一个可执行的代码时,就进入到了一个执行环境中
三者关系的示意图如下:
回到上述一开始的源码截图里,大概做了以下几步工作:
- 定义了一个 isolate
- 在 isolate 下,定义了一个handle_scope。handle_sope 的生命周期,决定了下面所有 v8::Local 的声明周期的有效性
- 定义了一个 context,并切换进入
- 编译 JS 源码成字节码
- 在当前 context 中,运行上一步编译出的字节码
三. 作用域与执行上下文相关
在上面第二节中,简单介绍了 V8 执行 JS 代码的流程。再简单概括一下,JavaScript属于解释型语言,JavaScript 的执行分为 「解释 」和 「执行 」两个阶段,如下:
解释阶段:
- 词法分析
- 语法分析
- 作用域规则确定
执行阶段:
- 创建执行上下文 context
- 执行函数代码
- 垃圾回收
-
两者区别
很多同学容易混淆,「作用域 」和 「执行上下文」 这两个概念,的确他们两有一定的相关性,但又有区别:
-
可以把作用域抽象理解成,是根据名称查找变量的一套规则,这套规则用来管理 js 引擎根据标识符名称如何查找变量。而一系列的嵌套作用域就形成了作用域链(你不知道的 JavaScript 中定义)
-
而执行上下文,如上一节中描述,是在函数运行之前,V8 创建的函数运行环境
-
作用域在 AST 解析阶段就确定,不会改变;而执行上下文,是在执行阶段才确定,可能发生改变。举个例子:
var a = 10;
function fn() {
var b = 20;
function bar() {
console.log(this.b); // 200
console.log(a + b); // 30
}
return bar;
}
var x = fn(),
b = 200;
x();
上面的打印的结果为 200、30,是因为对于 this.b 来说,this 的指向,就是执行上下文中确定的;而 bar 函数中的 b 值,是在 AST 解析 bar 函数定义时,就已经明确 bar 函数的作用域链,为 bar -> fn -> 全局,所以 b 变量会沿着作用域链寻找,找到 fn 中的定义,值为 20
- 一个作用域下,可能包含若干个上下文环境。因为在一个函数作用域里,每次在调用别的函数前,都要先创建调用函数所需的执行上下文。但是调用函数的次数是不定的,需要在运行时才能确定
-
函数执行流程介绍
为了更深入的介绍作用域与执行上下文的原理,我们以一个函数的执行为例子,进行详细描述,并对其中一些概念再进行介绍:
var scope = "global scope";
function checkscope() {
var scope = "local scope";
function f() {
return scope;
}
return f();
}
checkscope();
复制代码
-
执行全局代码,创建全局执行上下文,全局上下文被压入执行上下文栈
ECStack = [
globalContext
];
执行上下文栈:如上述介绍,V8 的执行流,在进入可执行代码之前,会为其创建一个执行上下文。执行流依次进入的执行环境,在逻辑上形成了一个栈,则成为执行上下文栈。栈的底部永远是全局环境,栈的顶部则是处于活动状态的当前执行环境(浏览器总是执行,处于栈顶的上下文)
-
全局上下文初始化(初始化全局环境的变量对象 VO,确定全局环境的 Scope,绑定全局环境的 this)
globalContext = {
VO: {
global: window,
scope: undefined,
checkscope: reference to function checkscope
},
Scope: [globalContext.VO],
this: globalContext.VO
}
变量对象 VO:
-
存储了在上下文中定义的变量和函数声明;除了我们无法访问它外,和普通对象没什么区别
-
对于函数,执行前的初始化阶段叫变量对象,执行中就变成了活动对象
-
每一个执行环境都有一个与之相关的变量对象,其中存储着上下文中声明的:变量、函数、形式参数
-
checkScope 函数执行前阶段。初始化的同时,checkscope 函数被创建,保存全局环境的作用域链,到函数 checkscope 的内部属性 [[scope]] 中
checkscope.[[scope]] = [
globalContext.VO
];globalContext = {
VO: {
global: window,
scope: “global scope”,
checkscope: reference to function checkscope
},
Scope: [globalContext.VO],
this: globalContext.VO
} -
执行 checkscope 函数,创建 checkscope 函数执行上下文,checkscope 函数的执行上下文,被压入执行上下文栈
ECStack = [
checkscopeContext,
globalContext
]; -
初始化 checkscope 函数执行上下文。会有以下几步:
- 用 arguments 创建活动对象 checkscopeContext.AO
- 利用 checkscopeContext.AO 与 checkscope.[[scope]],形成checkscope 函数执行环境的作用域链 checkscopeContext.Scope
- 绑定 this 到 undefined(非严格模式下会绑定到全局对象)
checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope: undefined,
f: reference to function f(){}
},
Scope: [AO, globalContext.VO],
this: undefined
}
活动对象 AO:
-
在没有执行当前环境之前,变量对象中的属性都不能访问。但是进入执行阶段之后,变量对象转变为了活动对象,所以活动对象和变量对象其实是一个东西,只是处于执行环境的不同生命周期
-
AO 实际上是包含了 VO 的。因为除了 VO 之外,AO 还包含函数的参数 parameters,以及 arguments 这个特殊对象
-
f 函数执行前阶段。更新 f.[[scope]], checkscopeContext.AO.scope 等赋值
f.[[scope]] = [
checkscopeContext.AO,
globalContext.VO
];checkscopeContext = {
AO: {
arguments: {
length: 0
},
scope: “local scope”,
f: reference to function f(){}
},
Scope: [AO, globalContext.VO],
this: undefined
} -
执行 f 函数,创建 f 函数执行上下文,f 函数执行上下文被压入执行上下文栈
ECStack = [
fContext,
checkscopeContext,
globalContext
]; -
f 函数执行环境初始化(参考第 e 步)
fContext = {
AO: {
arguments: {
length: 0
}
},
Scope: [AO, checkscopeContext.AO, globalContext.VO],
this: undefined
} -
f 函数中代码执行。需要对 scope 进行 RHS 查找。查找从作用域链中当前活动对象,开始沿着作用域链向上查找
// 查找过程: 1. fContext.AO.scope 没有该变量声明,继续 2. checkscopeContext.AO.scope 有该变量声明,获取其值为"local scope" 复制代码
LHS、RHS:为 V8 引擎的两种查询方式
-
LHS:代码中出现变量时,目的是要进行存储,也就是我们关心的是要找到变量的容器本身,来进行不同数据的存储赋值操作,而不关心现在这个容器里面存的是什么
-
RHS:目的只是拿这个变量来用,也就是只关心这个变量存储的内容是什么,而不需要关心这个变量存在哪个容器
-
f 函数执行完毕,返回”local scope”。f 函数上下文从执行上下文栈中弹出
ECStack = [
checkscopeContext,
globalContext
]; -
checkscope 函数在执行完 f 处,获取 f 执行的返回值 “local scope”,函数继续向下执行
-
checkScope 执行完毕,返回获取到的返回值 “local scope”。checkScope 函数上下文,从执行上下文栈中弹出
ECStack = [
globalContext
]; -
代码执行流回到全局执行环境中调用 checoscope 处,拿到 checkScope 返回值并继续向下执行
-
直到程序终止,或者页面关闭。全局上下文出栈并销毁
作者:陆瀚陶