8. 挂载与更新
8.1 挂载子节点和元素的属性
当
vnode.children
的值是字符串类型时,会把它设置为元素的文本内容。当它包含多个子节点时,需要将它设置成数组为了描述元素的属性,还需要为虚拟 DOM 定义新的
vnode.props
字段,它是一个对象,它的键代表元素的属性名称,它的值代表对应属性的值
const vnode = {
type: "div",
// 使用 props 描述一个元素的属性
props: {
id: "foo",
},
children: [
{
type: "p",
children: "hello",
},
],
};
function mountElement(vnode, container) {
const el = createElement(vnode.type);
// children 的处理
if (typeof vnode.children === "string") {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
// 如果 children 是数组,则遍历每一个子节点,并调用 patch 函数挂载它们
vnode.children.forEach((child) => {
patch(null, child, el);
});
}
// 如果 vnode.props 存在才处理它
if (vnode.props) {
// 遍历 vnode.props
for (const key in vnode.props) {
// 调用 setAttribute 将属性设置到元素上
el.setAttribute(key, vnode.props[key]);
// 除了使用setAttribute 函数为元素设置属性之外,还可以通过 DOM 对象直接设置
// el[key] = vnode.props[key];
}
}
insert(el, container);
}
8.2 HTML Attributes 与 DOM Properties
重点:HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值
- HTML Attributes 指的就是定义在 HTML 标签上的属性
<input id="my-input" type="text" value="foo" />
<!-- id="my-input" 对应 el.id
type="text" 对应 el.type
value="foo" 对应 el.value -->
- 但是 DOM Properties 与 HTMLAttributes 的名字不总是一模一样的
<input class="foo" />
<!-- class="foo" 对应的 DOM Properties 则是 el.className -->
<div aria-valuenow="75"></div>
<!-- aria-* 类的 HTML Attributes 没有与之对应的 DOM Properties -->
- HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。一旦值改变,那么 DOMProperties 始终存储着当前值。通过
getAttribute
函数得到的仍然是初始值,但仍然可以通过el.defaultValue
来访问初始值
<input value="foo" />
const el = document.querySelector("input");
el.value = "bar";
console.log(el.getAttribute("value")); // 'foo'
console.log(el.value); // 'bar'
console.log(el.defaultValue); // 'foo'
- 如果你通过 HTML Attributes 提供的默认值不合法,那么浏览器会使用内建的合法值作为对应 DOM Properties 的默认值
<input type="foo" />;
console.log(el.type); // 'text'
8.3 正确地设置元素属性
对于按钮来说,它的 el.disabled
属性值是布尔类型的,并且它不关心具体的 HTML Attributes 的值是什么,只要 disabled
属性存在,按钮就会被禁用
- 情况一:需要禁用按钮
<button disabled>Button</button>;
// 解析模板得到的 vnode
const button = {
type: "button",
props: {
disabled: "",
},
};
// 渲染器调用 setAttribute 设置属性
el.setAttribute("disabled", ""); // 符合预期
- 情况二:不需要禁用按钮
<button :disabled="false">Button</button>
// 解析模板得到的 vnode
const button = {
type: 'button',
props: {
disabled: false
}
}
// 渲染器调用 setAttribute 设置属性
el.setAttribute("disabled", false);
// 但 `setAttribute` 函数设置的值总是会被字符串化,等价于
el.setAttribute("disabled", 'false'); // 禁用了按钮,不符合预期
要彻底解决这个问题,我们只能做特殊处理,即优先设置元素的 DOM Properties,但当值为空字符串时,要手动将值矫正为 true
function shouldSetAsProps(el, key, value) {
// 特殊处理 因为有一些 DOM Properties 是只读的,不能设置
//<form id="form1"></form>
// <input form="form1" />
if (key === "form" && el.tagName === "INPUT") return false;
// 兜底 ,用 in 操作符判断 key 是否存在对应的 DOM Properties
return key in el;
}
function mountElement(vnode, container) {
const el = createElement(vnode.type);
// 省略 children 的处理
if (vnode.props) {
for (const key in vnode.props) {
const value = vnode.props[key];
// 使用 shouldSetAsProps 函数判断是否应该作为 DOM Properties 设置
if (shouldSetAsProps(el, key, value)) {
// 获取该 DOM Properties 的类型
const type = typeof el[key];
// 如果是布尔类型,并且 value 是空字符串,则将值矫正为 true
if (type === "boolean" && value === "") {
el[key] = true;
} else {
el[key] = value;
}
} else {
// 如果要设置的属性没有对应的 DOM Properties,则使用 setAttribute 函数设置属性
el.setAttribute(key, vnode.props[key]);
}
}
}
insert(el, container);
}
提取到渲染器里
const renderer = createRenderer({
createElement(tag) {
return document.createElement(tag);
},
setElementText(el, text) {
el.textContent = text;
},
insert(el, parent, anchor = null) {
parent.insertBefore(el, anchor);
},
// 将属性设置相关操作封装到 patchProps 函数中,并作为渲染器选项传递
patchProps(el, key, prevValue, nextValue) {
if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === "boolean" && nextValue === "") {
el[key] = true;
} else {
el[key] = nextValue;
}
} else {
el.setAttribute(key, nextValue);
}
},
});
function mountElement(vnode, container) {
const el = createElement(vnode.type);
if (typeof vnode.children === "string") {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach((child) => {
patch(null, child, el);
});
}
if (vnode.props) {
for (const key in vnode.props) {
// 调用 patchProps 函数即可
patchProps(el, key, null, vnode.props[key]);
}
}
insert(el, container);
}
8.4 class 的处理
Vue.js 对 calss 属性做了增强。在 Vue.js 中为元素设置类名有以下几种方式
- 字符串
<p class="foo bar"></p>
// 对应的 vnode
const vnode = {
type: "p",
props: {
class: "foo bar",
},
};
- 对象
<p :class="cls"></p>
const cls = { foo: true, bar: false };
// 对应的 vnode
const vnode = {
type: "p",
props: {
class: { foo: true, bar: false },
},
};
- 数组
<p :class="arr"></p>
const arr = ["foo bar", { baz: true }];
// 对应的 vnode
const vnode = {
type: "p",
props: ["foo bar", { baz: true }],
};
因为 class 的值可以是多种类型,所以在设置元素的 class 之前将值归一化为统一的字符串形式,再把该字符串作为元素的 class 值去设置
const vnode = {
type: "p",
props: {
// 使用 normalizeClass 函数对值进行序列化
class: normalizeClass(["foo bar", { baz: true }]),
},
};
在浏览器中为一个元素设置 class 有三种方式,即使用 setAttribute
、el.className
或 el.classList
。其中 el.className
的性能最优
const renderer = createRenderer({
// 省略其他选项
patchProps(el, key, prevValue, nextValue) {
// 对 class 进行特殊处理
if (key === "class") {
el.className = nextValue || "";
} else if (shouldSetAsProps(el, key, nextValue)) {
const type = typeof el[key];
if (type === "boolean" && nextValue === "") {
el[key] = true;
} else {
el[key] = nextValue;
}
} else {
el.setAttribute(key, nextValue);
}
},
});
8.5 卸载操作
卸载操作发生在更新阶段。当挂载时在 vnode
与真实 DOM 元素之间建立联系。卸载时根据 vnode
对象获取与其相关联的真实 DOM 元素,然后使用原生 DOM 操作方法将该 DOM 元素移除
function mountElement(vnode, container) {
// 让 vnode.el 引用真实 DOM 元素
const el = (vnode.el = createElement(vnode.type));
if (typeof vnode.children === "string") {
setElementText(el, vnode.children);
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach((child) => {
patch(null, child, el);
});
}
if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key]);
}
}
insert(el, container);
}
function unmount(vnode) {
const parent = vnode.el.parentNode;
if (parent) {
parent.removeChild(vnode.el);
}
}
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container);
} else {
if (container._vnode) {
// // 根据 vnode 获取要卸载的真实 DOM 元素
// const el = container._vnode.el;
// // 获取 el 的父元素
// const parent = el.parentNode;
// // 调用 removeChild 移除元素
// if (parent) parent.removeChild(el);
umount(container._vnode);
}
}
container._vnode = vnode;
}
8.6 区分 vnode 的类型
在真正执行更新操作之前,需要先检查新旧 vnode 所描述的内容是否相同
function patch(n1, n2, container) {
// 如果 n1 存在,则对比 n1 和 n2 的类型
if (n1 && n1.type !== n2.type) {
// 如果新旧 vnode 的类型不同,则直接将旧 vnode 卸载
unmount(n1);
n1 = null;
}
// 代码运行到这里,证明 n1 和 n2 所描述的内容相同
const { type } = n2;
// 如果 n2.type 的值是字符串类型,则它描述的是普通标签元素
if (typeof type === "string") {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (typeof type === "object") {
// 如果 n2.type 的值的类型是对象,则它描述的是组件
} else if (type === "xxx") {
// 处理其他类型的 vnode
}
}
8.7 事件的处理
在 vnode.props
对象中,凡是以字符串 on 开头的属性都视作事件
patchProps(el, key, prevValue, nextValue) {
// 匹配以 on 开头的属性,视其为事件
if (/^on/.test(key)) {
// 根据属性名称得到对应的事件名称,例如 onClick ---> click
const name = key.slice(2).toLowerCase()
// 移除上一次绑定的事件处理函数
prevValue && el.removeEventListener(name, prevValue)
// 绑定事件,nextValue 为事件处理函数
el.addEventListener(name, nextValue)
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}
缓存事件函数
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// 获取为该元素伪造的事件处理函数 invoker
let invoker = el._vei
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
// 如果没有 invoker,则将一个伪造的 invoker 缓存到 el._vei 中
// vei 是 vue event invoker 的首字母缩写
invoker = el._vei = (e) => {
// 当伪造的事件处理函数执行时,会执行真正的事件处理函数
invoker.value(e)
}
// 将真正的事件处理函数赋值给 invoker.value
invoker.value = nextValue
// 绑定 invoker 作为事件处理函数
el.addEventListener(name, invoker)
} else {
// 如果 invoker 存在,意味着更新,并且只需要更新 invoker.value 的值即可
invoker.value = nextValue
}
} else if (invoker) {
// 新的事件绑定函数不存在,且之前绑定的 invoker 存在,则移除绑定
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}
为了防止事件覆盖的问题,重新设计 el._vei
的数据结构。将 el._vei
设计为一个对象。且同一个事件还能绑定多个事件处理函数
const vnode = {
type: "p",
props: {
onClick: [
// 第一个事件处理函数
() => {
alert("clicked 1");
},
// 第二个事件处理函数
() => {
alert("clicked 2");
},
],
onContextmenu: () => {
alert("contextmenu");
},
},
children: "text",
};
renderer.render(vnode, document.querySelector("#app"));
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// 定义 el._vei 为一个对象,存在事件名称到事件处理函数的映射
const invokers = el._vei || (el._vei = {})
//根据事件名称获取 invoker
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
// 将事件处理函数缓存到 el._vei[key] 下,避免覆盖
invoker = el._vei[key] = (e) => {
// 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
// 否则直接作为函数调用
invoker.value(e)
}
}
invoker.value = nextValue
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}
8.8 事件冒泡与更新时机问题
const { effect, ref } = VueReactivity;
const bol = ref(false);
effect(() => {
// 创建 vnode
const vnode = {
type: "div",
props: bol.value
? {
onClick: () => {
alert("父元素 clicked");
},
}
: {},
children: [
{
type: "p",
props: {
onClick: () => {
bol.value = true;
},
},
children: "text",
},
],
};
// 渲染 vnode
renderer.render(vnode, document.querySelector("#app"));
});
- 在首次渲染完成之后,由于
bol.value
的值为false
,所以渲染器并不会为 div 元素绑定点击事件。当用鼠标点击 p 元素时,即使 click 事件可以从 p 元素冒泡到父级 div 元素,但由于 div 元素没有绑定 click 事件的事件处理函数,所以什么都不会发生。但父级 div 元素的点击事件处理函数执行了。 - 因为更新操作发生在事件冒泡之前,即为 div 元素绑定事件处理函数发生在事件冒泡之前
- 解决:屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
const invokers = el._vei || (el._vei = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
invoker = el._vei[key] = (e) => {
// e.timeStamp 是事件发生的时间
// 如果事件发生的时间早于事件处理函数绑定的时间,则不执行事件处理函数
if (e.timeStamp < invoker.attached) return
if (Array.isArray(invoker.value)) {
invoker.value.forEach(fn => fn(e))
} else {
invoker.value(e)
}
}
invoker.value = nextValue
// 添加 invoker.attached 属性,存储事件处理函数被绑定的时间
invoker.attached = performance.now()
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else if (key === 'class') {
// 省略部分代码
} else if (shouldSetAsProps(el, key, nextValue)) {
// 省略部分代码
} else {
// 省略部分代码
}
}
8.9 更新子节点
字节点的三种情况
<!-- 没有子节点 -->
<div></div>
<!-- 文本子节点 -->
<div>Some Text</div>
<!-- 多个子节点 -->
<div>
<p />
<p />
</div>
// 没有子节点
vnode = {
type: "div",
children: null,
};
// 文本子节点
vnode = {
type: "div",
children: "Some Text",
};
// 其他情况,子节点使用数组表示
vnode = {
type: "div",
children: [{ type: "p" }, "Some Text"],
};
function patchElement(n1, n2) {
const el = (n2.el = n1.el);
const oldProps = n1.props;
const newProps = n2.props;
// 第一步:更新 props
for (const key in newProps) {
if (newProps[key] !== oldProps[key]) {
patchProps(el, key, oldProps[key], newProps[key]);
}
}
for (const key in oldProps) {
// 旧有新无
if (!(key in newProps)) {
patchProps(el, key, oldProps[key], null);
}
}
// 第二步:更新 children
patchChildren(n1, n2, el);
}
function patchChildren(n1, n2, container) {
// 判断新子节点的类型是否是文本节点
if (typeof n2.children === "string") {
// 旧子节点的类型有三种可能:没有子节点、文本子节点以及一组子节点
// 只有当旧子节点为一组子节点时,才需要逐个卸载,其他情况下什么都不需要做
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c));
}
// 最后将新的文本节点内容设置给容器元素
setElementText(container, n2.children);
} else if (Array.isArray(n2.children)) {
// 说明新子节点是一组子节点
// 判断旧子节点是否也是一组子节点
if (Array.isArray(n1.children)) {
// 代码运行到这里,则说明新旧子节点都是一组子节点,这里涉及核心的 Diff 算法
// 将旧的一组子节点全部卸载
n1.children.forEach((c) => unmount(c));
// 再将新的一组子节点全部挂载到容器中
n2.children.forEach((c) => patch(null, c, container));
} else {
// 此时:
// 旧子节点要么是文本子节点,要么不存在
// 但无论哪种情况,我们都只需要将容器清空,然后将新的一组子节点逐个挂载
setElementText(container, "");
n2.children.forEach((c) => patch(null, c, container));
}
} else {
// 代码运行到这里,说明新子节点不存在
// 旧子节点是一组子节点,只需逐个卸载即可
if (Array.isArray(n1.children)) {
n1.children.forEach((c) => unmount(c));
} else if (typeof n1.children === "string") {
// 旧子节点是文本子节点,清空内容即可
setElementText(container, "");
}
// 如果也没有旧子节点,那么什么都不需要做
}
}
8.10 文本节点和注释节点
注释节点与文本节点不同于普通标签节点,它们不具有标签名称,所以需要人为创造一些唯一的标识,并将其作为注释节点和文本节点的 type
属性值
// 文本节点的 type 标识
const Text = Symbol();
const newVNode = {
// 描述文本节点
type: Text,
children: "我是文本内容",
};
// 注释节点的 type 标识
const Comment = Symbol();
const newVNode = {
// 描述注释节点
type: Comment,
children: "我是注释内容",
};
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (typeof type === "string") {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (type === Text) {
// 如果新 vnode 的类型是 Text,则说明该 vnode 描述的是文本节点
// 如果没有旧节点,则进行挂载
if (!n1) {
// 使用 createTextNode 创建文本节点
const el = (n2.el = document.createTextNode(n2.children));
// 将文本节点插入到容器中
insert(el, container);
} else {
// 如果旧 vnode 存在,只需要使用新文本节点的文本内容更新旧文本节点即可
const el = (n2.el = n1.el);
if (n2.children !== n1.children) {
el.nodeValue = n2.children;
}
}
}
}
将 createTextNode
和 el.nodeValue
这两个操作 DOM 的 API 封装到渲染器的选项中
const renderer = createRenderer({
createElement(tag) {
// 省略部分代码
},
setElementText(el, text) {
// 省略部分代码
},
insert(el, parent, anchor = null) {
// 省略部分代码
},
createText(text) {
return document.createTextNode(text);
},
setText(el, text) {
el.nodeValue = text;
},
patchProps(el, key, prevValue, nextValue) {
// 省略部分代码
},
});
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (typeof type === "string") {
if (!n1) {
mountElement(n2, container);
} else {
patchElement(n1, n2);
}
} else if (type === Text) {
if (!n1) {
// 调用 createText 函数创建文本节点
const el = (n2.el = createText(n2.children));
insert(el, container);
} else {
const el = (n2.el = n1.el);
if (n2.children !== n1.children) {
// 调用 setText 函数更新文本节点的内容
setText(el, n2.children);
}
}
}
}
8.11 Fragment
Fragment 本身并不渲染任何内容,只需要处理它的子节点
const Fragment = Symbol();
const vnode = {
type: Fragment,
children: [
{ type: "li", children: "1" },
{ type: "li", children: "2" },
{ type: "li", children: "3" },
],
};
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (typeof type === "string") {
// 省略部分代码
} else if (type === Text) {
// 省略部分代码
} else if (type === Fragment) {
// 处理 Fragment 类型的 vnode
if (!n1) {
// 如果旧 vnode 不存在,则只需要将 Fragment 的 children 逐个挂载即可
n2.children.forEach((c) => patch(null, c, container));
} else {
// 如果旧 vnode 存在,则只需要更新 Fragment 的 children 即可
patchChildren(n1, n2, container);
}
}
}
unmount
函数也需要支持 Fragment 类型的虚拟节点的卸载
function unmount(vnode) {
// 在卸载时,如果卸载的 vnode 类型为 Fragment,则需要卸载其 children
if (vnode.type === Fragment) {
vnode.children.forEach((c) => unmount(c));
return;
}
const parent = vnode.el.parentNode;
if (parent) {
parent.removeChild(vnode.el);
}
}