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函数调用的工具函数。最后将定义好的render和hydrate函数返回。当在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进行比对更新,更新后执行flushPreFlushCbs和flushPostFlushCbs函数,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的文档。大家下次见~