模板引擎实现原理(Vue篇)

4,546 阅读4分钟

在 Vue 框架里面内置了一个模板引擎,用于编译 Vue 专有语法,例如:

<div id="app">
  你好,{{ message }}!
  <p v-if="seen" styles="color: red; fontSize: 16px">条件渲染</p>
  <button v-on:click="reverseMessage">反转消息</button>
  <ol>
    <li v-for="todo in todos" class="color-gray ml-2">
      {{ todo.text }}
    </li>
  </ol>
</div>

这里面有 {{ message }}v-ifv-on:clickv-for 等特殊的语法,Vue 需要把这些内容提取出来,转换成响应式的函数或者对应的 DOM 事件。

在 Vue 中同样是用正则来提取这些内容的,它的转化流程如下:

  • 通过正则把模板转换成 AST 抽象语法树
  • 用 AST 生成 JS 代码
  • new Function 配合 with 来执行 JS 代码

AST 抽象语法树

AST 中的节点是具有特殊属性的 JS 对象,它的结构大致如下:

{
  tag: tagName, // 标签名
  type: 1, // 元素类型
  children: [], // 孩子列表
  attrs, // 属性集合
  parent: null, // 父元素
  text: null // 文本节点内容
  ...
}

在 AST 抽象语法树中,会按照节点类型的不同进行区分:

  • 元素类型
  • 文本类型
  • 注释类型
  • ...

正则分析

接下来开始用正则对节点进行提取,先看下 Vue 中定义的正则:

const ncname = `[a-zA-Z_][\\-\\.0-9_a-zA-Z]*`; // 标签名
const qnameCapture = `((?:${ncname}\\:)?${ncname})`; // ?: 表示匹配不捕获
const startTagOpen = new RegExp(`^<${qnameCapture}`); // 标签开头的正则 捕获的内容是标签名
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`); // 匹配标签结尾的 </div>
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/; // 匹配属性
const startTagClose = /^\s*(\/?)>/; // 匹配标签结束的 > 

ncname

ncname 就是不包含前缀的XML标签名称,规则如下:

字母(a-zA-Z)或下划线(_)开头,后面可以跟任意数量的:

  • 中横线(-)
  • 点(.)
  • 数字(0-9)
  • 下划线(_)
  • 字母(a-zA-Z)

qname 和 qnameCapture

qname 是合法的 XML 标签,它的组成规则是 <前缀:标签名称>,例如:<abc:span></abc:span>,其中前缀可以省略,也就是说,可能是一个 ncname,或者两个 ncname 中间通过冒号拼接起来。

这个正则中冒号和冒号前面的部分是一个非捕获分组,后面的标签名是捕获分组,即可以取到标签名称。

startTagOpen

匹配开始标签,例如 <div<abc:span

endTag

来匹配结束标签

attribute

/^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<>`]+)))?/

这个正则比较长,是用于匹配 HTML 标签属性的,可能的属性写法有:

  • 双引号:class="some-class"
  • 单引号:class='some-class'
  • 不用引号:class=some-class
  • 单独的属性名:disabled

这个表达式有五个捕获组,第一个捕获组用来匹配属性名,第二个捕获组用来匹配等于号,第三、第四、第五个捕获组都是用来匹配属性值的,同时 ? 表明第三、四、五个分组是可选的。

startTagClose

用于匹配结束标签,例如:br />/div>

解析器

利用上面的正则,可以写出下面简化版 AST 解析器:

