likes
comments
collection
share

Vue 编译器 -- 生成render函数

作者站长头像
站长
· 阅读数 14

前言

今天实在是太忙了,一下午埋头改 "祖传" 代码,谁能想到侧边栏是镶嵌在每个页面里的啊 😭

一个好好的组件也被抽取的乱七八糟,真的不至于把菜单抽取到文字的程度吧

代码写的也一点都不规范,这着实令人头大

本来是今天就要完成的功能,直到下班前才把页面结构调整完开始步入正轨😭

Vue 编译器 -- 生成render函数 山真的是太折磨人了,看来今天的更文计划又只能吃老本了 😂

那今天就来讲讲 vue 的编译原理吧,为后续的学习打下坚实的基础 💪

前置知识

vue 开发过程中编写 DOM 元素的方式

  • template 模板
  • render 函数 使用h函数来编写渲染的内容
  • 通过 .vue文件中的template来编写模板

模板的处理方式

  • template 模板通过源码中的一部分代码进行编译
  • render函数 返回虚拟节点(vnode)
  • .vue 文件中的 template 可以通过 vue-loader 对其进行编译和处理

vue 版本分为 运行时+编译器、仅运行时

  • 运行时+编译器 runtime-compiler:包含了对template 模板的编译代码,代码量大

template(html)->ast->render ->vdom ->真实dom

  • 仅运行时 runtime-only:没有包含对template 模板的编译代码,相对较小 render ->vdom ->真实dom

如果选择对template进行编译,就需要选择第一种,因为compiler就是对template进行编译。 但是vue默认使用 runtime-only, 不包含对template的编译,所以编译不会成功,界面渲染不出来。

