模板会被编译成 render 函数

我们知道 Vue 组件可以通过两种方式写模板,一种是通过 template 写字符串,另一种方式是直接写 render 函数。我们最常用的就是 template 字符串模板。而render 函数我们一般不会用到。官方的一个 render 示例如下:

  1. Vue.component('anchored-heading', {
  2. render: function (createElement) {
  3. return createElement(
  4. 'h' + this.level, // tag name
  5. this.$slots.default // array of children
  6. )
  7. }
  8. }

template 字符串最终会被编译成 render 函数,根据配置的不同,有可能是在webpack编译代码的时候,也可能是在运行时编译的。这一点,其实和 React 的JSX很相似。无论字符串模板还是JSX,都是为了减少我们通过 createElement 写模板的痛苦。

template1

如果我们选择了 runtime only 的 Vue 版本,那么由于没有 compiler, 所以只能在webpack中通过 vue-loadertemplate 编译成 render 函数。因为这种做法涉及到 webpackvue-loader 相关内容,这里我们讲解第二种方式,也就是通过 compiler 在浏览器中动态编译模板的方式。

render 函数如何生成

为了弄清楚模板被编译的过程,我们假设有如下代码:

  1. <div id="app"></div>
  2. <script>
  3. var app = new Vue({
  4. el: '#app',
  5. template: `
  6. <div class="hello">{{message}}</div>
  7. `,
  8. data: {
  9. message: 'Hello Vue!'
  10. }
  11. })
  12. </script>

我把代码都放在github了,如果你希望自己动手试一下,可以克隆这个仓库:https://github.com/lihongxun945/my-simple-vue-app

这里代码非常简单,就是一个模板中间输出了一个 message 变量。模板编译的入口,是在 $mount 函数中:

platform/web/entry-runtime-with-compiler

  1. const mount = Vue.prototype.$mount
  2. Vue.prototype.$mount = function (
  3. el?: string | Element,
  4. hydrating?: boolean
  5. ): Component {
  6. el = el && query(el)
  7. /* istanbul ignore if */
  8. if (el === document.body || el === document.documentElement) {
  9. process.env.NODE_ENV !== 'production' && warn(
  10. `Do not mount Vue to <html> or <body> - mount to normal elements instead.`
  11. )
  12. return this
  13. }
  14. const options = this.$options
  15. // resolve template/el and convert to render function
  16. if (!options.render) {
  17. let template = options.template
  18. if (template) {
  19. if (typeof template === 'string') {
  20. if (template.charAt(0) === '#') {
  21. template = idToTemplate(template)
  22. /* istanbul ignore if */
  23. if (process.env.NODE_ENV !== 'production' && !template) {
  24. warn(
  25. `Template element not found or is empty: ${options.template}`,
  26. this
  27. )
  28. }
  29. }
  30. } else if (template.nodeType) {
  31. template = template.innerHTML
  32. } else {
  33. if (process.env.NODE_ENV !== 'production') {
  34. warn('invalid template option:' + template, this)
  35. }
  36. return this
  37. }
  38. } else if (el) {
  39. template = getOuterHTML(el)
  40. }
  41. if (template) {
  42. /* istanbul ignore if */
  43. if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
  44. mark('compile')
  45. }
  46. const { render, staticRenderFns } = compileToFunctions(template, {
  47. shouldDecodeNewlines,
  48. shouldDecodeNewlinesForHref,
  49. delimiters: options.delimiters,
  50. comments: options.comments
  51. }, this)
  52. options.render = render
  53. options.staticRenderFns = staticRenderFns
  54. /* istanbul ignore if */
  55. if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
  56. mark('compile end')
  57. measure(`vue ${this._name} compile`, 'compile', 'compile end')
  58. }
  59. }
  60. }
  61. return mount.call(this, el, hydrating)
  62. }

完整的代码有些长,但是仔细看代码会发现其实逻辑很简单,代码主要包含两个 if 语句。第一段 if(template) {}else {} 的作用是处理各种不同写法的 template ,比如可能是 #id 或者是一个 DOM 元素,最终都会被转成一个字符串模板。这样经过处理之后,第二段 if(template)中的 template 就是一个字符串模板了,删除一些开发环境的性能代码,最终编译模板的代码如下:

  1. if (template) {
  2. const { render, staticRenderFns } = compileToFunctions(template, {
  3. shouldDecodeNewlines,
  4. shouldDecodeNewlinesForHref,
  5. delimiters: options.delimiters,
  6. comments: options.comments
  7. }, this)
  8. options.render = render
  9. options.staticRenderFns = staticRenderFns
  10. }
  11. }

