大结局!实现vue3模版编译功能🎉 🎉

1,849 阅读15分钟

hey🖐! 我是小黄瓜😊😊。不定期更新,期待关注➕ 点赞,共同成长~

写在前面

本文的目标是实现一个简单的模版编译解析器,本文是系列文章,本系列已全面使用vue3组合式语法,如果你对 vue3基础语法及响应式相关逻辑以及基本的 vue3 的虚拟DOM的节点渲染与更新还不了解,那么请移步:

超详细整理vue3基础知识💥

狂肝半个月!1.3万字深度剖析vue3响应式(附脑图)🎉🎉

手写mini-vue3第三弹!万字实现渲染器首次渲染流程 🎉 🎉

更新!更新!实现vue3虚拟DOM更新&diff算法优化🎉 🎉

本文只是是模版编译的内容。

食用提醒!必看!

由于整个编译过程中的函数实现以及流程过长,有很多函数的实现内容在相关的章节并不会全部展示,并且存在大量的伪代码,相关章节只会关注当前功能代码的显示和实现。

但是!为了便于理解,我在github上上传了每章节的具体实现。(请把贴心打在评论区 😂😂)把每一章节的实现都存放在了单独的文件夹:

截屏2022-09-10 08.18.15.png

只使用了单纯的htmljs来实现功能,只需要在index.html中替换相关章节的文件路径(替换以下三个文件),在浏览器中打开,就可以自行调试和查看代码。见下图:

image-20220909153126330.png

地址在这里,欢迎star!

👉 vue3-analysis(component)

mini-vue3的正式项目地址在这里!

👉 k-vue

欢迎star!

本文你将学到

  • 解析element标签
  • 解析text文本
  • 实现 transform 功能
  • 实现ast对象转换为render函数

在处理vnode的几篇文章中,我们进行编写DOM是是这样编写的:

 const App = {
   setup() {},
   render() {
     return h('div', { id: 'box' }, [
       h('p', {}, 'hello'),
       h('p', {}, '瓜')
     ])
   }
 }

使用h函数来生成虚拟节点,然后进行生成真实DOM,对比,更新等操作。但是我们在使用vue时编写html部分明明是这样子的:

 <template>
   <div id="box">
     <p>hello</p>
     <p></p>
   </div>
 </template>

这才是符合我们正常人认知的写法,毕竟这是最直观,最贴近原生的html写法,谁会愿意编写html的时候使用一堆函数呢,既不直观也不好维护。那么怎么将我们编写的html模版内容转换成使用h函数调用的方式呢?答案正是模版编译。

vue3模版编译的转换思路其实也很简单:

++转换过程图片++

  1. 首先将template转换成AST语法树
  2. 根据AST语法树(对象结构)进行拼接为render函数

很多人可能会对AST语法树感到好奇,AST语法树其实就是根据特定的标记对语法所生成的一个对象,这一点和虚拟DOM的概念很相似。比如:

var a = 123;

这个表达式所生成的AST语法树是这样的:

{
  "type": "Program",
  "start": 0,
  "end": 12,
  "body": [
    {
      "type": "VariableDeclaration",
      "start": 0,
      "end": 12,
      "declarations": [
        {
          "type": "VariableDeclarator",
          "start": 4,
          "end": 11,
          "id": {
            "type": "Identifier",
            "start": 4,
            "end": 5,
            "name": "a"
          },
          "init": {
            "type": "Literal",
            "start": 8,
            "end": 11,
            "value": 123,
            "raw": "123"
          }
        }
      ],
      "kind": "var"
    }
  ],
  "sourceType": "module"
}

有兴趣可以在这个网站进行试验一下:astexplorer.net/

因为我们的重点是要讲template中的DOM结构生成render函数,所以对语法层面的编译不会过多的进行研究。

我们最终的目的是将:

 <div>hi,{{ message }}</div>

这个模版编译生成为render函数。

一. 实现解析插值功能

首先第一部分先要将模版转换成AST对象,根据我们的模版已知,需要处理三种类型:

  • 插值表达式:{{ message }}
  • 文本节点:hi,
  • 元素节点:

我们需要将它们分别取出来生成不同表示的对象结构。

处理{{ message }}这个插值表达式时,核心的问题是将message取出来,因为在模版中通常在{{}}里面进行包裹的为需要执行的内容。

所要达成的目标为下面的对象结构:

 {
   children: [
     {
       // INTERPOLATION代表插值类型
       type: NodeTypes.INTERPOLATION,
       content: {
         // 简单表达式
         type: NodeTypes.SIMPLE_EXPRESSION,
         // 内容
         content: "message",
       }
     }
   ]
 }

定义baseParse函数,用于处理插值类型:

// content为{{ message }}
const baseParse = function (content) {
  const context = createParserContext(content)

  return createRoot(parseChildren(context))
}

其中涉及到了三个函数:createParserContext用于生成一个执行对象,用于包裹content

const createParserContext = function (context) {
  // 直接包裹content值后进行返回
  return {
    source: context
  }
}

