注:本文使用vue版本为3.3.4
Vue3出来好久了,Vue2将在2023年底停止维护,我们可以预见,在不久的将来Vue2将会淡出开发者的视野,Vue3接替了Vue2的地位。这意味这技术新一轮的淘汰开始了,总抱着一门框架不松手,最终会被拍在沙滩上,所以自身也需要拥抱变化,同时做到知其然,还要知其所以然。
如果自己没有进步的话,每天早上都会被菜醒,跟社区人对线都没有底气。
本文以Vue3.3.4版本作为目标,来探究Vue3的原理,首先需要大家熟悉Vue3的基本API,这样可以更快地入门。
结构
Vue3在结构上采用了单仓库(monorepo)的方式来管理包,这种做法使得代码管理更加清晰明了。每个包都负责实现一个核心功能,这种模块化的方式使得开发与测试更加便捷。这正是前面提到的组合思想的体现。
相较于之前的Options API,Vue3引入了Composition API,这使得我们不再需要将同一逻辑分散到不同地方,而是可以将逻辑抽取出来,复用在不同组件中。当然,Options API仍然得到支持,使得在快速开发过程中能够获得一些优势。
对于熟悉Vue2的人来说,会了解到Vue2的响应式核心是基于observe的Watcher运行机制实现的,具体实现是使用Object.defineProperty进行劫持。然而,在Vue3中,响应式使用了ES较新的Proxy,运行机制转变为了基于收集effec。
此外,Vue3还进行了大量优化,例如通过收束纯函数、增加树摇机制来减少包体积以及通过静态提升和diff算法变化等方式。
需要注意的是,如果阅读代码的话,会发现Vue3使用了pnpm作为包管理工具,因此依赖安装和启动都使用了pnpm。
$ pnpm install # 安装依赖
$ pnpm run dev # 开启 vue dev 环境 watch
$ pnpm run serve # 启动 example 示例的服务器,进入packages/vue/examples/**调试
渲染
挂载
让我们深入思考一个简单的问题:如何使用Vue3?
此时,有人可能会提到,Vue3的初始化方式从之前的new Vue改为了createApp,挂载方式也采用了链式调用,并使用返回值中的mount
方法来实现。
然而,这仅仅是表象。那么,在这个变化背后,Vue3实际上进行了哪些操作呢?我们一起来看一下源码。
export const createApp = ((...args) => {
const app = ensureRenderer().createApp(...args)
// ...
const { mount } = app
app.mount = (containerOrSelector: Element | ShadowRoot | string): any => {
const container = normalizeContainer(containerOrSelector)
//...
const proxy = mount(container, false, container instanceof SVGElement)
// ...
return proxy
}
return app
})
我们可以看到,mount是ensureRenderer().createApp(...args)所暴露出来的,虽然它后面针对mount做了一层包装和逻辑变更,但容器(container)仍然是作为参数被传递进去的,尽管它经过了normalizeContainer的包装。
在这里,如果containerOrSelector接受选择器的话,那么会返回被选中的具体元素。让我们先来看一下ensureRenderer是什么样的。
let renderer: Renderer<Element | ShadowRoot> | HydrationRenderer
function ensureRenderer() {
return (
renderer ||
(renderer = createRenderer<Node, Element | ShadowRoot>(rendererOptions))
)
}
似乎已经进行了一次缓存操作。如果存在renderer,则直接返回。我们可以看到renderer的定义,它接受的类型还有一个HydrationRenderer。我们可以在同一文件中找到createSSRApp,其逻辑类似于createApp。
让我们回到renderer。它是通过createRenderer返回的。那么,createRenderer是怎么做的呢?
export function createRenderer<
HostNode = RendererNode,
HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
return baseCreateRenderer<HostNode, HostElement>(options)
}
function baseCreateRenderer(
options: RendererOptions,
createHydrationFns?: typeof createHydrationFunctions
): any {
// ...2000行代码
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate)
}
我们注意到,这个函数调用实际上是在baseCreateRenderer中完成的,而baseCreateRenderer内部包含了多达2000行的代码。它的最终输出结果为createApp和render。createApp是由createAppAPI所创建并返回的。那么我们看一下createAppAPI。
export function createAppAPI<HostElement>(
render: RootRenderFunction<HostElement>,
hydrate?: RootHydrateFunction
): CreateAppFunction<HostElement> {
return function createApp(rootComponent, rootProps = null) {
const app: App = (context.app = {
_uid: uid++,
_component: rootComponent as ConcreteComponent,
_props: rootProps,
mount(
rootContainer: HostElement,
isHydrate?: boolean,
isSVG?: boolean
): any {
},
// unmount等属性
return app
})
}
}
在剔除了一些其他属性和逻辑之后,我们可以发现,他其实就是返回了一个mount方法。这个mount方法接收一个DOM作为参数,最终将页面挂载到选择的HTML元素上。让我们一起来看看这是如何实现的。
mount(
rootContainer: HostElement,
isHydrate?: boolean,
isSVG?: boolean
): any {
if (!isMounted) {
const vnode = createVNode(rootComponent, rootProps)
vnode.appContext = context
render(vnode, rootContainer, isSVG)
isMounted = true
return getExposeProxy(vnode.component!) || vnode.component!.proxy
}
}
在精简了SSR代码和一些开发工具后,我们可以发现背后的逻辑流程大致如此。尽管mount确实具有返回值,但通过深入代码,我们发现挂载实际上是通过render来实现的。render函数将由createVNode创建的虚拟节点挂载到传入的DOM上。这里的rootComponent对应于App.vue脚本标签中的大对象以及其他挂载函数(当然,也存在函数式的情况)。而rootProps实际上是createApp的第二个参数,它们通过计算出虚拟节点,然后通过render进行挂载。这里的rootContainer就是我们传入的DOM节点‘#app’。
创建VNode
整个渲染其实可以说是VNode的更新,VNode顾名思义,虚拟节点,是vue描述节点的数据结构,那么他是怎么创建出来的呢?
我们看看createVNode的代码
function _createVNode(
type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT, // vnode 类型,可以是元素、组件或空动态组件
props: (Data & VNodeProps) | null = null, // 属性和 vnode props,可以为空
children: unknown = null, // 子节点,可以为空
patchFlag: number = 0, // patch 标志,默认为 0
dynamicProps: string[] | null = null, // 动态属性数组,可以为空
isBlockNode = false // 是否是块节点,默认为 false
): VNode {
// 如果类型为空或为 NULL_DYNAMIC_COMPONENT
if (!type || type === NULL_DYNAMIC_COMPONENT) {
type = Comment // 将类型设置为注释节点
}
// 如果传入的类型已经是一个 vnode
if (isVNode(type)) {
// 创建一个 vnode 的克隆,可能在类似 <component :is="vnode"/> 的情况下发生
const cloned = cloneVNode(type, props, true /* mergeRef: true */)
// 如果存在子节点,规范化子节点并合并到克隆的 vnode 中
if (children) {
normalizeChildren(cloned, children)
}
// 如果启用了块树并且不是块节点,并且当前存在块
if (isBlockTreeEnabled > 0 && !isBlockNode && currentBlock) {
// 如果克隆的节点是组件,替换当前块中的相同组件节点,否则将克隆节点添加到当前块中
if (cloned.shapeFlag & ShapeFlags.COMPONENT) {
currentBlock[currentBlock.indexOf(type)] = cloned
} else {
currentBlock.push(cloned)
}
}
cloned.patchFlag |= PatchFlags.BAIL // 设置 patchFlag,表示该 vnode 需要特殊处理
return cloned // 返回克隆的 vnode
}
// 类组件的规范化处理
if (isClassComponent(type)) {
type = type.__vccOpts
}
// 类和样式的规范化处理
if (props) {
// 对于响应式或代理对象,需要克隆它以启用变异
props = guardReactiveProps(props)!
let { class: klass, style } = props
// 如果 class 不是字符串,规范化 class
if (klass && !isString(klass)) {
props.class = normalizeClass(klass)
}
// 如果样式是对象,需要克隆它,因为它们可能被改变
if (isObject(style)) {
if (isProxy(style) && !isArray(style)) {
style = extend({}, style)
}
props.style = normalizeStyle(style)
}
}
// 将 vnode 类型信息转成shapeFlag
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
// 创建基本的 vnode 并返回
return createBaseVNode(
type,
props,
children,
patchFlag,
dynamicProps,
shapeFlag,
isBlockNode,
true
)
}
最后调用了createBaseVNode,那么createBaseVNode是什么?
function createBaseVNode(
type: VNodeTypes | ClassComponent | typeof NULL_DYNAMIC_COMPONENT, // vnode 类型,可以是元素、组件或空动态组件
props: (Data & VNodeProps) | null = null, // 属性和 vnode props,可以为空
children: unknown = null, // 子节点,可以为空
patchFlag = 0, // patch 标志,默认为 0
dynamicProps: string[] | null = null, // 动态属性数组,可以为空
shapeFlag = type === Fragment ? 0 : ShapeFlags.ELEMENT, // 形状标志,默认为 ELEMENT,如果类型是 Fragment 则为 0
isBlockNode = false, // 是否是块节点,默认为 false
needFullChildrenNormalization = false // 是否需要完整的子节点规范化,默认为 false
) {
// 创建一个 vnode 对象
const vnode = {
__v_isVNode: true,
__v_skip: true,
type, // vnode 类型
props, // 属性和 vnode props
key: props && normalizeKey(props), // 规范化后的 key
ref: props && normalizeRef(props), // 规范化后的 ref
scopeId: currentScopeId, // 当前作用域 ID
slotScopeIds: null,
children, // 子节点
component: null,
suspense: null,
ssContent: null,
ssFallback: null,
dirs: null,
transition: null,
el: null,
anchor: null,
target: null,
targetAnchor: null,
staticCount: 0,
shapeFlag, // 形状标志
patchFlag, // patch 标志
dynamicProps, // 动态属性数组
dynamicChildren: null,
appContext: null,
ctx: currentRenderingInstance // 当前渲染实例的上下文
} as VNode
// 如果需要完整的子节点规范化
if (needFullChildrenNormalization) {
normalizeChildren(vnode, children)
// 规范化 suspense 子节点
if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
;(type as typeof SuspenseImpl).normalize(vnode)
}
} else if (children) {
// 编译后的元素 vnode - 如果传入了子节点,可能的类型是字符串或数组
vnode.shapeFlag |= isString(children)
? ShapeFlags.TEXT_CHILDREN
: ShapeFlags.ARRAY_CHILDREN
}
// 跟踪 vnode 以构建块树
if (
isBlockTreeEnabled > 0 &&
// 避免块节点跟踪自身
!isBlockNode &&
// 存在当前父块
currentBlock &&
// 存在 patch 标志表示该节点在更新时需要 patch。
// 组件节点也应该始终被 patch,因为即使组件不需要更新,它也需要将实例保留到下一个 vnode,以便稍后可以正确卸载。
(vnode.patchFlag > 0 || shapeFlag & ShapeFlags.COMPONENT) &&
// EVENTS 标志仅用于 hydration,如果它是唯一的标志,由于处理程序缓存,vnode 不应被视为动态的。
vnode.patchFlag !== PatchFlags.HYDRATE_EVENTS
) {
currentBlock.push(vnode)
}
return vnode // 返回 vnode
}
我们可以看到,createVNode 实际上是对数据进行了封装,同时引入了 shapeFlag 参数。然后,它调用了 createBaseVNode。这里需要特别提醒的是,shapeFlag 的逻辑至关重要,它的存在不仅是内置组件的逻辑分流节点,还对所有组件类型(函数式和组件式)的逻辑分流节点起着关键作用。通过它,createBaseVNode 才得以呈现出不同的处理逻辑。
还有一点需要我们关注的是,这里的 type 参数就是我们传入的App对象,或者是函数组件。请注意不要被参数名称所误导。如果 type 是对象类型,那么 shapeFlag 的值将是 STATEFUL_COMPONENT;如果它是函数式组件,那么 shapeFlag 则为 FUNCTIONAL_COMPONENT。
render
在之前的讲述中,我们提到了通过createVNode生成VNode节点,然后通过render方法将这个VNode挂载到对应的HTML元素上。我们上文只介绍了如何生成VNode,而没有详细说明render方法的具体作用。那么接下来,我们将详细探讨render的作用和实现方式。
const render: RootRenderFunction = (vnode, container, isSVG) => {
if (vnode == null) {
// 如果 vnode 为空
if (container._vnode) {
// 如果容器已经存在 vnode,卸载现有的 vnode
unmount(container._vnode, null, null, true)
}
} else {
// 如果 vnode 不为空,使用 patch 函数进行更新或挂载
patch(container._vnode || null, vnode, container, null, null, null, isSVG)
}
// 将容器的 vnode 设置为当前 vnode
container._vnode = vnode
}
从上面的逻辑来看,我们vnode显然是存在的,所以不走卸载逻辑,那么就应该走patch逻辑,但是这里patch是更新逻辑怎么回事?我们还刚刚创建一个vue,是初始化,怎么是更新呢?
显而易见,在vue中,初始化和更新是一个相同的逻辑。初始化,其实是一个全量的更新。那么patch的逻辑是什么呢?
const patch: PatchFn = (
n1,// n1是老节点
n2, // n2是新节点
container,// container是需要挂载的HTML容器
anchor = null,// anchor是参考元素,这个可能不太好理解,类比的话,类似dom里面进行插入操作,需要提供父节点以及插入到目标元素后面等信息,anchor起到参考定位的作用
parentComponent = null// parentComponent是父组件
isSVG = false,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
// 节点相同不处理
if (n1 === n2) {
return
}
// 对于类型不同的新老节点,直接进行卸载
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
// 如果新 vnode 的 patchFlag 是 BAIL,表示不需要优化,将 optimized 设置为 false
if (n2.patchFlag === PatchFlags.BAIL) {
optimized = false
n2.dynamicChildren = null
}
const { type, ref, shapeFlag } = n2
switch (type) {
case Text:
// 文本
processText(n1, n2, container, anchor)
break
// 注释
case Comment:
processCommentNode(n1, n2, container, anchor)
break
// Static Fragment等
default:
// 处理DOM
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
// 处理模板
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
} else if (...) {
// 内置组件
}
}
// 如果有ref,就设置ref
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
}
}
这段注释已经解释得非常清楚,我们可以按照初始化流程,n1 为 null,n2 是由 App.vue 的大对象生成的 vnode,也就是 createBaseVNode 的返回值,container 是 '#app' 的 DOM,其他都是 null。
这里的逻辑会获取 n2 的 type 和 shapeFlag,也就是 createBaseVNode 处理 App.vue 的大对象的结果。正如之前提到,type 是 App.vue 的大对象,因此是一个对象组件,shapeFlag 就是 STATEFUL_COMPONENT。
好的,我们现在已经获取到了 n2 的对应参数。在 switch 语句中,我们进入了 default 分支,接着执行 if-else 语句,最终到达处理模板这一步。然后会执行以下方法。
processComponent(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
我们进入这个方法,看看是做什么的。
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
) => {
// ...
// 如果旧 vnode 不存在,挂载组件
if (n1 == null) {
// 如果新 vnode 具有 COMPONENT_KEPT_ALIVE 标志,激活组件
if (n2.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
// KeepAlive 逻辑
} else {
// 否则,挂载组件
mountComponent(
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
)
}
} else {
// 如果旧 vnode 存在,更新组件
updateComponent(n1, n2, optimized)
}
}
这代码佐证了我们的想法,在vue中,初始化就是全量更新,在这里如果 n1 不存在,就走mountComponent逻辑。好的,我们看看mountComponent是干什么的。
const mountComponent: MountComponentFn = (
initialVNode,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
optimized
) => {
const instance: ComponentInternalInstance =
compatMountInstance ||
(initialVNode.component = createComponentInstance(
initialVNode,
parentComponent,
parentSuspense
))
// 为 keepAlive 注入 renderer 内部方法
if (isKeepAlive(initialVNode)) {
;(instance.ctx as KeepAliveContext).renderer = internals
}
// 解析 setup 上下文的 props 和 slots
if (!(__COMPAT__ && compatMountInstance)) {
setupComponent(instance)
}
// setup() 是异步的。在继续之前,该组件依赖于异步逻辑被解析
if (__FEATURE_SUSPENSE__ && instance.asyncDep) {
parentSuspense && parentSuspense.registerDep(instance, setupRenderEffect)
// 如果不是 hydration,为它提供一个占位符
if (!initialVNode.el) {
const placeholder = (instance.subTree = createVNode(Comment))
processCommentNode(null, placeholder, container!, anchor)
}
return
}
// 设置副作用函数
setupRenderEffect(
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
)
}
这段代码做了什么呢?首先,根据App.vue的大对象VNode,它创建了一个组件实例(component instance)。这个组件实例就是我们通过getCurrentInstance获取的那个对象。接着,它将这个实例作为参数传入名为setupComponent的函数。从名称就可以看出,这个函数是用来执行实例中的setup函数的。之后,它处理了一些针对异步setup的操作,然后调用了名为setupRenderEffect的函数。从名称可以看出,这个函数是一个副作用函数,用于执行render操作。让我们逐一了解这些步骤。
首先是createComponentInstance函数。它的作用是创建一个组件实例,与其说是创建,不如说是初始化。代码很简单,只是用来初始化组件实例,没有太多的逻辑。
export function createComponentInstance(
vnode: VNode,
parent: ComponentInternalInstance | null,
suspense: SuspenseBoundary | null
) {
// 获取组件类型
const type = vnode.type as ConcreteComponent
// 继承父组件的应用程序上下文,或者如果是根组件,则继承根 vnode 的应用程序上下文
const appContext =
(parent ? parent.appContext : vnode.appContext) || emptyAppContext
// 创建组件内部实例
const instance: ComponentInternalInstance = {
uid: uid++, // 组件实例唯一标识符
vnode, // 组件的 vnode
type, // 组件类型
parent, // 父组件实例
appContext,
root: null!, // 根实例,稍后会立即设置
next: null, // 下一个节点
subTree: null!, // 子树,会在创建后立即同步设置
effect: null!, // 副作用函数
update: null!, // 更新函数,会在创建后立即同步设置
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: [], // 渲染缓存
// 本地解析的资源
components: null, // 组件
directives: null, // 指令
// 解析的 props 和 emits 选项
propsOptions: normalizePropsOptions(type, appContext), // 规范化的 props 选项
emitsOptions: normalizeEmitsOptions(type, appContext), // 规范化的 emits 选项
// emit 方法
emit: null!, // 立即设置
emitted: null, // 发射的事件记录
// props 的默认值
propsDefaults: EMPTY_OBJ,
// inheritAttrs
inheritAttrs: type.inheritAttrs, // 是否继承属性
// 状态属性
ctx: EMPTY_OBJ,
data: EMPTY_OBJ,
props: EMPTY_OBJ,
attrs: EMPTY_OBJ,
slots: EMPTY_OBJ,
refs: EMPTY_OBJ,
setupState: EMPTY_OBJ, // setup 函数的返回值
setupContext: null, // setup 函数的上下文
attrsProxy: null, // 属性代理对象
slotsProxy: null, // 插槽代理对象
// 悬挂相关
suspense, // 悬挂边界
suspenseId: suspense ? suspense.pendingId : 0, // 悬挂 ID
asyncDep: null, // 异步依赖
asyncResolved: false, // 是否异步已解析
// 生命周期钩子函数
// 这里不使用枚举是因为它会导致计算属性
isMounted: false, // 是否已挂载
isUnmounted: false, // 是否已卸载
isDeactivated: false, // 是否已失活
bc: null, // beforeCreate 钩子函数
c: null, // created 钩子函数
bm: null, // beforeMount 钩子函数
m: null, // mounted 钩子函数
bu: null, // beforeUpdate 钩子函数
u: null, // updated 钩子函数
um: null, // unmounted 钩子函数
bum: null, // beforeUnmount 钩子函数
da: null, // deactivated 钩子函数
a: null, // activated 钩子函数
rtg: null, // renderTracked 钩子函数
rtc: null, // renderTriggered 钩子函数
ec: null, // errorCaptured 钩子函数
sp: null // serverPrefetch 钩子函数
}
instance.ctx = { _: instance }
// 如果存在自定义元素特殊处理函数,调用它
if (vnode.ce) {
vnode.ce(instance)
}
// 设置 emit 方法
instance.emit = emit.bind(null, instance)
return instance // 返回组件实例
}
上述代码就是他的返回属性,绝大部分值都是空值,或者空对象,因为他的作用仅仅是初始化一个instance对象而已,至于填充,那是后面的流程。
然后我们可以看 setupComponent 他做了什么。
export function setupComponent(
instance: ComponentInternalInstance,
isSSR = false
) {
// 标记当前是否在 SSR 组件的 setup 阶段
isInSSRComponentSetup = isSSR
// 从组件 vnode 中获取 props 和 children
const { props, children } = instance.vnode
// 判断组件是否是有状态组件
const isStateful = isStatefulComponent(instance)
// 初始化 props
initProps(instance, props, isStateful, isSSR)
// 初始化插槽
initSlots(instance, children)
// 如果组件是有状态组件,调用 setupStatefulComponent 进行设置
const setupResult = isStateful
? setupStatefulComponent(instance, isSSR)
: undefined
// 结束 SSR 组件的 setup 阶段
isInSSRComponentSetup = false
// 返回 setup 的结果
return setupResult
}
这里面提到了有状态组件,什么是有状态,在纯函数的文章里,我们拿React Hook讲过,组件会自己保存自己的信息,不像函数组件每次调用都会初始化。在Vue中,所有的模板组件都被认为是有状态的。那么,setupStatefulComponent做了什么呢?我们进去代码看看。
function setupStatefulComponent(
instance: ComponentInternalInstance,
isSSR: boolean
) {
// App.vue的大对象
const Component = instance.type as ComponentOptions
instance.accessCache = Object.create(null)
instance.proxy = markRaw(new Proxy(instance.ctx, PublicInstanceProxyHandlers))
const { setup } = Component
// 如果有setup
if (setup) {
// 根据setup的参数决定入参需要不需要计算上下文
const setupContext = (instance.setupContext =
setup.length > 1 ? createSetupContext(instance) : null)
// 执行setup
const setupResult = callWithErrorHandling(
setup,
instance,
ErrorCodes.SETUP_FUNCTION,
[__DEV__ ? shallowReadonly(instance.props) : instance.props, setupContext]
)
if (isPromise(setupResult)) {
setupResult.then(unsetCurrentInstance, unsetCurrentInstance)
if (isSSR) {
// 只有异步SSR才有返回值
return setupResult
.then((resolvedResult: unknown) => {
handleSetupResult(instance, resolvedResult, isSSR)
})
.catch(e => {
handleError(e, instance, ErrorCodes.SETUP_FUNCTION)
})
} else if (__FEATURE_SUSPENSE__) {
// 结果挂载到asyncDep
instance.asyncDep = setupResult
if (__DEV__ && !instance.suspense) {
const name = Component.name ?? 'Anonymous'
}
}
} else {
handleSetupResult(instance, setupResult, isSSR)
}
} else {
finishComponentSetup(instance, isSSR)
}
}
从这里可以看出,在正常开发下,setupStatefulComponent的返回值是不会用到的,因为他只有在异步setup并且是SSR的情况下才会有返回值,从而也说明 setupComponent 只有是SSR的情况下才会有返回值,那么非SSR的情况下,setup的返回值怎么处理呢?
如果是Promise的情况下,是挂载到实例的asyncDep下的( instance.asyncDep = setupResult),如果不是异步的情况,会进入到 handleSetupResult 方法。
handleSetupResult 会分SSR和非SSR,如果setup的返回值是一个函数,SSR会挂载到instance.ssrRender,而非SSR,会挂载到instance.render上。
是的,这就是实例中render函数的由来。
那么我们都知道,setup还可以返回对象,作为data等值的覆盖,返回对象如何处理呢?
如果返回的是一个对象,同时是vnode的话,会多抛出一个警告,然后会将这个对象挂载到instance.setupState并进行响应式化。
在 handleSetupResult 的最后 依然会调用 finishComponentSetup
也就是无论如何都会调用 finishComponentSetup。
finishComponentSetup 的逻辑很简单,我们一般在模板文件中写vue的时候,可能会写render函数,那么finishComponentSetup会检测到,如果实例上没有render,那么就拿模板的render赋值给实例,这里需要区分一下有俩render函数,一个是实例上的,可以(注意,这个是可以,不是必须,setup返回值不是函数的话就不会生成实例上的render)由setup生成的,一个是在模板文件里面写的option API 的render。如果实例存在render,也就是setup能生成render,那么就忽略模板中的render,如果实例到现在都没有render,(setup没生成或者根本没setup),那么拿模板的render来用,如果模板也没有render呢,那就用模板编译一个出来(这个下一节会讲),把模板中的template编译成render,先赋值给模板的render,再赋值给实例的render。
当然也有例外,比如没有编译器的时候,实例render就会进行兜底,给一个空函数。
finishComponentSetup的作用就是让实例上的render应有尽有,模板的render可以没有(没有编译函数的情况),但实例上的render一定存在,即使是空函数。
好的我们说完了setupComponent,一句话概况,就是在日常的开发逻辑上,setupComponent往实例上挂载了render函数或者setupState。
我们应该说 setupRenderEffect了。
const setupRenderEffect: SetupRenderEffectFn = (
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
) => {
const componentUpdateFn = () => {
// 如果组件尚未挂载
if (!instance.isMounted) {
// 渲染组件的子树,获取新的 VNode 树
const subTree = (instance.subTree = renderComponentRoot(instance))
// 挂载 vnode
patch(null, subTree, container, anchor, instance, parentSuspense, isSVG)
// 把渲染生成的子树根 DOM 节点存储到 el 属性上
initialVNode.el = subTree.el
instance.isMounted = true
}
else {
// 省略200行代码
}
}
// 创建一个ReactiveEffect实例,用于处理组件的副作用
const effect = (instance.effect = new ReactiveEffect(
componentUpdateFn, // 副作用函数,处理组件的更新逻辑
() => queueJob(update),// 调度函数,将更新任务加入队列中
instance.scope // 副作用函数的作用域,用于追踪依赖关系
))
// 赋值 effect 执行函数给 update
const update: SchedulerJob = (instance.update = () => effect.run())
// 设置update函数的唯一标识符为组件实例的唯一ID
update.id = instance.uid
// 执行
update()
}
setupRenderEffect 的逻辑有300行,不过我们这次说的初始化,因此相关逻辑就这么多,核心就是调用了 renderComponentRoot 来生成 subTree,然后再把 subTree 挂载到 container 中。其实 renderComponentRoot 的核心工作就是执行 instance.render 方法,我们在前面已经讨论过了,无论如何实例上都会有render方法的。
好的现在焦点来到了render函数上。
render函数到底是是什么,一般业务上来讲,render是template模板编译出来的,那么他具体是什么样,执行了他就可以得到一个渲染树。
直接打断点看看。
这个是官方弹窗的实例,例子不重要,编译后的render很重要。
我们可以看到render返回了一个 createElementBlock的返回值。
createElementBlock 是什么?
export function createElementBlock(
type: string | typeof Fragment,
props?: Record<string, any> | null,
children?: any,
patchFlag?: number,
dynamicProps?: string[],
shapeFlag?: number
) {
return setupBlock(
createBaseVNode(
type,
props,
children,
patchFlag,
dynamicProps,
shapeFlag,
true /* isBlock */
)
)
}
虽然被包装过,但我们看到了 createBaseVNode !这个我们前面探讨过,返回的是一个vnode,也就是说,render 的本质,是一个返回vnode的函数!
怪不得在 handleSetupResult 里面,如果setup返回对象是一个vnode的话,会抛出警告,警告说,vnode需要使用函数返回。也就是说,如果setup返回vnode的话,会让你包装成函数返回,而如果setup返回一个函数的话,那个函数就render。
render返回vnode。
上面示例中,需要注意的返回的vnode的type是Fragment,接下来又要执行patch,这次执行的patch的type不是App.vue的大对象了,而是Fragment。
也就是Switch 中的 processFragment。为了防止遗忘,我这里贴一些对应代码。
case Fragment:
processFragment(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
break
上次是走了default,这次进入了对应的case。执行了 processFragment。我们看看他做了什么。
const processFragment = (
n1: VNode | null,
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
const fragmentStartAnchor = (n2.el = n1 ? n1.el : hostCreateText(''))!
const fragmentEndAnchor = (n2.anchor = n1 ? n1.anchor : hostCreateText(''))!
let { patchFlag, dynamicChildren, slotScopeIds: fragmentSlotScopeIds } = n2
if (n1 == null) {
hostInsert(fragmentStartAnchor, container, anchor)
hostInsert(fragmentEndAnchor, container, anchor)
mountChildren(
n2.children as VNodeArrayChildren,
container,
fragmentEndAnchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
// ...非创建
}
上面是创建相关的代码,fragment 在vue中是一块区域,就是用template标签所处的区域,类似react的 <>空标签。
从创建的逻辑来说,首先会将当前的vnode的el初始化一个空的文字节点。hostCreateText的本质是document.createText。
然后将vnode的anchor也做同样的处理。之后将他们插入#app的dom上,hostInsert 本质是 parent.insertBefore(child, anchor || null)的封装。
插入两个空文字节点后,开始跨在子节点。也就是执行 mountChildren。
我们看看mountChildren 做了什么。
const mountChildren: MountChildrenFn = (
children,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
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(
null,
child,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
}
朴实无华,循环遍历子节点,然后放到patch中执行。
好的,我们再次来到了patch,由于实例的结构是Fragment包裹这html,所以这次的type是一个button,是一个标签名,或者说是一个字符串,因此在patch中,Switch走到了default,然后通过ShapeFlags来判断逻辑。由于是一个字符串,因此ShapeFlags是ShapeFlags.ELEMENT。
可能有人有问题:你怎么知道type是button呢?答案当然是传进来的。还记得打断点的截图吗?
这个地方传了child,而createElementVNode就是createBaseVNode的别名(export { createBaseVNode as createElementVNode })
所以在patch中逻辑走到了ShapeFlags.ELEMENT里面。也就是
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(
n1,
n2,
container,
anchor,
parentComponent,
parentSuspense,
isSVG,
slotScopeIds,
optimized
)
}
也就是执行了processElement逻辑。processElement 的逻辑也很简单。
function processElement(n1, n2, container, anchor, parentComponent) {
if (!n1) {
// 如果旧虚拟节点不存在,表示是初次渲染,调用mountElement进行挂载
mountElement(n2, container, anchor);
} else {
// 如果旧虚拟节点存在,表示是更新,调用patchElement进行更新
updateElement(n1, n2, container, anchor, parentComponent);
}
}
分为挂载和更新。众所周知 n1 是null,我们来看mountElement。
const mountElement = (
vnode: VNode, //虚拟节点
container: RendererElement, // 渲染容器
anchor: RendererNode | null, // 插入位置
parentComponent: ComponentInternalInstance | null, // 父组件实例
parentSuspense: SuspenseBoundary | null, // 父 Suspense
isSVG: boolean,
slotScopeIds: string[] | null, // 插槽作用域ID数组
optimized: boolean
) => {
let el: RendererElement;
let vnodeHook: VNodeHook | undefined | null;
const { type, props, shapeFlag, transition, dirs } = vnode;
// 创建元素节点
el = vnode.el = hostCreateElement(
vnode.type as string,
isSVG,
props && props.is,
props
);
// 挂载子节点,先挂载子节点,因为有些属性可能依赖于子节点内容的渲染,例如`<select value>`
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(el, vnode.children as string);
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(
vnode.children as VNodeArrayChildren,
el,
null,
parentComponent,
parentSuspense,
isSVG && type !== 'foreignObject',
slotScopeIds,
optimized
);
}
// 执行指令的created钩子
if (dirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'created');
}
// 设置作用域ID
setScopeId(el, vnode, vnode.scopeId, slotScopeIds, parentComponent);
// 挂载props属性
if (props) {
for (const key in props) {
if (key !== 'value' && !isReservedProp(key)) {
hostPatchProp(
el,
key,
null,
props[key],
isSVG,
vnode.children as VNode[],
parentComponent,
parentSuspense,
unmountChildren
);
}
}
// 处理特殊情况,设置DOM元素的value属性
if ('value' in props) {
hostPatchProp(el, 'value', null, props.value);
}
// 执行props的onVnodeBeforeMount钩子
if ((vnodeHook = props.onVnodeBeforeMount)) {
invokeVNodeHook(vnodeHook, parentComponent, vnode);
}
}
// 执行指令的beforeMount钩子
if (dirs) {
invokeDirectiveHook(vnode, null, parentComponent, 'beforeMount');
}
// 处理过渡效果
const needCallTransitionHooks =
(!parentSuspense || (parentSuspense && !parentSuspense.pendingBranch)) &&
transition &&
!transition.persisted;
// 执行过渡效果的beforeEnter钩子
if (needCallTransitionHooks) {
transition!.beforeEnter(el);
}
// 将元素插入到DOM中
hostInsert(el, container, anchor);
// 执行props的onVnodeMounted钩子、过渡效果的enter钩子和指令的mounted钩子
if (
(vnodeHook = props && props.onVnodeMounted) ||
needCallTransitionHooks ||
dirs
) {
queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, vnode);
needCallTransitionHooks && transition!.enter(el);
dirs && invokeDirectiveHook(vnode, null, parentComponent, 'mounted');
}, parentSuspense);
}
};
hostCreateElement 就是document.createElement 和document.createElementNS的封装,也就是创建了一个DOM。
之后会处理 hostSetElementText,看到这里有人会问,他的shapeFlag不是ELEMENT吗,怎么会走到 TEXT_CHILDREN的逻辑里面,其实看好了,上文中大部分不是 && 运算,而是 & 运算。也就是说 ELEMENT & ARRAY_CHILDREN 不为 0 就行。
有的同学会提出疑问,那么按理说当前shapeFlag就是ELEMENT所代表的的枚举,但经过打断点,发现并非ELEMENT,这是因为 createBaseVNode 让父节点的 shapeFlag 进行了偏移,如果拉回去看会有这段代码,我截图放到下面。
如果存在子节点,会让父元素进行偏移,也就是 |= 运算,偏移结果视子节点类型为准。因此会出现与枚举不同的情况。
回到正题。
hostSetElementText 是对 el.textContent = text 的封装,因此会将节点文字赋值上去。
那么问题来了,如果子节点是数组节点呢?答案是进入mountChildren,而mountChildren做了什么呢?上文我们解析过了。就是一个for循环,循环patch。
后面处理了props属性。在https://github.com/vuejs/core/issues/1318之前,都是先props后children的,但存在一些bug,后改成了先children后props,顺带一提,这个issues是ant-design-vue的作者提出的。
最后 执行hostInsert ,hostInsert之前提到过,是 parent.insertBefore(child, anchor || null)的封装。 也就是将el挂载到 #app 所在的dom下面。
至此,初始化结束。
更新
在前面讲setupComponent 的时候,我们提到了有状态组件,当时提到过,组件会自己保存自己的信息,这些自己的信息就是状态,而这些状态是在下次执行的时候可以保存下来的。
那么问题来了,什么叫下次执行。
当然很明确,就是更新。
这里有一个盲点,就是模板组件的状态是可以保存下来的。因为他有状态,那么没状态的呢?
换句话说,函数组件呢?
显而易见,vue没有react那样健全的hook,所以函数组件的状态是保存不下来的。
我们使用一个例子来加深印象。
我们知道,defineComponent 是可以接受一个setup函数的,所以下面是一个setup返回的jsx,也就是返回了一个render函数。他的状态是可以被保存的。
import { ref, defineComponent } from 'vue';
export default defineComponent(() => {
let num = ref(0);
const add = () => {
num.value ++;
};
return () => (
<div>
<button onClick={add}>
{ num.value }
</button>
</div>
)
});
但下面的函数,是一个render函数,当对应的数据或者状态更新的时候,整个函数都会被执行,也就是说num永远是0,从视觉上来看都不会变,因为当 0更新成1的时候,触发了render再次更新,从而让num再次初始化为0
import { ref } from 'vue';
export default () => {
let num = ref(0);
const add = () => {
num.value ++;
};
return (
<div>
<button onClick={add}>
{ num.value }
</button>
</div>
)
}
这里可能就会有人问了。为什么更新会重新执行render而不是重新执行setup呢?
实际上上文的代码已经指出了原因,只是当时的思路是挂载,而不是更新,因此没有往这个逻辑上靠。
首先,我们强调过,经过setupStatefulComponent的操作,vue执行了setup,并把返回的函数挂载到render上,这个时候render就是一个不纯的函数,他依赖setup中的变量。
而逻辑往下走,走到了setupRenderEffect里面,setupRenderEffect会定义一个函数componentUpdateFn,这个函数会执行render。
然后生成一个副作用函数,这个副作用函数类似vue2的watch + immediate(副作用函数与watch有些许不同,他会立即执行,同时副作用函数对响应式数据操作后(get,set)才会收集依赖,并当有依赖变化的时候再次执行,这里面有特殊处理,之后会讲),作用是当这个componentUpdateFn里面的依赖有变动的时候,就会重新执行componentUpdateFn。
因为componentUpdateFn里面会执行render,因此我们可以认为,当render的依赖有变化的时候,会重新执行componentUpdateFn,然后重新执行render。
可以还有人有疑问,上面的render没有对外依赖,他只是自己有一个响应式变量,而不是函数外的响应式,从这种意义上来说,上面的render就是一个纯函数。那么内部的响应式数据变化也算依赖变化吗?
是的!vue的依赖收集从来都是只要响应式数据变化,就执行对应的响应式,而不论这个响应式数据在哪。
因此上面的例子可以抽象为下面的vue3逻辑
<script setup>
import { ref,effect} from 'vue'
let state
effect(() => {
state = ref(0)
console.log(`state: ${state.value}`) // 第一次执行是 0, state.value ++后,打印出来还是0
});
state.value ++
</script>
state是一个内部响应数据,只不过暴露出来让外界可以修改,修改后,依然是疼得初始值。
对于一开始的问题,我们可以这么回答:之所以重新执行render而不重新执行setup,是因为被副作用函数收集依赖的就是render函数,而不是setup函数。
理解到了render的不同,因此我们回到整体的思路。
componentUpdateFn
我们刚刚说了,组件的更新其实就是componentUpdateFn的再次执行render,但整个逻辑显而易见不是一句话带过的,所以需要我们看一下具体逻辑。
const setupRenderEffect: SetupRenderEffectFn = (
instance,
initialVNode,
container,
anchor,
parentSuspense,
isSVG,
optimized
) => {
function componentUpdateFn() {
if (!instance.isMounted) {
// 初始化
}
else {
// updateComponent
// 此部分代码由组件自身状态的变化触发(next: null)
// 或者父组件调用 processComponent(next: VNode)触发
let { next, bu, u, parent, vnode } = instance; // 解构获取实例中的属性和状态
let originNext = next; // 缓存原始的 next 状态
let vnodeHook: VNodeHook | null | undefined;
// 在预生命周期钩子期间禁止组件效果递归
toggleRecurse(instance, false);
if (next) {
// 如果 next 不为 null,更新元素引用
next.el = vnode.el;
// 更新组件实例
updateComponentPreRender(instance, next, optimized);
} else {
next = vnode; // 否则使用当前 vnode
}
// beforeUpdate 钩子
if (bu) {
// 调用 beforeUpdate 钩子函数
invokeArrayFns(bu);
}
// onVnodeBeforeUpdate 钩子
if ((vnodeHook = next.props && next.props.onVnodeBeforeUpdate)) {
invokeVNodeHook(vnodeHook, parent, next, vnode);
}
if (
__COMPAT__ &&
isCompatEnabled(DeprecationTypes.INSTANCE_EVENT_HOOKS, instance)
) {
instance.emit('hook:beforeUpdate'); // 发射钩子事件(兼容处理)
}
toggleRecurse(instance, true); // 允许组件效果递归
// 渲染
const nextTree = renderComponentRoot(instance); // 渲染组件的根节点
const prevTree = instance.subTree; // 缓存之前的子树
instance.subTree = nextTree; // 更新实例的子树
patch(
prevTree,
nextTree,
hostParentNode(prevTree.el!)!,
getNextHostNode(prevTree),
instance,
parentSuspense,
isSVG
); // patch更新 DOM 节点
next.el = nextTree.el; // 更新 next 的元素引用
if (originNext === null) {
// 自我触发的更新。在 HOC 的情况下,更新父组件的 vnode 元素引用
// HOC 由父实例的 subTree 指向子组件的 vnode 表示
updateHOCHostEl(instance, nextTree.el);
}
// updated 钩子
if (u) {
// 调用 updated 钩子函数
queuePostRenderEffect(u, parentSuspense);
}
// onVnodeUpdated 钩子
if ((vnodeHook = next.props && next.props.onVnodeUpdated)) {
queuePostRenderEffect(
() => invokeVNodeHook(vnodeHook!, parent, next!, vnode),
parentSuspense
);
}
}
}
// 其他。。
}
我们看到,在更新逻辑里面,依然使用renderComponentRoot执行了render,并且同样的,挂载到实例的subTree上,但在这之前,将之前的subTree给保留了下来,然后使用了patch,将新旧subTree一起传入进去。
接下来就是看过很多次的patch流程。但这一次,n1是有值的。所以逻辑我们需要重新捋一下。
const patch: PatchFn = (
n1,
n2,
container,
anchor = null,
parentComponent = null,
parentSuspense = null,
isSVG = false,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
// 如果 n1 存在且与 n2 的节点类型不同
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1) // 获取 n1 的下一个节点作为锚点
unmount(n1, parentComponent, parentSuspense, true) // 卸载 n1 节点
n1 = null // 将 n1 置为 null,表示已卸载
}
const { type, shapeFlag } = n2; // 获取新的节点的 type 和 shapeFlag
switch (type) {
case Text:
processText(n1, n2, container); // 处理文本节点
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:
processFragment(n1, n2, container);
break;
default:
// shapeFlag 判断
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, anchor, parentComponent); // 处理普通元素节点
} else if (shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
processComponent(n1, n2, container, parentComponent); // 处理组件节点
}
}
}
大体没啥差距,依然是根据类型不同做分流,不过我们可以看到,在开头判断了一次类型是否相同,如果新老节点类型不同,那么直接卸载旧节点,新节点按照挂载逻辑走。
那么判断类型逻辑看起来是相当重要的,他决定了这个组件是走挂载逻辑还是更新逻辑。
export function isSameVNodeType(n1: VNode, n2: VNode): boolean {
// 判断两个VNode是否具有相同的类型和key
return n1.type === n2.type && n1.key === n2.key
}
剔除了一些逻辑,我们看到上面的代码,他们的 type 和 key 都相同就会被视为类型相同节点。
显而易见,更新一个节点的速度是快于卸载旧节点+ 重新创建新节点的。
这也提示了我们之前忽略的优化思路:当组件的key是随机数或者时间戳,他总要走卸载旧节点+ 重新创建新节点的流程,这样是增加vue渲染时间的,是一个需要优化的点。
而type我们之前强调过,虽然是 type 但作为模板类型的组件来说,type 就是模板里面script标签的大对象,虽然里面的属性会被更改,但是由于是引用类型,因此他们的对象是相同的,地址并没有变。
对于dom来说,type是他们的标签名称,因此如果标签名称相同,那么就是type相同。
好的,我们假设类型相同,这样n1就保留下来了,对应模板类型的组件,会走processComponent逻辑,而对于DOM的话,会走processElement逻辑。
processElement
我们看一下processElement的逻辑。processElement在更新的时候调用patchElement。
而patchElement的删减代码如下
const patchElement = (
n1: VNode,
n2: VNode,
parentComponent: ComponentInternalInstance | null,
parentSuspense: SuspenseBoundary | null,
isSVG: boolean,
slotScopeIds: string[] | null,
optimized: boolean
) => {
const el = (n2.el = n1.el!) // 获取元素节点
let { patchFlag, dynamicChildren, dirs } = n2 // 获取新VNode的标记、动态子节点和指令
// 考虑到用户可能会克隆编译生成的VNode,需将旧节点的patchFlag也考虑在内
patchFlag |= n1.patchFlag & PatchFlags.FULL_PROPS
const oldProps = n1.props || EMPTY_OBJ // 获取旧节点的属性对象
const newProps = n2.props || EMPTY_OBJ // 获取新节点的属性对象
let vnodeHook: VNodeHook | undefined | null
// 在beforeUpdate钩子中禁用递归更新
parentComponent && toggleRecurse(parentComponent, false)
if ((vnodeHook = newProps.onVnodeBeforeUpdate)) {
invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
}
if (dirs) {
invokeDirectiveHook(n2, n1, parentComponent, 'beforeUpdate')
}
parentComponent && toggleRecurse(parentComponent, true)
const areChildrenSVG = isSVG && n2.type !== 'foreignObject' // 检查子节点是否为SVG元素
if (dynamicChildren) {
patchBlockChildren(
n1.dynamicChildren!, // 旧动态子节点
dynamicChildren, // 新动态子节点
el, // 父元素
parentComponent, // 父组件实例
parentSuspense, // 父Suspense
areChildrenSVG, // 子节点是否为SVG元素
slotScopeIds // 插槽作用域id列表
)
} else if (!optimized) {
// 完整差异更新
patchChildren(
n1, // 旧VNode
n2, // 新VNode
el, // 父元素
null,
parentComponent, // 父组件实例
parentSuspense, // 父Suspense
areChildrenSVG, // 子节点是否为SVG元素
slotScopeIds,
false
)
if (patchFlag > 0) {
// 存在patchFlag表示该元素的渲染代码由编译器生成,可以采取快速路径。
// 在这个路径中,旧节点和新节点在模板中的确切位置是相同的
if (patchFlag & PatchFlags.FULL_PROPS) {
// 元素属性包含动态键,需要完整差异更新
patchProps(
el, // 元素节点
n2, // 新VNode
oldProps, // 旧属性
newProps, // 新属性
parentComponent, // 父组件实例
parentSuspense, // 父Suspense
isSVG // 是否为SVG元素
)
} else {
// class属性
// 当元素具有动态类绑定时,将匹配到该标志。
if (patchFlag & PatchFlags.CLASS) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, 'class', null, newProps.class, isSVG)
}
}
// style属性
// 当元素具有动态样式绑定时,将匹配到该标志。
if (patchFlag & PatchFlags.STYLE) {
hostPatchProp(el, 'style', oldProps.style, newProps.style, isSVG)
}
// 属性
// 当元素具有动态prop/attr绑定(除class和style之外的绑定)时,将匹配到该标志。
// 动态prop/attr的键保存在dynamicProps中,用于更快的迭代。
// 注意:像:[foo] =“bar”的动态键将导致此优化中断,需要通过完整差异更新来取消旧键
if (patchFlag & PatchFlags.PROPS) {
// 如果标志存在,则dynamicProps必须是非空的
const propsToUpdate = n2.dynamicProps!
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i]
const prev = oldProps[key]
const next = newProps[key]
// #1471 强制进行差异更新
if (next !== prev || key === 'value') {
hostPatchProp(
el, // 元素节点
key, // 属性名
prev, // 旧值
next, // 新值
isSVG, // 是否为SVG元素
n1.children as VNode[], // 旧的子节点数组(如果有)
parentComponent, // 父组件实例
parentSuspense, // 父Suspense
unmountChildren // 卸载子节点的函数
)
}
}
}
}
// 文本节点
// 当元素只有动态文本子节点时,将匹配到该标志。
if (patchFlag & PatchFlags.TEXT) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children as string)
}
}
} else if (!optimized && dynamicChildren == null) {
// 未优化,完整差异更新
patchProps(
el, // 元素节点
n2, // 新VNode
oldProps, // 旧属性
newProps, // 新属性
parentComponent, // 父组件实例
parentSuspense, // 父Suspense
isSVG // 是否为SVG元素
)
}
if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1)
dirs && invokeDirectiveHook(n2, n1, parentComponent, 'updated')
}, parentSuspense)
}
}
从上文的逻辑,我们看到是先更新子节点,然后更新props,同mountElement的顺序保持一致。这里面用到了dynamicChildren优化。我们按照全量更新的逻辑走。
这里面进入了patchChildren函数,我们可以看看这里面是什么
const patchChildren: PatchChildrenFn = (
n1, // 旧VNode
n2, // 新VNode
container, // 渲染容器元素
anchor, // 插入锚点
parentComponent, // 父组件实例
parentSuspense, // 父Suspense
isSVG, // 是否为SVG元素
slotScopeIds, // 插槽作用域id列表
optimized = false // 是否为优化过的更新,默认为false
) => {
const c1 = n1 && n1.children // 旧VNode的子节点
const prevShapeFlag = n1 ? n1.shapeFlag : 0 // 旧VNode的形状标志
const c2 = n2.children // 新VNode的子节点
const { patchFlag, shapeFlag } = n2 // 新VNode的patchFlag和shapeFlag
// 快速路径
if (patchFlag > 0) {
if (patchFlag & PatchFlags.KEYED_FRAGMENT) {
// 这可以是全key标记的或混合key标记的(一些用了key,一些没用)
// patchFlag的存在意味着子节点保证是数组
patchKeyedChildren(
c1 as VNode[], // 旧的key标记子节点数组
c2 as VNodeArrayChildren, // 新的VNode数组
container, // 渲染容器元素
anchor, // 插入锚点
parentComponent, // 父组件实例
parentSuspense, // 父Suspense
isSVG, // 是否为SVG元素
slotScopeIds, // 插槽作用域id列表
optimized // 是否为优化过的更新
)
return
} else if (patchFlag & PatchFlags.UNKEYED_FRAGMENT) {
// 无key标记
patchUnkeyedChildren(
c1 as VNode[], // 旧的无key标记子节点数组
c2 as VNodeArrayChildren, // 新的VNode数组
container, // 渲染容器元素
anchor, // 插入锚点
parentComponent, // 父组件实例
parentSuspense, // 父Suspense
isSVG, // 是否为SVG元素
slotScopeIds, // 插槽作用域id列表
optimized // 是否为优化过的更新
)
return
}
}
// 子节点有3种可能性:文本、数组或无子节点。
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 文本子节点快速路径
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 旧子节点为数组
unmountChildren(c1 as VNode[], parentComponent, parentSuspense)
}
if (c2 !== c1) {
hostSetElementText(container, c2 as string)
}
} else {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 旧子节点为数组
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 两个数组,执行完整差异更新
patchKeyedChildren(
c1 as VNode[], // 旧的key标记子节点数组
c2 as VNodeArrayChildren, // 新的VNode数组
container, // 渲染容器元素
anchor, // 插入锚点
parentComponent, // 父组件实例
parentSuspense, // 父Suspense
isSVG, // 是否为SVG元素
slotScopeIds, // 插槽作用域id列表
optimized // 是否为优化过的更新
)
} else {
// 没有新子节点,只需要卸载旧子节点
unmountChildren(c1 as VNode[], parentComponent, parentSuspense, true)
}
} else {
// 旧子节点为文本或null
// 新子节点为数组或null
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
hostSetElementText(container, '')
}
// 如果是数组,就挂载新的子节点
if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
mountChildren(
c2 as VNodeArrayChildren, // 新的VNode数组
container, // 渲染容器元素
anchor, // 插入锚点
parentComponent, // 父组件实例
parentSuspense, // 父Suspense
isSVG, // 是否为SVG元素
slotScopeIds, // 插槽作用域id列表
optimized // 是否为优化过的更新
)
}
}
}
}
我们可以看到,节点类型只会有三种:
文本节点
数组节点
空节点
而这里面根据新旧节点的类型不同,就会进行不同的逻辑。
下方为旧节点,右边为新节点 | 文本节点 | 数组节点 | 空节点 |
文本节点 | 替换文本 | 清空旧子节点,挂载数组节点 | 清空旧子节点 |
数组节点 | 卸载旧节点,设置文字 | dif算法 | 卸载旧子节点 |
空节点 | 设置文字 | 挂载数组节点 | 什么都不做 |
可以看到,上面的逻辑还是很清晰的,唯一的难点就是diff算法了,这个算法会单独去讲,他们的逻辑与Vue2少许不同。
之后的逻辑是patchProps,他的逻辑实际上是一个新旧props的比较,然后根据新的props来使用不同的行为,需要注意的是,这里的props是作用在dom上,因此需要将props转为dom属性,并设置具体默认值和边界情况。如果是设置在组件上,那么需要将class以及style、ref、key等单独处理,on开头的设置为事件,布尔值转为具体字段,其他的设置为dom的属性。
processComponent
如果是组件更新呢?我们进入processComponent。
同DOM类似,在更新的时候调用updateComponent。
// n1 旧的VNode n2 新的VNode optimized 是否为优化过的更新
const updateComponent = (n1: VNode, n2: VNode, optimized: boolean) => {
const instance = (n2.component = n1.component)! // 获取组件实例
if (shouldUpdateComponent(n1, n2, optimized)) {
// 如果需要更新组件
if (
__FEATURE_SUSPENSE__ &&
instance.asyncDep &&
!instance.asyncResolved
) {
// 异步且仍在挂起状态 - 只更新props和slots
// 因为组件的用于渲染的响应式effect还没有设置好
updateComponentPreRender(instance, n2, optimized) // 更新组件的props和slots
return
} else {
// 正常的更新
instance.next = n2
// 如果子组件也在队列中,将其移除,避免在同一个刷新中多次更新同一个子组件。
invalidateJob(instance.update)
// instance.update 是响应式effect。
instance.update() // 执行组件的响应式更新函数
}
} else {
// 不需要更新,只需复制属性
n2.el = n1.el
instance.vnode = n2
}
}
删减过一些开发环境下的逻辑,整个函数还是比较简单,主要是通过shouldUpdateComponent,判断是否更新,因为有的vnode的值变化不需要更新子组件,换句话说,父组件的更新,如果子组件没有依赖那些数据,就不更新子组件。
最后调用了update函数,在componentUpdateFn的时候我们提到,update是实际上是就是执行了 effect.run(),而effect就是ReactiveEffect创建的副作用函数,就是再走一遍自己的渲染流程,执行了一遍componentUpdateFn重新渲染,但这次,实例上会多出一个next属性,里面存放的是新的vnode。
那么让我们回过头来看看有了next后,componentUpdateFn如何执行。这次只贴重点逻辑
const setupRenderEffect = (instance, initialVNode, container, anchor, parentSuspense, isSVG, optimized) => {
function componentUpdateFn() {
if (!instance.isMounted) {
// 初始化组件
}
else {
// 更新组件
let { next, vnode } = instance
if (next) {
// 如果 next 不为 null,更新元素引用
next.el = vnode.el
// 更新组件实例信息
updateComponentPreRender(instance, next, optimized)
} else {
next = vnode
}
//...
next.el = nextTree.el // 此时instance中的next由于updateComponentPreRender会清除,但由于next是解构的,并且如果不存在就会被赋值为vnode,所以以下的next可以认为是最新的vnode
// 其他
}
}
// 其他
}
触发了componentUpdateFn后,由于实例上有next,所以 next.el = vnode.el,next的le会被补充上el属性,因为el是mountElement时候根据vnode产生的,然后挂载在vonode上,不是vnode产生的,所以n2也就是next是没有el属性的,需要将原始el赋值给他。
然后进入updateComponentPreRender逻辑。
const updateComponentPreRender = (
instance: ComponentInternalInstance,
nextVNode: VNode,
optimized: boolean
) => {
nextVNode.component = instance // 将新的VNode关联到组件实例上
const prevProps = instance.vnode.props // 记录旧的props
instance.vnode = nextVNode // 更新组件实例的VNode
instance.next = null // 将next置为null,重置nextt
// 更新组件的props和slots
updateProps(instance, nextVNode.props, prevProps, optimized)
updateSlots(instance, nextVNode.children, optimized)
pauseTracking() // 暂停追踪响应式属性的变化
// props的更新可能已经触发了预先刷新的观察者。
// 在渲染更新之前将它们刷新。
flushPreFlushCbs() // 刷新在渲染之前需要提前执行的回调
resetTracking() // 重置追踪响应式属性的状态
}
在updateComponentPreRender里面,会重新赋值一次nextVNode.component,尽管在其他地方处理过了,但为了函数纯度,这里再处理一次,然后保存了之前的props,然后将next赋值给vnode,让vnode成为最新,然后清除实例上的next,这也是使用next来判断是否有更新的标志——当实例上有next的时候(注意是实例,因为在componentUpdateFn的逻辑上,实例即使没有next也会被创造一个next常量)说明需要更新,更新准备好后清除实例上的next。
总结
以上是整个更新流程,我们来一个例子梳理一下,假设有以下的代码。
<template>
<div>
hello world
<hello :msg="msg" />
<button @click="changeMsg">修改 msg</button>
</div>
</template>
<script>
import { ref } from 'vue'
export default {
setup () {
const msg = ref('你好')
function changeMsg() {
msg.value = '你好世界'
}
return {
msg,
changeMsg
}
}
}
</script>
// hello.vue
<template>
<div>
{{msg}}
</div>
</template>
<script>
export default {
props: {
msg: String
}
}
</script>
是一个App.vue,内部一个hello组件,用于显示传入的msg,msg的状态通过按钮进行改变。
1、我们点击按钮,触发了App.vue 的 msg修改,由于组件会被编译成render,因此可以说render依赖的msg变量发生了变化,因此触发了componentUpdateFn.
2、但因为实例上没有next,所以会跳过updateComponentPreRender,直接进行获取子树的vnode,然后进入patch流程.
3、patch流程中,type是div,所以进入processElement,然后进入patchElement.
4、然后因为存在dynamicChildren,进入patchBlockChildren,然后根据dynamicChildren循环patch。
5、dynamicChildren包括hello.vue,,因此patch的时候会进入processComponent逻辑。
6、然后进入updateComponent函数,然后将hello组件的实例的next赋值为最新的vnode,然后主动调用hello实例的update,再次进入hello的componentUpdateFn。
7、但这次有next,并且实例App.vue变成了hello.vue(因为是调用的hello的update),有了next,就在updateComponentPreRender,将vnode、slots、props进行更新。
8、调用完updateComponentPreRender后,就需要调用renderComponentRoot来获取新的子树vnode,我们之前提到过renderComponentRoot就是执行了render,我们在updateComponentPreRender修改了props,所以render使用的props是最新的。
9、此时获取子树vnode也是最新的(没有使用上文的next变成的vnode,而是只使用了部分字段,然后新生成一个),所以没有el,保存为实例下的subTree,之前的subTree单独保存了下来,然后两个vnode进去patch。
10、patch进入processElement,然后进入patchElement,此时实例上的el被之前的subTree的el赋值,然后根据类型,进行更新,这里是文字,所以会走到文字的快速处理逻辑,通过hostSetElementText直接替换旧值。之后经过一些守卫逻辑,此次更新结束。
可能有人说,这没有进入diff算法,这个是有原因的的,在第4步,如果是数组循环(比如v-for)并且是开发环境开启了热更新,那么会将dynamicChildren设置为null,从而不进入patchBlockChildren,而是进入patchChildren,然后根据patchChildren对新旧节点的类型比较,进入patchKeyedChildren进行diff算法,然后使用patch进行更新。
如果是非热更新和非开发环境下,那么数组循环将会在第4步,循环patch的时候,由于整个数组是一个Fragment,从而进入patch的时候,进入processFragment,然后processFragment存在与mountChildren对应的patchChildren。然后根据patchChildren对新旧节点的类型比较,进入patchKeyedChildren,也就是进入了diff算法。
小结
上面是大体逻辑,而实际上关于逻辑优化等细节问题我们并没有涉及到,以及最终的diff算法,我也打算放到后面一节来探讨,这只是我们的开始,虽然很艰难,但从现在开始追踪vue3这种优秀的前端库并不算晚,Vue2淘汰在即(虽然可能会如同jQuery一样长久不衰),抱着守旧的思想肯定是不行的,所以需要学习的东西还有很多。我们依然在路上。