(建议精读)万字分析——Vue3 从初始化到首次渲染发生了什么?

前端三元同学

共 29134字,需浏览 59分钟

 ·

2021-05-22 02:08

前言

之前一直都是 React 用的比较多,对 vue 没有深入的学习了解过,所以想从源码的角度深入了解一下。

createApp 入口

在 vue3 中,是通过createApp的方式进行创建新的 Vue 实例,所以我们可以直接顺着createApp往下看。

// 从 createApp 开始
// vue3.0 中初始化应用
import { createApp } from 'vue'

const app = {
  template: '<div>hello world</div>'
}

const App = createApp(app)
// 把 app 组件挂载到 id 为 app 的 DOM 节点上
App.mount('#app')

createApp的内部比较清晰,先是创建了 app 对象,之后是改写了 mount 方法, 最后返回了这个 app 实例。

// runtime-dom/src/index.ts
const createApp = ((...args) => {
  // 创建传入的 app 组件对象
  const app = ensureRenderer().createApp(...args)
  
  // ...
    
  const { mount } = app
  
  // 重写 mount 方法
  app.mount = (containerOrSelector) => {
    // ...
  }
  
  return app
})

创建 app 对象

在这里可以发现,真正的 createApp 方法是在渲染器属性上的。为什么要有一个 ensureRender 方法呢?通过字面意思可以猜到是为了确保需要是需要渲染器的, 这里是一个优化点。在 vue3 中使用 monorepo 的方式对很多模块做了细粒度的包拆分,比如核心的响应式部分放在了 packages/reactivity 中,创建渲染器的 createRenderer 方法放在了 packages/runtime-core 中。所以如果没有调用 createApp 这个方法,也就不会调用 createRenderer 方法,那么当前的 runtime-dom 这个包内是可以通过 tree shaking 去避免打包的时候把没有用到的 packages/runtime-core 也打进去。

// runtime-dom/src/index.ts
// 渲染时使用的一些配置方法,如果在浏览器环境就是会传入很多 DOM API
let rendererOptions = {
  patchProp,
  forcePatchProp,
  insert,
  remove,
  createElement,
  cloneNode,
  ...
}
// lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue.
let renderer

function ensureRenderer () {
  return renderer || (renderer = createRenderer(rendererOptions))
}

接下来进入 createRenderer 方法之后,会发现还有一个 baseCreateRenderer 方法。这里是为了跨平台做准备的,比如我现在是浏览器环境,那么上面的 renderOptions 内的 insertcreateElement 等方法传入的就是 DOM API,如果以后需要完全可以根据平台的不同传入不同的 renderOptions 去生成不同的渲染器。在最后 baseCreateRenderer 会返回一个 render 方法和最终的 createApp (也就是 createAppAPI) 方法。

// runtime-core/src/renderer.ts
export function createRenderer(options) {
  return baseCreateRenderer(options)
}

function baseCreateRenderer(options) {
  // 组件渲染核心逻辑
  // ...

  retutn {
    render,
    createApp: createAppAPI(render)
  }
}

这个 createAppAPI 才是我们最终在应用层时调用的。首先他是返回了一个函数,这样做的好处是通过闭包把 render 方法保留下来供内部来使用。最后他创建传入的 app 实例,然后返回,我们可以看到这里有一个 mount 方法,但是这个 mount 方法还不能使用,vue 会在之后对这个 mount 方法进行改写,之后才会进入真正的 mount

// runtime-code/src/apiCreateApp.ts
export function createAppAPI(render) {
  // 这里返回了一个函数,使用闭包可以在下面 mount 的使用调用 render 方法
  return function createApp(rootComponent, rootProps = null) {
    const context = createAppContext()
    let isMounted = false
    const app = {
      _component: rootComponent,
      _props: rootProps,
      _container: null,
      _context: context,
      mount (rootContainer) {
        if (!isMounted) {
          // 创建 root vnode
          const vnode = createVNode(rootComponent, rootProps)
          // 缓存 context,首次挂载时设置
          vnode.appContext = context
          isMounted = true
          // 缓存 rootContainer
          app._container = rootContainer
          rootContainer.__vue_app__= app
          return vnode.component.proxy
        }
      }
      // ... 
    }
    return app
  }
}