此时的context是这个样子的:

{
  source: "{{ message }}"
}

parseChildren函数用于解析字符串,并当作子级进行收集。(至于为什么要嵌套这么多层,将会在以后处理别的类型时,发挥作用)

const parseChildren = function (context) {
  // 定义收集的容器
  const nodes = []

  let node;
  // 判断是否为{{开头的字符串,判断插值类型
  if (context.source.startsWith("{{")) {
    // 解析字符串,生成节点
    node = parseInterpolation(context)
  }
  // 收集
  nodes.push(node)
  return nodes
}

在上面的最终结果中,我们是将处理后的结果当作数组(children)中的一项来处理的,所以最终的生成结果要当作数组的其中一项进行收集。

然后是解析字符串的过程,parseInterpolation函数:

const parseInterpolation = function (context) {
  const openDelimiter = "{{"
  const closeDelimiter = "}}"

  // 查找到结束括号的位置
  // closeIndex = 11
  const closeIndex = context.source.indexOf(closeDelimiter, openDelimiter.length)
  // 截取字符串 message }}
  advanceBy(context, openDelimiter.length)

  // 获取除{{和}}外的总长度
  // rawContentLength = 9
  const rawContentLength = closeIndex - openDelimiter.length
  // rawContent = 空格message空格
  const rawContent = context.source.slice(0, rawContentLength)
  // content = message
  // 清除空格
  const content = rawContent.trim()
	// 截取(也可以理解为删除,删除已经处理过的字符串)
  advanceBy(context, rawContentLength + closeDelimiter.length)

  return {
    type: NodeTypes.INTERPOLATION,
    content: {
      type: NodeTypes.SIMPLE_EXPRESSION,
      content: content
    }
  }
}

截取函数:advanceBy

const advanceBy = function (context, length) {
  // 从length开始向后截取全部
  context.source = context.source.slice(length)
}

各个类型的标识是由枚举来进行取值:

export const enum NodeTypes {
  INTERPOLATION,
  SIMPLE_EXPRESSION
}

