来点干货,在本文,你将学到js编译的原理,以及词法相关的概念,如词法作用域,词法环境等
那就开始今天的学习吧
编译原理
AST
ast抽象语法树,是源代码结构的一种抽象表示。
可以从我的另一篇文章学习:AST语法树(转载) - 掘金 (juejin.cn)
可以下载 acorn 包进行语句的 ast 的解析:
const acorn = require("acorn");
const code =
`
var x = 1;
`;
const ast = acorn.parse(code);
console.log(JSON.stringify(ast, null, 4));
生成如下(看不懂没关系,往后看,看完再回来看这个!):
词法分析
通过词法分析器扫描源代码,并经过分析器和识别器之后把它们拆分成一个个Token词法单元序列,可以是数字、标签、运算符等
语法分析
通过解析器将词法分析出来的Tokens序列,按预定规则转换为树形的表达形式,同时判断是否有语法错误。通过语法检查后生成AST
js是解释性语言,编译时运行时进行的,js引擎会对代码进行语法分析和抽象语法树的构建(分析时编译),并根据AST生成可执行的字节码或机器码(执行时编译)
注:js是一门弱语言,没有静态代码分析,而ts引入了类型系统,编译器在编译代码时就能够进行类型检查和类型推断,从而发现代码问题
词法
词法指的是特定文本内语词的构成和使用的法则
let、const和var的区别?
当面试官问你:let、const和var的区别?你该怎么回答呢?
从作用域来说
- var声明的变量存在于函数作用域或者全局作用域
- let和const声明的变量存在于块级作用域
开发者断点调试并不能直接看到块级作用域,不会显示词法环境本身,而只是显示当前执行上下文的变量和函数定义
从内存分配来说
- var会在栈预先分配内存空间,等到实际执行语句再存储对应的变量,如果是引用类型,会在堆内存分配一个内存存储实际内容,栈内容会存储一个指向堆的内容
- let和const不会预先分配内存空间,而且在栈分配内存时,会做一个检查,如果有相同变量名存在则报错 (SynctaxError: 语法错误)
- 对于const,存在栈内存的数据是不可修改的
从变量提升来说
- var存在函数提升,即会将声明提升到作用域顶部,在赋值前访问该变量会得到undefined
- let和const不存在变量提升,且在let和const定义前对变量的访问会报出Reference Error引用错误,即存在暂时性死区
不急,且听我分析:
词法作用域
词法作用域 (也称静态作用域) 是指变量的作用域是在代码编写时就确定的,不会受到代码执行位置的影响。
即变量的作用域由代码在定义时的位置所决定的作用域规则。
在语法分析阶段生成的 AST 语法树中,每个节点表示一个语法单元,如表达式、语句、函数等,即描述了程序的语法结构,但是,并没有包含作用域信息,所以需要进行作用域分析来确定每个标识符在程序中的作用域,并将其绑定(bind)到相应的变量或函数
AST 树的节点之间通常会存在父子关系,代表了不同语法单元之间的嵌套关系。
在编译阶段就已经确定了变量的作用域范围
词法环境
词法环境(Lexical Environment)是 JavaScript 中的一个重要概念,它描述了变量和函数标识符在代码中的作用域和可见性。每个函数和代码块都会创建一个词法环境,用于管理其中的变量和函数标识符。
词法环境由两部分组成:环境记录和外部词法环境引用。环境记录是一个存储变量和函数标识符的数据结构,它是一个键值对的集合。外部词法环境引用指向了包含当前词法环境的外部词法环境 (当前作用域对外部词法环境的引用),形成了一个链式结构。这个链式结构就是作用域链。
作用域链
JavaScript 作用域链(Scope Chain)是指当 JavaScript 代码中需要查找一个变量的时候,会按照定义时的作用域链来查找变量,直到找到为止。作用域链是由当前作用域和上层作用域的变量对象组成的,通过作用域链可以访问到外部作用域的变量和函数。
作用域链查找规则如下:
- 当执行一个函数时,会创建一个新的执行上下文(Execution Context),并压入执行栈(Execution Stack)中。
- 在创建执行上下文时,会创建一个变量对象(Variable Object),它包含了当前作用域中的变量、函数声明和函数的形参。
- 然后,JavaScript 引擎会将当前变量对象添加到作用域链的最前端。
- 在查找变量时,首先从当前变量对象开始查找,如果找不到,就到上一级作用域的变量对象中查找,直到找到为止。如果一直查找到全局作用域还找不到,就会抛出 ReferenceError 异常。
- 在函数执行完成后,执行上下文会从执行栈中弹出,并将当前变量对象从作用域链中移除。
总结一下,作用域链的查找顺序是从内到外,从当前执行上下文的变量对象到全局执行上下文的变量对象,如果找不到就会报错。
回到之前的问题
let、const、var区别?
我们从解析 let
| const
| var
过程讲起:
- 词法分析:
将源代码分解成词汇单元,并建立起™的关系,生成Token序列
- 语法分析:
将Token序列转化为语法树
- 确定变量声明的位置
根据AST中let和const关键字的位置,确定™的变量声明的位置,是在块级作用域的起始位置,而不是在变量声明的位置之前(即没有函数提升)注意:let和const声明的变量会绑定到最近的块级作用域中,而不是隐式地创建一个块级作用域
- 检查变量是否已经声明
在块级作用域内,如果一个变量名已经在词法环境中声明过,则会抛出一个重复声明错误
- 初始化变量
在var变量声明语句执行前,变量的值为undefined,因此需要对变量进行初始化
啊这、这啥跟啥?
别急,听我慢慢分析:
如下代码:
let a = 1;
console.log(window.a, a); // undefined, 1
此时,全局作用域window是不存在a变量的,这个a变量是存在于脚本作用域(块级作用域中),回到我刚才那句话:注意:let和const声明的变量会绑定到最近的块级作用域中,而不是隐式地创建一个块级作用域。
我之前一直以为let和const声明的变量会自动隐式的创建一个块级作用域来存储,翻了一天伪代码源码。。。查了一天资料也没找到类似createBlock等创建块级作用域的代码
我们直观的从下面代码看一下 let
和 const
和 var
解析成 AST
后长啥样
const acorn = require("acorn");
const code = `
function foo() {
const x = 1;
let y = 2;
var z = 3;
}
`;
const ast = acorn.parse(code);
console.log(ast);
留下重要部分AST的输出,如下:
{ // type表示节点类型
"type": "Program", // 整个代码文件或模块的根节点
"body": [
{
"type": "FunctionDeclaration", // 函数声明节点
"id": {
"type": "Identifier", // 标识符节点,用于表示变量名或函数名
"name": "foo" // 函数名
},
"expression": false,
"generator": false, // 是否是生成器
"async": false, // 函数是否是async
"params": [],
"body": { // body内容域
"type": "BlockStatement, // 代码块节点,用于表示花括号包裹的一组语句
"body": [
{
"type": "VariableDeclaration", // 变量声明节点
"declarations": [ // 解析等号右边的式子。描述声明的具体情况,变量的名字,变量的初始化描述都在里面。变量声明最主要的都在里面
{
"type": "VariableDeclarator",// 变量声明节点,包括 var、let、const 等
"id": {
"type": "Identifier", // 标识符节点,用于表示变量名或函数名
"name": "x"
},
"init": {
"type": "Literal", // 字面量节点,用于表示字符串、数字、布尔值等常量
"value": 1,
"raw": "1"
}
}
],
"kind": "const" // 识别当前节点的类型
},
{
"type": "VariableDeclaration",
"declarations": [Array],
"kind": "let"
},
{
"type": "VariableDeclaration",
"declarations": [Array],
"kind": "var"
}
]
}
}
],
"sourceType": "script"// 指定代码的语法类型,可以为script脚本语法,也可以是module模板语法
}
/*
不同type类型:
- Program:整个代码文件或模块的根节点
- VariableDeclaration:变量声明节点,包括 var、let、const 等
- FunctionDeclaration:函数声明节点
- BlockStatement:代码块节点,用于表示花括号包裹的一组语句 {}
- ExpressionStatement:表达式语句节点,用于表示一条语句中的表达式部分
- CallExpression:函数调用节点
- Identifier:标识符节点,用于表示变量名或函数名
- Literal:字面量节点,用于表示字符串、数字、布尔值等常量
*/
看了一些源码,并写了一些大概的执行代码
首先,我们需要定义一个symbols符号表(这里用Map实现)来存储变量对象(包括作用域和body)
对于const和let声明的变量会继承于当前此法环境(scope作用域)
当然,源码还会定义一个Scope类来确定作用域链,这个我们后面讲
如下:
const acorn = require("acorn");
const code = `
{
const x = 1;
let y = 2;
console.log(x, y);
}
`;
const ast = acorn.parse(code);
const symbols = new Map();// 符号表
// 遍历代码块
function traverse(node, scope) {
if (node.type === "VariableDeclaration" && (node.kind === "const" || node.kind === "let")) {
// 如果是 const 或 let 声明,则将该变量加入到符号表中
node.declarations.forEach(declaration => {
symbols.set(declaration.id.name, scope);
});
} else if (node.type === "Identifier") {
// 如果是标识符节点,则从符号表中查找该变量的作用域
const variableScope = symbols.get(node.name);
if (variableScope !== undefined) {
console.log(`${node.name} is in scope of ${variableScope}`);
}
}
// 递归遍历子节点
if (node.body) {
node.body.forEach(childNode => {
traverse(childNode, node);
});
}
}
// 从根节点开始遍历
traverse(ast, null);
console.log(symbols);
/*
Map(2) {
'x' => Node {
type: 'BlockStatement', // 块级作用域
body: [ [Node], [Node], [Node] ]
},
'y' => Node {
type: 'BlockStatement',
body: [ [Node], [Node], [Node] ]
}
}
*/
当然,真正实现没有那么简单,对于块级作用域的函数声明,还会映射到全局作用域(window)。对于在脚本作用域的var的声明,也会映射到全局作用域(window)中
标:如果对源码感兴趣的话可以去 npm
安装 @babel/parser
和 @babel/traverse
来看,诶~反正我是真看不懂
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
总结:
变量声明的解析过程会分为两个阶段:词法绑定和执行。
词法绑定
在第一个阶段,解析器会扫描代码并创建变量的词法绑定。对于let和const声明,由于它们声明的变量具有块级作用域,因此会在作用域链中创建一条新的块级作用域,且该作用域链是在代码解析时就已经确定了。这意味着,在代码块内部声明的变量只能被该块级作用域以及其内部的子作用域访问。
执行
在第二个阶段,当解释器遇到let或const声明时,它会在当前作用域链上查找该变量名,如果找不到则会根据声明创建一个新的变量,并将其添加到作用域链上。如果找到了同名的变量,则会抛出一个SyntaxError错误。在执行过程中,let和const声明的变量也不能在声明之前被访问,因为它们不会被提升。
顺便略带讲一下函数提升吧:
函数提升
- 允许在块级作用域内声明函数
- 函数声明(函数名称) 类似于 var,即会提升到全局作用域或函数作用域的头部
- 同时,函数声明(函数整体) 还会提升到所在的块级作用域的头部
- 在{}里的funcion也会提升,提升到全局和代码块顶部。并新建局部作用域。
- 当执行到function的声明语句,会把声明语句之前的值复制给全局。
- 之后的赋值全是在局部作用域中进行。
代码输出题:(真一不留神就出错了)
console.log(fn) // undefined
{
fn() // 10
function fn() {console.log('10')}
fn() // 10
}
fn() // 10
var a;
{
a = 1;
function a() {}; // 在这一行之后,会在块级作用域中创建一个函数a,次之后的a的访问,都是在块级作用域访问a,与外部无关
a = 2;
}
a // 1
闭包
闭包 - JavaScript | MDN (mozilla.org)
MDN将闭包定义为:闭包(closure)是一个函数以及其捆绑的周边环境状态(lexical environment,词法环境)的引用的组合。
换而言之,闭包让开发者可以从内部函数访问外部函数的作用域。在 JavaScript 中,闭包会随着函数的创建而被同时创建。
如下一个简单的闭包,函数B作用域内引用了外部函数A作用域的a。
function A(){
let a = 1;
function B(){
let c = ++a;
}
B();
}
A();
现在再来看闭包的词法环境:环境记录和外部词法环境引用(是不是一下就变好理解了捏)
没有也不慌,继续听我分析:
这里就要回到我们刚才讲的词法环境和作用域链了。还记得我刚才那句话,源码的实现会定义一个Scope类,而每个模块使用了WeakMap来定义变量的作用域。
在解析AST语法树时,Scope类可以用于表示变量的作用域。Scope类包含以下属性和方法:
-
variables:表示该作用域中的所有变量。
-
childScopes:表示该作用域中的所有子作用域。
-
isGlobal:表示该作用域是否为全局作用域。
-
isFunctionScope:表示该作用域是否为函数作用域。
-
isBlockScope:表示该作用域是否为块级作用域。
-
addVariable(name):向该作用域中添加一个变量。
-
findVariable(name):在该作用域及其父作用域中查找指定名称的变量。
分析AST的作用域,获取根节点的作用域。
遍历AST中的每个节点,当遍历到Identifier节点时,调用根节点作用域的findVariable方法查找变量的定义。
如果找到了变量的定义,则将变量的作用域添加到缓存数据的作用域列表中。
如果没有找到变量的定义,则将变量的作用域设置为null。
在获取缓存数据时,遍历缓存数据的作用域列表,查找第一个非null的作用域,从而确定缓存数据的作用域。
为了方便理解,我这里简单实现一下
class Scoped {
constructor(parent) {
this.parent = parent;
this.vars = {};
}
// 声明变量
declare(name) {
this.vars[name] = true;
}
// 查找变量
lookup(name) {
if (this.vars[name]) {
return true;
} else if (this.parent) {
return this.parent.lookup(name);
} else {
return false;
}
}
// 创建子作用域
createChildScope() {
return new Scoped(this);
}
}
// 创建全局作用域
const globalScope = new Scoped(null);
// 在全局作用域中声明变量 a
globalScope.declare('a');
// 在全局作用域中创建子作用域
const childScope = globalScope.createChildScope();
// 在子作用域中声明变量 b
childScope.declare('b');
// 查找变量 a 和 b
console.log(globalScope.lookup('a')); // 输出 true
console.log(globalScope.lookup('b')); // 输出 false
console.log(childScope.lookup('a')); // 输出 true
console.log(childScope.lookup('b')); // 输出 true
在执行 B()
语句时,会沿着当前作用域查找变量a,此时并没有找到,就退回父作用域查找并找到了变量a。
这里并不难,闭包难理解的点在于它什么情况下不会被回收?为什么不会被回收?,很显然,刚才给出的闭包案例在执行完 A()
后就被回收了,并不能起到很好的栗子。
先浅浅说一下v8垃圾回收机制吧
V8回收机制基于分代回收的算法,它将所有对象分为两种类型:新生代和老生代:
- 分代垃圾回收算法
堆内存被分为新生代和老生代两个部分
新生代中的对象生命周期较短,垃圾回收频繁;
而老生代中的对象生命周期较长,垃圾回收相对较少。
- 标记-清除法:
当垃圾回收器需要回收内存时,它会标记所有活动对象,然后将未标记的对象视为垃圾进行清理
基于可达性,需要记录全局的信息
- 引用计数
记录每个对象被引用的次数,次数变为0,则被清除
- 增量标记算法
优化标记清除的方法,将标记清除过程分为多个节点,每个阶段执行一部分工作,避免长时间的停顿
在现代浏览器中,一般采用标记清除的方式来实现垃圾回收机制。标记清除的优点是可以处理循环引用的情况,而引用计数则无法处理循环引用。
继续闭包
function A(){
let a = 0;
return function(){
a++;
}
}
const B = A();
B();
这也是个闭包,返回的是一个函数,并且对父作用域的a进行了引用。
在AST语法树中,变量的引用通常表示为Identifier节点,其中包含一个name属性,表示变量的名称。通过遍历AST,可以获取每个Identifier节点。可以通过Identifier.references.length属性来获取变量引用的次数。
在上述代码中,B引用了A返回的函数,执行B()又引用了A函数中的a变量,此时a的生命周期延长了,它处于活动状态,并不会被当成垃圾回收。
当然,关于闭包这一块并不是我本文要讲的重点,想了解的可以看看我之前的文章对函数式编程的一些理解,怎么进行学习? - 掘金 (juejin.cn)
完结撒花
觉得文章写得不错的话,求个关注点赞啦啦啦
继续偷偷卷。。。