You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

18 KiB

32编译原理手写一个迷你Vue 3 Compiler的入门原理

你好,我是大圣。

前面我们用了四讲学习了Vue在浏览器中是如何执行的你可以参考上一讲结尾的Vue执行全景图来回顾一下。在Vue中组件都是以虚拟DOM的形式存在加载完毕之后注册effect函数。这样组件内部的数据变化之后用Vue的响应式机制做到了通知组件更新内部则使用patch函数实现了虚拟DOM的更新中间我们也学习了位运算、最长递增子序列等算法。

这时候你肯定还有一个疑问那就是虚拟DOM是从哪来的我们明明写的是template和JSX这也是吃透Vue源码最后一个难点Vue中的Compiler。

下图就是Vue核心模块依赖关系图reactivity和runtime我们已经剖析完毕迷你版本的代码你可以在GitHub中看到。今天开始我将用三讲的内容给你详细讲解一下Vue在编译的过程中做了什么。

图片

编译原理也属于计算机中的一个重要学科Vue的compiler是在Vue场景下的实现目的就是实现template到render函数的转变。

我们第一步需要先掌握编译原理的基本概念。Vue官方提供了模板编译的在线演示。下图左侧代码是我们写的template右侧代码就是compiler模块解析城的render函数我们今天的任务就是能够实现一个迷你的compiler。

图片

整体流程

上述转化的过程可以分为下面的示意图几步来实现。

首先代码会被解析成一个对象这个对象有点像虚拟DOM的概念用来描述template的代码关系这个对象就是抽象语法树简称AST后面我们细讲。然后通过transform模块对代码进行优化比如识别Vue中的语法静态标记、最后通过generate模块生成最终的render函数。

图片

理清了流程我们动手完成具体代码实现。用下面的代码就能实现上述的流程图里的内容。其中parse函数负责生成抽象语法树ASTtransform函数负责语义转换generate函数负责最终的代码生成。


function compiler(template) {
  const ast = parse(template);
  transform(ast)
  const code = generate(ast)
  return code
}

let template = `<div id="app">
  <div @click="()=>console.log(xx)" :id="name">{{name}}</div>
  <h1 :name="title">玩转vue3</h1>
  <p >编译原理</p>
</div>
`

const renderFunction = compiler(template)
console.log(renderFunction)

我们先来看下parse函数如何实现。template转成render函数是两种语法的转换这种代码转换的需求其实计算机的世界中非常常见。比如我们常用的Babel就是把ES6的语法转成低版本浏览器可以执行的代码。

tokenizer的迷你实现

首先我们要对template进行词法分析把模板中的

, @click, {{}}等语法识别出来转换成一个个的token。你可以理解为把template的语法进行了分类这一步我们叫tokenizer。

下面的代码就是tokenizer的迷你实现。我们使用tokens数组存储解析的结果然后对模板字符串进行循环在template中< > / 和空格都是关键的分隔符,如果碰见<字符我们需要判断下一个字符的状态。如果是字符串我们就标记tagstart如果是/我们就知道是结束标签标记为tagend最终通过push方法把分割之后的token存储在数组tokens中返回。

function tokenizer(input) {
  let tokens = []
  let type = ''
  let val = ''
  // 粗暴循环
  for (let i = 0; i < input.length; i++) {
    let ch = input[i]
    if (ch === '<') {
      push()
      if (input[i + 1] === '/') {
        type = 'tagend'
      } else {
        type = 'tagstart'
      }
    } if (ch === '>') {
      if(input[i-1]=='='){
        //箭头函数
      }else{
        push()
        type = "text"
        continue
      }
    } else if (/[\s]/.test(ch)) { // 碰见空格截断一下
      push()
      type = 'props'
      continue
    }
    val += ch
  }
  return tokens

  function push() {
    if (val) {
      if (type === "tagstart") val = val.slice(1) // <div => div
      if (type === "tagend") val = val.slice(2)   //  </div  => div
      tokens.push({
        type,
        val
      })
      val = ''
    }
  }
}

实现了上面的代码我们就得到了解析之后的token数组。

生成抽象语法树

下面的数组中我们分别用tagstart、props tagend和text标记用它们标记了全部内容。然后下一步我们需要把这个数组按照标签的嵌套关系转换成树形结构这样才能完整地描述template标签的关系。