重写 app.mount 方法, 进入真正的 mount

到这里进入改写 mount 方法的逻辑,这里的重写其实也是与平台相关的,在浏览器环境下,会先去获取正确的 DOM 容器节点,判断一切都合法之后,才会调用 mount 方法进入真正的渲染流程中。

// 返回挂载的DOM节点
app.mount = (containerOrSelector) => {
  // 获取 DOM 容器节点
  const container = normalizeContainer(containerOrSelector)
  // 不是合法的 DOM 节点 return
  if (!container) return
  // 获取定义的 Vue app 对象, 之前的 rootComponent
  const component = app._component
  // 如果不是函数、没有 render 方法、没有 template 使用 DOM 元素内的 innerHTML 作为内容
  if (!isFunction(component) && !component.render && !component.template) {
    component.template = container.innerHTML
  }
  // clear content before mounting
  container.innerHTML = ''
  // 真正的挂载
  const proxy = mount(container)
  
  // ...
  return proxy 
}

mount 方法内部的流程也比较清晰,首先是创建 vnode,之后是渲染 vnode,并将其挂载到 rootContainer 上。

mount (rootContainer) {
  if (!isMounted) {
    // 创建 root vnode
    const vnode = createVNode(rootComponent, rootProps)
    // 缓存 context,首次挂载时设置
    vnode.appContext = context
    
    render(vnode, rootContainer)
    
    isMounted = true
    // 缓存 rootContainer
    app._container = rootContainer
    rootContainer.__vue_app__= app
    return vnode.component.proxy
  }
}

到目前为止可以做一个小的总结:

接下来会针对创建 vnode 和 渲染 vnode 这两件事去看看到底做了些什么。

创建 vnode

vnode 本质上是 JavaScript 对象,用来描述各个节点的信息。

<template>
  <div class="hello" style="color: red">
    <p>hello world</p>
  </div>
</template>

例如针对以上的信息会生成这样一组 关于 div 标签的 vnode,其中有几个关键属性,typepropschildrentype 是当前 DOM 节点的类型props 里存放着当前 DOM 节点的属性,比如 classstylechildren 表示 DOM 元素的子节点信息,是由 vnode 组成的数组进入到主线,来看看 createVNode 方法,它就是创建一个对象并初始化了各种属性,比如类型编码、typeprops

// runtime-core/src/vnode.ts
function createVNode(type, props, children) {
  // ...
  // class & style normalization
  if (props) {
    let { class: klass, style }= props 
    props.class = normalizeClass(klass)
    props.style = normalizeStyle(style)
  }
  
  // vnode 类型
  const shapeFlag =isString(type)
    ? ShapeFlags.ELEMENT
    : __FEATURE_SUSPENSE__ && isSuspense(type)
      ? ShapeFlags.SUSPENSE
      : isTeleport(type)
        ? ShapeFlags.TELEPORT
        : isObject(type)
          ? ShapeFlags.STATEFUL_COMPONENT
          : isFunction(type)
            ? ShapeFlags.FUNCTIONAL_COMPONENT
            : 0 
    const vnode: VNode = {
      __v_isVNode: true,
      [ReactiveFlags.SKIP]: true,
      type,
      props,
      key: props && normalizeKey(props),
      ref: props && normalizeRef(props),
      scopeId: currentScopeId,
      children: null,
      // ...
    }
    // 处理 children 子节点
    normalizeChildren(vnode, children)    
}

注意上面这里最后会调用 normalizeChildren 方法去处理 vnode 的子节点

