React 实践揭秘之旅,中高级前端必备(上)

感谢你们又被个人标题党骗进来了😂。这是我最近几个月亲身探寻过的一趟旅途,感觉颇深。途中也遇到许多困难,但坚持到底,相信最终必定会让各位小伙伴受益不浅,不枉此行!前端

同时也感恩你们对以前一个系列文章的喜欢和修正😘,提了很多的建议和问题,我也会用心写个人每一系列文章,与你们共同成长!!vue

跪求点赞、关注、Star!更多文章猛戳 ->node

引言

以前面试三部曲简明地梳理了前端知识结构体系,浅尝辄止。这个系列则要进一步研究和领会 内在的奥妙。今天打算以一个比较新颖的角度切入,深刻地梳理下 React 的内部实现。react

  • 1. 有利于你们在 React 平常业务使用中更加驾轻就熟git

  • 2. 也可将领会到的思想融会贯通,拓展到其它领域github

那如何深刻研究一个玩具呢?最好的方式即是: 拆解 - 重组装web

众所周知,React 是一款很是神奇的 Web UI 框架,得益于强大的架构,逻辑层视图层 的解耦,使其思想及开发模式能够很好地移植到其它平台,例如 React-Native。因此今天,我打算跳出常规的 Web-DOM,目标是 以类 React 的模式来进行 Web 游戏开发。其实就是咱们须要实现一套 React 上层,并把底层对接 WebGL API。期待这样不一样的视角,能给你们带来新的启发与帮助。😄面试

Tips:算法

WebGL 不是本文关注的重点,使用到的 API 也很是有限,并不须要你具有相关的知识储备。编程

第一站: 穿越之门 - JSX

做为一名前端,咱们须要实现一个个展现给用户的页面。所以视图层即是咱们的工做之始。前端领域不断地快速发展,最终都是为了解答 如何更高效地开发更完美的页面。而 React 就是答案之一,其中 JSX 即是重要的第一站。

那什么是 JSX 呢?

JSX 就是在 JS 环境中约定的一种类 HTMLXML 的 动态模板语法,有着极高的 可读性 与 可拓展性,目的是 为了能更便捷地使用 JS 搭建视图结构与布局。

// 这就是 JSX
const jsx = <JSX>Hello World</JSX>
复制代码

这是一种新全新的 JS 语法,不属于标准,成功地把类 HTML 的标签型模板语法引入 JS 中,创造了一种全新高效的开发模式。但即便最新版的 V8 引擎也没法支持,那怎么执行呢?钥匙就是 预编译!得益于 Babel 的强大,咱们能够经过 预编译,将代码编译成浏览器看得懂的 JS

babel-plugin-transform-jsx

这是 Babel 的一款插件,主要的功能就是编译 JSX,直接配置便可 (对编译感兴趣的童鞋,能够继续深刻下了解下 Babel 的编译原理,这里就不做展开了)。

// 打包工具中的 babel loader 配置
use: {
    loader: 'babel-loader',
    options: {
        plugins: [["transform-jsx", { 
            "function": "ReactWebGL.createElement",
            "useVariables": true
        }]],
    }
}
复制代码

配置完毕,咱们先来写段 JSX 试试。因为咱们不使用 DOM 了,视图层被直接绘制于 canvas 上,就天然不能使用常规的 HTML 标签了。咱们先来定个容器标签 (<Container>)。

const jsx = (
    <Container name="parent"> ReactWebGL Hello World <Container name="child"> Child </Container> </Container>
)
复制代码

咦。秒报错。先无论,咱们来看下编译后的文件:

var jsx = ReactGL.createElement({
    elementName: Container,
    attributes: {
        name: 'parent'
    },
    children: [
        "ReactWebGL Hello World", 
        ReactWebGL.createElement({
            elementName: Container,
            attributes: {
                name: "child"
            },
            children: ["Child"]
        }),
    ]
});
复制代码

原来如此,其实 Babel 作的,就是把上面的 JSX 模板代码, 解析并提取出标签的信息后,转换成常规的函数形式。这个函数就是咱们配置中指定的 ReactWebGL.createElement

接下来咱们来看下报错。很明显,是由于在上下文中有一些变量没有进行定义,那接下来咱们先定义上:

// 因为编译是直接 变量传递
// 所以标签名做为一个变量须要先定义为 string;
const Container = 'Container'

