第八章:挂载与更新
挂载子节点和元素的属性
- 遍历 vnode 上的 props 完成属性的挂载
- 递归的处理 children,完成子节点的挂载
javascript
const vnode = {
type: 'div',
props: {
id: 'foo'
},
children: [
{
type: 'p',
children: 'hello'
}
]
};
function mountElement(vnode, container) {
const el = document.createElement(vnode.type);
if (vnode.props) {
for (const k in vnode.props) {
// 👇 等同 => el.setAttribute(k, vnode.props[k])
el[k] = vnode.props[k];
}
}
if (typeof vnode.children === 'string') {
el.textContent = vnode.children;
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
// 因为是第一次挂载,旧节点仍然传递 null。 挂载的元素是新创建出来的 el
patch(null, child, el);
});
}
container.appendChild(el);
}
HTML Attributes 和 DOM Properties
HTML Attributes: <div id='foo'>
DOM Properties:
javascript
const el = document.querySelector('#foo');
el.id = 'xxx';
TIP
HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。因此后面设置值时应优先使用 DOM Properties。
javascript
// 一个 input 标签 <input value="foo" />
const el = document.querySelector('input');
el.value; // foo
el.getAttribute('value'); // foo
// -------- 将 value 修改为 bar -------------
el.value; // bar
el.getAttribute('value'); // foo
el.defaultValue; // foo
一些特殊处理
我们写两个禁用按钮的 dom
HTML
<button disable> buttonA </button>
<button :disable="false"> buttonB </button>
在 vue 模版编译后输出的 vnode
javascript
const buttonA = {
type: 'button',
props: { disable: '' }
};
const buttonB = {
type: 'button',
props: { disable: false }
};
render 时
javascript
// -------------- 执行标签设置 ---------------
el.setAttribute('disable', ''); // 正常
// 设置属性时,总是会被 stringify,在 dom 处理时,再把 'false' => true,这样造成了错误
el.setAttribute('disable', 'false');
// -------------- 执行属性设置 ---------------
el.disable = ''; // '' => false,引起错误
el.disable = false; // 正常
可以发现,无论使用哪种方式都有可能出现问题,对于这种情况只能做特殊处理。
javascript
function mountElement(vnode, container) {
const el = document.createElement(vnode.type);
if (vnode.props) {
for (const k in vnode.props) {
// 属性是否存在于 DOM Properties
if (k in el) {
// 取出数据类型方便后面特殊处理
const type = typeof el[k];
const value = vnode.props[k];
// 特殊处理(button 的 disable)
if (type === 'boolean' && value === '') {
el[k] = true;
} else {
el[k] = value;
}
} else {
el.setAttribute(k, vnode.props[k]);
}
}
}
/** ... */
}
有一些属性是只读的,不能对其修改,也需要特殊处理。
html
<form id="form1"></form>
<input form="form1" />
javascript
function shouldSetAsProps(el, key, val) {
// 特殊处理
if (key === 'form' && el.tagName === 'INPUT') return false;
// 兜底
return key in el;
}
function mountElement(vnode, container) {
const el = document.createElement(vnode.type);
if (vnode.props) {
for (const k in vnode.props) {
// 属性是否存在于 DOM Properties
if (shouldSetAsProps(el, k, vnode.props[k])) {
/** ... */
} else {
el.setAttribute(k, vnode.props[k]);
}
}
}
/** ... */
}
class、style 的特殊处理(增强)
为了方便开发,在 Vue 中使用 class、style 可以使用多种方式
html
<!-- 字符串 -->
<div class="foo bar"></div>
<!-- 对象 -->
<div :class="{foo: true}"></div>
<!-- 数组 -->
<div :class="[{foo: true}, 'bar']"></div>
javascript
const vnode = {
type: 'div',
props: {
class: normalizeClass([{ foo: true }, 'bar']) // 序列化的结果 => 'foo bar'
}
};
class 有多种设置方式,在设置 1000 次时的性能: el.className
> el.setAttribute
> classList
, 因此 Vue 中使用了 className 的方式
卸载操作
javascript
function render(vnode, container) {
if (vnode) {
// 新 vnode 存在,将其与旧 vnode 一起传递给 patch 函数,进行打补丁
patch(container._vnode, vnode, container);
} else {
if (container._vnode) {
// 旧 vnode 存在,新的不存在,说明是卸载操作 - unmount
container.innerHTML = '';
}
}
container._vnode = vnode;
}
在之前我们是通过 container.innerHTML = '';
的方式完成组件卸载,但是这样做存在几个问题:
- 容器执行卸载时,应调用组件对应的
beforeUnmount、unmounted
生命周期 - 有的元素存在自定义指令,应调用对应指令的钩子函数
- innerHTML 不会移除绑定在 DOM 上的事件
正确的卸载方式:根据 vnode 获取与其相关联的真实 DOM,通过原生方法将元素移除。
javascript
function mountElement(vnode, container) {
const el = document.createElement(vnode.type);
vnode.el = el; // 将真实 DOM 挂载到 vnode 节点,方便后面使用
/** ... */
}
function unmount(vnode){
/** 有了虚拟节点,我们可以调用指令钩子和组件的生命周期了 */
const parent = vnode.el.parentNode;
if(parent){
parent.removeChild(vnode.el)
}
}
function render(vnode, container) {
if (vnode) {
// 新 vnode 存在,将其与旧 vnode 一起传递给 patch 函数,进行打补丁
patch(container._vnode, vnode, container);
} else {
if (container._vnode) {
unmount(container._vnode)
}
}
container._vnode = vnode;
}
事件的处理
在绑定事件时,我们绑定一个事件处理函数 invoker
,然后把真正事件处理的函数设置为 invoker.value
,这样更新时就不用调用 removeEventListener
来移除上次绑定的事件,只需要更新 invoker.value
的值即可。
vei:vue event invoker
javascript
function patchProps(el, key, pervValue, nextValue) {
if (/^on/.text(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 => {
invoker.value(e);
};
invoker.value = nextValue;
el.addEventListener(name, invoker);
} else {
invoker.value = nextValue;
}
} else if (invoker) {
el.removeEventListener(name, invoker);
}
}
}
更新子节点
html
<!-- 没有子节点:vnode.children: null -->
<div></div>
<!-- 文本子节点:vnode.children: 'Text'(文本内容字符串) -->
<div>Text</div>
<!-- 多个子节点:vnode.children: [](多个子节点数组)-->
<div>
<p />
<p />
</div>
javascript
// n1 是 old vnode
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);
}
}
// 更新子节点
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 算法 */
} 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, '');
}
}
}
处理文本节点、注释节点
vnode.type
代表了节点的类型,如果值为字符串就代表其为普通标签,那么注释节点、文本节点怎么表示呢?
html
<div>
<!-- 我是注释 -->
我是文本
</div>
用 Symbol()
创建一个唯一标识,渲染时通过判断是否等于 Text
或 Comment
来做特殊处理
javascript
const Comment = Symbol();
const vnode = {
type: Comment,
children: '我是注释'
};
const Text = Symbol();
const vnode = {
type: Text,
children: '我是文本'
};
Fragment
用来解决 vue 模版中只能存在一个 Root 标签的问题。
vue
<template>
<li>1</li>
<li>2</li>
<li>3</li>
</template>
javascript
const Fragment = Symbol();
const vnode = {
type: Fragment,
children: [
{type: 'li', children: '1'},
{type: 'li', children: '2'}.
{type: 'li', children: '3'}
]
};
javascript
function patch(n1, n2, container) {
if (n1 && n1.type !== n2.type) {
unmount(n1);
n1 = null;
}
const { type } = n2;
if (type === 'string') {
/** 执行普通的标签 patch */
} else if (type === Text) {
/** 处理文本节点 */
} else if (type === Fragment) {
// Fragment 本身是不需要渲染的 所以只处理 children
if (!n1) {
n2.children.forEach(c => patch(null, c, container));
} else {
patchChildren(n1, n2, container);
}
}
}
// 卸载时也要处理
function unmount(vnode) {
if (vnode.type === Fragment) {
vnode.children.forEach(c => unmount(c));
return;
}
/** 有了虚拟节点,我们可以调用指令钩子和组件的生命周期了 */
const parent = vnode.el.parentNode;
if (parent) {
parent.removeChild(vnode.el);
}
}