Vue3 源碼分析 - 從 createApp 開始的首次渲染

前言

之前一直都是 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,其中有幾個關鍵屬性,typepropschildren``type 是當前 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 工作的種種流程~希望你可以點個關注~

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/0svgn5mSLFy9bFE3-OtQVw