vue3简单渲染器实现(二)

68 阅读9分钟

一、正确设置元素属性

  1. 理解HTML Attributes和DOM Properties之间的差异和关联:
    • 它们各自是什么:
      • 前者指的是定义在HTML标签上的属性,当浏览器解析HTML代码后,会创建一个与之对应的DOM元素对象
        • 在vue.js的单文件组件里,模板不会由浏览器解析,而是由框架处理:HTML模板会先编译成vnode
      • 这个DOM对象也包含着有属性,即DOM Properties
    • 关联:
      • HTML Attributes的作用是设置与之对应的DOM Properties的初始值
    • 差异:
      • 他们之间对应属性的名字并不一定相同,而且也不是所有HTML Attributes都有与之对应的DOM Properties,反过来也一样
    • 注意:
      • 浏览器内部有一种类似于默认值校验机制,如果提供的HTML Attributes值不合法,那么会使用内置的合法值作为对应DOM Properties的默认值
  2. 关于disabled属性其属性值的设置:
    • 问题:
      • 如果直接通过 HTML Attributes来设置,会发现不管如何设置浏览器都会设置按钮成被禁用状态
      • 如果优先设置DOM Properties,也会有值不能正确设置的情况,比如相应属性值为空的情况
    • 解决方法:
      • 优先设置DOM Properties,但当值为空时手动将值矫正为true
  3. 关于一些只读属性的属性值设置:
    • 采取2的解决方法就不可行了
  4. 对class进行特殊处理:
    • 原因:
      • vue.js对class做了增强,vue.js里的class可以是多种类型,比如一个字符串,一个对象,一个数组
      • 所以必须在设置元素的class前将值归一化为统一的字符串形式,再把该字符串作为元素的class值去设置
    • 解决方法:
      • 封装normalizeClass函数(本质是一个数据结构转换的小算法)
    • 发现:
      • vnode.props对象中定义的属性值的类型并不总是与DOM元素属性的数据结构保持一致,这取决于上层API的设计
  5. 在浏览器中为一个元素设置class的三种方式
    • setAttribute, el.className, el.classList(el.className性能最优)

二、卸载操作

  1. 发生时机:
    • 更新阶段(更新:在初次挂载完成之后,后续渲染会触发更新)
  2. 更新的几种情况:
    • 后续调用render函数渲染空内容时(即卸载操作)
    • 后继调用render函数时传递了新vnode,这样不会触发卸载操作,而是触发打补丁操作
  3. 如何完成正确卸载:
    • 根据vnode对象获取与其相关联的真实DOM元素,然后使用原生DOM操作方式将该DOM元素移除

三、事件处理

  1. 如何在虚拟节点里描述事件:
    • 事件可作为一种特殊属性,因此可以先约定在vnode.props对象中,凡是以字符串on开头的属性都视作事件
  2. 如何将事件添加到DOM元素上:
    • 调用addEventListener函数来绑定事件即可
  3. 设置伪造的事件处理函数invoker来提高性能:
    • 在更新事件时减少了removeEventListener函数的调用,而且解决了事件冒泡与事件更新之间相互影响的问题
  4. 事件冒泡与更新时机问题:
    • 根据这个特点->事件触发的时间要早于事件处理函数被绑定的事件->
    • 我们可以选择屏蔽所有绑定时间晚于事件触发事件的事件处理函数的执行来解决问题
    • 关于时间的存储,performance.now使用的是高精时间,但是e.timeStamp是否为高精时间要视浏览器而定

四、元素节点

  1. 元素子节点类型:
    • 没有子节点:vnode.children = null
    • 只有文本子节点:vnode.children的值为字符串
    • 其他情况:vnode.childrem的值为数组
  2. 文本节点和注释节点:
    • 用vnode描述:vnode.type
      • 文本节点:
        • const Text = Symbol()
        • vnode.type = Text
      • 文本节点:
        • const Comment = Symbol()
        • vnode.type = Comment
  3. 片段(Fragment):
    • 作用:描述多根节点模板
    • vnode.type:Fragment
    • 特点:
      • 渲染器只会渲染它的子节点,因为Fragment本身不会渲染任何内容

