漫谈 JS 解析与作用域锁定

这部分内容,学了当然最好,没学,也不影响前端开发。当然,能了解肯定是比不了解的强。

依旧是无图无码,网文风格。我觉得,能用文字把逻辑或者概念表述清楚,一是对作者本身的能力提升有好处,二是对读者来说 思考文字表达的内容 有助于多使用抽象思维和逻辑思维能力,构建自己的思考模式,用现在流行的说法 就是心智模型。你自己什么都可以脑补,那不是厉害大了嘛。

上面的话不要相信,其实我就是为自己懒找的借口。

因为标题就说了 是漫谈,所以有些细节做了省略 有些边界情况做了简化表述。但是总体来说 准确性还是可以的。如果有错漏的地方,还请多多指正。 这是第一部分 词法和语法分析。

一.词法分析和语法分析

当浏览器从网络下载了js文件,比如app.js,浏览器引擎拿到的最初形态是一串**字节流 **。

  1. 识别: V8 首先要处理编码,V8 接收的是 UTF-8 编码的字节流,内部会转换为 UTF-16 处理字符串。

  2. 流式快速处理: 引擎并不是等整个文件下载完才开始干活的。只要网络传过来一段数据,V8 的扫描器 就开始工作了。 这样可以加快启动速度。此时的状态就是毫无意义的字符 c, o, n, s, t, , a, , =, , 1, ; ...

  3. 然后的这一步叫 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 (符号 ";")

    这一步,注释和多余的空格和换行符会被抛弃。

  4. 现在就是解析阶段了

    其实解析是一个总称,它分为 全量解析 和 预解析 两种形式。

    这就是v8的懒解析机制。看到这个懒字,也差不多能明白了吧。

    对于那些不是立即执行的函数(比如点击按钮才触发的回调),V8 会先用预解析快速扫一遍。

    检查基本的语法错误(比如有没有少写括号),确认这是一个函数 。并不会生成复杂的 AST 结构,也不建立具体的变量绑定,只进行最基础的闭包引用检查。御姐喜的结果是这个函数在内存里只是一个很小的占位符,跳过内部细节。

    而只有那些立即执行函数或者顶层代码,才会进入真正的全量解析,进行完整的 AST 构建。

    那么,问题就来了,v8怎么判断到底是使用预解析还是使用全量解析呢?

    它的原则就是 懒惰为主 全量为辅

    就是v8默认你写的函数暂时不会执行,除非是已经显式的通过语法告诉它,这段这行代码 马上就要跑 你赶快全量解析。

    下面 我们稍微详细的说一下

    • 默认绝大多数函数都是预解析

      v8认为js在初始运行时,仅仅只有很少很少一部分代码 是需要马上使用的 其他觉得大部分 都是要么是回调 要么是其他的暂时用不到的,所以,凡是具名函数声明、嵌套函数,默认都是预解析。

      javascript 复制代码
      function clickHandler() {
        console.log("要不要解析我");
      }
      // 引擎认为 这是一个函数声明  看起来还没人调勇它
      // 先不浪费时间了,只检查一下括号匹配吧,
      // 把它标记为 'uncompiled',然后跳过。"
    • 那么 如何才能符合它进行全量解析的条件呢

      1. 顶层代码

        写在最外层 不在任何函数内 的代码,加载完必须立即执行。

        判断依据: 只要不在 function 块里的代码,全是顶层代码,必须全量解析。

      2. 立即执行函数

        那么这里有个问题,就是V8 如何在还没运行代码时,就知道这个函数是立即调用执行函数呢?

        答案就是 看括号()

        当解析器扫描到一个函数关键字 function 时,它会看一眼这个 function 之前有没有左括号 (

        • 没括号

          csharp 复制代码
          function foo() { ... }
          // 没看到左括号,那你先靠边吧, 对它预解析。
        • 有括号

          javascript 复制代码
          (function() { ... })();
          // 扫描器扫到了这个左括号
          // 欸,这有个左括号包着 function
          // 根据万年经验,这是个立即执行函数,马上就要执行。
          // 直接上大菜,全量解析,生成 AST
        • 其他的立即执行的迹象:除了括号,!+- 等一元运算符放在 function 前面,也会触发全量解析

          scss 复制代码
          !function() { ... }(); // 全量解析
    • 如果有嵌套函数咋办呢

      嵌套函数默认是预解析,即使外部函数进行的是全量解析,它内部定义的子函数,默认依然是预解析。只有当子函数真的被调用时,V8 才会暂停执行,去把子函数的全量解析做完 把 AST 补齐

      csharp 复制代码
      //顶层代码全量解析
      (function outer() {
        var a = 1;
      
        // 内部函数 inner:
        // 虽然 outer 正在执行,但 inner 还没被调用
        // 引擎也不确定 inner 会不会被调用。
        // 所以inner 默认预解析。
        function inner() {
          var b = 2;
        }
      
        inner(); // 直到执行到这一行,引擎才会回头去对 inner 进行全量解析
      })();
    • 那么 引擎根据自己的判断 进行全量解析或者预解析,会出错吗

      当然会,

      如果是本该预解析的 结果判断错了 进行了全量解析 浪费了时间和内存生成了 AST 和字节码,结果这代码根本没跑。

      如果是本该全量解析的又巨又大又重的函数 结果判断错了 进行了预解析,然后马上下一行代码就调用了,结果就是 白白预解析了一遍,浪费了时间,发现马上被调用,又马上回头全量解析一边 又花了时间,两次的花费。

  5. 在上面只是讲了解析阶段的预解析和全量解析的不同,现在我们讲解析阶段的过程

    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父作用域。
    • 结果: 这种"父子关系"是静态锁定的。无论你将来在哪里调用这个函数,它的"父级"永远是定义时的那个作用域。

    变量引用关系被识别

    这是解析器最忙碌的工作之一,叫做 变量解析

    • 声明: 当解析器遇到 let a = 1,它会在当前 Scope 记录:"我有了一个叫 a 的变量"。
    • 引用: 当解析器遇到 console.log(a) 时,它会生成一个 变量代理
    • 链接过程: 解析器会尝试"连接"这个代理和声明:
      1. 先在当前 Scope 找 a
      2. 找不到?沿着 Scope Tree 往上找父作用域。
      3. 找到了?建立绑定。
      4. 一直到了全局还没找到?标记为全局变量(或者报错)。

    这里要注意: 这个"找"的过程是在编译阶段完成的逻辑推导。

    闭包的蓝图被预判

    这一步是 V8 性能优化的关键,也就是作用域分析。

    • 发现闭包: 解析器发现内部函数 inner 引用了外部函数 outer 的变量 x
    • 打个大标签:
      • 解析器会给 x 打上一个标签:"强制上下文分配"
      • 意思是:"虽然 x 是局部变量,但因为有人跨作用域引用它,所以它不能住在普通的栈(Stack)上了... 必须搬家,住到堆(Heap)里专门开辟的 Context(上下文对象) 中去。"
    • 还没有实例化:
      • 此时内存里没有 上下文对象,也没有 变量 x 的值(那是运行时的事)。
      • AST 只是生成了一张**"蓝图"**,图纸上写着:"注意,将来运行的时候,这个 x 要放在特别的地方 - Context里,别放在栈上。"

下面就是解释器Ignition该登场了。我们第二部分再见。

相关推荐
czhc11400756632 小时前
c# winform1212
java·javascript·c#
syt_10132 小时前
grid布局-子项放置3
前端·javascript·css
Gomiko2 小时前
JavaScript进阶(三):DOM事件
开发语言·javascript·ecmascript
丫丫7237342 小时前
相机动画总结-相机直线运动动画、相机圆周运动动画
javascript·webgl
哆啦A梦15882 小时前
商城后台管理系统 06,编辑商品
javascript·vue.js·elementui
qq_406176142 小时前
JavaScript中的循环特点和区别
开发语言·javascript·ecmascript
Aotman_3 小时前
JavaScript去除对象字段空格
开发语言·前端·javascript
爱网安的monkey brother3 小时前
vue3+ts项目自建训练
前端·javascript·vue.js
哆啦A梦15883 小时前
商城后台管理系统 02,上传图片实现
前端·javascript·vue.js·elementui