Vue3源码(二):从源码来看Vue3新特性

avatar
@智云健康

作者: 徐จุ๊บ,未经授权禁止转载。

前言

上一篇文章《Vue3源码(一)》简单介绍了Vue3源码结构,也通过源码学习了解Vue3基础也是核心响应式。这一次咱们接着了解另一核心组件,学一学Vue3组件初始化及其渲染过程。如有不对之处、遗漏之处,还望指正、补充。


正文

还记得上一篇文章中提到的Vue3应用初始吗?

createApp(App).mount('#app')

上次我们了解了 createApp(App) 的过程,通过闭包、柯里化可以应对不同场景、平台,创建并返回特定的app应用实例,那么这次咱们的学习从 mount('#app') 开始,了解初次渲染的过程。

mount方法

回顾上一篇的内容,我们发现源码中主要有两处定义了 mount 方法,分别是:

  1. runtime-dom/src/index.ts 针对浏览器Web平台重写 mount 方法
  const { mount } = app
  app.mount = (containerOrSelector: Element | string): any => {
    // normallizeContainer 这个方法顾名思义统一容器,mount参数可能是DOM对象或者选择器
    // 如果是选择器就取到对应DOM
    const container = normalizeContainer(containerOrSelector)
    if (!container) return
    
    // 这里app._component就是我们通过 rootComponent 参数,传入打包编译过的 App 组件(图1)
    const component = app._component
    
    // 如果我们传入的组件没有定义render,没有模版,那就取DOM里面原本内容当作模版
    if (!isFunction(component) && !component.render && !component.template) {
      component.template = container.innerHTML
    }
    
    // 这里会清除DOM里原有的内容
    container.innerHTML = ''
    
    // 执行之前暂存的基础的 mount 方法
    const proxy = mount(container)
    container.removeAttribute('v-cloak')
    container.setAttribute('data-v-app', '')
    return proxy
  }

图1:

图

通过代码以及里面注释,可以把重写方法分为几步: 1. 取到DOM容器; 2. 判断传入的根组件App; 3. 执行标准的 mount 方法。

  1. runtime-core/src/apiCreateApp.ts,这是在app实例里, 标准的、可跨平台的组件 mount 方法
 mount(rootContainer: HostElement, isHydrate?: boolean): any {
   // app应用是否已经被挂载
   if (!isMounted) {
     // 1. 创建VNode 这里 rootComponent 就是 createApp(App) 传入的 App 组件
     const vnode = createVNode(
       rootComponent as ConcreteComponent,
       rootProps
     )

     // app应用实例存储上下文,主要有 app应用实例本身,各类设置项,配置项
     vnode.appContext = context

     if (isHydrate && hydrate) {
       // 服务端渲染相关
       hydrate(vnode as VNode<Node, Element>, rootContainer as any)
     } else {
       // 2. render 渲染 VNode
       // 这里的render再上一篇文章有提到 ensureRenderer 创建出来的
       render(vnode, rootContainer)
     }
     isMounted = true
     
     // 存储DOM容器
     app._container = rootContainer
     // for devtools and telemetry
     ;(rootContainer as any).__vue_app__ = app
     // ...
     return vnode.component!.proxy
   } else if (__DEV__) {
     // ...
   }
 },

可以看到标准mount 方法主要有如下几步: 1. 创建VNode; 2. 渲染VNode为真实DOM

小结

至此,我们知道 mount 方法大致做了哪些事情。

  1. normalizeContainer 得到DOM容器
  2. createVNode,根据传入App组件,创建VNode
  3. render VNode,并挂载到DOM容器上
  4. 返回 VNode.component 的代理

咱们接着看看VNode相关的。

创建&渲染VNode

相信大家对VNode都不陌生,简单说是通过JavaScript对象抽象描述DOM、事物。面试中问起有什么好处的话,肯定会说到这几点,1. 不用频繁更改DOM,2. 抽象化带来的跨平台能力,3. VNode操作JS对比直接操作DOM产生的性能优势。但最近阅读一些文章,让我认为第三条优点也不是绝对性,对于数据量会很大的组件如Tree,Table,循环遍历render子VNode过程耗时也很长,最终也还是要操作DOM,页面甚至可以感觉到卡顿。

回归正题,来看下面例子

App.vue
<template>
  <HelloWorld msg="Hello Vue 3.0 + Vite" />
  <p>{{ showText }}</p>
</template>

HelloWorld.vue
<template>
  <div>{{ msg }}</div>
</template>

创建VNode

Vue3 中,有很多代表不同类别的VNode,例如上例中的 HelloWorld 组件 VNode,普通元素 VNode p

