鸽了六年的某大厂面试题:手写 Vue 模板编译(解析篇)

前言

为什么要写这篇文章,因为六年前信心满满的去面试某条(六年前我们还不叫它x跳动),结果被血虐,所以一直想着要写一篇面经。但是模板编译这个部分有点复杂,就一直拖拖拖,拖到我的标题从"鸽了一年"一直改成了"鸽了六年"。

不过我还是希望能写一篇合格的文章,让大家都能阅读后完整清晰的了解 Vue 模板编译究竟是如何工作的。

本文不涉及当时的面试过程,完整的面试过程我写在了这篇文章,有兴趣可以看下。

注:本文代码实现基于 Vue2

因为本文比较长,所以拆分成了两个部分:本文主要关注解析部分,生成可以看下篇:《鸽了六年的某大厂面试题:手写 Vue 模板编译(生成篇)》

理解模板编译在做什么

在实现模板编译之前,需要先知道模板编译是在做什么。假设我们有 Vue 组件如下:

html 复制代码
<template>
  <div id="app">
    <h1>{{ message }}</h1>
    <button @click="handleClick">Click me</button>
  </div>
</template>

<script>
export default {
  name: 'MyComponent',
  data() {
    return {
      message: 'Hello, Vue!',
    };
  },
  methods: {
    handleClick() {
      alert('Button clicked!');
    },
  },
};
</script>

很简单的一个组件,展示一条文案"Hello, Vue!" 和一个按钮,点击弹出"Button clicked!"。

而我们知道 Vue 也是支持通过 render 函数来编写组件的,上面的组件也可以用 render 来实现:

html 复制代码
<script>
export default {
  name: 'MyComponent',
  
  methods: {
    handleClick() {
      alert('Button clicked!');
    }
  },
  
  render(h) {
    return h('div', { id: 'app' }, [
      h('h1', 'Hello Vue!'),
      h('button', { on: { click: this.handleClick } }, 'Click me')
    ]);
  }
};
</script>

不过我们很少通过 render 函数来编写 Vue 组件呢,因为它的写法很繁琐,而模板写法类似于 HTML,更直观,更简洁。

但实际上,我们编写的模板最终会由 Vue 编写成 render 函数。可以理解为:在 Vue 中模板就是 render 函数的一种"语法糖"

我们可以看看 Vue 把我们的模板编译成了什么 在线查看

所以,我们手写 Vue 模板编译,要做的就是写一个函数,把模块字符串编译成 render 函数。

其实,Vue 已经给我们提供了这个能力 Vue.compile()

js 复制代码
var res = Vue.compile('<div><span>{{ msg }}</span></div>')  
  
new Vue({  
  data: {  
    msg: 'hello'  
  },  
  render: res.render,  
  staticRenderFns: res.staticRenderFns  
})

可以看到,通过 Vue.compile 会生成一个 renderstaticRenderFns,其中 render 就是我们上面说的 render 函数,而 staticRenderFns 是一些静态节点的渲染函数,后文会详细讲解。

接下来,我们就来手写这个 compile 函数。

模板编译流程概览

在开始实现之前,我们先大概了解下模板编译的流程,模板编译的过程分为三步:解析、优化、生成。

下面我们来分别说下这三个阶段都会做些什么。

1. 解析 parse

arduino 复制代码
const ast = parse(template.trim(), options)

AST(抽象语法树,Abstract Syntax Tree) 是一种用于描述源代码结构的树状表示。它不仅表示了代码的语法结构,还保留了代码中的所有信息。

在这一步,Vue 会解析模板字符串,并生成对应的 AST。比如我们下面示例中的模板大概会被编译成如下 AST:

js 复制代码
// 模板
<div>
  <h1>{{ message }}</h1>
  <h2>Hello Vue!</h2>
</div>
// ast
{
  "tag": "div",
  "children": [
    {
      "tag": "h1",
      "children": [
        {
          "expression": "message",
          "text": "{{ message }}"
        }
      ]
    },
    {
      "tag": "h2",
      "children": [
        {
          "text": "Hello Vue!"
        }
      ]
    }
  ]
}

当然这只是简化版,更详细的我们在后文说明。

2. 优化 optimize

js 复制代码
optimize(ast, options)

这个阶段主要是通过标记静态节点来进行语法树优化。

比如节点 <h1>{{ message }}</h1>,那它每一次渲染的内容都是变化的,依赖于 message 的具体值,而节点 <h2>Hello Vue!</h2> 便是一个静态节点,它渲染的结果永远不会变,所以在更新时可以跳过静态节点来提升性能。

在进行优化后,Vue 会在 AST 中标记某个节点是否为静态节点。

js 复制代码
// 动态节点
{
  "tag": "p",
  "static": false,
  "children": [
    {
      "expression": "message",
      "text": "{{ message }}"
    }
  ]
}
// 静态节点
{
  "tag": "h2",
  "static": true,
  "children": [
    {
      "text": "Hello Vue!"
    }
  ]
}

3. 生成 generate

js 复制代码
const render = generate(ast, options)

最后一步利用前面生成 AST(抽象语法树)转换成渲染函数(render function)。

render 函数也就是我们模板编译最终结果,它能够在运行时生成虚拟 DOM(Virtual DOM)节点,从而最终用于真实的 DOM 操作。

这就是我们模板编译的全部过程了,接下来我将详细讲 Vue 解模板编译,并带大家去实现这个过程。

parse:解析模板为 AST

首先我们来实现 parse 的过程。这个过程是最复杂的,主要是涉及正则匹配和栈相关知识点,我会一点一点的来实现。学不会打我!

在前文我们说了,AST 是抽象语法树,是一个表示代码结构的树状数据结构。事实上,AST 的结构和 DOM 结构是有些类似的,都是树形结构,有相似的父子节点关系,不过 AST 中还会存储节点所用的变量、表达式、指令等。

在处理模板的过程中,我们需要对标签进行匹配,比如 <h1></h1> 匹配作为一个 h1 节点,再把开闭标签中间的全部节点作为子节点放入对应的父节点中。

比如 <h1><h2></h2><p></p></h1> 要转成语法树:

css 复制代码
    h1
    /\
  h2  p

简化问题:括号匹配

为了简化问题,现在尝试下解决下面这个问题,把嵌套的括号对转成一棵树,比如 {[]()} 我们可以把它转成括号树:

scss 复制代码
    {}
    /\
  []  ()

用 AST 表示

json 复制代码
{
  "tag": "{}",
  "children": [
    {
      "tag": "[]",
      "children": []
    },
    {
      "tag": "()",
      "children": []
    }
  ]
}

我们的括号字符串中的字符分为两类,开始字符也就是左括号 [ ( { 和结束字符也就是右括号 ] ) }

我们按顺序从左至右遍历每个字符,然后解析它是开始还是结束字符,根据起止字符的顺序去生成一棵树。逻辑其实很简单:我们在遍历一个开始字符后,接下来遍历的所有字符都应该是它的孩子,直到遇到它对应的结束字符。

解析这段字符串需要用到数据结构:栈。栈遵循后进先出的原则,可以很方便的处理括号的嵌套关系。

我们使用栈来保存出现的字符。在每次遇到一个起始字符时,我们将其放入栈中,并将当前父节点设置为该字符,因为之后出现的节点会以该字符作为父节点,直到遇到相应的结束字符。此时,该节点及其所有子节点都已处理完毕,我们将该字符从栈中弹出,并将当前父节点重置为栈中的前一个节点。

为了让大家逻辑更清晰,我来模拟一下这个过程

