Vue.js的设计与实现 - 渲染器的设计(1)

216 阅读6分钟

一. 什么是渲染器

1.1 渲染器基本概念

在浏览器中,用其渲染真实DOM元素、执行渲染任务。

    function renderer(domString, container) {
        container.innerHTML = domString
    }

如上代码,通过一个renderer函数,把渲染内容创建到对应的节点中,这个过程我们称之为渲染。这里要注意一点,不仅仅是节点的改变,创建、删除、更新等操作都可以称为渲染。

同时在开发渲染器的时候,做好渲染器的自定义能力,可以让渲染器跨平台使用。

1.2 官方名称

在Vue3.0的官方说法中

render表示“渲染”, 是一个动作。 renderer表示“渲染器”。在浏览器中就是通过渲染器把虚拟dom转化为真实dom的。

virtual DOM 表示虚拟DOM。简称为vdom

virtual node 表示虚拟节点,简称为vnode

vdom和vnode是相同的,都是树形结构。

挂载简称为mount,是指渲染器把vdom渲染为真实DOM节点的过程,叫做挂载。所以在vue中mounted阶段中我们可以访问到真实dom。

1.3 渲染的真实含义

上面我们提到一点,渲染其实分为很多种类型。把vnode渲染成真实DOM的render函数只是其中的一部分。所以我们需要定义一个createRenderer函数。

    function createRenderer() {
        render() {

        }

        otherFunc() {

        }

        return {
            render,
            otherFunc
        }
    }

1.3.1 挂载动作

    const renderer = createRenderer()
    renderer.render(vnode, doucment.querySelector('#app'))

上面代码中,我们先调用createRender函数,创建一个渲染器,然后调用render函数执行渲染。因为是首次渲染,这里只需要创建新的节点就好。这个阶段就是挂载阶段。

1.3.2 更新动作

如果我们多次对上面的“app”容器修改的话,如下代码:

    const renderer = createRenderer()
    // 首次渲染
    renderer.render(vnode1, doucment.querySelector('#app'))
    // 二次渲染
      renderer.render(vnode2, doucment.querySelector('#app'))

这种情况下,不能简单的执行挂载动作,我们需要对vnode1,vnode2,也就是旧节点、新节点,进行比较,找到修改的地方,并更新到新节点里面。这个过程我们称为更新、也叫做“打补丁”。英文就是“patch”。其实挂载算一种特殊的patch(旧节点为空)。在代码中,我们可以对两者进行简单的处理,如下所示。

    function createRenderer() {
        render(vnode, container) {
            if (vnode) {
                // 存在新节点
                // 把旧节点、新节点、一起给patch函数,进行操作。
                patch(container._vnode, vnode, container)
            }
            else {
                // 不存在新节点,说明没有需要渲染的vnode
                if (container._vnode) {
                    // 存在旧节点、不存在新节点、说明是卸载
                    container.innerHTML = ''
                }
            }
            // 每次执行后,把新节点,设置为旧节点
            container._vnode = vnode
        }

        return {
            render
        }
    }

1.3.3 patch函数

渲染的核心内容其实是这个patch函数,因为它包含着整个渲染逻辑。后面是重点内容。从上面的代码中可以看出,patch函数至少需要3个参数

    patch(oldVnode, newVnode, container) {
        // 新节点
        // 旧节点
        // 容器
    }

首次渲染的时候,oldVnode为undefined,这时候,patch函数会忽略oldVnode,直接渲染newVnode。这也是挂载动作。

二. 渲染器的跨平台能力

一个合格的渲染器,不仅仅是可以在浏览器上完成渲染,而应该可以通过配置实现在多端平台都可以完成渲染。难点在于,如何暴露、暴露哪些可以抽离的API。下面以一个浏览器渲染为主,一步步实现一下。

2.1 渲染

先实现渲染一个简单的文本节点。

需要先用vnode对象描述一个。

一个简单的vnode对象,我们通过type来描述它的node类型。一般为字符串的值作为标签类型,非字符串作为节点。

    // 一个h1标签内容为hello呀
    const vnode = {
        type: 'h1',
        children: 'hello 呀'
    }
    // 创建渲染器
    const renderer = createRenderer()
    // 调用渲染函数
    renderer.render(vnode, document.querySelector('#app'))

具体化render函数:

    function createRender() {
        function patch(n1, n2, container) {
            // 核心的渲染逻辑
        }

        function render(vnode, container) {
            if (vnode) {
                patch(container._vnode, vnode, container)
            }
            else {
                if (container._vnode) {
                    container.innerHTML = ''
                }
            }
        }

        return {
            render
        }
    }

2.2 patch函数编写