vue打包后不同版本解析

  • vue(.runtime).global(.prod).js (不需要编译template可以只使用runtime版本)
    • 通过浏览器中的
    • 通过CDN引入和下载的vue
    • 会暴露一个全局的vue来使用
    • .prod 是要不要使用生产版本
  • vue(.runtime).esm-browser(.prod).js
    • 用于通过原生ES模块导入使用(在浏览器中通过
  • vue(.runtime).esm-bundler.js:(默认)仅运行时,且要求所有模板必须预编译
    • 用于webpack、rollup、parcel等构建工具
    • 构建工具中默认是vue.runtime.esm-bundler.js
    • 解析 template 需要手动指定 vue.esm-bundler.js
    • 包含运行时编译器。当你使用了构建工具但仍然想编译运行时模板
  • vue.cjs(.prod).js
    • 服端渲染时使用
    • 用过require() 在 nodejs中使用

编译的目的

编译的目标主要是用于生成 render 函数,执行 render 函数生成 vnode 实现页面的更新

const updateComponent = () => {
  // 执行 vm._render() 函数,得到 虚拟 DOM,
  // 并将 vnode 传递给 _update 方法,接下来就该到 patch 阶段了
  // 将vnode转换为真实DOM,并且更新到页面中
  vm._update(vm._render(), hydrating)
}


// 监听当前组件状态,当有数据变化时,更新组件
new Watcher(vm, updateComponent, noop, {
  before () {
    if (vm._isMounted && !vm._isDestroyed) {
      // 数据更新引发的组件更新
      callHook(vm, 'beforeUpdate')
    }
  }
}, true /* isRenderWatcher */)

当更新一个渲染 watcher 时,执行的是 updateComponent 方法: 根据源码可以发现每次更新前都需要先执行一下 vm._render() 方法 vm._render 就是大家经常听到的 render 函数,render函数的生成方式有两种:

  • 用户自己提供,在编写组件时,用 render 选项代替模版
  • 由编译器编译组件模版生成 render 选项

render的作用主要是生成 vnode

编译入口

编译入口 是从 $mount 开始的

暂存原本的 mount以便最后调用初始的挂载方法同时对运行时编译的mount 以便最后调用 初始的挂载方法 同时对运行时编译 的 mount以便最后调用初始的挂载方法同时对运行时编译的mount 进行重写

/**
 * 编译器的入口
 * 运行时的 Vue.js 包就没有这部分的代码,通过 打包器 结合 vue-loader + vue-compiler-utils 进行预编译,将模版编译成 render 函数
 * 
 * 就做了一件事情,得到组件的渲染函数,将其设置到 this.$options 上
 */
// 暂存原本的 $mount 以便最后调用 挂载
const mount = Vue.prototype.$mount

// 运行时编译 对 $mount 的重写
Vue.prototype.$mount = function (

  // 传入的  挂载点
  el?: string | Element,

  // 
  hydrating?: boolean
): Component {
  el = el && query(el)

  //  vue 不允许直接挂载到body或页面文档上
  if (el === document.body || el === document.documentElement) {
    process.env.NODE_ENV !== 'production' && warn(
      `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
    )
    return this
  }

  const options = this.$options
  /**
   * 如果用户提供了 render 配置项,则直接跳过编译阶段,否则进入编译阶段
   *   解析 template 和 el,并转换为 render 函数
   *   优先级:render > template > el
   */
  if (!options.render) {
    let template = options.template
    //  解析 template 和 el,并转换为 render 函数
    if (template) {
      // 存在template模板,解析vue模板文件
      if (typeof template === 'string') {
        if (template.charAt(0) === '#') {
          template = idToTemplate(template)
          /* istanbul ignore if */
          if (process.env.NODE_ENV !== 'production' && !template) {
            warn(
              `Template element not found or is empty: ${options.template}`,
              this
            )
          }
        }
      } else if (template.nodeType) {
        // template 获取其 innerHtml 作为模版
        template = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      // 获取 el 选择器的 outerHtml 作为模版
      template = getOuterHTML(el)
    }
    if (template) {
      /* istanbul ignore if */
      // 模版就绪,进入编译阶段
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }


      /**
       * 重点   进入 模版编译,得到 动态渲染函数和静态渲染函数
       * 1.将类 HTML 字符串模版解析成 AST 对象
       * 2.优化,遍历 AST,标记静态节点及静态根节点,静态根节点用于生成静态根节点的渲染函数
       * 3.将 ast 语法树生成render方法
       */
      const { render, staticRenderFns } = compileToFunctions(template, {
        outputSourceRange: process.env.NODE_ENV !== 'production',
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns

      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile end')
        measure(`vue ${this._name} compile`, 'compile', 'compile end')
      }
    }
  }
  // 执行挂载
  return mount.call(this, el, hydrating)
}

在$mount 中主要做了三件事

  • 暂存原本的$mount 挂载方法
  • 获取 template 模板:
    • 有 render 配置项则直接跳过编译阶段执行挂载
    • 获取template的 innerHtml 作为模版
    • 获取 el 选择器的 outerHtml 作为模版
  • 调用 compileToFunctions,会将 template 解析成render函数
  • 生成render函数,挂载到vm上后,会再次调用mount方法

被暂存的 $mount ,vue初始化时定义的方法

// public mount method
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && inBrowser ? query(el) : undefined
  // 渲染组件
  return mountComponent(this, el, hydrating)
}

mountComponent 主要用于挂载组件,其中实现了 beforeMount 到 mounted 组件的挂载过程

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  // 如果没有获取解析的render函数,则会抛出警告
  // render是解析模板文件生成的
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
    if (process.env.NODE_ENV !== 'production') {
      /* istanbul ignore if */
      if ((vm.$options.template && vm.$options.template.charAt(0) !== '#') ||
        vm.$options.el || el) {
        warn(
          'You are using the runtime-only build of Vue where the template ' +
          'compiler is not available. Either pre-compile the templates into ' +
          'render functions, or use the compiler-included build.',
          vm
        )
      } else {
        // 没有获取到vue的模板文件
        warn(
          'Failed to mount component: template or render function not defined.',
          vm
        )
      }
    }
  }
  // 执行beforeMount钩子
  callHook(vm, 'beforeMount')

  let updateComponent
  /* istanbul ignore if */
  if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
    updateComponent = () => {
      const name = vm._name
      const id = vm._uid
      const startTag = `vue-perf-start:${id}`
      const endTag = `vue-perf-end:${id}`

      mark(startTag)
      const vnode = vm._render()
      mark(endTag)
      measure(`vue ${name} render`, startTag, endTag)

      mark(startTag)
      vm._update(vnode, hydrating)
      mark(endTag)
      measure(`vue ${name} patch`, startTag, endTag)
    }
  } else {
     updateComponent = () => {
      // 执行_update 进入更新阶段  
      // _update主要功能是调用patch,首先执行 _render 将组件变成 VNode ,
      // 再将vnode转换为真实DOM,并且更新到页面中
      vm._update(vm._render(), hydrating)
    }
  }

  // 监听当前组件状态,当有数据变化时,更新组件
  // 在实例化 watcher 时会主动触发 watcher.get() 首次渲染会触发实现依赖收集
  // 通过 updateComponent 回调(_render)实现页面的更新 
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        // 数据更新引发的组件更新
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  hydrating = false

  // manually mounted instance, call mounted on self
  // mounted is called for render-created child components in its inserted hook
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

编译开始

编译的开始正是从 compileToFunctions 开始 顺着 **compileToFunctions ** 向上查找 可以发现 **compileToFunctions ** 来源于createCompilerCreator 的执行

export const createCompiler = createCompilerCreator(
  
  function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {

  // 解析  ===>>   将html模板字符串解析为ast对象  
  // 解析模板生成AST树   对每个结点的ast对象上设置了元素的所有属性  
  // 例如:标签信息、属性信息、插槽信息、父节点、子节点等。 
  // 具体有哪些属性,查看 start 和 end 这两个处理开始和结束标签的方法
  const ast = parse(template.trim(), options)

  if (options.optimize !== false) {
    // 优化  遍历ast语法树  标记静态节点 和静态根节点
    // 标记每个节点是否为静态节点,然后进一步标记出静态根节点
    // 这样在后续更新的过程中就可以跳过这些静态节点了
    // 标记静态根,用于生成渲染函数阶段,生成静态根节点的渲染函数
    optimize(ast, options)
  }
    
  // 代码生成 render()   将ast 转化为可执行的render函数的字符串形式 
  // 从 AST 生成渲染函数,生成像这样的代码,
  // 比如:code.render = "_c('div',{attrs:{"id":"app"}},_l((arr),function(item){return _c('div',{key:item},[_v(_s(item))])}),0)"
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,  // 为动态渲染函数
    staticRenderFns: code.staticRenderFns     // 静态渲染函数放到  code.staticRenderFns 数组中
  }
}

)

这里使用了JavaScript中的偏函数,它是一种高阶函数,借鉴自函数式编程思想。偏函数利用了JavaScript中的函数既可以作为参数传入另一个函数,也可以作为一个函数的返回值的特性,可以将一些通用函数包装成专用函数。

通用的编译器的构造器createCompilerCreator,它接受一个基础构造器,这里的大部分代码都是在定义这个基础构造器。

export const createCompiler = createCompilerCreator(function baseCompile (){ ... })
// createCompiler的值是createCompilerCreator的返回值  (baseCompiler的返回值)

解析

在解析的过程中 主要实现 类 html 模板字符串转化成AST语法树的过程

源码分析

解析入口 parse

/**
 * 
 * 将 HTML 字符串转换为 AST
 * @param {*} template HTML 模版
 * @param {*} options 平台特有的编译选项
 * @returns root
 */
export function parse(
  template: string,
  options: CompilerOptions
): ASTElement | void {
  // 日志
  warn = options.warn || baseWarn

  // 是否为 pre 标签
  platformIsPreTag = options.isPreTag || no
  // 必须使用 props 进行绑定的属性
  platformMustUseProp = options.mustUseProp || no
  // 获取标签的命名空间
  platformGetTagNamespace = options.getTagNamespace || no
  // 是否是保留标签(html + svg)
  const isReservedTag = options.isReservedTag || no
  // 判断一个元素是否为一个组件
  maybeComponent = (el: ASTElement) => !!el.component || !isReservedTag(el.tag)

  // 分别获取 options.modules 下的 class、model、style 三个模块中的 transformNode、preTransformNode、postTransformNode 方法
  // 负责处理元素节点上的 class、style、v-model
  transforms = pluckModuleFunction(options.modules, 'transformNode')
  preTransforms = pluckModuleFunction(options.modules, 'preTransformNode')
  postTransforms = pluckModuleFunction(options.modules, 'postTransformNode')

  // 界定符,比如: {{}}
  delimiters = options.delimiters

  const stack = []
  // 空格选项
  const preserveWhitespace = options.preserveWhitespace !== false
  const whitespaceOption = options.whitespace
  // 根节点,以 root 为根,处理后的节点都会按照层级挂载到 root 下,最后 return 的就是 root,一个 ast 语法树
  let root
  // 当前元素的父元素
  let currentParent
  let inVPre = false
  let inPre = false
  let warned = false
  
  // 解析 html 模版字符串,处理所有标签以及标签上的属性
  // 这里的 parseHTMLOptions 在后面处理过程中用到,再进一步解析
  // 提前解析的话容易让大家岔开思路
  parseHTML(template, parseHtmlOptions)
  
  // 返回生成的 ast 对象
  return root

处理html标签

/**
 * 通过循环遍历 html 模版字符串,依次处理其中的各个标签,以及标签上的属性
 * @param {*} html html 模版
 * @param {*} options 配置项
 */
export function parseHTML(html, options) {
  const stack = []
  const expectHTML = options.expectHTML
  // 是否是自闭合标签
  const isUnaryTag = options.isUnaryTag || no
  // 是否可以只有开始标签
  const canBeLeftOpenTag = options.canBeLeftOpenTag || no
  // 记录当前在原始 html 字符串中的开始位置
  let index = 0
  let last, lastTag
  while (html) {
    last = html
    // 确保不是在 script、style、textarea 这样的纯文本元素中
    if (!lastTag || !isPlainTextElement(lastTag)) {
      // 找第一个 < 字符
      let textEnd = html.indexOf('<')
      // textEnd === 0 说明在开头找到了
      // 分别处理可能找到的注释标签、条件注释标签、Doctype、开始标签、结束标签
      // 每处理完一种情况,就会截断(continue)循环,并且重置 html 字符串,将处理过的标签截掉,下一次循环处理剩余的 html 字符串模版
      if (textEnd === 0) {
        // 处理注释标签 <!-- xx -->
        if (comment.test(html)) {
          // 注释标签的结束索引
          const commentEnd = html.indexOf('-->')

          if (commentEnd >= 0) {
            // 是否应该保留 注释
            if (options.shouldKeepComment) {
              // 得到:注释内容、注释的开始索引、结束索引
              options.comment(html.substring(4, commentEnd), index, index + commentEnd + 3)
            }
            // 调整 html 和 index 变量
            advance(commentEnd + 3)
            continue
          }
        }

        // 处理条件注释标签:<!--[if IE]>
        // http://en.wikipedia.org/wiki/Conditional_comment#Downlevel-revealed_conditional_comment
        if (conditionalComment.test(html)) {
          // 找到结束位置
          const conditionalEnd = html.indexOf(']>')

          if (conditionalEnd >= 0) {
            // 调整 html 和 index 变量
            advance(conditionalEnd + 2)
            continue
          }
        }

        // 处理 Doctype,<!DOCTYPE html>
        const doctypeMatch = html.match(doctype)
        if (doctypeMatch) {
          advance(doctypeMatch[0].length)
          continue
        }

        /**
         * 处理开始标签和结束标签是这整个函数中的核型部分,其它的不用管
         * 这两部分就是在构造 element ast
         */

        // 处理结束标签,比如 </div>
        const endTagMatch = html.match(endTag)
        if (endTagMatch) {
          const curIndex = index
          advance(endTagMatch[0].length)
          // 处理结束标签
          parseEndTag(endTagMatch[1], curIndex, index)
          continue
        }

        // 处理开始标签,比如 <div id="app">,startTagMatch = { tagName: 'div', attrs: [[xx], ...], start: index }
        const startTagMatch = parseStartTag()
        if (startTagMatch) {
          // 进一步处理上一步得到结果,并最后调用 options.start 方法
          // 真正的解析工作都是在这个 start 方法中做的
          handleStartTag(startTagMatch)
          if (shouldIgnoreFirstNewline(startTagMatch.tagName, html)) {
            advance(1)
          }
          continue
        }
      }

      let text, rest, next
      if (textEnd >= 0) {
        // 能走到这儿,说明虽然在 html 中匹配到到了 <xx,但是这不属于上述几种情况,
        // 它就只是一个普通的一段文本:<我是文本
        // 于是从 html 中找到下一个 <,直到 <xx 是上述几种情况的标签,则结束,
        // 在这整个过程中一直在调整 textEnd 的值,作为 html 中下一个有效标签的开始位置

        // 截取 html 模版字符串中 textEnd 之后的内容,rest = <xx
        rest = html.slice(textEnd)
        // 这个 while 循环就是处理 <xx 之后的纯文本情况
        // 截取文本内容,并找到有效标签的开始位置(textEnd)
        while (
          !endTag.test(rest) &&
          !startTagOpen.test(rest) &&
          !comment.test(rest) &&
          !conditionalComment.test(rest)
        ) {
          // 则认为 < 后面的内容为纯文本,然后在这些纯文本中再次找 <
          next = rest.indexOf('<', 1)
          // 如果没找到 <,则直接结束循环
          if (next < 0) break
          // 走到这儿说明在后续的字符串中找到了 <,索引位置为 textEnd
          textEnd += next
          // 截取 html 字符串模版 textEnd 之后的内容赋值给 rest,继续判断之后的字符串是否存在标签
          rest = html.slice(textEnd)
        }
        // 走到这里,说明遍历结束,有两种情况,一种是 < 之后就是一段纯文本,要不就是在后面找到了有效标签,截取文本
        text = html.substring(0, textEnd)
      }

      // 如果 textEnd < 0,说明 html 中就没找到 <,那说明 html 就是一段文本
      if (textEnd < 0) {
        text = html
      }

      // 将文本内容从 html 模版字符串上截取掉
      if (text) {
        advance(text.length)
      }

      // 处理文本
      // 基于文本生成 ast 对象,然后将该 ast 放到它的父元素的肚子里,
      // 即 currentParent.children 数组中
      if (options.chars && text) {
        options.chars(text, index - text.length, index)
      }
    } else {
      // 处理 script、style、textarea 标签的闭合标签
      let endTagLength = 0
      // 开始标签的小写形式
      const stackedTag = lastTag.toLowerCase()
      const reStackedTag = reCache[stackedTag] || (reCache[stackedTag] = new RegExp('([\\s\\S]*?)(</' + stackedTag + '[^>]*>)', 'i'))
      // 匹配并处理开始标签和结束标签之间的所有文本,比如 <script>xx</script>
      const rest = html.replace(reStackedTag, function (all, text, endTag) {
        endTagLength = endTag.length
        if (!isPlainTextElement(stackedTag) && stackedTag !== 'noscript') {
          text = text
            .replace(/<!\--([\s\S]*?)-->/g, '$1') // #7298
            .replace(/<!\[CDATA\[([\s\S]*?)]]>/g, '$1')
        }
        if (shouldIgnoreFirstNewline(stackedTag, text)) {
          text = text.slice(1)
        }
        if (options.chars) {
          options.chars(text)
        }
        return ''
      })
      index += html.length - rest.length
      html = rest
      parseEndTag(stackedTag, index - endTagLength, index)
    }

    // 到这里就处理结束,如果 stack 数组中还有内容,则说明有标签没有被闭合,给出提示信息
    if (html === last) {
      options.chars && options.chars(html)
      if (process.env.NODE_ENV !== 'production' && !stack.length && options.warn) {
        options.warn(`Mal-formatted tag at end of template: "${html}"`, { start: index + html.length })
      }
      break
    }
  }

  // Clean up any remaining tags
  parseEndTag()
}

将 html 字符串模版变成 AST 对象

整个解析过程的核心是处理开始标签和结束标签

  • 遍历 HTML 模版字符串,通过正则表达式匹配 "<"
  • 跳过某些不需要处理的标签,比如:注释标签、条件注释标签、Doctype。
  • 处理开始标签
    • 得到一个对象,包括 标签名(tagName)、所有的属性(attrs)、标签在 html 模版字符串中的索引位置
    • 进一步处理上一步得到的 attrs 属性,将其变成 [{ name: attrName, value: attrVal, start: xx, end: xx }, ...] 的形式
    • 通过标签名、属性对象和当前元素的父元素生成 AST 对象,其实就是一个 普通的 JS 对象,通过 key、value 的形式记录了该元素的一些信息
    • 接下来进一步处理开始标签上的一些指令,比如 v-pre、v-for、v-if、v-once,并将处理结果放到 AST 对象上
    • 处理结束将 ast 对象存放到 stack 数组
    • 处理完成后会截断 html 字符串,将已经处理掉的字符串截掉
  • 解析闭合标签
    • 如果匹配到结束标签,就从 stack 数组中拿出最后一个元素,它和当前匹配到的结束标签是一对。
    • 再次处理开始标签上的属性,这些属性和前面处理的不一样,比如:key、ref、scopedSlot、样式等,并将处理结果放到元素的 AST 对象上
    • 然后将当前元素和父元素产生联系,给当前元素的 ast 对象设置 parent 属性,然后将自己放到父元素的 ast 对象的 children 数组中
  • 最后遍历完整个 html 模版字符串以后,返回 ast 对象

优化

从 HTML 模版字符串开始,解析所有标签以及标签上的各个属性,得到 AST 语法树,然后基于 AST 语法树进行静态标记,首先标记每个节点的static属性,判断其是否为静态的,然后进一步标记出静态根节点。从而将模板编译成渲染函数。

优化的目的

优化ast语法树在后续的更新中可以跳过静态根节点的更新,从而提高性能。

Vue标记静态节点分为两步:

  • 标记所有静态节点
  • 标记静态根节点

标记静态节点的作用:

  1. 每次重新渲染的时候不需要为静态节点创建新节点
  2. 在 Virtual DOM 中 patching 的过程可以被跳过
  3. 标记静态根用于生成渲染函数阶段,生成静态根节点的渲染函数

源码分析

优化入口 optimize

/src/compiler/index.js

export function optimize (root: ?ASTElement, options: CompilerOptions) {
  if (!root) return
  // 函数   获取静态key    options.staticKeys 比如  staticClass、  classStatic
  isStaticKey = genStaticKeysCached(options.staticKeys || '')

  // 判断是否是平台保留标签
  isPlatformReservedTag = options.isReservedTag || no


  // 遍历所有子节点  给每个属性设置 static属性 用于标记静态节点
  markStatic(root)


  // 基于静态节点做进一步的标记静态根节点
  // 节点本身是静态的  &&  有子节点  &&  子节点不能只有一个文本节点   
  // 静态根节点不能只有静态文本的子节点   
  markStaticRoots(root, false)
}

markStatic

目的:就是找到所有静态根节点,然后设置其static = true,这样在进行 patch 的过程跳过该节点。

/src/compiler/optimizer.js

// 标记每个节点是否为静态节点,  通过static属性来标记
function markStatic (node: ASTNode) {
  // 在节点设置static 属性,  判断节点是否为静态节点
  // 根节点为静态则整体是静态的   否则整体都是非静态的
  node.static = isStatic(node)
  if (node.type === 1) {
    // 元素节点    
    
    if (
      !isPlatformReservedTag(node.tag) &&
      node.tag !== 'slot' &&  
      node.attrsMap['inline-template'] == null
    ) {
      // 非平台保留标签 && 不是slot标签 && 没有 inline-template 属性,则直接结束
      return
    }

    // 遍历子节点  递归对每个子节点做静态标记
    for (let i = 0, l = node.children.length; i < l; i++) {
      const child = node.children[i]
      markStatic(child)
      // 如果子节点为动态节点,则需要同时更新父节点为动态节点
      if (!child.static) {
        node.static = false
      }
    }

    // 节点存在 v-if、v-else-if、 v-else 指令  则对block节点进行静态标记 
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        const block = node.ifConditions[i].block
        markStatic(block)
        // 如果子节点为动态节点,则需要同时更新父节点为动态节点
        if (!block.static) {
          node.static = false
        }
      }
    }
// 因为所有的 elseif 和 else 节点都不在 children 中, 
//如果节点的 ifConditions 不为空,则遍历 ifConditions 拿到所有条件中的 block,也就是它们对应的 AST 节点,递归执行 markStatic。
      
      
  }
}

1 是文本节点(node.type === 3)。直接判定为静态节点。 2 有pre属性。这表示开发者不希望节点的内容被编译,因此认定为静态节点。 3 没有动态绑定、没有if/for条件、不是Vue内置组件、不是自定义组件、不是带有for条件的template的直接子元素以及staticKeys没有被缓存。满足这里的所有条件也将被判定为静态节点。

isStatic

/src/compiler/optimizer.js

//  非静态节点(动态节点):
//    表达式、有指令绑定、框架内置标签、v-for 指令内部的template标签
//  其他情况则为静态节点: 比如文本节点


// 判断是否ast元素节点是静态的
function isStatic (node: ASTNode): boolean {
  if (node.type === 2) { // expression  表达式
    return false
  }
  if (node.type === 3) { // text  文本节点
    return true
  }

  // node.pre 则包含 v-pre 指令,判断节点为静态节点       
  return !!(node.pre || (
    !node.hasBindings && // no dynamic bindings
    !node.if && !node.for && // not v-if or v-for or v-else
    !isBuiltInTag(node.tag) && // not a built-in   框架内置标签  component、 slot
    isPlatformReservedTag(node.tag) && // not a component   平台保留标签   不是一个组件
    !isDirectChildOfTemplateFor(node) &&   //不是在v-for 所在节点内的template标签
    Object.keys(node).every(isStaticKey)
  ))
}

静态节点:

文本节点  或是  包含pre指令的标签  (v-pre 以原始的信息进行显示,跳过编译过程   可保留空格、换行)

否则要同时满足以下条件:

  • 没有使用 v-ifv-for
  • 没有使用其它指令(不包括 v-once),
  • 非内置组件,是平台保留的标签,
  • 非带有 v-fortemplate 标签的直接子节点,
  • 节点的所有属性的 key 都满足静态 key;

markStaticRoots

静态根节点:如果某个节点的所有直接子节点都是静态节点,那么该节点就是静态根节点。

一个节点一旦被标记为静态根节点,那么它的直接子节点本身是静态的(但是子节点的子节点不一定)。

这里判定一个节点是否为静态根节点,不需要检查它子节点的后代节点,因为diff算法在做比较的时候,每次就只比较当前节点及其子节点(后代节点通过递归的形式进行比较,与当前节点无关)。因此,只要一个节点是静态的,且直接子元素不变,那它就被认为是静态根节点。

随后就需要对当前节点的子元素进行遍历,来判断它的子元素是否为静态根节点,然后一直递归下去

/src/compiler/optimizer.js

// 对元素节点进行静态根节点标记
function markStaticRoots (node: ASTNode, isInFor: boolean) {
  if (node.type === 1) {
    // 元素节点
    if (node.static || node.once) {
      // 静态节点或存在 v-once 指令的节点   标记当前节点是否被包裹在 v-for 指令所在的节点内部
      node.staticInFor = isInFor   
    }
   
    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], isInFor || !!node.for)
      }
    }

    // 节点存在 v-if、 v-else-if、 v-else时 对block做静态根节点标记
    if (node.ifConditions) {
      for (let i = 1, l = node.ifConditions.length; i < l; i++) {
        markStaticRoots(node.ifConditions[i].block, isInFor)
      }
    }
  }
}
  1. 被标记为static。
  2. 直接子元素存在(children.length > 0)。
  3. 不满足直接子元素只有一个,且是文本节点。

生成渲染函数

代码生成,将 ast 转换成可执行的 render 函数的字符串形式,在调用render() 时可用于生成虚拟node

源码分析

代码生成入口 generate

/**
 * 从 AST 生成渲染函数
 * @returns {
 *   render: `with(this){return _c(tag, data, children)}`,
 *   staticRenderFns: state.staticRenderFns
 * } 
 */
export function generate(
  ast: ASTElement | void,
  options: CompilerOptions
): CodegenResult {
  // 实例化 CodegenState 对象,生成代码的时候需要用到其中的一些东西
  const state = new CodegenState(options)
  // 生成字符串格式的代码,比如:'_c(tag, data, children, normalizationType)'
  // data 为节点上的属性组成 JSON 字符串,比如 '{ key: xx, ref: xx, ... }'
  // children 为所有子节点的字符串格式的代码组成的字符串数组,格式:
  //     `['_c(tag, data, children)', ...],normalizationType`,
  //     最后的 normalization 是 _c 的第四个参数,
  //     表示节点的规范化类型,不是重点,不需要关注
  // 当然 code 并不一定就是 _c,也有可能是其它的,比如整个组件都是静态的,则结果就为 _m(0)
  const code = ast ? genElement(ast, state) : '_c("div")'
  return {
    render: `with(this){return ${code}}`,
    staticRenderFns: state.staticRenderFns
  }
}

函数 _c 是在初始化render环境的时候添加到vue实例上,用来创建 vnode 的全局实例方法。 它可以通vue实例直接调用,负责生成组件或 HTML 元素的 VNode。

编译结果


<div id="app">
  <div v-for="item in arr" :key="item">{{ item }}</div>
</div>

经过编译后生成可执行的字符串

with (this) {
  return _c(
    'div',
    {
      attrs:
      {
        "id": "app"
      }
    },
    _l(
      (arr),
      function (item) {
        return _c(
          'div',
          {
            key: item
          },
          [_v(_s(item))]
        )
      }
    ),
    0
  )
}

render helper 生成 Vnode

渲染函数之所以能生成 vnode 是通过其中的 _c、_l、、_v、_s 等方法实现的。比如:

  • 普通的节点被编译成了可执行 _c 函数
  • v-for 节点被编译成了可执行的 _l 函数

什么是vnode?

vnode 就是组件模版的 JS 对象表现形式,它就是一个普通的 JS 对象,详细描述了组件中各节点的信息。

export function installRenderHelpers (target: any) {
  // _c = $createElement   将组件转变为 vnode  
  // 处理 v-once 指令
  target._o = markOnce
  // 将值转化为数值  通过 praseFloat 实现 
  target._n = toNumber
  // 将值转化为字符串   对象通过 JSON.stringify, 原始值通过 String 强转 
  target._s = toString
  // v-for
  target._l = renderList
  // 插槽 <slot>
  target._t = renderSlot
  // 类似判断两个值是否相等  ==
  target._q = looseEqual
  // 从数组中查找指定元素 并返回元素下标 类似indexOf
  target._i = looseIndexOf
  // 渲染静态节点  负责生成静态树 VNode
  target._m = renderStatic
  // 解析 filter 
  target._f = resolveFilter
  target._k = checkKeyCodes
  target._b = bindObjectProps
  // 为文本节点生成 VNode
  target._v = createTextVNode
  // 为空节点生成 VNode
  target._e = createEmptyVNode
  // 作用域插槽
  target._u = resolveScopedSlots
  target._g = bindObjectListeners
  target._d = bindDynamicKeys
  target._p = prependModifier
}

将一个组件生成 VNode 的具体工作是由 render 函数中的 _c、_o、_l、_m 等方法完成的,这些方法都被挂载到 Vue 实例上面,负责在运行时生成组件 VNode

设置组件配置信息,然后通过 new VNode(组件信息) 生成组件的 VNode

  • _c,负责生成组件或 HTML 元素的 VNode
  • _l,运行时渲染 v-for 列表的帮助函数,循环遍历 val 值,依次为每一项执行 render 方法生成 VNode,最终返回一个 VNode 数组
  • _m,负责生成静态节点的 VNode,即执行 staticRenderFns 数组中指定下标的函数

_c

//  createElement 用来生成虚拟DOM
  // 将createElement函数挂载到 vm 的 _c 和 $createElement 两个属性上
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

案例

  1. 断点调试

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Vue</title>
  <script src="./dist/vue.js"></script>
</head>
<body>
  <div id="app"></div>
  
  <script>
    new Vue({
      el:' #app',
      template: `
        <div>
          <ul :class="bindCls" class="list" v-if="isShow">
            <li v-for="(item, index) in data" :key="index" @click="clickItem(index)">
              {{item}}:<{{index}}
            </li>
          </ul>
          <div v-else>
            <p>111</p>
          </div>
        </div>`,
      data() {
        return {
          bindCls: 'a',
          isShow: true,
          data: ['A', 'B', 'C', 'D']
        }
      },
      methods: {
        clickItem(index) {
          console.log(index)
        }
      }
    })
    
  </script>
</body>
</html>

因为所有的  elseif  和 else 节点都不在  children  中, 如果节点的  ifConditions  不为空,则遍历 ifConditions 拿到所有条件中的 block ,也就是它们对应的AST 节点,递归执行 markStatic。

  1. prase 解析生成的 ast 语法树

Vue 编译器 -- 生成render函数 根据生成的 ast树 ,所有的 elseif 和 else 节点都不在 children 中, 如果节点的 ifConditions 不为空,则遍历 ifConditions 拿到所有条件中的 block,也就是它们对应的 AST 节点,递归执行 markStatic。

  1. optimize 优化过后的 ast 语法树

Vue 编译器 -- 生成render函数 发现每一个 AST 元素节点都多了 staic属性,并且 type为 1 的普通元素 AST 节点多了 staticRoot 属性。

总结

这又是一篇长文啊 😂,但其实本文的目的很简单就是想说明 Vue 在编译阶段完成的三件事:

  • 将组件的 html 模版解析成 AST 对象
  • 优化 -- 遍历 AST,为每个节点做静态标记,标记其是否为静态节点,然后进一步标记出静态根节点,这样在后续更新的过程中就可以跳过这些静态节点了;标记静态根用于生成渲染函数阶段,生成静态根节点的渲染函数
  • 将 AST 语法树生成运行渲染函数,即大家说的 render函数,同时还生成了 staticRenderFns 数组,里面存放了所有的静态节点的渲染函数

讲解的过程可能不是很流畅,但是希望能够帮助大家深入学习,

了解更多关于 vue 的底层原理上的实现,与大家一起分享学习的过程,逐步提高,共同进步 💪