【Vue源码-初始化渲染】从template到挂载DOM的运行机制

240 阅读4分钟

前言

  • 告诉我自己:Vue是个考虑周全的框架,细枝末节做了太多处理,我只需要关注主线的逻辑原理,别钻牛角尖
  • 告诉自己:别扯别的,我就想知道一个问题,怎么从template到render到vnode到真实DOM的
  • 从template到render 整个parse过程 已经施工完毕
  • 还是结合实际例子,不搞复杂例子
  • 参考: ustbhuangyi.github.io/vue-analysi…

从例子出发

<div id="app">
  {{ message }}
</div>
var app = new Vue({
  el: '#app',
  data: {
    message: 'Hello Vue!'
  }
})

这篇只关心目标是弄清楚模板和数据如何渲染成最终的 DOM,这个初始化patch逻辑,不考虑视图更新的相关的patch逻辑。

$mount

在vue初始化过程的最后会调用 $mount进行实例挂载, 这个是入口, 主要做了:

  1. 缓存了原型链上的$mount,这个方法是不带compiler的挂载方法, 中间经过编译生成了render函数后再调用他.
  2. 对配置项进行了一个优雅降级: 用户直接提供了render函数那就省的编译了, 如果没有定义 render 方法,则会把 el 或者 template 字符串转换成 render 方法, 反正最后都得到了render方法, 本篇不关注编译过程, 整个parse过程 生成render函数如下:
with (this) {
  return _c('div', { attrs: { id: 'app' } }, [
    _v('\n' + _s(message) + '\n')
  ])
}

拿到了挂在vm的options上面

      const { render, staticRenderFns } = compileToFunctions
      options.render = render
      options.staticRenderFns = staticRenderFns
  1. 然后在调用刚刚缓存的mount函数 return mount.call(this, el, hydrating), 就开始了挂载流程

实际上Vue.prototype.$mount调用了mountComponent方法

mountComponent

核心就是updateComponent回调函数, 涉及两个核心的方法vm._render 和 vm._update

vm._render: render函数生成 VNode

vm._update: VNode 渲染成真实的 DOM

// 最核心的 2 个方法:`vm._render` 和 `vm._update`。
  updateComponent = () => {
    vm._update(vm._render(), hydrating)
  }

  // `Watcher` 在这里起到两个作用,一个是初始化的时候会执行回调函数,
  // 另一个是当 vm 实例中的监测的数据发生变化的时候执行回调函数
  new Watcher(vm, updateComponent, noop, {
    before () {
      if (vm._isMounted) {
        callHook(vm, 'beforeUpdate')
      }
    }
  }, true /* isRenderWatcher */)

在这里小结一下:

  1. Vue在实例化的时候, 调用完众多的initXXX之后, 会调用vm.$mount(vm.$options.el), 进入挂载过程
  2. 检查是否提供了render函数 (要么手写到options里面, 要么通过loader编译得到), 要是没有就进行parse过程编译render.
  3. render 函数挂载vm.$options 上面 把 vm 扔给 mountComponent
  4. mountComponent 构建一个回调函数: 把render转VNode, 把VNode转DOM, 新建一个Watcher, 把这个回调函数交给这个Watcher (渲染watcher), Watcher初始化调用回调函数, 生成VNode, 再挂载DOM

OK 这就是主体框架, 现在来深究 1. vm._render: render函数生成 VNode 2. vm._update: VNode 渲染成真实的 DOM

render函数生成 VNode

vm._render

其实这里就做了一个核心的代码,其他都是异常捕获,错误处理啥的


const { render } = vm.$options
// ...
vnode = render.call(vm._renderProxy, vm.$createElement)
// ... 
return vnode

也就是把之前挂载在vm.optionsrender函数拿出来执行,其中传入的参数vm.options的render函数拿出来执行, 其中传入的参数 vm.createElement, 也就是createElement才是实现业务的核心.

createElement

定义

// internal version is used by render functions compiled from templates
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.
vm.$createElement = (a, b, c, d) => createElement(vm, a, b, c, d, true)

