模板编译

本文共--字 阅读约--分钟 | 浏览: -- Last Updated: 2022-02-19

在底层实现上,Vue会将模板编译成虚拟DOM渲染函数。当应用状态发生变化时,Vue就会结合响应式系统,聪明地找出最小数量的组件进行重新渲染以及最少量地进行DOM操作。

将模板编译成渲染函数

模板编译的主要目的就是生产渲染函数,而渲染函数的作用是每次执行它,它就会使用当前最新的状态生成一份新的 vnode,然后使用这个 vnode 进行渲染。

模板编译成渲染函数,先将模板解析成 AST (Abstract Syntax Tree, 抽象语法树),然后再使用 AST 生成渲染函数。

但由于静态节点不需要总是重新渲染,所以在生成AST之后,生成渲染函数之前这个阶段,还需要进优化,给所有的静态节点做一个标记,这样虚拟DOM中更新节点时,发现节点有这个标记就不会去重新渲染它。

所以,模板编译分三部分内容:

  • 将模板解析为AST => 解析器
  • 遍历AST标记静态节点 => 优化器
  • 使用AST生成渲染函数 => 代码生成器

解析器

在解析器内部,分成了很多小解析器,其中包括过滤器解析器、文本解析器和 HTML 解析器,然后通过一条主线将这些解析器组装在一起。

主线上做的事就是监听HTML解析器,每当触发钩子函数时(每当解析到HTML标签的开始位置、结束位置、文本或注释时,都会触发钩子函数),就生成对应的AST节点,在生成AST之前,会根据类型使用不同的方式生成不同的AST,这个AST与vnode有点类似,都是使用 JavaScript 中的对象来表示节点。

// 伪代码

parseHTML(template, {
  start(tag, attrs, unary){
    // 每当解析到开始标签位置(<div>)的时候 触发该函数
    // 构建元素类型的节点
  },
  end(){
    // 每当解析到标签的结束位置(</div>)时,触发该函数
  },
  chars(text){
    // 每当解析到文本时,触发该函数
    // 构建文本类型的节点
  },
  comment(text){
    // 每当解析到注释是,触发该函数
    // 构建注释类型的节点
  },
})

start 钩子函数中的,三个参数分别是 标签名、标签属性、是否是自闭合标签(单标签),我们可以使用这三个参数来构建一个元素类型的 AST 节点:

