侧边栏

Vue源码阅读笔记之模板渲染(三)

发布于 | 分类于 源码分析/Vue

这是”Vue源码阅读笔记”系列第三篇文章。在前面我们分析了Vue响应式数据系统,并了解到当数据变化时会通知变化属性的Watcher,然后更新视图。渲染模板和更新视图的逻辑均由Vue内部封装,我们只需要关注数据的逻辑即可,接下来就让我们学习Vue的模板系统。

参考:

我们知道Vue不仅可以运行在浏览器环境中,也可以通过SSR渲染。因此针对不同的运行环境,输出结果肯定是不一样的。我们从Vue.prototype._init方法中可以找到相关模板挂载方法的调用

Js
if (vm.$options.el) {
	vm.$mount(vm.$options.el)
}

在不同的运行环境中,Vue.prototype.$mount的实现是不一样的,我们从这里入手。

Vue.prototype.$mount

首先我们找到浏览器环境下$mount的实现

Js
// /src/platforms/entry-runtime-with-compiler.js

const mount = Vue.prototype.$mount

Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)

  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    let template = options.template
    
    // 这里对template及el参数进行重载,用于获取模板内容
    // ...
    
    // 然后对template进行编译
    if (template) {
      const { render, staticRenderFns } = compileToFunctions(template, {
        shouldDecodeNewlines,
        shouldDecodeNewlinesForHref,
        delimiters: options.delimiters,
        comments: options.comments
      }, this)
      options.render = render
      options.staticRenderFns = staticRenderFns
    }
  }
  
  // 调用公共的mount方法,
  // 内部调用的是 /src/core/instance/lifecycle.js中的 mountComponent 方法
  return mount.call(this, el, hydrating)
}

先不考虑options.render的情况,$mount中做了三件事:

  • 找到对应的模板
  • 将模板进行解析,并将解析结果renderstaticRenderFns挂载到options上面
  • 调用mountComponent,并执行相关渲染函数render

文档:渲染函数 & JSX可以得知,Vue允许我们通过render配置项替代template选项,其格式为

js
(createElement: () => VNode) => VNode

通过createElement生成VNode,通过VNode构建虚拟DOM树

如果配置参数中存在render则会跳过第二步,那么先让我们来看看对应的render函数的作用,然后再去分析如果将template转换成render函数的。

Render渲染函数

执行流程

首先来看看mountComponent方法的工作内容

Js
// /src/core/instance/lifecycle.js
export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  vm.$el = el
  if (!vm.$options.render) {
    vm.$options.render = createEmptyVNode
  }
  callHook(vm, 'beforeMount')

  let updateComponent
  updateComponent = () => {
      const name = vm._name
      const id = vm._uid
      // 这里移除了性能测量相关的一些代码
      // 可以看见,_render()函数返回的就是VNode节点
      const vnode = vm._render()
      
      vm._update(vnode, hydrating)
  }
  
  // 实例Watcher对象,绑定响应式数据,这在下面的模板更新会提到
  new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)
  hydrating = false

  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

可以看见的是,实例化Watcher对象是在这里进行的

Js
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)

在前一篇分析响应式原理中了解到,在Watcher构造函数中,如果expOrFn是函数(这里就是updateComponent函数),则会将其赋值给watcher.getter,然后调用一次getter实现依赖收集。

了解了执行流程,我们知道了在$mount函数中会调用vm._render方法,在前面分析Vue原型对象的时候了解到,

  • vm._render()Vue.prototype._renderlifecycleMixin实现
  • vm._updaterenderMixin中实现,这里面主要实现diff,我们暂时放在一边
Js
// /src/core/instance/render.js

