白话vue——createApp的秘密(第二期)

950 阅读10分钟

vue3的文档在介绍完其使用方式后,第一步就是如何创建应用,而创建vue应用最常用的方法就是调用createApp生产应用实例,接下来我们一起研究下createApp这个API的底层逻辑。

1、createApp的使用方法

createApp方法的调用方式很简单,无非是使用前端脚手架还是cdn,都需要手动调用createApp并传入根组件对象,生成一个vue的应用实例:

import { createApp } from 'vue'
const app = createApp({
    /* 根组件选项 */
})

我们深入这个createApp方法,看看它究竟做了哪些工作。

export const createApp = ((...args) => {
  // 生成vue应用实例
  const app = ensureRenderer().createApp(...args)

  const { mount } = app
  app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
    // 改写mount函数
  }

  return app
}) as CreateAppFunction<Element>

从源码层面看,其实createApp的逻辑很简单,只有两步操作,第一步根据createApp传入的根组件对象创建app应用实例,第二步改写app上的mount函数。让我们一步一步分析

1.1 生成vue实例

const app = ensureRenderer().createApp(...args)

上述代码从调用关系来看,ensureRenderer函数返回一个对象,该对象上有createApp方法,该方法传入根组件对象,然后返回应用实例。ensureRenderer函数的执行逻辑如下:

// lazy create the renderer - this makes core renderer logic tree-shakable
// in case the user only imports reactivity utilities from Vue.
let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer

function ensureRenderer() {
  return (
    renderer ||
    (renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
  )
}

这段代码主要完成了两件事,第一件事是懒创建renderer渲染器对象,第二件事是ensureRenderer实现了单例模式,确保全局只有一个渲染器对象,因为真正创建vue应用实例的只是renderer对象上的createApp方法而已,所以不需要多个renderer对象。但是如果直接在代码里创建了renderer对象,就会在该模块中产生副作用,对于有副作用的代码,es module是没办法进行树摇的,所以这里在用户真正调用ensureRender时才会创建一个renderer对象。

1.2 创建render函数

可见ensureRenderer方法只是为了实现懒创建和单例,renderer对象的具体生成逻辑位于createRenderer函数中,并且参数是该模块传入的rendererOptions,我们先来看看传入参数究竟是什么样的:

const rendererOptions = /*@__PURE__*/ extend({ patchProp }, nodeOps)

从上述代码可以看出,rendererOptions是由两个对象合并而来,即将nodeOps中的属性扩展到
{ patchProp }中。其中patchProp是一个函数,该函数主要用于更新元素或者组件的属性,也可以用于派发绑定在组件上的事件,而nodeOps则是封装了一系列dom的操作方法,如插入节点、删除节点、创建节点等。也就是说rendererOptions是包含了dom操作的工具对象。 createRenderer接受了工具对象参数后,又将工具对象参数传递给baseCreateRenderer函数,用于创建不同平台的渲染器,那我们重点看看baseCreateRenderer函数究竟做了什么。

function baseCreateRenderer(
  options: RendererOptions,
  createHydrationFns?: typeof createHydrationFunctions,
): any {
  // 结构工具对象参数传入的各个方法
  const {
    insert: hostInsert,
    patchProp: hostPatchProp,
    ...
  } = options
  // 组合工具对象中的方法,构成render和hydrate函数需要的工具函数
  ...
  // 定义render方法
  const render: RootRenderFunction = (vnode, container, namespace) => {
    ...
  }
  // 返回由渲染对象和方法组成的对象
  return {
    render,
    hydrate,
    createApp: createAppAPI(render, hydrate)
  }
}

baseCreateRenderer函数虽然很长,但是主逻辑较清晰,首先对传入函数的工具对象进行解构,取出其中的工具函数,然后在baseCreateRenderer函数中组装成供render和hydrate函数调用的工具函数。最后将定义好的renderhydrate函数返回。当在SPA场景使用vue3时,hydrate变量的值为undefined,所以重点看render函数的内部逻辑:

 // vnode: 虚拟node对象
 // container: 挂载容器dom节点
 // namespace: 命名空间
 const render: RootRenderFunction = (vnode, container, namespace) => {
    if (vnode == null) {
      // 如果需要渲染的vnode为null,则需要将挂载容器上的node卸载
      if (container._vnode) {
        unmount(container._vnode, null, null, true)
      }
    } else {
      // 如果需要渲染的vnode不为null,则需要更新当前vnode
      patch(
        container._vnode || null,
        vnode,
        container,
        null,
        null,
        null,
        namespace,
      )
    }
    // 缓存当前挂载容器的_vnode
    container._vnode = vnode
    if (!isFlushing) {
    // isFlushing 初始值为false,表示当前并没有执行flushPreFlushCbs和flushPostFlushCbs
    // 开始执行时,isFlushing值为true
    // 这样可以确保,同一时间只有一组flushPreFlushCbs和flushPostFlushCbs在执行
      isFlushing = true
      flushPreFlushCbs()
      flushPostFlushCbs()
      isFlushing = false
    }
  }

在Vue 3的源码中,render函数是负责执行组件的渲染逻辑的核心部分。它接受一个新的vnode,和当前挂载容器的vnode进行比对更新,更新后执行flushPreFlushCbsflushPostFlushCbs函数,flushPreFlushCbs函数用于执行事件队列(queue)中被标记为PRE(预刷新)的回调函数,这些回调函数通常是在组件实例更新之前需要执行的操作。flushPostFlushCbs函数同flushPreFlushCbs函数类似,用于执行在下一个 dom 更新周期之后需要执行的回调函数。也就是说,这里提供了两个钩子,供需要在不同生命周期执行的函数挂载执行。
为了更好的分析render函数的逻辑,我们先从执行回调函数之前的逻辑开始研究。从条件分支语句的表单式可以看出,render函数根据传入的vnode值进行不同处理,如果需要更新的vnode为null,则表示需要卸载掉挂载容器上的vnode,而如果不为null,则需要更新挂载容器上的vnode。
由于unmount函数的代码很长,所以我们对该函数进行逐步分析

// unmount函数内部
// 首先解构出vnode上的属性
const {
  type,
  props,
  ref,
  children,
  dynamicChildren,
  shapeFlag,
  patchFlag,
  dirs,
  cacheIndex,
} = vnode
// 判断当前需要更新的vnode的patchFlag值
if (patchFlag === PatchFlags.BAIL) {
  optimized = false
}

首先来看看patchFlag值的含义,我们都知道vue3有个特性是dom的局部更新,一些静态的、不变的节点是不需要更新的,这里patchFlag值就是用来表示一个vnode哪些部分需要更新。PatchFlags.BAIL枚举值的含义是指当前vnode不需要进行优化对比算法来提高操作dom的性能,需要全量的更新。PatchFlags还有许多其他枚举值,每个枚举值都有很详细的注释,感兴趣的同学可以深入源码了解下。

// unmount函数内部
// unset ref
if (ref != null) {
  setRef(ref, null, parentSuspense, vnode, true)
}
// #6593 should clean memo cache when unmount
if (cacheIndex != null) {
  parentComponent!.renderCache[cacheIndex] = undefined
}
// 如果当前vnode需要保持keep-alive
if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
  ;(parentComponent!.ctx as KeepAliveContext).deactivate(vnode)
  return
}
// 如果该vnode上绑定了卸载的事件,则此时还需要执行绑定的方法
if (
  shouldInvokeVnodeHook &&
  (vnodeHook = props && props.onVnodeBeforeUnmount)
) {
  invokeVNodeHook(vnodeHook, parentComponent, vnode)
}