// 匹配配置中的 `ReactWebGL.createElement`
const ReactWebGL = {
    createElement(tag) {
        console.log(tag)
    }
}
复制代码

第二站: 天空之城 - Virtual DOM

来到第二站: 什么是 虚拟DDM 呢?

顾名思义,它并非真正的视图元素,而是将真实的视图元素抽象为一个 Javascript 对象,包含完整描述了一个真实元素的全部信息,但并不具有渲染功能。同时因为 DOM 自己即是 树形结构,所以使用 Javascript 对象便能很好对整个页面结构进行描述。

刚才 JSX 编译后,参数即是一个最简单的 虚拟DOM 对象(咱们称为 VNode):

// 一个最简单的 VNode
{
    // 类型
    type: 'Container'
    // 属性
    props: { 
        name: 'parent'
    }
    // 子级列表
    children: [...]
}
复制代码

这其实只是一个普通的 Javascript 对象,那为何要设计它呢?可能不少人都会有这种观念: 虚拟DOM 快啊,diff 算法很是厉害,直接操做真实的 DOM 消耗很大。

掐指一算,事情并无那么简单,听我细细道来🧐。想象下,当出现如下场景时:

  • 初次渲染:

    • 解析 JSX,生成 虚拟DOM 树,而后通过各类计算,最终调用 DOM 绘制一个个视图元素;

    • 很明显,咱们经过 HTMLinnerHTML 直接建立元素会更快,且白屏时间更短,多了上层的 计算消耗与内存消耗,反而是一种 性能损耗

  • 极小更新:

    • 须要修改一个标题文案,调用 setState 触发更新。此时,React 并不知道新的 state 会引发多大的变更,须要通过全局逐一的 diff 肯定变更的元素再触发更新。

    • 相较而言,获取对应标题元素修改,省去 diff,会更直接高效。那这么说来,虚拟DOM 反而变慢了,那为何还要用它呢?

若是你的场景是 大量且分散 地更新页面中的元素,那 虚拟DOM 能大大减小其业务逻辑的复杂度,达到一个比较高的消耗性价比。直接操做 DOM 元素,一来逻辑复杂,代码健壮性弱;二来错误的操做反而可能致使更差的性能。

因此快慢并非衡量 虚拟DOM 价值的因素,其价值更多在于:

  • 虚拟DOM 实际上是一种 牺牲最小性能与空间,换取 架构优化 的方式,能较大提高项目的可拓展性与可维护性;

  • DOM 的操做进行 集中化管理,更加安全稳定且高效;

  • 渲染逻辑 的解耦,高度的 组件化模块化,提高了代码复用性及开发效率;

  • 虚拟DOM 是一种抽象化的对象,能够对接不一样的渲染层,完成 跨平台渲染。例如 RN / SSR 等方案;

JSX 仅仅是一种 独立的动态模板语法,经过编译转化为 虚拟DOM,跟 React 并没有耦合。所以能够将其 接入到任意环境或者框架,例如咱们也能够在 Vue 中使用 JSX

聊完 虚拟DOM,咱们回归主线。须要先来设计一个最简单的 VNode 结构,以刚才 Babel 编译出的结构为基础,加上额外的值,方便渲染。

// VNode 定义
interface VNode {
    // 标签类型
    type: any,
    // 标签属性
    props: { [key: string]: any },
    // 子级列表
    children: VNode[],
    // 惟一标识
    key: string
    // 获取视图元素
    ref: any
    // 视图元素
    elm: any
    // 文本内容
    text: string | number | undefined
}

// VNode 生产函数
function createVNode(type, props, ref, key, children, elm, text) {
    return {
        type, 
        props, 
        children,
        ref, 
        key,
        elm,
        text,
    }
}
复制代码

如今定义了 VNode 后,咱们就能够开始编写对应的生成函数(createElement)了。

第三站: 转换之桥 - createElement

这个函数就是传说中的 h 函数,用于 模板 到 虚拟DOM 之间的桥梁。在如今的大多数主流 虚拟DOM 库中,都拥有该函数。在 React 中,它是经过 BabelJSX 编译成 h 函数,即 React.createElement。而在 Vue 中,则是经过 vue-loader<template> 编译成其 h 函数。该函数主要功能是:

加工生成完整的 虚拟DOM树,用于后面的渲染。

