08-挂载与更新

141 阅读27分钟

挂载与更新

挂载子节点和元素的属性

挂载子节点

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标签中有很多属性,有一些属性是通用的,像idclass这些,由一些又是特定的,像fromaction属性一样

首先,要为虚拟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 AttributeDOM 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')

DOM_Properties

两者的关系

  • 很多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 AttributesDOM 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 AttributesDOM Properties之后,我们需要分析一下第一节中存在的缺陷

以禁用按钮为例来了解这个缺陷:

 <button disabled>Bttton</button>

解析HTML代码的时候,发现这个按钮存在一个disabledHTML Attributes,所以浏览器会将这个按钮的状态设置为禁用,并且将其el.disabled这个DOM Properties设置为true

上述过程是浏览器帮我们处理的,但是现在我们的文件编写在单文件组件的模板中,不会被浏览器解析,所以需要vue来解析:

  • 首先,HTML模板会编译成vnode

     const button = {
         type: 'button',
         props: { disabled: ''}
     } 
    
  • 其次,这里的props.disabled的值为空字符串,如果在渲染其中调用setAttribute则相当于:

     el.setAttribute('disabled', '')
    
  • 这样的解析并没有问题,但是如果现在我们修改一下模板,结果就会出错了

     <button :disabled='false'>Bttton</button>
    
  • 现在,这个模板会编译成一下的vnode

     const button = {
         type: 'button',
         props: { disabled: false}
     } 
    
  • 如果我们仍然使用setAttribute来设置属性的话,就会出现错误,按钮被禁用了

     el.setAttribute('disabled', false)
    

    setAttribute在设置属性值的时候,要设置的值总是会被字符串化,所以上述代码也等价于:

     el.setAttribute('disabled', 'false')
    
  • 实际上,按钮并不关心具体的HTML Attribute的值是什么,只要存在这个disabled属性,那么按钮就会被禁用,所以渲染器不应该总是使用setAttribute函数将属性设置到元素上(使用setAttribute也就相当于设置了HTML Attributes一样)

  • 所以现在我们尝试一下优先设置DOM Properties

     el.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 propertiesel.className,所以表达式'class' in el的值将会是false

所以我们应该使用setAttribute函数来完成class的设置

但是现在我们可以考虑一个优化的地方,由于class在浏览器中有三种设置方式,即setAttributeel.classNameel.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清空,但是这么做有缺陷:

  • 容器的内容可能是由某个或多个组件渲染的,卸载操作发生时,应该正确调用这些组件的beforeUnmountunMounted等生命函数
  • 即使内容不是由组件渲染,也有可能存在自定义指令,应该在卸载操作发生时正确执行对应的指令钩子函数
  • 使用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元素绑定事件处理函数发生在事件冒泡之前

  • 解决问题:

    事件冒泡与更新时机.jpg 这张图描述了整个更新和事件触发的流程,可以发现触发事件的时间要早于事件处理函数被绑定的时间

    也就是说,当触发事件的时候,目标元素上还没有绑定相关的事件处理函数

    所以我们可以根据这个特点,屏蔽所有绑定事件晚于事件触发时间的事件处理函数的执行

    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.childrennull
  • 具有文本子节点,则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类型:Fragment

    const 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'},
                ]
            }
        ]
        
    }
    
  • 修改渲染器,让其支持Fragment

    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){
            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)
        }
    }