五、渲染器代码

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>渲染器实现</title>
</head>
<body>
    <div id="app"></div>
    <!--引入@vue/reactivity包提供的响应式API(名为VueReactivity)-->
    <script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script>
    <script>
        const { effect, ref } = VueReactivity // 通过VueReactivity得到effect, ref两个API

        function createRenderer(options) {
            // 通过 options 得到操作 DOM 的 API
            const {
                createElement,
                insert,
                setElementText,
                createText,
                setText
            } = options
            // mountElement 函数
            function mountElement(vnode, container) {
                // 让 vnode.el 引用真实DOM元素
                const el = vnode.el = createElement(vnode.type)
                // 处理子节点,如果子节点是字符串,代表元素有文本节点
                if (typeof vnode.children === 'string') {
                    // 因此只需要设置元素的 textContent 属性即可
                    setElementText(el, vnode.children)
                } else if (Array.isArray(vnode.children)) {
                    // 如果 children 是数组,则要遍历每一个子节点,并调用 patch 函数挂载它们
                    vnode.children.forEach(child => {
                        patch(null, child, el);
                    })
                }
                // 如果 vnode 存在才处理它
                if (vnode.props) {
                    // 遍历 vnode.props
                    for (const key in vnode.props) {
                        /// 调用 patchProps 函数即可,这样,属性相关的渲染逻辑就从渲染器的核心中抽离了出来
                        patchProps(el, key, null, vnode.props[key])
                    }
                }
                // 将元素添加到容器中
                insert(el, container)
            }     
            // patch 函数
            function patch(n1, n2, container) {
                // 如果 n1 存在,则对比 n1 和 n2 的类型
                if (n1 && n1.type !== n2.type) {
                   // 如果新旧节点的 vnode 类型不同,则直接将旧 vnode卸载
                   unmount(n1)
                   n1 = null
                } 
                // 代码运行到这说明 n1, n2 类型相同,但不一定描述内容相同,接下来要根据 n2 类型进行相应处理
                const { type } = n2
                // 如果 n2.type 的值是字符串类型,则它描述的是普通标签元素
                if (typeof type === 'string') {
                    if (!n1) {
                        mountElement(n2, container)
                    } else {
                        patchElement(n1, n2)
                    }
                } else if (typeof type === 'object') {
                    // 如果 n2.type 的值是对象,则它描述的是组件

                } else if (type === Text) {
                    // 该 vnode 描述的是文本节点
                    // 如果没有旧节点,就进行挂载
                    if (!n1) {
                        // 创建文本节点
                        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)
                        }
                    }
                } 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)
                    }
                }
            }
            // 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)
            }
            // 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)) {
                        // 核心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, '')
                    }
                    // 如果旧子节点也没有,那么什么都不需要做
                }
            }
            // 卸载操作
            function unmount(vnode) {
                // 在卸载时,如果卸载的 vnode 类型为 Fragment,则需要卸载其 children
                if (vnode.type === Fragment) {
                    vnode.children.forEach(c => unmount(c))
                    return 
                }
                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 不存在,说明是卸载( unmounted )操作
                        // 调用 unmount 函数卸载 vnode
                        unmount(container._vnode)
                    }
                }
                // 把 vnode 存储到 container_vnode 下,即后继渲染中的旧 vnode
                container._vnode = vnode
            }
            return {
                render
            }
        }

        // 创建一个 vnode 对象
        const vnode = { 
            type: 'div', // vnode 对象的类型
            // 使用 props 描述一个元素的属性
            props: {
                // 使用 normalizeClass 函数对值进行序列化
                class: normalizeClass([
                    'foo bar',
                    { bar: true }
                ])
            },
            children: [ // 描述元素的子节点,每一个子节点元素都是一个独立的虚拟节点对象,它们共同组成树型结构,即虚拟DOM树
                {
                    type: 'p',
                    children: 'hello'
                }
            ]
        }
         
        // 使用一个对象模拟挂载点
        const container = { type: 'root' }
        // 创建一个渲染器
        const renderer = createRenderer({// 把操作 DOM 的 API 封装成一个对象,这样相关函数就能通过配置项取得操作DOM的API了
            // 实现自定义渲染器
            // 用于创建元素
            createElement(tag) {
                return document.createElement(tag)
            }, 
            // 用于设置元素的文本节点
            setElementText(el, text) {
                el.textContent = text
            },
            // 用于在给定的 parent 下添加指定元素
            insert(el, parent, anchor = null) {
                parent.children = el
            },
            createText(text) {
                return document.createTextNode(text)
            },
            setText(el, text) {
                el.nodeValue = text
            },
            // 将属性设置相关操作封装到 patchProps 函数中,并作为渲染器选项传递
            patchProps(el, key, preValue, nextValue) {
                if(/^on/.test(key)) {
                    // 获取为该元素伪造的事件处理函数 invoker,定义 el._vel 为一个对象,存在事件名称到事件处理函数的映射
                    const invokers = el._vei || (el_vei = {})
                    // 根据事件名称获取 invoker
                    let invoker = invokers[key]
                    const name = key.slice(2).toLowerCase()
                    if (nextValue) {
                        if (!invoker) {
                            // 如果没有 invoker,则将一个伪造的 invoker 缓存到 el._vei 中
                            // vei:vue event invoker
                            invoker = el._vei[key] = (e) => {
                                // e.timeStamp 是事件发生时间
                                // 如果事件发生时间早于事件处理函数绑定的时间,则不执行事件处理函数
                                if (e.timeStamp < invoker.attached) return
                                // 当伪造的事件处理函数执行时,会执行真正的事件处理函数
                                // 如果 invoker.value 是数组,则遍历它并逐个调用事件处理函数
                                if (Array.isArray(invoker.value)) {
                                    invoker.value.forEach(fn => fn(e))
                                } else {
                                    invoker.value(e)
                                }
                            }
                            // 将真正的事件处理函数赋值给 invoker.value
                            invoker.value = nextValue
                            // 添加 invoker.attached属性,存储事件处理函数被绑定的时间
                            invoker.attached = performance.now()
                            // 绑定 invoker 作为事件处理函数
                            el.addEventListener(name, invoker)
                        } else {
                            // 如果 invoker 存在,意味着更新,并且只需要更新 invoker.value 的值即可
                            invoker.value = nextValue
                        }         
                    } else if (invoker) {
                        // 新的事件绑定函数不存在,且之前绑定的 invoker 存在,则移除绑定
                        el.removeEventListener(name, invoker)
                    }
                }// 对 class 进行特殊处理
                else if (key === 'class') {
                    el.className = nextValue || ''
                } // 使用 shouldSetAsProps 函数判断是否应该作为 DOM Properties 设置
                else if (shouldSetAsProps(el, key, nextValue)) {
                    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) // 也可直接设置:el[key]=vnode.props[key]
                }
            }
        })

        // 调用 render 函数渲染该 vnode
        renderer.render(vnode, container)
    </script>
</body>
</html>

参考资源:霍春阳《Vue.js设计与实现》第八章!