Mini-Vue之渲染系统实现(render) | 青训营笔记

70 阅读5分钟

这是我参与「第四届青训营 」笔记创作活动的的第6天。

Vue源码之三大核心系统

事实上,Vue源码包含三大核心:

  • Compiler模块:模板编译系统;
  • Runtime模块:也可以称之为Render(渲染)模块,真正的渲染模块;
  • Reactivity模块:响应式系统。

在下载后的Vue源码的package文件中,可看到这些模块:

32777ec094e6ea5e7c4c138b5ab1645a.png

大概流程就是通过开发者编写的template模板,经过编译系统后生成VNode,然后再通过渲染系统来生成真实DOM,最后,通过响应式系统对数据进行监听,当数据发生改变时,页面会通过diff算法对比VNode的变化,然后以最大粒度复用代码,保证性能相对来说消耗最小。


渲染系统实现

该模块主要包含三个功能:

  • h函数,用于返回一个VNode对象;
  • mount函数,用于将VNode挂载到DOM上,
  • patch函数,用于对两个VNode进行对比,决定如何处理新的VNode

标题已经说得很清楚了,只是一个迷你版的Vue,所以断然不会和真正的Vue源码一样写那么多Edge Case(边际判断),主要是从本篇文章中学到这个流程,然后做到看源代码时不至于一头雾水。

h函数-生成VNode

上一篇文章我已经介绍过,VNode本质上就是一个JavaScript对象。你可能会好奇为什么要经过VNode这一层,直接渲染不是更加方便快捷吗?首先,VNode可以对真实的元素节点进行抽象,因此很多直接操作DOM存在的限制,比如diff比对,clone操作在转换为一个JavaScript对象后,就变得更加简单了。此外,鉴于目前跨端开发的火爆,编写一套代码可以在多终端的需求越来越高涨,你肯定不想你写的代码只能在浏览器上运行吧?毕竟对大多数人来说,个人手机比PC的优先级要高太多了。

话不多说,下面开始上代码。

h函数主要的作用就是生成VNode,所以它的实现比较简单。

const h = (tag, props, children) => {
  return {
    tag,
    props,
    children
  }
}

也许你没有看过我上一篇文章,所以我在这里再解释一下,tag表示标签名;props表示标签上所加的各种属性,如css样式、方法绑定;最后的children则可能是文本,也可能是另一个对象。

Mount函数-挂载VNode

熟悉Vue开发的肯定知道mount函数是干嘛的,要是连这个都不知道,那也没必要来看源码了。

mount函数的实现可以分为散步:

  1. 根据tag,创建HTML元素,并且存储到VNode的el中;
  2. 处理props,如果是以on开头,那么则是事件监听,普通属性则直接通过setAttribute添加;
  3. 处理子节点,如果是字符穿,则直接设置文本内容,如果是数组节点,那么遍历调用mount
const mount = (vnode, container) => {
    // 1.创建出真实的元素,并且在vnode上保留el
    const el = vnode.el = document.createElement(vnode.tag)

    // 2.处理props
    if (vnode.props) {
        for (const key in vnode.props) {
            const value = vnode.props[key]

            // 如果监听的是事件
            if (key.startsWith('on')) {
                el.addEventListener(key.slice(2).toLowerCase(), value)
            } else {  // 是其它属性
                el.setAttribute(key, value)
            }
        }
    }

    // 3.处理children
    if (vnode.children) {
        if (typeof vnode.children === 'string') {
            el.textContent = vnode.children  // textContent是内置属性,给元素添加文本节点
        } else {
            vnode.children.forEach(item => {
                mount(item, el)
            })
        }
    }

    // 4.将el挂载到container上
    container.appendChild(el)
}

Patch函数-对比两个VNode

  • n1和n2是不同类型的节点
    • 找到n1的el父节点,删除n1节点
    • 挂载n2节点到n1的el的父节点上
  • 相同节点
    • 处理props
      • 将新节点的props全部挂载到el上;
      • 删除不需要的旧节点的属性
    • 处理children
      • 新节点是字符串,直接调用方法改变文本
      • 新节点不是字符串
        • 旧节点是一个字符串
          • 将textContent置空
          • 遍历新节点,挂载到el上
        • 旧节点也是数组类型
          • 取出数组的最小长度
          • 遍历所有节点,新旧节点进行patch操作
          • 如果新节点更长,则挂载
          • 如果旧节点更长,则卸载(删除)
const patch = (n1, n2) => {
    // 如果 n1 和 n2 是不同类型的节点
    if (n1.tag !== n2.tag) {
        const n1ElParent = n1.el.parentElement // 拿到conatiner
        n1ElParent.removeChild(n1.el)
        mount(n2, n1ElParent)
    } else {
        // 1.取出element对象,并且在n2对象中保存,这里要明白,n2中是不包含el节点的,具体看上述h函数的实现
        const el = n2.el = n1.el

        // 2.处理props
        const oldProps = n1.props || {}
        const newProps = n2.props || {}
        // 2.1 遍历新节点的所有props属性,进行比较
        for (const key in newProps) {
            const oldValue = oldProps[key]
            const newValue = newProps[key]
            if (newValue !== oldValue) {
                if (key.startsWith('on')) {
                    el.addEventListener(key.slice(2).toLowerCase(), newValue)
                } else {  // 是其它属性
                    el.setAttribute(key, newValue)
                }
            }
        }
        // 2.2 删除旧的props
        for (const key in oldProps) {
            // 看到这里你可能会疑惑,为什么要把这个判断提上来,其实这里如果不提出来的话,就会导致两次添加事件监听,导致事件一直不会停止,如果你不相信的话,可以把这个代码放下去,然后写一个点击+1的小demo,那么你就会理解了
            if (key.startsWith('on')) {
                const value = oldProps[key]
                el.removeEventListener(key.slice(2).toLowerCase(), value)
            }
            if (!(key in newProps)) {
                el.removeAttribute(key)
            }
        }

        // 3.处理children
        const oldChildren = n1.children || []
        const newChidlren = n2.children || []

        if (typeof newChidlren === "string") { // 情况一: newChildren本身是一个string
            // 边界情况 (edge case)
            if (typeof oldChildren === "string") {
                if (newChidlren !== oldChildren) {
                    el.textContent = newChidlren
                }
            } else {
                el.innerHTML = newChidlren;
            }
        } else { // 情况二: newChildren本身是一个数组
            if (typeof oldChildren === "string") {
                el.innerHTML = "";
                newChidlren.forEach(item => {
                    mount(item, el);
                })
            } else {
                // oldChildren: [v1, v2, v3, v8, v9]
                // newChildren: [v1, v5, v6]
                // 1.前面有相同节点的原生进行patch操作
                const commonLength = Math.min(oldChildren.length, newChidlren.length);
                for (let i = 0; i < commonLength; i++) {
                    patch(oldChildren[i], newChidlren[i]);
                }

                // 2.newChildren.length > oldChildren.length
                if (newChidlren.length > oldChildren.length) {
                    newChidlren.slice(oldChildren.length).forEach(item => {
                        mount(item, el);
                    })
                }

                // 3.newChildren.length < oldChildren.length
                if (newChidlren.length < oldChildren.length) {
                    oldChildren.slice(newChidlren.length).forEach(item => {
                        el.removeChild(item.el);
                    })
                }
            }
        }
    }
}

至此,Mini-Vue从编译后的生成的VNode到挂载节点、生成DOM并进行新旧VNode对比的算法部分就告一段了。