Vue.prototype._render = function (): VNode {
    const vm: Component = this
  	// 没错,终于找到了配置参数上面的render函数,也就是template编译后生成的render函数
    const { render, _parentVnode } = vm.$options
    // 这里移除了$slots相关处理代码
    vm.$vnode = _parentVnode
    // 这里移除了渲染错误相关处理代码
  	
    let vnode
    try {
      // render函数在这里被调用
      // 其中vm._renderProxy在 /src/core/instance/proxy.js中定义
      vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
      handleError(e, vm, `render`)
    }
   
    vnode.parent = _parentVnode
    return vnode
  }

最后,我们终于找到了调用render的地方

Js
vnode = render.call(vm._renderProxy, vm.$createElement)

不论是通过配置参数传入的render函数直接,还是通过template编译后得到的render函数,最后都会在这里被调用,并返回对应的VNode,那么render函数和VNode到底长啥样呢?

VNode

Vue2引入了虚拟DOM的概念。

高效的更新所有这些节点会是比较困难的,不过所幸你不必再手动完成这个工作了。你只需要告诉 Vue 你希望页面上的 HTML 是什么

上面提到的“这些节点”指的是页面中的真实DOM,由于操作真实DOM的性能代价比较昂贵,

  • 构建一个真实的DOM对象,其大部分属性是我们不需要的
  • 维护许多真实DOM对象的更新,需要占据大量的内存

而虚拟DOM可以看做是简化版的DOM树,虚拟DOM树上的虚拟节点即VNode,他们通过JavaScript对象构建:

VNode它更准确的名字可能是 createNodeDescription,因为它所包含的信息会告诉 Vue 页面上需要渲染什么样的节点,及其子节点

执行JavaScript代码的效率是远远高于操作真实DOM的效率的。关于虚拟DOM的深入理解及实现,可以参考这篇文章:理解 Virtual DOM

这里我们需要知道的是:render函数返回的,就是一个虚拟的DOM树

render函数的形式

为了方便分析,我们来写个最简单的模板测试下,然后跟着断点前进

Html
<div id="app">
    <h1>{{ msg }}</h1>
  	<ul>
        <li v-for="item in arr">{{item}}</li>
    </ul>
</div>
<script>
  	let vm = new Vue({
        el: "#app",
        data: {
            msg: "Hello Vue",
          	arr: [1,2,3]
        },
    })
</script>

先打印render函数看看

Js
function anonymous() {
  with (this) {
    return _c("div", { attrs: { id: "app" } }, [
      _c("h1", [_v(_s(msg))]),
      _v(" "),
      _c(
        "ul",
        _l(arr, function(item) {
          return _c("li", [_v(_s(item))]);
        })
      )
    ]);
  }
}

如果把上面的模板通过配置参数的render函数实现

html
<div id="app"></div>
<script>
	let vm = new Vue({
        el: "#app",
        data: {
            msg: "Hello World",
            arr: [1,2,3]
        },
        render: function (createElement) {
            return createElement("div", {
                attrs: {
                    id: "#app"
                }
            }, [
                createElement("h1", this.msg),
                createElement("ul", [
                    this.arr.map(function (item) {
                        return createElement('li', item)
                    })
                ])
            ])
        }
    })
</script>

可以发现,我们自定义的render函数,与通过template编译获得的render函数惊人地相似。

那么,也就不难理解_c_v_l这些字母函数的作用了:生成对应的VNode和虚拟DOM树结构。

回到上面那个匿名的render函数,通过with绑定当前作用域到vm._renderProxy(可以理解为vm自身)。也就是说,接下来我们的目标就是去寻找的来源及作用。

render函数的辅助函数

同样是在前面分析Vue构造函数和对象的时候了解到,相关的方法是通过renderMixin函数中调用installRenderHelpers(Vue.prototype)注册的。(PS:侧面说明先熟悉整体结构的必要性,比到处乱翻源码要轻松得多啊~)

Js
// /src/core/instance/render-helpers.js
export function installRenderHelpers (target: any) {
  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
}

慢着!上面好像漏掉了最重要的vm._c函数,这是在_init方法中通过initRender方法注册的。

Js
vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)

