30分钟从造车的角度去理解模板编译-Vue

1,220 阅读6分钟

前言

最近复习了一下Vue的相关知识点,毕竟温顾而知新,然后我也看了很多小伙伴写的文章但是感觉都没有我心中想要的那种描述的感觉,所以有了我个人的这次的一点点🤏🏻心得及理解分享


目录/相关知识点:

  • 模板编译是什么

    • 现实比喻
    • 官方说明-渲染管线
  • 模板编译的作用

    • VNode虚拟DOM简单介绍
    • render:Vue输出的render
    • 模板编译器Vue Template Explorer
    • Vue2.x Template Explorer VS Vue3 Template Explorer
  • 模板编译代码解析

    • 模板编译流程图
    • 模板编译相关API源码解析
      • compileToFunctions
      • compile
      • createCompilerCreator-baseCompile
        • 解析parse
        • 优化optimize
        • 生成generate
  • 作者寄语


模板编译是什么

现实比喻

兄弟们先来看一张好玩的图

55d090d518a1ab49439b5b6b833ffe0d.gif 有没有设想过我们使用Vue的时候就像一辆"🚗"在运行

"🚗跑起来"驱动的"油"是数据-数据驱动

数据的变化通过"引擎"展示到视图中让"🚗跑起来"-diff对比虚拟DOM更新视图

那么模板编译在我看来就是把"设计图"转换为"🚗"的过程(这里与跑起来的🚗是有区别的)-template模板转换为render

看到这里大家应该有点感觉,又不是很透彻,那我们再看看下面的官方说明

官方说明-渲染管线

从高层面的视角看,Vue 组件挂载后发生了如下这几件事:

  1. 编译:Vue 模板被编译为了渲染函数:即用来返回虚拟 DOM 树的函数。这一步骤可以通过构建步骤提前完成,也可以通过使用运行时编译器即时完成。
  2. 挂载:运行时渲染器调用渲染函数,遍历返回的虚拟 DOM 树,并基于它创建实际的 DOM 节点。这一步会作为响应式副作用执行,因此它会追踪其中所用到的所有响应式依赖。
  3. 更新:当一个依赖发生变化后,副作用会重新运行,这时候会创建一个更新后的虚拟 DOM 树。运行时渲染器遍历这棵新树,将它与旧树进行比较,然后将必要的更新应用到真实 DOM 上去。

image.png

现实比喻VS官方说明

image.png

我相信看到这里小伙伴们对于模板编译以及对于Vue的渲染机制有个大概的了解,可能引擎提供动力让🚗跑起来或许有点疑惑,那么我多打个比方:

  • 动画:我们都知道动画是一帧一帧的静态图组合而成的,你可以理解为第一帧静态图就是一辆🚗,而第二帧静态图就是第一帧的🚗的基础上修改了一些东西,第三帧,第四帧...第一百帧;当我们高速浏览这些帧静态图的时候,因为每一帧都会有差别看起来就形成了动画;当我们高速浏览这些帧静态图的时候这个动作就是对比不同状态的🚗就是引擎

模板编译的作用

模板编译的主要目的是将模板(template)转换为渲染函数(render)

  • Vue2.x使用VNode描述视图以及各种交互,用户自己编写VNode比较复杂
  • 用户只需要编写类似HTML的代码-Vue.js模板,通过编译器将模板转换为返回VNode的render函数
  • .vue文件会被webpack在构建的过程中转换成render函数

VNode虚拟DOM简单介绍

一句话总结虚拟DOM就是一个用来描述真实DOM的javaScript对象,这样说可能不够形象,那我们来举个🌰:分别用代码来描述真实DOM以及虚拟DOM

真实DOM:

<ul class="list">
    <li>a</li>
    <li>b</li>
    <li>c</li>
</ul>

对应的虚拟DOM:


let vnode = h('ul.list', [
  h('li','a'),
  h('li','b'),
  h('li','c'),
])