parseHTML(template, {
  start(tag, attrs, unary){
    // currentParent 用来记录当前节点的父节点是?
    let element = createASTElement(tag, attrs, currentParent)
  },
}

function createASTElement(tag, attrs, parent) {
  return {
    type: 1, // 节点的类型 1代表元素节点 3代表文本节点
    tag,
    attrsList: attr,
    parent,
    children: [],
  } // 表示AST的节点对象
}

创建文本类型的AST节点:

parseHTML(template, {
  chars(text){
    let element = { type: 3, text }
  },
}

创建注释节点的AST节点:

parseHTML(template, {
  comment(text) {
    let element = { type: 3, text, isComment: true }
  }
})

一个AST节点具有父节点和子节点,所以我们需要一套逻辑来实现层级关系,让每一个AST节点都能找到它的父级。

构建AST层级关系也非常简单,我们只需要维护一个栈即可,用栈来记录层级关系,也可以理解为DOM的深度。HTML解析器是从前往后解析的,每当遇到开始标签就会触发钩子函数 start,此时我们将当前需要构建的节点推入栈中,每当遇到结束标签时触发钩子函数 end 时,就从栈中弹出一个节点,这样就可以保证每当触发钩子函数 start的时候,栈的最后一个节点就是当前正在构建节点的父节点。

<div>
  <p>我是p</p>
  <button>我是button</button>
</div>

针对以上一个DOM,流程大致如下:

  • 会先触发divstart钩子,将div推入栈中
  • 接着解析,会触发divchars钩子(因为divp之间的空格会被当做文本触发文本钩子,在钩子函数会忽略这些空格)
  • 然后触发pstart钩子,此时栈中最后一个是div,所以divp的父元素,然后将p推入栈中,栈结构变成 [div, p]
  • 之后触发pchars钩子、接着end钩子,将p从栈中弹出,此时的栈中仍然就只有div
  • 触发buttonstart钩子,此时的栈中的最后一个是div,所以div也是button的父元素,将button推入栈中,栈结构变成[div, button]

HTML解析器

事实上,解析HTML模板的过程就是一个循环的过程,简单来说就是用 HTML模板字符串 来循环,每轮循环都从HTML模板中截取一小段字符串,然后重复以上过程,直到HTML模板被截成一个空字符串时结束循环,解析完毕。

每触发一个钩子都会将模板中的某部分截取掉,当整个模板被截取空了,说明HTML解析器已经解析完毕。

`
<div>
  <p>我是p</p>
  <button>我是button</button>
</div>
`

// 遇到开始标签并触发start处理后
` 
  <p>我是p</p>
  <button>我是button</button>
</div>
`

// 遇到空格触发chars处理后

`<p>我是p</p>
  <button>我是button</button>
</div>
`

// 遇到开始标签

`我是p</p>
  <button>我是button</button>
</div>
`
// ...

``

1、截取开始标签

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:?${ncname}))`
const startTagOpen = new RegExp(`^<${qnameCapture}`)

'<div></div>'.match(startTagOpen) // [ '<div', 'div', index: 0, input: '<div></div>', groups: undefined ]

'</div><div>hello world</div>'.match(startTagOpen) // null

'我是文本<div></div>'.match(startTagOpen) // null

当完成上面的解析时,我们可以得到这样一个数据结构:

const start = '<div></div>'.match(startTagOpen)
if (start) {
  const match = {
    tagName: start[1],
    attrs: []
  }
}

事实上,上面的匹配,只能匹配开始标签的一小部分 <div,连标签都不全,这是因为开始标签会被分为3部分,分别是标签名、属性和结尾。

解析标签属性的代码如下:

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

let html = ' class="box" id="el"></div>'
let end, attr;
const match = { tagName: 'div', attrs: [] }

while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
  html = html.substring(attr[0].length);
  match.attrs.push(attr)
}

console.log(match)
// {
//   tagName: 'div',
//   attrs: [
//     [
//       ' class="box"',
//       'class',
//       '=',
//       'box',
//       undefined,
//       undefined,
//       index: 0,
//       input: ' class="box" id="el"></div>',
//       groups: undefined
//     ],
//     [
//       ' id="el"',
//       'id',
//       '=',
//       'el',
//       undefined,
//       undefined,
//       index: 0,
//       input: ' id="el"></div>',
//       groups: undefined
//     ]
//   ]
// }

这样就能将标签上的所有属性解析出来了。

针对自闭合标签,又是另外一套逻辑,因为自闭合标签是没有子节点的,前文中提到构建AST层级时需要维护的一个栈,而一个节点是否需要推入栈中,可以使用这个自闭合标识来判断。

// 整个截取开始标签的逻辑
const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:?${ncname}))`
const startTagOpen = new RegExp(`^<${qnameCapture}`)
const startTagClose = /^\s*(\/?)>/
const attribute = /^\s*([^\s"'<>\/=]+)(?:\s*(=)\s*(?:"([^"]*)"+|'([^']*)'+|([^\s"'=<div>`]+)))?/


function advance(n) {
  html = html.substring(n) // 截取
}

function parseStartTag() { // 解析开始标签
  const start = html.match(startTagOpen)
  if (start) { // 是开始标签
    const match = {
      tagName: start[1],
      attrs: []
    }
    advance(start[0].length); // 截掉开始标签的前部分即 <div

    // 解析标签属性
    let end, attr;
    while(!(end = html.match(startTagClose)) && (attr = html.match(attribute))) {
      advance(attr[0].length);
      match.attrs.push(attr)
    }

    // 判断是否是自闭合标签
    if (end) {
      match.unarySlash = end[1] // 是闭合标签,unarySlash就有值为 /
      advance(end[0].length)
      return match;
    }
  }
}

let html = '<div class="box" id="el"></div>'
let result = parseStartTag();

console.log(result); //  { tagName: 'div', attrs: [...], unarySlash: '' }
console.log(html); // </div>  截完所有的开始标签部分

html = '<input class="name" id="el2" />'
result = parseStartTag();

console.log(result); //  { tagName: 'input', attrs: [...], unarySlash: '/' } 
console.log(html); // '' 自闭合标签只需要解析 开始标签就可以解析完毕

调用 parseStartTag 可以将模板开始部分的开始标签解析出来,如果模板不符合开始标签的正则表达式,parseStartTag 则返回 undefined,因此,如果调用得到了结果,那么说明符合,将解析出来的结果取出来并调用 start 钩子即可。

const startTagMatch = parseStartTag();
if (startTagMatch) {
  handleStartTag(startTagMatch)
  continue
}

// handleStartTag 就是将 startTagMatch 中的 tagName attrs unarySlash 处理为 tag attrs unary 传递start钩子

2、截取结束标签

结束标签的截取要比开始标签简单的多,因为它不需要解析什么,只需要分辨当前是否已经截取到结束标签,如果是,截掉这部分,触发end钩子即可

const ncname = '[a-zA-Z_][\\w\\-\\.]*'
const qnameCapture = `((?:${ncname}\\:?${ncname}))`
const endTag = new RegExp(`^<\\/${qnameCapture}[^>]*>`)

// '</div>'.match(endTag) // [ '</div>', 'div', index: 0, input: '</div>', groups: undefined ]
// '<div>'.match(endTag)  // null

const endTagMatch = html.match(endTag)
if (endTagMatch) {
  html = html.substring(endTagMatch[0].length)
  options.end(endTagMatch[1]) // 触发钩子
  continue
}

3、截取注释

const comment = /^<!--/

if (comment.test.html) {
  const commentEnd = html.indexOf('-->')

  if (commentEnd >= 0) {
    if (options.shouldKeepComment) { // 只有这个配置为true时才触发钩子函数,将注释的内容传递过去
      options.comment(html.substring(4, commentEnd))
    }

    // 否则直接截取调所有的注释部分
    html = html.substring(commentEnd+3)
    continue
  }
}

4、截取条件注释

条件注释不需要触发钩子函数,只需要截取掉就可以了

let html = '<![if !IE]><link href ="non-ie.css" rel="stylesheet"><![endif]>'

const conditionalComment = /^<!\[/

if (conditionalComment.test(html)) {
  const conditionalCommentEnd = html.indexOf(']>')
  
  if (conditionalCommentEnd >= 0) {
    html = html.substring(conditionalCommentEnd+2)
    continue
  }
}

console.log(html); // <link href ="non-ie.css" rel="stylesheet"><![endif]>
// 继续解析会将link当做标签进行处理然后截掉
// <![endif]> 又符合 conditionalComment 则也会直接被截掉

所以在Vue模板中条件注释是没有意义的,写了也白写,因为不会判断条件,但会处理条件注释的内容,即<link href ="non-ie.css" rel="stylesheet">必然会出现在模板中。

5、截取DOCTYPE

DOCTYPE与条件注释相同,也不需要触发钩子函数,只将匹配到的截取掉就可以了。

// <!DOCTYPE html>
const doctype = /^<!DOCTYPE [^>]+>/i
const doctypeMatch = html.match(doctype)

if (doctypeMatch) {
  html = html.substring(doctypeMatch[0].length)
  continue
}

6、截取文本

在HTML模板中,开始标签、结束标签和注释等,都是 < 开头,而当剩余模板不是 < 开头时,那么它一定就是文本的,例如剩余模板会长这样文本</div>

// 处理文本
while(html) {
  let text, rest, next;
  let textEnd = html.indexOf('<')

  if (textEnd > 0) {
    rest = html.slice(textEnd);

    // 针对文本中有 < 的情况
    while(
      !endTag.test(rest) &&
      !startTagOpen.test(rest) && 
      !comment.test(rest) &&
      !conditionalComment.test(rest)
    ) {
      // 如果满足以上判断 则将 < 视为纯文本
      next = rest.indexOf('<', 1) // 是否还有 <
      if (next < 0 ) break; // 跳出循环
      textEnd += next
      rest = html.slice(textEnd) // 继续截取判断,直到 < 是属于开始标签、结束标签、注释、条件注释的一部分,才不将其当做文本处理
    }

    text = html.substring(0, textEnd) // 截取文本内容
    html = html.substring(textEnd) // 截掉整个文本
  }

  if (textEnd <  0) { // 如果找不到 < 说明整个模板都是文本
    text = html;
    html = '';  
  }

  if (options.chars && text) {
    options.chars(text) // 触发钩子函数
  }
}

7、纯文本内容元素

什么是纯文本内容元素,scriptstyletextarea 这三种元素叫作纯文本内容元素,前面介绍的开始标签、结束标签、文本、注释的截取时,都是默认当前要截取的元素的父级元素不是纯文本内容元素,事实上,是处在一个判断的:

// html  = '<div><script>console.log(1)</script></div>'
while(html) {
  // lastTag 标识父元素  isPlainTextElement简单的关于script style textarea的判断
  if (!lastTag || !isPlainTextElement(lastTag)) {
    // 父元素为正常元素的处理
  } else {
    // 父元素为纯文本内容元素的处理  // 此时html = 'console.log(1)</script></div>' 开始标签会被处理
    const stackedTag = lastTag.toLowercase();
    const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'))

    const rest = html.replace(reStackedTag, function (all, text) {
      // 此时的text就是结束标签前的所有内容 text = 'console.log(1)'
      if (options.chars) {
        options.chars(text)  
      }
      return ''; // 返回空字符则rest,就是指将匹配到的结束标签的所有内容截掉了
    })
    html = rest // html = '</div>'
    options.end(stackedTag) // stackedTag = 'script'
  }
}

8、整体逻辑

首先,HTML解析器是一个函数,有两个参数,模板和选项,一点点的解析模板然后根据情况触发对应的钩子。

export function parseHTML(html, options) {
  while(html) {
    if (!lastTag || !isPlainTextElement(lastTag)) {
      let textEnd = html.indexOf('<')
      if (textEnd === 0) {
        if (comment.test(html)) {
          // 注释的处理
          continue
        }

        if (conditionalComment.test(html)) {
          // 条件注释的处理
          continue
        }
        
        const doctypeMatch = html.match(doctype)
        if (doctypeMatch) {
          // DOCTYPE的处理
          continue
        }

        const endTagMatch = html.match(endTag)
        if (endTagMatch) {
          // 结束标签的处理
          continue
        }

        const startTagMatch = parseStartTag()
        if (startTagMatch) {
          // 开始标签的处理
          continue
        }
      }

      let text, rest, next;
      if (textEnd >= 0) {
        // 解析文本
      }

      if (textEnd < 0) {
        text = html
        html = '';
      }

      if (options.chars && text) {
        options.chars(text)
      }

    } else {
      // 纯文本内容元素的处理
    }
  }
}

文本解析器

文本解析器就是解析文本,是对HTML解析器中的出来的文本进行二次加工,因为文本存在两种情况,一种是纯文本,另一种是带双花括号的变量(例, hello {{name}})的文本。

每当HTML解析器解析到文本时,都会触发 chars 钩子,并且从参数中得到文本,在 chars 函数中,我们需要构建文本类型的 AST,并将它添加到父节点的 children 属性中。

所以,在这里会对纯文本和带变量的文本分别处理。

parseHTML(template, {
  chars(text) {
    text = text.trim() // 去除头尾的空字符串
    if (text) {
      const children = currentParent.children
      let expression // 存储变量表达式
      if (expression = parseText(text)) {
        children.push({
          type: 2,
          expression,
          text
        })
      } else {
        children.push({
          type: 3,
          text
        })
      }
    }
  }
})

即如果执行 parseText() 有结果,则说明文本是带变量的文本,并且已经通过文本解析器parseText的二次加工了,此时会构建一个带变量的文本类型的AST并将其添加到父节点的children属性中。

function parseText(text) {
  const tagRE = /\{\{((?:.|\r?\n)+?)\}\}/g;
  if (!tagRE.test(text)) { // 没有匹配到双花括号 即文本中不含有变量
    return;
  }

  const tokens = []
  let lastIndex = tagRE.lastIndex = 0;
  let match, index;

  while ((match = tagRE.exec(text))) {
    // console.log(match); // [ '{{name}}', 'name', index: 2, input: '你好{{name}}', groups: undefined]
    index = match.index;
    // 先把 {{ 前边的文本添加到tokens中
    if (index > lastIndex) {
      tokens.push(JSON.stringify(text.slice(lastIndex, index)))
    }

    // 把变量变成 _s(variableName)  _s是用来将变量值转换为字符串的函数
    tokens.push(`_s(${match[1].trim()})`) // match[1] 就是双花括号内部的值

    // 设置 lastIndex 来保证下一次循环
    lastIndex = index + match[0].length
  }

  // 当所有的变量处理完成 如果最后一个变量右边还有文本 就将文本添加到数组中
  if (lastIndex < text.length) {
    tokens.push(JSON.stringify(text.slice(lastIndex)))
  }

  return tokens.join('+')
}

console.log(parseText('你好{{name}}'));  // '"你好"+_s(name)'
console.log(parseText('你好 jack')); // undefined 
console.log(parseText('你好{{name}},你今年已经 {{ age }} 岁了')); // '"你好"+_s(name)+",你今年已经 "+_s(age)+" 岁了"'

最终的使用环境会是这样

var obj = { name: 'jack' , age: 22}
with(obj) { // 处于 obj的作用域下
  function _s (val) {
    return val == null ? '' : (typeof val === 'object' ? JSON.stringify(val, null, 2) : String(val))
  }

  const expression = '"你好"+_s(name)+",你今年已经 "+_s(age)+" 岁了"'
  console.log(eval(expression)) // 你好jack,你今年已经 22 岁了
  console.log("你好" + _s(name)) // 你好jack
}

总结

解析器的作用是通过模板得到 AST (抽象语法树),生成AST的过程需要借助HTML解析器,当HTML解析器触发不同的钩子函数时,我们可以构建出不同的节点。随后,可以通过栈来得到当前正在构建的节点的父节点,然后将构建出的节点添加到父节点的下面,最终解析完毕后,就可以得到一个完整的带DOM层级关系的AST。

HTML解析器的内部原理是一小段一小段地截取模板字符串,每截取一小段就会根据截取出来的的字符串类型触发不同的钩子,直到模板字符串截空停止运行。

优化器

优化器会遍历AST,检测出所有的静态子树并给其打标记,标记静态子树有两点好处:

  • 每次重新渲染时,不需要为静态子树创建新节点
  • 在虚拟DOM中打补丁 patching 的过程可以跳过

而优化器内部实现主要分为两个部分,遍历AST找出所有的静态节点打上标记、遍历AST找出所有的静态根节点并打上标记。

静态根节点是指一个节点下所有子节点都是静态节点,并且它的父级是动态节点,那么它就是静态根节点。

静态节点在AST的对应属性为 { static: true }, 而静态根节点的对应属性为 { staticRoot: true }

比如,<div id="el">hello {{name}}</div>,这个模板转化为AST就是下面这个样子:

let AST = {
  type: 1,
  tag: 'div',
  attrsList: [
    {
      name: 'id',
      value: 'el'
    }
  ],
  attrsMap: {
    'id': 'el'
  },
  children: [
    {
      type: 2,
      expression: '"hello"+_s(name)',
      text: 'hello {{name}}'
    }
  ],
  plain: false,
  attrs: [
    {
      name: 'id',
      value: 'el'
    }
  ]
}

// 经过器优化之后

AST = {
  ...,
  children: [
    {
      type: 2,
      expression: '"hello"+_s(name)',
      text: 'hello {{name}}',
      static: false,
    }
  ],
  ...,
  static: false,
  staticRoot: false
}

而优化器是如何处理的呢?

export function optimize(root) {
  if (!root) return
  markStatic(root)
  markStaticRoots(root)
}

function markStatic(node) {
  node.static = isStatic(node)
  if (node.type === 1) { // 元素节点进行递归
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i];
      markStatic(child)

      if (!child.static) { // 如果子节点不是静态节点,那么它的父节点也不可能是静态节点,此时进行修正
        node.static = false
      }
    }
  }
}

function markStaticRoots(node) {
  if (node.type === 1) {
    // 要使这个节点符合静态根节点的操作,它必须要有子节点
    // 并要求子节点不能是一个只有静态文本的子节点
    if (node.static && node.children.length && !(node.children.length === 1 && node.children[0].type === 3)) {
      // 是只有一个静态文本的子节点,则说明是静态根节点,结束判断
      node.staticRoot = true
      return 
    } else {
      // 否则,则不是,继续判断
      node.staticRoot = false
    }

    if (node.children) {
      for (let i = 0, l = node.children.length; i < l; i++) {
        markStaticRoots(node.children[i]);
      }
    }
  }
}

function isStatic(node) {
  if (node.type === 2) {
    return false; // 带变量的动态文本节点
  }

  if (node.type === 3) {
    return true;  // 不带变量的纯文本节点
  }

  return !!(node.pre || ( // 使用v-pre则直接断定为一个静态标签
    !node.hasBindings && // 没有动态绑定
    !node.if && node.for && // 没有使用v-if 或 v-for 或 v-else
    !isBuiltInTag(node.tag) && // 不是内置标签
    isPlatformReservedTag(node.tag) && // 不是组件,即标签名是HTML保留标签和svg保留标签
    !isDirectChildOfTemplateFor(node) && // 当前节点的父节点不能带v-for指令的template标签 
    Object.keys(node).every(isStaticKey) // 不存在动态属性
  ))
}

代码生成器

将AST转换成渲染函数中的内容,这个内容可以成为“代码字符串”,例如下面这个模板:

<p title="test" @click="handle">hello {{ name }}</p>

生成后的代码字符串是

with(this){ // with可以延长作用域 当前代码块中this上的属性和方法就变成了“全局”的
  return _c( // _c  createElement的别称 参数列表(元素标签,属性对象,children)
    'p',
    {
      attrs: { "title": "test" },
      on: {"click": "handle"}
    },
    [_v("hello "+_s(name))],
  )
}

// 这是一个字符串

会将这个代码字符串,放到渲染函数里面,当渲染函数被导出到外界后,模板编译的任务就完成了。

// 如何将代码字符串放到函数里?
const code = `with(this){return 'hello world'}`
const hello = new Function(code);

console.log(hello()); // 'hello world'

实际中,这个渲染函数执行后就可以生成一份Vnode(即通过执行createElement),而虚拟DOM可以通过这个Vnode来渲染视图。

通过AST生成代码字符串

生成代码字符串是一个递归的过程,从顶向下依次处理每一个AST节点。使用_c (createElement)来创建元素节点, 使用_v (createTextVNode) 创建文本节点,使用_e (createEmptyVNode) 创建注释节点。

每处理一个AST节点,就会生成一个与节点类型对应的代码字符串。

例如:

<div id="el">
  <div>
    <p>hello {{ name }}</p>
  </div>
</div>

最终的代码字符串会是这样

// 遍历递归结束之后就可以得到一个完整的代码字符串
`
with(this){
  return _c("div", {attrs: {"id": "el"} }, [
    _c("div", [
      _c("p", [
        _v("hello "+_s(name))
      ])
    ])
  ])
}
`

代码生成器的原理

代码生成器就是根据AST对象遍历生成对应的 _c 、_v 、_e 的函数调用字符串,实质就是字符串的拼接的过程。

function genElement(el, state) {
  // 如果 el.plain 是true,则说明节点没有属性
  const data = el.plain ? undefined : genData(el, state)
  const children = genChildren(el, data)
  code = `_c(
    '${el.tag}'
    ${data ? `,${data}` : ''}
    ${children ? `,${children}`: ''}
  )`

  return code
}

function genData(el: ASTElement, state) {
  let data = '{'

  if (el.key) {
    data += `key: ${el.key},`
  }

  if (el.ref) {
    data += `ref: ${el.ref},`
  }

  if (el.pre) {
    data += `pre: ${el.pre},`
  }

  // 类似的还有很多的判断
  // 最终删除最后一个属性的末尾逗号
  data = data.replace(/,$/, '') + '}'
  return data;
}

function genChildren(el, state) {
  const children = el.children
  if (children.length) {
    return `[${children.map(child => genNode(child, state)).join(',')}]`
  }
}

function genNode(node, state) {
  if (node.type === 1) {
    return genElement(node, state)
  }

  if (node.type === 3 && node.isComment) {
    return genComment(node)
  }

  return genText(node)
}

function genText(node) {
  return `_v(
    ${node.type === 2 ? node.expression : JSON.stringify(node.text)}
  )`
}
// type为2则是带有动态变量的文本节点,所以返回表达式 '"hello"+_s(name)'

// 为什么纯文本内容要再使用JSON.stringify() 转换一次
// 因为不装换会是 _v(hello) 最终被执行时语法上就是错误的 应该是 _v('hello')

function genComment(node) {
  return `_e(
    ${JSON.stringify(node.text)}
  )`
}