unmount函数的第二步则是判断当前vnode是否被ref引用,即在vue的模板中使用ref属性引用一个dom或者是一个组件,如果该vnode有被ref引用,则将该应用清除。接着会判断当前vnode是否为缓存组件,如果已经被设置为缓存,则在卸载该组件时,会将缓存中的该组件应用清除掉。然后判断是否还有需要在beforeUnmount时执行的钩子函数,调用unmountComponent后,还会执行一次onVnodeUnmounted中的注册方法。这就是一个组件在卸载时所要做的所有操作。
我们回到render函数中,如果当前挂载容器上的vnode不为null,则需要执行patch函数更新当前容器挂载的vnode:

  const patch: PatchFn = (
    n1,
    n2,
    container,
    anchor = null,
    parentComponent = null,
    parentSuspense = null,
    namespace = undefined,
    slotScopeIds = null,
    optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren,
  ) => {
    // 如果两个vnode引用相同,则不需要更新当前dom
    if (n1 === n2) {
      return
    }
    // 如果两个vnode的类型都不一样,则也不需要比较了,直接将旧的vnode清空,然后做更新操作
    // patching & not same type, unmount old tree
    if (n1 && !isSameVNodeType(n1, n2)) {
      anchor = getNextHostNode(n1)
      unmount(n1, parentComponent, parentSuspense, true)
      n1 = null
    }
    // 和unmount函数类似,此标志表示不需要进行dom操作的性能优化
    if (n2.patchFlag === PatchFlags.BAIL) {
      optimized = false
      n2.dynamicChildren = null
    }

    const { type, ref, shapeFlag } = n2
    // 根据vnode的类型进行不同的dom操作
    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, namespace)
        } else if (__DEV__) {
          patchStaticNode(n1, n2, container, namespace)
        }
        break
      case Fragment:
        ...
        break
      default:
        if (shapeFlag & ShapeFlags.ELEMENT) {
          processElement(
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            namespace,
            slotScopeIds,
            optimized,
          )
        } else if (shapeFlag & ShapeFlags.COMPONENT) {
          processComponent(
            n1,
            n2,
            container,
            anchor,
            parentComponent,
            parentSuspense,
            namespace,
            slotScopeIds,
            optimized,
          )
        }
        ...
    }
    // 由于vnode更新,所以需要更新下ref引用该vnode的位置
    // set ref
    if (ref != null && parentComponent) {
      setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
    }
  }

