在前端开发过程中,对性能产生最大影响的因素莫过于DOM的重排重绘了,React作为前端框架领跑者,为了有效解决DOM更新开销的问题,采用了Virtual DOM的思路,不仅提升了DOM操作的效率,更推动了数据驱动式组件开发的形成与完善。虚拟 DOM 因为高效的性能经常备受关注,之后 Vue2.0 也加入了虚拟 DOM 的概念。目前主流的前端项目开发普遍会基于React、Vue等框架,采用数据驱动的模式,通过虚拟dom减少与真是dom的交互来提高渲染性能。
虚拟 DOM 结构(Virtual DOM)
在前端刀耕火种的时代,jquery 可谓是一家独大。然而慢慢的人们发现,在我们的代码中布满了一系列操作 DOM 的代码。这些代码难以维护,又容易出错。而且也难以测试。所以,react 利用了 Virtual DOM 简化 dom 操作,让数据与 dom 之间的关系更直观更简单。
实现 Virtual DOM
Virtual DOM的主要思想就是模拟DOM的树状结构,在内存中创建保存映射DOM信息的节点数据,在由于交互等因素需要视图更新时,先通过对节点数据进行diff算法后得到差异结果后,再一次性对DOM进行批量更新操作,这就好比在内存中创建了一个平行世界,浏览器中DOM树的每一个节点与属性数据都在这个平行世界中存在着另一个版本的虚拟DOM树,所有复杂曲折的更新逻辑都在平行世界中的Virtual DOM处理完成,只将最终的更新结果发送给浏览器中的DOM树执行,内存中的运算开销远比直接操作真实的DOM开销要小得多,这样就避免了冗余琐碎的DOM树操作负担,进而有效提高了性能。因此实现Virtual DOM 主要包括以下三个方面:
- 使用 js 数据对象 表示 DOM 结构 -> VNode
- 比较新旧两棵 虚拟 DOM 树的差异 -> diff
- 将差异应用到真实的 DOM 树上 -> patch
Diff效率之争
Virtual DOM是react在组件化开发场景下,针对DOM重排重绘性能瓶颈作出的重要优化方案,而他最具价值的核心功能是如何识别并保存新旧节点数据结构之间差异的方法,也即是diff算法。毫无疑问的是diff算法的复杂度与效率是决定Virtual DOM能够带来性能提升效果的关键因素。因此,在VirtualDOM方案被提出之后,社区中不断涌现出对diff的改进算法,引用司徒正美的经典介绍:
最开始经典的深度优先遍历DFS算法,其复杂度为O(n^3),存在高昂的diff成本,然后是cito.js的横空出世,它对今后所有虚拟DOM的算法都有重大影响。它采用两端同时进行比较的算法,将diff速度拉高到几个层次。紧随其后的是kivi.js,在cito.js的基出提出两项优化方案,使用key实现移动追踪及基于key的编辑长度距离算法应用(算法复杂度 为O(n^2))。但这样的diff算法太过复杂了,于是后来者snabbdom将kivi.js进行简化,去掉编辑长度距离算法,调整两端比较算法。速度略有损失,但可读性大大提高。再之后,就是著名的vue2.0 把snabbdom整个库整合掉了。
目前Virtual DOM的主流diff算法趋向一致,在主要diff思路上,snabbdom与react的reconilation方式基本相同。
Vue的Virtual Dom
Vue在2.0版本引入了vdom。其vdom是基于 snabbdom 库所做的修改。snabbdom是一个开源的vdom库。snabbdom的主要作用就是将传入的JS模拟的DOM结构转换成虚拟的DOM节点。先通过其中的 h函数 将JS模拟的DOM结构转换成虚拟DOM之后,再通过其中的 patch函数 将虚拟DOM转换成真实的DOM渲染到页面中。为了保证页面的最小化渲染,snabbdom引入了 Diff算法 ,通过Diff算法找出前后两个虚拟DOM之间的差异,只更新改变了的DOM节点,而不重新渲染为改变的DOM节点。
snabbdom
项目路径 : https://github.com/snabbdom/snabbdom 。
snabbdom是一个轻量级的虚拟dom框架,具有高效的diff算法及灵活的扩展性,snabbdom以其高效的实现,小巧的体积与灵活的可扩展性脱颖而出,它的核心代码只有300行+。一个使用snabbdom创建的demo是这样的:
import snabbdom from 'snabbdom';
import h from 'snabbdom/h';
const patch = snabbdom.init([
require('snabbdom/modules/class'), // makes it easy to toggle classes
require('snabbdom/modules/props'), // for setting properties on DOM elements
require('snabbdom/modules/style'), // handles styling on elements with support for animations
require('snabbdom/modules/eventlisteners'), // attaches event listeners
]);
var vnode = h('div', {style: {fontWeight: 'bold'}}, 'Hello world');
patch(document.getElementById('placeholder'), vnode)
其中的 h 函数,就是Vue中的Render函数,使用 Render 函数我们可以用 Js 语言来构建 DOM。Vue 推荐在绝大多数情况下使用模板来创建你的 HTML。然而在一些场景中,我们真的需要 JavaScript 的完全编程的能力。可以用渲染函数,它比模板更接近编译器。在下面两个基于Vue的UI框架中分别可以这样创建Dom
IView
render:(h, params)=>{ return h('div', {style:{width:'100px',height:'100px',background:'#ccc'}}, '地方') }
Element
<el-table-column :render-header="setHeader"> </el-table-column> setHeader (h) { return h('span', [ h( 'span', { style: 'line-height: 40px;' }, '测试'), h('el-button', { props: { type: 'primary', size: 'medium', disabled: this.isDisable || !this.tableData.length }, on: { click: this.save } }, '这是个按钮') ]) }
关于snabbdom的API的使用注意是 h(sel,data,children) 函数,它有三个参数
- 它的第一个参数是元素的选择器,这里可以参考jq的写法,#和.分别代表了id和class,对于多个class,可以div#divId.red.blue.black这样去写;
- 它的第二个参数是节点数据的定义,如class、styles、events;
- 一个字符串或者虚拟 DOM 的数组,可以是个Array,数组表示该元素的子元素,也可以是String
对于一些没有使用react、vue等框架开发的项目,若它们自身也有比较完善的组件定义、组件调用、数据传递的方式,且框架基本上也能满足使用。对于这样的项目若想采用数据驱动和虚拟dom渲染,是将原有框架全部否定,花很多的时间使用react、vue等框进行重构?其实只需把存在用户交互的组件调整为数据驱动和虚拟dom渲染即可。在不改变原框架的组件定义、组件调用、数据传递的前提下,灵活的加入数据驱动的思想,并通过虚拟dom和diff算法简化dom操作优化组件的渲染机制。下面是snabbdom的一个简单完整的使用示例
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title>
</head>
<body>
<div id="container">
<div id="test-container"></div>
</div>
<button id="btn-change">change</button>
<script type="text/javascript" src="https://cdn.bootcss.com/jquery/3.4.0/jquery.min.js"></script>
<!-- 引入snabbdom相关库 -->
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-class.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-props.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-style.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/snabbdom-eventlisteners.js"></script>
<script src="https://cdn.bootcss.com/snabbdom/0.7.3/h.js"></script>
<script type="text/javascript">
var snabbdom = window.snabbdom;
//init patch function
var patch = snabbdom.init([
snabbdom_class,
snabbdom_props,
snabbdom_style,
snabbdom_eventlisteners
]);
// 定义h函数
var h = snabbdom.h;
var container = document.getElementById('test-container');
var data = [{
name: "张三",
age: 24,
address: "深圳"
}, {
name: "张三",
age: 24,
address: "深圳"
}, {
name: "张三",
age: 24,
address: "深圳"
}];
var oldVnode;
function render(data) {
var vnode = h('ul#list', {}, data.map(function (item) {
return h('li', {}, item.name+' '+item.age+' '+item.address)
}))
if (!oldVnode) {
//初始化页面渲染
patch(container, vnode);
} else {
//对比原来的vnode和新生成的vnewnode,找出差异,只渲染修改的部分
patch(oldVnode, vnode);
}
oldVnode = vnode;
}
$('#btn-change').click(function () {
data[1].name = '李四';
data[2].name = '王五';
//修改后重新渲染
render(data);
})
render(data); //初始化页面渲染
</script>
</body>
</html>
最终页面渲染结果如下
snabbdom的diff是通过patch函数
来实现的,patch函数可以对比新、老Vnode,把变动的Vnode映射到真实dom上,也可以把Vnode挂载到html上,并替换指定的dom节点。patch方法的调用方式如下:
var oldNode = h('span',{class:'titile'},'标题');
var newNode = h('span',{class:'titile'},'新标题');
//把oldNode对应到真的的dom,替换html中选择器为.block的div标签
path('.block',oldNode);
//对比新老Vnode,只把有变更的Vnode映射到真实的dom
path(oldNode,newNode);
React的Virtual Dom
React实现可以粗划为两部分:reconciliation(diff阶段)和 renderer(操作DOM阶段)。在React的历史版本中,完成数据节点diff的过程是reconcilation,,当你在一个组件中调用setState时,react会将该组件节点标记为dirty,进行reconcile并得到重新构建的子树virtual-dom,在工作流结束时重新render带有dirty标记的节点, 如果你是在组件的根节点上进行setState,那么整个组件树Virtual DOM都会重新创建,但由于这并不是直接操作真实的DOM,所以实际上产生的影响仍然有限。
React 16 之前的不足:
首先我们了解一下 React 的工作过程,当我们通过 render()
和 setState()
进行组件渲染和更新的时候,React 主要有两个阶段:
调和阶段(Reconciler)
:React 会自顶向下通过递归,遍历新数据生成新的 Virtual DOM,然后通过 Diff 算法,找到需要变更的元素(Patch),放到更新队列里面去。渲染阶段(Renderer)
:遍历更新队列,通过调用宿主环境的API,实际更新渲染对应元素。宿主环境,比如 DOM、Native、WebGL 等。
在协调阶段阶段,由于是采用的递归的遍历方式,这种也被成为 Stack Reconciler,主要是为了区别 Fiber Reconciler 取的一个名字。这种方式有一个特点:一旦任务开始进行,就无法中断,那么JS 长时间执行(如大量计算等)将一直占用主线程, 一直要等到整棵 Virtual DOM 树计算完成之后,才能把执行权交给渲染引擎,那么这就会导致一些用户交互、动画等任务无法立即得到处理,就会有卡顿,出现页面脱帧现象,非常的影响用户体验。
在React16的重写中,最重要的改变时将核心架构改为了代号为Fiber
的异步渲染架构。从本质上看,一个Fiber就是一个POJO对象,一个React Element可以对应一个或多个Fiber节点,Fiber包含着DOM节点与React组件中的所有工作需要的属性数据。因此虽然React的代码中其实没有明确的Virtual DOM概念,但通过对Fiber的设计充分完成了Virtual DOM的功能与机制。Fiber除了承担Virtual DOM的工作之外,它真正设计目的是实现一种在前端执行的轻量执行线程,同普通线程一样共享定址空间,但却能够受React自身的Fiber系统调度,实现渲染任务细分,可计时,可打断,可重启,可调度的协作式多任务处理的强大渲染任务控制机制。React中创建节点提供的API是createElement
createElement
众所周知,JSX只是为React.createElement(component, props, ...children)
方法提供的语法糖
// JSX语法
let app = (<div id='app'>Hello World!</div>);
在babel编译后:
// babel编译后:
let app = React.createElement('div', {id: 'app'}, 'Hello World!');
ReactElement类型通过函数React.createElement()创建节点
ReactElement createElement( string/ReactClass type, [object props], [children …] )
在API的参数上和snabbdom提供的h函数的API基本相同,参数解释如下
- 第一个参数可以接受字符串(如“p”,“div”等HTML的tag)或ReactClass
- 第二个参数为元素的属性设置,如 id ,style,class等
- 第三个为子元素,可以为字符串或ReactElement。