<深入浅出Vuejs>模板编译_解析器&优化器&生成器

        模板编译的主要目的就是生成渲染函数.这个过程包含三个部分:

  1. 将模板字符串解析为AST(Abstract Syntax Tree 抽象语法树)
  2. 遍历AST标记所有的静态节点(不需要重新渲染的节点)
  3. 使用AST生成渲染函数

        以上三步分别对应模板编译中的三个模块:1.解析器(Html解析器 文本解析器 过滤器解析器) 2.优化器 3.代码生成器.

        让我们从vue.$mount函数开始,分别介绍以上三个模块的具体实现原理:

// 省略了部分代码,只保留了关键部分
const { compile, compileToFunctions } = createCompiler(baseOptions)

const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (el) {
  const options = this.$options
  
  // 如果没有 render 方法,则进行 template 编译
  if (!options.render) {
    let template = options.template
    if (template) {
      // 调用 compileToFunctions,编译 template,得到 render 方法
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      // 这里的 render 方法就是生成生成虚拟 DOM 的方法
      options.render = render
    }
  }
  return mount.call(this, el, hydrating)
}

        可以看到 vue在挂载实例时会调用compileToFunction方法,并将模板字符串编译成渲染函数Render.而compileToFunction的源码如下:

export function createCompiler(baseOptions) {
  const baseCompile = (template, options) => {
    // 解析 html,转化为 ast
    const ast = parse(template.trim(), options)
    // 优化 ast,标记静态节点
    optimize(ast, options)
    // 将 ast 转化为可执行代码
    const code = generate(ast, options)
    return {
      ast,
      render: code.render,
      staticRenderFns: code.staticRenderFns
    }
  }
  const compile = (template, options) => {
    const tips = []
    const errors = []
    // 收集编译过程中的错误信息
    options.warn = (msg, tip) => {
      (tip ? tips : errors).push(msg)
    }
    // 编译
    const compiled = baseCompile(template, options)
    compiled.errors = errors
    compiled.tips = tips

    return compiled
  }
  const createCompileToFunctionFn = () => {
    // 编译缓存
    const cache = Object.create(null)
    return (template, options, vm) => {
      // 已编译模板直接走缓存
      if (cache[template]) {
        return cache[template]
      }
      const compiled = compile(template, options)
     return (cache[key] = compiled)
    }
  }
  return {
    compile,
    compileToFunctions: createCompileToFunctionFn(compile)
  }
}

        其中主要逻辑写在baseCompile中 ,而其中所调用的parse函数对应将模板字符串解析为AST的解析过程,optimize对应标记静态节点的优化过程,generate对应生成渲染函数过程.即解析器,优化器,代码生成器三部分.接下来分别探讨这三个部分.

        1.解析器

        解析器的主要作用是将模板字符串构建成AST,如:

<div>
  <p>{{name}}</p>
</div>
{
  tag: "div"
  type: 1,
  staticRoot: false,
  static: false,
  plain: true,
  parent: undefined,
  attrsList: [],
  attrsMap: {},
  children: [
    {
      tag: "p"
      type: 1,
      staticRoot: false,
      static: false,
      plain: true,
      parent: {tag: "div", ...},
      attrsList: [],
      attrsMap: {},
      children: [{
        type: 2,
        text: "{{name}}",
        static: false,
        expression: "_s(name)"
      }]
    }
  ]
}

        事实上,解析器又分为好多子解析器,如HTML解析器,文本解析器,过滤器解析器等,其中最主要的就是HTML解析器.顾名思义,其作用就是解析HTML,在其过程中会触发不同的钩子函数(标签开始钩子 标签结束钩子 文本钩子 注释钩子).

import { parseHTML } from './html-parser'

export function parse(template, options) {
  let root
  parseHTML(template, {
    // some options...
    start(tag,attrs,unary) {}, // 解析到标签位置开始的回调
    end() {}, // 解析到标签位置结束的回调
    chars(text) {}, // 解析到文本时的回调
    comment(text) {} // 解析到注释时的回调
  })
  return root
}

        可以看到,start钩子有三个属性,分别对应标签名,标签属性,以及是否是自闭和标签.而文本钩子和注释钩子都只传入文本即可 .

        如何构建AST中节点的层级关系呢?其实非常简单,我们只需要维护一个栈即可.每当触发start函数时,就把当前节点推入栈中,每当触发end函数时,就从栈中弹出一个节点.如此,栈中的最后一个节点就是当前正在构建的节点的父节点.

        而parse函数中调用的parseHtml函数是一个循环的过程,每轮循环都解析模板字符串中一小段字符串并调用相应的钩子函数,然后从模板字符串中删除这个串,直到模板串为空.

        因为每次都是从字符串的开头截取字符串,那么可以获得 '<' 符号的位置,并根据该符号位置进行分类处理.