具体我们来看看生成 VNode 的方法 createVNode,代码略长,老办法注释掉本次流程不关心的内容。

function _createVNode(
  type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT,
  props: (Data & VNodeProps) | null = null,
  children: unknown = null,
  patchFlag: number = 0,
  dynamicProps: string[] | null = null,
  isBlockNode = false
): VNode {
  if (!type || type === NULL_DYNAMIC_COMPONENT) {
    type = Comment
  }

  if (isVNode(type)) { // 如果是VNode,直接clone,这里就是通过type的__v_isVNode属性判断的
    // createVNode receiving an existing vnode. This happens in cases like
    // <component :is="vnode"/>
    // #2078 make sure to merge refs during the clone instead of overwriting it
    const cloned = cloneVNode(type, props, true /* mergeRef: true */)
    if (children) {
      normalizeChildren(cloned, children)
    }
    return cloned
  }

  // class component normalization.
  if (isClassComponent(type)) { // class组件
    type = type.__vccOpts
  }

  // class & style normalization.
  if (props) {
    // ...
  }

  // 给组件类型增加一个编码标示
  const shapeFlag = isString(type)
    ? ShapeFlags.ELEMENT // 1 dom element
    : __FEATURE_SUSPENSE__ && isSuspense(type)
      ? ShapeFlags.SUSPENSE //128 suspense vue3中新增的组件
      : isTeleport(type)
        ? ShapeFlags.TELEPORT // 64 teleport 也是vue3中新增
        : isObject(type)
          ? ShapeFlags.STATEFUL_COMPONENT // 4 状态组件
          : isFunction(type)
            ? ShapeFlags.FUNCTIONAL_COMPONENT // 2 函数组件
            : 0
  // ...
  const vnode: VNode = {
    __v_isVNode: true,
    [ReactiveFlags.SKIP]: true,
    type,
    props,
    key: props && normalizeKey(props),
    ref: props && normalizeRef(props),
    scopeId: currentScopeId,
    children: null,
    component: null,
    shapeFlag
    // ...
  }

  /** 标准化子节点
    * 这里会给不同类型的children编码标示type 8: 文本; 16:数组; 32:slots;同时也转成对应类型。
    * 同时会因children类型不同,修改VNode的 shapeFlag,为之后挂载使用
    **/
  normalizeChildren(vnode, children)

  // normalize suspense children
  //...
  
  return vnode
}

通过本例来看上面代码执行过程

  1. 判断是否为VNode,Class组件,有props则进行class、style标准化转换
  2. 判断组件类型,并计算出标示,得出 4
  3. 创建VNode
  4. 标准化子节点,这里传入App组件时,children 为null
  5. 返回VNode

至此我们以及拿到由App组件创建的VNode:

VNode

渲染VNode

接着我们来看看 render(vnode, rootContainer) , 怎么渲染VNode。

上一篇文章我们也有了解到 render 方法, baseCreateRenderer 通过传入不同平台的 endererOptions,可生成应对不同平台的render。

render
// runtime-core/src/renderer.ts
const render: RootRenderFunction = (vnode, container) => {
  if (vnode == null) {
    if (container._vnode) {
      unmount(container._vnode, null, null, true)
    }
  } else {
    patch(container._vnode || null, vnode, container)
  }
  flushPostFlushCbs()
  // 存下 vnode于dom容器上
  container._vnode = vnode
}

可以看到如果传入VNode为空,且当前DOM容器有VNode则执行 unmount 销毁组件,否则 patch 传入的VNode。接着我们了解下 patch 的实现。

patch
  const patch: PatchFn = (
    n1, // n1 代表旧节点
    n2, // n2 代表新节点
    container,
    anchor = null,
    parentComponent = null,parentSuspense = null,isSVG = false,optimized = false
  ) => {
    // 如果有旧VNode,且不一样,umount销毁旧节点
    if (n1 && !isSameVNodeType(n1, n2)) {
      anchor = getNextHostNode(n1)
      unmount(n1, parentComponent, parentSuspense, true)
      n1 = null
    }

    if (n2.patchFlag === PatchFlags.BAIL) {
      optimized = false
      n2.dynamicChildren = null
    }

    const { type, ref, shapeFlag } = n2
    // 先通过type来判断选择处理方法
    switch (type) {
      case Text:
        // 文本
        processText(n1, n2, container, anchor)
        break
      case Comment:
        // 注释
        processCommentNode(n1, n2, container, anchor)
        break
      case Static:
        // 静态
        if (n1 == null) {
          mountStaticNode(n2, container, anchor, isSVG)
        } else if (__DEV__) {
          patchStaticNode(n1, n2, container, isSVG)
        }
        break
      case Fragment:
        // 碎片化,这也是Vue3新支持的多根节点
        processFragment(/** ... **/)
        break
      default:
        // 如果type都不满足,使用shapeFlag 编码判断
        if (shapeFlag & ShapeFlags.ELEMENT) {
          // dom元素
          processElement(/** ... **/)
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          // 组件本次初次渲染会走到这里
          processComponent(
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            isSVG,
            optimized
          )
          // 之后都是Vue3 里面新增两种组件
        } else if (shapeFlag & ShapeFlags.TELEPORT) {
          //
        } else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
        }
    }

    // set ref
    if (ref != null && parentComponent) {
      setRef(ref, n1 && n1.ref, parentComponent, parentSuspense, n2)
    }
  }