// runtime-core/src/vnode.ts
function normalizeChildren(vnode, children) {
  let type = 0
  const { shapeFlag } = vnode
  if (children == null) {
    children = null
  } else if (isArray(children)) {
    type = ShapeFlags.ARRAY_CHILDREN
  } else if (isString(children)) {
    children = String(children)
    type = ShapeFlags.TEXT_CHILDREN
  }
  
  // ...
  
  vnode.children = children
  vnode.shapeFlag |= type
}

normalizeChildren 方法中,会使用位或运算来改写 vnode.shapeFlag,这在之后渲染时有很大用处。

// shared/src/shapeFlags.ts
const enum ShapeFlags {
  ELEMENT = 1,
  FUNCTIONAL_COMPONENT = 1 << 1,
  STATEFUL_COMPONENT = 1 << 2,
  TEXT_CHILDREN = 1 << 3,
  ARRAY_CHILDREN = 1 << 4,
  SLOTS_CHILDREN = 1 << 5,
  TELEPORT = 1 << 6,
  SUSPENSE = 1 << 7,
  COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
  COMPONENT_KEPT_ALIVE = 1 << 9,
  COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}

上面是 vue3 中定义的所有 shapeFlags 类型,我们拿一个<p>hello world</p>这样的节点来举例说明,这个节点用 vnode 来描述是这样子的,当前这个 vnode 是还没有对 childrenshapeFlag 进行操作接下来执行完 normalizeChildren 之后,children 是一个纯文本,而 shapeFlag 变成了 9,下面在渲染vnode时会用这个 shapeFlag 来对子节点进行判断

渲染 vnode

可以看到,如果 vnode 是空执行的是销毁组件逻辑,否则则是使用 patch 进行创建或更新。

// runtime-core/src/renderer.ts
function baseCreateRenderer(options) {

  // ...
  
  const render = (vnode, container) => {
    // vnode 是空,销毁组件
    if (vnode === null) {
      if (container._vnode) {
        unmount(container._vnode, null, null, true)
      }
    // 创建 or 更新
    } else {
      // 第一个参数是之前缓存的旧节点,第二个参数是当前新生成的新节点
      patch(container._vnode || null, vnode, container)
    }
    flushPostFlushCbs()
    container._vnode = vnode
  }  
  
  // ...
  
  return {
    render,
    createApp: createAppAPI(render)
  }
}

patch 方法

从上面可以看到,在最开始首次渲染时组件的挂载和后续的组件更新都使用了 patch 方法,下面我们来主要看一下 patch 方法。

// runtime-core/src/renderer.ts
function baseCreateRenderer(options) {

  // ...
  // n1 旧节点 n2 新节点
  const patch = (n1, n2, ...args) => {
    // patching & not same type, unmount old tree
    if (n1 && !isSameVNodeType(n1, n2)) {
      // ...
    }
    switch (type) {
    // ...
      default:
        // 普通 DOM 元素
        if (shapeFlag & ShapeFlags.ELEMENT) {
          processElement(n1, n2, container, ...args)
        // 自定义的 Vue 组件
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          processComponent(n1, n2, container, ...args)
        // 传送门 TELEPORT
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          type.process(n1, n2, container, ...args)
        // 异步组件 SUSPENSE
        } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
          type.process(n1, n2, container, ...args)
        } else if (__DEV__) {
          warn('Invalid VNode type:'type, `(${typeof type})`)
        }
    }
  }

  // ...
  
  return {
    render,
    createApp: createAppAPI(render)
  }

因为这篇文章着重点在于首次渲染,所以我们的关注点在于处理 DOM 元素和处理 Vue 组件的情况,也就是 processElementprocessComponent 这两个函数。

processElement

// runtime-core/src/renderer.ts
const processElement = (n1, n2, container, ...args) => {
  // 如果旧节点是空,进行首次渲染的挂载
  if (n1 == null) {
      mountElement(n2, container, ...args)
    // 如果不为空,进行 diff 更新
    } else {
      patchElement(n1, n2, ...args)
    }
}