function parseHTML(html) {
  let root, parent, stack = []
  // 只要剩余的 html 不为空就一直解析
  while (html) {
    let textEnd = html.indexOf('<')
    if (textEnd == 0) {
      const { tag, attrs } = parseStartTag() || {}
      if (tag) {
        start(tag, attrs)
        continue
      }
      const endTagMatch = html.match(endTag)
      if (endTag) {
        advance(endTagMatch[0].length)
        end(endTagMatch[1])
        continue
      }
    } else {
      const text = textEnd > 0 ? html.substring(0, textEnd) : html
      advance(text.length)
      chars(text)
    }
  }
  // 获取截取后剩余的html
  function advance(n) {
    html = html.substring(n)
  }
  // 解析开始标签
  function parseStartTag() {
    const start = html.match(startTagOpen)
    if (start) {
      const match = { tag: start[1], attrs: [] }
      advance(start[0].length)
      let end, attr
      while (!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
        match.attrs.push({
          name: attr[1],
          value: attr[3] || attr[4] || attr[5],
        })
        advance(attr[0].length)
      }
      if (end) {
        advance(end[0].length)
        return match
      }
    }
  }
  // 解析到开始标签时触发
  function start(tag, attrs) {
    const el = createASTElement(tag, attrs)
    if (!root) root = el
    stack.push((parent = el))
    processFor(el) // 处理 v-for
    processIf(el) // 处理 v-if
    processAttrs(el) // 处理 v-on、v-show、v-bind 等
  }
  // 解析到结束标签时触发
  function end(tag) {
    const el = stack.pop()
    parent = stack[stack.length - 1]
    if (parent) {
      el.parent = parent
      parent.children.push(el)
    }
  }
  // 解析到文本时触发
  function chars(text) {
    text = text.trim()
    if (!text) return
    const el = { type: 3, text }
    if (parent) {
      parent.children.push(el)
      el.parent = parent
    }
  }
  return root
}
// 创建元素节点
function createASTElement(tag, attrs, parent) {
  return {
    type: 1,
    tag,
    attrsList: attrs,
    attrsMap: makeAttrsMap(attrs),
    parent,
    children: [],
  }
}
// 把数组类型的属性转换为对象
function makeAttrsMap(attrs) {
  const map = {}
  attrs.forEach((it) => (map[it.name] = it.value))
  return map
}
// 获取并删除数组中的某个属性
function getAndRemoveAttr(el, name) {
  let val
  if ((val = el.attrsMap[name]) != null) {
    const list = el.attrsList
    for (let i = list.length - 1; i >= 0; i--) {
      if (list[i].name === name) {
        list.splice(i, 1)
        break
      }
    }
  }
  return val
}
// 处理v-for
function processFor(el) {
  let exp
  if ((exp = getAndRemoveAttr(el, 'v-for'))) {
    const inMatch = exp.match(/([\s\S]*?)\s+(?:in|of)\s+([\s\S]*)/)
    if (!inMatch) return
    Object.assign(el, {
      alias: inMatch[1].trim(),
      for: inMatch[2].trim(),
    })
  }
}
// 处理v-if
function processIf(el) {
  const exp = getAndRemoveAttr(el, 'v-if')
  if (exp) el.if = exp
}
// 处理各种属性,这里以v-on为例
function processAttrs(el) {
  const list = el.attrsList,
    onRE = /^@|^v-on:/
  let i, l, name, rawName, value
  for (i = 0, l = list.length; i < l; i++) {
    name = rawName = list[i].name
    value = list[i].value
    if (onRE.test(name)) {
      name = name.replace(onRE, '')
      el.events = { [name]: value }
      list.splice(i, 1)
      break
    }
  }
}

对文章开头的示例模板运行这段代码,可以得到如下的 AST 树:

{
  type: 1,
  tag: 'div',
  attrsList: [ { name: 'id', value: 'app' } ],
  attrsMap: { id: 'app' },
  children: [
    { type: 3, text: '你好,{{ message }}!' },
    {
      type: 1,
      tag: 'p',
      attrsList: [ { name: 'style', value: 'color: red; fontSize: 16px' } ],
      attrsMap: { 'v-if': 'seen', style: 'color: red; fontSize: 16px' },
      children: [ { type: 3, text: '条件渲染' } ],
      if: 'seen'
    },
    {
      type: 1,
      tag: 'button',
      attrsList: [],
      attrsMap: { 'v-on:click': 'reverseMessage' },
      children: [ { type: 3, text: '反转消息' } ],
      events: { click: 'reverseMessage' }
    },
    {
      type: 1,
      tag: 'ol',
      attrsList: [],
      attrsMap: {},
      children: [
        {
          type: 1,
          tag: 'li',
          attrsList: [ { name: 'class', value: 'color-gray ml-2' } ],
          attrsMap: { 'v-for': 'todo in todos', class: 'color-gray ml-2' },
          children: [ { type: 3, text: '{{ todo.text }}' } ],
          alias: 'todo',
          for: 'todos'
        }
      ]
    }
  ]
}

注意这里并没有对注释节点等进行解析,只处理了元素节点和文本节点。

生成器

有了 AST 之后,就需要将其组装成代码了,本质上就是拼接代码字符串,用 new Functionwith 进行处理。所以接下来要写一个函数来处理上面的 AST 树:

function generate(node) {
  return node.type === 1 ? genElement(node) : genText(node.text)
}

同样这里只考虑元素节点和文本节点两种情况。

生成元素节点代码

对于元素节点,要拼成 _c(tag, data, childNodes) 函数,

function genElement(el) {
  const { tag, attrsList, children } = el
  const childNodes = children.map((child) => generate(child))
  if (el.for && !el.forProcessed) {
    el.forProcessed = true
    return (
      `_l((${el.for}),` +
      `function(${el.alias}){` +
      `return ${genElement(el)}` +
      '})'
    )
  } else if (el.if && !el.ifProcessed) {
    el.ifProcessed = true
    return `${el.if} ? ${genElement(el)}: _e('')`
  }
  return `_c('${tag}',${genAttrs(attrsList)},${childNodes})`
}

