都9102年了,或许这类的文章已经出现了不少,但依旧本身作一个记录吧。如若您愿意阅读更多个人我的笔记,能够访问 个人博客 或 个人博客仓库.javascript
虚拟 Dom(virtual Dom)正如其名,它并非真正的 Dom 对象,但能够根据虚拟 Dom 来转换为真正的 Dom 对象。css
虚拟 Dom 实际上是一个 JavaScript 对象,对于下面所示的 Dom 结构:html
<ul class="lists">
<li>1</li>
<li class="item">2</li>
<li>3</li>
</ul>
复制代码
该 Dom 结构所对应的 JavaScript 对象能够是这样的:前端
const virtualDom = {
type: 'ul'
props: {
class: 'lists'
},
children: [
{
type: 'li'
props: {},
children: ['1']
},
{
type: 'li'
props: { class: 'item' },
children: ['2']
},
{
type: 'li'
props: {},
children: ['3']
}
]
}
复制代码
这种可以表示 Dom 的 JavaScript 对象,就是虚拟 Dom。java
在建立新元素时,React 会首先建立出虚拟 Dom,而后根据虚拟 Dom 的表示,通过 render 方法转换为真实的 Dom。node
然后续有关界面上的交互,也是做用在虚拟 Dom 上,触发虚拟 Dom 的更新,从而引发真实 Dom 的更新。react
咱们在书写 React 组件时可使用两种语法:git
JSXgithub
React.createElement算法
JSX 是 React 提供的一个语法糖,借助 Babel 工具,使开发者可使用更方便的语法形式来书写,实际上这两种方式是等价的。换句话说,JSX 在通过 Babel 的转换后,会使用 React.createElement()
这一方法。
该方法主要作的就是建立一个对象,来描述 Dom 信息,能够建立一个构造函数来保存,并经过 new 关键字去实例化。
function Element(type, config, children) {
this.type = type;
this.props = config;
this.children = children;
}
function createElement(type, config, children) {
return new Element(type, config, children);
}
复制代码
使用时须要调用 createElement
方法:
let virtualDom1 = createElement("ul", { class: "lists" }, [
createElement("li", {}, ["1"]),
createElement("li", { class: "item" }, ["2"]),
createElement("li", {}, ["3"]),
]);
console.log(virtualDom1);
复制代码
虚拟 Dom 须要经过一个 render 方法,将虚拟 Dom 对象转换为真实 Dom。
function setAttr(node, key, value) {
switch(key) {
case "value":
if (node.tagName.toUpperCase === 'INPUT' || node.tagName.toUpperCase === "TEXTAREA") {
node.value = value;
} else {
node.setAttribute(key, value);
}
break;
case "style":
node.style.cssText = value;
break;
default:
node.setAttribute(key, value);
break;
}
}
function render(eleObj) {
// 建立Element
let el = document.createElement(eleObj.type);
// 遍历属性并设置
for (let key in eleObj.props) {
setAttr(el, key, eleObj.props[key]);
}
// 遍历孩子节点,并建立(若是是Element构造函数,则递归调用render方法,不然建立一个文本节点)
eleObj.children.forEach(child => {
if (child instanceof Element) {
child = render(child);
} else {
child = document.createTextNode(child);
}
el.appendChild(child);
});
return el;
}
复制代码
调用
let virtualDom1 = createElement("ul", { class: "lists" }, [
createElement("li", {}, ["1"]),
createElement("li", { class: "item" }, ["2"]),
createElement("li", { style: "color: red;" }, ["3"]),
]);
let dom = render(virtualDom1);
console.log(dom);
复制代码
要令 dom 显示在页面上,那么还须要最后作一次append操做:
// 这里只是最简单的插入到了body中,实际上还存在经过id选择root节点,再将dom插入到root节点中
document.body.appendChild(dom);
复制代码
以上咱们就简单的实现了一个虚拟Dom。
简单实现中并无包括 ref、key 等内容,若是你想了解更多,推荐阅读源码解析相关文章,这边推荐一篇文章:
React 经过 patch 补丁的形式来更新现有的 Dom,所谓的 patch 补丁,其实也是一个对象,这个对象描述了虚拟 Dom 树须要作出怎么样的修改。它的形式相似于:{ type: 'REPLACE', node: newNode }
。
上面那种形式的补丁,告诉咱们此处须要替换内容。那么根据这个补丁,所对应的依旧是Dom操做。
patch 补丁来源于 Dom Diff,Diff 则发生在新旧的虚拟 Dom 树上。
经过对比新旧虚拟 Dom 树,计算出差别,产生 patch 补丁,这些补丁也就是若是将旧的 Dom 树更新为新的 Dom 树的所须要作出的 Dom 操做。
使用虚拟 Dom 不必定会变得更快。虚拟 Dom 是 Dom 的 JavaScript 表示,在事件发生时,经过对比新旧虚拟 Dom 得出更新(经过 Diff 算法得到 patch 补丁),这是一系列转换、分析、计算的过程。
对于一个很简单的场景(点击按钮,页面显示的数字增长),直接操做 dom 将会是更快的,由于在一系列的分析计算后,所产生的 patch 补丁也将是这样的 dom 操做。尽管这个过程可能并不久,但依旧经历了额外的分析计算过程。
对于复杂场景,虚拟 Dom 会是更快的,页面性能所最重要的地方也就是重排、重绘,频繁的 Dom 操做所带来的页面开销将是巨大的。在通过 Diff 的分析计算后,产生 patch 补丁,将会简化 Dom 操做(可能并非最优的),极大的减小没必要要的、重复的 Dom 操做。
Diff 采用先序深度优先遍从来观察差别,所谓先序深度优先,也就是先遍历根节点,其次是子节点(对于二叉树是根、左、右)。
const diffHelper = {
Index: 0
}
function dfs(tree) {
console.log(tree.type, diffHelper.Index);
dfsChildren(tree.children);
}
function dfsChildren(nodeArray) {
nodeArray.forEach(node => {
// 每一个节点都占用一个编号
++diffHelper.Index;
if (node.type) {
// 是节点,递归调用
dfs(node);
} else {
// 文本节点
console.log(node, diffHelper.Index);
}
})
}
复制代码
对比两棵树的差别是 Diff 算法最核心的部分。
两棵树彻底 Diff(对比父节点、自身、子节点是否彻底一致)的时间复杂度是 O(n^3),因为前端中跨层级移动节点的场景较少,所以 React 的 Diff 算法中利用同级比较(只比较同级元素)巧妙的将时间复杂度下降至 O(n)。
同层级比较规则:
const diffHelper = {
Index: 0,
isTextNode: (eleObj) => {
return !(eleObj instanceof Element);
},
diffAttr: (oldAttr, newAttr) => {
let patches = {}
for (let key in oldAttr) {
if (oldAttr[key] !== newAttr[key]) {
// 可能产生了更改 或者 新属性为undefined,也就是该属性被删除
patches[key] = newAttr[key];
}
}
for (let key in newAttr) {
// 新增属性
if (!oldAttr.hasOwnProperty(key)) {
patches[key] = newAttr[key];
}
}
return patches;
},
diffChildren: (oldChild, newChild, patches) => {
if (newChild.length > oldChild.length) {
// 有新节点产生
patches[diffHelper.Index] = patches[diffHelper.Index] || [];
patches[diffHelper.Index].push({
type: PATCHES_TYPE.ADD,
nodeList: newChild.slice(oldChild.length)
});
}
oldChild.forEach((children, index) => {
dfsWalk(children, newChild[index], ++diffHelper.Index, patches);
});
},
dfsChildren: (oldChild) => {
if (!diffHelper.isTextNode(oldChild)) {
oldChild.children.forEach(children => {
++diffHelper.Index;
diffHelper.dfsChildren(children);
});
}
}
}
const PATCHES_TYPE = {
ATTRS: 'ATTRS',
REPLACE: 'REPLACE',
TEXT: 'TEXT',
REMOVE: 'REMOVE',
ADD: 'ADD'
}
function diff(oldTree, newTree) {
// 当前节点的标志 每次调用Diff,从0从新计数
diffHelper.Index = 0;
let patches = {};
// 进行深度优先遍历
dfsWalk(oldTree, newTree, diffHelper.Index, patches);
// 返回补丁对象
return patches;
}
function dfsWalk(oldNode, newNode, index, patches) {
let currentPatches = [];
if (!newNode) {
// 若是不存在新节点,发生了移除,产生一个关于 Remove 的 patch 补丁
currentPatches.push({
type: PATCHES_TYPE.REMOVE
});
// 删除了但依旧要遍历旧树的节点确保 Index 正确
diffHelper.dfsChildren(oldNode);
} else if (diffHelper.isTextNode(oldNode) && diffHelper.isTextNode(newNode)) {
// 都是纯文本节点 若是内容不一样,产生一个关于 textContent 的 patch
if (oldNode !== newNode) {
currentPatches.push({
type: PATCHES_TYPE.TEXT,
text: newNode
});
}
} else if (oldNode.type === newNode.type) {
// 若是节点类型相同,比较属性差别,如若属性不一样,产生一个关于属性的 patch 补丁
let attrs = diffHelper.diffAttr(oldNode.props, newNode.props);
// 有attr差别
if(Object.keys(attrs).length > 0) {
currentPatches.push({
type: PATCHES_TYPE.ATTRS,
attrs: attrs
});
}
// 若是存在孩子节点,处理孩子节点
diffHelper.diffChildren(oldNode.children, newNode.children, patches);
} else {
// 若是节点类型不一样,说明发生了替换
currentPatches.push({
type: PATCHES_TYPE.REPLACE,
node: newNode
});
// 替换了但依旧要遍历旧树的节点确保 Index 正确
diffHelper.dfsChildren(oldNode);
}
// 若是当前节点存在补丁,则将该补丁信息填入传入的patches对象中
if(currentPatches.length) {
patches[index] = patches[index] ? patches[index].concat(currentPatches) : currentPatches;
}
}
复制代码
调用
let virtualDom1 = createElement("ul", { class: "lists" }, [
createElement("li", {}, ["1"]),
createElement("li", { class: "item" }, ["2"]),
createElement("li", { style: "color: red;" }, ["3"])
]);
let virtualDom2 = createElement("ul", {}, [
createElement("div", {}, ["1"]),
createElement("li", { class: "item" }, ["这里变了"]),
createElement("li", { style: "color: blue;" }, [
createElement("li", {}, ["3-1"]),
]),
createElement("li", {}, ["1"]),
]);
console.log(diff(virtualDom1, virtualDom2));
复制代码
执行结果以下图所示:
上面的形式对于列表存在比较大的缺陷:改变顺序的列表,所产生的开销将是巨大的。
举例来讲,对于下面的两个 dom,其实发生的是一个顺序的变化,可是在同级比较中,会产生2个替换的 patch 补丁(将3替换为4,将4替换为3),实际上最优的 dom 操做,是进行移动,将3移动到4的位置。
<ul>
<li>1</li>
<li>2</li>
<li>3</li>
<li>4</li>
</ul>
<ul>
<li>1</li>
<li>2</li>
<li>4</li>
<li>3</li>
</ul>
复制代码
React 引入 key
属性来进行列表层面的 diff 判断。
若是在书写 React 列表时,你没有给列表的每一项设置一个 key
值,那么在控制台上将会打印出一则警告,这是 React 在告诉你它没法高效的进行列表层面的 Diff 判断。
未引入 key
时,React 将采用咱们刚才介绍的方式进行 Diff。
如上图所示,C 将会被替换成 F,D 将会被替换成 C,E 将会别替换成 D,同时新增了一个 E。
使用 key
属性后,React Diff 算法将能够复用元素(key
一致时且标签类型一致时,认为是同一元素)
经过算法分析将能够知道 A、B、C、D、E 均未发生改变,所以会得到一个有关插入的 patch 补丁。它的形式可能相似于:
{
type: 'REORDER',
moves: [{remove or insert}, {remove or insert}, ...]
}
复制代码
这个 patch 补丁所对应的 dom 操做能够是:
删除元素 element.removeChild()
在某一元素前面增长元素 element.insertBefore()
这一部分的代码将不会在本篇进行讲述。
经过 diff 算法能够获得 patch 补丁对象,如今咱们就能够根据 patch 补丁对象进行修补补丁。
let patches = diff(virtualDom1, virtualDom2);
patch(dom, patches);
复制代码
补丁对象的形式以下,咱们能够从中得知第 n 个节点须要打的补丁。
patches = {
0: [{
type: 'ATTR',
attrs: {
class: undefined
}
}],
3: [{
type: 'TEXT',
text: "这里变了"
}]
}
复制代码
咱们要执行更新,也要作一遍先序深度优先遍历,并执行相关的补丁操做。
const patchHelper = {
Index: 0
}
function patch(node, patches) {
dfsPatch(node, patches);
}
function dfsPatch(node, patches) {
let currentPatch = patches[patchHelper.Index++];
node.childNodes.forEach(child => {
dfsPatch(child, patches);
});
if (currentPatch) {
doPatch(node, currentPatch);
}
}
function doPatch(node, patches) {
patches.forEach(patch => {
switch (patch.type) {
case PATCHES_TYPE.ATTRS:
for (let key in patch.attrs) {
if (patch.attrs[key] !== undefined) {
setAttr(node, key, patch.attrs[key]);
} else {
node.removeAttribute(key);
}
}
break;
case PATCHES_TYPE.TEXT:
node.textContent = patch.text;
break;
case PATCHES_TYPE.REPLACE:
let newNode = patch.node instanceof Element ? render(patch.node) : document.createTextNode(patch.node);
node.parentNode.replaceChild(newNode, node);
break;
case PATCHES_TYPE.REMOVE:
node.parentNode.removeChild(node);
break;
case PATCHES_TYPE.ADD:
patch.nodeList.forEach(newNode => {
let n = newNode instanceof Element ? render(newNode) : document.createTextNode(newNode);
node.appendChild(n);
});
break;
default:
break;
}
})
}
复制代码
这一部分大量引用了 Deep In React 之浅谈 React Fiber 架构(一) 的文章内容,您也能够直接阅读这一篇内容来了解 Fiber 的相关内容。
React 主要有两个阶段:
调和阶段(Reconciler):React 经过先序深度优先遍历生成 Virtual DOM,而后经过 Diff 算法,得到变动补丁(Patch),放到更新队列里面去。
渲染阶段(Renderer):遍历更新队列,经过调用宿主环境的API,实际更新渲染对应元素。宿主环境,好比 DOM、Native、WebGL 等。
更多关于调和阶段的解释能够点击 这里
从刚才咱们的实现来看,表明了调和阶段一旦开始,就没法 中断。该功能将一直占用主线程, 一直要等到整棵 Virtual DOM 树计算完成以后,才能把执行权交给渲染引擎。
这样的状况致使一些用户交互、动画等任务没法当即获得处理,容易形成卡顿、失帧等现象,影响用户体验。
Fiber 的诞生正是为了解决这个问题。
为了解决这个问题,有如下几个可供改进的地方:
为了作到这些,咱们首先须要一种方法将任务分解为单元。从某种意义上说,这就是 Fiber,Fiber 表明一种工做单元。
Fiber 就是从新实现的堆栈帧,本质上 Fiber 也能够理解为是一个 虚拟的堆栈帧,将可中断的任务拆分红多个子任务,经过按照优先级来自由调度子任务,分段更新,从而将以前的同步渲染改成异步渲染。
因此咱们能够说 Fiber 是一种数据结构(堆栈帧),也能够说是一种解决可中断的调用任务的一种解决方案,它的特性就是 时间分片(time slicing)和暂停(supense)。
关于 Fiber,本篇再也不展开讲述,这里说起只是为了说明在 Fiber 架构引入后,React的 diff 将会在浏览器有“空闲”的时候进行可中断的执行。
本文代码你能够在 个人Github仓库 中找到。