感谢你们又被个人标题党骗进来了😂。这是我最近几个月亲身探寻过的一趟旅途,感觉颇深。途中也遇到许多困难,但坚持到底,相信最终必定会让各位小伙伴受益不浅,不枉此行!前端
同时也感恩你们对以前一个系列文章的喜欢和修正😘,提了很多的建议和问题,我也会用心写个人每一系列文章,与你们共同成长!!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 也很是有限,并不须要你具有相关的知识储备。编程
做为一名前端,咱们须要实现一个个展现给用户的页面。所以视图层即是咱们的工做之始。前端领域不断地快速发展,最终都是为了解答 如何更高效地开发更完美的页面。而 React
就是答案之一,其中 JSX
即是重要的第一站。
那什么是 JSX
呢?
JSX
就是在 JS
环境中约定的一种类 HTML
或 XML
的 动态模板语法,有着极高的 可读性 与 可拓展性,目的是 为了能更便捷地使用 JS 搭建视图结构与布局。
// 这就是 JSX
const jsx = <JSX>Hello World</JSX>
复制代码
这是一种新全新的 JS
语法,不属于标准,成功地把类 HTML
的标签型模板语法引入 JS 中,创造了一种全新高效的开发模式。但即便最新版的 V8 引擎也没法支持,那怎么执行呢?钥匙就是 预编译!得益于 Babel
的强大,咱们能够经过 预编译,将代码编译成浏览器看得懂的 JS
。
这是 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)
}
}
复制代码
来到第二站: 什么是 虚拟DDM 呢?
顾名思义,它并非真正的视图元素,而是将真实的视图元素抽象为一个 Javascript 对象,包含完整描述了一个真实元素的全部信息,但并不具有渲染功能。同时因为 DOM
自己即是 树形结构,所以使用 Javascript
对象便能很好对整个页面结构进行描述。
刚才 JSX
编译后,参数即是一个最简单的 虚拟DOM 对象(咱们称为 VNode
):
// 一个最简单的 VNode
{
// 类型
type: 'Container'
// 属性
props: {
name: 'parent'
}
// 子级列表
children: [...]
}
复制代码
这其实只是一个普通的 Javascript
对象,那为何要设计它呢?可能不少人都会有这种观念: 虚拟DOM 快啊,diff 算法很是厉害,直接操做真实的 DOM 消耗很大。
掐指一算,事情并无那么简单,听我细细道来🧐。想象下,当出现如下场景时:
初次渲染:
解析 JSX
,生成 虚拟DOM 树,而后通过各类计算,最终调用 DOM
绘制一个个视图元素;
很明显,咱们经过 HTML
或 innerHTML
直接建立元素会更快,且白屏时间更短,多了上层的 计算消耗与内存消耗,反而是一种 性能损耗;
极小更新:
须要修改一个标题文案,调用 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
)了。
这个函数就是传说中的 h
函数,用于 模板 到 虚拟DOM 之间的桥梁。在如今的大多数主流 虚拟DOM 库中,都拥有该函数。在 React
中,它是经过 Babel
将 JSX
编译成 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
简单包裹下:
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)
}
},
}
复制代码
这一部分能够称为 对接层,能够对接到各个平台,若是使用原生 DOM
的 API
,则就是 Web
渲染,有点相似于 react-dom
所完成的事。
为了演示方便,咱们就使用 pixi.js
来做为 渲染接口。这里与 WebGL
库无关,能够对接到 任意渲染框架。
有了 接口层 API 与 VNode
后,能够开始 建立真实视图元素,并同时根据 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
算法的用武之地了!
说到 虚拟DOM 就立刻能提到其核心的 diff
算法。当咱们经过 setState
去更新组件时,是从新生成一棵全新的完整 虚拟DOM树,此时就须要对比 新旧两棵树的差别点,再针对性更新。也就是一种 计算得出两个对象差别 的算法。
这种 diff
算法其实使用的场景不少,例如咱们很熟悉的代码版本控制。先后两份提交的代码须要比对出差别点,而后进行更新保存。只不过这里的 代码文件 变成了 虚拟DOM。因为 虚拟DOM 至关复杂,包含很是多的属性,而且可能拥有很是深的层级,所以若是用常规的循环递归去比较,时间复杂度为 O(n^3)
,这性能是没法接受的。
所以天才工程师们基于 Web 视图渲染的一些特征,在比对上经过 制定规则,选择性取舍,大大优化了算法的效率。包含如下三种优化策略:
因为在大部分 Web 视图渲染中,咱们不多会去跨层级移动元素,移动元素一般出如今同层级的列表中,所以这里能够有一个优化策略:
只作同层级的比对,忽略跨层级的元素移动。
传统的 diff
算法须要两层循环,每两个节点之间都须要进行对比。而制定了同层比对后,节点只须要跟同一层级的节点进行比对,以下图所示。
图2. diff 比对策略
此时,性能已经大大的提高了,时间复杂度优化成 O(n^2)
。另外,若是真出现跨层级移动时,会直接将旧元素删除,在新的位置从新建立,也能保证更新的准确行。但可能会致使状态的丢失。
虽然咱们作了同层比对的优化,但此时有一个问题:
例如图2,当 C1
/ C2
交换位置,咱们在循环比对时,因为它们均属于相同类型的节点,单单经过 type 并没有法正确区分,没法识别出位置的移动。只能作到把 C1
修改为 C2
,把 C2
修改为 C1
。这样不只会 损耗性能,并且可能致使 状态丢失。
最优的方式应该就是: 把 C1
与 C2
正确交换位置。关键点就在于: 如何正确识别与区分节点。所以这里便引入了 key
做为惟一标识,用 type
+ key
即可确切地识别出节点的准确位置,从而将时间复杂度优化成了 O(n)
。
在复杂度方面,已经有了有效的优化。接下来,即是从逻辑层进行优化。
首先一个最大的损耗就是: 没法准肯定位目标节点,须要 树遍历 寻找更新的目标节点。
如图2,咱们只要更新 D
节点,却必须从最根级的 A
节点 逐层 diff
,完整比对新旧两棵 虚拟树,这里有明显的无谓损耗。若是将 D
节点 抽离成一个独立的模块,则能够只调用 D
节点 自身的 diff
。 所以便引入了 组件模式,可以 碎片化 虚拟DOM。
若是咱们确实须要同时更新 A
/ D
节点呢?其实左边分支 B1
节点并不须要更新,不须要 diff
。若是能让 B1
节点拥有一个标识标识本身为非更新目标,在更新流中能够 主动打断更新流。那就能够只 diff
A
-> B2
-> D
这条线了。这里,即是咱们熟知的 shouldComponentUpdate
。
首先咱们先来梳理下两个 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
}
复制代码
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)
}
复制代码
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 !== F
且 E !== 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
;
图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)
}
}
}
复制代码
恭喜童鞋们~ 咱们完成了传说中的 两端比对算法
咯🥳。其实只要按比对的优先级把逻辑理清楚,逐一执行判断,思路仍是比较清晰的。
经过上面真正的代码编写以及了解实现原理后,咱们其实能从中找到一些好的实践方式,从而优化咱们的代码,提升性能。
props
的传递JSX
中标签能够传递属性,最简单的方式就是 值传递:
const tmp = <Container text="text" data={{ a: 1 }}>Text</Container>
复制代码
因为在比对的时候,在判断 props
是否发生变化时,采用 全等 的比较。所以,当出现值是 引用对象 时,若是直接像上面这样, data
写在 JSX
中,则每次 diff
时 data
的值都是全新的对象,不会相等。即便对象属性所有一致,但每次均须要循环比对每一项属性。
所以建议:
Object
、Array
、Function
等,直接使用 引用传递:const data = { a: 1 }
const tmp = <Container text="text" data={data}>Text</Container>
复制代码
这样的建议能够有效下降 diff
时的性能损耗,当场景复杂时,收益就比较可观了。
diff
算法采用的 同层比对 的策略,所以若是是跨层级的移动,就会 从新建立新节点并删除原来的节点,并非真正的移动。因此保证 渲染树结构稳定 能够有效提升性能。
尽可能避免节点的 跨层级移动;
如没法避免,则须要考虑 状态同步 的问题;
同层移动一样也会在 diff
时须要额外的循环比对,应该减小没必要要的频繁移动;
当须要列表中 VNode
的 同层移动 时,加上惟一标识 key
能有效提升 diff
性能,避免元素的 重渲染。
注意确保 VNode.key
在 diff
先后的 一致,这样才可有效提高性能,避免使用 index
、 Math.random
、时间戳等值;
即便在非循环列表渲染时,给标签添加 key
值一样会生效,不一样的 key
会致使节点被断定为非同类节点,从而进行替换;
复用性高 且须要 频繁更新 的节点抽离成 组件,会使 VNode Tree
碎片化,从而能更有效地进行 局部更新,减小触发 diff
的节点数量,提升性能且提升代码复用率;
但因为组件的建立和 diff
相比普通节点来讲更为 复杂,须要执行例如生命周期,组件比对 等,因此须要 合理规划,避免 过度组件化 致使 内存的浪费和影响性能,一些 复用率低的静态元素 直接使用元素节点更为合理;
受篇幅所限,本文暂且完成到这里。先来总结回顾下咱们完成的部分:
1. JSX
是一种 动态模板语法,经过 Babel
编译为 createElement
函数;
2. createElement
将 JSX
转换为 虚拟Dom(VNode),包含完整的标签信息 (类型、属性、子级列表);
3. 虚拟DOM 是一种 牺牲最小性能与空间,换取 架构优化 的方式,其 组件化 以及 解耦 的思想,提升了项目的拓展性、复用性与可维护性,同时为 跨平台渲染 奠基基础;
4. 经过实现 createElm
与 render
,完成 VNode
的 初次渲染;
5. Diff
是一种 计算得出两个 VNode 差别 的算法,使用 同层比对、惟一标识、组件模式 优化了算法比对性能,将时间复杂度下降为 O(n)
;
6. 列表比对 (diffChildren
) 采用 两端比对算法 + Key值比对 算法,大大提升了 Diff
效率;
7. 实现 diffVNode
、diffProps
、diffChildren
,完成 VNode
的 动态更新;
咱们完成了框架最核心的 JSX
- Render
- Diff
的渲染更新的主机制,奠基了最底层的基础。在下一篇文章中,咱们将继续基于此完成 组件化(Component
)、组件更新(setState
) 以及 生命周期。但愿能帮助你们更了解 React,掌握一些优秀的编程思惟。
这篇文章所完成的类 React
框架是过去几个月我本身在 Web
游戏方面的尝试和探索,目的是指望能 下降传统前端工程师开发游戏的门槛 和 引入前端开发模式提高开发效率,使 前端开发 与 游戏开发 造成必定程度的优点互补。固然这个目标很大,也并不简单,规划中仍然有许多工做要作。有兴趣的小伙伴移步 github 查看完整代码:
最后,咱们去年下半年在厦门创办了一家技术公司,主要是在 游戏领域 和 K12 STEAM 教育 方向上的努力。很是欢迎 有兴趣想深刻了解 或者 想跟我探讨 的小伙伴直接联系我!🥳~~
Tips:
看博主写得这么辛苦下,跪求点赞、关注、Star!更多文章猛戳 ->
邮箱: 159042708@qq.com 微信/QQ: 159042708