挂载与更新
挂载子节点和元素的属性
挂载子节点
vnode.children的值是字符串类型时,会把它设置为元素的文本内容
而当vnode.children要表示多个子节点的时候,他应该是一个数组:
const vnode = {
type: 'div',
children: [
{type: 'p', children: 'hello'}
]
}
现在需要完成子节点的渲染,所以应该修改mountElement函数:
function mountElement(vnode, container) {
const el = createElement(vnode.type)
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)
})
}
insert(el, container)
}
此处需要注意两点:
- 传递给
patch的第一个参数是null,因为是挂载阶段,没有旧vnode,所以只需要传递null即可 - 传递给
patch的第三个参数是挂载点,由于正在挂载的子元素是div标签的子节点,所以需要把刚刚创建的div元素作为挂载点,这样才能确保子节点挂载到正确的位置
元素的属性
HTML标签中有很多属性,有一些属性是通用的,像id、class这些,由一些又是特定的,像from的action属性一样
首先,要为虚拟DOM定义新的vnode.props字段,代表标签的属性 ,该字段设计为一个对象,拥有键值对的映射
const vnode = {
type: 'div',
props: { id: 'foo' }
children: [
{type: 'p', children: 'hello'}
]
}
然后,需要修改我们的mountElement函数,让其加上解析props参数的功能
function mountElement(vnode, container) {
const el = createElement(vnode.type)
/** 省略children的处理 */
//检查有没有属性
if(vnode.props){
//有属性的话将其遍历
for(const key in vnode.props){
//调用setAttribute将属性设置到元素上
el.setAttribute(key, vnode.props[key])
}
}
insert(el, container)
}
除了使用上述的**setAttribute函数之外,还可以通过DOM对象直接设置**
el[key] = vnode.props[key]
HTML Attributes 与 DOM Properties
在上一节中,我们使用了setAttribute和直接操作DOM对象来添加属性,但是这两种方法都存在缺陷
而要要清楚缺陷,就必须先了解HTML Attribute和DOM Properties
HTML Attributes
指的是定义在HTML标签上的属性
<input id='my-input' type='text' value='foo' />
对于以上HTML代码,定义在HTML标签上的属性则有id='my-input'、type='text'、value='foo'
DOM Properties
对于上述HTML中的属性,浏览器解析这段代码后,会创建一个与之相符的DOM对象,可以通过JS代码读取该对象
const el = document.querySelector('#my-input')
两者的关系
- 很多
HTML Attributes在DOM对象上都有与之同名的DOM Properties,例如id='my-input'对应el.id - 但是有的又不对应,像
class='foo'对应的DOM Properties则是el.className - 并不是所有的
HTML Attributes都有与之对应的DOM Properties,如aria-*类的就没有对应的DOM Properties - 并不是所有的
DOM Properties都有与之对应的HTML Attributes,如el.textContent可以用来设置元素的文本内容,但是与之没有对应的HTML Attributes来完成同样的工作
看起来上述的规则好像在表示这两者并没有关系,实际上,两者还是存在一定的关系的
-
我们把
HTML Attributes和DOM Properties具有相同名称的属性看作直接映射 -
现在有一种神奇的现象:
<input value='foo'/>如果用户没有修改文本框的内容,那么
el.value的值就是foo现在如果修改文本框的内容为
bar,那么就会出现以下情况:console.log(el.value); // bar console.log(el.getAttribute('value')) // foo可以看到,修改文本框内容并不会影响
el.getAttribute('value')的返回值,这也意味着**HTML Attributes的作用是设置HTML Attributes与之对应的DOM Properties的初始值**,一旦值改变,DOM Properties存储当前值,通过getAttribute存储的是初始值我们也可以通过
defaultValue来获取文本框的初始值除此之外,如果我们通过
HTML Attributes提供的默认值不合法,那么浏览器就会使用内建的合法值作为对应DOM Properties的默认值
通过以上分析,我们可以总结一个结论,两者的关系为:HTML Attributes的作用是设置与之对应的DOM Properties的初始值
正确设置元素的属性
缺陷的分析
了解了HTML Attributes和DOM Properties之后,我们需要分析一下第一节中存在的缺陷
以禁用按钮为例来了解这个缺陷:
<button disabled>Bttton</button>
解析HTML代码的时候,发现这个按钮存在一个disabled的HTML Attributes,所以浏览器会将这个按钮的状态设置为禁用,并且将其el.disabled这个DOM Properties设置为true
上述过程是浏览器帮我们处理的,但是现在我们的文件编写在单文件组件的模板中,不会被浏览器解析,所以需要vue来解析:
-
首先,HTML模板会编译成
vnodeconst button = { type: 'button', props: { disabled: ''} } -
其次,这里的
props.disabled的值为空字符串,如果在渲染其中调用setAttribute则相当于:el.setAttribute('disabled', '') -
这样的解析并没有问题,但是如果现在我们修改一下模板,结果就会出错了
<button :disabled='false'>Bttton</button> -
现在,这个模板会编译成一下的
vnodeconst button = { type: 'button', props: { disabled: false} } -
如果我们仍然使用
setAttribute来设置属性的话,就会出现错误,按钮被禁用了el.setAttribute('disabled', false)而
setAttribute在设置属性值的时候,要设置的值总是会被字符串化,所以上述代码也等价于:el.setAttribute('disabled', 'false') -
实际上,按钮并不关心具体的
HTML Attribute的值是什么,只要存在这个disabled属性,那么按钮就会被禁用,所以渲染器不应该总是使用setAttribute函数将属性设置到元素上(使用setAttribute也就相当于设置了HTML Attributes一样) -
所以现在我们尝试一下优先设置
DOM Propertiesel.disabled = false按照这样可以正常工作,但是又带来新的问题,如果模板对应的
vnode中的disabled的值为'',那么我们经过这样的编译,相当于执行了以下的代码:el.disabled = ''由于**
disabled的值是布尔值,所以浏览器会将其矫正为布尔类型的值,即false**,相当于:el.disabled = false -
但是现在这段代码违背了用户的本意,用户希望禁用按钮,但是现在产生的效果是不禁用
解决缺陷
经过上述漫长的分析,我们已经知道了不能直接使用setAttribute函数和直接设置的方法去设置一个元素的属性
要彻底解决这个问题,我们需要优先设置元素的DOM Properties,当值为空字符串时,手动将值矫正为true
所以要对mountElement函数进一步修改:
function mountElement(vnode, container) {
const el = createElement(vnode.type)
/** 省略children的处理*/
//检查有没有属性
if(vnode.props){
//有属性的话将其遍历
for(const key in vnode.props){
//使用in操作符判断key是否存在对应的DOM Properties
if(key in el){
//获取该DOM Properties的类型
const type = typeof el[key]
//获取要设置的值
const value = vnode.props[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)
}
现在的代码还存在问题,那就是有一些DOM Properties是只读的,例如:
<form id='from1'></form>
<input from='from1'/>
由于只读,所以我们只能通过setAttribute来设置它,所以需要进一步修改mountElement函数
//判断属性是否应该被作为DOM Properties被设置
function shouldSetAsProps(el, key, value){
//需要特殊处理
if(key === 'from' && el.tagName === 'INPUT') return false
return key in el
}
function mountElement(vnode, container) {
const el = createElement(vnode.type)
/** 省略children的处理*/
//检查有没有属性
if(vnode.props){
//有属性的话将其遍历
for(const key in vnode.props){
//使用shouldSetAsProps判断key是否存在对应的DOM Properties
if(shouldSetAsProps(el, key, value)){
//获取该DOM Properties的类型
const type = typeof el[key]
//获取要设置的值
const value = vnode.props[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)
}
此处的<input from='from1'/>只是一个特殊的例子,还有许多类似这种的特殊情况需要处置
完善代码
由于属性设置也要提取到渲染器选项中,所以要将其相关逻辑从渲染器核心中抽离出来
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(el, key, prevValue, nextValue){
if(shouldSetAsProps(el, key, nextValue)){
//获取该DOM Properties的类型
const type = typeof el[key]
//获取要设置的值
const value = vnode.props[key]
//如果是布尔类型并且value为空字符串,则要将值矫正为true
if(type === 'boolean' && value === ''){
el[key] = true
}else{
el[key] = value
}
}else{
//如果要设置的属性没有对应的DOM Properties调用setAttribute将属性设置到元素上
el.setAttribute(key, vnode.props[key])
}
}
})
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)
})
}
//检查有没有属性
if(vnode.props){
//有属性的话将其遍历
for(const key in vnode.props){
patchProps(el, key, null, vnode.props[key])
}
}
insert(el, container)
}
class的处理
class的归一化处理
这一节要对class做特殊处理,因为Vue对class属性做了增强:
-
方式一:指定
class为一个字符串<p class='foo bat'></p> -
方式二:指定
class为一个对象值<p :class='cls'></p> const cls = {foo: true, bar: false} -
方式三:
class是包含上述两种类型的数组<p :class='arr'></p> const arr = ['foo bar', {baz: true}]
主要有上面这三种设置方式,所以与其对应的vnode也不相同:
-
方式一:
const vnode = { type: 'p', props: { class: 'foo bar' } } -
方式二:
const vnode = { type: 'p', props: { class: {foo: true, bar: false} } } -
方式三:
const vnode = { type: 'p', props: { class: [ 'foo bar', {baz: true} ] } }
所以我们应该在设置元素的class之前就将值归一化为统一的字符串形式,再把该字符串作为元素的class值去设置,因此我们需要封装一个normalizeClass函数 (实现略) ,用它来将不同类型的class值正常化为字符串
const vnode = {
type: 'p',
props:{
class: normalizeClass([
'foo bar',
{baz: true}
])
}
}
//序列化之后
const vnode = {
type: 'p',
props: {
class: 'foo bar baz'
}
}
class设置到元素上
由于class属性对应的DOM properties是el.className,所以表达式'class' in el的值将会是false
所以我们应该使用setAttribute函数来完成class的设置
但是现在我们可以考虑一个优化的地方,由于class在浏览器中有三种设置方式,即setAttribute、el.className和el.classList这三种方式,所以我们可以对比一下其性能
经过1000次设置class的性能,得出结论是使用el.className的性能最优,这样的话,则与我们前面使用的setAttribute不同了,所以应该重新调整patchProps函数
patchProps(el, key, prevValue, nextValue){
//对class进行特殊处理
if(key === 'class'){
el.className = nextValue || ''
}else if(shouldSetAsProps(el, key, nextValue)){
//获取该DOM Properties的类型
const type = typeof el[key]
//如果是布尔类型并且value为空字符串,则要将值矫正为true
if(type === 'boolean' && nextValue === ''){
el[key] = true
}else{
el[key] = nextValue
}
}else{
//如果要设置的属性没有对应的DOM Properties调用setAttribute将属性设置到元素上
el.setAttribute(key, nextValue)
}
}
卸载操作
直接清空innerHTML
除了挂载操作,剩余的就是更新操作,而更新操作之中有一个特殊的操作:卸载操作
在我们之前的代码中,我们直接在render函数中将其第一个参数写为null,然后相应的逻辑为:
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 旧 vnode 存在,且新 vnode 不存在,说明是卸载(unmount)操作
// 只需要将 container 内的 DOM 清空即可
container.innerHTML = ''
}
}
container._vnode = vnode
}
我们直接使用innerHTML清空,但是这么做有缺陷:
- 容器的内容可能是由某个或多个组件渲染的,卸载操作发生时,应该正确调用这些组件的
beforeUnmount、unMounted等生命函数 - 即使内容不是由组件渲染,也有可能存在自定义指令,应该在卸载操作发生时正确执行对应的指令钩子函数
- 使用
innerHTML清空容器还有一个缺陷,不会移除绑定在DOM元素上面的事件处理函数
使用原生DOM移除DOM元素
应该根据vnode对象获取其相关联的真实DOM元素,然后使用原生DOM操作方法将该DOM元素移除
所以应该在vnode与真实DOM之间建立联系,故要修改mountElement函数
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)
}
通过把真实DOM元素赋值给vnode.el之后,我们就可以通过vnode.el获取到他对应的真实的DOM元素,这样卸载操作发生时,我们就只需要根据虚拟节点对象vnode.el取得真实的DOM元素,再将其从父元素中移除即可
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 旧 vnode 存在,且新 vnode 不存在,说明是卸载(unmount)操作
// 根据vnode获取要卸载的真实DOM元素
const el = container._vnode.el
//获取el的父元素
const parent = el.parentNode
//调用removeChild移除元素
if(parent) parent.removeChild(el)
}
}
container._vnode = vnode
}
封装卸载操作
由于比较常见,所以我们将其封装到unmount函数中
function unmount(vnode){
//获取el的父元素
const parent = vnode.el.parentNode
//调用removeChild移除元素
if(parent){
parent.removeChild(vnode.el)
}
}
现在则直接可以在render函数中调用它来完成卸载任务了
function render(vnode, container) {
if (vnode) {
patch(container._vnode, vnode, container)
} else {
if (container._vnode) {
// 旧 vnode 存在,且新 vnode 不存在,说明是卸载(unmount)操作
unmount(container._vnode)
}
}
container._vnode = vnode
}
封装后还带来了两点好处:
- 在
unmount函数内,我们有机会调用绑定在DOM元素上的指令钩子函数,像unmounte等 - 当
unmount函数执行时,有机会检测到虚拟节点vnode的类型,如果虚拟节点描述的是组件,则有机会调用组件相关的生命周期函数
区分vnode类型
在上一节的末尾,我们提到了检测虚拟节点vnode类型,那为什么要检测其类型呢?
假如现在初次渲染了一个p元素
const vnode = {
type: 'p'
}
renderer.render(vnode, document.querySelector('#app'))
后续又渲染了一个input元素
const vnode = {
type: 'input'
}
renderer.render(vnode, document.querySelector('#app'))
这样会造成新旧vnode所描述的内容不同,即vnode.type属性的值不同,所以p元素和input元素之间是不存在打补丁的意义的,因为不同元素都存在特有的属性,例如input元素的type属性p元素就没有
相当于在这种情况下,要做的事情并不是打补丁,而是要先把p元素卸载,再渲染input元素,所以需要调整patch的代码:
function patch(n1, n2, container) {
//如果n1存在,则对比n1和n2的区别
if(n1 && n1.type !== n2.type){
//如果新旧vnode的类型不同,则直接将vnode卸载
unmount(n1)
//此处要将n1重置为null,保证后续的挂载操作能正常执行
n1 = null
}
if (!n1) {
mountElement(n2, container)
} else {
//
}
}
到现在,我们已经可以实现新旧元素不同的情况下的更新挂载,现在如果新旧元素类型是相同的话,则我们要对不同类型的vnode使用不同的处理方式
function patch(n1, n2, container) {
//如果n1存在,则对比n1和n2的区别
if(n1 && n1.type !== n2.type){
//如果新旧vnode的类型不同,则直接将vnode卸载
unmount(n1)
//此处要将n1重置为null,保证后续的挂载操作能正常执行
n1 = null
}
//代码运行到这里,证明n1和n2描述的内容相同(n1为空除外)
const {type} = n2
//如果是字符串类型,则描述的是普通标签元素
if(typeof type === 'string'){
//首次挂载,直接递归调用mountElement函数
if (!n1) {
mountElement(n2, container)
} else {
//更新
}
}else if(typeof type === 'object'){
//如果n2.type的类型是对象,则说明描述的是组件
}else if(typeof type === 'xxx'){
//处理其他类型的vnode
}
}
事件的处理
在虚拟节点中描述事件
事件实际上相当于一种特殊的属性,所以我们可以约定,在vnode.props对象中,凡是以字符串on开头的属性都视为事件
const vnode = {
type: 'p',
props: {
onClick: () => {
alert('clicked')
}
},
children: 'text'
}
把事件添加到DOM元素上
调用addEventListener函数来绑定事件即可,修改patchProps函数
patchProps(el, key, prevValue, nextValue){
//匹配以on开头的属性,视其为事件
if(/^on/.test(key)){
//根据属性名得到对应的事件名称,例如onClick-->click
const name = key.slice(2).toLowCase()
//绑定事件,nextValue为事件处理函数
el.addEventListener(name, nextValue)
}
else if(key === 'class'){
//省略部分代码
}else if(shouldSetAsProps(el, key, nextValue)){
//省略部分代码
}else{
//省略部分代码
}
}
更新事件
按照一般思路,我们应该先将之前添加的事件处理函数移除,然后在添加新的事件处理函数
patchProps(el, key, prevValue, nextValue){
//匹配以on开头的属性,视其为事件
if(/^on/.test(key)){
//根据属性名得到对应的事件名称,例如onClick-->click
const name = key.slice(2).toLowCase()
//移除上一次绑定的事件
prevValue && el.removeEventListener(name, prevValue)
//绑定事件,nextValue为事件处理函数
el.addEventListener(name, nextValue)
}
else if(key === 'class'){
//省略部分代码
}else if(shouldSetAsProps(el, key, nextValue)){
//省略部分代码
}else{
//省略部分代码
}
}
伪造事件处理函数优化
在绑定事件的时候,可以绑定一个伪造的事件处理函数invoker,然后把真正的事件处理函数设置为 invoker.value属性的值,更新事件的时候只需要更新invoker的值即可,不用移除上一次绑定的事件
patchProps(el, key, prevValue, nextValue){
//匹配以on开头的属性,视其为事件
if(/^on/.test(key)){
//获取为该元素伪造的事件处理函数invoker
let invoker = el._vei
//根据属性名得到对应的事件名称,例如onClick-->click
const name = key.slice(2).toLowCase()
if(nextValue){
if(!invoker){
//如果没有invoker,则将一个伪造的invoker缓存到el._vei中
//vei是vue event invoker的首字母缩写
invoker = el._vei = (e) => {
//当伪造的事件处理函数执行时,内部会执行真正的事件处理函数invoker.value
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{
//省略部分代码
}
}
所以使用伪造事件处理函数这个方法,在更新事件的时候,可以避免一次removeEventListener函数的调用,提升了性能,并且还能够解决事件冒泡与事件更新相互之间的影响(后文解析)
其他问题
上述通过伪造事件处理函数优化之后,还存在一些问题
比如现在一个元素不能同时绑定多种事件,多种事件会出现覆盖现象
造成这个问题的原因是我们采用的el._vei只是单纯的赋值了一个函数,所以现在我们要修改其数据结构,设计为一个对象,它的键是事件名称,值是对应的事件处理函数
patchProps(el, key, prevValue, nextValue){
//匹配以on开头的属性,视其为事件
if(/^on/.test(key)){
//获取为该元素伪造的事件处理函数invoker,如果不存在则需要赋值一个空对象
let invoker = el._vei || (el._vei = {})
const name = key.slice(2).toLowCase()
if(nextValue){
if(!invoker){
//事件处理函数缓存到el.vei[key]下,避免重复
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)
}
}
else if(key === 'class'){
//省略部分代码
}else if(shouldSetAsProps(el, key, nextValue)){
//省略部分代码
}else{
//省略部分代码
}
}
现在解决了可以绑定多种事件的问题,但是又存在一个问题,那就是现在不能绑定多个同一种事件,而在原生DOM编程种是可以做到的,所以我们应该调整一下vnode.props对象中事件的数据结构,用一个数组描述事件,数组的每个元素都是独立的事件处理函数
const vnode = {
type: 'p',
props: {
onClick: [
() => {
alert('clicked')
},
() => {
alert('clicked2')
}
]
},
children: 'text'
}
为了将这些事件都正确的绑定到对应元素上,我们应该进一步修改patchProps函数
patchProps(el, key, prevValue, nextValue){
//匹配以on开头的属性,视其为事件
if(/^on/.test(key)){
//获取为该元素伪造的事件处理函数invoker,如果不存在则需要赋值一个空对象
let invokers = el._vei || (el._vei = {})
//根据事件名获取invoker
let invoker = invokers[key]
const name = key.slice(2).toLowCase()
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{
//省略部分代码
}
}
事件冒泡与更新时机问题
我们已经知道基本的事件处理方法了,现在我们了解一下事件冒泡与更新时机的问题
-
例子:
const {effect, ref} = VueReactivity //现在创建一个响应式数据,初始值为false const bol = ref(false) effect(() => { const vnode = { type: 'div', //首次渲染时,bol的值为false,所以props的值是一个空对象 props: bol.value ? { onClick: () => { alert('父元素clicked') } } : {}, children: [ { type: 'p', props: { //点击p元素时,bol的值会改为true onClick: () => { bol.value = true } }, children: 'text' } ] } renderer.render(vnode, document.querySelector('#app')) }) -
理想结果:
首次渲染之后,由于
bol的值为false,所以不会为div绑定点击事件点击
p元素的时候,即使click事件会冒泡到父级div元素,但是由于div元素没有绑定click事件,所以并不会触发父元素的事件 -
实际结果:
点击
p元素的时候,父级div元素的click事件的事件处理函数执行了 -
原因分析:
点击
p元素的时候,绑定到他身上的click函数会执行,于是bol的值会被改为true由于**
bol是一个响应式数据**,所以值发生变化的时候,会重新执行副作用函数此时的
bol已经变成了true,所以在更新阶段,渲染器会为父级div元素绑定click处理事件更新完成之后,点击事件才从
p元素冒泡到父级div元素绑定的click事件的处理函数总结原因:
div元素绑定事件处理函数发生在事件冒泡之前 -
解决问题:
这张图描述了整个更新和事件触发的流程,可以发现触发事件的时间要早于事件处理函数被绑定的时间
也就是说,当触发事件的时候,目标元素上还没有绑定相关的事件处理函数
所以我们可以根据这个特点,屏蔽所有绑定事件晚于事件触发时间的事件处理函数的执行
patchProps(el, key, prevValue, nextValue){ //匹配以on开头的属性,视其为事件 if(/^on/.test(key)){ let invokers = el._vei || (el._vei = {}) let invoker = invokers[key] const name = key.slice(2).toLowCase() if(nextValue){ if(!invoker){ invoker = el._vei[key] = (e) => { //如果事件发生的时间早于事件处理函数绑定的时间,则不执行事件处理函数 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{ //省略部分代码 } }此处在学习过程中遇到一个问题:上述代码11行,不理解为什么事件发生的事件会比事件处理函数绑定事件要早,发现事件冒泡是没有时间差的,现在触发子元素,同一时间就会触发父元素,所以根据上述的特点,就可以屏蔽所有绑定事件晚于事件触发时间的事件处理函数的执行
此处使用了
performance.now(),获取了高精时间,因为现在的浏览器,大部分e.timeStamp的值都是高精时间
更新子节点
统一子节点描述规范
首先,我们先回顾一下目前的元素子节点是如何被挂载的:
function mountElement(vnode, container) {
//让vnode.el引用真实DOM元素
const el = vnode.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)
})
}
//检查有没有属性
if(vnode.props){
for(const key in vnode.props){
patchProps(el, key, null, vnode.props[key])
}
}
insert(el, container)
}
我们在这区分了子节点的类型,分为字符串和数组,字符串表示是一个文本子节点,数组表示具有多个子节点
而区分子节点类型的原因,就是要为了统一规范,让我们更好的编写更新逻辑
所以我们现在要对子节点vnode.children做进一步的规范
- 没有子节点:
vnode.children为null - 具有文本子节点,则
vnode.children为字符串,代表文本的内容 - 其他情况,无论是单元素子节点还是多个子节点,都可以用数组来表示
更新情况
在渲染器执行更新的时候,新旧节点都分别是三种情况之一,所以更新子节点一共会有9种可能
新建一个patchElement函数来对一个元素进行打补丁:
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)
}
}
然后,我们来考虑新子节点是一组子节点的情况,同样的,对应的旧子节点还是有三种情况,当旧子节点是一组子节点的时候,新旧节点需要进行比较,也就是Diff算法,此处先用另一种方法代替,也就是把全部旧子节点卸载,再把新子节点全部挂载,而另外两种情况,我们只需要将容器元素清空,然后逐个将新的一组子节点挂载上去即可
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))
}
}
}
最后,我们考虑最后一种清空,也就是新子节点不存在的情况,这时候,如果旧子节点是一组子节点,则只需要逐个卸载,如果是文本节点,就将文本清空即可,如果原本就不存在旧子节点,那么啥都不用做
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, '')
}
//如果也没有旧子节点,则啥都不用做
}
}
文本节点和注释节点
在前面,我们只涉及到一种类型的vnode,那就是用于普通标签的vnode,使用type属性描述元素的名称
那么现在我们需要来描述更多类型的vnode,像文本节点和注释节点
<div>
<!-- 注释节点 -->
文本节点
</div>
目前,如果vnode.type是一个字符串类型的值,则说明该节点是一个普通标签,该字符串就是标签的名称
但是注释节点和文本节点不同于普通标签节点,因为不具有标签名称
所以现在我们可以使用唯一的标识来描述注释节点和文本节点
const Text = Symbol()
const newVNode = {
type: Text,
children: '文本内容'
}
const Comment = Symbol()
const newVNode = {
type: Comment,
children: '注释内容'
}
使用渲染器来渲染文本节点:
function patch(n1, n2, container) {
//如果n1存在,则对比n1和n2的区别
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,则说明是文本节点
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
}
}
}else if(typeof type === 'object'){
//如果n2.type的类型是对象,则说明描述的是组件
}else if(typeof type === 'xxx'){
//处理其他类型的vnode
}
}
上述代码已经完成渲染文本节点了,但是在这过程中,我们使用了浏览器平台特有的API,所以为了保证渲染器核心的跨平台能力,我们要将特有的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, preValue, nextValue){
//省略部分代码
}
})
修改patch函数中的对应部分:
//如果vnode的类型是Text,则说明是文本节点
if(!n1){
//使用CreateTextNode创建文本节点
const el = n2.el = createText(n2.children)
//将文本节点插入容器
insert(el. container)
}else{
//如果旧vnode存在,则只需要用新文本节点的文本更新旧文本节点即可
const el = n2.el = n1.el
if(n2.children !== n1.children){
setText(el, n2.children)
}
}
而对于注释节点的处理方式,则于文本节点类似,只需要使用document.createComment函数创建注释节点
Fragment
Fragment是vue3中新增的一个vnode类型
-
假设场景:封装一组列表组件
<List> <Items /> </List><List>组件内部实现:<template> <ul> <slot /> </ul> </template><Item>组件内部实现:<template> <li>1</li> <li>2</li> <li>3</li> </template> -
这种情况在vue2中是实现不了的,因为一个
<Item>组件最多只能渲染一个<li>,即组件的模板不允许存在多个根节点所以在vue2中我们通常需要配合
v-for指令来达到目的<List> <Item v-for="item in list" /> </List> -
而vue3支持存在多根节点模板,所以现在要用
vnode来描述多根节点模板故现在就要使用一种新的
vnode类型:Fragmentconst Fragment = Symbol() const vnode = { type: Fragment, children: [ {type: 'li', children: 'text 1'}, {type: 'li', children: 'text 2'}, {type: 'li', children: 'text 3'}, ] } -
所以现在我们可以用虚拟节点描述整个模板
const vnode = { type: 'ul', children: [ { type: Fragment, children: [ {type: 'li', children: 'text 1'}, {type: 'li', children: 'text 2'}, {type: 'li', children: 'text 3'}, ] } ] } -
修改渲染器,让其支持
Fragmentfunction patch(n1, n2, container) { //如果n1存在,则对比n1和n2的区别 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 = document.createTextNode(n2.children) insert(el, container) }else{ const el = n2.el = n1.el if(n2.children !== n1.children){ el.nodeValue = n2.children } } }else if(type === Fragment){ if(!n1){ //如果旧vnode不存在,则只需要将Fragment的children逐个挂载即可 n2.children.forEach(c => patch(null, c, container)) }else{ //如果旧vnode存在,则只需要更新Fragment的children即可 patchChildren(n1, n2, container) } }else if(typeof type === 'object'){ //如果n2.type的类型是对象,则说明描述的是组件 }else if(typeof type === 'xxx'){ //处理其他类型的vnode } } -
同时也需要让
unmount支持Fragment类型的虚拟节点的卸载function unmount(vnode){ //卸载时,如果卸载的vnode是Fragment,则需要卸载其children if(vnode.type === Fragment){ vnode.children.forEach(c => unmount(c)) return } //获取el的父元素 const parent = vnode.el.parentNode //调用removeChild移除元素 if(parent){ parent.removeChild(vnode.el) } }