export function parseHTML(html, options) {
  let index = 0
  let last,lastTag
  const stack = []//用于管理层级关系
  while(html) {
    last = html
    let textEnd = html.indexOf('<')
    /* "<" 字符在当前 html 字符串开始位置,说明是一个标签 分以下几种情况
    *    注释<!-- -->
    *    条件注释
    *    DOCTYPE
    *    开始标签
    *    结束标签
    */    
if (textEnd === 0) {
      // 1、匹配到注释: <!-- -->
      if (/^<!\--/.test(html)) {
        const commentEnd = html.indexOf('-->')
        if (commentEnd >= 0) {
          // 调用 options.comment 回调,传入截取出的注释内容
          options.comment(html.substring(4, commentEnd))
          // 裁切掉注释剩余部分
          advance(commentEnd + 3)
          continue
        }
      }
      // 2、匹配到条件注释: <![if !IE]>  <![endif]>
      if (/^<!\[/.test(html)) {
        // ... 逻辑与匹配到注释类似
      }
      // 3、匹配到 Doctype: <!DOCTYPE html>
      const doctypeMatch = html.match(/^<!DOCTYPE [^>]+>/i)
      if (doctypeMatch) {
        // ... 逻辑与匹配到注释类似
      }
      // 4、匹配到结束标签: </div>
      const endTagMatch = html.match(endTag)
      if (endTagMatch) {
          const curIndex = index
          advance(endTagMatch[0].length)
          parseEndTag(endTagMatch[1], curIndex, index)
          continue
      }
      // 5、匹配到开始标签: <div>
      const startTagMatch = parseStartTag()
      if (startTagMatch) {
          handleStartTag(startTagMatch)
          continue
      }
    }
    // "<" 字符在当前 html 字符串中间位置,说名前面的都是文本节点
    let text, rest, next
    if (textEnd > 0) {
      // 提取中间字符
      rest = html.slice(textEnd)
      // 这一部分当成文本处理
      text = html.substring(0, textEnd)
      advance(textEnd)
    }
    // "<" 字符在当前 html 字符串中不存在,则整个模板字符串都是文本
    if (textEnd < 0) {
      text = html
      html = ''
    }
    // 如果存在 text 文本
    // 调用 options.chars 回调,传入 text 文本
    if (options.chars && text) {
      // 字符相关回调
      options.chars(text)
    }
  }
  // 向前推进,裁切 html
  function advance(n) {
    index += n
    html = html.substring(n)
  }
}

        这里着重分析一下处理标签开始和标签结束时候的逻辑(parseStartTag handleStartTag parseEndTag):

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

// 判断是否标签开始位置,如果是,则提取标签名以及相关属性
function parseStartTag () {
  // 提取 <xxx
  const start = html.match(startTagOpen)
  if (start) {
    const [fullStr, tag] = start
    const match = {
      attrs: [],
      start: index,
      tagName: tag,
    }
    advance(fullStr.length)
    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]
      advance(end[0].length)
      match.end = index
      return match
    }
  }
}

// 处理开始标签
function handleStartTag (match) {
  const tagName = match.tagName
  const unary = match.unarySlash
  const len = match.attrs.length
  const attrs = new Array(len)
  for (let i = 0; i < l; i++) {
    const args = match.attrs[i]
    // 这里的 3、4、5 分别对应三种不同复制属性的方式
    // 3: attr="xxx" 双引号
    // 4: attr='xxx' 单引号
    // 5: attr=xxx   省略引号
    const value = args[3] || args[4] || args[5] || ''
    attrs[i] = {
      name: args[1],
      value
    }
  }

  if (!unary) {
    // 非单标签,入栈
    stack.push({
      tag: tagName,
      lowerCasedTag:
      tagName.toLowerCase(),
      attrs: attrs
    })
    lastTag = tagName
  }

  if (options.start) {
    // 开始标签的回调
    options.start(tagName, attrs, unary, match.start, match.end)
  }
}

// 处理闭合标签
function parseEndTag (tagName, start, end) {
  let pos, lowerCasedTagName
  if (start == null) start = index
  if (end == null) end = index

  if (tagName) {
    lowerCasedTagName = tagName.toLowerCase()
  }

  // 在栈内查找相同类型的未闭合标签
  if (tagName) {
    for (pos = stack.length - 1; pos >= 0; pos--) {
      if (stack[pos].lowerCasedTag === lowerCasedTagName) {
        break
      }
    }
  } else {
    pos = 0
  }

  if (pos >= 0) {
    // 关闭该标签内的未闭合标签,更新堆栈
    for (let i = stack.length - 1; i >= pos; i--) {
      if (options.end) {
        // end 回调
        options.end(stack[i].tag, start, end)
      }
    }

    // 堆栈中删除已关闭标签
    stack.length = pos
    lastTag = pos && stack[pos - 1].tag
  }
}

        这样我们就理清了调用解析器解析模板字符串时候的过程,每次解析后让不同类型的内容调用不同类型的钩子函数.接下来这些钩子函数又要进行怎样的操作呢?