[
  { type: 'tagstart', val: 'div' },
  { type: 'props', val: 'id="app"' },
  { type: 'tagstart', val: 'div' },
  { type: 'props', val: '@click="()=console.log(xx)"' },
  { type: 'props', val: ':id="name"' },
  { type: 'text', val: '{{name}}' },
  { type: 'tagend', val: 'div' },
  { type: 'tagstart', val: 'h1' },
  { type: 'props', val: ':name="title"' },
  { type: 'text', val: '玩转vue3' },
  { type: 'tagend', val: 'h1' },
  { type: 'tagstart', val: 'p' },
  { type: 'text', val: '编译原理' },
  { type: 'tagend', val: 'p' },
  { type: 'tagend', val: 'div' }

然后我们分析token数组看看它是如何转化成一个体现语法规则的树形结构的。
就像我们用虚拟DOM描述页面DOM结构一样我们使用树形结构描述template的语法这个树我们称之为抽象语法树简称AST。

下面的代码中我们用parse函数实现AST的解析。过程是这样的首先我们使用一个AST对象作为根节点。然后通过walk函数遍历整个tokens数组根据token的类型不同生成不同的node对象。最后根据tagend的状态来决定walk的递归逻辑最终实现了整棵树的构建。

function parse(template) {
  const tokens = tokenizer(template)
  let cur = 0
  let ast = {
    type: 'root',
    props:[],
    children: []
  }
  while (cur < tokens.length) {
    ast.children.push(walk())
  }
  return ast

  function walk() {
    let token = tokens[cur]
    if (token.type == 'tagstart') {
      let node = {
        type: 'element',
        tag: token.val,
        props: [],
        children: []
      }
      token = tokens[++cur]
      while (token.type !== 'tagend') {
        if (token.type == 'props') {
          node.props.push(walk())
        } else {
          node.children.push(walk())
        }
        token = tokens[cur]
      }
      cur++
      return node
    }
    if (token.type === 'tagend') {
      cur++
      // return token
    }
    if (token.type == "text") {
      cur++
      return token
    }
    if (token.type === "props") {
      cur++
      const [key, val] = token.val.replace('=','~').split('~')
      return {
        key,
        val
      }
    }
  }
}

上面的代码会生成抽象语法树AST这个树的结构如下面代码所示通过type和children描述整个template的结构。

{
  "type": "root",
  "props": [],
  "children": [
    {
      "type": "element",
      "tag": "div",
      "props": [
        {
          "key": "id",
          "val": "\"app\""
        }
      ],
      "children": [
        {
          "type": "element",
          "tag": "div",
          "props": [
            {
              "key": "@click",
              "val": "\"()"
            },
            {
              "key": ":id",
              "val": "\"name\""
            }
          ],
          "children": [
            {
              "type": "text",
              "val": "{{name}}"
            }
          ]
        },
        {
          "type": "element",
          "tag": "h1",
          "props": [
            {
              "key": ":name",
              "val": "\"title\""
            }
          ],
          "children": [
            {
              "type": "text",
              "val": "玩转vue3"
            }
          ]
        },
        {
          "type": "element",
          "tag": "p",
          "props": [],
          "children": [
            {
              "type": "text",
              "val": "编译原理"
            }
          ]
        }
      ]
    }
  ]
}

语义分析和优化

有了抽象语法树之后,我们还要进行语义的分析和优化,也就是说,我们要在这个阶段理解语句要做的事。咱们结合例子来理解会更容易。

在template这个场景下两个大括号包裹的字符串就是变量@click就是事件监听。

下面的代码中我们使用transform函数实现这个功能这一步主要是理解template中Vue的语法并且为最后生成的代码做准备。我们使用context对象存储AST所需要的上下文如果我们用到了变量{{}}就需要引入toDisplayString函数上下文中的helpers存储的就是我们用到的工具函数。

function transform(ast) {
  // 优化一下ast
  let context = {
    // import { toDisplayString , createVNode , openBlock , createBlock } from "vue"
    helpers:new Set(['openBlock','createVnode']), // 用到的工具函数 
  }
  traverse(ast, context)
  ast.helpers = context.helpers
}

然后我们使用traverse函数递归整个AST去优化AST的结构并且在这一步实现简单的静态标记。

当节点标记为element的时候我们递归调用整个AST内部挨个遍历AST所有的属性我们默认使用ast.flag标记节点的动态状态。如果属性是@开头的我们就认为它是Vue中的事件绑定使用arg.flag|= PatchFlags.EVENT 标记当前节点的事件是动态的需要计算diff这部分位运算的知识点我们在上一讲已经学习过了。

然后冒号开头的就是动态的属性传递并且把class和style标记了不同的flag。如果都没有命中的话就使用static:true标记当前节点位是静态节点。

function traverse(ast, context){
  switch(ast.type){
    case "root":
      context.helpers.add('createBlock')
      // log(ast)
    case "element":
      ast.children.forEach(node=>{
        traverse(node,context)
      })
      ast.flag = 0
      ast.props = ast.props.map(prop=>{
        const {key,val} = prop
        if(key[0]=='@'){
          ast.flag |= PatchFlags.EVENT // 标记event需要更新
          return {
            key:'on'+key[1].toUpperCase()+key.slice(2),
            val
          }
        }
        if(key[0]==':'){
          const k = key.slice(1)
          if(k=="class"){
            ast.flag |= PatchFlags.CLASS // 标记class需要更新

          }else if(k=='style'){
            ast.flag |= PatchFlags.STYLE // 标记style需要更新
          }else{
            ast.flag |= PatchFlags.PROPS // 标记props需要更新
          }
          return{
            key:key.slice(1),
            val
          }
        }
        if(key.startsWith('v-')){
          // pass such as v-model 
        }
        //标记static是true 静态节点
        return {...prop,static:true} 
      })
      break
    case "text":
      // trnsformText
      let re = /\{\{(.*)\}\}/g
      if(re.test(ast.val)){
        //有{{
          ast.flag |= PatchFlags.TEXT // 标记props需要更新
          context.helpers.add('toDisplayString')
          ast.val = ast.val.replace(/\{\{(.*)\}\}/g,function(s0,s1){
            return s1
          })
      }else{
        ast.static = true
      }
  }
}  


经过上面的代码标记优化之后项目在数据更新之后浏览器计算虚拟dom diff运算的时候就会执行类似下面的代码逻辑。

我们通过在compiler阶段的标记让template产出的虚拟DOM有了更精确的状态可以越过大部分的虚拟DOM的diff计算极大提高Vue的运行时效率这个思想我们日常开发中也可以借鉴学习。

if(vnode.static){
  return 
}
if(vnode.flag & patchFlag.CLASS){
  遍历class 计算diff  
}else if(vnode.flag & patchFlag.STYLE){
  计算style的diff
}else if(vnode.flag & patchFlag.TEXT){
  计算文本的diff
}

接下来我们基于优化之后的AST生成目标代码也就是generate函数要做的事遍历整个AST拼接成最后要执行的函数字符串。

下面的代码中我们首先把helpers拼接成import语句并且使用walk函数遍历整个AST在遍历的过程中收集helper集合的依赖。最后在createVnode的最后一个参数带上ast.flag进行状态的标记。

function generate(ast) {
  const {helpers} = ast 

  let code = `
import {${[...helpers].map(v=>v+' as _'+v).join(',')}} from 'vue'\n
export function render(_ctx, _cache, $props){
  return(_openBlock(), ${ast.children.map(node=>walk(node))})}`

  function walk(node){
    switch(node.type){
      case 'element':
        let {flag} = node // 编译的标记
        let props = '{'+node.props.reduce((ret,p)=>{
          if(flag.props){
            //动态属性
            ret.push(p.key +':_ctx.'+p.val.replace(/['"]/g,'') )
          }else{
            ret.push(p.key +':'+p.val )
          }

          return ret
        },[]).join(',')+'}'
        return `_createVnode("${node.tag}",${props}),[
          ${node.children.map(n=>walk(n))}
        ],${JSON.stringify(flag)}`
        break
      case 'text':
        if(node.static){
          return '"'+node.val+'"'
        }else{
          return `_toDisplayString(_ctx.${node.val})`
        }
        break
    }
  }
  return code
}

最终实现效果

最后我们执行一下代码看下效果输出的代码。可以看到它已经和Vue输出的代码很接近了到此为止我们也实现了一个非常迷你的Vue compiler这个产出的render函数最终会和组件的setup函数一起组成运行时的组件对象。

function compiler(template) {
  const ast = parse(template);
  transform(ast)

  const code = generate(ast)
  return code
}

let template = `<div id="app">
  <div @click="()=>console.log(xx)" :id="name">{{name}}</div>
  <h1 :name="title">玩转vue3</h1>
  <p >编译原理</p>
</div>
`

const renderFunction = compiler(template)
console.log(renderFunction)

// 下面是输出结果
import { openBlock as _openBlock, createVnode as _createVnode, createBlock as _createBlock, toDisplayString as _toDisplayString } from 'vue'

export function render(_ctx, _cache, $props) {
  return (_openBlock(), _createVnode("div", { id: "app" }), [
    _createVnode("div", { onClick: "()=>console.log(xx)", id: "name" }), [
      _toDisplayString(_ctx.name)
    ], 24, _createVnode("h1", { name: "title" }), [
      "玩转vue3"
    ], 8, _createVnode("p", {}), [
      "编译原理"
    ], 0
  ], 0)
}


总结

我们总结一下今天所学的内容。今天我带你手写了一个非常迷你的Vue compiler这也是我们学习框架源码的时候一个比较正确的思路在去看实际的源码之前先通过迷你版本的实现熟悉整个Vue compiler工作的主体流程。

图片

通过这个迷你的compiler我们学习了编译原理的入门知识包括parser的实现、AST是什么AST的语义化优化和代码生成generate模块这给我们下一讲弄清楚Vue的compiler的核心逻辑打下了良好的理论基础。

我想提醒你注意一个优化方法我们实现的迷你compiler也实现了属性的静态标记通过在编译期间的标记方式让虚拟DOM在运行时有更多的状态从而能够精确地控制更新。这种编译时的优化也能够对我们项目开发有很多指引作用我会在剖析完Vue的compiler之后在第34讲那里跟你分享一下实战中如何使用编译优化的思想。

思考题

最后留一个思考题吧Vue的compiler输出的代码会有几个hoisted开头的变量这几个变量有什么用处呢欢迎在评论区分享你的答案也欢迎你把这一讲分享给你的同事和朋友们我们下一讲再见