深入探讨前端UI框架

2,271 阅读15分钟
原文链接: imweb.io

本文作者:IMWeb 黎清龙 原文出处:IMWeb社区 未经同意,禁止转载

深入探讨前端UI框架

1 前言

先说说这篇文章的由来

最近看riot的源码,发现它很像angular的dirty check,每个component ( tag )都保存一个expressions数组,更新时,遍历expressions数组,重新求值,对比旧值,如果有变更则更新DOM。

这不就是dirty check吗?为什么riot还声称它实现了virtual DOM?

疑惑之下,就去复盘了一下各大前端框架,把一些收获分享给大家

本文内容很多,实在不知道怎么取标题,最终取了一个泛泛的标题,请读者不要纠结

本文将会涉及的内容有:

  1. MV*前端框架,UI框架,UI更新相关介绍
  2. UI更新机制原理及其代表框架介绍
  3. 深入探讨各个UI更新机制(为什么virtual DOM会快)
  4. 浏览器渲染机制
  5. riot的真相(virtual DOM的本质,给我自己一个交代!)

裹脚布较长,读者慎入!

2 理解前端框架

2.1 前端的工作

说起前端的工作,其实很简单,主要是:

  1. 页面加载之后,如果有初始数据的话,则处理这些数据,并将其展示到UI上(通过DOM操作)
  2. 用户与UI交互,比如点击某个button,或者某些异步事件,比如setTimeout,Ajax,产生了一个事件,事件监听者进行相应的处理,然后把变动体现到UI上,或者把用户的输入数据上传到服务器

2.2 前端框架

可以看到前端要做的工作还是比较直观,简单的

但是,当一个页面很复杂,比如SPA的时候,就需要有一个成熟的架构来提升前端开发的效率

前端框架提供一套成熟的解决方案来组织前端代码,前端数据流等

前端框架的核心作用有且并不完全是:

  1. 模块化,组件化,提高可复用性
  2. 数据流清晰,提高可维护性

常见的前端框架模式有: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更新;缺点是不需要改的也更新了!

不需要变更的都一起更新会引发以下问题:

  1. 重新生成dom tree
  2. 原来绑定的事件没了
  3. 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是什么即可

但是其他呢?

接下来需要介绍关于浏览器渲染机制的两个话题

  1. 浏览器对渲染的优化
  2. 浏览器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

  1. 浏览器必须要立刻进行reflow,比如上面test case展示的那样,浏览器需要返回元素最新的位置信息
  2. 一段时间之后

详见: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更新的性能目标有两个:

  1. 减少reflow
  2. 减少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渲染线程

下面根据两者不同点来分析:

  1. AngularJs 的DOM操作是分布式的,DOM操作封装在watcher里面,每当有属性变更,就会触发watcher,然后执行DOM操作

而react的DOM操作是集中式的,在diff之后,根据最终的patches执行DOM操作

集中式的DOM操作可以最大限度的利用浏览器的优化机制,详见【4.1.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更新语句序列

  1. 稳定的输入,是指在js计算过程中,不接受新的输入

如果在js计算过程中,需要改变输入源store,那么会通过另外的机制(事件机制)把这些改变放到下一个UI更新事件

感兴趣的同学可以去试试,不过我们一般不会在virtual DOM计算过程中改变store,这也算是react的设计模式的约定之一

  1. 通过js计算是指不会插入任何的DOM写操作语句
  2. 得出UI更新的语句序列,在web是DOM写操作,在react native就是app的UI更新语句

这也是virtual DOM的一大优势,在这里就不详述了

5.2 riot 做了什么?

riot主要解决react的两个痛点:

  1. jsx难以理解
  2. react库太大

解决方案:

  1. 参考web component组织html,js,css
  2. 实现粗粒度的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更新这点来剖析这些框架的设计也是一件有趣的事情

也让作者对这些框架有了更深的认识

另外,这些框架的设计理念以及设计模式都非常值得回味

如果有熟悉本文没有介绍到的框架的同学,可以分享出来供大家一起学习