深入探讨前端UI框架
1 前言
先说说这篇文章的由来
最近看riot的源码,发现它很像angular的dirty check,每个component ( tag )都保存一个expressions数组,更新时,遍历expressions数组,重新求值,对比旧值,如果有变更则更新DOM。
这不就是dirty check吗?为什么riot还声称它实现了virtual DOM?
疑惑之下,就去复盘了一下各大前端框架,把一些收获分享给大家
本文内容很多,实在不知道怎么取标题,最终取了一个泛泛的标题,请读者不要纠结
本文将会涉及的内容有:
MV*前端框架,UI框架,UI更新相关介绍
UI更新机制原理及其代表框架介绍
深入探讨各个UI更新机制(为什么virtual DOM会快)
浏览器渲染机制
riot的真相(virtual DOM的本质,给我自己一个交代!)
裹脚布较长,读者慎入!
2 理解前端框架
2.1 前端的工作
说起前端的工作,其实很简单,主要是:
页面加载之后,如果有初始数据的话,则处理这些数据,并将其展示到UI上(通过DOM操作)
用户与UI交互,比如点击某个button,或者某些异步事件,比如setTimeout,Ajax,产生了一个事件,事件监听者进行相应的处理,然后把变动体现到UI上,或者把用户的输入数据上传到服务器
2.2 前端框架
可以看到前端要做的工作还是比较直观,简单的
但是,当一个页面很复杂,比如SPA的时候,就需要有一个成熟的架构来提升前端开发的效率
前端框架提供一套成熟的解决方案来组织前端代码,前端数据流等
前端框架的核心作用有且并不完全是:
模块化,组件化,提高可复用性
数据流清晰,提高可维护性
常见的前端框架模式有:MVC, MVP, MVVM,可以查看阮大大的blog
上图是MVVM框架的图示,取自阮大大的blog
MVVM把model和view分离,把model和view的通信以及处理逻辑封装在vm对象中
使得vm对象可复用,同一个vm对象可以绑定不同的view
另外view和vm对象进行双向绑定,它们之间的数据流也非常清晰,提高可维护性
2.3 UI & UI框架
什么是UI?
UI实际上是View层,用户看到的内容就是UI
对于前端,web站点来说,UI就是HTML+CSS
html在js的表现就是dom tree
前端可以通过js脚本操作DOM,浏览器会根据最新的dom tree 和 css 进行渲染操作
这个过程叫做UI更新
UI框架是针对UI层的一套解决方案,提高了UI的组件化,提高复用性
另外UI框架同时也会对UI更新有一套解决方案,提高UI更新的效率
一些大型成熟的前端框架会有自己的一个UI框架,比如ember.js,extjs等
一个比较典型的UI框架就是大家都熟悉的react
2.4 UI更新及其策略
前端界都知道,DOM操作(UI更新)通常都是前端页面的性能高消费者
因此一个框架需要在UI更新这方面考虑的更加仔细,才能让系统获得更好的性能
一般UI更新的策略有两种,大家也经常使用到
直接上代码:
// 1 需要改的才去改
$('.我就是要找到你1').text('改文案');
$('.我就是要找到你2').css('color', '改颜色');
$('.我就是要找到你3').width('改宽度');
// 2 使用模板
$('.我是你们的公共父节点!').html(tpl({
text: '改文案',
color: '改颜色',
width: '改宽度'
});
方式一是找到要改的节点,然后进行相应的DOM操作
方式二是直接利用模板,直接更新一块dom tree
方式一的优点是直观;缺点是代码很难维护
方式二的优点是简单,只有一次UI更新;缺点是不需要改的也更新了!
不需要变更的都一起更新会引发以下问题:
重新生成dom tree
原来绑定的事件没了
input, textarea会失去焦点
backbone 是方式二
3 理解那些你所知道的前端框架
现在有许多优秀的前端框架,下面分别介绍一下这些框架,以及这些框架与UI更新相关的内容
3.1 AngularJs ( dirty check )
AngularJs是mvvm框架,它的组件是vm组件,scope是vm组件的数据集合
AngularJs通过directive来声明vm的行为,它实现为一个watcher,监听scope的属性的变化,把最新的属性更新UI
另外当用户操作DOM的时候,产生事件,也通过watcher来把用户的输入修改到scope的属性中,这个技术称为双向绑定
有一个关键的问题是,AngularJs如何实现监听scope的属性变更的呢?
AngularJs使用的是dirty check技术,dirty check方案是在某个关键点,进入$digest循环,遍历所有的scope的属性,如果发现变更,则触发相应的watcher
需要注意的是,watcher在执行的过程中有可能会修改scope的属性值,因此$digest要一直检查,直到scope完全稳定为止
每个directive都是关注某一个点,比如修改css,class操作,text操作等
因此Angular的UI更新机制本质上是方式一,它只是把定位元素节点的逻辑封装起来,并绑定了scope的字段,然后自动监控而已
3.2 Vue、Avalon ( setter & getter )
这些库的架构基本与AngularJs一致,唯一不同的就是如何实现监听scope的属性变更
它们使用defineProperty的特性来监听scope的属性变更
这种方式和使用setter,getter来实现属性变更入口的框架比较类似
3.3 React ( virtual DOM )
react和前面的框架不一样,因为它只是单纯的ui框架
react组件没有scope的概念,虽然可以把state看作scope,但是react组件并不强制要定义state
另外,react的实现与上面两者也不一样,它的处理逻辑如下图所示
react组件根据输入:props【静态】& this.state【动态】
输出一个virtual DOM 树,然后用它与原来的virtual DOM 树通过DIFF算法,找出它们的差异PATCHES
最后,根据这些差异PATCHES再去执行UI更新
React与AngularJs比较类似,都是在某些关键点(程序自己决定什么时候开始执行更新算法)
AngularJs通过dirty check算法找到差异,并更新UI
React则是通过virtual DOM的对比找到差异,然后更新UI
React的UI更新策略包含了两种方式
PATCHES有很多种类型
它可以是简单的某个属性改变,比如text,class
它也可以是复杂的整个子树的增删移动,这时就可以使用方式二,重新渲染整个子树
详情可以参考react的Reconciliation算法
3.4 那些我不知道的
前端框架太多了,那些作者没看过的不做任何点评。。。
4 考虑性能
4.1 UI更新性能核心
提起浏览器渲染机制这个高级话题,可能大多数同学只知道大概原理吧(其实作者也是的)
大部分知道浏览器渲染的基本过程,然后还有repaint和reflow是什么即可
但是其他呢?
接下来需要介绍关于浏览器渲染机制的两个话题
浏览器对渲染的优化
浏览器UI渲染线程
4.1.1 浏览器渲染机制的优化
直接上一个测试代码就能说明这两个话题了
var ul = document.getElementById('list');
var e;
var s = +new Date();
for (var j = 0, l = 10000; j < l; ++j) {
e = document.createElement('li');
e.innerText = j;
ul.appendChild(e);
}
console.log('>>> cost1:', +new Date() - s);
// 到这句的时候,页面还是一片空白!
s = +new Date();
for (var k = 0, kl = 10000; k < kl; ++k) {
e = document.createElement('li');
e.innerText = kl;
ul.appendChild(e);
ul.offsetHeight; // 这句会引发浏览器渲染
}
console.log('>>> cost2:', +new Date() - s);
// 直到js执行结束,页面才有内容出来!
这段代码执行之后的结果如下
可以看到,两个test case只相差了一句代码:ul.offsetHeight
但是最后测出来的耗时差了1w倍
原因是这一句代码影响了浏览器渲染机制的优化
浏览器会缓存一些DOM操作,直到它必须要reflow为止
一些读取元素的位置信息的代码就让浏览器立刻进行reflow,因为浏览器需要返回元素最新的位置信息
这个test case也可以看到,reflow对性能的损耗有多大。。。
另外还需要注意的,在第一个test case执行完了之后,页面还是一片空白,第一个test case插入的节点并没有展示出来
即使执行了reflow,页面也没有展示UI
直到js执行完才展示
原因是reflow并不是就会执行UI渲染,UI渲染需要等待js执行完毕才会执行,可以理解为浏览器对js的执行和UI渲染都是同一个线程(虽然表现是这样,但是底层应该是js一个线程,UI渲染一个线程,只是浏览器只能执行一个线程)
从上面的例子可以看到,浏览器每次计算reflow都会消耗很多性能,因此浏览器对这块做了优化
浏览器的优化是浏览器会缓存一些DOM操作,直到以下两个条件之一才会进行真正的reflow
浏览器必须要立刻进行reflow,比如上面test case展示的那样,浏览器需要返回元素最新的位置信息
一段时间之后
详见:Rendering: repaint, reflow/relayout, restyle
4.1.2 浏览器原生事件循环
从【2.1 前端的工作】中可以看到,用户对于前端页面的大部分交互都是通过事件
实际上,浏览器在运行过程中,也有一个原生的事件循环
当一个事件被触发,浏览器就会执行该事件的注册callbacks,这时浏览器就进入了js的context
直到js执行完毕,浏览器就会执行UI更新线程,对新的UI改变进行渲染(如果有的话)
上图是AngularJs解释$digest loop时的配图,很好的说明了浏览器的原生事件循环
AngularJs提到$digest loop扩展了在js context里的过程
实际上,$digest loop就是一个类似死循环的逻辑,直到dirty check执行完毕才退出
因此,AngularJs保证了每次dirty check只有1次UI刷新
那么图上面的$evalAsyncqueue是什么呢?
实际上是需要在$digest loop异步执行的callback队列
要知道平常js的异步callback是插入到浏览器原生的事件循环队列里面的,比如setTimeout等
在AngularJs,如果需要在$digest loop里面执行异步callback
就需要把callback放到$evalAsyncqueue里
让异步callback可以在$digest loop内执行
4.1.3 UI更新性能目标
从前面两节可以看到
reflow是在执行js的过程中执行的,它对性能有很大的影响
而UI渲染是js执行之后才执行的,它对性能的消耗更加巨大
因此,UI更新的性能目标有两个:
减少reflow
减少UI渲染次数
4.2 为什么 virtual DOM 快?
下面我们讨论一下为什么virtual DOM会比其他框架的UI更新(dirty check & setter)策略要快
首先,使用defineProperty自动检测变化或者setter类型的就不参与讨论了,每次改属性都会进入绑定流程,想想都可怕
剩下AngularJs和react,他们的更新逻辑的入口都是在关键点调用更新接口
它们的共同点都是一次更新逻辑只会造成一次UI更新
AngularJs通过类似死循环的$digest循环扩展浏览器的原生事件循环,所有更新逻辑都是在js中执行完
react通过virtual DOM的diff得出改动,然后再统一的更新UI,这个过程也是一个js过程结束
两者都有同样的特征:通过大量的js计算完成所有的DOM操作,结束之后才返回浏览器的UI渲染线程
下面根据两者不同点来分析:
AngularJs 的DOM操作是分布式的,DOM操作封装在watcher里面,每当有属性变更,就会触发watcher,然后执行DOM操作
而react的DOM操作是集中式的,在diff之后,根据最终的patches执行DOM操作
集中式的DOM操作可以最大限度的利用浏览器的优化机制,详见【4.1.1 浏览器渲染机制的优化】
AngularJs 组件自带store,组件之间的互相影响可能会引起震荡
具体的是当组件A的属性变化之后,对应watcher里面的操作导致了B组件的属性变化,这时就需要触发相对应的watcher,这个过程有可能无穷无尽
另外AngularJs的dirty check是基于循环的,所以有可能watcher改变的是已经经过dirty check的store,因此dirty check要一直循环,直到所有的store都保持稳定,不再有任何新的变化,才能结束,当这个过程很长的时候,页面就会假死,因为浏览器不能执行UI更新,UI事件不能被处理,因为这个过程本身就在一个UI事件的处理期间,其他新的UI事件还在队列里面等着
这个问题的根本原因是AngularJs不能很好的控制组件之间的store
react没有这个问题就是因为react不是vm库,它没有store,看到这个估计大家都会傻眼,确实,AngularJs和react根本就不是一个可对比的库,本质都不一样
react应用,不管是配合flux还是redux,他们都是先把store计算稳定之后,再交给react去更新UI,这整个过程并不会劫持浏览器的原生事件循环,因此不会有页面的假死现象出现
另外,store计算完全是js计算,不会执行DOM的写操作,需要的只有甚至没有DOM的读操作,对于已经稳定的dom tree来说(浏览器的渲染队列里面已经没有缓存的DOM操作),批量的读操作是不会导致浏览器的repain和reflow的,因此store的计算过程会很快
因此,结论:store的稳定计算很快,react本身渲染也很快,所以使用virtual DOM的react很快
然后大家得出:virtual DOM很快
本质上,需要做的工作都是一样的,只是react把store的计算分离出去而已,但这也正体现了react的内聚性
另外还有一点也需要提及:
AngularJs,vue,avalon等vm库,都是用watcher模式,watcher是长存的
react是实时计算的,在diff之后,old tree就会被销毁,然后保留new tree作为下一次diff的old tree
因此在内存占用方面,也是react有优势
5 回到我的疑惑
5.1 virtual DOM 的本质
根据前面的讨论,我们得出virtual DOM的本质是
根据稳定的输入【state & props】,通过js计算,得出UI更新语句序列
稳定的输入,是指在js计算过程中,不接受新的输入
如果在js计算过程中,需要改变输入源store,那么会通过另外的机制(事件机制)把这些改变放到下一个UI更新事件
感兴趣的同学可以去试试,不过我们一般不会在virtual DOM计算过程中改变store,这也算是react的设计模式的约定之一
通过js计算是指不会插入任何的DOM写操作语句
得出UI更新的语句序列,在web是DOM写操作,在react native就是app的UI更新语句
这也是virtual DOM的一大优势,在这里就不详述了
5.2 riot 做了什么?
riot主要解决react的两个痛点:
jsx难以理解
react库太大
解决方案:
参考web component组织html,js,css
实现粗粒度的virtual DOM
第一点就不多说了
关于第二点,粗粒度的virtual DOM的意思是riot为每个组件创建一个tag对象
tag对象保存了所有它里面的expressions,tag之间和dom tree一样的父子结构组织
这种方式有点类似vm库,但是riot参考react,也有props(静态)和本身数据(动态),具有和react一样的输入
检查更新的过程就是dirty check,但是和AngularJs的做法不同,riot只做一轮,它和react一样,没有sotre,因此没有watcher,也不需要等待store稳定
至于输出,riot没有与react一样,UI更新语句序列也是分布式的
最终得出的结论,riot的实现实际上就是react + angular,另外组件代码组织方式是参考Polymer
正如riot官网上介绍的那样,riot是从已有的工具中提取精华
6 结语
本文主要讲解UI更新这个主题
介绍了浏览器的UI更新相关的内容
并介绍了几个比较流行的前端框架的设计核心
同时讲解了这些设计核心在UI更新方面的分析
实际上这些框架都是老生常谈的内容了
但是通过UI更新这点来剖析这些框架的设计也是一件有趣的事情
也让作者对这些框架有了更深的认识
另外,这些框架的设计理念以及设计模式都非常值得回味
如果有熟悉本文没有介绍到的框架的同学,可以分享出来供大家一起学习
前端