漫谈 JS 解析与作用域锁定
这部分内容,学了当然最好,没学,也不影响前端开发。当然,能了解肯定是比不了解的强。
依旧是无图无码,网文风格。我觉得,能用文字把逻辑或者概念表述清楚,一是对作者本身的能力提升有好处,二是对读者来说 思考文字表达的内容 有助于多使用抽象思维和逻辑思维能力,构建自己的思考模式,用现在流行的说法 就是心智模型。你自己什么都可以脑补,那不是厉害大了嘛。
上面的话不要相信,其实我就是为自己懒找的借口。
因为标题就说了 是漫谈,所以有些细节做了省略 有些边界情况做了简化表述。但是总体来说 准确性还是可以的。如果有错漏的地方,还请多多指正。 这是第一部分 词法和语法分析。
一.词法分析和语法分析
当浏览器从网络下载了js文件,比如app.js,浏览器引擎拿到的最初形态是一串**字节流 **。
-
识别: V8 首先要处理编码,V8 接收的是 UTF-8 编码的字节流,内部会转换为 UTF-16 处理字符串。
-
流式快速处理: 引擎并不是等整个文件下载完才开始干活的。只要网络传过来一段数据,V8 的扫描器就开始工作了。 这样可以加快启动速度。此时的状态就是毫无意义的字符
c,o,n,s,t,,a,,=,,1,;... -
然后的这一步叫 Tokenization 词语切分。 负责这一步的组件就是上面提到的叫 Scanner(扫描器)。它的工作就像是一个切菜工,把滔滔不绝连绵不断的字符串切成一个个有语法意义的最小单位,叫做 Token(记号)。看到这个词 ,大家是不是惊觉心一缩,没错,就是它,它们就是以它为单位来收咱钱的。
scanner 内部是一个状态机。它逐个读取字符:
- 读到
c可能是const,也可能是变量名,继续。 - 读到
o,n,s,t凑齐了5个娃,且下一个字符不是字母(比如是空格),确认这是一个关键字 const。”(防止误判constant这种变量名) - 读到
空格 忽略,跳过去。 - 读到
1这是一个数字。
这样就由原来的字节流变成了 Token 流。这是一种扁平的列表结构。
-
源码:
const a = 1; -
Token 流:
-
CONST(关键字) -
IDENTIFIER(值为 "a") -
ASSIGN(符号 "=") -
SMI(小整数 "1") -
SEMICOLON(符号 ";")
-
这一步,注释和多余的空格和换行符会被抛弃。
- 读到
-
现在就是解析阶段了
其实解析是一个总称,它分为 全量解析 和 预解析 两种形式。
这就是v8的懒解析机制。看到这个懒字,也差不多能明白了吧。
对于那些不是立即执行的函数(比如点击按钮才触发的回调),V8 会先用预解析快速扫一遍。
检查基本的语法错误(比如有没有少写括号),确认这是一个函数。并不会生成复杂的 AST 结构,也不建立具体的变量绑定,只进行最基础的闭包引用检查。御姐喜的结果是这个函数在内存里只是一个很小的占位符,跳过内部细节。
而只有那些立即执行函数或者顶层代码,才会进入真正的全量解析,进行完整的 AST 构建。
那么,问题就来了,v8怎么判断到底是使用预解析还是使用全量解析呢?
它的原则就是 懒惰为主 全量为辅
就是v8默认你写的函数暂时不会执行,除非是已经显式的通过语法告诉它,这段这行代码 马上就要跑 你赶快全量解析。
下面 我们稍微详细的说一下
-
默认绝大多数函数都是预解析
v8认为js在初始运行时,仅仅只有很少很少一部分代码 是需要马上使用的 其他觉得大部分 都是要么是回调 要么是其他的暂时用不到的,所以,凡是具名函数声明、嵌套函数,默认都是预解析。
function clickHandler() { console.log("要不要解析我"); } // 引擎认为 这是一个函数声明 看起来还没人调勇它 // 先不浪费时间了,只检查一下括号匹配吧, // 把它标记为 'uncompiled',然后跳过。" -
那么 如何才能符合它进行全量解析的条件呢
-
顶层代码
写在最外层 不在任何函数内 的代码,加载完必须立即执行。
判断依据: 只要不在
function块里的代码,全是顶层代码,必须全量解析。 -
立即执行函数
那么这里有个问题,就是V8 如何在还没运行代码时,就知道这个函数是立即调用执行函数呢?
答案就是 看括号()
当解析器扫描到一个函数关键字
function时,它会看一眼这个 function 之前有没有左括号(-
没括号
function foo() { ... } // 没看到左括号,那你先靠边吧, 对它预解析。 -
有括号
(function() { ... })(); // 扫描器扫到了这个左括号 // 欸,这有个左括号包着 function // 根据万年经验,这是个立即执行函数,马上就要执行。 // 直接上大菜,全量解析,生成 AST -
其他的立即执行的迹象:除了括号,
!、+、-等一元运算符放在function前面,也会触发全量解析!function() { ... }(); // 全量解析
-
-
-
如果有嵌套函数咋办呢
嵌套函数默认是预解析,即使外部函数进行的是全量解析,它内部定义的子函数,默认依然是预解析。只有当子函数真的被调用时,V8 才会暂停执行,去把子函数的全量解析做完 把 AST 补齐
//顶层代码全量解析 (function outer() { var a = 1; // 内部函数 inner: // 虽然 outer 正在执行,但 inner 还没被调用 // 引擎也不确定 inner 会不会被调用。 // 所以inner 默认预解析。 function inner() { var b = 2; } inner(); // 直到执行到这一行,引擎才会回头去对 inner 进行全量解析 })(); -
那么 引擎根据自己的判断 进行全量解析或者预解析,会出错吗
当然会,
如果是本该预解析的 结果判断错了 进行了全量解析 浪费了时间和内存生成了 AST 和字节码,结果这代码根本没跑。
如果是本该全量解析的又巨又大又重的函数 结果判断错了 进行了预解析,然后马上下一行代码就调用了,结果就是 白白预解析了一遍,浪费了时间,发现马上被调用,又马上回头全量解析一边 又花了时间,两次的花费。
-
-
在上面只是讲了解析阶段的预解析和全量解析的不同,现在我们讲解析阶段的过程
V8 使用的是递归下降分析法。它根据js 的语法规则来匹配 Token。
它的规则类似于:当我们遇到
const,根据语法规则,后面必须跟一个变量名,然后是一个赋值号,然后是一个表达式。过程示例:
看到
const创建一个变量声明节点。看到
a把它作为声明的标识符。看到
=知道后面是初始值。看到
1创建一个字面量节点,挂在=的右边。而在这个阶段的同时,作用域分析也在同步进行,因为在构建 AST 的过程中,解析器必须要搞清楚变量在哪里。
它会盘算 这个
a是全局变量,还是函数内的局部变量?如果当前函数内部引用了外层的变量,解析器会在这个阶段打上标记:“要小心,这个变量被逮住了,将来可能需要上下文来分配”。
这个作用域分析比较重要,我们用稍微大点的篇幅来讲讲。
首先 强烈建议 不要再去用以前的 活动对象AO vo 等等的说法来思考问题。应该使用现在的词法作用域 环境记录 等等思考模型。
词法作用域 (Lexical Scoping)” 的定义:作用域是由代码书写的位置决定的,而不是由调用位置决定的。
这说明,引擎在还没开始执行代码,仅仅通过“扫描”源代码生成 AST 的阶段,就已经把“谁能访问谁”、“谁被谁逮住”这笔账算得清清楚楚了。
一旦AST被生成,那么至少意味着下面的情况
作用域层级被确定
AST 本身的树状结构,就是作用域层级的物理体现。
-
AST 节点: 当解析器遇到一个
function关键字,它会在 AST 上生成一个FunctionLiteral节点。 -
Scope 对象: 在 V8 内部,随着 AST 的生成,解析器会同时维护一棵 “作用域树”。
- 每进入一个函数,V8 就会创建一个新的
Scope对象。 - 这个
Scope对象会有一个指针指向它的Outer Scope父作用域。
- 每进入一个函数,V8 就会创建一个新的
-
结果: 这种“父子关系”是静态锁定的。无论你将来在哪里调用这个函数,它的“父级”永远是定义时的那个作用域。
变量引用关系被识别
这是解析器最忙碌的工作之一,叫做 变量解析。
-
声明: 当解析器遇到
let a = 1,它会在当前 Scope 记录:“我有了一个叫a的变量”。 -
引用: 当解析器遇到
console.log(a)时,它会生成一个 变量代理。 -
链接过程: 解析器会尝试“连接”这个代理和声明:
- 先在当前 Scope 找
a。 - 找不到?沿着 Scope Tree 往上找父作用域。
- 找到了?建立绑定。
- 一直到了全局还没找到?标记为全局变量(或者报错)。
- 先在当前 Scope 找
这里要注意: 这个“找”的过程是在编译阶段完成的逻辑推导。
闭包的蓝图被预判
这一步是 V8 性能优化的关键,也就是作用域分析。
-
发现闭包: 解析器发现内部函数
inner引用了外部函数outer的变量x。 -
打个大标签:
- 解析器会给
x打上一个标签:“强制上下文分配”。 - 意思是:“虽然
x是局部变量,但因为有人跨作用域引用它,所以它不能住在普通的栈(Stack)上了... 必须搬家,住到堆(Heap)里专门开辟的 Context(上下文对象) 中去。”
- 解析器会给
-
还没有实例化:
- 此时内存里没有上下文对象,也没有变量
x的值(那是运行时的事)。 - AST 只是生成了一张**“蓝图”**,图纸上写着:“注意,将来运行的时候,这个
x要放在特别的地方 - Context里,别放在栈上。”
- 此时内存里没有上下文对象,也没有变量
-
下面就是解释器Ignition该登场了。我们第二部分再见。