🤯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