function createElement(tag) {
    const { elementName: type, attributes: data, children } = tag
    const { key, ref, ...props } = data
    
    // 处理文本内容
    let text
    
    // 处理子级列表中的 string or number
    // 一样转换为 VNode
    if (children && children.length) {
        let i, l = children.length
        for (i = 0; i < l; ++i) {
            const child = children[i]
            if (['string', 'number'].includes(typeof child)) {
                if (type === 'Text') {
                    // 基于 WebGL 的须要,区别于 DOM
                    // 这里新增一个 <Text> 标签,vnode.type === 'Text'
                    // 需特殊处理
                    if (text === undefined) text = ''
                    text += String(child)
                } else {
                    // 非标签的文字节点, vnode.type === undefined
                    // 例如 <Container>Text</Container>
                    // 中间的 Text 实际上是一样须要一个文字元素
                    children[i] = vnode(
                    	undefined, 
                    	{}, 
                    	undefined, 
                    	undefined, 
                    	undefined, 
                    	undefined, 
                    	String(children[i])
                    )
                }
            }
        }
    }
    return vnode(type, props, ref, key, children, undefined, text)
}
复制代码

执行,Perfect!打印下 JSX,能够看到一棵转换后的 VNode Tree,而且包含咱们模板标签中的全部完整信息。

图1. VNode Tree

WebGL API

为了更好地 解耦视图层,咱们把须要用到的一些与 WebGL 相关的 API 简单包裹下:

const Api = {
    // 根据 标签类型 建立 视图元素
    createElement(vnode) {
        return new PIXI[vnode.type]()
    },
    // 建立、设置 文本元素
    createTextElement(vnode) {
        const { text: content = '', style } = vnode
        return new PIXI.Text(content, style)
    },
    setTextContent(elm, content) {
        if (elm && ['string', 'number'].includes(typeof content)) {
            elm.text = content
        }
    },
    // 获取父级
    parentNode(elm) {
        return elm && elm.parent
    },
    // 添加、删除子级
    appendChild(parent, child) {
        parent.addChild(child)
    },
    removeChild(parent, child) {
        if (child && child.parent) {
            parent.removeChild(child)
        }
    },
    // 获取下一个兄弟元素
    nextSibling(elm) {
        const parent = Api.parentNode(elm)
        if (parent) {
            const index = parent.children.indexof(elm)
            return parent.children[index + 1]
        } else {
            return undefined
        }
    },
    // 插入到指定元素以前
    insertBefore(parentElm, newElm, referenceElm) {
        if (referenceElm) {
            const refIndex = parentElm.children.indexOf(referenceElm)
            parentElm.addChildAt(newElm, refIndex)
        } else {
            Api.appendChild(parentElm, newElm)
        }
    },

}
复制代码

这一部分能够称为 对接层,能够对接到各个平台,若是使用原生 DOMAPI,则就是 Web 渲染,有点相似于 react-dom 所完成的事。

为了演示方便,咱们就使用 pixi.js 来做为 渲染接口。这里与 WebGL 库无关,能够对接到 任意渲染框架

第四站: 创造之柱 - Render

有了 接口层 APIVNode 后,能够开始 建立真实视图元素,并同时根据 vnode.props 同步属性并绑定事件。最后 递归建立子级:

// 根据 vnode 建立 视图元素
function createElm(vnode) {
    const { children, type, text, props } = vnode
    
  	 // vnode 的 type 为字符串时,表示其为 元素节点 
  	 // 依照 type 建立 视图元素
    if (type && typeof type === 'string') {
        // 调用接口 
        vnode.elm = Api.createElement(vnode) 
        
        // 递归建立子级元素,并添加到父元素中
        if (Array.isArray(children)) {
            // 建立子级
            let i, l = children.length
            for (i = 0; i < l; ++i) {
                Api.appendChild(vnode.elm, createElm(children[i]))
            }
        }
    } else if (type === undefined && text) {
        // 被非 <Text> 包裹的文字节点
        vnode.elm = Api.createTextElement(vnode)
    }
    
    // 元素建立成功时,执行设置属性
    if (vnode.elm) setProps(vnode.elm, props)
    
    return vnode.elm
}