console.log(vnode)

控制台打印出来的Vnode:

image.png

h函数生成的虚拟DOM这个JS对象(Vnode)的源码:

export interface VNodeData {
    props?: Props
    attrs?: Attrs
    class?: Classes
    style?: VNodeStyle
    dataset?: Dataset
    on?: On
    hero?: Hero
    attachData?: AttachData
    hook?: Hooks
    key?: Key
    ns?: string // for SVGs
    fn?: () => VNode // for thunks
    args?: any[] // for thunks
    [key: string]: any // for any other 3rd party module
}

export type Key = string | number

const interface VNode = {
    sel: string | undefined, // 选择器
    data: VNodeData | undefined, // VNodeData上面定义的VNodeData
    children: Array<VNode | string> | undefined, //子节点,与text互斥
    text: string | undefined, // 标签中间的文本内容
    elm: Node | undefined, // 转换而成的真实DOM
    key: Key | undefined // 字符串或者数字
}

render:Vue输出的render

<template>
  <div id="app">
    0
    {{test}}
    <h1>Vue</h1><span>模板编译</span>
    <test :bbc="1"></test>
    something
  </div>
</template>

mounted() {
  // 页面已渲染完成
  console.log(this.$options.render);// 查看输出的render函数
},
  
// 控制台输出的render函数  
  function() {
    var _vm = this
    var _h = _vm.$createElement
    var _c = _vm._self._c || _h
    
    //这里返回的就是一个虚拟DOM树
    return _c(
      "div",
      { attrs: { id: "app" } },
      [
        _vm._v(" 0 " + _vm._s(_vm.test) + " "),
        _c("h1", [_vm._v("Vue")]),
        _c("span", [_vm._v("模板编译")]),
        _c("test", { attrs: { bbc: 1 } }),
        _vm._v(" something ")
      ],
      1
    )
}

render中返回的虚拟DOM树中的_c,_v_s...又是什么

  • _c = _vm._self_c || _h(_h就是我们上文提到也是常说的h函数)

    • _h也就是$createElement手写reder函数进行渲染的方法
    • _vm._self_c编译生成的render进行渲染的方法
    • 两者本质上都是调用createElement方法,传的一个参数不一样,其参数代表是手写的render还是编译生成的render
    • 对于createElement方法,有兴趣的同学可以在本文的尾部[1]得到更详细的说明
  • _v,_s是什么?->看源码

// vue.runtime.esm.js

function installRenderHelpers (target) {
  target._o = markOnce;
  target._n = toNumber;
  target._s = toString; // 转换为字符串
  target._l = renderList;
  target._t = renderSlot;
  target._q = looseEqual;
  target._i = looseIndexOf;
  target._m = renderStatic;
  target._f = resolveFilter;
  target._k = checkKeyCodes;
  target._b = bindObjectProps;
  target._v = createTextVNode; // 创建一个文本节点
  target._e = createEmptyVNode;
  target._u = resolveScopedSlots;
  target._g = bindObjectListeners;
  target._d = bindDynamicKeys;
  target._p = prependModifier;
}


// _s函数
/**
 * Convert a value to a string that is actually rendered.
 */
function toString (val) {
  return val == null
    ? ''
    : Array.isArray(val) || (isPlainObject(val) && val.toString === _toString)
      ? JSON.stringify(val, null, 2)
      : String(val)
}

// _v函数
function createTextVNode (val) {
  return new VNode(undefined, undefined, undefined, String(val))
}

更直观的体验vue的模板编译工具->模板编译器Vue Template Explorer

image.png

image.png

Vue2.xTemplate VS Vue3Template

  • Fragments(片段特性),模板中不需要创建一个唯一的根节点,模板里可以直接放文本内容或很多同级的标签
  • 静态提升
  • Patch flag(静态标记)
  • 缓存事件处理函数

