我的开源库:
- fly-barrage 前端弹幕库,项目官网:https://fly-barrage.netlify.app/,可实现类似于 B 站的弹幕效果,并提供了完整的 DEMO,Gitee 推荐项目;
- fly-gesture-unlock 手势解锁库,项目官网:https://fly-gesture-unlock.netlify.app/,在线体验:https://fly-gesture-unlock-online.netlify.app/,可高度自定义锚点的数量、样式以及尺寸;
组件是对页面中内容的一种模块化封装,这篇博客讲解 Vue 中组件化的实现方式。
1,Vue 中组件的定义方式
在讲解组件化的实现原理前,首先说明在 Vue 中,一个组件的本质到底是什么。Vue 中有两种形式的组件,分别是有状态组件和无状态组件,有状态组件的本质是一个对象字面量,无状态组件的本质是一个函数。也就是说,在 Vue 中,组件的本质是一个对象字面量或者一个函数,这里以有状态组件进行讲解。
2,VNode 简介
VNode 的本质就是一个普通的对象字面量,只不过这个对象字面量能够很好的描述真实 DOM,通过 VNode 可以渲染出页面中真实的 DOM。
VNode 是通过组件的 render 函数创建出来的,我们平时在开发中,一般都是使用 template 字符串描述页面内容,这个模板字符串会被 Vue 的编译器编译成 render 函数,所以在 Vue 的运行时,用于描述组件渲染内容的是 render 函数。
下面展示一下 Vue3 中 VNode 的 TypeScript 的类型定义:
export interface VNode<
HostNode = RendererNode,
HostElement = RendererElement,
ExtraProps = { [key: string]: any }
> {
__v_isVNode: true
[ReactiveFlags.SKIP]: true
type: VNodeTypes
props: (VNodeProps & ExtraProps) | null
key: string | number | symbol | null
ref: VNodeNormalizedRef | null
scopeId: string | null
slotScopeIds: string[] | null
children: VNodeNormalizedChildren
component: ComponentInternalInstance | null
dirs: DirectiveBinding[] | null
transition: TransitionHooks<HostElement> | null
el: HostNode | null
anchor: HostNode | null // fragment anchor
target: HostElement | null // teleport target
targetAnchor: HostNode | null // teleport target anchor
staticCount: number
suspense: SuspenseBoundary | null
ssContent: VNode | null
ssFallback: VNode | null
shapeFlag: number
patchFlag: number
dynamicProps: string[] | null
dynamicChildren: VNode[] | null
appContext: AppContext | null
memo?: any[]
isCompatRoot?: true
ce?: (instance: ComponentInternalInstance) => void
}
VNode 对象的属性还是很多的,这里不用看这么多,先关注一下 type 属性。
export interface VNode<
HostNode = RendererNode,
HostElement = RendererElement,
ExtraProps = { [key: string]: any }
> {
type: VNodeTypes
}
export type VNodeTypes =
| string
| VNode
| Component
| typeof Text
| typeof Static
| typeof Comment
| typeof Fragment
| typeof TeleportImpl
| typeof SuspenseImpl
type 属性用于描述 VNode 的类型,VNode 的类型有很多种,这里我们看下 string 和 Component 类型,当 VNode 的 type 属性是字符串的时候,说明当前的 VNode 描述的是普通的元素,当 VNode 的 type 是 Component 的时候,说明当前的 VNode 描述的是一个组件。
假设我们的 Vue 中有一个 MyComponent 组件,我们在一个模板字符串中使用了这个组件,代码如下所示:
<template>
<MyComponent></MyComponent>
</template>
上面的模板字符串会被编译成一个 render 函数,render 函数执行返回一个 VNode,这个 VNode 是一个组件类型的 VNode,表明需要渲染一个组件。
componentVNode = {
type: {
...组件的定义对象...
},
......
}
有了组件类型的 VNode,接下来看看这个组件 VNode 是如何渲染和更新的。
3,组件的挂载和更新
组件挂载和更新的逻辑都写在渲染器中,我们直接看源码。
const patch: PatchFn = (
n1,
n2,
container,
anchor = null,
parentComponent = null,
parentSuspense = null,
isSVG = false,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
// 解构获取 n2 的 type、ref、shapeFlag
const { type, ref, shapeFlag } = n2
// 根据 n2 Vnode 的类型进行不同的处理
switch (type) {
......
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
// 处理元素节点
processElement()
} else if (shapeFlag & ShapeFlags.COMPONENT) {
// 处理组件节点
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (__DEV__) {
// 如果以上条件都不满足,并且是在开发模式下的话,则打印出相关警告:违法的 vnode 类型
warn('Invalid VNode type:', type, `(${typeof type})`)
}
}
}
在 patch 函数中,会根据 VNode 类型的不同使用不同的函数进行处理,如果当前的 VNode 表示的是组件的话,则会使用 processComponent 函数进行处理,processComponent 函数的内容如下所示:
const processComponent = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
if (n1 == null) {
mountComponent(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
} else {
updateComponent(n1, n2, optimized)
}
}
在这里,判断 oldVNode 是否存在,如果存在的话,则执行 updateComponent 函数进行组件的更新,如果不存在的话,则执行 mountComponent 函数进行组件的挂载,我们首先看组件的挂载。
3-1,组件的挂载
// 挂载组件节点
const mountComponent: MountComponentFn = (
initialVNode,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
) => {
// 创建组件实例对象
const compatMountInstance =
__COMPAT__ && initialVNode.isCompatRoot && initialVNode.component
const instance: ComponentInternalInstance =
compatMountInstance ||
(initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense
))
// resolve props and slots for setup context
// 解析初始化一些数据
if (!(__COMPAT__ && compatMountInstance)) {
setupComponent(instance)
}
setupRenderEffect(
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
)
}
在 mountComponent 函数中,首先创建组件的实例,每渲染一次组件,就会创建一个对应的实例,组件实例就是一个对象,这个对象维护着组件运行过程中的所有信息,例如:注册的生命周期函数、组件上次渲染的 VNode,组件状态等等。一个组件实例的内容如下所示:
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
effect: null!,
update: null!, // will be set synchronously right after creation
scope: new EffectScope(true /* detached */),
render: null,
proxy: null,
exposed: null,
exposeProxy: null,
withProxy: null,
provides: parent ? parent.provides : Object.create(appContext.provides),
accessCache: null!,
renderCache: [],
// local resolved assets
components: null,
directives: null,
// resolved props and emits options
propsOptions: normalizePropsOptions(type, appContext),
emitsOptions: normalizeEmitsOptions(type, appContext),
// emit
emit: null!, // to be set immediately
emitted: null,
// props default value
propsDefaults: EMPTY_OBJ,
// inheritAttrs
inheritAttrs: type.inheritAttrs,
// state
ctx: EMPTY_OBJ,
data: EMPTY_OBJ,
props: EMPTY_OBJ,
attrs: EMPTY_OBJ,
slots: EMPTY_OBJ,
refs: EMPTY_OBJ,
setupState: EMPTY_OBJ,
setupContext: null,
// suspense related
suspense,
suspenseId: suspense ? suspense.pendingId : 0,
asyncDep: null,
asyncResolved: false,
// lifecycle hooks
// not using enums here because it results in computed properties
isMounted: false,
isUnmounted: false,
isDeactivated: false,
bc: null,
c: null,
bm: null,
m: null,
bu: null,
u: null,
um: null,
bum: null,
da: null,
a: null,
rtg: null,
rtc: null,
ec: null,
sp: null
}
上面的对象包含着很多的状态信息,是实现组件化一个很重要的内容。
创建完组件实例后,Vue 使用 setupComponent 函数进行一些数据的解析和初始化,下面调用的 setupRenderEffect 函数是重点。
const setupRenderEffect: SetupRenderEffectFn = (
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
) => {
const componentUpdateFn = () => {
// 使用组件实例的 isMounted 属性判断组件是否挂载
// 如果为属性为 false,说明还未挂载,所以执行挂载逻辑
// 如果属性为 true 的话,说明已经挂载,所以执行更新逻辑
if (!instance.isMounted) {
const { bm, m } = instance
// beforeMount hook
// 触发执行 beforeMount 生命周期函数
if (bm) {
invokeArrayFns(bm)
}
// 执行 render 函数,获取组件当前的 VNode
const subTree = (instance.subTree = renderComponentRoot(instance))
// 使用 patch 函数进行组件内容的渲染
patch(
null,
subTree,
container,
anchor,
instance,
parentSuspense,
isSVG
)
// mounted hook
// 触发执行 mounted 生命周期函数
if (m) {
queuePostRenderEffect(m, parentSuspense)
}
// 将组件实例的 isMounted 属性设为 true,表明当前的组件已经完成了挂载操作
instance.isMounted = true
} else {
let { bu, u } = instance
// beforeUpdate hook
// 触发执行 beforeUpdate 生命周期函数
if (bu) {
invokeArrayFns(bu)
}
// render
// 执行 render 函数,获取组件最新的 VNode
const nextTree = renderComponentRoot(instance)
// 获取组件上次渲染的 VNode
const prevTree = instance.subTree
instance.subTree = nextTree
// 使用 patch 函数进行组件的更新
patch(
prevTree,
nextTree,
// parent may have changed if it's in a teleport
hostParentNode(prevTree.el!)!,
// anchor may have changed if it's in a fragment
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG
)
// updated hook
// 触发执行 updated 生命周期函数
if (u) {
queuePostRenderEffect(u, parentSuspense)
}
}
}
// 组件的更新借助了响应式系统中的 ReactiveEffect 类
const effect = (instance.effect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(update),
instance.scope // track it in component's effect scope
))
const update: SchedulerJob = (instance.update = () => effect.run())
update()
}
上段代码中,借助 ReactiveEffect 类实现组件的更新,关于这个类的作用和源码可以看我的这篇博客,这里就不过多赘述了。
这里实现功能的重点在 componentUpdateFn 函数中,在上面代码的最后,执行了 update 函数,这会进而触发执行上面 componentUpdateFn 函数的执行,componentUpdateFn 函数的内部会执行组件的 render 函数,render 函数会读取组件的响应式数据,这会触发依赖收集。
componentUpdateFn 函数的解析看上面的注释即可。
3-2,组件的更新
当后续 render 函数依赖的响应式数据发生变化的时候,会再次触发执行 componentUpdateFn 函数进行组件的重新渲染,详细解释看上面源码的注释。
4,组件的 render 函数执行时,如何通过 this 访问到组件的响应式数据
结论:Vue 通过代理让 render 函数执行时能够通过 this 访问到组件实例中的响应式数据。
Vue 通过 renderComponentRoot 函数执行 render 函数,获取 VNode。
instance.subTree = renderComponentRoot(instance)
export function renderComponentRoot(
instance: ComponentInternalInstance
): VNode {
const {
type: Component,
vnode,
proxy,
props,
propsOptions: [propsOptions],
slots,
attrs,
emit,
render,
renderCache,
data,
setupState,
ctx,
inheritAttrs
} = instance
result = normalizeVNode(
render!.call(
proxy,
proxy!,
renderCache,
props,
setupState,
data,
ctx
)
)
return result;
}
render 函数执行的时候,函数中的 this 指向 instance.proxy,接下来看 instance.proxy 属性是如何创建出来的。
export function setupComponent(instance) {
......
setupStatefulComponent(instance);
}
function setupStatefulComponent(instance) {
instance.proxy = new Proxy(instance.ctx, PublicInstanceProxyHandlers);
}
export const PublicInstanceProxyHandlers = {
get({ _: instance }: ComponentRenderContext, key: string) {
const { ctx, setupState, data, props, accessCache, type, appContext } =
instance
if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
accessCache![key] = AccessTypes.SETUP
return setupState[key]
} else if (data !== EMPTY_OBJ && hasOwn(data, key)) {
accessCache![key] = AccessTypes.DATA
return data[key]
} else if (
(normalizedProps = instance.propsOptions[0]) &&
hasOwn(normalizedProps, key)
) {
accessCache![key] = AccessTypes.PROPS
return props![key]
} else if (ctx !== EMPTY_OBJ && hasOwn(ctx, key)) {
accessCache![key] = AccessTypes.CONTEXT
return ctx[key]
}
}
};
可以发现,在 render 函数中通过 this 读取某些属性的时候,代理会判断 instance 的 setupState、data、props 中有没有同名的属性,如果有的话,就进行数据的读取和返回,并且这些被读取属性已经是响应式的了。
5,setup 函数的实现
setup 的官方文档点击这里。
setup 函数只会在组件挂载的时候执行一次,setup 函数既可以返回一个对象,也可以返回一个函数,如果返回的是一个对象的话,这个对象中的数据可以像 data 和 props 一样使用,如果返回的是一个函数的话,这个函数会被当成组件的 render 函数。
源码如下所示:
// 挂载组件节点
const mountComponent: MountComponentFn = (
initialVNode,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
) => {
......
// 解析初始化一些数据
if (!(__COMPAT__ && compatMountInstance)) {
setupComponent(instance)
}
setupRenderEffect(
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
)
}
export function setupComponent(instance) {
......
setupStatefulComponent(instance);
}
function setupStatefulComponent(instance) {
const Component = instance.type as ComponentOptions
......
const { setup } = Component
if (setup) {
setCurrentInstance(instance)
const setupResult = callWithErrorHandling(
setup,
instance,
ErrorCodes.SETUP_FUNCTION,
[__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
)
unsetCurrentInstance()
handleSetupResult(instance, setupResult, isSSR)
}
}
export function handleSetupResult(
instance: ComponentInternalInstance,
setupResult: unknown,
isSSR: boolean
) {
if (isFunction(setupResult)) {
instance.render = setupResult as InternalRenderFunction
} else if (isObject(setupResult)) {
instance.setupState = proxyRefs(setupResult)
}
}
在 setupStatefulComponent 函数中,获取用户编写的 setup 函数,执行它并获取 setup 函数的返回值 setupResult。
接下来使用 handleSetupResult 函数处理结果,如果 setupResult 是一个函数的话,则将它赋值给组件实例的 render 属性,如果 setupResult 是一个对象的话,则将它赋值给组件实例的 setupState 属性上,当我们想在 render 函数中访问 setup 函数返回的数据时,Vue 会将读取操作代理到 setupState 属性上,源码如下所示:
export const PublicInstanceProxyHandlers = {
get({ _: instance }: ComponentRenderContext, key: string) {
const { ctx, setupState, data, props, accessCache, type, appContext } =
instance
if (setupState !== EMPTY_OBJ && hasOwn(setupState, key)) {
accessCache![key] = AccessTypes.SETUP
return setupState[key]
}
......
}
};
6,组件生命周期的实现原理
组件的生命周期原理很简单,主要分为两部分,分别是生命周期的注册以及生命周期的执行。
首先说生命周期的注册,这里以 setup 函数中进行的生命周期注册为例。
function setupStatefulComponent(instance) {
const Component = instance.type as ComponentOptions
......
const { setup } = Component
if (setup) {
setCurrentInstance(instance)
const setupResult = callWithErrorHandling(
setup,
instance,
ErrorCodes.SETUP_FUNCTION,
[__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
)
unsetCurrentInstance()
handleSetupResult(instance, setupResult, isSSR)
}
}
export let currentInstance: ComponentInternalInstance | null = null
export const setCurrentInstance = (instance: ComponentInternalInstance) => {
currentInstance = instance
}
export const unsetCurrentInstance = () => {
currentInstance = null
}
在生命周期函数执行前,会执行一个 setCurrentInstance 函数,这个函数的作用是将当前的组件实例设置到全局中。
接下来看生命周期注册函数的内容,以 onMounted 函数为例:
export const onMounted = createHook(LifecycleHooks.MOUNTED)
export const createHook =
<T extends Function = () => any>(lifecycle: LifecycleHooks) =>
(hook: T, target: ComponentInternalInstance | null = currentInstance) =>
// post-create lifecycle registrations are noops during SSR (except for serverPrefetch)
(!isInSSRComponentSetup || lifecycle === LifecycleHooks.SERVER_PREFETCH) &&
injectHook(lifecycle, hook, target)
export function injectHook(
type: LifecycleHooks,
hook: Function & { __weh?: Function },
target: ComponentInternalInstance | null = currentInstance,
prepend: boolean = false
): Function | undefined {
if (target) {
const hooks = target[type] || (target[type] = [])
const wrappedHook =
hook.__weh ||
(hook.__weh = (...args: unknown[]) => {
if (target.isUnmounted) {
return
}
// disable tracking inside all lifecycle hooks
// since they can potentially be called inside effects.
pauseTracking()
// Set currentInstance during hook invocation.
// This assumes the hook does not synchronously trigger other hooks, which
// can only be false when the user does something really funky.
setCurrentInstance(target)
const res = callWithAsyncErrorHandling(hook, target, type, args)
unsetCurrentInstance()
resetTracking()
return res
})
if (prepend) {
hooks.unshift(wrappedHook)
} else {
hooks.push(wrappedHook)
}
return wrappedHook
}
}
当我们在 setup 函数中执行 onMounted 等生命周期注册函数时,Vue 会将我们想要注册的生命周期函数保存到组件实例中,组件实例用于保存生命周期函数的属性如下所示:
const instance: ComponentInternalInstance = {
// lifecycle hooks
bc: null,
c: null,
bm: null,
m: null,
bu: null,
u: null,
um: null,
bum: null,
da: null,
a: null,
rtg: null,
rtc: null,
ec: null,
sp: null
}
知道了生命周期函数是如何注册的,接下来看看生命周期函数是如何触发的,生命周期函数触发的代码在 setupRenderEffect 函数中,代码如下所示:
const setupRenderEffect: SetupRenderEffectFn = (
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
) => {
const componentUpdateFn = () => {
// 使用组件实例的 isMounted 属性判断组件是否挂载
// 如果为属性为 false,说明还未挂载,所以执行挂载逻辑
// 如果属性为 true 的话,说明已经挂载,所以执行更新逻辑
if (!instance.isMounted) {
const { bm, m } = instance
// beforeMount hook
// 触发执行 beforeMount 生命周期函数
if (bm) {
invokeArrayFns(bm)
}
// 执行 render 函数,获取组件当前的 VNode
const subTree = (instance.subTree = renderComponentRoot(instance))
// 使用 patch 函数进行组件内容的渲染
patch(
null,
subTree,
container,
anchor,
instance,
parentSuspense,
isSVG
)
// mounted hook
// 触发执行 mounted 生命周期函数
if (m) {
queuePostRenderEffect(m, parentSuspense)
}
// 将组件实例的 isMounted 属性设为 true,表明当前的组件已经完成了挂载操作
instance.isMounted = true
} else {
let { bu, u } = instance
// beforeUpdate hook
// 触发执行 beforeUpdate 生命周期函数
if (bu) {
invokeArrayFns(bu)
}
// render
// 执行 render 函数,获取组件最新的 VNode
const nextTree = renderComponentRoot(instance)
// 获取组件上次渲染的 VNode
const prevTree = instance.subTree
instance.subTree = nextTree
// 使用 patch 函数进行组件的更新
patch(
prevTree,
nextTree,
// parent may have changed if it's in a teleport
hostParentNode(prevTree.el!)!,
// anchor may have changed if it's in a fragment
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG
)
// updated hook
// 触发执行 updated 生命周期函数
if (u) {
queuePostRenderEffect(u, parentSuspense)
}
}
}
// 组件的更新借助了响应式系统中的 ReactiveEffect 类
const effect = (instance.effect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(update),
instance.scope // track it in component's effect scope
))
const update: SchedulerJob = (instance.update = () => effect.run())
update()
}
在 componentUpdateFn 函数中,进行了组件的初始挂载和更新,生命周期函数就是在这些操作的前后触发执行的,在上面的源码中,使用 invokeArrayFns 函数进行生命周期函数的触发执行,它的源码如下所示:
export const invokeArrayFns = (fns: Function[], arg?: any) => {
for (let i = 0; i < fns.length; i++) {
fns[i](arg)
}
}
7,结语
这篇博客讲解了 Vue3 中是如何实现组件化的,下面的博客详细讲讲异步组件以及 Vue 提供的一些内置组件。