其实 patch 最重要的逻辑是通过vnode的 type、shapeFlag来选择怎么处理组件。

由于我们是首次渲染,n1是空,且又App组件创建 VNodeshapeFlag 为 4 ShapeFlags.STATEFUL_COMPONENT ,所以会走到 ShapeFlags.COMPONENT 条件下,执行 processComponent 方法。那么接着看一看这个方法。

processComponent
  const processComponent = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
    if (n1 == null) {
    // 如果没有旧节点
      if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) { // 512
      // 如果是 keep-alive 组件
        ;(parentComponent!.ctx as KeepAliveContext).activate(
          n2,
          container,
          anchor,
          isSVG,
          optimized
        )
      } else {
        // 执行挂载组件
        mountComponent(
          n2,
          container,
          anchor,
          parentComponent,
          parentSuspense,
          isSVG,
          optimized
        )
      }
    } else {
      // 如果n1 n2 都有则执行更新
      updateComponent(n1, n2, optimized)
    }
  }

这个方法最主要逻辑通过有无新旧节点,来进行挂载组件mountComponent, 还是updateComponent 更新组件。

接着来看看 本次初次渲染执行到的 mountComponent

mountComponent
  const mountComponent: MountComponentFn = (
    initialVNode,   // 初始VNode 也就是App组件生成的VNode
    container,  // #app Dom容器
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    optimized  
  ) => {
    // 创建组件实例
    const instance: ComponentInternalInstance = (initialVNode.component =    createComponentInstance(
      initialVNode,
      parentComponent,
      parentSuspense
    ))


    // inject renderer internals for keepAlive
    if (isKeepAlive(initialVNode)) {
      ;(instance.ctx as KeepAliveContext).renderer = internals
    }
    
    // 设置实例 初始化 props,slots 还有Vue3新增的composition API
    setupComponent(instance)
    
    // ...

    // effect 上一篇说到的副作用函数
    setupRenderEffect(
      instance,
      initialVNode,
      container,
      anchor,
      parentSuspense,
      isSVG,
      optimized
    )
  }

挂载组件VNode主要逻辑就是 createComponentInstance 创建组件 instance 实例,setupComponent 设置组件,setupRenderEffect 执行带副作用渲染函数。

createComponentInstance 最主要就是创建并返回 instance 实例,我们先看看instance 是什么样子的。


  const instance: ComponentInternalInstance = {
    uid: uid++,
    vnode,
    type,
    parent,
    appContext,
    root: null!, // to be immediately set
    next: null,
    subTree: null!, // will be set synchronously right after creation
    update: null!, // will be set synchronously right after creation
    render: null,
    proxy: null,
    withProxy: null,
    effects: null,
    provides: parent ? parent.provides : Object.create(appContext.provides),
    accessCache: null!,
    renderCache: [],

    // local resovled assets
    components: null,
    directives: null,

    // resolved props and emits options
    // 

    // emit
    emit: null as any, // to be set immediately
    emitted: null,

    // state
    ctx: EMPTY_OBJ,
    data: EMPTY_OBJ,
    props: EMPTY_OBJ,
    // ...

    // suspense related
    // ...

    // lifecycle hooks
    // 以下是 组件生命周期相关的属性
    isMounted: false,
    isUnmounted: false,
    isDeactivated: false,
    bc: null, // beforeCreate
    c: null, // created
    // ...
  }

instance 对象属性比较多,都会在特定场景中使用到,之后的 setupComponent 方法也是设置初始化 instance 中各个属性,例如初始化 props , slots 还有执行 Vue3 新增的 setup 函数。

因为涉及到 Vue3 中新增的composition API、setup 函数,可以挖坑单独学习这一块内容