注意:vue有运行时以及完整版两个版本,两者主要的区别就是是否带有编译器

  • 运行时需要配合vue-loader/webpack把template内容转换为render来使用
  • vue默认使用的是运行时版本,内存空间比较少
  • 一般我们直接通过script引用的UMD版本就是这里所阐述的完整版

UMD 版本可以通过 <script> 标签直接用在浏览器中。jsDelivr CDN 的 cdn.jsdelivr.net/npm/vue@2.7… 默认文件就是运行时 + 编译器的 UMD 版本 (vue.js)。-官方说明


模板编译代码解析

流程图

image.png

  • compilerToFunctions生成render的入口

    • 获取缓存结果,没有即进行编译
    • compile的字符串代码转化为render函数
  • compile

    • 合并用户选项参数以及默认参数
    • 进行编译
  • baseCompile核心API

    • parse函数把template转换成AST抽象语法树
    • optimize优化AST抽象语法树
    • generate生成字符串形式的JS代码

compilerToFunctions

//xxx/node_modules/vue/src/compiler/to-function.js

export function createCompileToFunctionFn (compile: Function): Function {
  const cache = Object.create(null)

  return function compileToFunctions (
    template: string,
    options?: CompilerOptions,
    vm?: Component
  ): CompiledFunctionResult {
    options = extend({}, options)
    const warn = options.warn || baseWarn
    delete options.warn

    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production') {
      // detect possible CSP restriction
      try {
        new Function('return 1')
      } catch (e) {
        if (e.toString().match(/unsafe-eval|CSP/)) {
          warn(
            'It seems you are using the standalone build of Vue.js in an ' +
            'environment with Content Security Policy that prohibits unsafe-eval. ' +
            'The template compiler cannot work in this environment. Consider ' +
            'relaxing the policy to allow unsafe-eval or pre-compiling your ' +
            'templates into render functions.'
          )
        }
      }
    }

    // check cache
    const key = options.delimiters
      ? String(options.delimiters) + template
      : template
    if (cache[key]) {
      return cache[key]
    }

    // compile
    const compiled = compile(template, options)

    // check compilation errors/tips
    if (process.env.NODE_ENV !== 'production') {
      if (compiled.errors && compiled.errors.length) {
        if (options.outputSourceRange) {
          compiled.errors.forEach(e => {
            warn(
              `Error compiling template:\n\n${e.msg}\n\n` +
              generateCodeFrame(template, e.start, e.end),
              vm
            )
          })
        } else {
          warn(
            `Error compiling template:\n\n${template}\n\n` +
            compiled.errors.map(e => `- ${e}`).join('\n') + '\n',
            vm
          )
        }
      }
      if (compiled.tips && compiled.tips.length) {
        if (options.outputSourceRange) {
          compiled.tips.forEach(e => tip(e.msg, vm))
        } else {
          compiled.tips.forEach(msg => tip(msg, vm))
        }
      }
    }

    // turn code into functions
    const res = {}
    const fnGenErrors = []
    res.render = createFunction(compiled.render, fnGenErrors)
    res.staticRenderFns = compiled.staticRenderFns.map(code => {
      return createFunction(code, fnGenErrors)
    })

    // check function generation errors.
    // this should only happen if there is a bug in the compiler itself.
    // mostly for codegen development use
    /* istanbul ignore if */
    if (process.env.NODE_ENV !== 'production') {
      if ((!compiled.errors || !compiled.errors.length) && fnGenErrors.length) {
        warn(
          `Failed to generate render function:\n\n` +
          fnGenErrors.map(({ err, code }) => `${err.toString()} in\n\n${code}\n`).join('\n'),
          vm
        )
      }
    }

    return (cache[key] = res)
  }
}

compile

//xxx/node_modules/vue/src/compiler/create-compiler.js
/* @flow */

import { extend } from 'shared/util'
import { detectErrors } from './error-detector'
import { createCompileToFunctionFn } from './to-function'