看注释, 一个是给编译生成的render函数用的, 一个是给手写的render函数用的, 其实都调用了createElement. createElement 方法实际上是对 _createElement的封装, 咱简单点, 就直接以后还是说createElement. 对于我们的例子 _c('div', { attrs: { id: 'app' } }, [ _v('\n' + _s(message) + '\n') ])

  • _c : createElement
  • tag : 'div'
  • data : { attrs: { id: 'app' }
  • children: [ _v('\n' + _s(message) + '\n') ]

children 的规范化

毛主席说抓主要矛盾,我主要分析 2 个重点的流程 : children 的规范化以及 VNode 的创建 下面是createElement 中 children规范化的代码片段

  if (normalizationType === ALWAYS_NORMALIZE) {
    children = normalizeChildren(children)
  } else if (normalizationType === SIMPLE_NORMALIZE) {
    children = simpleNormalizeChildren(children)
  }

simpleNormalizeChildren 方法调用场景是 render 函数是编译生成的, 这时候Children已经是一个VNode数组, 就像我们的例子, 就是这种情况, 因为是模板编译好的render函数

_c( 'div', 
    { attrs: { id: 'app' } }, 
    [_v('\n' + _s(message) + '\n')]
  )

会先执行一下 _s _v

target._s = toString

target._v = createTextVNode 把children变成一个 VNode Array 后才传入_c, 所以可以跳过规范化过程 特别的: 针对functional component 还是会生成嵌套数组, 需要展开, 于是还是要调用simpleNormalizeChildren

normalizeChildren 方法的调用场景有 2 种

  1.  render 函数是用户手写的,当 children 只有一个节点的时候,Vue.js 从接口层面允许用户把 children 写成基础类型用来创建单个简单的文本节点,这种情况会调用 createTextVNode 创建一个文本节点的 VNode;
  2. 另一个场景是当编译 slotv-for 的时候会产生二维数组, 这个时候调用normalizeArrayChildren
function normalizeArrayChildren (children: any, nestedIndex?: string): Array<VNode> {
  const res = []
  let i, c, lastIndex, last
  // 遍历每个 c 进行检查
  for (i = 0; i < children.length; i++) {
    c = children[i]
    if (isUndef(c) || typeof c === 'boolean') continue
    
    // 这里的目的是记录res这个 Array<VNode> 的最后一个元素, 合并文字结点用
    lastIndex = res.length - 1
    last = res[lastIndex]
    //  如果 c 是数组 递归调用
    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)) {
    // 如果是基础类型,则通过 createTextVNode 方法转换成 VNode 类型
      if (isTextNode(last)) {
        // 优化 如果上个结点就是text类型, 那么合并这两个结点
        res[lastIndex] = createTextVNode(last.text + c)
      } else if (c !== '') {
        res.push(createTextVNode(c))
      }
    } else {
    // 否则 c 就已经是一个 VNode类型了
      if (isTextNode(c) && isTextNode(last)) {
        // 还是检查一下文字结点进行合并
        res[lastIndex] = createTextVNode(last.text + c.text)
      } else {
       // ... 
        // 最后就是新的节点啦, push进入res
        res.push(c)
      }
    }
  }
  return res
}

经过对 children 的规范化,children 变成了一个类型为 VNode 的 Array。

VNode的创建

规范化了children 保证得到了一个 Array, 就可以分情况创建VNode

if (typeof tag === 'string') :

  • if 如果tag属于Vue内置结点 直接创建 普通VNode
  • else if 是已经注册的组件名, createComponent 创建组件类型的VNode
  • else 根据tag 直接创建一个未知 VNode

如果是 tag 一个 Component 类型:

 vnode = createComponent(tag, data, context, children)

VNode 到 DOM

_update

_update 是实例的一个私有方法,它被调用的时机有 2 个,一个是首次渲染,一个是数据更新的时候;本文只设计首次渲染过程. 函数就一个目的, 把VNode渲染成真实DOM

  if (!prevVnode) {
    // initial render
    // function patch (oldVnode, vnode, hydrating, removeOnly)
    vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)
  } else {
    // updates
    vm.$el = vm.__patch__(prevVnode, vnode)
  }

_update 的核心就是调用 vm.__patch__ 方法,初次渲染的时候, 用真实dom作为oldVnode, 然后patch过程就会进行初始化的逻辑

// 第一次渲染调用了这个逻辑
 vm.$el = vm.__patch__(vm.$el, vnode, hydrating, false /* removeOnly */)

patch

初始化

因为weex与web平台的patch方法有差异, 所以Vue用了函数柯里化的方式, 首先给平台写了自己的每个平台都有各自的 nodeOps(一些平台DOM的操作方法) 和 modules(平台的模块比如钩子函数), 用 createPatchFunction 生成平台的patch函数

主要逻辑

patch 有很多核心逻辑, 比如更新的diff算法, 这篇还是只研究初始化过程