这里调用了 ompileToFunctions 方法,传入了三个参数分别是:

  • template 字符串,这里就是 <div class="hello">{{message}}</div>
  • 一些编译时的参数
  • this

返回的结果中包含了一个 render 函数和一个 staticRenderFns 方法。我们暂时跳过 staticRenderFns ,来看看 render 函数,他其实就是一个匿名函数,由于我们的模板特别简单,因此这个函数也很简单:

  1. ƒ anonymous(
  2. ) {
  3. with(this){return _c('div',{staticClass:"hello"},[_v(_s(message))])}
  4. }

只看这个函数结果显然是看不懂的,那么我们还是从源码入手,看看 compileToFunctions 函数都做了什么。

platform/web/compiler/index.js

  1. import { baseOptions } from './options'
  2. import { createCompiler } from 'compiler/index'
  3. const { compile, compileToFunctions } = createCompiler(baseOptions)
  4. export { compile, compileToFunctions }

这里调用了 createCompiler 方法生成了 compilecompileToFunctions 两个方法,我们先看看 baseOptions

platform/web/compiler/options.js

  1. export const baseOptions: CompilerOptions = {
  2. expectHTML: true,
  3. modules,
  4. directives,
  5. isPreTag,
  6. isUnaryTag,
  7. mustUseProp,
  8. canBeLeftOpenTag,
  9. isReservedTag,
  10. getTagNamespace,
  11. staticKeys: genStaticKeys(modules)
  12. }

baseOptions 是一些编译选项,因为不同平台的编译方式不同,这里我们暂且不去深究这些选项。我们再看 createCompiler 函数的定义:

compiler/index.js

  1. export const createCompiler = createCompilerCreator(function baseCompile (
  2. template: string,
  3. options: CompilerOptions
  4. ): CompiledResult {
  5. const ast = parse(template.trim(), options)
  6. if (options.optimize !== false) {
  7. optimize(ast, options)
  8. }
  9. const code = generate(ast, options)
  10. return {
  11. ast,
  12. render: code.render,
  13. staticRenderFns: code.staticRenderFns
  14. }
  15. })

这里又是一个函数调用,createCompilercreateCompilerCreator 的返回值,他在调用的时候传入了一个 baseCompile 函数作为参数,从这个调用方式我们知道createCompilerCreator 肯定是返回了一个 createCompiler 函数。这是典型的柯里化,可以复用参数,减少单次调用传递参数的个数。记住这些,我们继续往下看:

compiler/create-compiler.js

  1. export function createCompilerCreator (baseCompile: Function): Function {
  2. return function createCompiler (baseOptions: CompilerOptions) {
  3. function compile (
  4. template: string,
  5. options?: CompilerOptions
  6. ): CompiledResult {
  7. const finalOptions = Object.create(baseOptions)
  8. const errors = []
  9. const tips = []
  10. finalOptions.warn = (msg, tip) => {
  11. (tip ? tips : errors).push(msg)
  12. }
  13. if (options) {
  14. // merge custom modules
  15. if (options.modules) {
  16. finalOptions.modules =
  17. (baseOptions.modules || []).concat(options.modules)
  18. }
  19. // merge custom directives
  20. if (options.directives) {
  21. finalOptions.directives = extend(
  22. Object.create(baseOptions.directives || null),
  23. options.directives
  24. )
  25. }
  26. // copy other options
  27. for (const key in options) {
  28. if (key !== 'modules' && key !== 'directives') {
  29. finalOptions[key] = options[key]
  30. }
  31. }
  32. }
  33. const compiled = baseCompile(template, finalOptions)
  34. if (process.env.NODE_ENV !== 'production') {
  35. errors.push.apply(errors, detectErrors(compiled.ast))
  36. }
  37. compiled.errors = errors
  38. compiled.tips = tips
  39. return compiled
  40. }
  41. return {
  42. compile,
  43. compileToFunctions: createCompileToFunctionFn(compile)
  44. }
  45. }
  46. }