js 复制代码
初始化:字符串为 '{[]()}' 栈为空<> 父节点为null
1. 首先遇到字符 '{' 为开始节点
    1.当前没有父节点,所以 '{' 为根节点
    2.我们把当前父节点置为 '{' 当前栈为 <{>
    3.当前树为 {
    4.把字符串前进一位,当前字符串为 '[]{}}'
2. 接下来遇到字符 '[' 为开始节点
    1.当前父节点为 '{',所以 '[' 的父节点为 '{'
    2.我们把当前父节点置为 '[' 当前栈为 <{,[>
    3.当前树为 {
             /
            [
    4.把字符串前进一位,当前字符串为 ']()}'
3. 接下来遇到字符 ']' 为结束节点
    1.在栈顶找到 '[',括号匹配,弹出 '[',当前栈为: <{>
    2.当前树为 {
             /
            []
    3.找到栈中前一个节点 '{' 作为当前父节点
    4.把字符串前进一位,当前字符串为 '()}'
4. 接下来遇到字符 '(' 为开始节点
    1.当前父节点为 '{',所以 '(' 的父节点为 '{'
    2.我们把当前父节点置为 '(' 当前栈为 <{,(>
    3.当前树为 {
             / \
            [] (
    4.把字符串前进一位,当前字符串为 ')}'
5. 接下来遇到字符 ')' 为结束节点
    1.在栈顶找到 '(' 并弹出,当前栈为: <{>
    2.当前树为 {
             / \
            [] ()
    3.找到栈中前一个节点 { 作为当前父节点
    4.把字符串前进一位,当前字符串为 '}' 
6. 接下来遇到字符 '}' 为结束节点
    1.在栈顶找到 '{' 并弹出,当前栈为: <>
    2.在栈中找不到前一个节点,父节点为 null
    3.把字符串前进一位,字符串处理完成

接下来,我们来用代码实现上述流程,我先实现了一个解析器,非常非常简单,解析器接受 start 函数和 end 函数,遇到开始标签调用 start 函数遇到结束标签调用 end 函数,为了清晰的看到解析的过程,我加了很多 log。

js 复制代码
/**
 * 解析给定的字符串,构建一个表示括号嵌套结构的树
 * 此函数通过遍历字符串并调用回调函数来处理开始和结束标签,
 * 从而允许外部逻辑(如构建 AST)与解析过程解耦
 * @param {string} str - 待解析的包含括号的字符串
 * @param {object} options - 解析选项对象
 * @param {function} [options.start] - 处理开始标签的回调函数,接收标签字符作为参数
 * @param {function} [options.end] - 处理结束标签的回调函数,接收标签字符作为参数
 */
function parse(str, options = {}) {
  console.log(`[解析开始] 输入字符串: "${str}"`)
  // 当前解析在字符串中的位置索引
  let index = 0

  // 持续处理字符串,直到其内容为空
  while (str) {
    // 获取字符串的第一个字符进行处理
    let cur = str[0]
    console.log(
      `[解析循环] 当前字符: "${cur}", 剩余字符串: "${str}", 索引: ${index}`
    )

    // --- 标签判断与处理 ---
    // 检查当前字符是否为结束标签
    if (cur === ']' || cur === ')' || cur === '}') {
      handleEndTag(cur)
      // 检查当前字符是否为开始标签
    } else if (cur === '[' || cur === '(' || cur === '{') {
      handleStartTag(cur)
    }
    /*
     * 注意:这是一个简化的解析器,它假定输入字符串仅包含有效的、
     * 配对的括号字符它不处理非括号字符、不匹配的括号或其它错误情况
     */
  }

  /**
   * 将解析指针向前移动 n 个字符
   * 同时,通过截取字符串的方式移除已处理的部分
   * @param {number} n - 需要前进的字符数量
   */
  function advance(n) {
    console.log(`[前进] 从索引 ${index} 前进 ${n} 个字符`)
    // 更新当前位置索引
    index += n
    // 移除字符串头部已处理的 n 个字符
    str = str.substring(n)
    console.log(`[前进] 完成. 剩余字符串: "${str}", 新索引: ${index}`)
  }

  /**
   * 处理当前字符为结束标签的情况
   * 调用配置中提供的 end 回调函数(如果存在),并前进一个字符
   */
  function handleEndTag(tagName) {
    // 获取当前的结束标签字符
    const endTag = str[0]
    console.log(`[处理结束标签] 在索引 ${index} 找到结束标签: "${endTag}"`)
    // 如果 options 对象中定义了 end 回调函数,则调用它
    // 这个回调通常用于处理节点闭合的逻辑,例如从栈中弹出节点
    if (options.end) {
      // 因为括号的长度固定为1 所以这里标签结束下标设置为index+1
      options.end(endTag, index, index + 1)
    }
    // 处理完结束标签后,将解析位置向前移动一位
    advance(1)
  }

  /**
   * 处理当前字符为开始标签的情况
   * 调用配置中提供的 start 回调函数(如果存在),并前进一个字符
   */
  function handleStartTag(tagName) {
    // 获取当前的开始标签字符
    const startTag = str[0]
    console.log(`[处理开始标签] 在索引 ${index} 找到开始标签: "${startTag}"`)
    // 如果 options 对象中定义了 start 回调函数,则调用它
    // 这个回调通常用于处理新节点开始的逻辑,例如创建节点并压入栈
    if (options.start) {
      // 因为括号的长度固定为1 所以这里标签结束下标设置为index+1
      options.start(tagName, index, index + 1)
    }
    // 消耗掉当前这个开始标签字符
    advance(1)
  }
  console.log(`[解析结束] 解析完成.`)
}

parse('{[]()}')

接下来,我们需要构建一棵树,按照上面说的流程来实现,由于解析的工作已经在 parse 实现了,这里只需要关注如何构建语法树。

js 复制代码
// 待解析的字符串,包含嵌套的括号
const str = '{[]()}'

// 初始化一个空栈,用于存储正在处理的节点(即尚未闭合的标签对应的节点)
// 栈顶元素始终是当前正在处理的节点的父节点
const stack = []
// 定义整个解析树根节点
let root
// 定义当前父节点,用于在解析过程中建立节点间的父子关系
let currentParent

/**
 * 将一个已完成解析的元素(节点)添加到其父节点的子节点列表中
 * @param {object} element - 当前要闭合(即处理完毕)的元素节点
 */
function closeElement(element) {
  // 确保当前存在父节点(即栈不为空)
  if (currentParent) {
    // 将当前元素添加到当前父节点的 children 数组中
    currentParent.children.push(element)
    // 设置当前元素的 parent 属性,指向其父节点
    element.parent = currentParent
  }
}

/**
 * 调用 parse 函数开始解析字符串 str
 * 并传入包含 start 和 end 回调函数的 options 对象
 */
parse(str, {
  /**
   * 处理开始标签的回调函数
   * @param {string} tag - 遇到的开始标签字符,例如 '{', '[', '('
   */
  start(tag, start, end) {
    // 当遇到一个开始标签时,创建一个新的元素(节点)对象
    let element = {
      tag: tag, // 节点的标签名(即括号字符)
      children: [] // 用于存储子节点的数组
      // parent 属性将在 closeElement 中设置
    }
    // 检查是否尚未设置根节点
    if (!root) {
      // 如果还没有根节点,将当前创建的第一个元素设置为根节点
      root = element
    }
    // 将当前节点设置为新的"当前父节点",后续遇到的节点将成为它的子节点
    currentParent = element
    // 将新创建的节点压入栈中,表示开始处理这个新节点
    stack.push(element)
  },
  /**
   * 处理结束标签的回调函数
   * @param {string} tag - 遇到的结束标签字符,例如 '}', ']', ')'
   */
  end(tag, start, end) {
    // 当遇到一个结束标签时,意味着栈顶的元素(节点)已经处理完毕
    // 获取栈顶元素,即与当前结束标签对应的开始标签所创建的节点
    const element = stack[stack.length - 1]
    // 将栈顶元素弹出,表示该节点处理完成
    stack.length -= 1 // 等同于 stack.pop()
    // 更新"当前父节点"为弹出后的新的栈顶元素
    // 如果栈为空,currentParent 将变为 undefined
    currentParent = stack[stack.length - 1]
    // 调用 closeElement,将刚刚处理完成的节点 element 添加到其父节点(新的 currentParent)的 children 列表中
    closeElement(element)
  }
})

// 打印构建好的语法树
console.dir(root, {
  depth: null
})

把 root 打印出来,可以看到我们已经可以正确解析括号树了。

js 复制代码
// 在 node 中输出
<ref *1> {
  tag: '{',
  children: [
    { tag: '[', children: [], parent: [Circular *1] },
    { tag: '(', children: [], parent: [Circular *1] }
  ]
}

在我们的代码中,我们在 parse 函数中遍历字符串,并识别开始和结束字符,然后使用 options 传入的对应函数 options.startoptions.end 分别进行处理。

利用正则处理 html 标签

在理解了括号匹配的逻辑后,我们可以尝试去处理 HTML 模板字符串,这一步我们需要用到 JavaScript 的正则表达式。

不熟悉正则的小伙伴可以先复习下正则相关知识,安利文章:JavaScript 正则表达式全面总结

比如我们有字符串:<div><p></p><h1></h1></div>

我们需要将它处理为 DOM 树:

css 复制代码
    div
    /  \
   p   h1

和括号匹配相比,HTML 模板处理的难点是识别起止标签,我们不再可以像前面那样通过简单的对比字符来判断。我们可以观察HTML标签的特点:

  • 开始标签格式:<tagname>
  • 结束标签格式:</tagname>

我们可以使用正则表达式来精确匹配这些标签:

  1. 标签名的正则表达式: /[a-zA-Z_][\-\.0-9_a-zA-Z]*/

    • 以字母或下划线开头
    • 后跟任意数量的字母、数字、下划线、中划线或点
  2. 开始标签的正则表达式:

    js 复制代码
    const ncname = "[a-zA-Z_][\\-\\.0-9_a-zA-Z]*" // 标签名
    const startTag = new RegExp(`^<(${ncname})>`) // 匹配开始标签,如 <div> 用于捕获<和>中间的标签名
    • < 开始,以 > 结束
    • 使用括号分组捕获标签名,便于后续提取
  3. 结束标签的正则表达式:

    js 复制代码
    const ncname = "[a-zA-Z_][\\-\\.0-9_a-zA-Z]*" // 标签名
    const endTag = new RegExp(`^<\\/(${ncname})>`) // 匹配结束标签,如 </div> 用于捕获<\和>中间的标签名
    • </ 开始,以 > 结束
    • 同样使用括号分组捕获标签名

现在我们可以用处理括号匹配的逻辑来处理 HTML 字符串了。

js 复制代码
// 定义用于匹配标签名的正则表达式:字母或下划线开头,后面可以是连字符、点、数字、字母或下划线
const ncname = '[a-zA-Z_][\\-\\.0-9_a-zA-Z]*'
// 匹配开始标签,如 <div> 可以捕获<和>中间的标签名
const startTag = new RegExp(`^<(${ncname})>`)
// 匹配结束标签,如 </div> 可以捕获<\和>中间的标签名
const endTag = new RegExp(`^<\\/(${ncname})>`)

// 要解析的HTML模板字符串
const str = '<div><p></p><h1></h1></div>'

// 用于处理节点树的栈
const stack = []
// 存储解析后的根节点
let root
// 当前正在处理的父节点
let currentParent

/**
 * HTML模板解析器主函数
 * @param {string} html - 需要解析的HTML字符串
 * @param {object} options - 配置选项,包含标签处理的回调函数
 * @param {Function} options.start - 处理开始标签的回调
 * @param {Function} options.end - 处理结束标签的回调
 */
function parse(html, options) {
  let index = 0 // 当前解析位置

  while (html) {
    // 尝试匹配结束标签
    const endTagMatch = html.match(endTag)
    // 如果当前字符串能匹配结束标签
    if (endTagMatch) {
      const curIndex = index
      // 前进结束标签对应的字符数
      // endTagMatch[0]是匹配的整个标签,如 </div>
      advance(endTagMatch[0].length)
      // 处理结束标签,传入标签名和位置信息
      // endTagMatch[1]是捕获的标签名,如 div
      parseEndTag(endTagMatch[1], curIndex, index)
      continue
    }

    // 尝试解析开始标签
    const startTagMatch = parseStartTag()
    if (startTagMatch) {
      handleStartTag(startTagMatch)
    }

  }

  /**
   * 向前移动解析位置
   * @param {number} n - 需要前进的字符数
   */
  function advance(n) {
    index += n
    html = html.substring(n)
  }

  /**
   * 解析开始标签
   * @returns {Object|null} 返回标签匹配信息,包含标签名和位置信息;如果不是开始标签则返回null
   */
  function parseStartTag() {
    // 匹配测试是否匹配开始标签
    const start = html.match(startTag)
    // start表示匹配开始标签正则的结果,start 为空表示没有匹配到开始标签
    // 其中 start[0] 是整个开始标签 start[1] 是标签名
    if (start) {
      const match = {
        tagName: start[1],
        start: index,
        end: index + start[0].length
      }
      // 前进开始标签对应的字符数
      advance(start[0].length)
      return match
    }
  }

  /**
   * 处理开始标签 调用外部提供的开始标签处理函数(如果存在)
   * @param {Object} match - 标签匹配信息
   * @param {string} match.tagName - 标签名
   */
  function handleStartTag(match) {
    const tagName = match.tagName
    if (options.start) {
      options.start(tagName)
    }
  }

  /**
   * 解析结束标签 调用外部提供的结束标签处理函数(如果存在)
   * @param {string} tagName - 标签名
   * @param {number} start - 标签开始位置
   * @param {number} end - 标签结束位置
   */
  function parseEndTag(tagName, start, end) {
    if (options.end) {
      options.end(tagName, start, end)
    }
  }
  
  return root
}

/**
 * 关闭元素,将其添加到父节点的子节点列表中
 * @param {Object} element - 当前处理的节点
 * @param {string} element.tag - 标签名
 * @param {Array} element.children - 子节点数组
 */
function closeElement(element) {
  if (currentParent) {
    // 把当前节点添加到父节点的子节点中
    currentParent.children.push(element)
    // 设置当前节点的父节点
    element.parent = currentParent
  }
}

/**
 * 解析字符串,并传入开始和结束标签处理函数
 */
const ast = parse(str, {
  // 处理开始标签
  start(tag) {
    // 创建新的节点对象
    let element = {
      type: 1, // type=1表示是元素节点,其他类型后面再说
      tag,
      children: []
    }
    
    // 设置根节点(如果还没有根节点)
    if (!root) {
      root = element
    }
    // 把当前节点作为父节点,后面遇到的节点都是其子节点
    currentParent = element
    // 把节点压入栈
    stack.push(element)
  },
  
  // 处理结束标签
  end() {
    // 获取对应的开始标签节点(栈顶元素)
    const element = stack[stack.length - 1]
    // 弹出栈顶元素
    stack.length -= 1
    // 更新当前父节点为新的栈顶元素
    currentParent = stack[stack.length - 1]
    // 建立节点间的父子关系
    closeElement(element)
  }
})

console.dir(ast, {
  depth: null
})

基本逻辑都没有变,只有在判断开始和结束标签的位置进行了修改,每次处理标签后前进的字符数由标签的长度决定。现在可以得到简单的 AST:

js 复制代码
<ref *1> {
  type: 1,
  tag: 'div',
  children: [
    { type: 1, tag: 'p', children: [], parent: [Circular *1] },
    { type: 1, tag: 'h1', children: [], parent: [Circular *1] }
  ]
}

到目前为止,我们初步实现了模板解析为 AST,不过还有很多逻辑是没有处理的,我们下面一个一个进行补充。

单标签

HTML 中存在单标签,比如 <br/>,单标签与普通开始标签的区别在于:

  1. 结尾多了一个 / 字符
  2. 没有对应的结束标签

为了同时处理普通标签和单标签,我们可以将开始标签的正则表达式拆分为两部分:

js 复制代码
const ncname = '[a-zA-Z_][\\-\\.0-9_a-zA-Z]*'
const startTagOpen = new RegExp(`^<(${ncname})`)
const startTagClose = /^\s*(\/?)>/
  • startTagOpen 匹配标签的开始部分 <tagname
  • startTagClose 匹配标签的结束部分 >/>
    • 允许结束部分前有任意数量的空白字符
    • 使用分组 (\/?) 捕获可能存在的 /,用于判断是否为单标签

接下来,我们需要对 parseStartTag() 函数进行以下修改:

  1. 添加单标签的判断逻辑
  2. handleStartTag() 中调用 start() 函数时,传入单标签标志
  3. start() 函数中,根据单标签标志决定是否立即结束标签处理

具体的代码调整,调整 parseStartTaghandleStartTag,以及传入的 options.start 三个函数:

js 复制代码
// 定义用于匹配标签名的正则表达式:字母或下划线开头,后面可以是连字符、点、数字、字母或下划线
const ncname = '[a-zA-Z_][\\-\\.0-9_a-zA-Z]*'
// 匹配开始标签左半部分,如 <div
const startTagOpen = new RegExp(`^<(${ncname})`)
// 匹配开始标签右半部分,如 > 或 />
const startTagClose = /^\s*(\/?)>/
// 匹配结束标签,如 </div> 可以捕获<\和>中间的标签名
const endTag = new RegExp(`^<\\/(${ncname})>`)


// 要解析的HTML模板字符串 - 加上单标签 <br /> 来测试
const str = '<div><br /><p></p><h1></h1></div>'

/**
 * 解析开始标签 返回开始标签的匹配结果
 * @returns 开始标签的匹配结果
 */
function parseStartTag() {
  // 匹配开始标签的开始部分 `<tagname`
  const start = html.match(startTagOpen)
  // start表示匹配开始标签正则的结果,start 为空表示没有匹配到开始标签
  // 其中 start[0] 是整个开始标签 start[1] 是标签名
  if (start) {
    const match = {
      tagName: start[1],
      start: index,
    }
    // 前进开始标签开始部分对应的字符数
    advance(start[0].length)
    // 匹配开始标签的结束部分 `>` 或 `/>`
    let end = html.match(startTagClose)
    if (end) {
      // 如果是单标签则 unarySlash 为 '/' 否则为空串
      match.unarySlash = end[1]
      // 前进开始标签结束部分对应的字符数
      advance(end[0].length)
      // 记录结束标签的位置
      match.end = index
      return match
    }
  }
}


/**
 * 处理开始标签 调用外部提供的开始标签处理函数(如果存在)
 * @param match 开始标签的匹配结果
 */
function handleStartTag(match) {
  const tagName = match.tagName
  // 单标签标志
  const unary = match.unarySlash

  if (options.start) {
    options.start(tagName, unary)
  }
}


/**
 * 传入的开始标签处理函数,新增了单标签标志参数
 * @param tag 标签名
 * @param unary 单标签标志
 */
start(tag, unary) {
  // 遇到开始标签创建节点
  let element = {
    type: 1, // type=1表示是元素节点,其他类型后面再说
    tag,
    children: []
  }
  // 如果还没有根节点,将当前节点设为根节点
  if (!root) {
    root = element
  }
  // 如果不是单标签,把当前节点作为父节点,后面遇到的节点都是其子节点
  if (!unary) {
    currentParent = element
    // 把节点压入栈
    stack.push(element)
  } else {
    // 单标签不需要加入栈,因为没有子节点
    closeElement(element)
  }
},

解析结果:

js 复制代码
<ref *1> {
  type: 1,
  tag: 'div',
  children: [
    { type: 1, tag: 'br', children: [], parent: [Circular *1] },
    { type: 1, tag: 'p', children: [], parent: [Circular *1] },
    { type: 1, tag: 'h1', children: [], parent: [Circular *1] }
  ]
}

可以看到 <br/> 被成功解析了。

文本节点

接下来考虑标签中间的文本,比如 <span>Hello World</span> 中间的 Hello World。我们每次处理标签都是从 <</ 开始,到 >/> 结束,如果处理到某处发现 < 并不是第一个字符,那么这段标签之间的字符串就是文本字符串。

下面我们来处理文本之间的文本内容,我们在代码中调整 parse 函数,新增处理文本节点的逻辑,并在传入的 options 中,增加文本处理函数 options.chars

js 复制代码
// 要处理的模板字符串
const str = '<div><br/><span> Hello World </span></div>'

function parse(html, options) {
  // 当前解析的位置
  let index = 0
  while (html) {
    // 寻找 < 的位置
    let textEnd = html.indexOf('<')
    if (textEnd === 0) {
      // 如果textEnd=0 证明没有文本内容
      const endTagMatch = html.match(endTag)
      // 如果当前字符是结束标签
      if (endTagMatch) {
        // 前进结束标签对应的字符数,并处理结束标签
        const curIndex = index
        advance(endTagMatch[0].length)
        parseEndTag(endTagMatch[1], curIndex, index)
        continue
      }
      // 如果当前字符是开始标签
      const startTagMatch = parseStartTag()
      if (startTagMatch) {
        // 处理开始标签
        handleStartTag(startTagMatch)
        continue
      }
    }
    // textEnd!=0 解析从开始到 < 之间的文本
    // 比如 "Hello World</span></div>" textEnd=13
    // 文本节点的长度就是textEnd
    let text = 0
    if (textEnd > 0) {
      // 如果存在 < 字符,截取从开始到 < 之间的文本
      text = html.substring(0, textEnd)
    }
    if (textEnd < 0) {
      // 不存在 < 字符 证明整个html都是文本节点
      text = html
    }
    if (text) {
      // 前进文本节点对应的字符数
      advance(text.length)
    }
    if (options.chars && text) {
      // 调用外部提供的文本处理函数(如果存在)
      options.chars(text, index - text.length, index)
    }
  }
  // ... while 之后的代码不变
}

/**
 * 解析字符串,并传入开始和结束标签处理函数
 */
const ast = parse(str, {
  start(tag, unary) {/** 不变 */},
  end() { /** 不变 */ },
  /**
   * 新增一个文本节点处理函数
   */
  chars(text, start, end) {
    if (!currentParent) {
      // text节点不能做根节点 必须存在父节点
      console.warn('text节点不能做根节点 必须存在父节点')
      return
    }
    // 获取当前父节点的子节点数组
    const children = currentParent.children
    // 去除文本前后的空白字符
    text = text.trim()
    // 如果去除空白后文本为空,则将其设置为一个空格
    // 这是为了保持节点的占位作用
    if (!text) {
      text = ' '
    }
    // 如果只有一个文本节点,空格将会被保留占位,如果有多个文本节点,则不会保留空格
    if (
      text !== ' ' || // 如果文本不是单个空格
      !children.length || // 当前没有子节点
      children[children.length - 1].text !== ' ' // 最后一个子节点的文本不是单个空格
    ) {
      // 创建一个纯文本的子节点
      currentParent.children.push({
        type: 3, // type=3表示文本节点
        text
      })
    }
  }
})

输出如下,可以看到文本被处理成了 text 子节点,我们用 type=3 表示文本节点。

js 复制代码
<ref *1> {
  type: 1,
  tag: 'div',
  children: [
    { type: 1, tag: 'br', children: [], parent: [Circular *1] },
    {
      type: 1,
      tag: 'span',
      children: [ { type: 3, text: 'Hello World' } ],
      parent: [Circular *1]
    }
  ]
}

插值表达式

在 Vue 中文本可能包含表达式,比如 <span>{{name}}</span> 如果已经定义变量 name=cookie 则为渲染为 <span>cookie</span>,所以我们要对被 {{}} 包裹的字符串进行特殊处理,把其当做表达式。

首先写一个正则用来匹配 {{}} 格式包裹的表达式。

js 复制代码
const tagRE = /\{\{((?:.|\r?\n)+?)\}\}/g

下面是对这个正则表达式的详细解释:

  • \{\{ 配插值表达式的起始标记 {{,反斜杠 \ 用于转义特殊字符
  • ((?:.|\r?\n)+?) 这是一个捕获组,用于匹配插值表达式中的内容。
    • (?: ) 用于定义非捕获性分组,它定义一个组,但不捕获该组的内容。
    • . 匹配除换行符以外的任意字符,\r?\n 匹配换行符,.|\r?\n 表示匹配任意字符
    • +? 非贪婪匹配,尽可能少地匹配前面的模式
  • }}:匹配插值表达式的结束标记 }}
  • /g:全局匹配标志,表示匹配字符串中的所有符合条件的部分,而不是在找到第一个匹配后停止。

测试正则的匹配结果如下,可以看到我们可以通过正则来提取表达式字符串。

js 复制代码
const tagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
const text = `<span>{{name}}</span>`
console.log(tagRE.exec(text))
// [   
//     0: "{{name}}",
//     1: "name",
//     groups: undefined,
//     index: 6,
//     input: "<span>{{name}}</span>"
// ]

下面我们调整 options.chars 函数,添加处理插值表达式的逻辑,并把相关逻辑封装到函数 parseText 中。

参考 Vue 中处理逻辑,我们对插值表达式的处理主要包含两个步骤:

  1. 变量存储: 将表达式中的变量名以特定格式存储在 tokens 数组中。每个变量被表示为一个对象:{ '@binding': 变量名 }

  2. 表达式重构: 重新构造表达式,将所有变量用 _s() 函数包裹。这个过程会生成一个新的字符串表达式。这样在最终的渲染过程中,只需将实际的求值函数赋值给 _s,就能完成动态内容的插入。而 _s() 内部处理其实就是 toString

例如,原始表达式 "Hello, {{name}}" 经过处理后会变为:

  • tokens: ["Hello, ", { '@binding': 'name' }]
  • 重构后的表达式:"Hello, " + _s(name)

下面是具体代码:

js 复制代码
// 要处理的模板字符串
const str = '<div><span>userInfo: {{name}}({{nickname}})</span></div>'

/**
 * 解析文本中的插值表达式
 * @param {string} text - 待解析的文本
 * @returns {object|undefined} 解析结果,包含最终expression和token列表
 */
function parseText(text) {
  const tagRE = /{{((?:.|\r?\n)+?)}}/g
  if (!tagRE.test(text)) {
    // 没有表达式的话直接返回
    return
  }
  const tokens = []
  const rawTokens = []
  // 全局正则表达式的 lastIndex 指定下一次从哪个位置开始匹配
  // 因为上面使用了 tagRE.test 现在要重置为 0
  let lastIndex = (tagRE.lastIndex = 0)
  let match, index, tokenValue
  while ((match = tagRE.exec(text))) {
    // 循环匹配每一个表达式
    index = match.index // 记录当前匹配到的下标
    if (index > lastIndex) {
      // 如果不是从开始位置开始匹配的
      // 证明和上一个表达式(或起始位置)中间有字符串
      // 此处处理插值表达式之间的普通文本
      rawTokens.push((tokenValue = text.slice(lastIndex, index)))
      tokens.push(JSON.stringify(tokenValue))
    }
    // 提取并处理插值表达式
    const exp = match[1].trim()
    // 把表达式放在token中,用_s()包裹起来
    tokens.push(`_s(${exp})`)
    // rawTokens 以 { '@binding': exp } 的形式存放表达式
    rawTokens.push({ '@binding': exp })
    // lastIndex 是接下来要处理的位置
    lastIndex = index + match[0].length
  }
  // 如果没有处理到字符串结尾 证明最后一段也是普通文本
  if (lastIndex < text.length) {
    rawTokens.push((tokenValue = text.slice(lastIndex)))
    tokens.push(JSON.stringify(tokenValue))
  }
  // 返回解析的结果
  return {
    expression: tokens.join('+'),
    tokens: rawTokens,
  }
}


/**
 * 文本节点处理函数
 */
chars(text, start, end) {
  if (!currentParent) {
    // text节点不能做根节点 必须存在父节点
    console.warn('text节点不能做根节点 必须存在父节点')
    return
  }
  // 获取当前父节点的子节点数组
  const children = currentParent.children
  // 去除文本前后的空白字符
  text = text.trim()
  // 如果去除空白后文本为空,则将其设置为一个空格
  // 这是为了保持节点的占位作用
  if (!text) {
    text = ' '
  }
  let res, child
  // 如果文本不是单个空格,尝试解析其中的插值表达式
  if (text !== ' ' && (res = parseText(text))) {
    // 如果成功解析出插值表达式,创建一个包含表达式信息的子节点
    child = {
      type: 2, // type=2 表示表达式节点
      expression: res.expression,
      tokens: res.tokens,
      text
    }
  }
  // 如果只有一个文本节点,空格将会被保留占位,如果有多个文本节点,则不会保留空格
  else if (
    text !== ' ' || // 如果文本不是单个空格
    !children.length || // 当前没有子节点
    children[children.length - 1].text !== ' ' // 最后一个子节点的文本不是单个空格
  ) {
    // 创建一个纯文本的子节点
    child = {
      type: 3, // type=3 表示文本节点
      text
    }
  }
  // 如果创建了子节点(无论是包含表达式的还是纯文本的)
  // 将其添加到子节点数组中

  if (child) {
    children.push(child)
  }
}

处理结果:

js 复制代码
<ref *1> {
  type: 1,
  tag: 'div',
  children: [
    {
      type: 1,
      tag: 'span',
      children: [
        {
          type: 2,
          expression: '"userInfo: "+_s(name)+"("+_s(nickname)+")"',
          tokens: [
            'userInfo: ',
            { '@binding': 'name' },
            '(',
            { '@binding': 'nickname' },
            ')'
          ],
          text: 'userInfo: {{name}}({{nickname}})'
        }
      ],
      parent: [Circular *1]
    }
  ]
}

标签属性

在上面的讨论中,我们通过 startTagOpenstartTagClose 来匹配开始标签。然而,实际的 HTML 标签通常还包含各种属性。这些属性主要有四种形式:

  1. 双引号包裹的属性值:<div class="example"></div>
  2. 单引号包裹的属性值:<div class='example'></div>
  3. 无引号的属性值:<div class=example></div>
  4. 无值的布尔属性:<input disabled />

为了准确匹配这些不同形式的属性,Vue 使用了一个复杂的正则表达式。让我们来分析这个正则表达式:

js 复制代码
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

看不懂很正常,我专门写了一篇文章来解析这个正则,感兴趣可以看看,当然也可以先不理解,当做工具直接使用:Vue 源码中一行正则让你学会80%正则语法

这个正则表达式能够匹配各种形式的 Vue 模板属性,包括带引号的属性值、不带引号的属性值,以及没有值的布尔属性。

为了新增属性解析的能力,我们需要修改三个与开始标签相关的函数:

  1. parseStartTag:解析开始标签
  2. handleStartTag:处理开始标签
  3. options.start:开始标签的处理选项

具体代码:

js 复制代码
// 匹配属性的正则
const attribute =
  /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/
// 要处理的模板字符串
const str = `<div class='box' @click="onclick" disabled></div>`

/**
 * 解析开始标签 返回开始标签的匹配结果
 * @returns 开始标签的匹配结果
 */
function parseStartTag() {
  // 匹配开始标签的开始部分 `<tagname`
  const start = html.match(startTagOpen)
  // start表示匹配开始标签正则的结果,start 为空表示没有匹配到开始标签
  // 其中 start[0] 是整个开始标签 start[1] 是标签名
  if (start) {
    const match = {
      tagName: start[1],
      attrs: [],
      start: index,
    }
    // 前进开始标签开始部分对应的字符数
    advance(start[0].length)
    let end, attr
    while (
      // 还没有匹配到结束标签,并且匹配到属性,则一直匹配,因为可能有多个属性
      !(end = html.match(startTagClose)) &&
      (attr = html.match(attribute))
    ) {
      // 保存属性信息
      attr.start = index
      advance(attr[0].length)
      attr.end = index
      match.attrs.push(attr)
    }
    if (end) {
      // 如果是单标签则 unarySlash 为 '/' 否则为空串
      match.unarySlash = end[1]
      // 前进开始标签结束部分对应的字符数
      advance(end[0].length)
      // 记录结束标签的位置
      match.end = index
      return match
    }
  }
}
/**
 * 处理开始标签 调用外部提供的开始标签处理函数(如果存在)
 * @param match 开始标签的匹配结果
 */
function handleStartTag(match) {
  const tagName = match.tagName
  // 单标签标志
  const unary = match.unarySlash

  // 保存属性
  const l = match.attrs.length
  const attrs = new Array(l)
  for (let i = 0; i < l; i++) {
    const args = match.attrs[i]
    // args[3]、args[4]、args[5] 分别对应双引号属性值,单引号属性值和无引号属性值
    // 这三种只能匹配其中一种
    const value = args[3] || args[4] || args[5] || ''
    attrs[i] = {
      name: args[1],
      value,
    }
  }

  if (options.start) {
    options.start(tagName, attrs, unary, match.start, match.end)
  }
}
/**
 * 将属性数组转换为属性映射对象
 * @param {Array} attrs - 属性数组,每个元素包含 name 和 value 属性
 * @returns {Object} 属性名到属性值的映射对象
 */
function makeAttrsMap(attrs) {
  const map = {}
  for (let i = 0, l = attrs.length; i < l; i++) {
    // 将每个属性的名称作为键,值作为值,存入映射对象
    map[attrs[i].name] = attrs[i].value
  }
  return map
}
/**
 * 传入的开始标签处理函数,新增了单标签标志参数
 * @param tag 标签名
 * @param unary 单标签标志
 */
start(tag, attrs, unary, start, end) {
  // 遇到开始标签创建节点
  let element = {
    type: 1, // type=1表示是元素节点
    tag,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs),
    children: [],
  }
  // 如果还没有根节点,将当前节点设为根节点
  if (!root) {
    root = element
  }
  // 如果不是单标签,把当前节点作为父节点,后面遇到的节点都是其子节点
  if (!unary) {
    currentParent = element
    // 把节点压入栈
    stack.push(element)
  } else {
    // 单标签不需要加入栈,因为没有子节点
    closeElement(element)
  }
}

处理结果:

js 复制代码
{
  type: 1,
  tag: 'div',
  attrsList: [
    { name: 'class', value: 'box' },
    { name: '@click', value: 'onclick' },
    { name: 'disabled', value: '' }
  ],
  attrsMap: { class: 'box', '@click': 'onclick', disabled: '' },
  children: []
}

指令 v-for

接下来,我们将处理 Vue 中的指令 v-for,它一般用于列表渲染。

v-for 指令支持多种迭代语法:

  1. 迭代数组:
    • item in items
    • (item, index) in items
  2. 迭代对象:
    • value in object
    • (value, key) in object
    • (value, key, index) in object

首先写出用于解析 v-for 指令的正则表达式:

js 复制代码
const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
// 示例:
forAliasRE.exec("(item, index) in items")
// ['(item, index) in items', '(item, index)', 'items']

其中 [\s\S] 表示匹配任意字符,和 . 的区别是,这种方式可以匹配换行符。

  • ([\s\S]*?) 捕获组1,匹配被迭代的数组元素的别名(包含项和索引),比如 (item, index),其中 ? 表示非贪婪匹配
  • \s+ 一个或多个空白字符
  • (?:in|of) 非捕获组,匹配 "in" 或 "of"(作为分隔符)
  • \s+ 一个或多个空白字符
  • ([\s\S]*) 捕获组2,匹配源数据数组或对象,比如 items

下面我们通过正则处理别名部分,因为别名主要会有下面几种情况

  • item, index
  • {name, age}, index
  • value, key, index
js 复制代码
const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
  • , 匹配分割第一个和第二个变量的逗号
  • ([^,\}\]]*) 第一个捕获组,获取别名中第二个变量,匹配任意个不为 , } ] 的字符
  • (?:,([^,\}\]]*)) 非捕获组
    • , 匹配分割第二个和第三个变量的逗号
    • ([^,\}\]]*) 第二个捕获组,获取别名中第三个变量,匹配任意个不为 , } ] 的字符
bash 复制代码
// 示例
forIteratorRE.exec('item, index')
//  [', index', ' index', undefined]
forIteratorRE.exec('{name, age}, index')
// [', index', ' index', undefined]
forIteratorRE.exec('(value, key, index)')
// [', key, index)', ' key', ' index)']
forIteratorRE.exec('item')
// null

如果 forIteratorRE 不能和别名部分进行匹配,则证明只有一个参数。

在之前的程序,我们处理 <div v-for="(item, index) in list">{{item}}</div> 这段代码,可以得到的对属性的解析结果为:

json 复制代码
"attrsList": [
  {
    "name": "v-for",
    "value": "(item, index) in list"
  }
],
"attrsMap": {
  "v-for": "(item, index) in list"
}

现在我们在 start 中新增 processFor 用来处理 v-for,在 processFor 我们通过上面的正则对表达式进行处理:

js 复制代码
// 匹配 v-for 表达式,并提取 迭代元素别名 和 源数据数组或对象
const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
// 匹配别名中的变量
const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
// 匹配括号 ( 和 )
const stripParensRE = /^\(|\)$/g

// 要处理的模板字符串
const str = `<div v-for="(item, index) in list">{{item}}</div>`

start(tag, attrs, unary, start, end) {
    let element = {
        // ... 不变
    };
    // 增加 v-for 处理逻辑
    processFor(element);
    // ...
}

/**
 * 处理 v-for 属性
 */
function processFor(el) {
  let exp
  // 获取 v-for 的值,然后删除属性
  if ((exp = getAndRemoveAttr(el, 'v-for'))) {
    // 解析 v-for 属性值
    const res = parseFor(exp)
    if (res) {
      // 在 el 中添加 v-for 解析值
      extend(el, res)
    }
  }
}

/**
 * 获取并移除元素的指定属性
 * @param {Object} el - 元素对象
 * @param {String} name - 要获取和移除的属性名
 * @returns {String|null} 属性值,如果属性不存在则返回 null
 */
function getAndRemoveAttr(el, name) {
  let val
  if ((val = el.attrsMap[name]) != null) {
    const list = el.attrsList
    for (let i = 0, l = list.length; i < l; i++) {
      if (list[i].name === name) {
        list.splice(i, 1)
        break
      }
    }
  }
  return val
}

/**
 * 把 _from 中的属性全部赋值给 to
 */
function extend(to, _from) {
  for (const key in _from) {
    to[key] = _from[key]
  }
  return to
}

/**
 * 解析 v-for 表达式
 * @param {String} exp 表达式字符串
 * @returns 解析结果
 */
function parseFor(exp) {
  // 使用正则处理 v-for 表达式
  const inMatch = exp.match(forAliasRE)
  if (!inMatch) return
  const res = {}
  // inMatch[2]为源数据数组或对象 比如 "item in items" 中的 items
  res.for = inMatch[2].trim()
  // 删除字符串中的括号 ( )
  const alias = inMatch[1].trim().replace(stripParensRE, '')
  // 处理别名中的多个变量
  const iteratorMatch = alias.match(forIteratorRE)
  // 存在iteratorMatch 证明有多个变量
  if (iteratorMatch) {
    // 删除后面的变量,保留的字符串为别名变量字符串
    res.alias = alias.replace(forIteratorRE, '').trim()
    // 保存第二个变量
    res.iterator1 = iteratorMatch[1].trim()
    // 如果存在,保存第三个变量
    if (iteratorMatch[2]) {
      res.iterator2 = iteratorMatch[2].trim()
    }
  } else {
    // 如果不能匹配正则,则只有一个变量,即为别名
    res.alias = alias
  }
  return res
}

得到结果为:

js 复制代码
{
  type: 1,
  tag: 'div',
  attrsList: [],
  attrsMap: { 'v-for': '(item, index) in list' },
  children: [
    {
      type: 2,
      expression: '_s(item)',
      tokens: [ { '@binding': 'item' } ],
      text: '{{item}}'
    }
  ],
  for: 'list',
  alias: 'item',
  iterator1: 'index'
}

指令 v-if

接下来我们在模板编译中去处理 v-if 语法。

v-if 是 Vue 中的条件渲染指令,经常同时出现的还有 v-elsev-else-if。详情查看 Vue 条件渲染

一般 v-if 可能对应多个节点,比如:

template 复制代码
<div>
  <h1 v-if="A">A</h1>
  <h1 v-else-if="B">B</h1>
  <h1 v-else>C</h1>
</div>

但是由于这三个分支最后只会渲染一个,所以我们把这三种情况统一存到第一个 v-if 节点里面,统一用 ifConditions 存储:

js 复制代码
ifConditions: [
  { exp: 'A', block: /.../ },
  { exp: 'B', block: /.../ },
  { exp: undefined, block: /.../ }
]

在渲染时,我们会依次判断 exp 条件是否成立,然后执行对应的 block,如果都不成立,则 exp=undefined 也就是 else 模块会被执行。

接下来我们在 start() 中新增 processIf 用来处理 v-if 相关的指令,然后在 closeElement 时增加逻辑,会把 v-else-ifv-else 节点存到前面的 v-if 节点里。下面是改动代码:

js 复制代码
// 要处理的 v-if 模板字符串
const str = `<div><h1 v-if="A">A</h1><h1 v-else-if="B">B</h1><h1 v-else>C</h1></div>`

start(tag, attrs, unary, start) {
    let element = {
        // ...
    };
    processFor(element)
    processIf(element)
    // 后面不变
}

/**
 * 为元素添加 if 条件
 * @param {Object} el - 当前处理的元素对象
 * @param {Object} condition - if 条件对象,包含表达式和对应的元素块
 */
function addIfCondition(el, condition) {
  // 如果元素还没有 ifConditions 数组,则创建一个
  if (!el.ifConditions) {
    el.ifConditions = []
  }
  // 将新的条件对象添加到 ifConditions 数组中
  el.ifConditions.push(condition)
}

/**
 * 处理元素的 v-if 指令
 * @param {Object} el - 当前处理的元素对象
 */
function processIf(el) {
  // 获取 v-if 表达式,并从元素的属性列表中移除 v-if 属性
  const exp = getAndRemoveAttr(el, 'v-if')

  if (exp) {
    // 如果存在 v-if 表达式, 将表达式存储到元素的if属性中
    el.if = exp
    // 添加 if 条件,包含表达式和当前元素块
    addIfCondition(el, {
      exp: exp, // 存储表达式
      block: el // 存储当前元素
    })
  } else {
    if (getAndRemoveAttr(el, 'v-else') != null) {
      el.else = true
    }
    const elseif = getAndRemoveAttr(el, 'v-else-if')
    if (elseif) {
      el.elseif = elseif
    }
  }
}

/**
 * 从子节点数组中查找最后一个 type=1 的节点
 * @param {Array} children - 子节点数组
 */
function findPrevElement(children) {
  let i = children.length
  while (i--) {
    if (children[i].type === 1) {
      return children[i]
    }
  }
}

/**
 * 把节点放进前一个 v-if 节点的 ifConditions 里面
 * @param {*} el
 * @param {*} parent
 */
function processIfConditions(el, parent) {
  const prev = findPrevElement(parent.children)
  if (prev && prev.if) {
    addIfCondition(prev, {
      exp: el.elseif,
      block: el
    })
  }
}

/**
 * 关闭元素,将其添加到父节点的子节点列表中
 * @param {Object} element - 当前处理的节点
 * @param {string} element.tag - 标签名
 * @param {Array} element.children - 子节点数组
 */
function closeElement(element) {
  if (currentParent) {
    if (element.elseif || element.else) {
      // 处理 v-else-if/v-else 条件
      processIfConditions(element, currentParent)
    } else {
      // 把当前节点添加到父节点的子节点中
      currentParent.children.push(element)
      // 设置当前节点的父节点
      element.parent = currentParent
    }
  }
}

生成的 AST 代码:

json 复制代码
<ref *2> {
  type: 1,
  tag: 'div',
  attrsList: [],
  attrsMap: {},
  children: [
    <ref *1> {
      type: 1,
      tag: 'h1',
      attrsList: [],
      attrsMap: { 'v-if': 'A' },
      children: [ { type: 3, text: 'A' } ],
      if: 'A',
      ifConditions: [
        { exp: 'A', block: [Circular *1] },
        {
          exp: 'B',
          block: {
            type: 1,
            tag: 'h1',
            attrsList: [],
            attrsMap: { 'v-else-if': 'B' },
            children: [ { type: 3, text: 'B' } ],
            elseif: 'B'
          }
        },
        {
          exp: undefined,
          block: {
            type: 1,
            tag: 'h1',
            attrsList: [],
            attrsMap: { 'v-else': '' },
            children: [ { type: 3, text: 'C' } ],
            else: true
          }
        }
      ],
      parent: [Circular *2]
    }
  ]
}

指令 v-bind 和事件处理

最后一趴,在 Vue 中,我们经常会使用 v-bind 来绑定属性值,以及使用 @v-on 来绑定事件,下面我们来实现对它们的解析。

v-bind 有两种写法:

  1. 完整写法: v-bind:属性名="表达式"
  2. 简写: :属性名="表达式"

事件绑定也有两种写法:

  1. 完整写法: v-on:事件名="处理函数"
  2. 简写: @事件名="处理函数"

首先定义用于匹配这些指令的正则表达式:

js 复制代码
// 匹配 v-bind 和 @ 开头的属性
const bindRE = /^:|^v-bind:/
const onRE = /^@|^v-on:/

然后实现处理这些指令的函数:

js 复制代码
/**
 * 处理 v-bind 指令
 * @param {Object} el - 当前处理的元素对象
 */
function processBindings(el) {
  const list = el.attrsList
  let i, l, name, value
  for (i = 0, l = list.length; i < l; i++) {
    name = list[i].name
    value = list[i].value
    if (bindRE.test(name)) {
      // 移除 v-bind: 或 : 前缀
      name = name.replace(bindRE, '')
      // 将绑定信息添加到元素的 bindingMap 中
      if (!el.bindingMap) {
        el.bindingMap = {}
      }
      el.bindingMap[name] = value
      // 从属性列表中移除该属性
      list.splice(i, 1)
      i--
      l--
    }
  }
}

/**
 * 处理事件绑定
 * @param {Object} el - 当前处理的元素对象
 */
function processEvents(el) {
  const list = el.attrsList
  let i, l, name, value
  for (i = 0, l = list.length; i < l; i++) {
    name = list[i].name
    value = list[i].value
    if (onRE.test(name)) {
      // 移除 @ 或 v-on: 前缀
      name = name.replace(onRE, '')
      // 将事件处理函数添加到元素的 events 中
      if (!el.events) {
        el.events = {}
      }
      el.events[name] = value
      // 从属性列表中移除该属性
      list.splice(i, 1)
      i--
      l--
    }
  }
}

最后在 start 函数中调用这些处理函数:

js 复制代码
start(tag, attrs, unary, start, end) {
  let element = {
    type: 1,
    tag,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs),
    children: []
  }
  processFor(element)
  processIf(element)
  processBindings(element)  // 处理 v-bind
  processEvents(element)    // 处理事件
  // ... 后面的代码不变
}

接下来尝试解析下面这个字符串:

js 复制代码
const str = `<div 
  v-bind:class="myClass"
  :style="myStyle"
  @click="handleClick"
  v-on:input="handleInput"
>hello</div>`

这段模板会被解析成如下的 AST:

js 复制代码
{
  type: 1,
  tag: 'div',
  attrsList: [],
  attrsMap: {
    'v-bind:class': 'myClass',
    ':style': 'myStyle',
    '@click': 'handleClick',
    'v-on:input': 'handleInput'
  },
  children: [ { type: 3, text: 'hello' } ],
  bindingMap: { class: 'myClass', style: 'myStyle' },
  events: { click: 'handleClick', input: 'handleInput' }
}

小结

到此为止,我们的解析部分代码完成,完整代码:

js 复制代码
// 定义用于匹配标签名的正则表达式:字母或下划线开头,后面可以是连字符、点、数字、字母或下划线
const ncname = '[a-zA-Z_][\\-\\.0-9_a-zA-Z]*'
// 匹配开始标签左半部分,如 <div
const startTagOpen = new RegExp(`^<(${ncname})`)
// 匹配开始标签右半部分,如 > 或 />
const startTagClose = /^\s*(\/?)>/
// 匹配结束标签,如 </div> 可以捕获<\和>中间的标签名
const endTag = new RegExp(`^<\\/(${ncname})>`)

// 匹配属性的正则
const attribute =
  /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

// 匹配 v-for 表达式,并提取 迭代元素别名 和 源数据数组或对象
const forAliasRE = /([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/
// 匹配别名中的变量
const forIteratorRE = /,([^,\}\]]*)(?:,([^,\}\]]*))?$/
// 匹配括号 ( 和 )
const stripParensRE = /^\(|\)$/g

// 匹配 v-bind 和 @ 开头的属性
const bindRE = /^:|^v-bind:/
const onRE = /^@|^v-on:/

// 要处理的模板字符串
const str = `<div  v-bind:class="myClass" :style="myStyle" @click="handleClick" v-on:input="handleInput">hello</div>`
// 用于处理节点树的栈
const stack = []
// 存储解析后的根节点
let root
// 当前正在处理的父节点
let currentParent

/**
 * HTML模板解析器主函数
 * @param {string} html - 需要解析的HTML字符串
 * @param {object} options - 配置选项,包含标签处理的回调函数
 * @param {Function} options.start - 处理开始标签的回调
 * @param {Function} options.end - 处理结束标签的回调
 */
function parse(html, options) {
  // 当前解析的位置
  let index = 0
  while (html) {
    // 寻找 < 的位置
    let textEnd = html.indexOf('<')
    if (textEnd === 0) {
      // 如果textEnd=0 证明没有文本内容
      const endTagMatch = html.match(endTag)
      // 如果当前字符是结束标签
      if (endTagMatch) {
        // 前进结束标签对应的字符数,并处理结束标签
        const curIndex = index
        advance(endTagMatch[0].length)
        parseEndTag(endTagMatch[1], curIndex, index)
        continue
      }
      // 如果当前字符是开始标签
      const startTagMatch = parseStartTag()
      if (startTagMatch) {
        // 处理开始标签
        handleStartTag(startTagMatch)
        continue
      }
    }
    // textEnd!=0 解析从开始到 < 之间的文本
    // 比如 "Hello World</span></div>" textEnd=13
    // 文本节点的长度就是textEnd
    let text = 0
    if (textEnd > 0) {
      // 如果存在 < 字符,截取从开始到 < 之间的文本
      text = html.substring(0, textEnd)
    }
    if (textEnd < 0) {
      // 不存在 < 字符 证明整个html都是文本节点
      text = html
    }
    if (text) {
      // 前进文本节点对应的字符数
      advance(text.length)
    }
    if (options.chars && text) {
      // 调用外部提供的文本处理函数(如果存在)
      options.chars(text, index - text.length, index)
    }
  }

  /**
   * 向前移动解析位置
   * @param {number} n - 需要前进的字符数
   */
  function advance(n) {
    index += n
    html = html.substring(n)
  }

  /**
   * 解析开始标签 返回开始标签的匹配结果
   * @returns 开始标签的匹配结果
   */
  function parseStartTag() {
    // 匹配开始标签的开始部分 `<tagname`
    const start = html.match(startTagOpen)
    // start表示匹配开始标签正则的结果,start 为空表示没有匹配到开始标签
    // 其中 start[0] 是整个开始标签 start[1] 是标签名
    if (start) {
      const match = {
        tagName: start[1],
        attrs: [],
        start: index
      }
      // 前进开始标签开始部分对应的字符数
      advance(start[0].length)
      let end, attr
      while (
        // 还没有匹配到结束标签,并且匹配到属性,则一直匹配,因为可能有多个属性
        !(end = html.match(startTagClose)) &&
        (attr = html.match(attribute))
      ) {
        // 保存属性信息
        attr.start = index
        advance(attr[0].length)
        attr.end = index
        match.attrs.push(attr)
      }
      if (end) {
        // 如果是单标签则 unarySlash 为 '/' 否则为空串
        match.unarySlash = end[1]
        // 前进开始标签结束部分对应的字符数
        advance(end[0].length)
        // 记录结束标签的位置
        match.end = index
        return match
      }
    }
  }
  /**
   * 处理开始标签 调用外部提供的开始标签处理函数(如果存在)
   * @param match 开始标签的匹配结果
   */
  function handleStartTag(match) {
    const tagName = match.tagName
    // 单标签标志
    const unary = match.unarySlash

    // 保存属性
    const l = match.attrs.length
    const attrs = new Array(l)
    for (let i = 0; i < l; i++) {
      const args = match.attrs[i]
      // args[3]、args[4]、args[5] 分别对应双引号属性值,单引号属性值和无引号属性值
      // 这三种只能匹配其中一种
      const value = args[3] || args[4] || args[5] || ''
      attrs[i] = {
        name: args[1],
        value
      }
    }

    if (options.start) {
      // TODO 在前面加上match.start, match.end
      options.start(tagName, attrs, unary, match.start, match.end)
    }
  }

  /**
   * 解析结束标签 调用外部提供的结束标签处理函数(如果存在)
   * @param {string} tagName - 标签名
   * @param {number} start - 标签开始位置
   * @param {number} end - 标签结束位置
   */
  function parseEndTag(tagName, start, end) {
    if (options.end) {
      options.end(tagName, start, end)
    }
  }

  return root
}

/**
 * 从子节点数组中查找最后一个 type=1 的节点
 * @param {Array} children - 子节点数组
 */
function findPrevElement(children) {
  let i = children.length
  while (i--) {
    if (children[i].type === 1) {
      return children[i]
    }
  }
}

/**
 * 把节点放进前一个 v-if 节点的 ifConditions 里面
 * @param {*} el
 * @param {*} parent
 */
function processIfConditions(el, parent) {
  const prev = findPrevElement(parent.children)
  if (prev && prev.if) {
    addIfCondition(prev, {
      exp: el.elseif,
      block: el
    })
  }
}

/**
 * 关闭元素,将其添加到父节点的子节点列表中
 * @param {Object} element - 当前处理的节点
 * @param {string} element.tag - 标签名
 * @param {Array} element.children - 子节点数组
 */
function closeElement(element) {
  if (currentParent) {
    if (element.elseif || element.else) {
      // 处理 v-else-if/v-else 条件
      processIfConditions(element, currentParent)
    } else {
      // 把当前节点添加到父节点的子节点中
      currentParent.children.push(element)
      // 设置当前节点的父节点
      element.parent = currentParent
    }
  }
}

/**
 * 解析文本中的插值表达式
 * @param {string} text - 待解析的文本
 * @returns {object|undefined} 解析结果,包含最终expression和token列表
 */
function parseText(text) {
  const tagRE = /{{((?:.|\r?\n)+?)}}/g
  if (!tagRE.test(text)) {
    // 没有表达式的话直接返回
    return
  }
  const tokens = []
  const rawTokens = []
  // 全局正则表达式的 lastIndex 指定下一次从哪个位置开始匹配
  // 因为上面使用了 tagRE.test 现在要重置为 0
  let lastIndex = (tagRE.lastIndex = 0)
  let match, index, tokenValue
  while ((match = tagRE.exec(text))) {
    // 循环匹配每一个表达式
    index = match.index // 记录当前匹配到的下标
    if (index > lastIndex) {
      // 如果不是从开始位置开始匹配的
      // 证明和上一个表达式(或起始位置)中间有字符串
      // 此处处理插值表达式之间的普通文本
      rawTokens.push((tokenValue = text.slice(lastIndex, index)))
      tokens.push(JSON.stringify(tokenValue))
    }
    // 提取并处理插值表达式
    const exp = match[1].trim()
    // 把表达式放在token中,用_s()包裹起来
    tokens.push(`_s(${exp})`)
    // rawTokens 以 { '@binding': exp } 的形式存放表达式
    rawTokens.push({ '@binding': exp })
    // lastIndex 是接下来要处理的位置
    lastIndex = index + match[0].length
  }
  // 如果没有处理到字符串结尾 证明最后一段也是普通文本
  if (lastIndex < text.length) {
    rawTokens.push((tokenValue = text.slice(lastIndex)))
    tokens.push(JSON.stringify(tokenValue))
  }
  // 返回解析的结果
  return {
    expression: tokens.join('+'),
    tokens: rawTokens
  }
}
/**
 * 将属性数组转换为属性映射对象
 * @param {Array} attrs - 属性数组,每个元素包含 name 和 value 属性
 * @returns {Object} 属性名到属性值的映射对象
 */
function makeAttrsMap(attrs) {
  const map = {}
  for (let i = 0, l = attrs.length; i < l; i++) {
    // 将每个属性的名称作为键,值作为值,存入映射对象
    map[attrs[i].name] = attrs[i].value
  }
  return map
}

/**
 * 处理 v-for 属性
 */
function processFor(el) {
  let exp
  // 获取 v-for 的值,然后删除属性
  if ((exp = getAndRemoveAttr(el, 'v-for'))) {
    // 解析 v-for 属性值
    const res = parseFor(exp)
    if (res) {
      // 在 el 中添加 v-for 解析值
      extend(el, res)
    }
  }
}

/**
 * 获取并移除元素的指定属性
 * @param {Object} el - 元素对象
 * @param {String} name - 要获取和移除的属性名
 * @returns {String|null} 属性值,如果属性不存在则返回 null
 */
function getAndRemoveAttr(el, name) {
  let val
  if ((val = el.attrsMap[name]) != null) {
    const list = el.attrsList
    for (let i = 0, l = list.length; i < l; i++) {
      if (list[i].name === name) {
        list.splice(i, 1)
        break
      }
    }
  }
  return val
}

/**
 * 把 _from 中的属性全部赋值给 to
 */
function extend(to, _from) {
  for (const key in _from) {
    to[key] = _from[key]
  }
  return to
}

/**
 * 解析 v-for 表达式
 * @param {String} exp 表达式字符串
 * @returns 解析结果
 */
function parseFor(exp) {
  // 使用正则处理 v-for 表达式
  const inMatch = exp.match(forAliasRE)
  if (!inMatch) return
  const res = {}
  // inMatch[2]为源数据数组或对象 比如 "item in items" 中的 items
  res.for = inMatch[2].trim()
  // 删除字符串中的括号 ( )
  const alias = inMatch[1].trim().replace(stripParensRE, '')
  // 处理别名中的多个变量
  const iteratorMatch = alias.match(forIteratorRE)
  // 存在iteratorMatch 证明有多个变量
  if (iteratorMatch) {
    // 删除后面的变量,保留的字符串为别名变量字符串
    res.alias = alias.replace(forIteratorRE, '').trim()
    // 保存第二个变量
    res.iterator1 = iteratorMatch[1].trim()
    // 如果存在,保存第三个变量
    if (iteratorMatch[2]) {
      res.iterator2 = iteratorMatch[2].trim()
    }
  } else {
    // 如果不能匹配正则,则只有一个变量,即为别名
    res.alias = alias
  }
  return res
}

/**
 * 为元素添加 if 条件
 * @param {Object} el - 当前处理的元素对象
 * @param {Object} condition - if 条件对象,包含表达式和对应的元素块
 */
function addIfCondition(el, condition) {
  // 如果元素还没有 ifConditions 数组,则创建一个
  if (!el.ifConditions) {
    el.ifConditions = []
  }
  // 将新的条件对象添加到 ifConditions 数组中
  el.ifConditions.push(condition)
}

/**
 * 处理元素的 v-if 指令
 * @param {Object} el - 当前处理的元素对象
 */
function processIf(el) {
  // 获取 v-if 表达式,并从元素的属性列表中移除 v-if 属性
  const exp = getAndRemoveAttr(el, 'v-if')

  if (exp) {
    // 如果存在 v-if 表达式, 将表达式存储到元素的if属性中
    el.if = exp
    // 添加 if 条件,包含表达式和当前元素块
    addIfCondition(el, {
      exp: exp, // 存储表达式
      block: el // 存储当前元素
    })
  } else {
    if (getAndRemoveAttr(el, 'v-else') != null) {
      el.else = true
    }
    const elseif = getAndRemoveAttr(el, 'v-else-if')
    if (elseif) {
      el.elseif = elseif
    }
  }
}

/**
 * 处理 v-bind 指令
 * @param {Object} el - 当前处理的元素对象
 */
function processBindings(el) {
  const list = el.attrsList
  let i, l, name, value
  for (i = 0, l = list.length; i < l; i++) {
    name = list[i].name
    value = list[i].value
    if (bindRE.test(name)) {
      // 移除 v-bind: 或 : 前缀
      name = name.replace(bindRE, '')
      // 将绑定信息添加到元素的 bindingMap 中
      if (!el.bindingMap) {
        el.bindingMap = {}
      }
      el.bindingMap[name] = value
      // 从属性列表中移除该属性
      list.splice(i, 1)
      i--
      l--
    }
  }
}

/**
 * 处理事件绑定
 * @param {Object} el - 当前处理的元素对象
 */
function processEvents(el) {
  const list = el.attrsList
  let i, l, name, value
  for (i = 0, l = list.length; i < l; i++) {
    name = list[i].name
    value = list[i].value
    if (onRE.test(name)) {
      // 移除 @ 或 v-on: 前缀
      name = name.replace(onRE, '')
      // 将事件处理函数添加到元素的 events 中
      if (!el.events) {
        el.events = {}
      }
      el.events[name] = value
      // 从属性列表中移除该属性
      list.splice(i, 1)
      i--
      l--
    }
  }
}

/**
 * 解析字符串,并传入开始和结束标签处理函数
 */
const ast = parse(str, {
  /**
   * 传入的开始标签处理函数,新增了单标签标志参数
   * @param tag 标签名
   * @param unary 单标签标志
   */
  start(tag, attrs, unary, start, end) {
    // 遇到开始标签创建节点
    let element = {
      type: 1, // type=1表示是元素节点
      tag,
      attrsList: attrs,
      attrsMap: makeAttrsMap(attrs),
      children: []
    }
    processFor(element)
    processIf(element)

    processBindings(element) // 处理 v-bind
    processEvents(element) // 处理事件
    // 如果还没有根节点,将当前节点设为根节点
    if (!root) {
      root = element
    }
    // 如果不是单标签,把当前节点作为父节点,后面遇到的节点都是其子节点
    if (!unary) {
      currentParent = element
      // 把节点压入栈
      stack.push(element)
    } else {
      // 单标签不需要加入栈,因为没有子节点
      closeElement(element)
    }
  },

  // 处理结束标签
  end() {
    // 获取对应的开始标签节点(栈顶元素)
    const element = stack[stack.length - 1]
    // 弹出栈顶元素
    stack.length -= 1
    // 更新当前父节点为新的栈顶元素
    currentParent = stack[stack.length - 1]
    // 建立节点间的父子关系
    closeElement(element)
  },
  /**
   * 文本节点处理函数
   */
  chars(text, start, end) {
    if (!currentParent) {
      // text节点不能做根节点 必须存在父节点
      console.warn('text节点不能做根节点 必须存在父节点')
      return
    }
    // 获取当前父节点的子节点数组
    const children = currentParent.children
    // 去除文本前后的空白字符
    text = text.trim()
    // 如果去除空白后文本为空,则将其设置为一个空格
    // 这是为了保持节点的占位作用
    if (!text) {
      text = ' '
    }
    let res, child
    // 如果文本不是单个空格,尝试解析其中的插值表达式
    if (text !== ' ' && (res = parseText(text))) {
      // 如果成功解析出插值表达式,创建一个包含表达式信息的子节点
      child = {
        type: 2, // type=2 表示表达式节点
        expression: res.expression,
        tokens: res.tokens,
        text
      }
    }
    // 如果只有一个文本节点,空格将会被保留占位,如果有多个文本节点,则不会保留空格
    else if (
      text !== ' ' || // 如果文本不是单个空格
      !children.length || // 当前没有子节点
      children[children.length - 1].text !== ' ' // 最后一个子节点的文本不是单个空格
    ) {
      // 创建一个纯文本的子节点
      child = {
        type: 3, // type=3 表示文本节点
        text
      }
    }
    // 如果创建了子节点(无论是包含表达式的还是纯文本的)
    // 将其添加到子节点数组中

    if (child) {
      children.push(child)
    }
  }
})

console.dir(ast, {
  depth: null
})

待续未完,生成代码部分会尽快发出。

有任何问题都可以在评论区留言,感谢阅读 :D

相关推荐
光影少年14 分钟前
vue3.0性能提升主要通过那几方面体现的?
前端·vue.js
小磊哥er27 分钟前
【前端工程化】前端开发中的这些设计规范你知道吗
前端
江城开朗的豌豆27 分钟前
路由守卫:你的Vue路由‘保安’,全局把关还是局部盯梢?
前端·javascript·vue.js
Jinxiansen021135 分钟前
Vue 3 响应式核心源码详解(基于 @vue/reactivity)
前端·javascript·vue.js
崎岖Qiu1 小时前
【Spring篇08】:理解自动装配,从spring.factories到.imports剖析
java·spring boot·后端·spring·面试·java-ee
OEC小胖胖5 小时前
去中心化身份:2025年Web3身份验证系统开发实践
前端·web3·去中心化·区块链
Cacciatore->6 小时前
Electron 快速上手
javascript·arcgis·electron
vvilkim6 小时前
Electron 进程间通信(IPC)深度优化指南
前端·javascript·electron
某公司摸鱼前端7 小时前
ES13(ES2022)新特性整理
javascript·ecmascript·es13
心平愈三千疾8 小时前
通俗理解JVM细节-面试篇
java·jvm·数据库·面试