🤯vue3核心源码剖析(九)
Vue3 Element 元素的props和children更新逻辑
之前我们实现了 element 元素的挂载,还没有实现当元素内的数据发生改变的时候,要对元素进行更新操作。
为此我准备了一个小 demo,期望用户在点击 button 按钮的时候能让count加一:
import { ref, h } from '../../lib/guide-toy-vue3.esm.js'
export const App = {
name: 'app',
setup() {
const count = ref(0)
const click = () => {
count.value++
}
return {
count,
click,
}
},
render() {
return h('div', {}, [
h('p', {}, `count: ${this.count}`),
h('button', { onClick: this.click }, '点击更新'),
])
},
}
可以看到 this.count 并没有打印出数值而是一个[Object object],为什么会这样呢?我们再进一步打印this.count是什么。
他是一个 ref 对象,我们想要的值在_value里面,那要怎么去除这个值呢?我们之前是不是实现了proxyRefs,我当时说过他是用来在 h 函数里面自动解包 ref 的。
生怕你们忘了我在这里再贴一次这段代码的实现吧。
// 通常用在vue3 template里面ref取值,在template里面不需要.value就可以拿到ref的值
export function proxyRefs<T extends object>(obj: T) {
return isReactive(obj)
? obj
: new Proxy<any>(obj, {
get(target, key) {
// unref已经处理了是否ref的情况所以我们不需要自己if处理,如果是,返回.value,如果不是,直接返回值
return unref(Reflect.get(target, key))
},
set(target, key, value) {
// 因为value为普通值类型的情况特殊,要把value赋值给ref的.value
if (isRef(target[key]) && !isRef(value)) {
target[key].value = value
return true
} else {
return Reflect.set(target, key, value)
}
},
})
}
我们只需要对 setup 的返回值进行解包就好了。
在 runtime-core 里面的 component.ts 里面添加 proxyRefs 的调用操作
function handleSetupResult(instance: any, setupResult: any) {
// TODO handle function
if (isFunction(setupResult)) {
instance.render = setupResult
} else if (isObject(setupResult)) {
// 把setup返回的对象挂载到setupState上 proxyRefs对setupResult解包
instance.setupState = proxyRefs(setupResult) // 新增
}
}
这样 ref 的 value 就能正常显示出来了。
下一步我们来完成元素的更新
页面上的元素都是虚拟 dom 对象
{
type: 'div',
props: {},
children:{},
el: {},
shapeFlag: 2
...
}
元素更新的时候需要生成新的 vnode 对象,然后通过旧的 vnode 和新的 vnode 进行对比,从而找出要更新的地方。如果是文本更新,那我们可以用 dom.textContext 属性更新。 
前提是我们需要获取旧的 vnode 和新的 vnode。新的 vnode 怎么获取?重新执行一次render函数就可以了啊,那什么时候应该重新执行一次 render 函数呢?上面的 count 值是一个 ref 响应式对象,只要 count 发生改变,视图中的 text 就应该发生更新啊,render 函数就应该重新执行一次生成一个 vnode 对象。
之前的章节我们实现了effect,它的主要作用是响应式对象的值发生改变的时候,会执行一次对该响应式对象相关联的effect(触发依赖)。
我们只需要在 render 函数被执行的函数里面,用 effect 包裹起来。等到 count 发生改变的时候,就会重新执行一次 render 函数了。
// In renderer.ts
function setupRenderEffect(instance: any, vnode: any, container: any) {
effect(() => {
const subTree = instance.render.call(instance.proxy)
console.log('subTree===>', subTree)
patch(subTree, container, instance)
vnode.el = subTree.el
instance.isMounted = true
})
}
依赖收集
render() {
// 触发了ref的get操作(依赖收集)
return h('div', {}, [
h('p', {}, `count: ${this.count}`),
h('button', { onClick: this.click }, '点击更新'),
])
},
触发依赖
setup() {
const count = ref(0)
const click = () => {
// 触发了ref的set操作(执行effect回调函数)
count.value++
}
return {
count,
click,
}
},
根据现在的逻辑,可以看到多次点击 button 之后,虽然 count 更新了,但是重复的元素也被 mount 进 dom 树上面。原因是执行 render 的函数的时候它并不知道组件是否已经被挂载。
所以需要在组件实例上新增一个属性isMounted,该属性值是 boolean 类型,表示组件是否已经被挂载。
export function createComponentInstance(vnode: any, parentComponent: any) {
console.log('createComponentInstance', parentComponent)
const type = vnode.type
const instance = {
vnode,
type,
render: null,
setupState: {},
props: {},
emit: () => {},
slots: {},
isMounted: false, // 新增
provides: parentComponent
? parentComponent.provides
: ({} as Record<string, any>),
parent: parentComponent,
}
instance.emit = emit.bind(null, instance) as any
return instance
}
更改 setupRenderEffect 内部逻辑
function setupRenderEffect(instance: any, vnode: any, container: any) {
effect(() => {
if (!instance.isMounted) {
// 这里处理第一次被挂载的逻辑
const subTree = instance.render.call(instance.proxy)
instance.subTree = subTree
patch(null, subTree, container, instance)
vnode.el = subTree.el
instance.isMounted = true
} else {
// 这里处理更新的逻辑
console.log('update')
}
})
}
注意:第一次被挂载的逻辑完成之后,还需要把 isMounted 设置为 true,这样下次更新的时候,就不会执行第一次的逻辑了。
我们把注意力聚焦到更新的逻辑。
我们需要两个 vnode,新的和旧的,所以我们同样要执行一遍 render 函数,为了能在更新的时候拿到老的 vnode,我们还需要在上一次 render 函数执行的时候得到的 vnode 赋值给一个新的属性subTree,这个属性同样是在组件实例 instance 上。
这就很好解释了为什么我在第一次挂载的逻辑中加入了instance.subTree = subTree这个表达式。
export function createComponentInstance(vnode: any, parentComponent: any) {
console.log('createComponentInstance', parentComponent)
const type = vnode.type
const instance = {
vnode,
type,
render: null,
setupState: {},
props: {},
emit: () => {},
slots: {},
isMounted: false,
subTree: {}, // 新增
provides: parentComponent
? parentComponent.provides
: ({} as Record<string, any>),
parent: parentComponent, // 父组件的组件实例
}
instance.emit = emit.bind(null, instance) as any
return instance
}
function setupRenderEffect(instance: any, vnode: any, container: any) {
effect(() => {
if (!instance.isMounted) {
// 这里处理第一次被挂载的逻辑
const subTree = instance.render.call(instance.proxy)
instance.subTree = subTree
patch(null, subTree, container, instance)
vnode.el = subTree.el
instance.isMounted = true
} else {
// 这里处理更新的逻辑
// 新的vnode
const subTree = instance.render.call(instance.proxy)
// 老的vnode
const prevSubTree = instance.subTree
// 存储这一次的vnode,下一次更新逻辑作为老的vnode
instance.subTree = subTree
patch(prevSubTree, subTree, container, instance)
}
})
}
element 的 props 更新
我们讲一下元素的属性的更新。
有三种情况需要我们处理:
- 新的 props 和旧的 props 不同(新增,修改)
- 新的 props 被赋值为 null 或者 undefined(删除)
- 旧的 props 有些属性在新的 props 中没有(删除)
目前我们只处理元素的情况,所以我们在patch中只修改processElement内部的逻辑。
给 patch 传入n1和n2,其中 n1 代表旧的 vnode,n2 代表新的 vnode。
function patch(n1: any, n2: any, container: any, parentComponent: any) {
// 检查是什么类型的vnode
const { type } = n2
switch (type) {
case Fragment:
processFragment(n2, container, parentComponent)
break
case Text:
processText(n2, container)
break
default: {
if (n2.shapeFlag & ShapeFlags.ELEMENT) {
processElement(n1, n2, container, parentComponent)
} else if (n2.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
// 是一个组件?处理vnode是组件的情况
processComponent(n2, container, parentComponent)
}
break
}
}
}
当 n2 的 shapeFlag 表示元素的时候,我们同样把 n1 和 n2 传入 processElement 函数。
// 处理元素的情况
function processElement(
n1: any,
n2: any,
container: any,
parentComponent: any
) {
if (!n1) {
mountElement(n2, container, parentComponent)
} else {
patchElement(n1, n2, container)
}
}
其中需要处理 n1 为 null 的情况,即第一次挂载到 dom 上的情况,否则对 n1 和 n2 做对比 patch。我把对比的操作抽离成了一个函数 patchElement。
patchElement 的具体实现如下:
function patchElement(n1: any, n2: any, container: any) {
// 这里的EMPTY_OBJ是一个空对象,在shareFlags.ts中定义:const EMPTY_OBJ = {}
const oldProps = n1.props || EMPTY_OBJ
const newProps = n2.props || EMPTY_OBJ
const el = (n2.el = n1.el)
patchProps(el, oldProps, newProps)
}
在patchProps()内部遍历 n2(新的 vnode)的 props,看看 n1 的 props 和 n2 的 props 是否相同。
// newProps就是n2.props
// oldProps就是n1.props
for (let key in newProps) {
const newProp = newProps[key]
const oldProp = oldProps[key]
if (newProp !== oldProp) {
patchProp(el, key, oldProp, newProp)
}
}
patchProp()在之前的篇章已经出现过了,它是通过 DOM API 将 h 函数的 prop(h 函数的第二个参数)赋值给真实的 dom 元素。这里我可以重新贴一下具体的代码实现。
export function patchProp(el: any, key: string, oldValue: any, newValue: any) {
// 这里处理属性值有多个的情况,比如class="flex flex-column flex-grow-1"
if (Array.isArray(newValue)) {
el.setAttribute(key, newValue.join(' '))
} else if (isOn(key) && isFunction(newValue)) {
el.addEventListener(key.slice(2).toLowerCase(), newValue)
} else {
el.setAttribute(key, newValue)
}
}
其实到这里后已经实现了三种情况的第一种了(新增和修改)
当 n2 的 props 有属性值是 null 或者 undefined 的时候,我们也应该移除这个属性。此时我们可以修改patchProp
export function patchProp(el: any, key: string, oldValue: any, newValue: any) {
if (Array.isArray(newValue)) {
el.setAttribute(key, newValue.join(' '))
} else if (isOn(key) && isFunction(newValue)) {
// 添加事件
el.addEventListener(key.slice(2).toLowerCase(), newValue)
} else {
// props属性的属性值是undefined或者null,删除该属性
if (newValue === null || newValue === undefined) {
el.removeAttribute(key)
} else {
el.setAttribute(key, newValue)
}
}
}
这样就是实现了第二种情况了
接着因为需要检查 n1 的 props 是不是在 n2 的 props 中被删除了,所以我们需要遍历 n1 的 props,看看是不是在 n2 的 props 中不复存在。
for (let key in oldProps) {
// 新的props没有该属性
if (!(key in newProps)) {
patchProp(el, key, oldProps[key], null)
}
}
要想删除属性,只需要给 patchProp 传入 null 即可,在内部会调用el.removeAttribute。
好了,三种情况都已经解决了。
element 的 children 更新
element 的 children 更新涉及到以下四种情况:
- 旧的子节点是 array 类型 ==> 新的子节点是 string 类型
- 旧的子节点是 string 类型 ==> 新的子节点是 string 类型
- 旧的子节点是 string 类型 ==> 新的子节点是 array 类型
- 旧的子节点是 array 类型 ==> 新的子节点是 array 类型
当然其他情况比如旧的子节点是空的,新的子节点可能是空的。
第一种情况:子节点从数组变为字符串
其中
shapeFlag是区分 vnode 的一个标识
以下为用于解决这种元素 children 更新的测试 demo
export const App = {
name: 'app',
setup() {},
render() {
// this.count进行依赖收集,
return h('div', {}, [
h('p', {}, '主页'),
// 旧的是数组 新的是文本
h(arrayToText),
])
},
}
export default {
name: 'arrayToText',
setup() {
const isChange = ref(false)
// 把ref对象挂载在window是为了方便在外部改变ref的值
window.isChange = isChange
return {
isChange,
}
},
render() {
return this.isChange
? h('div', {}, 'new text')
: h('div', {}, [h('div', {}, 'A'), h('div', {}, 'B')])
},
}
预期需求:
当我在 console 中输入isChange.value = true,然后在浏览器中查看结果,会发现子节点从显示'A'和'B'更新为'new text'。这样就能达到数据驱动视图的效果。
由于之前已经实现了setupRenderEffect()内部的逻辑,可以让 ref 更新的时候,重新执行 patch 函数。我们只需要在调用patchProps()的地方上新增实现patchChildren函数
patchChildren 内部逻辑是怎样的呢?
当子节点的 shapeFlag 是数组的时候,遍历这个数组,并依次 removeChild 做卸载,之后通过 textContent 把文本更新上去。
function patchChildren(n1: any, n2: any, container: any, parentComponent: any) {
const prevShapeFlag = n1.shapeFlag
const newShapeFlag = n2.shapeFlag
const c1 = n1.children
const c2 = n2.children
if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// 卸载旧的子节点
unmountChildren(container)
// 设置元素文本
setElementText(container, c2)
}
}
}
function unmountChildren(child: any) {
for (let i = 0; i < child.length; i++) {
remove(child[i])
}
}
export function remove(child: HTMLElement) {
// 这里为什么不直接用child.remove()呢?可以用,不过这样写兼容性更好而已
const parent = child.parentNode
if (parent) {
parent.removeChild(child)
}
}
// 设置元素文本
export function setElementText(el: HTMLElement, text: string) {
el.textContent = text
}
第二种情况:子节点从字符串变为数组
以下为用于解决这种元素 children 更新的测试 demo
export const App = {
name: 'app',
setup() {},
render() {
return h('div', {}, [
h('p', {}, '主页'),
// 旧的是文本 新的是数组
h(textToArray),
])
},
}
export default {
name: 'textToArray',
setup() {
const isChange = ref(false)
window.isChange = isChange
return {
isChange,
}
},
render() {
return this.isChange
? h('div', {}, [h('p', {}, 'p标签'), h('span', {}, 'span标签')])
: h('div', {}, 'old text')
},
}
预期需求:
跟情况一正好相反,当我在 console 中输入isChange.value = true,然后在浏览器中查看结果,会发现子节点从显示'old text'更新为'A'和'B'。这样就能达到数据驱动视图的效果。
实现思路:
先把子节点重新通过 textContext 设置为空字符串,再通过已经实现的 mountChildren 函数,把新的子节点挂载上去。不过在此之前还是要通过 shapeFlag 判断一下 vnode 的类型。
// n1是旧节点 n2是新节点
function patchChildren(n1: any, n2: any, container: any, parentComponent: any) {
const prevShapeFlag = n1.shapeFlag
const newShapeFlag = n2.shapeFlag
const c1 = n1.children
const c2 = n2.children
// 判断新节点的shapeFlag是否是文本
if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
// 判断旧节点的shapeFlag是否是数组
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
// array to text
unmountChildren(container)
setElementText(container, c2)
}
} else {
// text to array
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
setElementText(container, '')
// 挂载新的子节点
mountChildren(c2, container, parentComponent)
}
}
}
第三种情况:子节点从旧的文本更新为新的文本
这种情况相对简单,只需要判断 n1.children 和 n2.children 是不是相等的,如果不是则利用 textContext 更新文本即可。相等就没必要往下了,节省性能。
同时针对上面的 patchChildren 我们可以优化以下逻辑:
// n1是旧节点 n2是新节点
function patchChildren(n1: any, n2: any, container: any, parentComponent: any) {
const prevShapeFlag = n1.shapeFlag
const newShapeFlag = n2.shapeFlag
const c1 = n1.children
const c2 = n2.children
if (newShapeFlag & ShapeFlags.TEXT_CHILDREN) {
if (prevShapeFlag & ShapeFlags.ARRAY_CHILDREN) {
unmountChildren(container)
}
// 判断是否相等,单独把`setElementText`拿出来,兼容了第一种和第三种情况
if (c1 !== c2) {
setElementText(container, c2)
}
} else {
// text to array
if (prevShapeFlag & ShapeFlags.TEXT_CHILDREN) {
setElementText(container, '')
mountChildren(c2, container, parentComponent)
}
}
}
第四种情况旧节点是数组,新节点是数组,patch 的时候相对复杂,涉及到 vue 的 diff 算法。我将在下一篇带来diff算法的分享。
最后肝血阅读,栓 Q
转载自:https://juejin.cn/post/7116163214785642533