解析插值类型字符串的核心逻辑就是“截取,推进”,来从头撸一下整个的实现思路:(以下实现均以{{ message }}为例

  1. 定义插值表达式开始与结束的标识
// 开始
const openDelimiter = "{{"
// 结束
const closeDelimiter = "}}"
  1. 查找结束括号的位置
// 查找到结束括号的位置
  // closeIndex = 11
  const closeIndex = context.source.indexOf(closeDelimiter, openDelimiter.length)
  1. 截取字符串,删除{{,此时整体字符串为 message }}
// context = {{ message }} openDelimiter.length = 2
advanceBy(context, openDelimiter.length) //  message }}
  1. 获取除{{和}}外的总长度
// rawContentLength = 9
// closeIndex = 11 closeDelimiter.length = 2
const rawContentLength = closeIndex - openDelimiter.length
  1. 截取content长度并删除空格
// rawContent = 空格message空格
const rawContent = context.source.slice(0, rawContentLength)
// content = message
const content = rawContent.trim()
  1. 删除处理完毕的字符串
// rawContentLength = 9 closeDelimiter.length = 2
// context.source未处理前为 message }}
advanceBy(context, rawContentLength + closeDelimiter.length)

处理完毕后使用createRoot函数返回根对象:

const createRoot = function (children) {
  return {
    children
  }
}

二. 实现解析 element 标签

接下来实现解析element类型,也就是:

<div></div>

解析element类型的重点就是取出和记录节点的类型,所以最终需要生成的数据:

{
  children: [
    {
      type: NodeTypes.ELEMENT,
      tag: "div",
    }
  ]
}

首先增加element类型的枚举值

const NodeType = {
  INTERPOLATION: "interpolation",
  SIMPLE_EXPRESSION: "simple_expression",
  // element类型
  ELEMENT: "element",
}

然后在parseChildren函数中增加对element类型的判断处理:

const parseChildren = function(context) {
  const nodes = []

  let node;
  let s = context.source
  // 判断插值类型
  if(s.startsWith("{{")) {
    node = parseInterpolation(context)
   // 判断eleemnt类型
  } else if(s[0] === '<') {
    // 第二个字符是否为字母?
    if(/[a-z]/i.test(s[1])) {
      node = parseElement(context)
    }
  }
  nodes.push(node)
  return nodes
}

判断是否为element类型首先判断字符串是否为<开头,并且第二个字符为字母。

接下来使用parseElement进行解析:

const parseElement = function(context) {
  const element = parseTag(context, TagType.START)
  // 处理</div>,闭合标签
  parseTag(context, TagType.END)

  return element
}

parseTag函数取值并生成数据:

由于element类型的开始和闭合标签都是由一个函数进行解析的,所以分别对这两个标签进行解析,分别将<div></div>进行解析,只不过当解析闭合标签的时候只对字符串进行截取,而不返回数据。

增加开始和闭合标签的标记:

const TagType = {
  START: "start",
  END: "end"
}
const parseTag = function(context, type) {
  // 获取 <div 或 </div
  const match = /^</?([a-z]*)/i.exec(context.source)
  // 获取标签名 div
  const tag = match[1]
  // 截取 ></div>
  advanceBy(context, match[0].length)
  // 删除 >
  advanceBy(context, 1)
  // 如果为闭合标签,直接截取,不返回值
  if(type === TagType.END) return

  return {
    type: NodeType.ELEMENT,
    tag
  }
}

首先使用正则获取标签名,当作tag,然后对其进行截取,最后返回对象即可。

三. 实现解析 text 功能

还剩最后一种类型,就是文本类型:

hello gua

对于文本类型,其实无需处理其他内容,截取文本内容,然后删除此段字符串就可以了。

文本类型的目标结果对象是这样子的:

{
  children: [
    {
      type: NodeTypes.TEXT,
      content: "hello gua"
    }
  ]
}

一开始还是老规矩,先增加文本类型的枚举值:

const NodeType = {
  INTERPOLATION: "interpolation",
  SIMPLE_EXPRESSION: "simple_expression",
  ELEMENT: "element",
  TEXT: "text"
}

然后在parseChildren函数中增加对于文本类型的判断处理:

const parseChildren = function(context) {
  const nodes = []

  let node;
  let s = context.source
  if(s.startsWith("{{")) {
    node = parseInterpolation(context)
  } else if(s[0] === '<') {
    if(/[a-z]/i.test(s[1])) {
      node = parseElement(context)
    }
  }
	// 执行到这里,如果node还没有被赋值,则说明该字符串既不是element也不是插值类型
  if(!node) {
    node = parseText(context)
  }

  nodes.push(node)
  return nodes
}

在判断文本类型的字符串后,调用parseText函数对文本值进行收集解析

parseText函数中调用parseTextData进行取值,并进行截取。

const parseText = function(context) {
  const content = parseTextData(context, context.source.length)
	// 获取结果后,返回生成对象
  return {
    type: NodeType.TEXT,
    content,
  }
}

文本类型直接截取整个字符串就可以了,因为文本类型一整个字符串都是我们需要的值,其他的嵌套情况在处理联合类型时进行处理。

const parseTextData = function(context, length) {
  // 截取字符串
  const content = context.source.slice(0, length)

  // 推进,截取字符串
  advanceBy(context, length)
  return content
}

可以看到parseTextData函数的功能只要是取值和截取,这个函数可以应用到parseInterpolation函数中,可以帮助我们减少一些处理逻辑。

const parseInterpolation = function(context) {
  // 省略...
  
  const rawContentLength = closeIndex - closeDelimiter.length
  
  // 使用parseTextData函数取值并截取,下文中的advanceBy函数不需要加入rawContentLength的长度
  // 之前的处理方式只是进行了取值,并没有删除处理之后的字符串
  
  // const rawContent = context.source.slice(0, rawContentLength)
  const rawContent = parseTextData(context, rawContentLength)

  const content = rawContent.trim()
	// 只需要删除闭合标签的长度
  // advanceBy(context, rawContentLength + closeDelimiter.length)
  advanceBy(context, closeDelimiter.length)

  return {
    type: NodeType.INTERPOLATION,
    content: {
      type: NodeType.SIMPLE_EXPRESSION,
      content: content
    }
  }
}

四. 实现解析三种联合类型

在上面的内容中,已经实现了分别对elementtext以及插值类型的字符串处理,但是我们在日常的开发中肯定不会是只使用某一个单独的类型的,在正常的情况下,我们处理的都会是这种情况:

 <div>hi,{{ message }}</div>

处理之后的结果为:

 {
   children: [
     {
       type: NodeTypes.ELEMENT,
       tag: 'div',
       children: [
         {
           type: NodeTypes.TEXT,
           content: 'hi,'
         },
         {
           type: NodeTypes.INTERPOLATION,
           content: {
             type: NodeTypes.SIMPLE_EXPRESSION,
             content: 'message'
           }
         }
       ]
     }
   ]
 }

上面最后的结果就是我们将字符串转化为ast对象后的结果。

对于联合类型的解析,我们从入口函数开始进行修改处理逻辑,首先来处理一下baseParse函数:

 export const baseParse = function (content) {
   const context = createParserContext(content)
   // 增加第二个参数,用于记录当前处理element类型的标签
   return createRoot(parseChildren(context, [])) // 修改
 }

在调用parseChildren函数时,传入了第二个参数,一个数组,也可以理解为一个栈?

啥是一个栈?可以理解为一个后进先出的结构,之所以用在这里,主要是来记录父子标签之间的关系。下面是一个🌰:

加入我们有这样一个结构

 <div><p></p><div>

这个DOM结构有两层的element结构,那么此时我们用栈结构来保存的层级关系是这样的:

 p <- 第二次入栈添加
 div <- 首次入栈添加

用数组来模拟栈的话可以使用push方法进行入栈。

<div><p>入栈后,该段字符串就已经代表处理结束了,接下来是</p><div>,可以看到我们检测到了第一个闭合标签,判断当前需要处理的闭合字符串与栈中保存的最后一个元素进行对比,如果相同,则尾部出栈,删除闭合标签。一直处理的最后一个字符串与栈元素进行对比,如果同时处理完毕,则说明该字符串之间的标签关系是一一对应的,即合法的。

可以看到使用栈结构也可以很方便的判断一段element类型是否都能正确的闭合。

接下来修改parseChildren函数,parseChildren函数中需要对字符串进行循环处理,主要以闭合标签作为结束节点。

 const parseChildren = function (context, ancestors) {
   const nodes = []
   // 判断此类型是否已经处理完毕?
   while(!isEnd(context, ancestors)) {
     let node;
     let s = context.source
     if (s.startsWith("{{")) {
       node = parseInterpolation(context)
     } else if(s[0] === "<") {
       if(/[a-z]/i.test(s[1])) {
         node = parseElement(context, ancestors)
       }
     }
 
     if(!node) {
       node = parseText(context)
     }
 
     nodes.push(node)
   }
 
   return nodes
 }

isEnd函数用于使用闭合标签</来判断此element是否已经处理执行完毕。

 const isEnd = function(context, ancestors) {
   const s = context.source
   // 是否以</开头?结束标签?
   if(s.startsWith('</')) {
     // 取出ancestors栈的最后一个,记录当前element开始标签的栈
     const startTag = ancestors[ancestors.length - 1].tag
     // 判断是否相等
     if (startsWithEndTagOpen(s, startTag)) {
       return true;
     }
   }
 
   return !s
 }

startsWithEndTagOpen函数:

 const startsWithEndTagOpen = function(source, tag) {
   return source.startsWith('</') && source.slice(2, 2 + tag.length).toLowerCase() === tag.toLowerCase()
 }

因为在传入startsWithEndTagOpen函数时,两个参数并非是同一种字符串,比如如果要处理的是div,在传入函数时,

source = </div>
tag = div

tag参数是已经处理好的element标签的名称,而source只是一个包含尖括号的字符串。

parseElement函数中需要加入对于标签保存栈的处理:

 const parseElement = function(context, ancestors) {
   // 处理<div>
   const element: any = parseTag(context, TagType.START)
   // 开始标签入栈
   ancestors.push(element)
   // 开始标签后的字符串内容为该element类型中children的内容
   element.children = parseChildren(context, ancestors)
   // 当前element处理完毕后,出栈
   ancestors.pop()
   // 判断是否存在结束标签
   // context.source = </div>
   // element.tag = div
   if(startsWithEndTagOpen(context.source, element.tag)) {
     // 处理</div>
     parseTag(context, TagType.END)
   } else {
     throw new Error('缺少结束标签')
   }
   return element
 }

而另一个对于text的处理也需要修改,因为之前我们只是简单进行全部截取,但是如果在文本类型中存在插值或者element类型,就需要进行查找:

 const parseText = function(context) {
   // 寻找字符串内的< 或者 {{
   let endIndex = context.source.length
   const endTags = ['<', '{{']
   // 查找是否存在插值或element类型
   for(let i = 0; i < endTags.length; i++) {
     const index = context.source.indexOf(endTags[i])
     // 记录位置,这里取值的是最早出现的位置
     if(index !== -1 && index < endIndex) {
       endIndex = index
     }
   }
 
   // 获取content
   const content = parseTextData(context, endIndex)
 
   return {
     type: NodeTypes.TEXT,
     content
   }
 }

因为我们是对['<', '{{']进行遍历来查找的,所以这就需要我们能够找到这两个开始标签最早出现的位置,将字符串截取到此位置之前作为文本内容。

五. 实现 transform 功能

transform功能主要是用于方便在调用编译函数的时候,由使用者来自定义一些功能,也就是类似于插件的功能。

现在有这样一个例子,我们想要在text类型解析的时候对输出的字符串进行一些处理,如果解析到text类型,则将文本内容与k-vue进行拼接。

 // 解析字符串
 const ast = baseParse('<div>hi,{{ message }}</div>')
 // 自定义处理函数
 const plugin = node => {
   if(node.type === NodeTypes.TEXT) {
     node.content = node.content + 'k-vue'
   }
 }
 // 注册插件
 transform(ast, {
   nodeTransforms: [plugin]
 })
 ​
 const nodeText = ast.children[0].children[0]
 console.log(nodeText.content) // 'hi,k-vue'

在使用transform函数时,将自定义的插件函数进行注册,我们的处理函数接收ast对象进行处理。

因为我们可能需要自定义不止一个函数,那么用一个数组对所有的函数进行存储。

 export const transform = function(root, options) {
   // 生成转换上下文,用于保存所有相关的信息
   const context = createTransformContext(root, options)
   // 执行转换函数,传入ast对象以及上下文对象
   traverseNode(root, context)
 }

createTransformContext函数返回ast对象以及处理函数数组的包裹对象。

const createTransformContext = function(root, options) {
  const context = {
    root,
    nodeTransforms: options.nodeTransforms || []
  }

  return context
}

由于我们的自定义函数是由数组来进行包裹的,所以遍历nodeTransforms,执行所有的自定义函数,并将ast对象当作参数进行传入。

const traverseNode = function(node, context) {
  // 获取执行队列
  const nodeTransforms = context.nodeTransforms
  for(let i = 0; i < nodeTransforms.length; i++) {
    // 遍历执行
    const transform = nodeTransforms[i]
    transform(node)
  }
	// 处理子级(children)
  traverseChildren(node, context)
}

因为在ast对象的处理上有可能存在children,所有还需要对子级进行处理:

const traverseChildren = function(node, context) {
  // 获取children
  const children = node.children

  if(children) {
    for(let i = 0; i < children.length; i++) {
      // 取出children中的所有项,递归调用traverseNode
      const node = children[i]
      traverseNode(node, context)
    }
  }
}

对于子级的处理,只需要进行遍历,然后递归调用traverseNode函数进行处理即可。

六. 实现string 类型生成代码函数

当三种类型被全部解析完毕后,接下来就需要进行下一步,将ast对象解析构建为render函数。

为了更加直观的看到需要将各种类型的ast转化后的结果函数,可以参考

vue-next-template-explorer.netlify.app/

这个网站可以将输入的字符串转化为对应的render函数。

比如我们首先要转化的文本类型:

image-20221021145422782.png

可以看到我们输入hi,k-vue,被转化后的结果为:

return function render(_ctx, _cache, $props, $setup, $data, $options) {
  return "hi,k-vue"
}

而我们的目标就是实现由ast对象生成render函数字符串。

观察一下上面被生成后的函数字符串,我们需要动态添加的只有文本的内容部分,其他的函数名以及参数都是固定的,这里只添加_ctx, _cache两个参数。

对于文本内容的取值,我们可以在transform函数中进行保存:

export const transform = function(root, options = {}) {
  const context = createTransformContext(root, options)
  traverseNode(root, context)
	// 保存取出来的文本内容
  createRootCodegen(root) // 新增
}
// 保存值,保存内容到codegenNode属性中
const createRootCodegen = function(root) {
  root.codegenNode = root.children[0]
}

使用generate函数进行拼接:

const generate = function(ast) {
  // 生成处理上下文对象,包含拼接函数,拼接的结果
  const context = createCodegenContext()
  const { push } = context
  // 拼接return
  push('return ')

  const functionName = 'render'
  const args = ['_ctx', '_cache']
  const signature = args.join(',')
  // 拼接函数名及参数
  push(`function ${functionName}(${signature}){`)
  push('return ')
  // 通过genNode函数获取文本内容,并进行拼接
  genNode(ast.codegenNode, context)
  push('}')

  return {
    code: context.code
  }
}

整个处理流程非常简单,就是依次将return,函数名,参数,函数体进行拼接,最后返回整个拼接好的字符串函数返回。

createCodegenContext函数,用于创建处理的上下文,包含处理结果以及拼接函数push

const createCodegenContext = function() {
  const context = {
    // 保存拼接结果
    code: '',
    // 拼接函数,接受一个字符串
    push(source) {
      context.code += source
    }
  }

  return context
}

genNode函数拼接文本内容,

const genNode = function(node, context) {
  const { push } = context
  // 使用push函数对文本内容进行拼接
  push(`${node.content}`)
}

七. 实现插值类型生成代码函数

先来看一下插值类型生成后的函数是什么样子的:

const { toDisplayString: _toDisplayString } = Vue

return function render(_ctx, _cache) {
  return " hi! " + _toDisplayString(_ctx.name)
}

生成后的函数首先从vue中解构出来一个方法toDisplayString,然后在返回的的拼接字符串中对插值的属性进行处理。

所以主要处理的地方主要为toDisplayString函数的提取和使用。因为后续需要使用和提取的函数可能不止一个,所以需要将toDisplayString函数的定义放在transform中进行插入。

所以我们这样来进行调用:

// 解析插值字符串
const ast = baseParse("{{message}}");
// 注入处理逻辑
transform(ast, {
  nodeTransforms: [transformExpression],
});
// code即为最后处理完毕的字符串
const { code } = generate(ast);

我们的transformExpression函数主要的作用是自定义对于插值内容的处理,这里生成后的插值属性是需要被_ctx.name进行调用,所以此逻辑抽离出来进行自定义处理。

import { NodeTypes } from "../ast";

function transformExpression(node) {
  // 判断是否为插值类型
  if (node.type === NodeTypes.INTERPOLATION) {
    // 生成content字符串
    node.content = processExpression(node.content);
  }
}

function processExpression(node: any) {
  // 拼接_ctx.
  node.content = `_ctx.${node.content}`;
  return node;
}

加下来在transform函数中加入对插值类型的支持,加入toDisplayString的处理逻辑,这里为了统一处理引入函数,将从第三方引入的函数建立统一的映射。

const TO_DISPLAY_STRING = Symbol("toDisplayString");
// toDisplayString函数
const helperMapName = {
  [TO_DISPLAY_STRING]: "toDisplayString",
};

在创建transform对象上下文对象的时候,增加helpers字段,目的是保存所有的支持函数:

const createTransformContext = function(root, options) {
  const context = {
    root,
    nodeTransforms: options.nodeTransforms || [],
    helpers: new Map(), // 新增
    helper(key) { // 新增
      context.helpers.set(key) // 新增
    }
  }

  return context
}

context对象中增加了了helpers(保存支持函数),helper(添加函数的方法)。

transform函数中将helper集合绑定到ast对象上,便于取值。

const transform = function(root, options = {}) {
  const context = createTransformContext(root, options)
  traverseNode(root, context)

  createRootCodegen(root)
	// 保存到ast对象的helpers属性中
  root.helpers = [...context.helpers.keys()]; // 新增
}

那么支持函数是在哪里呗添加到helpers集合中的呢,可以观察一下,我们是调用了traverseNode函数对ast对象进行处理,然后在函数内部再次调用traverseChildren函数对其子级进行处理,处理完毕后再次调用traverseNode函数进行递归处理,所以需要在traverseNode函数进行添加所需支持函数的操作:

const traverseNode = function(node, context) {
  const nodeTransforms = context.nodeTransforms
  for(let i = 0; i < nodeTransforms.length; i++) {
    const transform = nodeTransforms[i]
    transform(node)
  }

  // traverseChildren(node, context)
  switch(node.type) { // 新增
      // 如果没有当前处理的对象为INTERPOLATION类型,那么添加TO_DISPLAY_STRING
    case NodeTypes.INTERPOLATION: // 新增
      context.helper(TO_DISPLAY_STRING) // 新增
      break
      // 初始化的时候添加ROOT类型,标记根对象
    case NodeTypes.ROOT: // 新增
    case NodeTypes.TEXT: // 新增
      traverseChildren(node, context) // 新增
      break
    default: 
      break
  }
}

在初始化ast对象时,标记为ROOT类型:

export const enum NodeTypes {
  INTERPOLATION,
  SIMPLE_EXPRESSION,
  ELEMENT,
  TEXT,
  ROOT // 新增
}

const createRoot = function (children) {
  return {
    children,
    type: NodeTypes.ROOT // 新增
  }
}

然后到了处理解析ast对象的函数generate,在执行拼接函数的逻辑之前要先生成引入函数:

export const generate = function(ast) {
  const context = createCodegenContext()
  const { push } = context
  // push('return ')
  // 首先生成引入函数
  genFunctionPreamble(ast, context) // 新增

 	// 省略...
}

因为我们在执行transform函数时已经将所有的所需要的引入函数保存到helpers集合中,所以直接在ast对象中进行获取。

function genFunctionPreamble(ast, context) {
  // 解构push函数
  const { push } = context;
  const VueBinging = "Vue";
  // 对所有引入函数进行格式化,添加下划线
  // toDisplayString: _toDisplayString
  const aliasHelper = (s) => `${helperMapName[s]}:_${helperMapName[s]}`;
  if (ast.helpers.length > 0) {
    // 拼接引入函数
    push(
      `const { ${ast.helpers.map(aliasHelper).join(", ")} } = ${VueBinging}`
    );
  }
  push("\n");
  push("return ");
}

genFunctionPreamble函数主要是处理引入的第三方函数,在ast对象的helpers属性中获取所有的函数名,然后对其进行格式化,最后拼接。

在处理TEXT类型的ast对象时,我们是直接将所有的字符串直接与整个字符串拼接在了一起,看了看到两者不同的区别:

return "hi, k-vue"

return _toDisplayString(_ctx.name)

所以在genNode函数的处理中,不能直接进行返回,还需要拼接引入函数的调用。

修改上下文对象,添加helper方法,返回拼接下划线的调用方法名:

const createCodegenContext = function() {
  const context = {
    code: '',
    push(source) {
      context.code += source
    },
    helper(key) { // 新增
      // 返回格式化方法名
      return `_${helperMapName[key]}` // 新增
    }
  }

  return context
}
const genNode = function(node, context) {
 switch(node.type) {
     // 如果type类型为文本,则调用genText进行处理
    case NodeTypes.TEXT:
      genText(node, context)
      break
     // 如果type类型为插值,则调用genInterpolation进行处理
    case NodeTypes.INTERPOLATION:
      genInterpolation(node, context)
      break
    case NodeTypes.SIMPLE_EXPRESSION:
      genExpression(node, context)
      break
    default:
      break
 }
}

处理文本类型,处理方法与之前一致:

const genText = function(node, context) {
  const { push } = context
  // 直接拼接内容
  push(`${node.content}`)
}

处理插值类型:

const genInterpolation = function(node, context) {
  const { push, helper } = context
	// 将TO_DISPLAY_STRING方法名进行格式化
  push(`${helper(TO_DISPLAY_STRING)}(`)
  // 递归调用genNode
  genNode(node.content, context)
  push(')')
}

处理插值类型,只是将引入的方法名进行调用,传入插值的内容属性。

genExpression函数,处理逻辑与文本类型一致。

const genExpression = function(node, context) {
  const { push } = context
  push(`${node.content}`)
}

八. 实现生成三种联合类型的代码函数

以下面的字符串为例:

<div >hi! {{ name }} bye</div>

还是先来看一下最后的目标字符串:

截屏2022-10-22 17.41.33.png

const { toDisplayString: _toDisplayString, createElementVNode: _createElementVNode } = Vue

return function render(_ctx, _cache, $props, $setup, $data, $options) {
  return (_createElementVNode("div", null, "hi! " + _toDisplayString(_ctx.name) + " bye", 1))
}

我们所要实现的目标字符串和网站上生成的有一点点区别,我们使用createElementVNode函数来处理参数,因为这是我们在之前实现过的函数,可以对比一下最后return返回的结果与前面实现的插值结果的对比:

// 插值
return " hi! " + _toDisplayString(_ctx.name)
// element
return (_createElementVNode("div", null, "hi! " + _toDisplayString(_ctx.name) + " bye", 1))

可以看到在处理element类型的ast对象时,需要使用_createElementVNode函数进行处理,并传入参数,其中第一个参数为标签名,第二个参数为属性,如果没有的话,默认值为null,后面是一个由+号来拼接的字符串,将插值类型与文本类型拼接在了一起。

在看一下调用的过程:

 const ast: any = baseParse("<div>hi,{{message}}</div>");
transform(ast, {
  nodeTransforms: [transformExpression,transformElement, transformText],
});

const { code } = generate(ast);

这一次的插件函数一共有三个,其中transformExpression函数的作用还用用于生成_ctx的调用函数,transformElement函数是用来生成element类型的codegenNode

import { createVNodeCall, NodeTypes } from "../ast";

export function transformElement(node, context) {
  // 如果当前ast对象的type为element
  if (node.type === NodeTypes.ELEMENT) {
    return () => {
      // tag
      const vnodeTag = `'${node.tag}'`;

      // props
      let vnodeProps;

      // children
      const children = node.children;
      let vnodeChildren = children[0];
			// 生成对象
      node.codegenNode = createVNodeCall(
        context,
        vnodeTag,
        vnodeProps,
        vnodeChildren
      );
    };
  }
}

// createVNodeCall函数中定义element类型所需要的引入函数
export const createVNodeCall = function (context, tag, props, children) {
  // 添加函数到helpers中
  context.helper(CREATE_ELEMENT_VNODE)

  return {
    type: NodeTypes.ELEMENT,
    tag,
    props,
    children
  }
}

// 定义createElementVNode函数映射
export const TO_DISPLAY_STRING = Symbol('toDisplayString')
export const CREATE_ELEMENT_VNODE = Symbol('createElementVNode') // 新增

export const helperMapName = {
  [TO_DISPLAY_STRING]: 'toDisplayString',
  [CREATE_ELEMENT_VNODE]: 'createElementVNode' // 新增
}

transformText函数主要的功能是将文本类型与插值类型进行拼接,形成一种新类型COMPOUND_EXPRESSION

??COMPOUND_EXPRESSION是个啥?其实我们可以看一下

// 处理插值类型,后面有字符串
hi! {{ name }} bye
"hi! " + _toDisplayString(_ctx.name) + " bye"

// 处理插值类型,后面没有字符串
hi! {{ name }}
" hi! " + _toDisplayString(_ctx.name)

解析文本节点和插值类型时,会将他们使用+号拼接在一起,而这一点被+号拼接起来的字符串,就是我们的心类型COMPOUND_EXPRESSION

首先对其进行定义:

export const enum NodeTypes {
  INTERPOLATION,
  SIMPLE_EXPRESSION,
  ELEMENT,
  TEXT,
  ROOT,
  COMPOUND_EXPRESSION // 新增
}

transformText函数实现:

import { NodeTypes } from "../ast";
import { isText } from "../utils";

export function transformText(node) {
	// 判断是否为element类型
  if (node.type === NodeTypes.ELEMENT) {
    // 返回一个匿名函数
    return () => {
      // 获取children
      const { children } = node;

      let currentContainer;
      // 遍历children
      for (let i = 0; i < children.length; i++) {
        const child = children[i];

        if (isText(child)) {
          // 拼接的策略是首先取出第一个字符,然后开始从第二个字符开始向后遍历
          for (let j = i + 1; j < children.length; j++) {
            const next = children[j];
            // 如果后面的也是文本类型或者插值类型
            if (isText(next)) {
              // 如果是第一次,创建COMPOUND_EXPRESSION对象
              if (!currentContainer) {
                currentContainer = children[i] = {
                  type: NodeTypes.COMPOUND_EXPRESSION,
                  children: [child],
                };
              }
							// 使用加号进行拼接
              currentContainer.children.push(" + ");
              currentContainer.children.push(next);
              // 拼接完成后删掉已处理字符
              children.splice(j, 1);
              j--;
            } else {
              // 如果当前不是文本/插值,将currentContainer清空
              currentContainer = undefined;
              // 退出循环
              break;
            }
          }
        }
      }
    };
  }
}

// isText函数主要用于判断是否为文本或者插值类型
const isText = function (node) {
  return (
    node.type === NodeTypes.TEXT || node.type === NodeTypes.INTERPOLATION
  )
}

transform函数中主要处理一下插件函数的调用顺序:

function traverseNode(node: any, context) {
  const nodeTransforms = context.nodeTransforms;
  const exitFns: any = []; // 定义结果数组
  for (let i = 0; i < nodeTransforms.length; i++) {
    const transform = nodeTransforms[i];
    const onExit = transform(node, context); // 新增
    if (onExit) exitFns.push(onExit); // 新增
  }

  // 省略...

  let i = exitFns.length;  // 新增
  while (i--) {  // 新增
    exitFns[i]();  // 新增
  }
}

因为在transform函数对于element类型,还需要进行递归处理,所以在调用插件函数时只是返回了一个匿名函数,在递归完成后再执行真实的处理逻辑。

在处理codegenNode的取值时,element类型直接获取root中的codegenNode属性。

function createRootCodegen(root: any) {
  const child = root.children[0];
  // 判断是否为element类型
  if (child.type === NodeTypes.ELEMENT) {
    root.codegenNode = child.codegenNode;
  } else {
    root.codegenNode = root.children[0];
  }
}

然后在genNode函数中加入对于COMPOUND_EXPRESSIONELEMENT的处理逻辑:

const genNode = function (node, context) {
  switch (node.type) {
    // 省略...
    case NodeTypes.ELEMENT: // 新增
      genElement(node, context) // 新增
      break
    case NodeTypes.COMPOUND_EXPRESSION: // 新增
      genCompoundExpression(node, context) // 新增
      break
    default:
      break
  }
}

处理COMPOUND_EXPRESSION时,因为我们在transformText函数中将所有需要处理的字符串已经变为:

// type为枚举值产生的索引,可以理解为上面的ELEMENT和COMPOUND_EXPRESSION类型
[
  { type: 3, content: 'hi,' },
  ' + ',
  { type: 0, content: { type: 1, content: '_ctx.message' } }
]

所以现在只需要遍历拼接字符串即可:

// 新增
const genCompoundExpression = function (node, context) {

  const { push } = context
  const children = node.children
  // 遍历children
  for (let i = 0; i < children.length; i++) {
    const child = children[i]
    // 是否为字符串?
    if (isString(child)) {
      push(child)
    } else {
      genNode(child, context)
    }
  }
}
// 判断是否为字符串
export const isString = (value) => typeof value === "string"

而处理element类型,则需要考虑对于props或者children属性进行默认值null的处理。

// 新增
const genElement = function (node, context) {
  // 解构push和helper
  const { push, helper } = context
  const { tag, children, props } = node
  // 拼接CREATE_ELEMENT_VNODE函数
  push(`${helper(CREATE_ELEMENT_VNODE)}(`)
  genNodeList(genNullable([tag, props, children]), context)
  // 拼接结束括号
  push(')')
}


// 新增
const genNodeList = function (nodes, context) {
  const { push } = context
	// 遍历各个属性
  for (let i = 0; i < nodes.length; i++) {
    const node = nodes[i]
		// 如果为字符串,直接拼接
    if (isString(node)) {
      push(node)
    } else {
      genNode(node, context)
    }
		// 中间插入,号
    if (i < nodes.length - 1) {
      push(', ')
    }
  }
}

// 新增
const genNullable = function (args) {
  // 默认值赋值为null, 返回数组
  return args.map(arg => arg || 'null')
}

九. 实现编译 template 成 render 函数

关于最后将template模版编译并生成到页面内容:

export const App = {
  name: "App",
  template: `<div>hi,{{count}}</div>`,
  setup() {
    const count = (window.count = ref(1));
    return {
      count,
    };
  },
};

其实原理是将template中的模版字符串取出来进行编译,编译后的结果返回,赋值给组件的render函数。

 function compileToFunction(template) {
   const { code } = baseCompile(template);
   const render = new Function("Vue", code)(runtimeDom);
   return render;
 }
 
 let compiler = compileToFunction;

 
 function finishComponentSetup(instance: any) {
   const Component = instance.type
 
   // 省略 ...
   if (compiler && !Component.render) {
     if (Component.template) {
       Component.render = compiler(Component.template);
     }
   }
 }

详细代码可参见:

github.com/konvyi/k-vu…

至此,vue3源码系列的文章就全部完结了,希望有了这些文章,看源码会更加的顺利!🥳🥳

写在最后 ⛳

未来可能会更新typescriptjavascript基础知识系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