在之前的章节中已经讲过了Vue3
的组件是怎么挂载的,组件挂载依赖的是内部实现的render
函数,然后通过patch
方法进行挂载;
而组件更新的过程,其实就是render
函数的重新执行,然后通过patch
方法进行更新,他们的过程是一样的,只是在patch
方法中,会对新旧VNode
进行对比,然后进行更新;
组件挂载
在之前我们是通过源码来感受Vue
的组件挂载的,这次我将结合代码示例来演示不一样的组件挂载过程:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
</body>
<script src="../packages/vue/dist/vue.global.js"></script>
<script>
const {h, render} = Vue
const app = document.createElement("div");
document.body.appendChild(app);
const component = h('div', {id: 'foo'}, 'Hello!');
render(component, app);
</script>
</html>
这里使用了Vue3
的全局构建版本,然后通过h
函数创建了一个VNode
,然后通过render
函数进行挂载,这样页面一样是可以正常显示的;
如果我们再创建一个VNode
,然后再次调用render
函数,那么页面就会更新:
const component2 = h('div', {id: 'foo', className: 'foo'}, 'world!');
render(component2, app);
是不是非常有意思?接下里我们通过断点的方式来看看这个过程,我们可以在第二次调用render
函数的时候打上断点,然后看看render
函数的执行过程;
组件更新
通过断点,代码执行不出意外会走到patch
方法中,然后我们就可以跟到patch
方法中,看看patch
方法是如何进行组件更新的;
以我们这个简洁的代码来看,代码最后会走到processElement
方法中,之前的章节进入到processElement
方法之后只讲了mountElement
,今天就来看看patchElement
;
patchElement
patchElement
方法的源码如下:
const patchElement = (n1, n2, parentComponent, parentSuspense, isSVG, slotScopeIds, optimized) => {
const el = n2.el = n1.el;
let { patchFlag, dynamicChildren, dirs } = n2;
patchFlag |= n1.patchFlag & 16;
const oldProps = n1.props || EMPTY_OBJ;
const newProps = n2.props || EMPTY_OBJ;
let vnodeHook;
parentComponent && toggleRecurse(parentComponent, false);
if (vnodeHook = newProps.onVnodeBeforeUpdate) {
invokeVNodeHook(vnodeHook, parentComponent, n2, n1);
}
if (dirs) {
invokeDirectiveHook(n2, n1, parentComponent, "beforeUpdate");
}
parentComponent && toggleRecurse(parentComponent, true);
if (isHmrUpdating) {
patchFlag = 0;
optimized = false;
dynamicChildren = null;
}
const areChildrenSVG = isSVG && n2.type !== "foreignObject";
if (dynamicChildren) {
patchBlockChildren(
n1.dynamicChildren,
dynamicChildren,
el,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds
);
{
traverseStaticChildren(n1, n2);
}
} else if (!optimized) {
patchChildren(
n1,
n2,
el,
null,
parentComponent,
parentSuspense,
areChildrenSVG,
slotScopeIds,
false
);
}
if (patchFlag > 0) {
if (patchFlag & 16) {
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
);
} else {
if (patchFlag & 2) {
if (oldProps.class !== newProps.class) {
hostPatchProp(el, "class", null, newProps.class, isSVG);
}
}
if (patchFlag & 4) {
hostPatchProp(el, "style", oldProps.style, newProps.style, isSVG);
}
if (patchFlag & 8) {
const propsToUpdate = n2.dynamicProps;
for (let i = 0; i < propsToUpdate.length; i++) {
const key = propsToUpdate[i];
const prev = oldProps[key];
const next = newProps[key];
if (next !== prev || key === "value") {
hostPatchProp(
el,
key,
prev,
next,
isSVG,
n1.children,
parentComponent,
parentSuspense,
unmountChildren
);
}
}
}
}
if (patchFlag & 1) {
if (n1.children !== n2.children) {
hostSetElementText(el, n2.children);
}
}
} else if (!optimized && dynamicChildren == null) {
patchProps(
el,
n2,
oldProps,
newProps,
parentComponent,
parentSuspense,
isSVG
);
}
if ((vnodeHook = newProps.onVnodeUpdated) || dirs) {
queuePostRenderEffect(() => {
vnodeHook && invokeVNodeHook(vnodeHook, parentComponent, n2, n1);
dirs && invokeDirectiveHook(n2, n1, parentComponent, "updated");
}, parentSuspense);
}
};
代码有很多,我来给大家整理一下:
/**
* @param n1 旧的 VNode
* @param n2 新的 VNode
*/
const patchElement = (n1, n2) => {
// 获取旧的 VNode 的真实 DOM
const el = n2.el = n1.el;
// 获取新 VNode 的 patchFlag
let { patchFlag } = n2;
// 这里的 patchFlag 是通过位运算来进行计算的,这里的意思是将新旧 VNode 的 patchFlag 进行合并
patchFlag |= n1.patchFlag & 16;
// 获取新旧的 VNode 的 props
const oldProps = n1.props || {};
const newProps = n2.props || {};
// 先更新子节点
patchChildren(
n1,
n2,
el,
);
// 更新 props
patchProps(
el,
n2,
oldProps,
newProps
);
};
因为我们上面的实例代码就只会执行这么多内容,所以简化之后的代码应该是都可以看明白的,非常简单;
patchChildren
不再提供源码,因为源码太长了,对于阅读体验不是很好,如果想看源码的可以自行阅读,后面只会提供简化之后的代码;
patchChildren
方法简化之后如下:
/**
* @param n1 旧的 VNode
* @param n2 新的 VNode
* @param container 真实 DOM
*/
const patchChildren = (n1, n2, container) => {
// 获取新旧 VNode 的 children,这里是 Hellow! 和 world! 文本内容
const c1 = n1 && n1.children;
const c2 = n2.children;
const { shapeFlag } = n2;
// shapeFlag 指代的是 VNode 的类型,这里是文本类型,目前还没完全摸清楚
if (shapeFlag & 8) {
// 两个文本节点不相等,那么就直接更新文本内容
if (c2 !== c1) {
hostSetElementText(container, c2);
}
}
};
// 更新文本内容很简单,就是直接使用 textContent 属性进行更新
const hostSetElementText = (el, text) => {
el.textContent = text;
};
这样就非常简单的完成了文本节点的更新,接下来看看属性的更新;
patchProps
patchProps
方法简化之后如下:
/**
* @param el 真实 DOM
* @param vnode 新的 VNode
* @param oldProps 旧的 props
* @param newProps 新的 props
*/
const patchProps = (el, vnode, oldProps, newProps) => {
// 新旧 props 不相等,那么就进行更新
if (oldProps !== newProps) {
// 旧的 props 不为空,那么就遍历旧的 props
if (oldProps !== EMPTY_OBJ) {
for (const key in oldProps) {
// 判断是否是保留的属性,如果不是保留的属性,并且新的 props 中不存在这个属性,那么就移除这个属性
if (!isReservedProp(key) && !(key in newProps)) {
hostPatchProp(
el,
key,
oldProps[key],
null,
);
}
}
}
// 遍历新的 props
for (const key in newProps) {
// 如果是保留属性就跳过
if (isReservedProp(key)) continue;
// 获取新旧 props 中的值
const next = newProps[key];
const prev = oldProps[key];
// 如果新旧 props 中的值不相等,并且不是 value 属性,那么就更新这个属性
if (next !== prev && key !== "value") {
hostPatchProp(
el,
key,
prev,
next
);
}
}
}
};
const hostPatchProp = (el, key, prevValue, nextValue) => {
// 如果是 class 属性,那么就更新 class
if (key === "class") {
patchClass(el, nextValue);
} else if (key === "style") {
patchStyle(el, prevValue, nextValue);
} else if (isOn(key)) {
if (!isModelListener(key)) {
patchEvent(el, key, prevValue, nextValue);
}
} else if (shouldSetAsProp(el, key, nextValue)) {
patchDOMProp(
el,
key,
nextValue
);
}
};
function patchDOMProp(el, key, value) {
// 这里处理了很多特殊的属性,比如 innerHTML、textContent、value 等等
// 最开头这里有 innerHTML、textContent 的处理,这里被我删除
// 表单元素和自定义元素的 value 属性的一些特殊处理
const tag = el.tagName;
if (key === "value" && tag !== "PROGRESS" && // custom elements may use _value internally
!tag.includes("-")) {
el._value = value;
const oldValue = tag === "OPTION" ? el.getAttribute("value") : el.value;
const newValue = value == null ? "" : value;
if (oldValue !== newValue) {
el.value = newValue;
}
if (value == null) {
el.removeAttribute(key);
}
return;
}
// 其他常规属性的属性值处理
let needRemove = false;
if (value === "" || value == null) {
const type = typeof el[key];
if (type === "boolean") {
value = includeBooleanAttr(value);
} else if (value == null && type === "string") {
value = "";
needRemove = true;
} else if (type === "number") {
value = 0;
needRemove = true;
}
}
try {
// 直接通过真实 dom 元素的属性来设置属性值
el[key] = value;
} catch (e) {
if (!needRemove) {
warn(
`Failed setting prop "${key}" on <${tag.toLowerCase()}>: value ${value} is invalid.`,
e
);
}
}
// 如果需要移除属性,那么就移除属性
needRemove && el.removeAttribute(key);
}
对于属性的更新,其实就是通过真实 DOM 元素的属性来进行更新,这里的代码比较多,还是需要有耐心的看一下;
首先是对旧的属性进行处理,如果旧的属性在新的属性中不存在就移除这个属性,移出很简单,就是调用hostPatchProp
方法,对nextValue
传入null
即可;
for (const key in oldProps) {
// 这里使用 in 操作符来判断是否存在这个属性
if (!(key in newProps)) {
hostPatchProp(
el,
key,
oldProps[key],
null,
);
}
}
然后是对新的属性进行处理,就是拿出新旧的属性进行对比,然后如果不相等就更新这个属性,更新的过程也是调用hostPatchProp
方法,这里的prevValue
传入的是旧的属性值,nextValue
传入的是新的属性值;
// 遍历新的 props
for (const key in newProps) {
// 如果是保留属性就跳过
if (isReservedProp(key)) continue;
// 获取新旧 props 中的值
const next = newProps[key];
const prev = oldProps[key];
// 如果新旧 props 中的值不相等,并且不是 value 属性,那么就更新这个属性
if (next !== prev && key !== "value") {
hostPatchProp(
el,
key,
prev,
next
);
}
}
而hostPatchProp
内部的实现就是区分这个属性应该怎么处理,因为dom
属性有很多设置了之后会有特殊效果,同时属性名在js dom
中呈现的形式和在html
中呈现的形式也不一样,所以这里需要做一些特殊处理;
const hostPatchProp = (el, key, prevValue, nextValue) => {
if (key === "class") {
// 如果是 class 属性,那么就更新 class
patchClass(el, nextValue);
} else if (key === "style") {
// 如果是 style 属性,那么就更新 style
patchStyle(el, prevValue, nextValue);
} else if (shouldSetAsProp(el, key, nextValue)) {
// 如果是其他常规属性,那么就更新其他常规属性
patchDOMProp(
el,
key,
nextValue
);
}
};
这里我只列出了patchDOMProp
方法的实现,因为patchClass
就是设置class
属性,不过操作的是className
属性;
patchStyle
方法内部实现较多,不易吸收,所以我就不列出来了,有兴趣的可以自行阅读;
patchDOMProp
方法的实现也并没有详细介绍每一行的作用,因为都是一些边界情况的处理,需要很多的知识储备,所以了解一下就好;
简单实现
有了上面的一些内容做铺垫,那么现在我们也来简单的实现一下这个更新的过程:
总结
这次只是初窥组件更新的皮毛,只是替换了一点文本内容,而真实的一个组件内部是有很多节点的,更新过程更加复杂;
常听网上说的diff
算法、最长递增子序列
算法、双端比较
算法等等,为啥在我这里没见到?
因为并不是所有的节点都需要用算法来搞定,像这种简单的更新,直接替换就可以了,所以这里并没有用到算法;
当我们了解了这一段过程之后,再去深入了解diff
算法,那么就会更加容易理解了,而这一块内容将在下一章中进行讲解;