export function createCompilerCreator (baseCompile: Function): Function {
  return function createCompiler (baseOptions: CompilerOptions) {
    function compile (
      template: string,
      options?: CompilerOptions
    ): CompiledResult {
      const finalOptions = Object.create(baseOptions)
      const errors = []
      const tips = []

      let warn = (msg, range, tip) => {
        (tip ? tips : errors).push(msg)
      }

      if (options) {
        if (process.env.NODE_ENV !== 'production' && options.outputSourceRange) {
          // $flow-disable-line
          const leadingSpaceLength = template.match(/^\s*/)[0].length

          warn = (msg, range, tip) => {
            const data: WarningMessage = { msg }
            if (range) {
              if (range.start != null) {
                data.start = range.start + leadingSpaceLength
              }
              if (range.end != null) {
                data.end = range.end + leadingSpaceLength
              }
            }
            (tip ? tips : errors).push(data)
          }
        }
        // merge custom modules
        if (options.modules) {
          finalOptions.modules =
            (baseOptions.modules || []).concat(options.modules)
        }
        // merge custom directives
        if (options.directives) {
          finalOptions.directives = extend(
            Object.create(baseOptions.directives || null),
            options.directives
          )
        }
        // copy other options
        for (const key in options) {
          if (key !== 'modules' && key !== 'directives') {
            finalOptions[key] = options[key]
          }
        }
      }

      finalOptions.warn = warn

      const compiled = baseCompile(template.trim(), finalOptions)
      if (process.env.NODE_ENV !== 'production') {
        detectErrors(compiled.ast, warn)
      }
      compiled.errors = errors
      compiled.tips = tips
      return compiled
    }

    return {
      compile,
      compileToFunctions: createCompileToFunctionFn(compile)
    }
  }
}

baseCompile:模板编译核心API

// xxx/node_modules/vue/src/compiler/index.js

// `createCompilerCreator` allows creating compilers that use alternative
// parser/optimizer/codegen, e.g the SSR optimizing compiler.
// Here we just export a default compiler using the default parts.

export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 1.把模板转换成AST抽象语法树                                               // 抽象语法树:用树形的方式描述代码结构  
  const ast = parse(template.trim(), options)
  if (options.optimize !== false) {
  // 2.优化AST抽象语法树
    optimize(ast, options)
  }
  // 3.把抽象语法树生成字符串形式的JS代码
  const code = generate(ast, options)
  return {
    ast,
    // 渲染函数
    render: code.render,
    // 静态渲染函数,生成静态VNode树(静态根节点)
    staticRenderFns: code.staticRenderFns
  }
})

模板编译三阶段总结

  • 分为三个阶段
    • 解析阶段:使用大量正则对template解析转化成AST抽象语法树(parse函数)

      • 使用一个开源库simple-html-parse
      • 返回AST
    • 优化阶段:遍历AST,找出静态节点,静态根节点进行标记,执行diff算法的时候跳过这些节点** **(optimize函数)

      • 静态节点

        • node.type === 3的纯文本节点
        • node.type === 1的元素节点(简单可以理解为非文本节点)进一步判断
          • 如果节点使用了v-pre指令,那就断定它是静态节点;
          • 如果节点没有使用v-pre指令,那它要成为静态节点必须满足:
            • 不能使用动态绑定语法,即标签上不能有v-、@、:开头的属性;
            • 不能使用v-if、v-else、v-for指令;
            • 不能是内置组件,即标签名不能是slot和component;
            • 标签名必须是平台保留标签,即不能是组件;
            • 当前节点的父节点不能是带有 v-for 的 template 标签;
            • 节点的所有属性的 key 都必须是静态节点才有的 key,注:静态节点的key是有限的,它只能是type,tag,attrsList,attrsMap,plain,parent,children,attrs之一;
      • 静态根节点

        • 节点本身必须是静态节点

        • 必须拥有子节点 children

        • 子节点不能只是只有一个文本节点

    • 生成阶段:将最终的AST转换成render函数 (generate函数)

      • 首先通过generator生成器把AST对象转换成JS形式的字符串代码
  • 模板预编译

    • 指的就是在项目构建的时候把template提前转换为render函数给页面渲染