我们先不用管具体的实现细节(尤其是createElement),只需要知道他们是用来方便生成对应的DOM结构即可,这些方法最终会组成render函数然后被执行。

OK,现在我们了解了$mount方法的执行流程,也清楚了render函数的大致样子和作用。接下来我们看看,如果通过配置参数传入template,Vue内部是如何将其编译成render函数的。

生成Render函数

执行流程

回到Vue.prototype.$mount的实现

Js
const { render, staticRenderFns } = compileToFunctions(template, {
	//...
	}, this)
options.render = render

逐步找到compileToFunctions的出处,由于代码比较多,为了更清晰地了解整个流程,简化了很多代码,只保留了参数和返回值。

js
// /src/platforms/web/compiler/index.js
const { compile, compileToFunctions } = createCompiler(baseOptions)

// /src/compiler/index.js
// Vue允许自定义其他的编译函数,这里声明编译函数的接口,并提供了一个基本的编译函数
export const createCompiler = createCompilerCreator(function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  // 将模板解析成AST树
  const ast = parse(template.trim(), options)
  // 优化AST树
  optimize(ast, options)
  // 编译模板数据
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
})

// /src/compiler/create-compiler.js
export function createCompilerCreator (baseCompile: Function): Function {
  return function createCompiler (baseOptions: CompilerOptions) {
    function compile(){
      // ... 
      // 编译函数的返回结果,就是上面的 { ast, render, staticRenderFns }
      const compiled = baseCompile(template, finalOptions)
      return compiled
    }
    return {
      compile,
      // 传入compile函数,获得compileToFunctions函数
      compileToFunctions: createCompileToFunctionFn(compile)
    }
  }
}

// /src/compiler/to-function.js
export function createCompileToFunctionFn (compile: Function): Function {
  return function compileToFunctions (
    template: string,
    options?: CompilerOptions,
    vm?: Component
    ): CompiledFunctionResult {
    // ...
    const compiled = compile(template, options)
    // 这里即$mount方法中编译模板得到的结果
    res.render = createFunction(compiled.render, fnGenErrors)
    res.staticRenderFns = compiled.staticRenderFns.map(code => {
      return createFunction(code, fnGenErrors)
    })
    return (cache[key] = res)
  }
}

这里的使用了大量的闭包,不要被绕晕了,简单梳理一下流程

  • 编译模板需要实现一个compile编译函数的接口,Vue内置了一个baseCompile函数

  • createCompilerCreator接收baseCompile函数,并返回createCompiler函数

  • createCompileToFunctionFn接收一个compile编译函数,并返回compileToFunctions

  • createCompiler函数中调用baseCompile构建compile函数,并将compile函数传递给createCompileToFunctionFn,返回compileToFunctions函数

  • compileToFunctions函数接收一个template模板和配置参数,最后返回render函数

好吧,这个确实有点绕。

baseCompile

事实上经过简单的分析,我们就可以发现,真正的解析工作是在baseCompile这个函数中进行的

Js
function baseCompile (
  template: string,
  options: CompilerOptions
): CompiledResult {
  const ast = parse(template.trim(), options)
  optimize(ast, options)
  const code = generate(ast, options)
  return {
    ast,
    render: code.render,
    staticRenderFns: code.staticRenderFns
  }
}

可以发现这里执行的三个函数:parseoptimizegenerate,找到对应的定义文件,先大致了解他们的作用。

parse

这个函数主要用来将 template字符串解析成 AST

首先要理解的是AST,下面摘自维基百科:

计算机科学中,抽象语法树abstract syntax tree或者缩写为AST),或者语法树syntax tree),是源代码的抽象语法结构的状表现形式,这里特指编程语言源代码。树上的每个节点都表示源代码中的一种结构

/flow/compiler.js中可以找到AST的相关类型声明,包含ASTElementASTExpressionASTText三种类型。

也就是说,parse的主要功能就是解析template,包括模板标签、指令和属性等,并返回一个AST对象。其内部实现比较复杂,大致是通过正则匹配进行的。