start(tag,attrs,unary):创建一个新的元素入栈,并建立当前节点和其父节点的父子关系

start(tag, attrs, unary) {
    // 创建 AST 节点
    let element = createASTElement(tag, attrs, currentParent)

    // 处理指令: v-for v-if v-once
    processFor(element)
    processIf(element)
    processOnce(element)
    processElement(element, options)

    // 处理 AST 树
    // 根节点不存在,则设置该元素为根节点
    if (!root) {
      root = element
      checkRootConstraints(root)
    }
    // 存在父节点
    if (currentParent) {
      // 将该元素推入父节点的子节点中
      currentParent.children.push(element)
      element.parent = currentParent
    }
    if (!unary) {
     // 非单标签需要入栈,且切换当前父元素的位置
      currentParent = element
      stack.push(element)
    }
  }
})


function createASTElement(tag, attrs, parent) {
  const attrsList = attrs
  const attrsMap = makeAttrsMap(attrsList)
  return {
    type: 1,       // 节点类型
    tag,           // 节点名称
    attrsMap,      // 节点属性映射
    attrsList,     // 节点属性数组
    parent,        // 父节点
    children: [],  // 子节点
  }
}

end() :将弹出栈顶标签

end() {
    const element = stack[stack.length - 1]
    const lastNode = element.children[element.children.length - 1]
    // 处理尾部空格的情况
    if (lastNode && lastNode.type === 3 && lastNode.text === ' ') {
      element.children.pop()
    }
    // 出栈,重置当前的父节点
    stack.length -= 1
    currentParent = stack[stack.length - 1]
  }

chars(text):对带表达式的文本以及静态文本与其父节点关系构建,文本节点不入栈.

chars(text) {
    if (!currentParent) {
      // 文本节点外如果没有父节点则不处理
      return
    }
    
    const children = currentParent.children
    text = text.trim()
    if (text) {
      // parseText 用来解析表达式
      // delimiters 表示表达式标识符,默认为 ['{{', '}}']
      const res = parseText(text, delimiters))
      if (res) {
        // 表达式
        children.push({
          type: 2,
          expression: res.expression,
          tokens: res.tokens,
          text
        })
      } else {
        // 静态文本
        children.push({
          type: 3,
          text
        })
      }
    }
  }

2.优化器 

        通过解析器的处理,我们就得到了 Vue 模板的 AST。由于 Vue 是响应式设计,所以拿到AST 之后还需要进行一系列优化,确保静态的数据不会进入虚拟 DOM 的更新阶段,以此来优化性能.

        简单来讲,就是把静态节点的static属性设置为true,然后找到再遍历一次找到静态根节点.

export function optimize (root, options) {
  if (!root) return
  // 标记静态节点
  markStatic(root)
}
function isStatic (node) {
  if (node.type === 2) { // 表达式,返回 false
    return false
  }
  if (node.type === 3) { // 静态文本,返回 true
    return true
  }
  // 此处省略了部分条件
  return !!(
    !node.hasBindings && // 没有动态绑定
    !node.if && !node.for && // 没有 v-if/v-for
    !isBuiltInTag(node.tag) && // 不是内置组件 slot/component
    !isDirectChildOfTemplateFor(node) && // 不在 template for 循环内
    Object.keys(node).every(isStaticKey) // 非静态节点
  )
}

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
      }
    }
  }
}

 !注意: 静态节点的所有子节点都是静态节点,动态节点的父节点是动态节点.这个特性保证,我们找到的第一个静态节点会被标记为静态根节点,此时不用再遍历其子节点,因为他的子节点必然是静态节点.

 3.代码生成器

        至此,我们已经得到了优化后的AST,我们只需要做一些简单的字符串拼接就能生成Render函数

export function generate(ast, options) {
  const state = new CodegenState(options)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

export function genElement (el, state) {
  let code
  const data = genData(el, state)
  const children = genChildren(el, state, true)
  code = `_c('${el.tag}'${
    data ? `,${data}` : '' // data
  }${
    children ? `,${children}` : '' // children
  })`
  return code
}

生成的渲染函数效果如图:

<div>
  <h2 v-if="message">{{message}}</h2>
  <button @click="showName">showName</button>
</div>
with (this) {
    return _c(
      'div',
      [
        (message) ? _c('h2', [_v(_s(message))]) : _e(),
        _v(' '),
        _c('button', { on: { click: showName } }, [_v('showName')])
      ])
    ;
}

注意

vm._c 是创建DOM标签的
vm._v 是创建文本节点的
vm_s 就是 toString

总结:

1.解析器的主要功能就是将模板字符串转换为AST.解析器parse调用parseHtml函数用于循环解析模板字符串,并把不同类型的字符串调用相应的钩子函数(作用是维护层次栈以及建立AST),最终返回AST根节点.

2.优化器用于标记所有静态节点以及静态根节点

3.代码生成器将优化获得AST转换为渲染函数.

        本文章仅为本人学习总结,如果有不足还请各位指出!

 

 

         

 

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值