Vue2源码系列-render函数

989 阅读6分钟

前面我们对 Vue 实例化的大致流程进行了梳理。现在我们再具体看看初始化中的 initRender 的处理,通过本篇文章可以学习到 Vuerender 函数处理逻辑。

初始化渲染函数

上篇文章我们分析了初始化逻辑

let uid = 0

export function initMixin (Vue: Class<Component>) {
  Vue.prototype._init = function (options?: Object) {
    const vm: Component = this
    vm._uid = uid++
    //...
    initLifecycle(vm)
    initEvents(vm)
    initRender(vm)
    callHook(vm, 'beforeCreate')
    initInjections(vm) // resolve injections before data/props
    initState(vm)
    initProvide(vm) // resolve provide after data/props
    callHook(vm, 'created')

    // 挂载节点
    if (vm.$options.el) {
      vm.$mount(vm.$options.el)
    }
  }
}

其中的 initRender(vm) 我们没有进行深入分析,那是为了留给今天

export function initRender (vm: Component) {
  // ...
  vm._c = (a, b, c, d) => createElement(vm, a, b, c, d, false)
  // normalization is always applied for the public version, used in
  // user-written render functions.

  // 为实例添加了两个方法 其中只有一个参数不同
  // 我们主要分析 $createElement
  vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)
  // ... 
}

我们可以看到,除去我省略的(分别和 slot / $attrs|listeners 相关 )不影响主流程的代码,initRender 仅仅为实例添加了方法 $createElement 并透传了参数

挂载实例

初始化之后,我们来到 _init 函数的最后一行

// 挂载节点
if (vm.$options.el) {
  vm.$mount(vm.$options.el)
}

这不是 $mount 方法么,激动啊,终于要开始渲染了么。问题是我们没看到 $mount 在哪里定义的呀

挂载入口

我们前面说过,在溯源得到 Vue 函数的时候,发现在溯源链路上不同的文件或多或少都有对 Vue 进行改造,或添加修改静态方法,或修改原型方法。既然没有看到 $mount 方法,那我们再从入口开始。

努力的人运气都不会太差,恰好 entry-runtime-with-compiler.js 中就找到了 $mount

// 先缓存原有的 $mount 函数
const mount = Vue.prototype.$mount
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  el = el && query(el)

  // 挂载根节点提示
  /* istanbul ignore if */
  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
  }

  // 判断是否有 render 函数,没有的话会调用 compileToFunctions 将 template 编译成 render 函数
  // 我们选择自己写 render 函数 就不用分析 compileToFunctions 过程了 偷懒就是这么简单
  const options = this.$options
  // resolve template/el and convert to render function
  if (!options.render) {
    let template = options.template
    if (template) {
      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 = template.innerHTML
      } else {
        if (process.env.NODE_ENV !== 'production') {
          warn('invalid template option:' + template, this)
        }
        return this
      }
    } else if (el) {
      template = getOuterHTML(el)
    }
    if (template) {
      /* istanbul ignore if */
      if (process.env.NODE_ENV !== 'production' && config.performance && mark) {
        mark('compile')
      }

      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 方法主要是对配置 options 进行判断处理,如果定义了 render 方法,则啥事没有。如果没有则继续判断是否定义了 el,有则获取元素的内容 innerHTML 作为 template。最终通过 compileToFunctions 生成 options.render

还有个注意点是,前面我们缓存了 $mount 方法,最后我们又调用了刚才缓存的 $mount。其实这边的函数仅仅是对 options.render 进行了判断或生成。这样做的好处是将不同的逻辑分散在不同的文件模块中,很好地进行解耦,最后通过装饰器的效果实现整体代码,这点很值得我们学习和思考。当我们不知道一个函数到底该叫什么,或者到底属于哪部分时,不妨将其拆分解耦,再通过装饰器效果添加逻辑,这样各部分代码就不耦合杂糅。

$mount

接着我们继续往上溯源寻找我们的 $mount 真面目,在 plateform/web/runtime/index.js 找到了它

这时候突有所悟,这段代码在 web 目录下找到的,不就说明是和平台相关的么,前面解耦的作用不就体现的淋漓尽致,不管我的 $mount 是如何基于平台渲染的,都不用管我外层 render 函数的定义,他们的逻辑相互分离,简直妙不可言

// public mount method
Vue.prototype.$mount = function (
  el?: string | Element,
  hydrating?: boolean
): Component {
  // 这边对 el 还有个兼容处理 开发者可以自己配置 dom 节点 或者让框架帮你去查找 dom 节点
  el = el && inBrowser ? query(el) : undefined
  return mountComponent(this, el, hydrating)
}

mountComponent

接着来到 mountComponent 函数在 core/instance/lifecycle.js,探探其逻辑。

export function mountComponent (
  vm: Component,
  el: ?Element,
  hydrating?: boolean
): Component {
  // 跳过render检查
  vm.$el = el
  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 {
        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 = () => {
      vm._update(vm._render(), hydrating)
    }
  }

  // 此处watcher用于订阅数据改变时调用 updateComponent 回调函数,是我们响应式的原理
  // 当然创建Watcher实例的时候也会触发一次 updateComponent 函数
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted && !vm._isDestroyed) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)
  
  // 又是熟悉得钩子
  if (vm.$vnode == null) {
    vm._isMounted = true
    callHook(vm, 'mounted')
  }
  return vm
}

