沐游虞笔记
  • 前端面试题

    • HTML面试题汇总(无答案)
    • HTML面试题汇总
    • CSS 面试题汇总(无答案)
    • CSS 面试题汇总
    • javascript 面试题汇总(无答案)
    • javascript 面试题汇总
    • promise 面试题(无答案)
    • promise 面试题
    • 浏览器面试题汇总(无答案)
    • 浏览器面试题汇总
    • 网络面试题汇总(无答案)
    • 网络面试题汇总
    • 工程化面试题汇总(无答案)
    • 工程化面试题汇总
    • VUE面试题汇总(无答案)
    • VUE面试题汇总
  • 直播课文件

    • 静态页面学习指导
    • 属性的计算过程
    • 层叠继承规则总结
    • BFC
    • JS基础知识回顾
    • DOM 事件的传播机制
    • DOM 事件的注册和移除
    • 阻止事件默认行为
    • 基础领航考试题
    • 基础领航考试题(答案)
    • 2024前端发展
    • JS核心概念学习指导
    • 第三方库与工程化学习指导
    • Vue入门学习指导
    • vue进阶学习指导
    • 前端性能优化
  • 笔面试环节知识讲解

    • 目录
    • 图像处理
    • 图像处理(面试)
    • Webpack构建优化
    • Webpack构建优化(面试)
    • TTS性能优化
    • TTS性能优化(面试)
    • 实时协作
    • 实时协作(面试)
    • 网页复制图片到剪贴板
    • 网页复制图片到剪贴板(面试)
    • vite插件
    • vite插件(面试)
    • 表单数据同步与保持
    • 表单数据同步与保持(面试)
    • 优化虚拟列表
    • 优化虚拟列表(面试)
    • 微前端解决巨石应用
    • 微前端解决巨石应用(面试)
    • DNS解析与优化
    • DNS解析与优化(面试)
    • 前端监控
    • 前端监控(面试)
    • 12.跨标签页通信
    • 12.跨标签页通信(面试)
    • 13.Vite相关优化
    • 13.Vite相关优化(面试)
    • 14.计时器节流问题
    • 14.计时器节流问题(面试)
    • 15.多文件预览支持
    • 15.多文件预览支持(面试)
    • 16.defer优化白屏时间
    • 16.defer优化白屏时间(面试)
  • Vue3整体变化
  • Vue2响应式回顾
  • Vue3响应式变化
  • nextTick实现原理
  • 两道代码题
  • Vue运行机制
  • 渲染器核心功能
  • 事件绑定与更新
  • computed面试题
  • watch面试题
  • 图解双端diff
  • 图解快速dff
  • 最长递增子序列
  • 模板编译器
  • 模板编译提升
  • 组件name作用
  • 路由传参方式
  • 基础篇

    • 序章React介绍
    • JSX基础语法
    • React基本介绍
    • 表单
    • 生命周期
    • 组件与事件绑定
    • 组件状态与数据传递
    • Hooks
    • React--redux介绍
    • React-router介绍
  • 就业篇

    • 属性默认值和类型验证
    • 高阶组件
    • Ref
    • Context
    • Render Props
    • Portals
    • 错误边界
    • 组件渲染性能优化
    • 前端框架的理解
    • Reacti和Vue描述页面的区别
    • 前端框架的分类
    • 虚拟DoM
    • React整体架构
    • React渲染流程
    • Fiber双缓冲
    • MessageChannel
    • Scheduleri调度普通任务
    • Scheduleri调度延时任务
    • 最小堆
    • React中的位运算
    • beginWork工作流程
    • completeWork工作流程
    • 图解diff算法
    • commit工作流程
    • lane模型
    • React中的事件
    • Hooks原理
    • useStateuseReducer.
    • effect相关hook
    • useCallbackuseMemo
    • useRef
    • Update
    • 性能优化策略之eagerState
    • 性能优化策略之bailout
    • bailoutContextAPl
    • 性能优化对日常开发启示
  • 前端监控概述
  • 错误监控
  • 数据上报
  • 页面性能监控
  • 用户行为收集与埋点
  • CSS3手册
  • HTML5手册
  • JavaScript语言提升

    • es补充
    • 事件循环
    • promise基础
    • Promise的链式调用
    • Promise的静态方法
    • async和await
    • Promise相关面试题
  • 网络

    • 客户端与服务器
    • 关于 Apifox 的使用
  • git文档
  • 工程化

    • CommonJS
    • ES module
    • npm文档(包管理)
    • Lass笔记
    • webpack工具
  • canvas详解
  • uinapp笔记
  • 自动化测试
  • oauth2令牌

    • 认识Oauth2
    • 三方应用实现github授权
    • 微信三方应用登录实现
    • 支付宝沙箱支付功能
  • 前端面试题

    • HTML面试题汇总(无答案)
    • HTML面试题汇总
    • CSS 面试题汇总(无答案)
    • CSS 面试题汇总
    • javascript 面试题汇总(无答案)
    • javascript 面试题汇总
    • promise 面试题(无答案)
    • promise 面试题
    • 浏览器面试题汇总(无答案)
    • 浏览器面试题汇总
    • 网络面试题汇总(无答案)
    • 网络面试题汇总
    • 工程化面试题汇总(无答案)
    • 工程化面试题汇总
    • VUE面试题汇总(无答案)
    • VUE面试题汇总
  • 直播课文件

    • 静态页面学习指导
    • 属性的计算过程
    • 层叠继承规则总结
    • BFC
    • JS基础知识回顾
    • DOM 事件的传播机制
    • DOM 事件的注册和移除
    • 阻止事件默认行为
    • 基础领航考试题
    • 基础领航考试题(答案)
    • 2024前端发展
    • JS核心概念学习指导
    • 第三方库与工程化学习指导
    • Vue入门学习指导
    • vue进阶学习指导
    • 前端性能优化
  • 笔面试环节知识讲解

    • 目录
    • 图像处理
    • 图像处理(面试)
    • Webpack构建优化
    • Webpack构建优化(面试)
    • TTS性能优化
    • TTS性能优化(面试)
    • 实时协作
    • 实时协作(面试)
    • 网页复制图片到剪贴板
    • 网页复制图片到剪贴板(面试)
    • vite插件
    • vite插件(面试)
    • 表单数据同步与保持
    • 表单数据同步与保持(面试)
    • 优化虚拟列表
    • 优化虚拟列表(面试)
    • 微前端解决巨石应用
    • 微前端解决巨石应用(面试)
    • DNS解析与优化
    • DNS解析与优化(面试)
    • 前端监控
    • 前端监控(面试)
    • 12.跨标签页通信
    • 12.跨标签页通信(面试)
    • 13.Vite相关优化
    • 13.Vite相关优化(面试)
    • 14.计时器节流问题
    • 14.计时器节流问题(面试)
    • 15.多文件预览支持
    • 15.多文件预览支持(面试)
    • 16.defer优化白屏时间
    • 16.defer优化白屏时间(面试)
  • Vue3整体变化
  • Vue2响应式回顾
  • Vue3响应式变化
  • nextTick实现原理
  • 两道代码题
  • Vue运行机制
  • 渲染器核心功能
  • 事件绑定与更新
  • computed面试题
  • watch面试题
  • 图解双端diff
  • 图解快速dff
  • 最长递增子序列
  • 模板编译器
  • 模板编译提升
  • 组件name作用
  • 路由传参方式
  • 基础篇

    • 序章React介绍
    • JSX基础语法
    • React基本介绍
    • 表单
    • 生命周期
    • 组件与事件绑定
    • 组件状态与数据传递
    • Hooks
    • React--redux介绍
    • React-router介绍
  • 就业篇

    • 属性默认值和类型验证
    • 高阶组件
    • Ref
    • Context
    • Render Props
    • Portals
    • 错误边界
    • 组件渲染性能优化
    • 前端框架的理解
    • Reacti和Vue描述页面的区别
    • 前端框架的分类
    • 虚拟DoM
    • React整体架构
    • React渲染流程
    • Fiber双缓冲
    • MessageChannel
    • Scheduleri调度普通任务
    • Scheduleri调度延时任务
    • 最小堆
    • React中的位运算
    • beginWork工作流程
    • completeWork工作流程
    • 图解diff算法
    • commit工作流程
    • lane模型
    • React中的事件
    • Hooks原理
    • useStateuseReducer.
    • effect相关hook
    • useCallbackuseMemo
    • useRef
    • Update
    • 性能优化策略之eagerState
    • 性能优化策略之bailout
    • bailoutContextAPl
    • 性能优化对日常开发启示
  • 前端监控概述
  • 错误监控
  • 数据上报
  • 页面性能监控
  • 用户行为收集与埋点
  • CSS3手册
  • HTML5手册
  • JavaScript语言提升

    • es补充
    • 事件循环
    • promise基础
    • Promise的链式调用
    • Promise的静态方法
    • async和await
    • Promise相关面试题
  • 网络

    • 客户端与服务器
    • 关于 Apifox 的使用
  • git文档
  • 工程化

    • CommonJS
    • ES module
    • npm文档(包管理)
    • Lass笔记
    • webpack工具
  • canvas详解
  • uinapp笔记
  • 自动化测试
  • oauth2令牌

    • 认识Oauth2
    • 三方应用实现github授权
    • 微信三方应用登录实现
    • 支付宝沙箱支付功能
  • 课程导读-必看
  • Vue3整体变化
  • Vue2响应式回顾
  • Vue3响应式变化
  • nextTick实现原理
  • 两道代码题
  • Vue运行机制
  • 渲染器核心功能
  • 事件绑定与更新
  • computed面试题
  • watch面试题
  • 图解双端diff
  • 图解快速diff
  • 最长递增子序列
  • 模板编译器
    • 模板编译提升
    • 组件name作用
    • Vue项目性能优化
    • 路由传参方式
    • vue3笔面试题汇总
    luzhichang
    2024-09-27
    目录

    模板编译器

    # 模板编译器

    面试题:说一下 Vue 中 Compiler 的实现原理是什么?

    Vue中的编译器

    Vue 里面的编译器,主要负责将开发者所书写的模板转换为渲染函数。例如:

    <template>
    	<div>
      	<h1 :id="someId">Hello</h1>
      </div>
    </template>
    

    编译后的结果为:

    function render(){
      return h('div', [
        h('h1', {id: someId}, 'Hello')
      ])
    }
    

    这里整个过程并非一触而就的,而是经历一个又一个步骤一点一点转换而来的。

    整体来讲,整个编译过程如下图所示:

    image-20231113095532166

    可以看到,在编译器的内部,实际上又分为了:

    • 解析器:负责将模板解析为所对应的 AST
    • 转换器:负责将模板 AST 转换为 JavaScript AST
    • 生成器:根据 JavaScript 的 AST 生成最终的渲染函数

    解析器

    解析器的核心作用是负责将模板解析为所对应的模板 AST。

    首先用户所书写的模板,例如:

    <template>
    	<div>
      	<h1 :id="someId">Hello</h1>
      </div>
    </template>
    

    对于解析器来讲仍然就是一段字符串而已,类似于:

    '<template><div><h1 :id="someId">Hello</h1></div></template>'
    

    那么解析器是如何进行解析的呢?这里涉及到一个 有限状态机 的概念。

    # FSM

    FSM,英语全称为 Finite State Machine,翻译成中文就是有限状态机,它首先定义了一组状态,然后还定义了状态之间的转移以及触发这些转移的事件。然后就会去解析字符串里面的每一个字符,根据字符做状态的转换。

    举一个例子,假设我们要解析的模板内容为:

    '<p>Vue</p>'
    

    那么整个状态的迁移过程如下:

    1. 状态机一开始处于 初始状态。
    2. 在 初始状态 下,读取字符串的第一个字符 < ,然后状态机的状态会更新为 标签开始状态。
    3. 接下来继续读取下一个字符 p,由于 p 是字母,所以状态机的状态会更新为 标签名称开始状态。
    4. 接下来读取的下一个字符为 >,状态机的状态会回到 初始状态,并且会记录在标签状态下产生的标签名称 p。
    5. 读取下一个字符 V,此时状态机会进入到 文本状态。
    6. 读取下一个字符 u,状态机仍然是 文本状态。
    7. 读取下一个字符 e,状态机仍然是 文本状态。
    8. 读取下一个字符 <,此时状态机会进入到 标签开始状态。
    9. 读取下一个字符 / ,状态机会进入到 标签结束状态。
    10. 读取下一个字符 p,状态机进入 标签名称结束状态。
    11. 读取下一个字符 >,状态机进重新回到 初始状态。

    具体如下图所示:

    image-20231113140436969
    let x = 10 + 5;
    
    token:
    let(关键字) x(标识符) =(运算符) 10(数字) +(运算符) 5(数字) ;(分号)
    

    对应代码:

    const template = '<p>Vue</p>';
    // 首先定义一些状态
    const State = {
      initial: 1, // 初始状态
      tagOpen: 2, // 标签开始状态
      tagName: 3, // 标签名称开始状态
      text: 4, // 文本状态
      tagEnd: 5, // 标签结束状态
      tagEndName: 6 // 标签名称结束状态
    }
    
    // 判断字符是否为字母
    function isAlpha(char) {
      return (char >= "a" && char <= "z") || (char >= "A" && char <= "Z");
    }
    
    // 将字符串解析为 token
    function tokenize(str){
      // 初始化当前状态
      let currentState = State.initial;
      // 用于缓存字符
      const chars = [];
      // 存储解析出来的 token
      const tokens = [];
      
      while(str){
        const char = str[0]; // 获取字符串里面的第一个字符
        
        switch(currentState){
          case State.initial:{
            if(char === '<'){
              currentState = State.tagOpen;
              // 消费一个字符
              str = str.slice(1);
            } else if(isAlpha(char)){
              // 判断是否为字母
              currentState = State.text;
              chars.push(char);
              // 消费一个字符
              str = str.slice(1);
            }
            break;
          }
          case State.tagOpen: {
            // 相应的状态处理
          }
          case State.tagName: {
            // 相应的状态处理
          }
        }
      }
      
      return tokens;
    }
    tokenize(template);
    

    最终解析出来的 token:

    [
      {type: 'tag', name: 'p'}, // 开始标签
      {type: 'text', content: 'Vue'}, // 文本节点
      {type: 'tagEnd', name: 'p'}, // 结束标签
    ]
    

    构造模板AST

    根据 token 列表创建模板 AST 的过程,其实就是对 token 列表进行扫描的过程。从列表的第一个 token 开始,按照顺序进行扫描,直到列表中所有的 token 处理完毕。

    在这个过程中,我们需要维护一个栈,这个栈将用于维护元素间的父子关系。每遇到一个开始标签节点,就构造一个 Element 类型的 AST 节点,并将其压入栈中。

    类似的,每当遇到一个结束标签节点,我们就将当前栈顶的节点弹出。

    举个例子,假设我们有如下的模板内容:

    '<div><p>Vue</p><p>React</p></div>'
    

    经过上面的 tokenize 后能够得到如下的数组:

    [
      {"type": "tag","name": "div"},
      {"type": "tag","name": "p"},
      {"type": "text","content": "Vue"},
      {"type": "tagEnd","name": "p"},
      {"type": "tag","name": "p"},
      {"type": "text","content": "React"},
      {"type": "tagEnd","name": "p"},
      {"type": "tagEnd","name": "div"}
    ]
    

    那么接下来会遍历这个数组(也就是扫描 tokens 列表)

    1. 一开始有一个 elementStack 栈,刚开始有一个 Root 节点,[ Root ]

    2. 首先是一个 div tag,创建一个 Element 类型的 AST 节点,并将其压栈到 elementStack,当前的栈为 [ Root, div ],div 会作为 Root 的子节点

    image-20231113150248725
    1. 接下来是 p tag,创建一个 Element 类型的 AST 节点,同样会压栈到 elementStack,当前的栈为 [ Root, div, p ],p 会作为 div 的子节点
    image-20231113150335866
    1. 接下来是 Vue text,此时会创建一个 Text 类型的 AST 节点,作为 p 的子节点。
    image-20231113150356416
    1. 接下来是 p tagEnd,发现是一个结束标签,所以会将 p 这个 AST 节点弹出栈,当前的栈为 [ Root, div ]

    2. 接下来是 p tag,同样创建一个 Element 类型的 AST 节点,压栈后栈为 [ Root, div, p ],p 会作为 div 的子节点

    image-20231113150442450
    1. 接下来是 React text,此时会创建一个 Text 类型的 AST 节点,作为 p 的子节点。
    image-20231113150537351
    1. 接下来是 p tagEnd,发现是一个结束标签,所以会将 p 这个 AST 节点弹出栈,当前的栈为 [ Root, div ]

    2. 最后是 div tagEnd,发现是一个结束标签,将其弹出,栈区重新为 [ Root ],至此整个 AST 构建完毕

    落地到具体的代码,大致就是这样的:

    // 解析器
    function parse(str){
      const tokens = tokenize(str);
      
      // 创建Root根AST节点
      const root = {
        type: 'Root',
        children: []
      }
      
      // 创建一个栈
      const elementStack = [root]
      
      while(tokens.length){
        // 获取当前栈顶点作为父节点,也就是栈数组最后一项
        const parent = elementStack[elementStack.length - 1];
        // 从 tokens 列表中依次取出第一个 token
        const t = tokens[0];
        
        switch(t.type){
            // 根据不同的type做不同的处理
          case 'tag':{
            // 创建一个Element类型的AST节点
            const elementNode = {
              type: 'Element',
              tag: t.name,
              children: []
            }
            // 将其添加为父节点的子节点
            parent.children.push(elementNode)
            // 将当前节点压入栈里面
            elementStack.push(elementNode)
            break;
          }
          case 'text':
            // 创建文本类型的 AST 节点
            const textNode = {
              type: 'Text',
              content: t.content
            }
            // 将其添加到父级节点的 children 中
            parent.children.push(textNode)
            break
          case 'tagEnd':
            // 遇到结束标签,将当前栈顶的节点弹出
            elementStack.pop()
            break
        }
        // 将处理过的 token 弹出去
        tokens.shift();
      }
    }
    

    最终,经过上面的处理,就得到了模板的抽象语法树:

    {
      "type": "Root",
      "children": [
        {
          "type": "Element",
          "tag": "div",
          "children": [
            {
              "type": "Element",
              "tag": "p",
              "children": [
                  {
                    "type": "Text",
                    "content": "Vue"
                  }
              ]
            },
            {
              "type": "Element",
              "tag": "p",
              "children": [
                  {
                    "type": "Text",
                    "content": "React"
                  }
              ]
            }
          ]
        }
      ]
    }
    

    转换器

    目前为止,我们已经得到了模板的 AST,回顾一下 Vue 中整个模板的编译过程,大致如下:

    // 编译器
    function compile(template){
      // 1. 解析器对模板进行解析,得到模板的AST
      const ast = parse(template)
      // 2. 转换器:将模板AST转换为JS AST
      transform(ast)
      // 3. 生成器:在 JS AST 的基础上生成 JS 代码
      const code = genrate(ast)
      
      return code;
    }
    

    转换器的核心作用就是负责将模板 AST 转换为 JavaScript AST。

    整体来讲,转换器的编写分为两大部分:

    • 模板 AST 的遍历与转换
    • 生成 JavaScript AST

    模板AST的遍历与转换

    步骤一:先书写一个简单的工具方法,方便查看一个模板 AST 中的节点信息。

    function dump(node, indent = 0) {
        // 获取当前节点的类型
        const type = node.type;
        // 根据节点类型构建描述信息
        // 对于根节点,描述为空;对于元素节点,使用标签名;对于文本节点,使用内容
        const desc =
          node.type === "Root"
            ? ""
            : node.type === "Element"
            ? node.tag
            : node.content;
    
        // 打印当前节点信息,包括类型和描述
        // 使用重复的"-"字符来表示缩进(层级)
        console.log(`${"-".repeat(indent)}${type}: ${desc}`);
    
        // 如果当前节点有子节点,递归调用dump函数打印每个子节点
        if (node.children) {
          node.children.forEach((n) => dump(n, indent + 2));
        }
    }
    

    步骤二:接下来下一步就是遍历整棵模板 AST 树,并且能够做一些改动

    function tranverseNode(ast){
      // 获取到当前的节点
      const currentNode = ast;
      
      // 将p修改为h1
      if(currentNode.type === 'Element' && currentNode.tag === 'p'){
        currentNode.tag = 'h1';
      }
      
      // 新增需求:将文本节点全部改为大写
      if(currentNode.type === 'Text'){
        currentNode.content = currentNode.content.toUpperCase();
      }
      
      // 获取当前节点的子节点
      const children = currentNode.children;
      if(children){
        for(let i = 0;i< children.length; i++){
          tranverseNode(children[i])
        }
      }
    }
    
    function transform(ast){
      // 在遍历模板AST树的时候,可以针对部分节点作出一些修改
      tranverseNode(ast);
      
      console.log(dump(ast));
    }
    

    目前tranverseNode虽然能够正常工作,但是内部有两个职责:遍历、转换,接下来需要将这两个职责进行解耦。

    步骤三:在 transform 里面维护一个上下文对象(环境:包含执行代码时用到的一些信息)

    // 需要将之前的转换方法全部提出来,每一种转换提取成一个单独的方法
    function transformElement(node){
      if(node.type === 'Element' && node.tag === 'p'){
        node.tag = 'h1';
      }
    }
    
    function transformText(node){
      if(node.type === 'Text'){
        node.content = node.content.toUpperCase();
      }
    }
    
    // 该方法只负责遍历,转换的工作交给转换函数
    // 转换函数是存放于上下文对象里面的
    function tranverseNode(ast, context) {
      // 获取到当前的节点
      context.currentNode = ast;
    
      // 从上下文对象里面拿到所有的转换方法
      const transforms = context.nodeTransforms;
    
      for (let i = 0; i < transforms.length; i++) {
        transforms[i](context.currentNode);
      }
    
      // 获取当前节点的子节点
      const children = context.currentNode.children;
      if (children) {
        for (let i = 0; i < children.length; i++) {
          // 更新上下文里面的信息
          context.parent = context.currentNode;
          context.childIndex = i;
          tranverseNode(children[i], context);
        }
      }
    }
    
    
    function transform(ast){
      // 上下文对象:包含一些重要信息
      const context = {
        currentNode: null, // 存储当前正在转换的节点
        childIndex: 0, // 子节点在父节点的 children 数组中的索引
        parent: null, // 存储父节点
        nodeTransforms: [transformElement, transformText], // 存储具体的转换方法
      }
      
      // 在遍历模板AST树的时候,可以针对部分节点作出一些修改
      tranverseNode(ast, context);
      
      
    }
    

    步骤四:完善 context 上下文对象,这里主要是添加2个方法

    1. 替换节点方法
    2. 删除节点方法
    const context = {
      currentNode: null, // 存储当前正在转换的节点
      childIndex: 0, // 子节点在父节点的 children 数组中的索引
      parent: null, // 存储父节点
      // 替换节点
      replaceNode(node){
        context.parent.children[context.childIndex] = node;
        context.currentNode = node;
      },
      // 删除节点
      removeNode(){
        if(context.parent){
          context.parent.children.splice(context.childIndex, 1);
          context.currentNode = null;
        }
      },
      nodeTransforms: [transformElement, transformText], // 存储具体的转换方法
    }
    

    注意因为存在删除节点的操作,所以在tranverseNode方法里面执行转换函数之后,需要进行非空的判断:

    function tranverseNode(ast, context) {
      // 获取到当前的节点
      context.currentNode = ast;
    
      // 从上下文对象里面拿到所有的转换方法
      const transforms = context.nodeTransforms;
    
      for (let i = 0; i < transforms.length; i++) {
        transforms[i](context.currentNode, context);
        // 由于删除节点的时候,当前节点会被置为null,所以需要判断
        // 如果当前节点为null,直接返回
        if(!context.currentNode) return;
      }
    
      // 获取当前节点的子节点
      const children = context.currentNode.children;
      if (children) {
        for (let i = 0; i < children.length; i++) {
          // 更新上下文里面的信息
          context.parent = context.currentNode;
          context.childIndex = i;
          tranverseNode(children[i], context);
        }
      }
    }
    

    步骤五:解决节点处理的次数问题

    目前来讲,遍历的顺序是深度遍历,从父节点到子节点。但是我们的需求是:子节点处理完之后,重新回到父节点,对父节点进行处理。

    首先需要对转换函数进行改造:返回一个函数

    function transformText(node, context) {
      // 省略第一次处理....
      
      return ()=>{
        // 对节点再次进行处理
      }
    }
    

    tranverseNode需要拿一个数组存储转换函数返回的函数:

    function tranverseNode(ast, context) {
      // 获取到当前的节点
      context.currentNode = ast;
      
      // 1. 增加一个数组,用于存储转换函数返回的函数
      const exitFns = []
    
      // 从上下文对象里面拿到所有的转换方法
      const transforms = context.nodeTransforms;
    
      for (let i = 0; i < transforms.length; i++) {
        // 执行转换函数的时候,接收其返回值
        const onExit = transforms[i](context.currentNode, context);
        if(onExit){
          exitFns.push(onExit)
        }
        // 由于删除节点的时候,当前节点会被置为null,所以需要判断
        // 如果当前节点为null,直接返回
        if(!context.currentNode) return;
      }
    
      // 获取当前节点的子节点
      const children = context.currentNode.children;
      if (children) {
        for (let i = 0; i < children.length; i++) {
          // 更新上下文里面的信息
          context.parent = context.currentNode;
          context.childIndex = i;
          tranverseNode(children[i], context);
        }
      }
      
      // 在节点处理完成之后,执行exitFns里面所有的函数
      // 执行的顺序是从后往前依次执行
      let i = exitFns.length;
      while(i--){
        exitFns[i]()
      }
    }
    

    生成JS AST

    要生成 JavaScript 的 AST,我们首先需要知道 JavaScript 的 AST 是如何描述代码的。

    假设有这么一段代码:

    function render(){
      return null
    }
    

    那么所对应的 JS AST 为:

    image-20231120143716229

    这里有几个比较关键的部分:

    • id:对应函数的名称,类型为 Identifier
    • params:对应的是函数的参数,是一个数组
    • body:对应的是函数体,由于函数体可以有多条语句,因此是一个数组

    要查看一段 JS 代码所对应的 AST 结构,可以在 这里 (opens new window) 进行查看。

    于是,我们可以仿造上面的样子,自己设计一个基本的数据结构来描述函数声明语句,例如:

    const FunctionDeclNode = {
      type: 'FunctionDecl', // 代表该节点是一个函数声明
      id: {
        type: 'Identifier'
        name: 'render' // name 用来存储函数名称
      },
      params: [], // 函数参数
      body: [
        {
          type: 'ReturnStatement',
          return: null
        }
      ]
    }
    

    对比真实的 AST,这里去除了箭头函数、生成器函数、async 函数等情况。

    接下来回到我们上面的模板,假设模板内容仍然为:

    <div><p>Vue</p><p>React</p></div>
    

    那么转换出来的渲染函数应该是:

    function render(){
      return h('div', [
        h('p', 'Vue'),
        h('p', 'React'),
      ])
    }
    

    这里出现了 h 函数的调用以及数组表达式还有字符串表达式,仍然可以去参阅这段代码真实的 AST。

    这里 h 函数对应的应该是:

    // 我们自己设计一个节点表示 h 函数的调用
    const callExp = {
      type: 'CallExpression',
      callee: {
        type: 'Identifier',
        name: 'h'
      }
    }
    

    字符串对应的是:

    // 我们自己设计字符串对应的节点
    const Str = {
      type: 'StringLiteral',
      value: 'div'
    }
    

    这里以最外层的 div 字符串为例

    数组对应的是:

    const Arr = {
      type: 'ArrayExpression',
      // 数组中的元素
      elements: []
    }
    

    因此按照我们所设计的 AST 数据结构,上面的模板最终转换出来的 JavaScript AST 应该是这样的:

    {
      "type": "FunctionDecl",
      "id": {
          "type": "Identifier",
          "name": "render"
      },
      "params": [],
      "body": [
          {
              "type": "ReturnStatement",
              "return": {
                  "type": "CallExpression",
                  "callee": {"type": "Identifier", "name": "h"},
                  "arguments": [
                      {"type": "StringLiteral", "value": "div"},
                      {"type": "ArrayExpression","elements": [
                            {
                                "type": "CallExpression",
                                "callee": {"type": "Identifier", "name": "h"},
                                "arguments": [
                                    {"type": "StringLiteral", "value": "p"},
                                    {"type": "StringLiteral", "value": "Vue"}
                                ]
                            },
                            {
                                "type": "CallExpression",
                                "callee": {"type": "Identifier", "name": "h"},
                                "arguments": [
                                    {"type": "StringLiteral", "value": "p"},
                                    {"type": "StringLiteral", "value": "React"}
                                ]
                            }
                        ]
                      }
                  ]
              }
          }
      ]
    }
    

    我们需要一些辅助函数,这些辅助函数都很简单,一并给出如下:

    function createStringLiteral(value) {
      return {
        type: 'StringLiteral',
        value
      }
    }
    
    function createIdentifier(name) {
      return {
        type: 'Identifier',
        name
      }
    }
    
    function createArrayExpression(elements) {
      return {
        type: 'ArrayExpression',
        elements
      }
    }
    
    function createCallExpression(callee, arguments) {
      return {
        type: 'CallExpression',
        callee: createIdentifier(callee),
        arguments
      }
    }
    

    有了这些辅助函数后,接下来我们来修改转换函数。

    首先是文本转换

    function transformText(node, context){
      if(node.type !== 'Text'){
        return
      }
      // 创建文本所对应的 JS AST 节点
      // 将创建好的 AST 节点挂到节点的 jsNode 属性上面
      node.jsNode = createStringLiteral(node.content);
    }
    

    Element元素转换

    function transformElement(node, context){
      // 这里应该是所有的子节点处理完毕后,再进行处理
      return ()=>{
        if(node.type !== 'Element'){
          return;
        }
        
        // 创建函数调用的AST节点
        const callExp = createCallExpression('h', [
          createStringLiteral(node.tag),
        ])
        
        // 处理函数调用的参数
        node.children.length === 1
        ? // 如果长度为1说明只有一个子节点,直接将子节点的 jsNode 作为参数
          callExp.arguments.push(node.children[0].jsNode)
        : // 说明有多个子节点
        callExp.arguments.push(
        	createArrayExpression(node.children.map(c=>c.jsNode))
        )
        
        node.jsNode = callExp
      }
    }
    

    transformRoot转换:

    function transformRoot(node, context){
      // 在退出的回调函数中书写处理逻辑
      // 因为要保证所有的子节点已经处理完毕
      return ()=>{
        if(node.type !== 'Root'){
          return;
        }
        
        const vnodeJSAST = node.children[0].jsNode;
        
        node.jsNode = {
          type: 'FunctionDecl',
          id: {type: 'Identifier', name: 'render'},
          params: [],
          body: [{
            type: 'ReturnStatement',
            return: vnodeJSAST
          }]
        }
      }
    }
    

    最后修改 nodeTransforms,将这几个转换函数放进去:

    nodeTransforms: [
      transformRoot,
      transformElement,
      transformText
    ]
    

    至此,我们就完成模板 AST 转换为 JS AST 的工作。

    通过 ast.jsNode 能够拿到转换出来的结果。

    生成器

    目前编译器的整体流程:

    // 编译器
    function compile(template){
      // 1. 解析器对模板进行解析,得到模板的AST
      const ast = parse(template)
      // 2. 转换器:将模板AST转换为JS AST
      transform(ast)
      // 3. 生成器:在 JS AST 的基础上生成 JS 代码
      const code = genrate(ast)
      
      return code;
    }
    

    在生成器里面需要维护一个上下文对象,用于存储一些重要的状态信息。

    function generate(ast){
      const context = {
        code: "", // 存储最终生成的代码
        // 生成代码本质上就是字符串的拼接
        push(code){
          context.code += code;
        },
        // 当前缩进的级别,初始值为0,没有缩进
        currentIndent: 0,
        // 用于换行的,并且会根据缩进的级别添加对应的缩进
        newLine(){
          context.code += "\n" + `  `.repeat(context.currentIndent);
        },
        // 增加缩进级别
        indent(){
          context.currentIndent++;
          context.newLine();
        },
        // 降低缩进级别
        deIndent(){
          context.currentIndent--;
          context.newLine();
        }
      }
      
      genNode(ast, context);
      
      return context.code;
    }
    

    genNode 方法:根据不同的节点类型,调用不同的方法:

    function genNode(node, context){
      switch(node.type){
        case 'FunctionDecl':
          genFunctionDecl(node, context)
          break
        case 'ReturnStatement':
          genReturnStatement(node, context)
          break
       	case 'CallExpression':
          genCallExpression(node, context)
          break
        case 'StringLiteral':
          genStringLiteral(node, context)
          break
        case 'ArrayExpression':
          genArrayExpression(node, context)
          break
      }
    }
    

    最后就是各种生成方法:本质上就是根据不同的节点类型,做不同的字符串拼接

    // 生成字符串字面量
    function genStringLiteral(node, context){
      const { push } = context;
      push(`'${node.value}'`)
    }
    // 生成返回语句
    function genReturnStatement(node, context){
      const { push } = context;
      push(`return `)
      genNode(node.return, context);
    }
    // 生成函数声明
    function genFunctionDecl(node, context) {
      // 从上下文中获取一些实用函数
      const { push, indent, deIndent } = context;
      // 向输出中添加 "function 函数名"
      push(`function ${node.id.name} `);
      // 添加左括号开始参数列表
      push(`(`);
      // 生成参数列表
      genNodeList(node.params, context);
      // 添加右括号结束参数列表
      push(`) `);
      // 添加左花括号开始函数体
      push(`{`);
      // 缩进,为函数体的代码生成做准备
      indent();
      // 遍历函数体中的每个节点,生成相应的代码
      node.body.forEach((n) => genNode(n, context));
      // 减少缩进
      deIndent();
      // 添加右花括号结束函数体
      push(`}`);
    }
    
    // 生成节点列表
    function genNodeList(nodes, context) {
      const { push } = context;
      for (let i = 0; i < nodes.length; i++) {
        const node = nodes[i];
    
        // 生成当前节点的代码
        genNode(node, context);
    
        // 如果当前节点不是最后一个节点,添加逗号分隔
        if (i < nodes.length - 1) {
          push(", ");
        }
      }
    }
    
    // 生成函数调用表达式
    function genCallExpression(node, context) {
      const { push } = context;
      const { callee, arguments: args } = node;
    
      // 添加 "函数名("
      push(`${callee.name}(`);
      // 生成参数列表
      genNodeList(args, context);
      // 添加 ")"
      push(`)`);
    }
    
    // 生成数组表达式
    function genArrayExpression(node, context) {
      const { push } = context;
      // 添加 "["
      push("[");
      // 生成数组元素
      genNodeList(node.elements, context);
      // 添加 "]"
      push("]");
    }
    

    面试题:说一下 Vue 中 Compiler 的实现原理是什么?

    参考答案:

    在 Vue 中,Compiler 主要用于将开发者的模板编译为渲染函数,内部可以分为 3 个大的组件:

    1. 解析器:负责将模板解析为对应的模板 AST

      • 内部用到了有限状态机来进行解析,这是解析标记语言的常用方式,浏览器内部解析 HTML 也是通过有限状态机的方式进行解析的。

      • 解析的结果能够获取到一个 token 的数组

      • 紧接着扫描 token 列表,通过栈的方式将 token 压入和弹出栈,发现是起始标记时就入栈,发现是结束标记时就出栈,最终能够得到模板 AST 树结构

    2. 转换器:负责将模板 AST 转换为 JS AST

      • 内部会维护一个上下文对象,用于存储一些关键的信息

        • 当前正在转换的节点

        • 当前正在转换的子节点在父节点的 children 数组中的索引

        • 当前正在转换的父节点

        • 具体的转换函数

          • 对节点的处理分为进入阶段处理一次和退出阶段处理一次
            • 这种思想在各个地方都非常常见,例如:

              • React 中的 beginWork、completeWork
              • Koa 中间件所采用的洋葱模型
      • 生成 JS AST

        • 不同的节点对应不同的节点对象,对象里面会包含节点的 type、name、value 一类的信息
          • 主要就是遍历模板的 AST,根据不同的节点,返回对应的对象
    3. 生成器:根据 JS AST 生成最终的渲染函数

      • 主要就是遍历 JS AST,根据不同的节点对象,拼接不同的字符

    当然,整个 Compiler 内部还会做很多的优化,从而带来性能上的提升。不知道这一块儿需不需要我展开讲一下?


    -EOF-

    最长递增子序列
    模板编译提升

    ← 最长递增子序列 模板编译提升→

    Theme by Vdoing | Copyright © 2021-2024 蜀ICP备2024068710号-1
    • 跟随系统
    • 浅色模式
    • 深色模式
    • 阅读模式