之前写过的一个简单的JS模板引擎,至于Vue这里具体的解析实现,暂时没有深入。可以先看看返回的ast结构

Js
{type: 1, tag: "div", attrsList: Array(1), attrsMap: {…}, parent: undefined, …}

optimize

这个函数主要用来标记ast的静态节点,优化虚拟DOM树,被标记为 static 的节点,会在更新时被忽略,为后面 patch 过程中对比新旧 VNode 树形结构做优化

generate

这个函数根据 ast 拼接生成 render 函数的字符串,在其内部对ast结构进行解析,使用在前面提到的render辅助函数,并拼接成对应的虚拟DOM树。

最后生成的render函数,就是我们控制台所输出的那种形式~

小结

render函数的生成是一个比较复杂的地方,这里我直接避开了其内部的具体实现,只是分析了从template到render函数的转换流程,目的是为了避免陷入模板解析的泥潭中。实际上这并不影响我们后续的分析(嗯~假装自己说的很有道理)。

模板更新

mountComponent函数中,我们找到了下面的代码

js
let updateComponent
updateComponent = () => {
      const name = vm._name
      const id = vm._uid
      const vnode = vm._render()
      vm._update(vnode, hydrating)
}
  
// 实例Watcher对象,绑定响应式数据
new Watcher(vm, updateComponent, noop, null, true /* isRenderWatcher */)

我们已经知道,

  • 在进行依赖收集时,会对调用一次get,这里即updateComponent函数;
  • 在数据发生变化时,Dep会通知Watcher订阅者的update方法,实际上又会调用updateComponent
  • updateComponent方法内部,调用了 vm._rendervm._update方法

在上一节我们已经分析了render函数,现在让我们来看看update的实现。

Js
// /src/core/instance/lifecycle.js

Vue.prototype._update = function (vnode: VNode, hydrating?: boolean) {
    const vm: Component = this
    const prevEl = vm.$el
    const prevVnode = vm._vnode
    const prevActiveInstance = activeInstance
    activeInstance = vm
    vm._vnode = vnode

    if (!prevVnode) {
      // 第一次初始化
      vm.$el = vm.__patch__(
        vm.$el, vnode, hydrating, false /* removeOnly */,
        vm.$options._parentElm,
        vm.$options._refElm
      )
      vm.$options._parentElm = vm.$options._refElm = null
    } else {
      // 数据变化,diff新旧虚拟DOM树,修改变化的VNode
      vm.$el = vm.__patch__(prevVnode, vnode)
    }
}

其中vm.__patch__/src/instance/vdom/patch.js中定义。其作用就是实现新旧 VNode 对比的 diff 函数,然后将对应的VNode转换成真实DOM,然后添加到页面上。如果想要深入diff算法,可以参考这篇文章:解析vue2.0的diff算法

换句话说,视图的更新是在vm._update中,通过vm.__patch__实现的。

总结

至此,整个模板渲染和更新的过程似乎已经逐渐清晰了,简单整理一下:

  • initData中,通过Observer修改data的属性访问描述符,将其转换为可监控的值

  • $mount 中,通过 updateComponent实例化了Watcher对象,并在updateComponent这个getter中对 renderupdate求值,

    • 第一次执行render 时会编译模板,并生成对应的render函数,render函数会返回虚拟DOM树;
    • 第一次执行update时,会将VNode转换为真实DOM,然后渲染页面。
  • 当变量改变时,Dep通过调用Watcher对象的update方法,再次执行updateComponent

    • 后续执行render时,会从缓存中提取已编译的render函数,
    • 后续执行update时,会通过diff算法计算需要更新的VNode,然后实现 re-render,实现视图的更新。

这篇文章的篇幅不短,实际上只研究了两个地方:

  • template编译成render函数的过程
  • 数据变化自动更新视图的实现

其中还有很多细节,比如模板转换成AST、diff算法等,都没有进一步深入,这些地方还需要继续学习才行。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。