实例创建完,设置完,最后一步设置运行 render 副作用函数 setupRenderEffect

setupRenderEffect
  const setupRenderEffect: SetupRenderEffectFn = (
    instance,
    initialVNode,
    container,
    anchor,
    parentSuspense,
    isSVG,
    optimized
  ) => {
    // 创建响应式的副作用render函数
    instance.update = effect(function componentEffect() {
      if (!instance.isMounted) {
        let vnodeHook: VNodeHook | null | undefined
        const { el, props } = initialVNode 
        const { bm, m, parent } = instance // 生命周期, beforemounted , mounted

        // bm 生命周期 及 hook 执行
        if (bm) {
          invokeArrayFns(bm)
        }
        // ..
        
       // 渲染组件生成 subTree VNode
       const subTree = (instance.subTree = renderComponentRoot(instance))

        if (el && hydrateNode) {
          // ...
        } else {
          // 把 subTree 挂载到Dom容器中
          patch(
            null,
            subTree,
            container,
            anchor,
            instance,
            parentSuspense,
            isSVG
          )

          initialVNode.el = subTree.el
        }
        
        // 生命周期 mounted hook 执行
        if (m) {
          queuePostRenderEffect(m, parentSuspense)
        }
        // ...
        
        instance.isMounted = true
      } else {
        // updateComponent
        // This is triggered by mutation of component's own state (next: null)
        // OR parent calling processComponent (next: VNode)

      }
    },  prodEffectOptions)
  }

回顾上一篇的内容 effect 函数想必大家不会陌生,运行 componentEffect 触发依赖收集,收集这个 effect 函数,当组件数据改变,会重新执行这个 effect 函数中的 componentEffect 方法。

componentEffect 主要逻辑就是生成 subTree VNode,再挂载这个subTree。

renderComponentRoot
export function renderComponentRoot(
  instance: ComponentInternalInstance
): VNode {
  const {
    type: Component,
    vnode,
    proxy,
    withProxy,
    props,
    propsOptions: [propsOptions],
    slots,
    attrs,
    emit,
    render,  // 这里render 是 .vue 编译后的render函数
    renderCache,
    data,
    setupState,
    ctx
  } = instance

  let result
  currentRenderingInstance = instance
  
  try {
    let fallthroughAttrs
    if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
      const proxyToUse = withProxy || proxy
      // 本次例子中 这里会循环创建 Helloworld, p标签 VNode
      result = normalizeVNode(
        render!.call(
          proxyToUse,
          proxyToUse!,
          renderCache,
          props,
          setupState,
          data,
          ctx
        )
      )
      fallthroughAttrs = attrs
    } else {
      // functional
  } catch (err) {
    // ...
  }
  currentRenderingInstance = null

  return result
}

subTree 是什么? 例如最开始的例子 App 组件为 initialVNodesubTree 就是 App组件模版里的结构生成的VNode,children 属性为 HelloWorld 组件VNode, p 标签VNode。

而App组件 initialVNodechidren 里面,根据 HelloWorld 标签生成的 VNode, 对于 HelloWorld 组件内部DOM结构来说就是 initialVNode,而其内部DOM结构生成的VNode就是 subTree

下图是实例里 HelloWorld.vue 编译后的 render 函数

这是App的subTree

可以看到children里面有着 Helloworld p 标签 VNode。

回到 setupRenderEffect 方法,生成 subTree 后,又回到我们之前patch流程,判断怎样处理传入的VNode,这样不停循环,直至 patch 真实DOM元素,注释等VNode。

不知道大家有没有注意到,开始例子中 App.vue 的模版中是没有根节点的,这也是 Vue3 中 新支持的功能,在Vue2 中肯定是需要一个 divHelloWorld, p 标签包裹起来的。

所以咱们例子中APP组件的 subTree 被解析为 typeSymbol(Fragment) 的VNode。

回到 patch 方法,看看 processFragment

  const processFragment = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
    // 没有根节点,要确认分配在何处
    const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))!
    const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))!
    // ...
    if (n1 == null) {
      hostInsert(fragmentStartAnchor, container, anchor)
      hostInsert(fragmentEndAnchor, container, anchor)
      // 走到这里children一定会是数组
      mountChildren(
        n2.children as VNodeArrayChildren,
        container,
        fragmentEndAnchor,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized
      )
    } else {}
  }

上面 hostCreateText , hostInsert 都是咱上一篇说过的创建 render 时传入的 rendererOptions,这个 包含着浏览器的DOM API,props。例如 hostCreateText 其实就是 document.createTextNodehostInsert 就是 parent.insertBefore(*child*, *anchor* || null)

