本章节学习渲染器的核心功能:挂载与更新
1、挂载子节点和元素的属性
一个元素除了具有文本子节点,还可以包含其他元素子节点,且子节点可以有多个。举个栗子,vnode的子节点写成个数组
const vnode = {
type: 'div',
children: [
{
type: 'p',
children: 'hello'
}
]
}
上面代码表示虚拟节点div下面有个子节点p,很直观的形成了虚拟DOM树。
完成树型结构的子节点渲染,需要调整之前的mountElement方法,添加判断分支,判断子节点children是否是数组,如果是数组,则遍历并调用patch挂载子节点。
注意:
1.传递给patch函数的第一个参数是null,挂载阶段不需要旧的vnode
2.传递给patch函数的第三个个参数是挂载点,确保子节点的挂载位置正确
代码如下:
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)
})
}
insert(el, container)
}
描述元素的属性
描述元素的属性,需要在vnode中添加props字段,props是对象,它的key来表示元素名称,它的值表示属性的值,遍历props对象,把属性渲染到元素上。修改之前代码如下:
const vnode = {
type: 'div',
props: {
name: 'fred'
},
children: [
{
type: 'p',
children: 'hello'
}
]
}
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])
}
}
insert(el, container)
}
2、HTML Attributes与DOM Properties
HTML Attributes与DOM Properties的差异和关联
HTML Attributes就是定义在HTML标签上的属性,html代码在浏览器解析之后会生成对应的js对象,举个栗子:
<input id="example-input" type="text" value="666" />
<script>
const el = document.querySelector('#example-input')
console.log(el.value)
</script>
html标签的属性可以通过js对象进行访问,但名字不是完全一一对应,而且js对象也并不是能访问所有属性,例如aria-valuenow标签属性,反过来也一样,js对象的textContent,html标签中也没有。
修改文本框的值输出el.value会是修改后的值,但是调用getAttribute方法拿到的仍然是旧值。
通过这个效果可以知道HTML Attributes 是与DOM Properties(也就是js对象属性)的初始值对应,标签的改变并没有影响到,DOM Properties,DOM Properties里面的存放的值始终是初始值,通过getAttribute获取仍然是初始值。
上面的例子还可以说明一个HTML Attribute可能关联多个DOM Properties。
3、正确的设置元素属性
上面提到了HTML Attributes 与 DOM Properties的初始值对应,那么之前的渲染器对于布尔值类型的处理会产生问题,会影响例如按钮禁用时出现的问题,需要优先设置DOM Properties,但当字符串为空值时,需要手动矫正,避免出现一直为false或者true的问题。代码如下:
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) {
const value = vnode.props[key]
if (shouldSetAsProps(el, key, value)) {
const type = typeof el[key]
if (type === 'boolean' && value === '') {
el[key] = true
} else {
el[key] = value
}
} else {
el.setAttribute(key, vnode.props[key])
}
}
}
insert(el, container)
}
4、class的处理
Vue.js对class的处理进行了增强,有以下三种方式设置:
1.指定class为一个字符串
<div class="demo">
</div>
const vnode = {
type: 'div',
props: {
class: 'demo'
}
}
2.指定class为一个对象
<div :class="cls">
</div>
const cls = {foo:true, bar:false}
const vnode = {
type: 'div',
props: {
class: 'demo1'
}
}
3.class包含以上两种类型数组
<div :class="arr">
</div>
const arr = [
'foo',
{
bar: true
}
]
const vnode = {
type: 'div',
props: {
class: ['foo',
{
bar: true
}
]
}
}
性能更好的class设置方式
在浏览器中为一个元素设置class有三种方法,使用setAttribute、el.className、el.classList
,其中1000次的性能对比如下图所示
其中性能最好的是className方式,所以需要调整patchProps函数,对class进行特殊处理。
代码如下:
patchProps(el, key, preValue, nextValue) {
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)
}
}
5、卸载操作
卸载操作发生在更新阶段,更新:指的是首次渲染之后再次进行渲染,后续渲染会触发更新
当前的渲染器的卸载方法存在缺陷:
1、容器内容可能是多个组件渲染,卸载时,应该正确执行生命周期函数
2、即使内容不是由组件渲染的,元素存在自定义指令,应该在卸载操作发生时正确执行对应的钩子函数
3、使用innerHTML清空容器元素内容,不会移除绑定在DOM元素上的事件处理函数
所以,不能简单地使用innerHTML来完成卸载操作。
正确的卸载方式:根据vnode对象获取与其相关联的真实DOM元素,然后使用原生DOM操作方法将该DOM元素移除。代码如下
function mountElement(vnode, container) {
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)
}
//根据虚拟节点对象vnode.el取得真实DOM元素,再从父元素中移除
function unmount(vnode) {
const parent = vnode.el.parentNode
if (parent) {
parent.removeChild(vnode.el)
}
}
6、区分vnode的类型
之前所说的打补丁、卸载都没有提过一个重要的问题,那就是区分当前要操作哪个元素,如果挂载了两个元素,那么需要区分是对哪个更新,哪个卸载。所以,还需要调整patch函数
function patch(n1, n2, container) {
// 如果n1存在对比,n1 n2类型,如果新旧类型不同,直接卸载旧vnode
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') {
// 组件
}
}
7、事件的处理
虚拟节点中描述事件
虚拟节点中所有on描述的属性都视为事件,如onClick
添加事件到DOM元素上
在patchProps上调用addEventListener函数绑定事件
更新事件
先移除之前的事件,再添加,总体代码如下
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) => {
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') {
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、事件冒泡与更新时机问题
当前的事件处理和事件触发存在着时间差的问题,具体问题如下:事件触发的时间早于事件处理函数绑定的时间,在虚拟节点复杂的情况下,可能会有标签响应事件失效
解决方案:屏蔽所有绑定时间晚于事件触发处理函数的执行,调整patchProps函数,代码如下:
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) => {
console.log(e.timeStamp)
console.log(invoker.attached)
//触发时间如果早于绑定时间,则不执行处理函数
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 = performance.now()
el.addEventListener(name, invoker)
} else {
invoker.value = nextValue
}
} else if (invoker) {
el.removeEventListener(name, invoker)
}
} else 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)
}
}
9、更新子节点
vnode子节点规范:没有子节点、文本子节点、一组子节点。
更新子节点的操作:首先,检测新子节点的类型是否是文本节点,如果是,则要检查旧子节点的类型,旧子节点也是三种情况(看上面)。如果没有旧子节点或者子节点是文本子节点,那么只需要把新的文本内容设置给容器元素即可,如果就子节点是一组子节点需要遍历逐个调用unmount方法卸载。代码如下:
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, '')
}
}
}
10、Fragment
Fragment(片段)是Vue.js 3中新增的vnode类型。
应用场景:可以解决Vue.js 2只能有单一根节点缺陷
vnode中描述方法:创建唯一标识Fragment,children中设置为数组
注意:Fragment本身并不渲染任何内容
代码如下:
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) {
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 (type === Fragment) {
if (!n1) {
n2.children.forEach(c => patch(null, c, container))
} else {
patchChildren(n1, n2, container)
}
}
}
总结
1、学习了如何挂载子节点,递归调用patch,两个重要概念HTML Attributes 与 DOM Properties。
2、特殊属性的处理,class的处理,三种设置class的方式及其性能
3、卸载操作,卸载操作的三个注意事项,不能够通过简单的innerHTML清空容器
4、vnode类型区分,判断新旧vnode描述的内容是否相同,才去打补丁,如何判断是普通标签还是对象
5、事件的处理,vnode.props对象中以on开头的属性当做事件处理
6、处理事件与更新时机,利用触发和绑定的时间差,屏蔽所有绑定时间晚于触发的事件处理函数的执行
7、子节点的更新,子节点只能是三种类型:字符串类型、数组类型、null
8、Fragment及其用途,Fragment本身不会渲染任何DOM元素