《Vue.js 设计与实现》读书笔记 - 第8章、挂载与更新
第8章、挂载与更新
8.1 挂载子节点和元素的属性
扩展子元素的类型可以为数组,并判断如果是数组的话,就先依次挂载所有的子元素。
同时新增节点属性。属性可以通过 el.setAttribute
添加到 DOM 上,也可以直接在 DOM 对象上设置。
function createRenderer(options) {
const { createElement, insert, setElementText } = options
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) {
el.setAttribute(key, vnode.props[key])
// el[key] = vnode.props[key]
}
}
insert(el, container)
}
// ... 同上一章 省略
}
const renderer = createRenderer({
// ...
})
renderer.render(
{
type: 'div',
props: {
id: 'foo'
},
children: [
{
type: 'p',
children: 'hello'
}
],
},
document.getElementById('app')
)
8.2 HTML Attributes 与 DOM Properties
-
HTML Attributes:HTML 标签上的属性,比如
<div id="foo"></div>
这里的id
-
DOM Properties:JavaScript 中 DOM 对象的属性。如下图,DOM 对象上的属性:
-
HTML Attributes 和 DOM Properties 的名字不总是相同,并不是所有的 HTML Attributes 都有对应的 DOM Properties,也不是所有的 DOM Properties 都有对应的 HTML Attributes
- HTML Attributes 中的
class
在 DOM Properties 为className
- HTML Attributes 中
<div aria-valuenow="75"></div>
中的aria-*
类在 DOM Properties 没有对应值。 - DOM Properties 中的
el.textContent
在 HTML Attributes 也没有对应属性。
- HTML Attributes 中的
-
HTML Attributes 和 DOM Properties 具体相同名称的看作 直接映射。
-
HTML Attributes 的作用是设置与之对应的 DOM Properties 的初始值。(
input
的value
和defaultValue
)
8.3 正确的设置元素属性
在 <button disabled>Button</button>
中,对应的 vnode 为
{
type: 'node',
props: {
disabled: ''
}
}
是在表示禁用。而 <button disabled="false">Button</button>
中,对应的 vnode 为 {disabled: false}
是在表示不禁用。
如果设置 HTML Attributes 的话 el.setAttribute(key, 'false')
会被设置为禁用,设置 DOM Properties 的话 el.disabled = ''
又会把 ''
转为 false
设置成不禁用。我们我们需要对 Boolean 值进行特殊处理。
function mountElement(vnode, container) {
const el = createElement(vnode.type)
// ...
if (vnode.props) {
for (const key in vnode.props) {
if (key in el) {
// 判断 key 是否存在对应的 DOM Properties
const type = typeof el[key] // 获取该属性的类型
const value = vnode.props[key]
if (type === 'boolean' && value === '') {
el[key] = true
} else {
el[key] = value
}
} else {
// 没有对应的 DOM Properties
el.setAttribute(key, vnode.props[key])
}
}
}
insert(el, container)
}
还有种特殊情况,在 input
中 form
对应的 DOM Properties 属性是只读的。我们需要先判断是否只读再决定如何修改属性。所以新增函数,用于判断一个属性是否要通过 DOM Properties 来设置。
function shouldSetAsProps(el, key, value) {
// 特殊处理(可能有很多需要特殊处理的情况 此处只列这一种)
if (key === 'form' && el.tagName === 'INPUT') return false
return key in el
}
接下来要把上面的代码中平台相关的部分抽出来。
function mountElement(vnode, container) {
const el = createElement(vnode.type)
// ...
if (vnode.props) {
for (const key in vnode.props) {
patchProps(el, key, null, vnode.props[key])
}
}
insert(el, container)
}
const renderer = createRenderer({
// ...
patchProps(el, key, prevValue, nextValue) {
if (shouldSetAsProps(el, key, nextValue)) {
// 判断 key 是否存在对应的 DOM Properties
const type = typeof el[key] // 获取该属性的类型
if (type === 'boolean' && nextValue === '') {
el[key] = true
} else {
el[key] = nextValue
}
} else {
// 没有对应的 DOM Properties
el.setAttribute(key, nextValue)
}
},
})
8.4 class 的处理
在 Vue 中,可以通过字符串,对象,数组的方式来设置类名,所以我们需要对各种方式的书写做处理,处理为统一的字符串格式。这里假设只有字符串形式。
同时在浏览器中使用 el.className
设置类名的效率是最高的,所以这里特殊处理一下使用该方法类设置域名。
patchProps(el, key, prevValue, nextValue) {
if (key === 'class') {
el.className = nextValue || ''
} else if (shouldSetAsProps(el, key, nextValue)) {
//...
},
- 为用户提供便利的代价是在底层做统一化的处理,这消耗了更多的性能。(
style
也是) vnode.props
不是总和 DOM 元素属性保持一致,这取决于上层 API 设计。
8.5 卸载操作
前文通过 container.innerHTML = ''
来清空容器,这样做并不严谨。原因如下:
- 需要调用相关生命周期函数,如
beforeUnmount
- 需要自定自定义指令的卸载钩子函数
- 需要移除绑定的事件函数
我们需要找到关联的 DOM 元素,并使用原生 DOM 操作方法将该 DOM 元素移除。因此,我们需要给 vnode 绑定对应的 DOM 元素。
function mountElement(vnode, container) {
const el = (vnode.el = createElement(vnode.type))
// ...
}
然后修改 render
中的卸载逻辑:
// 传入一个 vnode 卸载与其相关联的 DOM 节点
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) {
// 没有新 vnode 但是有旧 vnode 卸载
unmount(container._vnode)
}
}
// 把旧 vnode 缓存到 container
container._vnode = vnode
}
8.6 区分 vnode 的类型
当我们传递了新旧两个节点,来使用 patch
打补丁的时候,我们需要在 patch
判断新旧节点的类型,如果类型不同那就先卸载再挂载,节点类型相同时才有打补丁的意义。
// n1 旧node n2 新node container 容器
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 (typeof type === 'object') {
// 组件
} else if (type == 'xxx') {
// 处理其他类型的vnode
}
}
可以看到 patch
的具体操作还是和 vnode 的类型有关,要看是原始的 HTML 类型还是组件或其他。
8.7 事件的处理
在 vnode 中,我们约定把 on
开头的属性视作事件。然后我们可以通过 addEventListener
函数来绑定事件。
如果之前就有值,我们会自然想到先移除旧事件再绑定新事件,不过还有更优雅的方式,就是我们存储一个事件处理函数,并把真正的事件函数赋值到该函数。
我真的觉得这个处理方式好牛逼啊!!!!!
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// evl: vue event invoker
let invoker = el._vel
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
invoker = el._vel = (e) => {
invoker.value(e)
}
invoker.value = nextValue
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
}
// ... 省略之前的代码
},
// 使用
renderer.render(
{
type: 'button',
props: {
onClick: () => {
console.log('click!')
},
},
children: 'Button',
},
document.getElementById('app')
)
由于上面把所有的时间都通过 invoker
存储,如果有多种事件的话会相互覆盖,所以应该把 invoker
设计为一个对象。同时,同一个事件也可能绑定多个事件函数,我们还需要判断是否为数组。
patchProps(el, key, prevValue, nextValue) {
if (/^on/.test(key)) {
// evl: vue event invoker
const invokers = el._vel || (el._vel = {})
let invoker = invokers[key]
const name = key.slice(2).toLowerCase()
if (nextValue) {
if (!invoker) {
invoker = el._vel[key] = (e) => {
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)
}
}
// ...
},
8.8 事件冒泡与更新时机问题
看如下示例(patchElement
函数的实现在 8.9 但是下面的示例需要用到。。。),我们定义了两个节点,父节点一开始点击事件为空,点击子节点会切换父元素的点击事件。
const bol = ref(false)
effect(() => {
const vnode = {
type: 'div',
props: bol.value
? {
onClick: () => {
alert('父元素 clicked')
},
}
: {},
children: [
{
type: 'p',
props: {
onClick: () => {
bol.value = true
},
},
children: 'text',
},
],
}
renderer.render(vnode, document.querySelector('#app'))
})
现在我们点击子节点会发现父元素的事件被执行了。原因是子元素点击后副作用函数会被重新执行,我们先执行副作用函数把父元素事件调整了之后,冒泡才到父元素的 DOM 节点,导致事件函数被执行。
解决方法:记录事件触发的时间和事件绑定的时间,只有触发时间在绑定时间之后才会执行。
但这里书中的处理方法我认为有问题,不确定,给老师提了意见,再看看。
8.9 更新子节点
元素的子节点分为三种类型
- 没有子节点
vnode.children = null
- 文本子节点
typeof vnode.children = string
- 其他情况,单个元素或多个子节点,此时用数组表示
我们在新旧子节点切换的时候,理论上是这三种的互相切换,就是有 9 种可能。现在实现之前没有实现的 patchElement
函数(就是之前新旧节点都存在时,patch
中用于处理的函数)。
// n1 旧node n2 新node
function patchElement(n1, n2) {
const el = (n2.el = n1.el)
const oldProps = n1.props
const newProps = n2.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, '')
}
}
}
在 patchChildren
分别对 9 种情况讨论处理。其中 diff 会在后面章节实现。
8.10 文本节点和注释节点
我们使用 type: 'div'
这种形式表示一个 HTML 中的普通标签,但是对于注释节点和文本节点是没有标签的。所以我们使用 Symbol 来表示,如下:
const Text = Symbol()
const newVnode = {
type: Text,
children: '我是文本内容'
}
const Comment = Symbol()
const newVnode = {
type: Comment,
children: '我是注释内容'
}
调整之前的 patch
函数,新增文本类型的判断。
function createRenderer(options) {
const { createElement, insert, setElementText, patchProps, createText, setText } = options
// ...
// n1 旧node n2 新node container 容器
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) {
// 如果新节点是文本类型
if (!n1) {
const el = (n2.el = createText(n2.children))
insert(el, container)
} else {
const el = (n2.el = n1.el)
if (n2.children !== n1.children) {
// 更新文本节点内容
setText(el, n2.children)
}
}
} else if (typeof type === 'object') {
// 组件
} else if (type == 'xxx') {
// 处理其他类型的vnode
}
}
// ...
}
const renderer = createRenderer({
// ...
createText(text) {
return document.createTextNode(text)
},
setText(el, text) {
el.nodeValue = text
},
patchProps(el, key, prevValue, nextValue) {
// ...
}
})
注释节点和文本节点逻辑类似,不过需要使用 document.createComment
来创建节点。
8.11 Fragment
在 Vue3 中允许多根节点模板,实际上是通过 Fragment 来实现的。Fragment 没有标签名,也通过 Symbol
作为唯一表标识。
const Fragment = Symbol()
// ...
// 在 patch 中 Fragment 的更新逻辑
else if (type === Fragment) {
if (!n1) {
// 如果之前不存在 需要把节点一次挂载
n2.children.forEach((c) => patch(null, c, container))
} else {
// 之前存在只需要更新子节点即可
patchChildren(n1, n2, children)
}
}
// unmount 中添加 Fragment 的逻辑,因为 Fragment 没有实际节点 只需要卸载子节点
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)
}
}