在前端技术蓬勃发展的上古时代,前端开发主要是一些静态页面,使用 ajax、jQuery 等命令式的完成一些对 DOM 的操做,而伴随着前端工程化的不断发展,涌现了诸如 angular、react 等一系列 MVVM 模式的前端框架,这些框架公有的特色就是再也不关心具体 DOM 的操做,而是把重点放在了基于数据状态的操做,一旦数据更改,跟它绑定的那个地方的 DOM 也会跟着变化。这种声明式的开发方式极大的增长了开发体验,更好的帮助咱们完成组件复用、逻辑解耦等。html
借助于上面提到的前端框架,咱们不用再主动的对 DOM 进行操做,框架在背后已经替咱们作了,咱们只须要关心应用的数据便可。而Virtual DOM
(虚拟 DOM)的概念就是在此期间因为其在React
框架中的使用而变得流行起来。那么到底什么是Virtual DOM
呢?前端
引用 react 官网上的介绍:node
Virtual DOM 是一种编程概念。在这个概念里, UI 以一种理想化的,或者说“虚拟的”表现形式被保存于内存中,并经过如 ReactDOM 等类库使之与“真实的” DOM 同步。这一过程叫作协调。react
这种方式赋予了 React 声明式的 API:您告诉 React 但愿让 UI 是什么状态,React 就确保 DOM 匹配该状态。这使您能够从属性操做、事件处理和手动 DOM 更新这些在构建应用程序时必要的操做中解放出来。git
总结来讲,理解 Virtual DOM 的含义主能够从如下几点出发:github
咱们常常会说到真实的 DOM 操做代价昂贵,操做频繁还会引发页面卡顿影响用户体验,而虚拟 DOM 就是为了解决这个浏览器性能问题才被创造出来。web
在介绍 Virtual DOM 有什么好处以及为何要使用它以前,咱们先来了解下为何会说 DOM 操做是耗费性能的?ajax
首先咱们要明白一点,DOM 并不属于 JavaScript 语言的一部分,它是 JavaScript 的运行平台(浏览器)提供的,好比在 nodejs 中就没有 DOM。浏览器中的 DOM 对应的是 HTML 页面中的元素节点,它自己和 JS 对象没有什么关联,可是 webkit 渲染引擎和 JS 引擎之间经过 V8 Binding 在 V8 内部会把原生 DOM 对象映射为 JS 对象,咱们称之为 Wrapper objects(包装对象)。所以,咱们平时在写代码时,操做 DOM 对象就是操做的这种包装对象,和操做 JS 对象是同样的。下图为浏览器和 JS 引擎的关系(以 Chrome 和 V8 举例,其余浏览器也大同小异)。算法
因为 JS 是可操纵 DOM 的,若是在修改这些元素属性同时渲染界面(即 JS 线程和渲染线程同时运行),那么渲染线程先后得到的元素数据就可能不一致了。所以为了防止渲染出现不可预期的结果,浏览器设置 渲染线程 与 JS 引擎线程 为互斥的关系,当 JS 引擎执行时渲染线程会被挂起,GUI 更新则会被保存在一个队列中等到 JS 引擎线程空闲时当即被执行。编程
所以咱们在操做 DOM 时,任何 DOM API 调用都要先将 JS 数据结构转为 DOM 数据结构,再挂起 JS 引擎线程并启动渲染引擎线程,执行事后再把可能的返回值反转数据结构,重启 JS 引擎继续执行。这种两个线程之间的上下文切换势必会很耗性能。
另外不少 DOM API 的读写都涉及页面布局的 重绘(repaint)和回流(reflow),这会更加的耗费性能。
综上所述,单次 DOM API 调用性能就不够好,频繁调用就会迅速积累上述损耗,但咱们又不可能不去操做 DOM,所以解决问题的本质是要 减小没必要要的 DOM API 调用。
不少人一谈到 Virtual DOM 的优点就会说 “原生 DOM 操做太慢了,virtual DOM 更快些”,首先咱们要认识到一点:没有任何框架能够比纯手动的优化 DOM 操做更快,由于框架的 DOM 操做层须要应对任何上层 API 可能产生的操做,它的实现必须是普适的。框架的意义在于为你掩盖底层的 DOM 操做,让你用更声明式的方式来描述你的目的,从而让你的代码更容易维护。
React 也历来没有说过 “React 比原生操做 DOM 快”。并非说 Virtual DOM 操做必定是比原生 DOM 操做快,这和具体的页面模板大小和数据的变更量都有关系的 可是相比于操做 DOM,原生的 js 对象操做起来的确是会更快、更简单。
React.js 相对于直接操做原生 DOM 最大的优点在于 batching 和 diff。为了尽可能减小没必要要的 DOM 操做, Virtual DOM 在执行 DOM 的更新操做后,不会直接操做真实 DOM,而是根据当前应用状态的数据,生成一个全新的 Virtual DOM,而后跟上一次生成 的 Virtual DOM 去 diff,获得一个 Patch,这样就能够找到变化了的 DOM 节点,只对变化的部分进行 DOM 更新,而不是从新渲染整个 DOM 树,这个过程就是 diff。还有所谓的batching
就是将屡次比较的结果合并后一次性更新到页面,从而有效地减小页面渲染的次数,提升渲染效率。batching 或者 diff, 说到底,都是为了尽可能减小对 DOM 的调用。简要的示意图以下:
所以总结下关于 Virtual DOM 的优点有哪些:
附上知乎上尤雨溪 对于 Virtual DOM 的优点的回答
引用 React 官网关于 Virtual DOM 的一段话:
与其将 “Virtual DOM” 视为一种技术,不如说它是一种模式,人们提到它时常常是要表达不一样的东西。在 React 的世界里,术语 “Virtual DOM” 一般与React 元素关联在一块儿,由于它们都是表明了用户界面的对象。而 React 也使用一个名为 “fibers” 的内部对象来存放组件树的附加信息。上述两者也被认为是 React 中 “Virtual DOM” 实现的一部分。
下面的部分咱们就来分别看看 ReactElement 和 Fiber 是什么东西。
咱们前面说了本质上 Virtual DOM 对应的是一个 JavaScript 对象,那么 React 是如何经过一个 js 对象将 Virtual DOM 和真实 DOM 对应起来的呢?这里面的关键就是 ReactElement。
ReactElement 即 react 元素,描述了咱们在屏幕上所看到的内容,它是构成 React 应用的最小单元。好比下面的 jsx 代码:
const element = <h1 id="hello">Hello, world</h1>
复制代码
上面的代码通过编译后其实生成的代码是这样的:
React.createElement("h1", {
id: "hello"
}, "Hello, world");
复制代码
执行 React.createElement 函数,会返回相似于下面的一个 js 对象,这个对象就是咱们所说的 React 元素:
const element = {
type: 'h1',
props: {
id: 'hello',
children: 'hello world'
}
}
复制代码
React 元素也能够是用户自定义的组件:
function Button(props) {
return <button style={{ color }}>{props.children}</button>;
}
const buttonComp = <Button color="red">点击我</Button>
复制代码
编译后的代码以下:
React.createElement("Button", {
color: "red"
}, "点击我");
复制代码
所以咱们就能够说 React 元素其实就是一个普通的 js 对象(plain object),这个对象用来描述一个 DOM 节点及其属性 或者组件的实例,当咱们在 JSX 中使用 Button 组件时,就至关于调用了React.createElement()
方法对组件进行了实例化。因为组件能够在其输出中引用其余组件,当咱们在构建复杂逻辑的组件时,会造成一个树形结构的组件树,React 便会一层层的递归的将其转化为 React 元素,当碰见 type 为大写的类型时,react 就会知道这是一个自定义的组件元素,而后执行组件的 render 方法或者执行该组件函数(根据是类组件或者函数组件的不一样),最终返回 描述 DOM 的元素进行渲染。
咱们来看下 React 源码中关于 ReactElement 和 createElement 方法的实现:
var ReactElement = function (type, key, ref, self, source, owner, props) {
var element = {
// This tag allows us to uniquely identify this as a React Element
$typeof: REACT_ELEMENT_TYPE,
// Built-in properties that belong on the element
type: type,
key: key,
ref: ref,
props: props,
// Record the component responsible for creating this element.
_owner: owner
};
// do somethings ....
return element;
}
function createElement(type, config, children) {
var propName; // Reserved names are extracted
var props = {};
var key = null;
var ref = null;
var self = null;
var source = null;
if (config != null) {
if (hasValidRef(config)) {
ref = config.ref;
{
warnIfStringRefCannotBeAutoConverted(config);
}
}
if (hasValidKey(config)) {
key = '' + config.key;
}
self = config.__self === undefined ? null : config.__self;
source = config.__source === undefined ? null : config.__source; // Remaining properties are added to a new props object
for (propName in config) {
if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
props[propName] = config[propName];
}
}
} // Children can be more than one argument, and those are transferred onto
// the newly allocated props object.
//....
return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
复制代码
从上面的源码中能够看出:
为了更加清楚的表示,咱们经过在控制台打印出整个 ReactElement 对象来看看它的真实的结构:
<div className="box" id="name" key="uniqueKey" ref="boxRef">
<h1>header</h1>
<div className="content">content</div>
<div>footer</div>
</div>
复制代码
它最终会生成下面这样的一个对象:
经过上面这些属性,React 就能够用 js 对象把 DOM 树上的结构信息、属性信息轻易的表达出来了。
React 15 及更早的 reconciler 架构能够分为两层:
每当有状态更新时,Reconciler会作以下工做:
它的工做流程很像是函数调用的方式,一旦 setState 以后,便开始从父节点开始递归的进行遍历,找出 Virtual DOM 的不一样。在将全部的 Virtual DOM 遍历完成以后,React 才能给出当前须要更新的 DOM 信息。这个过程是个同步的过程。对于一些特别庞大的组件来讲,js 执行会占据很长的主线程时间,这样会致使页面响应速度变慢,出现卡顿等现象,尤为是在动画显示上,极可能会出现丢帧的现象。
那么为何 Stack reconsiler 会致使丢帧呢?咱们来看一下一帧都作了什么。在上面的图中,咱们能够看出一帧包括了用户的交互行为的处理、js 的执行、requestAnimationFrame 的调用、layout 布局、paint 页面重绘等工做,假如某一帧里面要执行的任务很少,在不到 16ms(1000/60=16)的时间内就完成了上述任务的话,页面就会正常显示不会出现卡顿的现象,可是若是一旦 js 执行时间过长,超过了 16ms,这一帧的刷新就没有时间执 layout 和 paint 部分了,就可能会出现页面卡顿的现象。
咱们仔细考虑,其实对于视图来讲,同步的改变并非一种好的解决方案,主要有如下几点考虑:
为了解决上面的 stack reconciler 中固有的问题,react 团队重写了核心算法 --reconciliation,即 fiber reconciler(二者之间效果对比更直观的感觉能够看下这个demo)。fiber reconciler 的架构在原来的基础上增长了 Scheduler(调度器)的概念:
上面咱们在讲一帧的过程的时候提到,假如某一帧里面要执行的任务很少,在不到 16 ms 的时间内就完成了任务,那么这一帧就有空闲时间,咱们就能够利用这个空闲时间用来执行低优先级的任务,浏览器有个 api 叫requestIdleCallback,就是指在浏览器的空闲时段内调用的一些函数的回调。React 实现了功能更完备的 requestIdleCallbackpolyfill,这就是Scheduler。除了在空闲时触发回调的功能外,Scheduler还提供了多种调度优先级供任务设置。Scheduler 主要决定应该在什么时候作什么,它在接收到更新后,首先看看有没有其它高优先级的更新须要先执行,若是有就先执行高优先级的任务,等到空闲期再执行这次更新;若是没有则将这次任务交给 reconciler 。
还记得前面在讲 ReactElement 时在控制台打印出的对象里面有个 _owner 对象吗,它就是咱们说到的 Fiber 节点。当一个 React Element 第一次被转换为 fiber 节点的时候, React 将会从 React Element 中提取数据并在在createFiberFromTypeAndProps函数中建立一个新的 fiber 节点。Fiber 的主要目标是使 React 可以利用调度。具体来讲,咱们须要可以
为了作到这一点,咱们首先须要一种将工做分解为单元的方法。从某种意义上说,这就是 Fiber。Fiber 表明一种工做单位。React 会为每一个获得的 React Element 建立 fiber,这些 fiber 节点被链接起来组成 fiber tree。每一个 fiber 对应一个 React Element,保存了该元素的类型、对应的 DOM 节点、本次更新中的该元素改变的状态、要执行的任务(删除、插入、更新)等信息。咱们看一下 React 源码中 FiberNode 构造函数的部分:
type 和 key 与 React 元素的用途相同,React 经过它们来判断 Fiber 是否能够重复使用。
stateNode 是 Fiber 对应的真实 DOM 节点。
多个 fiber 节点中是怎么链接造成 fiber tree 的呢?主要靠如下三个属性:
在 React Fiber 中,一次更新过程会分红多个分片完成,因此彻底有可能一个更新任务尚未完成,就被另外一个更高优先级的更新过程打断,这时候,优先级高的更新任务会优先处理完,而低优先级更新任务所作的工做则会彻底做废,而后等待机会重头再来。由于一个更新过程可能被打断,因此 React Fiber 一个更新过程被分为两个阶段(Phase):第一个阶段Reconciliation Phase****和第二阶段Commit Phase。在第一阶段 Reconciliation Phase,React Fiber 会找出须要更新哪些 DOM,这个阶段是能够被打断的;可是到了第二阶段 Commit Phase,那就一气呵成把 DOM 更新完,毫不会被打断。
在 React 中最多会同时存在两棵fiber tree
。当前屏幕上显示内容对应的fiber tree
称为current fiber tree
,正在内存中构建的fiber tree
称为workInProgress fiber tree
。current fiber tree 中的 Fiber 节点被称为 current fiber,workInProgress fiber tree 中的 Fiber 节点被称为 workInProgress fiber,他们经过 alternate 属性链接。
currentFiber.alternate === workInProgressFiber;
workInProgressFiber.alternate === currentFiber;
复制代码
React 应用的根节点经过current
指针在不一样fiber tree
的rootFiber
间切换来实现fiber tree
的切换。双缓冲具体指的是当workInProgress fiber tree
构建完成交给Renderer
渲染在页面上后,应用根节点的current
指针指向workInProgress fiber tree
,此时workInProgress fiber tree
就变为current fiber tree
。每次状态更新都会产生新的workInProgress fiber tree
,经过current
与workInProgress
的替换,完成 DOM 更新。这样作的好处是:
前面咱们了解了 ReactElement 和 React Fiber,如今总结一下整个 Virtual DOM 的工做流程。
在调用 React 的 render() 方法,会建立一棵由 React 元素组成的树。在下一次 state 或 props 更新时,相同的 render() 方法会返回一棵不一样的树。React 须要基于这两棵树之间的差异来进行比较,这个比较的过程就是俗称的 diff 算法,换成前面咱们讲的 React Fiber 的概念来讲,就是将当前组件与该组件在上次更新时对应的 Fiber node 比较,将比较的结果生成新的 Fiber 节点。为了方便理解,咱们列举下这个更新的 DOM 节点在某一时刻会有这么几个概念与其相关:
Diff 算法的本质就是对比 1 和 3,生成 2。
React 文档中提到,即便在最前沿的算法中,将先后两棵树彻底比对的算法的复杂程度为 O(n 3 ),其中 n 是树中元素的数量。若是在 React 中使用了该算法,那么展现 1000 个元素所须要执行的计算量将在十亿的量级范围。这个开销实在是太太高昂,显然没法知足性能要求,因而 React 在如下两个假设的基础之上提出了一套 O(n) 的启发式算法:
如上图所示,React 只会对相同颜色框内的 DOM 节点进行比较,即同一个父节点下的全部子节点。当发现节点已经不存在,则该节点及其子节点会被彻底删除掉,不会用于进一步的比较。这样只须要对树进行一次遍历,便能完成整个 DOM 树的比较。当有下面的状况时(A 节点直接被整个移动到 D 节点下):
由于 React 只会对同级节点进行比较,这时候 React 发现的是 A 节点不见了,就会直接销毁 A 节点,在 D 节点那里发现多了一个新的子节点 A,则会建立一个新的 A 节点做为子节点。
上面的例子是对于在不一样层级的节点的比较,对于同一层级的节点,React 引入了 key 属性来来给每个节点添加惟一标识,这样 React 就能匹配到原有的节点,提升转换效率,以下面的例子:
// 更新前
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
// 更新后
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
复制代码
若是没有 key 值,React 会从新建立每个子元素,由于在比较 ul 的第一个子元素时发现二者不一样,即开始重建,但当子元素拥有 key 时,React 使用 key 来匹配原有树上的子元素以及最新树上的子元素,如今 React 知道只有带着 '2014' key 的元素是新元素,带着 '2015' 以及 '2016' key 的元素仅仅移动了。
因此咱们在写代码时遇到列表渲染的时候,必定要记得给列表的每一项加上 key 属性,这个 key 不须要全局惟一,但在列表中须要保持惟一。
咱们从 Diff 的入口函数 reconcileChildFibers 出发,该函数会根据 newChild(即 ReactElement 对象)类型调用不一样的处理函数。其中几个参数的含义以下:
咱们能够从同级的节点数量将 Diff 分为两类:
对于单个节点,咱们以类型 object 为例,会进入 reconcileSingleElement 函数里,这个函数主要作了如下事情:reconcileSingleElement 方法的部分代码以下:
function reconcileSingleElement(
returnFiber: Fiber,
currentFirstChild: Fiber | null,
element: ReactElement
): Fiber {
const key = element.key;
let child = currentFirstChild;
// 首先判断是否存在对应DOM节点
while (child !== null) {
// 上一次更新存在DOM节点,接下来判断是否可复用
// 首先比较key是否相同
if (child.key === key) {
// key相同,接下来比较type是否相同
switch (child.tag) {
// ...省略case
default: {
if (child.elementType === element.type) {
// type相同则表示能够复用
// 返回复用的fiber
return existing;
}
// type不一样则跳出循环
break;
}
}
// 代码执行到这里表明:key相同可是type不一样
// 将该fiber及其兄弟fiber标记为删除
deleteRemainingChildren(returnFiber, child);
break;
} else {
// key不一样,将该fiber标记为删除
deleteChild(returnFiber, child);
}
child = child.sibling;
}
// 建立新Fiber,并返回 ...省略
}
复制代码
当 ReactElement 的 children 属性不是单一节点的话,以下面结构:
<ul>
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
<li key="3">3</li>
</ul>
复制代码
此时它返回的对象的 children 是包含 4 个对象的数组:
{
$typeof: Symbol(react.element),
key: null,
props: {
children: [
{$typeof: Symbol(react.element), type: "li", key: "0", ref: null, props: {…}, …}
{$typeof: Symbol(react.element), type: "li", key: "1", ref: null, props: {…}, …}
{$typeof: Symbol(react.element), type: "li", key: "2", ref: null, props: {…}, …}
{$typeof: Symbol(react.element), type: "li", key: "3", ref: null, props: {…}, …}
]
},
ref: null,
type: "ul"
}
复制代码
这种状况下,reconcileChildFibers
的newChild
参数类型为Array
,对应的处理函数是reconcileChildrenArray
里的newChildren
,在比较时,和newChildren
里的每个child
比较的是current fiber
,即newChildren[0]
与fiber
比较,newChildren[1]
与fiber.sibling
比较。
多节点 diff 的状况比较多比较复杂,大体能够分为如下几个方面:
React 团队发现,在平常开发中,相较于新增和删除,更新组件发生的频率更高。因此 Diff 会优先判断当前节点是否属于更新。基于以上缘由,Diff 算法的总体逻辑会经历两轮:
第一轮遍历的步骤以下:
第一轮遍历结束后,有如下几种结果:
等上面全部的节点都遍历完成后,都已经打上了增/删/更新的标记,此时就生成了 workInProgress Fiber,剩下的工做就是交个 renderer 处理了。