// 属性处理
// 将 虚拟DOM 上的属性同步设置到 上面建立的真实元素 elm 上
function setProps(elm, props) {
    if (elm && typeof props === 'object') {
        const keys = Object.keys(props)
        let l = keys.length, i
        for (i = 0; i < l; i++) {
            const key = keys[i]
            const value = props[key]

            if (key.startsWith('on')) {
                // 事件绑定
                if (typeof value === 'function') {
                    const evName = key.substring(2).toLowerCase()
                    elm.on(evName, value)
                }
            } else {
                // 属性设置
                elm[key] = value
            }
        }
    }
}
复制代码

最后,咱们能够开始渲染 VNode

function render(vnode, parent) {
	 // 根据 vnode 建立出对应的元素
    const elm = createElm(vnode)
    
	 // 并添加到容器中便可 
    Api.appendChild(parent, elm)
    
    return elm
}
复制代码

大功告成,到这里咱们已经成功完成把 JSX 的初次渲染了。那下一步的需求就是: 如何更新视图呢? 这里就是传说中的 Diff 算法的用武之地了!

第五站: 时空之匙 - Diff

说到 虚拟DOM 就立刻能提到其核心的 diff 算法。当咱们经过 setState 去更新组件时,是从新生成一棵全新的完整 虚拟DOM树,此时就须要对比 新旧两棵树的差别点,再针对性更新。也就是一种 计算得出两个对象差别 的算法。

这种 diff 算法其实使用的场景不少,例如咱们很熟悉的代码版本控制。先后两份提交的代码须要比对出差别点,而后进行更新保存。只不过这里的 代码文件 变成了 虚拟DOM。因为 虚拟DOM 至关复杂,包含很是多的属性,而且可能拥有很是深的层级,所以若是用常规的循环递归去比较,时间复杂度为 O(n^3),这性能是没法接受的。

所以天才工程师们基于 Web 视图渲染的一些特征,在比对上经过 制定规则,选择性取舍,大大优化了算法的效率。包含如下三种优化策略:

1. 同层比对策略

因为在大部分 Web 视图渲染中,咱们不多会去跨层级移动元素,移动元素一般出如今同层级的列表中,所以这里能够有一个优化策略:

只作同层级的比对,忽略跨层级的元素移动

传统的 diff 算法须要两层循环,每两个节点之间都须要进行对比。而制定了同层比对后,节点只须要跟同一层级的节点进行比对,以下图所示。

图2. diff 比对策略

此时,性能已经大大的提高了,时间复杂度优化成 O(n^2)。另外,若是真出现跨层级移动时,会直接将旧元素删除,在新的位置从新建立,也能保证更新的准确行。但可能会致使状态的丢失。

2. 惟一标识策略

虽然咱们作了同层比对的优化,但此时有一个问题:

例如图2,当 C1 / C2 交换位置,咱们在循环比对时,因为它们均属于相同类型的节点,单单经过 type 并没有法正确区分,没法识别出位置的移动。只能作到把 C1 修改为 C2,把 C2 修改为 C1。这样不只会 损耗性能,并且可能致使 状态丢失

最优的方式应该就是: 把 C1C2 正确交换位置。关键点就在于: 如何正确识别与区分节点。所以这里便引入了 key 做为惟一标识,用 type + key 即可确切地识别出节点的准确位置,从而将时间复杂度优化成了 O(n)

3. 组件模式策略

在复杂度方面,已经有了有效的优化。接下来,即是从逻辑层进行优化。

首先一个最大的损耗就是: 没法准肯定位目标节点,须要 树遍历 寻找更新的目标节点。

如图2,咱们只要更新 D节点,却必须从最根级的 A节点 逐层 diff,完整比对新旧两棵 虚拟树,这里有明显的无谓损耗。若是将 D节点 抽离成一个独立的模块,则能够只调用 D节点 自身的 diff。 所以便引入了 组件模式,可以 碎片化 虚拟DOM

若是咱们确实须要同时更新 A / D 节点呢?其实左边分支 B1 节点并不须要更新,不须要 diff。若是能让 B1 节点拥有一个标识标识本身为非更新目标,在更新流中能够 主动打断更新流。那就能够只 diff A -> B2 -> D 这条线了。这里,即是咱们熟知的 shouldComponentUpdate

Diff 的实现

首先咱们先来梳理下两个 VNode diff 可能出现的状况:

  • 非同类型节点:
    • 直接 建立新元素替换旧元素
  • 同类型节点:
    • 更新属性、事件
    • 递归更新子级列表

图3. diff 流程图

根据这个流程图,咱们能够先从入口开始实现。