通过分析可得 mountComponent 主要就是创建了 Watcher 实例。首次及当数据变化时调用 updateComponent 实现渲染。我们就来分析下 updateComponent 的逻辑

vm._update(vm._render())

代码虽短,但逻辑不少,我们先看看 vm._render()

不好,又遇到难题了,我们前面分析了 vm.$options.render 的来源,怎么这边又来了个 vm._render,这又是个啥

renderMixin

寻根溯源,在 core/instance/index.js 我们找到这么一段代码

// ...
initMixin(Vue)
stateMixin(Vue)
eventsMixin(Vue)
lifecycleMixin(Vue)
renderMixin(Vue)

没错 renderMixin 就是我们的猎物,我们看看其中代码实现

export function renderMixin (Vue: Class<Component>) {
  // install runtime convenience helpers
  installRenderHelpers(Vue.prototype)

  const { render, _parentVnode } = vm.$options

  Vue.prototype.$nextTick = function (fn: Function) {
    return nextTick(fn, this)
  }

  // 重点关注
  Vue.prototype._render = function (): VNode {
    const vm: Component = this
    
    // ...
    vm.$vnode = _parentVnode
    // render self
    let vnode
    try {
      currentRenderingInstance = vm
      // 生产环境 vm._renderProxy === vm
      // 可以看出_render的主要逻辑还是执行options.render函数 不过多加了异常处理提示
      // 当然有个重点是传入了vm.$createElement作为render函数的第一个参数
      // $createElement 的定义我们在initRender中有分析 接下来我们就看看render函数的执行了
      vnode = render.call(vm._renderProxy, vm.$createElement)
    } catch (e) {
      // 异常逻辑暂不分析
      // ...
    } finally {
      currentRenderingInstance = null
    }

    // ...
    return vnode
  }
}

中场总结

我们先梳理下上半篇分析出来的成果

  1. 在 Vue 实例初始化的时候 initRender 函数为实例添加了 $createElement 函数

  2. 初始化的最终一步是执行 $mount函数,$mount 包括两部分,其中最外层主要校验并生成 render 函数

  3. 在内层的 $mount 逻辑最终执行的是 mountComponent 函数

  4. mountComponent 的重点是创建 Watcher 实例并执行 updateComponent

  5. updateComponent 的逻辑在于执行 vm._update(vm._render(), hydrating)

  6. vm._render() 实际就是执行在 renderMixin 中定义的 _render

  7. _render 函数最终就是将步骤①中定义的 $createElement 作为参数传递给 options.render 并执行 render

render

接下来我们来分析 options.render($createElement) 的实现

为方便分析,我们先在项目中配置 render 函数来创建实例

new Vue({
  el: '#app',
  render: $createElement => $createElement('h1', {style: {color: 'red'}}, 'hello world')
})

不出意外,大屏幕显示的是红色的标题 hello world

接下来就是分析 $createElement 的逻辑了

刚才有分析到 $createElement 主要是透传参数给 createElement

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

所以我们接下来的重点是分析 createElement

createElement

export function createElement (
  context: Component,
  tag: any,
  data: any,
  children: any,
  normalizationType: any,
  alwaysNormalize: boolean
): VNode | Array<VNode> {
  // 如果data是数组或者非对象类型数据
  // 则默认为data位置就是子节点 而实际data置为undefined
  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)
}

看一眼定义的参数我们就能很好的明白 createElement 各参数的含义了,分别是 标签 数据 子节点 归一化类型。对参数进行简单处理转化后,接着执行 _createElement

_createElement

