Vue3 的渲染器和渲染函数

111 阅读8分钟

渲染器用来执行渲染任务,渲染真实的节点

渲染器是框架跨平台能力的关键

渲染器和渲染是完全不同的两个概念,渲染器的作用是将元素渲染为特定平台上的真实元素,浏览器平台,渲染器将虚拟 dom 渲染为真实的 dom

借组 effect 实现响应式渲染

/**
 * 浏览器端的渲染器
 * @params domString
 * @params container HTMLELEMENT
 * @return null
 */
function renderer(domString, container) {
    container.innerHTML = domString
}
// https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js
const { effect, ref } = window.VueReactivity
function renderer(domString, container) {
    container.innerHTML = domString
}
const count = ref(0)

// 借助effect 才能实现 响应式渲染
effect(() => {
    renderer(`<h1>${count.value}</h1>`, document.getElementById('app'))
})

setTimeout(() => {
    count.value++
}, 1000)

createRenderer() 封装渲染器

/**
 * 创建渲染器,渲染器包含很多,比如renderer,createApp
 * @returns
 */
function createRenderer() {
    function patch(n1, n2, container) {
        /**
         * 旧节点不存在,意味着 挂载,调用 mountElement 函数完成渲染
         */
        if (!n1) {
            mountElement(n2, container)
        }
    }

    /**
     * 挂载元素抽离为单独的Api,目的是为了跨平台渲染
     */
    function mountElement(vnode, container) {
        const el = (vnode.el = document.createElement(vnode.type))
        if (typeof vnode.children === 'string') {
            /**
             * vnode.children 是字符串 就直接作为 el的文本节点
             */
            el.innerText = vnode.children
        }

        // 将元素添加到容器中
        container.appendChild(el)
    }

    /**
     * 浏览器端的渲染器, 将元素渲染到节点
     * @param {*} domString
     * @param {HTMLElement} container
     */
    function renderer(vnode, container) {
        if (vnode) {
            // 新 node 存在,将新、旧节点同时传递给patch函数,进行打补丁
            // 首次渲染也是一次特殊的patch,首次渲染时旧节点不存在

            patch(container._vnode, vnode, container)
        } else {
            // 旧节点存在,新节点不存在,说明是卸载操作
            if (container._vnode) {
                container.innerHTML = ''
            }
        }
        container._vnode = vnode // 保存节点,作为后续渲染的旧节点
    }
    function hydrate(vnode, container) {}

    return {
        renderer,
        hydrate,
    }
}
const { renderer } = createRenderer()

createRenderer({createElement,setElementText}) 传递 options 实现渲染器与浏览器解耦

// 通过参数传递 createElement 方法,使得渲染器与浏览器解耦,实现跨平台的一步
const brower = {
    createElement(el) {
        return document.createElement(el)
    },
    setElementText(el, text) {
        el.innerText = text
    },
    insert(el, parent, auchor = null) {
        parent.insertBefore(el, anchor)
    },
}
// 传递 options 实现 渲染器与浏览器环境解耦,实现跨平台
function createRenderer(options = brower) {
    const { createElement, setElementText, insert } = options

    // xxx 其他代码
}

给元素设置 HTML attributes || Dom Properties

function mountElement() {
    // ... 省略部分代码
    /**
     * 判断 key 是否是 dom attributes
     * @param {HTMLElement} el
     * @param {string} key
     * @returns
     */
    function shouldSetAsProps(el, key) {
        /**
         * 特殊处理部分属性,比如 form属性,是只读的,不能通过 el.form 来修改,只能通过el.setAttribute()来设置
         */
        if (key === 'form' && el.tagName === 'INPUT') return false
        return key in el
    }

    if (vnode.props) {
        /**
         * 设置属性
         */
        if (vnode.props) {
            for (let key in vnode.props) {
                /**
                 * 思路:
                 *  1. 先设置 dom Properties el[key] = value, 并且要将 '' 矫正为true
                 *  2. dom Properties 不存在 就通过 el.setAttribute(key,value) 来设置属性
                 *
                 * 使用 shouldSetAsProps(el,key) 函数来代替 el in key 判断 是否应该作为 dom Properties
                 */

                if (shouldSetAsProps(el, key)) {
                    /**
                     * dom Properties
                     * const node = {
                     *      type: 'button',
                     *      props:{
                     *          disabled: '' // 这里就等价于 disabled: true
                     *      }
                     * }
                     */
                    const type = typeof el[key]
                    const value = vnode.props[key]
                    /**
                     * 获取 dom Properties 的类型,如果 布尔类型,并且value 为 空字符串 将value矫正为 true
                     * button.disabled = '' -> 浏览器渲染元素时将 '' 识别为了false,所以这段代码变为 button.diabled = false, 按钮不禁用
                     */
                    if (value === '' && type === 'boolean') {
                        el[key] = true
                    } else {
                        el[key] = value
                    }
                } else {
                    /**
                     * el.setAttribute会将属性值字符串化
                     */
                    el.setAttribute(key, vnode.props[key])
                }
            }
        }
    }
}