function diff(oldVNode, newVNode) {
    if (isSameVNode(oldVNode, newVNode)) {
        // 开始 diff
        // diffVNode
        ...
    } else {
        // 新节点替换旧节点
        // replaceVNode
        ...
    }
}

// 根据 type || key 判断是否为同类型节点
function isSameVNode(oldVNode, newVNode) {
    return oldVNode.key === newVNode.key && oldVNode.type === newVNode.type
}
复制代码

1. 当新旧节点不一样时,直接替换 (replaceVNode)

function replaceVNode(oldVNode, newVNode) {
    // 移除旧元素
    const { elm: oldElm } = oldVNode
    const parent = Api.parentNode(oldElm)
    Api.removeChild(parent, oldElm)

    // 建立新元素,并添加到父级中
    const newElm = createElm(newVNode)
    Api.appendChild(parent, newElm)
}
复制代码

2. 当新旧节点为同一节点时,开始正式的比对 (diffVNode)

根据上面的流程图,咱们也能明白这里咱们要作如下这些事:

  • 比对属性与事件 (diffProps);

  • 递归比对子级列表: 这里也有三种状况;

    • 新旧节点 均有子级列表,则进入 列表比对 (diffChildren);

    • 旧节点没有 子级,新节点有 子级,则 直接 新增 新子级 (addVNodes);

    • 旧节点有 子级,新节点没有 子级,则 直接 删除 旧子级 (removeVNodes);

根据以上梳理,咱们先来实现 diffVNode 这个函数。

function diffVNode(oldVNode, newVNode) {
    const { elm, children: oldChild, text: oldText, props: oldProps } = oldVNode
    const { children: newChild, text: newText, props: newProps } = newVNode

    if (oldVNode === newVNode || !elm) return

    // 已判断为同一节点,目的为 更新元素
    // 所以直接复用旧元素
    newVNode.elm = elm
    
        
    // 比对属性与事件
    diffProps(elm, oldProps, newProps)

    const hasOldChild = !!(oldChild && oldChild.length)
    const hasNewChild = !!(newChild && newChild.length)
     
    // 判断为 元素节点 或者 文字节点
    if (newText === undefined) {
        // 元素节点
        
        // 判断如何更新子级
        if (hasOldChild && hasNewChild) { 
            // 新旧节点均存在子级列表时,直接 diff 列表
            if (oldChild !== newChild) {
                // diff 列表
                diffChildren(elm, oldChild, newChild)
            }
        } else if (hasNewChild) {
            // 旧节点 不包含子级,而新节点包含子级
            // 则直接新增新子级
            addVNodes(elm, null, newChild, 0, newChild.length - 1)
        } else if (hasOldChild) {
            // 新子级不包含元素,而旧节点包含子级
            // 则须要删除旧子级
            removeVNodes(elm, oldChild, 0, oldChild.length - 1)
        } else if (oldText !== undefined) {
            // 当新旧均无子级
            // 这里有可能存在 <Text> 标签,且新内容为空
            // 所以直接清空旧元素文字
            Api.setTextContent(elm, '')
        }
    } else if (oldText !== newText) {
        // 文字节点
        // 当新旧文字内容不一样时,直接修改内容
        Api.setTextContent(elm, newText)
    }
}

// 更新属性
function diffProps(elm, oldProps, newProps) {
    if (oldProps === newProps || !elm) return
    if (typeof oldProps === 'object' && typeof newProps === 'object') {
        let keys = Object.keys(oldProps), i, l = keys.length
        
        // 重置被删除的旧属性
        for (i = 0; i < l; i++) {
            const key = keys[i]
            const oldValue = oldProps[key], newValue = newProps[key]

            if (key.startsWith('on')) {
                /* * 当存在旧事件,且新旧值不一致时 * 事件解绑 */
                if (typeof oldValue === 'function' && oldValue !== newValue) {
                    const evName = key.substring(2).toLowerCase()
                    elm.off(evName, oldValue)
                }
            } else {
                /* * 属性被赋值时会被自动重置 * 只须要重置被删除的属性便可 */
                if (newValue === undefined) {
                    // 元素属性默认值
                    elm[key] = DEFAULT_PROPS[key]
                }
            }
        }
        // 设置新属性
        setProps(elm, newProps)
    }
}
复制代码

比对子级列表 (diffChildren)