至此,render函数创建完毕。render函数创建完毕后传入createAppAPI函数中,createAppAPI函数是一个高阶函数,返回创建vue实例需要的createApp函数

export function createAppAPI<HostElement>(
  render: RootRenderFunction<HostElement>,
  hydrate?: RootHydrateFunction,
): CreateAppFunction<HostElement> {
  return function createApp(rootComponent, rootProps = null) {
    // createAppContext用于创建一个空的context对象
    // context对象可以理解为一个容器对象,该对象中保存了app实例的各种数据
    // 如组件信息、mixin信息等
    const context = createAppContext()
    // 创建一个插件的map
    const installedPlugins = new WeakSet()
    // 创建一个用于存储清除插件的方法列表
    const pluginCleanupFns: Array<() => any> = []

    let isMounted = false
    // 新建一个app实例对象
    const app: App = (context.app = {
      _uid: uid++,
      _component: rootComponent as ConcreteComponent,
      _props: rootProps,
      _container: null,
      _context: context,
      _instance: null,

      version,

      get config() {
        return context.config
      },

      set config(v) {
        if (__DEV__) {
          warn(
            `app.config cannot be replaced. Modify individual options instead.`,
          )
        }
      },

      use(plugin: Plugin, ...options: any[]) {
        ...
      },

      mixin(mixin: ComponentOptions) {
        ...
      },

      component(name: string, component?: Component): any {
        ...
      },

      directive(name: string, directive?: Directive) {
        ...
      },

      mount(
        rootContainer: HostElement,
        isHydrate?: boolean,
        namespace?: boolean | ElementNamespace,
      ): any {
        ...
      },

      onUnmount(cleanupFn: () => void) {
        ...
      },

      unmount() {
        ...
      },

      provide(key, value) {
        ...
      },

      runWithContext(fn) {
       ...
      },
    })

    return app
  }
}

从代码层面可以看出,createAppAPI方法返回的createApp方法内部逻辑简单清晰,就是用于创建一个vue应用实例,该实例上的方法我们在使用vue时也能经常看到。
我们在第一步分析被直接调用的createApp方法时,createApp方法会对app实例上的mount函数进行改写,具体改写逻辑如下:

  app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
    const container = normalizeContainer(containerOrSelector)
    if (!container) return

    const component = app._component
    if (!isFunction(component) && !component.render && !component.template) {
      // 如果非函数式组件,没有手动写渲染函数,也没有定义根组件的模板字符串
      // 则将挂载容器的内部html字符串赋值给根组件的template
      component.template = container.innerHTML
    }

    // clear content before mounting
    // nodeType是真实dom的节点类型
    // 1表示挂载容器是元素节点,则将挂载容器中的文本内容全部清除
    if (container.nodeType === 1) {
      container.textContent = ''
    }
    // 执行app创建时的mount方法
    const proxy = mount(container, false, resolveRootNamespace(container))
    // 设置挂载容器的元素属性
    if (container instanceof Element) {
      container.removeAttribute('v-cloak')
      container.setAttribute('data-v-app', '')
    }
    // 返回mount函数的返回值
    return proxy
  }

从上述改写逻辑可以看出,app.mount改写操作只是做了一些预处理操作和元素属性操作,比如我们常用的v-clock指令就是在执行完mount函数后去除的,这个指令能够阻止dom的初始渲染,避免出现闪屏的情况。也就是说,app.mount函数执行的背后,仍然以创建app实例对象定义的mount函数为主要执行逻辑,让我们深入mount函数看看它究竟是如何定义的

  // apiCreateApp.ts
  // createAppAPI函数内部
  mount(
    rootContainer: HostElement,
    isHydrate?: boolean,
    namespace?: boolean | ElementNamespace,
  ): any {
    //  isMounted是一个闭包变量,在调用createApp时初始化为false
    if (!isMounted) {
      // 获取当前根组件的vnode
      const vnode = app._ceVNode || createVNode(rootComponent, rootProps)
      // 将调用createApp时创建的context对象挂载在根组件的vnode上
      vnode.appContext = context

      if (namespace === true) {
        namespace = 'svg'
      } else if (namespace === false) {
        namespace = undefined
      }
      // SSR时会进行水合的操作,即进行客户端vnode和服务端返回的html结构进行对比
      if (isHydrate && hydrate) {
        hydrate(vnode as VNode<Node, Element>, rootContainer as any)
      } else {
      // 非SSR模式下会将根组件的vnode渲染到挂载容器上
        render(vnode, rootContainer, namespace)
      }
      // 将是否挂载的标志位置为true
      isMounted = true
      // 将挂载容器在app上也存储一份
      app._container = rootContainer
      // for devtools and telemetry
      ;(rootContainer as any).__vue_app__ = app
      // 返回根组件实例
      return getComponentPublicInstance(vnode.component!)
    }
  }

平时使用中,我们一般都会直接调用app.mount()来进行vue的实例挂载,而不会使用挂载函数执行的返回值,所以这里就不再赘述了。
至此整个createApp函数的逻辑执行完毕,感兴趣的同学可以关注我,我将会持续从源码的角度看vue3的文档。大家下次见~