之前说过, 调用patch的时候, 把dom对象作为第一个参数(oldVnode传入)

// 通过一个属性判断是否传入了dom对象
const isRealElement = isDef(oldVnode.nodeType)
if (!isRealElement && sameVnode(oldVnode, vnode)) {
  // patch existing root node
  patchVnode(oldVnode, vnode, insertedVnodeQueue, removeOnly)
} else {
  if (isRealElement) {
    // ... 这里不重要直接省略
    oldVnode = emptyNodeAt(oldVnode)
}
// 替换一些变量
const oldElm = oldVnode.elm
const parentElm = nodeOps.parentNode(oldElm)
  

sameVnode, patchVnode就是更新视图有关的逻辑了, 这里不命中, 会直接命中else分支, 把dom对象转换为一个Vnode, 赋值给oldVnode

 createElm(
    vnode,
    insertedVnodeQueue,
    oldElm._leaveCb ? null : parentElm,
    nodeOps.nextSibling(oldElm)
  )
}

createElm

function createElm (
  vnode, // 需要生成dom的Vnode
  insertedVnodeQueue,
  parentElm, // 父容器的占位符, 生成dom后需要insert到上面
  refElm,
  nested,
  ownerArray,
  index
)

重点了xdm, createElm 的作用是通过虚拟节点创建真实的 DOM 并插入到它的父节点中

这个标题下都是这个函数的代码, 但是拆开说不重要的就不贴了

  if (createComponent(vnode, insertedVnodeQueue, parentElm, refElm)) {
    return
  }

createComponent 方法目的是尝试创建子组件, 这个是另一个故事了, 这里我们的例子不会命中true

  if (isDef(tag)) {
    // ... tag的校验
    vnode.elm = vnode.ns
      ? nodeOps.createElementNS(vnode.ns, tag)
      : nodeOps.createElement(tag, vnode)

首先 在vnode存在 tag的时候 , 一番参数校验先略去, 这里调用了 nodeOps.createElement 方法, 其实就是平台的创建dom的api, web平台就是封装了的createElement, 到这里 vnode.elm 就指向了一个新建的dom元素

if (__WEEX__) {
      // ...
} else {
    // 实际上是遍历子虚拟节点,递归调用 `createElm`
    createChildren(vnode, children, insertedVnodeQueue)
    if (isDef(data)) {
        invokeCreateHooks(vnode, insertedVnodeQueue)
     }
    insert(parentElm, vnode.elm, refElm)
 }

createChildren先对孩子结点递归调用了createELm, 把父亲的vnode.elm 作为 parentElm 传入,这样子子孙孙都会挂载成一棵dom树, 妙哉!

function insert (parent, elm, ref) {
  if (isDef(parent)) {
    if (isDef(ref)) {
      if (ref.parentNode === parent) {
        nodeOps.insertBefore(parent, elm, ref)
      }
    } else {
      nodeOps.appendChild(parent, elm)
    }
  }
}

只要看到nodeOps.xxx 就想到web平台的dom api, 对应上就好了, 所以其实insert就是把子结点插入到父节点身上

上面这些是有tag的case, 如果没有tag

  else if (isTrue(vnode.isComment)) {
    vnode.elm = nodeOps.createComment(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  } else {
    vnode.elm = nodeOps.createTextNode(vnode.text)
    insert(parentElm, vnode.elm, refElm)
  }

要么是注释结点, 要么是文本结点, 直接调用dom api生成就好了

总结

  1. Vue在实例化的时候, 调用完众多的initXXX之后, 会调用vm.$mount(vm.$options.el), 进入挂载过程
  2. 检查是否提供了render函数 (要么手写到options里面, 要么通过loader编译得到), 要是没有就进行parse过程编译render.
  3. render 函数挂载vm.$options 上面 把 vm 扔给 mountComponent
  4. mountComponent 构建一个回调函数: 把render转VNode, 把VNode转DOM, 新建一个Watcher, 把这个回调函数交给这个Watcher (渲染watcher), Watcher初始化调用回调函数, 生成VNode, 再挂载DOM
  5. render到Vnode的入口是 vm._render , 调用的主要逻辑是createElement:
    • 处理children为Vnode的数组
    • 创建 VNode
  6. Vnode到DOM的入口时 vm._update, 调用patch后patch发现是新结点, 转调核心逻辑函数createElm:
    • 调用 dom api 生成 vnode的 dom 对象
    • 对其子孙递归调用 createElm
    • 将生成的dom树挂载到父节点身上