其实比对属性、事件都是相对简单的,而 子级列表的比对,才是整个 diff 算法中最核心且最考验性能的部分,所以这里的列表比对算法决定了整个更新渲染的性能。在 虚拟DOM 刚出现时,使用的是比较简单的 深度优先(DFS) + 排序比对 的方式。后来出现了更为高效且沿用至今的 两端比对算法 + Key值比对,直接把 diff 的效率提升了一个层级,且更好理解,有三种优先级不一样的比对策略:

  • 优先重新旧列表的 两端四个节点 开始进行 两两比对

  • 若是均不匹配,则尝试 key 值比对

    • key 值 匹配上,则移动并更新节点;

    • 如 未匹配上,则在对应的位置上 新增新节点

  • 最后所有比对完后,列表中 剩余的节点 执行 删除或新增

这里这么说你们是否是一脸懵🤣。没事,先稍微理解下就行。咱们接下来直接用动画来更直观地看下两端比较算法的具体过程。这里就以刚才 图2 的子级列表为例,即:

  • oldChildren 包含 5 个子级节点: [A, B, C, D, E]

  • newChildren 的子级修改成: [F, C, B, D, A]

Tips:

如新旧列表中的 A,表明这两个节点为 同类型节点,即节点的 type / key 均相等;

第一轮比对:

图4. diff 第一轮循环

  • 1. 优先重新旧列表的两端正向开始,不相同: A !== FE !== A

  • 2. 两端交叉比对,发现 旧列表的第一项与新列表的末项相同 (isSameVNode):

    • 把旧列表中的第一项 移动 到最后一项;

    • 继续递归 diff 新旧 A

第二轮比对:

图5. diff 第二轮循环

  • 1. 一样,优先从两端正向比对,B !== F & E !== D

  • 2. 两端交叉比对,B !== D & E !== F

  • 3. 进入 key 比对:

    • 固定取 新列表首项 (F)

    • 循环与旧列表 key列表 逐项比对,均 没法匹配

    • 所以为 新节点,直接 建立并添加到旧列表首项

第三轮比对:

图6. diff 第三轮循环

  • 1. 两端正向、交叉比对,不匹配;

  • 2. 进入 key 比对

    • 固定取 新列表首项 (C)

    • 循环与旧列表逐项比对,匹配 到旧列表第二项;

    • 则把旧列表中的第二项 移动到首项,并继续 递归继续 diff 新旧 C

第4、五轮比对:

图7. diff 第四五轮循环

  • 1. 首项比对,匹配成功;

    • 递归继续 diff 新旧 B

    • 移动下标,进入下一轮比对;

  • 2. 一样首项比对匹配;

    • 递归继续 diff 新旧 D

    • 移动下标,进入下一轮比对;

  • 3. 新列表循环已结束,删除 旧列表中的剩余节点;

通过了五轮的比对,旧列表已经被成功更新。为了包含咱们上面解释的三种策略,举例时用的是复杂度较高,较少出现的场景。在平常业务中,大部分都会更简单,性能表现会更好。接下来,咱们把这个算法用代码实现下,采用 双列表游标 + while 循环 的方式:

