- Vue3源码01 : 代码管理策略-monorepo
- Vue3源码02: 项目构建流程和源码调试方法
- Vue3源码03: Vue3响应式核心原理
- Vue3源码04: Vue3响应式系统源码实现1/2
- Vue3源码05 : Vue3响应式系统源码实现(2/2)
- Vue3源码06: reactive、ref相关api源码实现
- Vue3源码07: 故事要从createApp讲起
前面我们知道了,从虚拟Node到真实Node是借助一个叫做render
的函数来完成。本文会带着大家进入render
函数,先从从总体上把握Vue3的渲染核心流程以及部分源码实现细节。至于比较重要的一些细节,比如组件如何渲染如何更新,diff算法具体如何实现,将在后续的文章一一进行分析。
render
函数
先直接看render
函数的代码实现:
// 代码片段1
const render: RootRenderFunction = (vnode, container, isSVG) => {
if (vnode == null) {
if (container._vnode) {
unmount(container._vnode, null, null, true)
}
} else {
patch(container._vnode || null, vnode, container, null, null, null, isSVG)
}
flushPostFlushCbs()
container._vnode = vnode
}
我们先来看看该函数的参数,第一个参数是虚拟Node对象,第二个参数是一个Element
对象,第三个参数暂时先忽略。render
函数的内部逻辑也很简单,做了下面几件事情:
- 如果传入的虚拟Node对象是空,则判断
container
对应的元素曾经是否渲染过其他虚拟Node,如果是则从container
上卸载该虚拟Node对应的节点,如果不是则什么都不做,将container._vnode
置空即可。container._vnode
中的值来源于render
函数的最后一行代码; - 如果传入的虚拟Node不为空,则需要和
container
元素上挂载过的_vnode
所代表的DOM元素进行比较并修改当前的真实DOM树,这个逻辑都由patch
函数来实现,也是本文的重点内容; - 执行
flushPostFlushCbs
将保存在数组pendingPostFlushCbs
中的函数依次执行,至于什么时候给数组pendingPostFlushCbs
中添加元素,具体又是如何执行的这些函数,本文暂时不讲,后续的文章中如有必要会用一小节来介绍。
patch
才是灵魂
Vue3的渲染流程,虽然是通过调用render
函数实现,但patch
才是整个渲染流程的灵魂。我们来看看patch
函数的具体实现:
// 代码片段2
const patch: PatchFn = (
n1,
n2,
container,
anchor = null,
parentComponent = null,
parentSuspense = null,
isSVG = false,
slotScopeIds = null,
optimized = __DEV__ && isHmrUpdating ? false : !!n2.dynamicChildren
) => {
if (n1 === n2) {
return
}
// patching & not same type, unmount old tree
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
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:
processFragment(
// 此处省略若干代码...
)
break
default:
if (shapeFlag & ShapeFlags.ELEMENT) {
processElement(
// 此处省略若干代码...
)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
processComponent(
// 此处省略若干代码...
)
} else if (shapeFlag & ShapeFlags.TELEPORT) {
;(type as typeof TeleportImpl).process(
// 此处省略若干代码...
)
} else if (__FEATURE_SUSPENSE__ && shapeFlag & ShapeFlags.SUSPENSE) {
;(type as typeof SuspenseImpl).process(
// 此处省略若干代码...
)
} else if (__DEV__) {
warn('Invalid VNode type:', type, `(${typeof type})`)
}
}
// set ref
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
}
}
patch
函数内部根据传入的虚拟Node的类型不同,会分别调用不同的函数进行处理。这里面有两个点值得我们关注:
- 搞清楚
patch
函数的使命; - 通过位运算的方式来进行类型判断;
patch
函数的使命
可能大家会觉得奇怪,刚才不是已经讲过了patch
函数的主要逻辑就是根据虚拟Node的不同类型来调用不同的函数来进行处理吗?还有什么使命?没错,patch
函数的逻辑很清晰,但是我想在这里强调,patch
存在的根本意义是寻找新虚拟Node和当前真实Node对应的旧虚拟Node的差异,并根据这种差异修改DOM树以抹平这种差异。理解了这个就能很轻松的理解,为什么有这样的语句:
// 代码片段3
if (n1 === n2) {
return
}
因为新旧虚拟Node没有差异,当然也就没有继续进行的必要了。我们也能轻松的理解下面的代码:
代码语言:javascript复制// 代码片段4
if (n1 && !isSameVNodeType(n1, n2)) {
anchor = getNextHostNode(n1)
unmount(n1, parentComponent, parentSuspense, true)
n1 = null
}
如果旧虚拟Node存在,而且新虚拟Node和旧虚拟Node的类型不一致,则卸载旧虚拟Node,同时将该旧虚拟Node置为空。会发现这里有个anchor
变量,如果该anchor
始终为null
则会导致我们新插入元素的时候始终是在尾部,与其所替换的元素的位置不一致,所以需要在卸载旧虚拟Node对应的真实Node之前,用anchor
记录其下一个元素。
同时我们理解了patch
函数的使命,可以尝试想象如果让我们来实现patch
函数该怎么做,可能我们很自然的想到,完全可以直接把旧节点删除,插入新节点的内容即可,实现相同的功能可以将几千行代码简化到几行完成,看似低级的实现却也让我们认清了patch
函数的本质。在本文的后半部分,会介绍patch
函数中调用的很多其他函数,相信有了我们前面的认识可以更好的理解Vue3为什么要这么实现patch
函数。
类型判断方式
我们发现代码片段2中有几处形如if (shapeFlag & ShapeFlags.ELEMENT)
的代码,为什么要这么判断呢?要回答这个问题,我们先看看shapeFlag
是什么,ShapeFlags.ELEMENT
是从哪里来的。
shapeFlag
是从patch
函数的第2个参数也就是新虚拟Node上解构出来的,该值是个数值类型。我们再来看看ShapeFlags
的代码:
// 代码片段5
export const enum ShapeFlags {
ELEMENT = 1,
FUNCTIONAL_COMPONENT = 1 << 1,
STATEFUL_COMPONENT = 1 << 2,
TEXT_CHILDREN = 1 << 3,
ARRAY_CHILDREN = 1 << 4,
SLOTS_CHILDREN = 1 << 5,
TELEPORT = 1 << 6,
SUSPENSE = 1 << 7,
COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8,
COMPONENT_KEPT_ALIVE = 1 << 9,
COMPONENT = ShapeFlags.STATEFUL_COMPONENT | ShapeFlags.FUNCTIONAL_COMPONENT
}
从代码片段5中可以看见ShapeFlags
是一个枚举类型。对位运算不了解的朋友可能已经充满了疑惑,为什么要这么表示?要回答这个问题,还得先了解位运算的左移、与、或运算。
假设我们有8个二进制位00000000
,每一个二进制位表示小A是否具备一项能力,1
表示具备,0
表示不具备,具体能力映射如下。
篮球 | 足球 | 游泳 | 英语 | 喝酒 | 美食 | 跑步 | 开车 |
---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
如果小A会跑步可以这样描述:
篮球 | 足球 | 游泳 | 英语 | 喝酒 | 美食 | 跑步 | 开车 |
---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
如果小A不仅会跑步还会喝酒,可以这样描述:
篮球 | 足球 | 游泳 | 英语 | 喝酒 | 美食 | 跑步 | 开车 |
---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 |
基于上面的认知,我们可以把不同状态这样来表示:
代码语言:javascript复制// xiaoAState为0,表示小A,什么技能都不会
let xiaoAState = 0; // 0 0 0 0 0 0 0 0
const DRIVE_CAR = 1; // 0 0 0 0 0 0 0 1
const RUN = 1 << 1; // 0 0 0 0 0 0 1 0
const FOOD = 1 << 2; // 0 0 0 0 0 1 0 0
const DRINK = 1 << 3;// 0 0 0 0 1 0 0 0
// 让小A具备喝酒的能力,可以这样进行运算:
xiaoAState |= DRINK; // 0 0 0 0 1 0 0 0
// 让小A具备跑步的能力,可以这样运算:
xiaoAState |= RUN; // 0 0 0 0 1 0 1 0
或运算可以下面的表格进行理解:
篮球 | 足球 | 游泳 | 英语 | 喝酒 | 美食 | 跑步 | 开车 | 运算符号 | 含义 |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 1 | 0 | 0 | 0 | DRINK | |
0 | 0 | 0 | 0 | 0 | 0 | 1 | 0 | 或 | RUN |
0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | 结果 |
当我们想判断小A是否具备某项能力的时候可以借助于&
运算,例如:
if(xiaoAState & DRINK){
console.log('小A确实会喝酒')
}
if(xiaoAState & FOOD){
console.log('小A会做饭')
}else{
console.log('小A不会做饭')
}
为什么可以这样判断呢,我们先来看看,xiaoAState & FOOD
的示意:
篮球 | 足球 | 游泳 | 英语 | 喝酒 | 美食 | 跑步 | 开车 | 运算符号 | 含义 |
---|---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 1 | 0 | 1 | 0 | xiaoAState | |
0 | 0 | 0 | 0 | 0 | 1 | 0 | 0 | & | FOOD |
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 结果 |
不难发现xiaoAState
和自己不具备的能力进行了&
运算之后,结果值是0
,反之如果是和自己具备的能力进行&
运算,结果值就是1
,这也就是为什么能够通过&
运算来判断xiaoAState
是否具备某个能力的原理。到了这里也就不难发现代码片段5
为什么要以1
为初始值,然后不断左移1
位,一切都是为了方便计算。同时这种方式可以让一个属性值表示多个状态,就像上文示范的xiaoAState
不仅可以表示具备喝酒的能力还可以表示具备跑步的能力或者其他很多的能力。不得不说这种方式很巧妙,而且性能也比较高,在实际工作中类似场景完全可以借鉴。
下面我们开始探索patch
函数内部调用的其他函:
processText
代码语言:javascript复制// 代码片段6
const processText: ProcessTextOrCommentFn = (n1, n2, container, anchor) => {
if (n1 == null) {
hostInsert(
(n2.el = hostCreateText(n2.children as string)),
container,
anchor
)
} else {
const el = (n2.el = n1.el!)
if (n2.children !== n1.children) {
hostSetText(el, n2.children as string)
}
}
}
逻辑比较简单,如果旧虚拟Node为null
,则直接将文本插入到容器即可,如果不为null
,则说明需要进行更新。这里有三个点值得我们关注:
hostInsert
、hostSetText
从哪里来的呢?还记得我们在runtime-dom
传入的参数const rendererOptions = extend({ patchProp }, nodeOps)
吗,没错,具体对DOM
节点进行删除、修改、增加都是runtime-dom
或者其他平台传入的方法。runtime-core
只需要关心将要对节点进行什么类型的操作,但这些操作具体怎么实现由传入的参数决定。这就是runtime-core
平台无关的原因。- 代码
n2.el = hostCreateText(n2.children as string)
可以看出虚拟Node的el
属性,保存的是一个DOM
对象,哪怕这个DOM
对象是个文本也不例外。 const el = (n2.el = n1.el!)
这行代码比较巧妙,将旧虚拟Node的el
属性值赋值给新虚拟Node的属性el
,相当于在旧虚拟Node对应的DOM
节点的基础上进行操作,而不是新创建节点,减少了性能消耗。
processCommentNode
代码语言:javascript复制// 代码片段7
const processCommentNode: ProcessTextOrCommentFn = (
n1,
n2,
container,
anchor
) => {
if (n1 == null) {
hostInsert(
(n2.el = hostCreateComment((n2.children as string) || '')),
container,
anchor
)
} else {
// there's no support for dynamic comments
n2.el = n1.el
}
}
这里逻辑比较简单,如果新虚拟Node是注释类型,则判断旧虚拟Node是否存在,如果不存在则直接执行插入操作。如果存在则直接将旧虚拟Node对应的el
元素赋值给新虚拟Node的el
,不做任何其他处理,因为Vue3中是不支持注释响应式发生变化,也就是说注释创建后不会被更改。
mountStaticNode
代码语言:javascript复制// 代码片段8
const mountStaticNode = (
n2: VNode,
container: RendererElement,
anchor: RendererNode | null,
isSVG: boolean
) => {
// static nodes are only present when used with compiler-dom/runtime-dom
// which guarantees presence of hostInsertStaticContent.
;[n2.el, n2.anchor] = hostInsertStaticContent!(
n2.children as string,
container,
anchor,
isSVG,
n2.el,
n2.anchor
)
}
mountStaticNode
的功能是将新虚拟Node的静态内容挂载到container
上,处理方法也很简单,直接调用runtime-dom
传入的函数hostInsertStaticContent
。需要注意的两个细节如下:
- 在平时编码的过程中,以
(
、[
开头的表达式,前面应该加一个;
以防止在代码被压缩后与上一行的内容拼接成属性访问语句。 - 不太清楚解构赋值的语法朋友对
[n2.el, n2.anchor] = xxx
的表示可能很疑惑,可以查阅MDN
文档了解相关含义。
patchStaticNode
代码语言:javascript复制// 代码片段9
const patchStaticNode = (
n1: VNode,
n2: VNode,
container: RendererElement,
isSVG: boolean
) => {
// static nodes are only patched during dev for HMR
if (n2.children !== n1.children) {
const anchor = hostNextSibling(n1.anchor!)
// remove existing
removeStaticNode(n1)
// insert new
;[n2.el, n2.anchor] = hostInsertStaticContent!(
n2.children as string,
container,
anchor,
isSVG
)
} else {
n2.el = n1.el
n2.anchor = n1.anchor
}
}
函数patchStaticNode
只在开发环境下才有可能调用,为什么呢?因为既然是静态节点,就不存在响应式数据的变化也就不存在更新,所以也就不会调用这个函数。但是开发环境热更新的时候可能会变化相应数据,里面逻辑比较简单,如果还是觉得读起来有困难可以先跳过,不做重点掌握。
processFragment
、processComponent
关于函数processFragment
、processComponent
内部的流程,在后续的文章中进行分析。
setRef
代码语言:javascript复制// 代码片段10
if (ref != null && parentComponent) {
setRef(ref, n1 && n1.ref, parentSuspense, n2 || n1, !n2)
}
还记得我们在上一篇文章中介绍的关于通过ref
获取子组件的内容吗,当时我们介绍了getExposeProxy
的核心功能是保护子组件的内容不被父组件随意访问。在patch
函数中调用了setRef
,而setRef
中则调用了getExposeProxy
函数。我们看看setRef
究竟做了什么:
// 代码片段11
export function setRef(
rawRef: VNodeNormalizedRef,
oldRawRef: VNodeNormalizedRef | null,
parentSuspense: SuspenseBoundary | null,
vnode: VNode,
isUnmount = false
) {
// 此处省略许多代码...
const refValue =
vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT
? getExposeProxy(vnode.component!) || vnode.component!.proxy
: vnode.el
const value = isUnmount ? null : refValue
const { i: owner, r: ref } = rawRef
// 此处省略许多代码...
const oldRef = oldRawRef && (oldRawRef as VNodeNormalizedRefAtom).r
const refs = owner.refs === EMPTY_OBJ ? (owner.refs = {}) : owner.refs
const setupState = owner.setupState
if (oldRef != null && oldRef !== ref) {
if (isString(oldRef)) {
refs[oldRef] = null
if (hasOwn(setupState, oldRef)) {
setupState[oldRef] = null
}
} else if (isRef(oldRef)) {
oldRef.value = null
}
}
if (isFunction(ref)) {
callWithErrorHandling(ref, owner, ErrorCodes.FUNCTION_REF, [value, refs])
} else {
const _isString = isString(ref)
const _isRef = isRef(ref)
if (_isString || _isRef) {
const doSet = () => {
// 此处省略许多代码...
}
if (value) {
;(doSet as SchedulerJob).id = -1
queuePostRenderEffect(doSet, parentSuspense)
} else {
doSet()
}
}
// 此处省略许多代码...
}
}
关于函数setRef
,我们目前只需要知道主要做了3点工作即可:
- 获取
ref
的代理对象; - 找到旧虚拟Node对应的
ref
,如果存在且和新虚拟Node对应的ref
不一致则置为null
; - 将新的
ref
代理对象赋值给新虚拟Node相应的属性。
至于代码片段11呈现出来的关于ref
的各种属性以及一些细节,在后续文章中合适的时机我们再继续探讨。