export function _createElement (
  context: Component,
  tag?: string | Class<Component> | Function | Object,
  data?: VNodeData,
  children?: any,
  normalizationType?: number
): VNode | Array<VNode> {
  // 响应式数据不能作为data
  if (isDef(data) && isDef((data: any).__ob__)) {
    process.env.NODE_ENV !== 'production' && warn(
      `Avoid using observed data object as vnode data: ${JSON.stringify(data)}\n` +
      'Always create fresh vnode data objects in each render!',
      context
    )
    return createEmptyVNode()
  }

  // v-bind:is逻辑
  // object syntax in v-bind
  if (isDef(data) && isDef(data.is)) {
    tag = data.is
  }

  // 无tag创建空节点
  if (!tag) {
    // in case of component :is set to falsy value
    return createEmptyVNode()
  }

  // key值为对象类型时提示
  // warn against non-primitive key
  if (process.env.NODE_ENV !== 'production' &&
    isDef(data) && isDef(data.key) && !isPrimitive(data.key)
  ) {
    if (!__WEEX__ || !('@binding' in data.key)) {
      warn(
        'Avoid using non-primitive value as key, ' +
        'use string/number value instead.',
        context
      )
    }
  }

  // slot相关
  // support single function children as default scoped slot
  if (Array.isArray(children) &&
    typeof children[0] === 'function'
  ) {
    data = data || {}
    data.scopedSlots = { default: children[0] }
    children.length = 0
  }

  // 子节点扁平化处理,列如[a, [b, c]] => [a, b, c]
  if (normalizationType === ALWAYS_NORMALIZE) {
    // 后文将单独分析
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }

  // ns 的逻辑暂时可以不去了解
  let vnode, ns
  if (typeof tag === 'string') {
    let Ctor
    ns = (context.$vnode && context.$vnode.ns) || config.getTagNamespace(tag)
    if (config.isReservedTag(tag)) {
      // ..
      // 创建Vnode
      vnode = new VNode(
        config.parsePlatformTagName(tag), data, children,
        undefined, undefined, context
      )
    } else if ((!data || !data.pre) && isDef(Ctor = resolveAsset(context.$options, 'components', tag))) {
      // component
      vnode = createComponent(Ctor, data, context, children, tag)
    } else {
      vnode = new VNode(
        tag, data, children,
        undefined, undefined, context
      )
    }
  } else {
    // direct component options / constructor
    vnode = createComponent(tag, data, context, children)
  }

  // 返回VNode
  if (Array.isArray(vnode)) {
    return vnode
  } else if (isDef(vnode)) {
    if (isDef(ns)) applyNS(vnode, ns)
    if (isDef(data)) registerDeepBindings(data)
    return vnode
  } else {
    return createEmptyVNode()
  }
}

通过 _createElement 分析,我们可以得知 _createElement 的主要任务在于实例化 VNode 对象并返回。其中 VNode 就是我们常说的虚拟DOM,而不同的 VNode 对象拥有 children 属性构成一颗虚拟树。具体 VNode 创建过程及实例,我们将通过专门的文章分析,在此明白返回的是个 VNode 实例即可。

normalizeChildren

前面在 _createElement 函数创建虚拟节点之前,还有个子节点扁平化的过程

children = normalizeChildren(children)

我们来看看 normalizeChildren 的处理逻辑

export function normalizeChildren (children: any): ?Array<VNode> {
  // 如果子节点是非对象数据 非返回单元素数组 值为文本虚拟节点
  // 否则返回 normalizeArrayChildren(children)
  return isPrimitive(children)
    ? [createTextVNode(children)]
    : Array.isArray(children)
      ? normalizeArrayChildren(children)
      : undefined
}

看来逻辑主要在 normalizeArrayChildren(children),我们接着进行分析

normalizeArrayChildren

函数的判断逻辑比较长。但本着分析主要流程的心态,我们大致了解下处理逻辑即可

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
    // last为返回值末端节点
    lastIndex = res.length - 1
    last = res[lastIndex]
    //  nested
    if (Array.isArray(c)) {
      // 遇到嵌套则递归调用normalizeArrayChildren拍拍平数组
      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)) {
        res[lastIndex] = createTextVNode(last.text + c)
      } else if (c !== '') {
        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
}

可以看出 normalizeArrayChildren 的主要逻辑就是创建返回数组,遍历 children 数组。

  • 如果是节点数组,则递归调用 normalizeArrayChildren 来拍平子节点数组

  • 如果是非数组非对象数据,则为其创建文本虚拟节点,文本节点还涉及了合并文本的逻辑(这边我没发现怎么创建这样的数据,所以暂不分析)

  • 否则正常节点将节点添加至返回值即可

所以 normalizeArrayChildren 的返回值将为 [VNode, VNode, VNode, VNode] 这样的虚拟节点数组。

有个奇怪的问题,就是我们刚才分析是先分析 _createElement 创建虚拟节点,而创建虚拟节点之前先拍平数组。那我怎么说 normalizeArrayChildren 函数返回的是虚拟节点数组呢?

其实涉及到JS基础问题

new Vue({
  el: '#app',
  render: $createElement => $createElement('h1', {style: {color: 'red'}}, [$createElement('span', 'hello world')]),
})

缓过神了吧,我们创建的 render 函数本身就是个函数嵌套函数的函数,所以实际运行中先会调用 $createElement 创建子节点 span 再创建父节点 h1

所以在我们在调用 normalizeChildren 拍平数组前,chilren 已经是经过 createElement 处理后形如 [VNode, VNode, [VNode, VNode]] 的节点数据了。

至此 render 函数就已经分析完了。

结语

我们今天分析了 render 函数的入口及执行逻辑。很遗憾的是我们的节点还是没有渲染到浏览器中,我们只是创建了 VNode 数据。等后文我们再去分析 Vnode 的实现及创建逻辑,以及 vm_update 是如何将 VNode 渲染为真实 DOM 的。

最后啰嗦一句,贴的代码比较多。分析的有不对的地方希望帮忙指正,有不清楚的地方也可以提出来,大家一起交流~