8.1 挂载子节点和元素的属性
我们知道 html 标签是有属性的,比如说 id、class、disable 等,虚拟节点也需要描述标签的属性。
通过虚拟节点vnode
的props
属性保存 dom 对象上的属性;
8.2 HTML Attributes 与 DOM Properties
HTML Attributes
指的就是定义在 HTML 标签上的属性。
<input id="my-input" type="text" value="foo" />
当浏览器解析这段 HTML 代码后,会创建一个与之相符的 DOM 元素对象,这个 dom 对象下有很多属性,也就是DOM Properties
HTML Attributes
与 DOM Properties
之间通常是有对应关系的,但并不是所有的属性都有直接的对应关系。
通过 dom 元素的getAttribute
函数可以获取HTML Attributes
,但需要注意的是,**HTML Attributes**
的作用是设置与之对应的**DOM Properties**
**的初始值。**一旦值改变,那么 DOM Properties
始终存储着当前值,而通过 getAttribute 函数得到的仍然是初始值。
比如说对于这个 input 元素,初始值设为 value,此时通过 el.getAttribute 函数获取到的值为 foo,
<input value="foo" />
而当用户修改了 value 属性为“bar”后:
console.log(el.getAttribute('value')) // 仍然是 'foo'
console.log(el.value) // 'bar'
8.3 正确的设置元素属性
我们知道给 html 标签元素设置属性,有两种方法:
- 直接通过 dom.properties
- setAttribute 函数
对于大多数的元素属性,可以通过DOM Properties
设置,但也有一些特殊情况,比如说 form 属性,它对应的 DOM Properties 是 el.form,但 el.form 是只读的,因此我们只能够通过setAttribute 函数来设置它。
<form id="form1"></form>
<input form="form1" />
因此我们提炼一个函数shouldSetAsProps
,判断属性是否应该作为 DOM Properties 被设置。
8.4 class 的处理
vue 为了方便开发者使用,允许开发者通过多种方式为元素设置 class:
- 指定 class 为一个字符串值
<p class="foo bar"></p>
- 指定 class 为一个对象:
const cls = { foo: true, bar: false }
- 指定class 是包含上述两种类型的数组:
const arr = [
// 字符串
'foo bar',
// 对象
{
baz: true
}
]
因此,我们需要封装 normalizeClass 函数,在设置元素的 class 之前将值归一化为统一的字符串形式,再把该字符串作为元素的 class 值去设置
/**
* class对象转换为html支持的class写法
* @param classObj class
* @returns
*/
function normalizeClass(classObj: Array<Object> | string | Object): string {
// classobj可能有三种方式
// 1. 字符串方式 'foo bar'
if (typeof classObj === 'string') {
return classObj
}
// 2. 对象 {foo:true}
if (Object.prototype.toString.call(classObj) === '[object Object]') {
let result = ''
for (const key in classObj) {
if (classObj[key]) {
result = result.concat(' ', key)
}
}
return result
}
// 3. 两种形势结合['foo bar',{foo: true}]
if (Array.isArray(classObj)) {
let result = ''
classObj.forEach(item => {
result = result.concat(' ', normalizeClass(item))
})
return result.trim()
}
return ''
}
8.5 正确的执行卸载
- 通过 vnode._el 保存对应的真实节点;
- 使用 dom 元素的卸载方法执行卸载,从而正确的卸载事件监听等函数
- 封装
unmount
函数用于执行卸载逻辑,这样逻辑清晰的同时,也有另外的好处:之后的卸载生命周期钩子函数也可以放到里边
8.6 区分 vnode 的类型
这一部分主要关于当新旧节点的类型不同时,应该如何处理的逻辑内容:
此时应该直接卸载掉旧节点,再挂载新的节点;
8.7 事件的处理
本节我们将讨论如何处理事件,包括如何在虚拟节点中描述事件,如何把事件添加到DOM 元素上,以及如何更新事件。
- 我们可以约定,在 vnode.props 对象中,凡是以字符串 on 开头的属性都视作事件
const vnode = {
type: 'p',
props: {
// 使用 onXxx 描述事件
onClick: () => {
alert('clicked')
}
},
children: 'text'
}
- 如何将事件添加到 DOM 元素及如何更新事件:
- 为 dom 元素绑定事件是通过
addEventListener
函数实现的。 - 按照一般的更新事件的时候的思路,我们需要先移除旧的事件,再通过
addEventListener
函数添加新的事件。但考虑到一些性能,我们采用代理模式,创建一个事件代理函数**invoker**
,当更新事件的时候,修改代理函数invoker.value
的值。
patchProps(el: HTMLElement, key: string, preValue, nextValue) {
//省略
// 新增: 以ON开头的视为事件
if (/^on/.test(key)) {
// 定义el._vei为一个对象,存储事件名称到事件处理函数的映射
let invokers = el._vei || (el._vei = {})
// 代理模式:通过invoker作为一个代理事件处理函数,不用再每次事件更新时,重新解绑再绑定,提高性能
let invoker = invokers[key]
const name = key.slice(2).toLocaleLowerCase()
// 如果存在新的事件绑定函数,则为更新或新增
if (nextValue) {
// 如果invoker不存在,则为新增事件绑定函数
if (!invoker) {
// 将事件处理函数缓存到el._vei[key]下,避免覆盖
invoker = el._vei[key] = (e) => {
invoker.value(e)
}
el.addEventListener(name, invoker)
}
// 将真正的事件处理函数传递给invoker.value
invoker.value = nextValue
} else if (invoker) {
// 如果新的事件绑定函数不存在,且存在旧的事件绑定函数,则解绑
el._vei = null
el.removeEventListener(name, invoker)
}
}
// 省略...
}
这段代码中值得借鉴的东西:
- 通过 vnode._el 绑定真实的 dom 元素,通过 _el._vei 绑定该 dom 元素对应的处理函数
- 绑定函数的写法(代理模式):
invoker
本身是 一个函数,同时有value
属性用于存储真实的事件处理函数,这样通过 el.addEvenListener 绑定invoker
后,后续更新函数只需要修改invoker.value
就可以了
invoker = el._vei[key] = (e) => {
invoker.value(e)
}
el.addEventListener(name, invoker)
8.8 事件冒泡和更新时机的问题
这里涉及到一些边界条件的处理,基本情况是:父节点默认是隐藏的,子节点上绑定了一个事件 A,事件 A 的执行会出发父节点的显示,那么此时父节点上绑定的事件 B 要不要被冒泡而执行呢?
我们之前的实现事件 B 是会执行的,而我们期望正常情况事件 B 是不会执行的(),这里涉及到 vue 更新微任务队列相关的内容。
vue 源码中解决的基本思路是屏蔽所有绑定时间晚于事件触发时间的事件处理函数的执行
8.9 更新子节点
前边几节是关于节点自身属性和事件的更新,本节内容是关于节点的子节点的更新:
对于一个元素来说,它的子节点无非有以下三种情况。
- 没有子节点,此时 vnode.children 的值为 null
- 具有文本子节点,此时 vnode.children 的值为字符串,代表文本的内容。
- 其他情况,无论是单个元素子节点,还是多个子节点(可能是文本和元素的混合),都可以用数组来表示。
<!-- 没有子节点 -->
<div></div>
<!-- 文本子节点 -->
<div>Some Text</div>
<!-- 多个子节点 -->
<div>
<p/>
<p/>
</div>
那么对于元素子节点的更新来说,无非就是九中情况:
具体到代码来说,并不需要覆盖着九中情况,我们把子节点的更新抽离除patchChildren
函数:
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)) {
// 将旧的一组子节点全部卸载
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, '')
}
// 如果也没有旧子节点,那么什么都不需要做
}
}
}