我们终于看到了createCompiler的真面目,这个函数其实用到了两个参数,一个是闭包中的 baseCompile,一个是自己的形参 baseOptions ,返回的结果中的 compile 是一个函数。那么 createCompileToFunctions 又是什么呢?这里我不展开了,它其实主要作用是把 compile 返回的结果中的函数字符串转化成一个真正的函数。

所以编译的主要逻辑都在 compile 函数中,我们再看函数体前面的一大段都是对 options 的处理,首先finalOptions 通过原型链完整继承了 baseOptions:

  1. const finalOptions = Object.create(baseOptions)

然后增加了一个 warn 方法,接着对用户自定义的 modulesdirectives ,全部和 baseOptions 进行了合并。baseOptions 中的指令目前包括三个 v-text, v-htmlv-model

在处理完 options 之后,就会调用 baseCompile 函数进行模板编译,生成的结果 compiled 结构如下:

  • ast 模板解析出来的抽象语法树
  • render 我们前面提到的 render 函数,不过要注意的是,此时的render函数是一个字符串,而不是一个真正的函数
  • staticRenderFns 编译的辅助函数

compileToFunctions 会把 compile 包装一层,把他的结果中的 render 转换成一个可执行的函数,这才是我们最终要的结果。转换的核心代码如下:

  1. res.render = createFunction(compiled.render, fnGenErrors)

这样经过一大推函数调用和柯里化,我们终于得到了 render 函数。至于抽象语法树的解析过程,我们会放到后面一个单独的章节来讲。

组件挂载和更新

让我们回到$mount 函数,他最终调用了 mount 函数,这个函数只做了一件事,就是调用 mountComponent 挂载组件。 mountComponent 代码比较长,其中重要的代码如下:

  1. export function mountComponent (
  2. vm: Component,
  3. el: ?Element,
  4. hydrating?: boolean
  5. ): Component {
  6. vm.$el = el
  7. // 省略
  8. callHook(vm, 'beforeMount')
  9. let updateComponent
  10. // 省略
  11. updateComponent = () => {
  12. vm._update(vm._render(), hydrating)
  13. }
  14. // we set this to vm._watcher inside the watcher's constructor
  15. // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  16. // component's mounted hook), which relies on vm._watcher being already defined
  17. new Watcher(vm, updateComponent, noop, {
  18. before () {
  19. if (vm._isMounted) {
  20. callHook(vm, 'beforeUpdate')
  21. }
  22. }
  23. }, true /* isRenderWatcher */)
  24. hydrating = false
  25. // manually mounted instance, call mounted on self
  26. // mounted is called for render-created child components in its inserted hook
  27. if (vm.$vnode == null) {
  28. vm._isMounted = true
  29. callHook(vm, 'mounted')
  30. }
  31. return vm
  32. }

最核心的代码是如下几行:

  1. updateComponent = () => {
  2. vm._update(vm._render(), hydrating)
  3. }
  4. // we set this to vm._watcher inside the watcher's constructor
  5. // since the watcher's initial patch may call $forceUpdate (e.g. inside child
  6. // component's mounted hook), which relies on vm._watcher being already defined
  7. new Watcher(vm, updateComponent, noop, {
  8. before () {
  9. if (vm._isMounted) {
  10. callHook(vm, 'beforeUpdate')
  11. }
  12. }
  13. }, true /* isRenderWatcher */)

首先创建了一个 updateComponent 函数,他会调用 vm._update 更新组件。然后,创建了一个 watcher,只要vm发生变化,就会触发一次 update,最终会触发 getter 也就是 updateComponent 函数。我花了一个简单的图,我们可以理解组件是如何被更新的:

template1

其中红色的箭头,就是我们更新了组件状态之后的调用过程。因为之前讲过 Watcher 这里我们就不再重复这一块。有了 watcher 观察,我们在 vm 上进行任何修改,比如 this.message ='xxx' 修改数据,就会触发一次更新。不过有一点需要注意一下,就是这个watcher 其实并不是 deep 的,因为 vm 本身已经是响应式的了,所以没有必要重复监听它的所有属性。

我们在本章有两个疑问没有解决:

  • render函数的生成过程是怎样的?回答这个问题需要我们深入到 compiler 内部理解他的工作原理
  • _update 函数则涉及到 VDOM相关的内容

这两个问题我们在接下来的文章中解读

下一章,让我们理解 compiler 的工作原理。

下一章:Vue2.x源码解析系列七:深入Compiler理解render函数的生成过程