processFragment 在确定完位置后,便会执行 mountChildren 去处理 children VNode数组。

mountChildren
const mountChildren: MountChildrenFn = (
    children,
    container,
    anchor,
    parentComponent,
    parentSuspense,
    isSVG,
    optimized,
    start = 0
  ) => {
    for (let i = start; i < children.length; i++) {
      const child = (children[i] = optimized
        ? cloneIfMounted(children[i] as VNode)
        : normalizeVNode(children[i]))
      // patch每一个VNode
      patch(
        null,
        child,
        container, 
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized
      )
    }
  }

可以看到 mountChildren 会遍历 children, patch 每一个VNode 至当前的 container 下。

又回到了 patch , 那接下来我们看看如果是一个DOM节点VNode,是怎样处理的。

  const processElement = (
    n1: VNode | null,
    n2: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
    if (n1 == null) {
      mountElement(
        n2,
        container,
        anchor,
        parentComponent,
        parentSuspense,
        isSVG,
        optimized
      )
    } else {
      // 
    }
  }

与处理组件的流程差不多都是通过有无旧节点,来判断执行 mount 还是 update。

mountElement
  const mountElement = (
    vnode: VNode,
    container: RendererElement,
    anchor: RendererNode | null,
    parentComponent: ComponentInternalInstance | null,
    parentSuspense: SuspenseBoundary | null,
    isSVG: boolean,
    optimized: boolean
  ) => {
    let el: RendererElement
    let vnodeHook: VNodeHook | undefined | null
    const {
      type,
      props,
      shapeFlag,
      transition,
      scopeId,
      patchFlag,
      dirs
    } = vnode
    // ...
      // 调用传入的API创建DOM元素
      el = vnode.el = hostCreateElement(
        vnode.type as string,
        isSVG,
        props && props.is
      )

      if (shapeFlag & ShapeFlags.TEXT_CHILDREN) { // 8
        // 如果是子节点文本 创建文本
        hostSetElementText(el, vnode.children as string)
      } else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) { // 16
        // 如果是数组,回到mountChildren遍历继续patch子节点
        // 注意这里传入的 container 已经是刚刚创建的 el DOM元素,这样就创建了父子关系
        mountChildren(
          vnode.children as VNodeArrayChildren,
          el,
          null,
          parentComponent,
          parentSuspense,
          isSVG && type !== 'foreignObject',
          optimized || !!vnode.dynamicChildren
        )
      }

      if (dirs) {
        // 调用指令相关的生命周期处理
        invokeDirectiveHook(vnode, null, parentComponent, 'created')
      }
      // 如果有DOM的 props,例如原生的class style,自定义的prop等
      if (props) {
        for (const key in props) {
          if (!isReservedProp(key)) {
            hostPatchProp(
              el,
              key,
              null,
              props[key],
              isSVG,
              vnode.children as VNode[],
              parentComponent,
              parentSuspense,
              unmountChildren
            )
          }
        }
        if ((vnodeHook = props.onVnodeBeforeMount)) {
          invokeVNodeHook(vnodeHook, parentComponent, vnode)
        }
      }
      // ...

    
    if (dirs) {
      invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount')
    }

    /** 把创建的el DOM挂载到 contanier容器上
    *   初次渲染container是 #app 容器,但是之后就是对应的父级DOM容器了
    **/
    hostInsert(el, container, anchor)
    
    // ...
  }

可以看到处理挂载DOM节点主要逻辑是,首先调用 hostCreateElement 创建DOM,hostCreateElement其实就是调用浏览器的 document.createElement。 然后判断处理子节点是文本还是数组的情况。之后处理DOM的原生或者自定义属性。最后调用 insert 挂载到DOM容器上。

HelloWorld组件内部 div 举例,它的 children 只是一段我们通过 prop 传入的文本,所以调用 hostSetElementText el.textContent = *text*插入文本即可。

有人可能会有疑问 div VNode的 shapeFlag 怎么会是9, 还记得 createVNode 方法里面 normalizeChildren 操作吗? 它会根据children的类型是数组、文本或者是插槽修改 shapeFlag 的值。

小结

通过代码看渲染流程感觉是不是很绕,大家可以用流程图的方式去理解它。

结尾

感谢你的阅读,日前智云健康大前端团队正在参加掘金人气团队评选活动。如果你觉得不错的话,那就来 给我们投几票 吧!

今日总共可以投12票,网页4票,App4票,分享4票。感谢你的支持,2021我们还会创作更多的技术好文~~~

你的支持是是我们最大的动力~