function diffChildren(parentElm, oldChild, newChild) {

    /** * 更新子级列表 * 双列表游标 + while */
	 
    // 初始化游标
    let oldStartIdx = 0, newStartIdx = 0
    let oldEndIdx = oldChild.length - 1, newEndIdx = newChild.length - 1
    
    // 列表首尾节点
    let oldStartVNode = oldChild[0], oldEndVNode = oldChild[oldEndIdx]
    let newStartVNode = newChild[0], newEndVNode = newChild[newEndIdx]

    let oldKeyToIdx, idxInOld, elmToMove, before

    /** * 当起始游标 < 终止游标时, * 表示列表中仍有未 diff 的节点 * 进入循环 */
    while (oldStartIdx <= oldEndIdx && newStartIdx <= newEndIdx) {
        /** * 1. 排除非有效的节点 * 剔除列表中包含的 undefined || false || null */
        if (oldStartVNode == null) {
            oldStartVNode = oldChild[++oldStartIdx]
        } else if (oldEndVNode == null) {
            oldEndVNode = oldChild[--oldEndIdx]
        } else if (newStartVNode == null) {
            newStartVNode = newChild[++newStartIdx]
        } else if (newEndVNode == null) {
            newEndVNode = newChild[--newEndIdx]
        } else if (isSameVNode(oldStartVNode, newStartVNode)) {
            /** * 2. 正反向两两比对列表首项与末项匹配成功 * 移动游标,递归 diff 两个节点 * 均未匹配上,则进入 3. key 值比对 */
            diff(oldStartVNode, newStartVNode)
            
            oldStartVNode = oldChild[++oldStartIdx]
            newStartVNode = newChild[++newStartIdx]
        } else if (isSameVNode(oldEndVNode, newEndVNode)) {
        
            diff(oldEndVNode, newEndVNode)
            
            oldEndVNode = oldChild[--oldEndIdx]
            newEndVNode = newChild[--newEndIdx]
        } else if (isSameVNode(oldStartVNode, newEndVNode)) {
        
            Api.insertBefore(parentElm, oldStartVNode.elm, Api.nextSibling(oldEndVNode.elm)) 
            diff(oldStartVNode, newEndVNode)
            
            oldStartVNode = oldChild[++oldStartIdx]
            newEndVNode = newChild[--newEndIdx]
        } else if (isSameVNode(oldEndVNode, newStartVNode)) {
        
            Api.insertBefore(parent, oldEndVNode.elm, oldStartVNode.elm)
            diff(oldEndVNode, newStartVNode)
            
            oldEndVNode = oldChild[--oldEndIdx]
            newStartVNode = newChild[++newStartIdx]
        } else {
            /** * 3. 两端比对均不匹配 * 进入 key 值比对 */
            // 根据剩余的旧列表建立 key list
            if (!oldKeyToIdx) {
                oldKeyToIdx = createKeyList(oldChild, oldStartIdx, oldEndIdx)
            }
            
            // 判断新列表项的 key值 是否存在
            idxInOld = oldKeyToIdx[newStartVNode.key || '']
            if (!idxInOld) {
                /* * 4. 新 key 值在旧列表中不存在 * 直接将该节点插入 */
                Api.insertBefore(parentElm, createElm(newStartVNode), oldStartVNode.elm)
                newStartVNode = newChild[++newStartIdx]
            } else {
                /* * 5. 新 key 在旧列表中存在时 * 继续判断是否为同类型节点 */
                elmToMove = oldChild[idxInOld]
                if (isSameVNode(elmToMove, newStartVNode)) {
                    /* * 6. 新旧节点类型一致 * key 有效,直接移动并 diff */
                    Api.insertBefore(parentElm, elmToMove.elm, oldStartVNode.elm)    
                    diff(elmToMove, newStartVNode)
                    
                    // 清空旧列表项
                    // 后续的比对能够直接跳过
                    oldChild[idxInOld] = undefined
                } else {
                    /* * 7. 新旧节点类型不一致 * key 效,直接建立元素并插入 */
                    Api.insertBefore(parentElm, createElm(newStartVNode), oldStartVNode.elm)
                } 
                newStartVNode = newChild[++newStartIdx]
            }
        }
    }

    /* * 8. 当有游标列表为空时,则结束循环,进入策略3 * 当 旧列表为空 时,则建立并插入新列表中的剩余节点 * 当 新列表为空 时,则删除旧列表中的剩余节点 */
    if (oldStartIdx <= oldEndIdx || newStartIdx <= newEndIdx) {
        if (oldStartIdx > oldEndIdx) {
            // 新增节点
            const vnode = newChild[newEndIdx + 1]
            before = vnode ? vnode.elm : null
            addVNodes(parentElm, before, newChild, newStartIdx, newEndIdx)
        } else {
            // 删除节点
            removeVNodes(parentElm, oldChild, oldStartIdx, oldEndIdx)
        }
    }
}
复制代码

恭喜童鞋们~ 咱们完成了传说中的 两端比对算法 咯🥳。其实只要按比对的优先级把逻辑理清楚,逐一执行判断,思路仍是比较清晰的。

实践建议

经过上面真正的代码编写以及了解实现原理后,咱们其实能从中找到一些好的实践方式,从而优化咱们的代码,提升性能。

1.props的传递

JSX 中标签能够传递属性,最简单的方式就是 值传递:

const tmp = <Container text="text" data={{ a: 1 }}>Text</Container>
复制代码

因为在比对的时候,在判断 props 是否发生变化时,采用 全等 的比较。所以,当出现值是 引用对象 时,若是直接像上面这样, data 写在 JSX 中,则每次 diffdata 的值都是全新的对象,不会相等。即便对象属性所有一致,但每次均须要循环比对每一项属性。