卸载 元素, 获取 parentNode,使用 parentNode.removeChild(el) 来卸载

/**
 * 卸载元素
 */
function unmount(vnode) {
    const el = container._vnode.el
    const parentNode = el.parentNode
    if (parentNode) {
        parentNode.removeChild(parentNode)
    }
}
function renderer(vnode, container) {
    if (vnode) {
        // ...
    } else {
        if (container._vnode) {
            // const el = container._vnode.el
            // const parentNode = el.parentNode
            // if (parentNode) {
            //     parentNode.removeChild(parentNode)
            // }
            unmount(container._vnode)
        }
    }
    container._vnode = vnode // 保存节点,作为下次渲染的旧节点
}

处理 单个事件 & vue event invoker

const vnode = {
    props: {
        onClick: () => {},
    },
}

function patchProps(el, key, prevValue, newValue) {
    if (/^on/.test(key)) {
        /**
         * 简易版的事件处理函数,每次都需要调用 removeListeners 来移除旧的事件
         */
        let name = key.slice(2).toLowerCase()
        prevValue && el.removeListeners(name, prevValue) // 移除旧事件处理函数
        el.addEventListeners(name, newValue) // 添加事件处理函数
    }
}

// 将el._evi 设为普通值,处理单个事件
function patchProps(el, key, prevValue, newValue) {
    if (/^on/.test(key)) {
        /**
         * 使用 vue event invoker 避免每次更新都需要 removeListeners(name,prevValue)
         */
        let name = key.slice(2).toLowerCase()
        let invoker = el._evi // 重点在这里
        if (newValue) {
            if (!invoker) {
                // 第一次处理
                invoker = el._evi = function (e) {
                    invoker.value(e) // 执行事件处理函数
                }
                invoker.value = newValue // 事件处理函数
                el.addEventListeners(name, invoker)
            } else {
                // 更新事件,不需要调用 el.removeListeners(name,prevValue) 来移除旧事件,直接更新事件处理函数即可
                invoker.value = newValue
            }
        } else if (invoker) {
            /**
             * 没有新事件,但是 invoker存在,说明不需要事件处理函数了,直接移除
             */
            el.removeListeners(name, invoker)
        }
    }
}

// 将 el._evi 设为对象,处理多个事件
function patchProps(el, key, prevValue, newValue) {
    if (/^on/.test(key)) {
        /**
         * let props = {
                onClick:(){},
                onContextmenu(){}
            }
         */

        const invokers = el._evi || (el._evi = {}) // 将el._evi 变为一个对象,避免元素绑定多个事件时,后面的事件覆盖前面的事件
        let invoker = invokers[key]
        if (newValue) {
            if (!invoker) {
                /**
                 * el._evi = {
                 *  click(){},
                 *  contextmenu(){}
                 * }
                 * 第一次: invoker = click
                 * 第二次: invoker = contextmenu
                 */
                invoker = el._evi[key] = function (e) {
                    invoker.value(e)
                }
                invoker.value = newValue
                el.addEventListeners(name, invoker)
            } else {
                invoker.value = newValue
            }
            // ...
        }
    }
}

处理 多个事件

const div = document.getElementById('app')
const fn = () => {
    console.log(1)
}
const fn1 = () => {
    console.log(2)
}
// 单独绑定两个事件
// div.addEventListener('click', fn)
// div.addEventListener('click', fn1)