处理元素节点上的属性

下面的代码用于处理属性:

function genAttrs(attrs) {
  const obj = {}
  for (let i = 0; i < attrs.length; i++) {
    let attr = attrs[i]
    if (attr.name === 'style') {
      const kv = {} // 对样式进行特殊的处理
      attr.value.split(';').forEach((item) => {
        let [key, value] = item.split(':')
        kv[key.trim()] = value.trim()
      })
      attr.value = kv
    }
    obj[attr.name] = attr.value
  }
  return JSON.stringify(obj)
}

生成文本节点代码

function genText(text) {
  const defaultTagRE = /\{\{((?:.|\r?\n)+?)\}\}/g
  if (!defaultTagRE.test(text)) {
    return `_v(${JSON.stringify(text)})`
  }
  let tokens = []
  let lastIndex = (defaultTagRE.lastIndex = 0)
  let match, index
  while ((match = defaultTagRE.exec(text))) {
    index = match.index
    if (index > lastIndex) {
      tokens.push(JSON.stringify(text.slice(lastIndex, index)))
    }
    tokens.push(`_s(${match[1].trim()})`)
    lastIndex = index + match[0].length
  }
  if (lastIndex < text.length) {
    tokens.push(JSON.stringify(text.slice(lastIndex)))
  }
  return `_v(${tokens.join('+')})`
}

代码生成结果

将 ast 带入函数得到代码字符串为:

_c(
  'div',
  { id: 'app' },
  _v('你好,' + _s(message) + '!'),
  seen
    ? _c('p', { style: { color: 'red', fontSize: '16px' } }, _v('条件渲染'))
    : _e(''),
  _c('button', {}, _v('反转消息')),
  _c(
    'ol',
    {},
    _l(todos, function (todo) {
      return _c('li', { class: 'color-gray ml-2' }, _v(_s(todo.text)))
    })
  )
)

虚拟 DOM

有了代码字符串之后,就可以带入环境变量来生成虚拟 DOM 了,下面是生成虚拟 DOM 用到的一些辅助函数:

function _c(tag, data, ...children) {
  return { tag, data, children: children.flat() }
}
function _v(text) {
  return { text }
}

function _s(val) {
  if (val == null) return ''
  if (typeof val == 'object') return JSON.stringify(val, null, 2)
  return String(val)
}

function _l(val, render) {
  const ret = new Array(val.length)
  for (i = 0, l = val.length; i < l; i++) {
    ret[i] = render(val[i], i)
  }
  return ret
}

function _e(text) {
  return { text, isComment: true }
}

function createVdom(vm, code) {
  const f = new Function('vm', `with(vm){return ${code}}`)
  return f({ ...vm, _c, _s, _v, _l, _e })
}

如果 vm 用下面的变量带入:

{
  message: '消息',
  seen: false,
  todos: [{ text: 'study' }, { text: 'reading' }],
}

会得到虚拟 DOM:

{
  tag: 'div',
  data: { id: 'app' },
  children: [
    { text: '你好,消息!' },
    { text: '', isComment: true },
    { tag: 'button', data: {}, children: [ { text: '反转消息' } ] },
    {
      tag: 'ol',
      data: {},
      children: [
        {
          tag: 'li',
          data: { class: 'color-gray ml-2' },
          children: [ { text: 'study' } ]
        },
        {
          tag: 'li',
          data: { class: 'color-gray ml-2' },
          children: [ { text: 'reading' } ]
        }
      ]
    }
  ]
}

如果 vm 换成下面的环境:

{
  message: '水果',
  seen: true,
  todos: [{ text: '香蕉' }, { text: '苹果' }, { text: '西瓜' }],
}

则可以生成另一种 DOM 结构:

{
  tag: 'div',
  data: { id: 'app' },
  children: [
    { text: '你好,水果!' },
    {
      tag: 'p',
      data: { style: { color: 'red', fontSize: '16px' } },
      children: [ { text: '条件渲染' } ]
    },
    { tag: 'button', data: {}, children: [ { text: '反转消息' } ] },
    {
      tag: 'ol',
      data: {},
      children: [
        {
          tag: 'li',
          data: { class: 'color-gray ml-2' },
          children: [ { text: '香蕉' } ]
        },
        {
          tag: 'li',
          data: { class: 'color-gray ml-2' },
          children: [ { text: '苹果' } ]
        },
        {
          tag: 'li',
          data: { class: 'color-gray ml-2' },
          children: [ { text: '西瓜' } ]
        }
      ]
    }
  ]
}

由于 vue 的数据是响应式的,数据改变会触发页面渲染,而页面渲染的逻辑就是新旧虚拟 DOM 利用 patch 算法进行比较得到差异,最终更新真实 DOM。