由于我们这里关心的是首次渲染,所以只看 mountElement

// runtime-core/src/renderer.ts
const mountElement = (vnode, container) => {
  let el
  let vnodeHook
  const { type, props, shapeFlag, transition, scopeId, patchFlag, dirs } = vnode
  // 这里判断 patchFlag 为静态节点, 不进行创建而是 clone,提升性能
  // 是对静态节点的处理,首次渲染时不会遇到,先忽略
  if (
      !__DEV__ &&
      vnode.el && // 存在 el
      hostCloneNode !== undefined && // 
      patchFlag === PatchFlags.HOISTED
    ) {
      el = vnode.el = hostCloneNode(vnode.el)
    } else {
      el = vnode.el = hostCreateElement(vnode.type, isSVG, props && props.is)
}

简单分析一下,首先会先判断当前节点 vnode 是否是已经存在的静态节点(即没有绑定任何动态属性,所以也就不需要重新渲染的 vnode 节点),如果是则直接拿来复用,调用 hostCloneNode,显然在首次渲染的时候不会进行这个过程,所以我们这里主要还是来看 hostCreateElement 方法。

// runtime-core/src/renderer.ts 
function baseCreateRenderer(options) {
  const { createElement: hostCreateElement } = options
  // ...
}

这个 hostCreateElement 方法是在创建渲染器的时候传进去的平台相关的 rendererOptions 里,可以很清楚的看到如果是在浏览器环境下最终创建元素使用的就是 DOM API,即 `document.createElement ``

// runtime-dom/src/index.ts
const rendererOptions = extend({ patchProp, forcePatchProp }, nodeOps)

// runtime-dom/src/nodeOps.ts
const doc = typeof document !== 'undefined' ? document : null

const nodeOps = {
  // ...
    
  createElement: (tag, isSvg, is) => 
    isSvg
      ? doc.createElementNs(svgNS, tag)
      : doc.createElement(tag, is ? { is } : undefined)  

  // ...
}
// runtime-core/src/renderer.ts
const {
    setElementText: hostSetElementText
  } = options
const mountElement = (vnode, container) => {
  // ...
  
  // 如果子节点是文本节点 执行 hostSetElementText 实际上就是 setElementText,直接填入文本
  if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
    hostSetElementText(el, vnode.children as string)
  } else if (shapeFlag & ShapeFlag``s.ARRAY_CHILDREN) {
    mountChildren(
      vnode.children as VNodeArrayChildren,
      el,
      null,
      parentComponent,
      parentSuspense,
      isSVG && type !== 'foreignObject',
      optimized || !!vnode.dynamicChildren
    )
  }
  
  // ...
}

通过之前创建 vnodenormalizeChildren 方法,可以通过 shapeFlag 判断子节点的类型,如果 children 是一个文本节点,执行 hostSetElementText,在浏览器环境下实际上就是 setElementText,直接填入文本

// runtime-dom/src/nodeOps.ts
const doc = typeof document !== 'undefined' ? document : null

const nodeOps = {
  // ...
    
  setElementText: (el, text) => {
    el.textContent = text
  }, 

  // ...
}

如果 children 是一个数组,会调用 mountChildren,实际上就是对 children 数组进行深度优先遍历,递归的调用 patch 方法依次将子节点 child 挂载到父节点上

// runtime-core/src/renderer.ts
const mountChildren = (children, container, ...args) => {
  for (let i = start; i < children.length; i++) {
      const child = (children[i] = optimized
        ? cloneIfMounted(children[i])
        : normalizeVNode(children[i]))
      patch(null, child, container, ...args)
    }
}

接着往下执行,对 props 进行处理,因为我们当前是浏览器环境,所以对 props 的处理主要是 classstyle、和一些绑定的事件,现在我们不难猜到,如果是处理 class,最后也是使用原生的 DOM 操作 el.setAttribute('class', value)

// runtime-core/src/renderer.ts
const mountElement = (vnode, container) => {
  // ...
  if (props) {
    for (const key in props) {
      if (!isReservedProp(key)) {
        hostPatchProp(el, key, ...args)
      }
    }
  }
  // ...
}

// runtime-dom/src/patchProp.ts
const patchProp = (el, key) => {
  switch(key) {
    case 'class':
      patchClass(el, nextValue)
      break;
    case 'style':
      patchStyle(el, preValue, nextValue)
    default:
      if (isOn(key)) {
        patchEvent(el, key, prevValue, nextValue, parentComponent)
      }
      // ...
  }
}

经过漫长的操作,我们终于是把当前的 DOM 节点通过 vnode 创建出来了,现在就是要真正的将这个 DOM 节点真正的挂载到 container 上,这里最终执行的依然是原生的 DOM API el.insertBefore

// runtime-core/src/renderer.ts
const mountElement = (vnode, container) => {
  // ...
  hostInsert(el, container)
  // ...
}

// runtime-dom/src/index.ts
const rendererOptions = extend({ patchProp, forcePatchProp }, nodeOps)

// runtime-dom/src/nodeOps.ts
const nodeOps = {
  insert: (child, parent, anchor) => {
    parent.insertBefore(child, anchor || null)
  },  
  
  // ..
}

好了!以上是对一个 element 类型的 vnode 节点首次渲染挂载的全部流程!万里长征还有最后一点,接下来我们慢慢看对于 component 类型的 vnode 节点又有什么不一样

processComponent

在没有看之前,我们不妨先先想一下,element 类型和 component 类型有什么不一样。我们编写的一个个 component,在 HTML 中是不存在这个标签的,我们根据相关需求,进行了一层抽象,本质上渲染到页面上的其实是你书写的模板。所以这也就意味着我们拿到了一个 component 类型的 vnode,并不能和 element 类型的 vnode 一样去直接使用原生 DOM API 进行渲染挂载。有了这点印象之后我们再去看组件 vnode 的渲染流程。因为我们关心的是首次渲染的流程,所以先不去看 update 的逻辑,n1 === null 的第一个分支是 keep-alive 相关,我们也先不去管。最终的重点关注对象是 mountComponent 方法

// runtime-core/src/renderer.ts
// n1 旧节点,n2 新节点
const processComponent = (n1, n2, container, ...args) => {
  if (n1 === null) {
    if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
      // ...
    } else {
      mountComponent(n2, container, ...args)
    }
  } else {
    updateComponent(n1, n2)
  }
}

刚才上面说到,组件是一层抽象,他实际上不存在和 HTML 元素一样的父子、兄弟节点的关系,所以 vue 需要去为每一个组件创建一个实例,并去构建组件间的关系。那么拿到一个 component vnode 挂载时,首先就是要创建当前组件的实例。通过 createComponentInstance 方法创建了当前渲染的组件实例,instance 对象中有很多属性,后面用到的时候会慢慢介绍。在这里着重了解每个组件会创建一个实例即可。

// runtime-core/src/renderer.ts
const mountComponent = (initialVNode, container, anchor, parentComponent) => {
  const instance = initialVNode.component = createComponentInstance(
    initialVNode,
    parentComponent
  )
  
  // ...
}

// runtime-core/src/component.ts
let uid = 0

function createComponentInstance(vnode, parent) {
  const type = vnode.type
  const appContext = (parent ? parent.appContext : vnode.appContext) || emptyAppContext
  const instance = {
    uid: uid++,
    vnode,
    type,
    parent,
    appContext,
    root: null!,
    next: null,
    subTree: null!,
    update: null!,
    render: null,
    proxy: null,
    // ...
    
    // 生命周期相关也是在这里定义的
    bc: null,
    c: null,
    bm: null,
    m: null
    // ...
  }
  instance.root = parent ? parent.root : instance
  return instance  
}

创建完组件的实例后,继续往下执行会对刚才创建好的实例进行设置,调用 setupComponent 方法后会对如 propsslots、生命周期函数等进行初始化

// runtime-core/src/renderer.ts
const mountComponent = (initialVNode, container, anchor, parentComponent) => {
  // ...
  setupComponent(instance)
  // ...
}

// runtime-core/src/component.ts
function setupComponent(instance) {
  const { props, children, shapeFlag } = instance.vnode
  const isStateful = shapeFlag & ShapeFlags.STATEFUL_COMPONENT
  initProps(instance, props, isStateful)
  initSlots(instance, children)
  setupStatefulComponent(instance)
}

创建好了组件实例,并对组件实例的各个属性进行了初始化后,是运行副作用 setupRenderEffect 函数,我们知道 vue3 的一大特色是新增了 Composition API,并且 Vue3.0 中将所有的响应式部分都独立到了 packages/reactivity 中。观察这个函数名称,可以猜到是要建立渲染的副作用函数,首先他是在组件实例的 update 上调用响应式部分的 effect 函数,当数据发生变化时,响应式函数 effect 会自动的去执行内部包裹的 componentEffect 方法,也就是去自动的重新 render,重新渲染。

// runtime-core/src/renderer.ts
const mountComponent = (initialVNode, container, anchor, parentComponent) => {
  // ...
  setupRenderEffect(instance, initialVNode, container, ...args )
}

import { effect, stop, ReactiveEffectOptions, isRef } from '@vue/reactivity'
const setupRenderEffect = (instance, initialVNode, container, ...args) => {
  // create reactive effect for rendering
  instance.update = effect(function componentEffect() {
    // ...
  })
}

进入到 componentEffect 函数内部去看一下,首先我们关注第一个 if 分支,即首次渲染的时候,else 分支表示的是更新组件暂时先不去管他。这里的 initialVNode 其实就是之前的 vue 组件的 vnode,这里叫 initialVNode 是为了区别抽象与现实,因为我们最终渲染的还是组件内真实的 DOM 的。渲染组件 vnode 和渲染 DOM vnode 不一样的地方在于,DOM vnode 可以直接渲染,但是组件 vnode 有定义的一系列方法,比如生命周期函数,所以在开头从 instance 内取出了 bm(beforeMount)、m(mount) 生命周期函数调用。下面重点部分要来了,生成的 subTree 才是最终组件模板里的真实 DOM vnode

// runtime-core/src/renderer.ts 
function componentEffect() {
  if (!instance.isMounted) {
    // create component
    let vnodeHook
    const { el, props } = initialVNode
    // 取出 mount 相关的生命周期函数
    const { bm, m, parent } = instance 
    if (bm) {
      invokeArrayFns(bm)
    }
    // ...

    const subTree = (instance.subTree = renderComponentRoot(instance))    

  } else {
    // update component
    // ...
  }
}

通过官网可以了解到,所有的 template 模板最终都会被编译成渲染函数。

renderComponentRoot 所做的工作就是去执行编译后的渲染函数,最终得到的 subTree。以下面这个组件来举例,initialVNodeFoo 组件生成的组件 vnodesubTreeFoo 组件内部的 DOM 节点嵌套的 DOM vnode

我们得到的这个 subTree 已经是一个 DOM vnode 了,所以在接下来 patch 的时候直接将 subTree 挂载到 container 即可,之后流程就由上面的 processElement 进行接管。

// runtime-core/src/renderer.ts 
function componentEffect() {
  // ...
  
  patch(null, subTree, container, ...args)
  
  // ...
}

在创建 vnode 和渲染 vnode 这部分主要的流程在这里,初次看的话可能记不太住,在这里做了各个主要函数使用的流程图,大家可以对照着多看几次

最后

恭喜🎉你成功看完了vue3的首次渲染流程,给自己呱唧呱唧~后面会陆续分析关于vue工作的种种流程~希望你可以点个关注~


浏览 56
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报