// 伪造一个额外的事件, 额外的事件里面调用原来的两个事件
// 调用一次addEventListenr, 回调里面执行多个处理函数 比调用多次 addEventListener, 好
const invoker = function (e) {
    fn(e)
    fn1(e)
}
div.addEventListener('click', invoker)
const vnode = {
    props: {
        onClick: [() => {}, () => {}], // onClick 绑定多个事件
    },
}
function patchProps(el, key, prevValue, newValue) {
    if (/^on/.test(key)) {
        // ...
        if (!invoker) {
            invoker = el._evi[key] = function (e) {
                if (Array.isArray(invoker.value)) {
                    invoker.value.forEach((fn) => fn(e))
                } else {
                    invoker.value(e)
                }
            }
            invoker.value = newValue
        }
    }
}

处理事件冒泡

const bol = ref(false)
const node = {
    type: 'div',
    props: {
        onClick: bol.value
            ? () => {
                  alert('div')
              }
            : false,
    },
    children: [
        {
            type: 'p',
            props: {
                onClick: () => {
                    bol.value = true
                },
            },
        },
    ],
}
effect(() => {
    renderer.render(node, documente.getElementById('#app'))
})
function patchProps(el, key, prevValue, newValue) {
    if (/^on/.test(key)) {
        /**
         * 点击 p 元素 触发的事件,点击p元素却执行了div元素的没有被绑定的事件
         *  1. bol.value = true
         *  2. 响应式数据发生变化, 触发 更新,为 div 绑定了事件处理函数
         *  3. 事件冒泡触发,div的事件也被执行,导致明明bol为false没有为div绑定事件的情况下,div的事件被执行了
         * 发生错误的原因:
         *  为div绑定事件在p元素事件冒泡执行之前,导致事件冒泡发生时div元素已经绑定了事件,导致事件执行
         *
         * 解决办法
         *  1. 记录事件绑定的时间,invoker.attached = performance.now()
         *  2. 记录事件(冒泡)执行的事件,e.timestamp
         *  3. 事件执行的时间 > 事件被绑定的时间,才执行这个事件
         *  3. 事件执行的时间 < 事件被绑定的时间,不执行这个事件
         *
         * 说明:
         *  p 元素先被执行,记录了 e.timestamp
         *  然后更新为div绑定事件,记录了div事件被绑定的事件 invoker.attached ,这个时间 > p元素被执行的时间
         *  在 p 元素的事件内发生事件冒泡,记录的是p元素执行的时间 e.timestamp, e.timestamp < invokerd.attached, 所以不需要执行div元素被绑定的事件
         *
         *
         */
        if (!invoker) {
            invoker = el._evi[key] = function (e) {
                /**
                 * p 元素 执行的时间 e.timestamp
                 * 这个时间 e.timestamp < div.attached 直接return
                 */
                if (e.timestamp < invoker.attached) return

                if (Array.isArray(invoker.value)) {
                    invoker.value.forEach((fn) => fn(e))
                } else {
                    invoker.value(e)
                }
            }
            invoker.value = newValue

            invoker.attached = performance.now() // 记录事件被绑定的事件
        }
    }
}

更新子节点 (元素子节点)

  • 元素子节点的三种规范:

    • 没有子节点 node.children = null
    • 一个字符串子节点 node.children = 'string'
    • 数组子节点,里面包含各种类型的子节点
    // 元素子节点的 type 都是字符串
    // 1
    const vnode = [
        {
            type: 'p',
            children: null,
        },
    ]
    
    // 2
    const vnode = [
        {
            type: 'p',
            children: '',
        },
    ]
    
    // 3
    const vnode = [
        {
            type: 'p',
            children: [
                {
                    type: 'span',
                    children: 'text',
                },
                null,
                'some text',
            ],
        },
    ]
    
  • 新旧子节点的 9 种情况

    • 新子节点 为 null

      • 旧子节点 为 null (清空就行,setElementText(el, ''))
      • 旧子节点 为 字符串 (清空就行,setElementText(el, ''))
      • 旧子节点 为 数组 (逐个卸载就行)
    • 新子节点 为 字符串

      • 旧子节点 为 null (更新内容,setElementText(el,newValue))
      • 旧子节点 为 字符串 (更新内容,setElementText(el,newValue))
      • 旧子节点 为 数组 (先卸载所有的旧子节点,然后更新内容,setElementText(el,newValue))
    • 新子节点 为 数组

      • 旧子节点 为 null (清空容器 setElementText(el, ''),逐个挂载新子节点)
      • 旧子节点 为 字符串 (清空容器 setElementText(el, ''),逐个挂载新子节点)
      • 旧子节点 为 数组 (核心 diff 算法,比较两组差异)