现在只考虑挂载阶段的patch,打补丁阶段先不考虑。

    function patch(n1, n2, container) {
        // n1旧节点
        // n2新节点
        if (!n1) {
            // 如果旧节点不存在,直接走挂载
            mountElement(n2, container)
        }
        else {
            // 如果都有,打补丁,先省略
        }
    }   

    function mountElement(vnode, container) {
        // 创建dom,通过type创建
        const el = document.createElement(vnode.type)
        if (typeof vnode.children === 'string') {
            // vnode的子元素为string,代表元素具有文本节点
            // 设置元素的textContent
            el.textContent = vnode.children
        }
        container.appendChild(el)
    }

这样就完成了一个文本节点的挂载。现在考虑一个问题?类似于document.createElement、 el.textContent、appendChild等都是依赖于浏览器的API。离开浏览器环境,肯定不行。所以,在设计通用渲染器的时候就要考虑到抽离特有API,把这些API变成配置项。

具体要如何实现?也不难。

就是在创建createRenderer的时候,把这些配置项传人。

    function createRenderer(options) {
        const { api1, api2, .... api3 } = options
    }

如下,传入配置项:

   const render = createRenderer({
        // 创建元素
        createElement(tag) {
            return document.createElement(tag)
        },
        // 设置文本节点
        setElementText(el, text) {
             el.textContent = text
        },
        // 在给定parent下面添加元素
        insert(el, parent, anchor = null) {
            parent.insertBefore(el, anchor)
        }
   })

   function createRenderer(options) {
        const { createElement, setElementText, insert } = options
         ...
   }

完整代码如下:

        function createRenderer(options) {
            const { createElement, setElementText, insert, removeAllElement } = options
            function mountElement(vnode, container) {
                const el = createElement(vnode.type)
                if (typeof vnode.children === 'string') {
                    setElementText(el, vnode.children)
                }
                insert(el, container)
            }

            function patch(n1, n2, container) {
                if (!n1) {
                    mountElement(n2, container)
                }
                else {
                    // 之后完善
                }
            }

            function render(vnode, container) {
                debugger
                if (vnode) {
                    patch(container._vnode, vnode, container)
                }
                else {
                    if (container._vnode) {
                        removeAllElement(container)
                    }
                }
                container._vnode = vnode
            }

            return {
                render
            }
        }

        const vnode = {
            type: 'h1',
            children: 'hello'
        }

        const container = document.querySelector('#app')

        const renderer = createRenderer({
            // 创建元素
            createElement(tag) {
                return document.createElement(tag)
            },
            // 设置文本节点
            setElementText(el, text) {
                el.textContent = text
            },
            // 在给定parent下面添加元素
            insert(el, parent, anchor = null) {
                parent.insertBefore(el, anchor)
            },

            // 元素移除全部
            removeAllElement(container) {
                container.innerHTML = ''
            }
        })

        renderer.render(vnode, container)

在HTML中试一下:

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Document</title>
    </head>
    <body>
        <div id="app">

        </div>
    </body>
    </html>
    <script>
        // 上面的代码copy一下
    </script>

运行结果如下图:

image.png

三. 与响应式数据结合

具体响应式数据怎么实现的,我会放到后面的篇幅中。现在就考虑一下通过响应式数据和我们的renderer结合起来。

3.1 接入响应式api

unpkg.com/@vue/reacti… vue官方给出的单独响应式数据包。提供响应式api。暴露一个全局的API叫VueReactivity。

只要在script中引入即可

 <script src="https://unpkg.com/@vue/reactivity@3.0.5/dist/reactivity.global.js"></script>

3.2 具体实现

通过VueReactivity暴露出两个api effect, ref, 把vnode定义为响应式数据。通过调用effect副作用函数,完成响应式数据的渲染。具体如下:

     const { effect, ref } = VueReactivity
   
     // 修改vnode为响应式数据
     const vnode = ref({
       type: 'h1',
       children: 'hello'
     })
     effect(
           () => {
               renderer.render(vnode.value, container)
           }
     )

     setTimeout(() => {
        vnode.value.children = 'okok'
     }, 1000)
 

有一点要注意,当我们在定时器里面修改vnode.value.children的时候,会出现一个问题,之前已经渲染一次,相当于执行过一次挂载阶段。这时候新、旧节点都存在,会在patch函数中走另一段代码。所以我们需要修改一下patch代码:

       function patch(n1, n2, container) {
           mountElement(n2, container)
           // 去掉下面的判断,直接全部走挂载。
           // if (!n1) {
           //     mountElement(n2, container)
           // }
           // else {

           // }
       }

OK,我们在页面上看一下

image.png

OK,简单的渲染器基本完成。后续将对渲染器细化,以及patch函数的实现完善。