补充[1]

_c与$createElement的区别

//create.element.js

import {
  normalizeChildren,
  simpleNormalizeChildren
} from './helpers/index'

const SIMPLE_NORMALIZE = 1
const ALWAYS_NORMALIZE = 2

// wrapper function for providing a more flexible interface
// without getting yelled at by flow
export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  if (Array.isArray(data) || isPrimitive(data)) {
    normalizationType = children
    children = data
    data = undefined
  }
  if (isTrue(alwaysNormalize)) {
    normalizationType = ALWAYS_NORMALIZE
  }
  return _createElement(context, tag, data, children, normalizationType)
}


export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  //.... 
  if (normalizationType === ALWAYS_NORMALIZE) {
    // 把一个多维数组转换为一个一维数组(用户手写render)
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    // 把一个二维数组转换为一个一维数组(编译生成render)
    children = simpleNormalizeChildren(children)
  }
  //...
}

normalizeChildren与simpleNormalizeChildren及simpleNormalizeChildren

//normalize-children.js

// 1. When the children contains components - because a functional component
// may return an Array instead of a single root. In this case, just a simple
// normalization is needed - if any child is an Array, we flatten the whole
// thing with Array.prototype.concat. It is guaranteed to be only 1-level deep
// because functional components already normalize their own children.
// 对应_c函数
export function simpleNormalizeChildren (children: any) {
  for (let i = 0; i < children.length; i++) {
    if (Array.isArray(children[i])) {
      return Array.prototype.concat.apply([], children)
    }
  }
  return children
}

// 2. When the children contains constructs that always generated nested Arrays,
// e.g. <template>, <slot>, v-for, or when the children is provided by user
// with hand-written render functions / JSX. In such cases a full normalization
// is needed to cater to all possible types of children values.
// 对应$createElement函数
export function normalizeChildren (children: any): ?Array<VNode> {
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
  const res = []
  let i, c, lastIndex, last
  for (i = 0; i < children.length; i++) {
    c = children[i]
    if (isUndef(c) || typeof c === 'boolean') continue
    lastIndex = res.length - 1
    last = res[lastIndex]
    //  nested
    if (Array.isArray(c)) {
      if (c.length > 0) {
        c = normalizeArrayChildren(c, `${nestedIndex || ''}_${i}`)
        // merge adjacent text nodes
        if (isTextNode(c[0]) && isTextNode(last)) {
          res[lastIndex] = createTextVNode(last.text + (c[0]: any).text)
          c.shift()
        }
        res.push.apply(res, c)
      }
    } else if (isPrimitive(c)) {
      if (isTextNode(last)) {
        // merge adjacent text nodes
        // this is necessary for SSR hydration because text nodes are
        // essentially merged when rendered to HTML strings
        res[lastIndex] = createTextVNode(last.text + c)
      } else if (c !== '') {
        // convert primitive to vnode
        res.push(createTextVNode(c))
      }
    } else {
      if (isTextNode(c) && isTextNode(last)) {
        // merge adjacent text nodes
        res[lastIndex] = createTextVNode(last.text + c.text)
      } else {
        // default key for nested array children (likely generated by v-for)
        if (isTrue(children._isVList) &&
          isDef(c.tag) &&
          isUndef(c.key) &&
          isDef(nestedIndex)) {
          c.key = `__vlist${nestedIndex}_${i}__`
        }
        res.push(c)
      }
    }
  }
  return res
}

寄语

最后希望大家看在我辛勤造车的努力下,给我点个,同时也欢迎大家交流评论,我开着我的🚗载大家去往前端的二仙桥