function patchElement(n1, n2) {
    const el = (n2.el = n1.el)
    const oldProps = n1.props
    const newProps = n2.props

    /**
     * 遍历新的props,newProps[key] !== oldProps[key] 把newProps[key] 替换上去
     */
    for (let key in newProps) {
        if (oldProps[key] !== newProps[key]) {
            patchProps(el, key, oldProps[key], newProps[key])
        }
    }

    /**
     * 再次遍历旧的props,如果新的props没有这个属性,还是以旧props为主
     */
    for (let key in oldProps) {
        if (!newProps[key]) {
            patchProps(el, key, oldProps[key], null)
        }
    }

    patchChildren(n1, n2, el)
}
/**
 * 更新节点
 * 新旧子节点共有9种情况,渲染器.md 426 有详细注释
 * @param {vnode} n1
 * @param {vnode} n2
 * @param {*} container
 */
function patchChildren(n1, n2, container) {
    if (typeof n2.children === 'string') {
        /**
         * 处理 新子节点为字符串的情况
         */
        if (Array.isArray(n1.children)) {
            // 旧子节点为数组的情况,需要卸载旧子节点
            n1.children.forEach((node) => unmount(node))
        }

        setElementText(container, n2.childern) // 最后都把内容置为字符串
    } else if (Array.isArray(n2.children)) {
        /**
         * 处理 新子节点 为数组的情况
         */
        if (Array.isArray(n1.children)) {
            /**
             *
             * 旧子节点为数组,diff算法
             * diff 算法
             */
        } else {
            /**
             *
             * 旧子节点为字符串 | 为 null
             * 清空容器,逐个挂载新子节点
             */
            setElementText(container, '')
            n2.children.forEach((node) => patch(null, node, container))
        }
    } else {
        /**
         * 新子节点为 null
         *
         */
        if (Array.isArray(n1.children)) {
            // 旧子节点为数组,逐个卸载
            n1.children.forEach((node) => unmount(node))
        } else {
            // 旧子节点为 null | "" 只需要清空内容就行
            setElementText(container, '')
        }
    }
}

文本节点 和 注释节点

// 文本节点和注释节点的type 都是 Symbol
const Text = Symbol()
const textVnode = {
    type: Text,
    children: '我是文本节点',
}
const Comment = Symbol()
const CommentVnode = {
    type: Comment,
    children: '我是注释节点',
}
  • 更新文本节点 | 更新注释节点
    • 旧节点不存在,根据 n2.children 创建新的文本节点(注释节点)插入到容器中
    • 旧节点存在,对比两个字符串的值是否一样,不一样更新容器的 nodeValue 即可
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) {
        // n1, n2 都是文本节点
        if (!n1) {
            // n1 不存在,根据n2.children的值创建新的文本节点插入到容器中
            const el = createTextNode(n2.children)
            insert(el, container)
        } else {
            // n1 存在,比较新旧节点的值,不一样就更新文本节点的内容
            const el = (n2.el = n1.el)
            if (n1.children !== n2.childern) {
                setText(el, n2.childern)
            }
        }
    }
}

Fragment 节点 片段节点,允许 template 下存在多个根节点, Fragment 只会渲染 children

<!-- Item 组件 -->
<template>
    <li>1</li>
    <li>2</li>
    <li>3</li>
</template>
const Fragment = Symbol()
const vnode = {
    type: Fragment,
    children: [
        // children 存储的内容是模板中的所有根节点
        {
            type: 'li',
            childern: 1,
        },
        {
            type: 'li',
            childern: 2,
        },
        {
            type: 'li',
            childern: 3,
        },
    ],
}
<template>
    <List>
        <Item />
    </List>
</template>
const vnode = {
    type: 'ul',
    children: [
        {
            type: Fragment,
            children: [
                {
                    type: 'li',
                    childern: 1,
                },
                {
                    type: 'li',
                    childern: 2,
                },
                {
                    type: 'li',
                    childern: 3,
                },
            ],
        },
    ],
}
  • 更新 Fragment 节点
    • 旧节点不存在,逐个挂载 n2.children 即可
    • 旧节点存在,更新 children 即可 patchChildren
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 === Fragment) {
        // Fragment 节点
        if (!n1) {
            // 旧 节点不存在,逐个挂载 n2.children 即可
            n2.children.forEach((node) => patch(null, node, container))
        } else {
            // 旧节点存在,更新 children 即可,调用patchChildren
            patchChildren(n1.children, n2.children, container)
        }
    }
}