所以建议:

  • 当属性值为 引用对象 时,如 ObjectArrayFunction等,直接使用 引用传递:
const data = { a: 1 }
const tmp = <Container text="text" data={data}>Text</Container>
复制代码
  • 同理,当数据须要修改时,不要直接修改源对象,而是应该 遵循数据不可变原则,生成一个新对象。

这样的建议能够有效下降 diff 时的性能损耗,当场景复杂时,收益就比较可观了。

2. 渲染树结构稳定

diff 算法采用的 同层比对 的策略,所以若是是跨层级的移动,就会 从新建立新节点并删除原来的节点,并非真正的移动。因此保证 渲染树结构稳定 能够有效提升性能。

  • 尽可能避免节点的 跨层级移动

  • 如没法避免,则须要考虑 状态同步 的问题;

  • 同层移动一样也会在 diff 时须要额外的循环比对,应该减小没必要要的频繁移动;

3. key 的使用

当须要列表中 VNode同层移动 时,加上惟一标识 key 能有效提升 diff 性能,避免元素的 重渲染

  • 注意确保 VNode.keydiff 先后的 一致,这样才可有效提高性能,避免使用 indexMath.random、时间戳等值;

  • 即便在非循环列表渲染时,给标签添加 key 值一样会生效,不一样的 key 会致使节点被断定为非同类节点,从而进行替换;

4. 组件化

  • 复用性高 且须要 频繁更新 的节点抽离成 组件,会使 VNode Tree 碎片化,从而能更有效地进行 局部更新,减小触发 diff 的节点数量,提升性能且提升代码复用率;

  • 但因为组件的建立和 diff 相比普通节点来讲更为 复杂,须要执行例如生命周期,组件比对 等,因此须要 合理规划,避免 过度组件化 致使 内存的浪费和影响性能,一些 复用率低的静态元素 直接使用元素节点更为合理;

第六站: 休憩之地 - 总结概括

受篇幅所限,本文暂且完成到这里。先来总结回顾下咱们完成的部分:

  • 1. JSX 是一种 动态模板语法,经过 Babel 编译为 createElement 函数;

  • 2. createElementJSX 转换为 虚拟Dom(VNode),包含完整的标签信息 (类型、属性、子级列表);

  • 3. 虚拟DOM 是一种 牺牲最小性能与空间,换取 架构优化 的方式,其 组件化 以及 解耦 的思想,提升了项目的拓展性、复用性与可维护性,同时为 跨平台渲染 奠基基础;

  • 4. 经过实现 createElmrender,完成 VNode初次渲染

  • 5. Diff 是一种 计算得出两个 VNode 差别 的算法,使用 同层比对、惟一标识、组件模式 优化了算法比对性能,将时间复杂度下降为 O(n)

  • 6. 列表比对 (diffChildren) 采用 两端比对算法 + Key值比对 算法,大大提升了 Diff 效率;

  • 7. 实现 diffVNodediffPropsdiffChildren,完成 VNode动态更新

咱们完成了框架最核心的 JSX - Render - Diff 的渲染更新的主机制,奠基了最底层的基础。在下一篇文章中,咱们将继续基于此完成 组件化(Component)组件更新(setState) 以及 生命周期。但愿能帮助你们更了解 React,掌握一些优秀的编程思惟。

这篇文章所完成的类 React 框架是过去几个月我本身在 Web 游戏方面的尝试和探索,目的是指望能 下降传统前端工程师开发游戏的门槛引入前端开发模式提高开发效率,使 前端开发 与 游戏开发 造成必定程度的优点互补。固然这个目标很大,也并不简单,规划中仍然有许多工做要作。有兴趣的小伙伴移步 github 查看完整代码:

react-webgl.js ->

最后,咱们去年下半年在厦门创办了一家技术公司,主要是在 游戏领域K12 STEAM 教育 方向上的努力。很是欢迎 有兴趣想深刻了解 或者 想跟我探讨 的小伙伴直接联系我!🥳~~

Tips:

看博主写得这么辛苦下,跪求点赞、关注、Star!更多文章猛戳 ->

邮箱: 159042708@qq.com 微信/QQ: 159042708

祝福#感恩#武汉加油##RIP KOBE#

相关文章
相关标签/搜索