前端技术演进(五):现代前端交互框架

30次阅读

共计 8664 个字符,预计需要花费 22 分钟才能阅读完成。

这个来自之前做的培训,删减了一些业务相关的,参考了很多资料(参考资料列表),谢谢前辈们,么么哒 ????
随着前端技术的发展,前端框架也在不断的改变。
操作 DOM 时代
DOM(Document Object Model,文档对象模型)将 HTML 文档表达为树结构,并定义了访问和操作 HTML 文档的标准方法。

前端开发基本上都会涉及到 HTML 页面,也就避免不了和 DOM 打交道。
最早期的 Web 前端,就是一个静态的黄页,网页上的内容不能更新。
慢慢的,用户可以在 Web 页面上进行一些简单操作了,比如提交表单,文件上传。但是整个页面的部分或者整体的更新,还是靠刷新页面来实现的。
随着 AJAX 技术的出现,前端页面上的用户操作越来越多,越来越复杂,所以就进入了对 DOM 元素的直接操作时代。要对 DOM 元素操作,就要使用 DOM API,常见的 DOM API 有:

类型
方法

节点查询
getElementById、getElementsByName、getElementsByClassName、getElementsByTagName、querySelector、querySelectorAll

节点创建
createElement、createDocumentFragment、createTextNode、cloneNode

节点修改
appendChild、replaceChild、removeChild、insertBefore、innerHTML

节点关系
parentNode、previousSibling、childNodes

节点属性
innerHTML、attributes、getAttribute、setAttribure、getComputedStyle

内容加载
XMLHttpRequest、ActiveX

使用 DOM API 可以完成前端页面中的任何操作,但是随着网站应用的复杂化,使用原生的 API 非常低效。所以 jQuery 这个用来操作 DOM 的交互框架就诞生了。
jQuery 为什么能成为在这个时代最流行的框架呢?主要是他帮前端开发人员解决了太多问题:

封装了 DOM API,提供了统一和方便的调用方式。
简化了元素的选择,可以很快的选取到想要的元素。
提供了 AJAX 接口,对 XMLHttpRequest 和 ActiveX 统一封装。
统一了事件处理。
提供异步处理机制。
兼容大部分主流浏览器。

除了解决了上面这些问题,jQuery 还拥有良好的生态,海量的插件拿来即用,让前端开发比以前流畅很多。尤其是在 IE6、IE7 时代,没有 jQuery,意味着无穷的兼容性处理。
// DOM API:
document.querySelectorAll(‘#container li’);

// jQuery
$(‘#container’).find(‘li’);
随着 HTML5 技术的发展,jQuery 提供的很多方法已经在原生的标准中实现了,慢慢的,jQuery 的必要性在逐渐降低。http://youmightnotneedjquery.com/
渐渐地,SPA(Single Page Application,单页面应用)开始被广泛认可,整个应用的内容都在一个页面中并完全通过异步交互来加载不同的内容,这时候使用 jQuery 直接操作 DOM 的方式就不容易管理了,页面上事件的绑定会变得混乱,在这种情况下,迫切需要一个可以自动管理页面上 DOM 和数据之间交互操作的框架。
MV* 模式
MVC,MVP 和 MVVM 都是常见的软件架构设计模式(Architectural Pattern),它通过分离关注点来改进代码的组织方式。
单纯从概念上,很难区分和感受出来这三种模式在前端框架中有什么不同。我们通过一个例子来体会一下:有一个可以对数值进行加减操作的组件:上面显示数值,两个按钮可以对数值进行加减操作,操作后的数值会更新显示。

Model 层用于封装和应用程序的业务逻辑相关的数据以及对数据的处理方法。这里我们把需要用到的数值变量封装在 Model 中,并定义了 add、sub、getVal 三种操作数值方法。
var myapp = {}; // 创建这个应用对象

myapp.Model = function() {
var val = 0; // 需要操作的数据

/* 操作数据的方法 */
this.add = function(v) {
if (val < 100) val += v;
};

this.sub = function(v) {
if (val > 0) val -= v;
};

this.getVal = function() {
return val;
};
};
View 作为视图层,主要负责数据的展示。
myapp.View = function() {

/* 视图元素 */
var $num = $(‘#num’),
$incBtn = $(‘#increase’),
$decBtn = $(‘#decrease’);

/* 渲染数据 */
this.render = function(model) {
$num.text(model.getVal() + ‘rmb’);
};
};
这里,通过 Model&View 完成了数据从模型层到视图层的逻辑。但对于一个应用程序,这远远是不够的,我们还需要响应用户的操作、同步更新 View 和 Model。
前端 MVC 模式

MVC(Model View Controller)是一种很经典的设计模式。用户对 View 的操作交给了 Controller 处理,在 Controller 中响应 View 的事件调用 Model 的接口对数据进行操作,一旦 Model 发生变化便通知相关视图进行更新。
Model 层用来存储业务的数据,一旦数据发生变化,模型将通知有关的视图。
// Model
myapp.Model = function() {
var val = 0;

this.add = function(v) {
if (val < 100) val += v;
};

this.sub = function(v) {
if (val > 0) val -= v;
};

this.getVal = function() {
return val;
};

/* 观察者模式 */
var self = this,
views = [];

this.register = function(view) {
views.push(view);
};

this.notify = function() {
for(var i = 0; i < views.length; i++) {
views[i].render(self);
}
};
};
Model 和 View 之间使用了观察者模式,View 事先在此 Model 上注册,进而观察 Model,以便更新在 Model 上发生改变的数据。
View 和 Controller 之间使用了策略模式,这里 View 引入了 Controller 的实例来实现特定的响应策略,比如这个栗子中按钮的 click 事件:
// View
myapp.View = function(controller) {
var $num = $(‘#num’),
$incBtn = $(‘#increase’),
$decBtn = $(‘#decrease’);

this.render = function(model) {
$num.text(model.getVal() + ‘rmb’);
};

/* 绑定事件 */
$incBtn.click(controller.increase);
$decBtn.click(controller.decrease);
};
控制器是模型和视图之间的纽带,MVC 将响应机制封装在 Controller 对象中,当用户和应用产生交互时,控制器中的事件触发器就开始工作了。
// Controller
myapp.Controller = function() {
var model = null,
view = null;

this.init = function() {
/* 初始化 Model 和 View */
model = new myapp.Model();
view = new myapp.View(this);

/* View 向 Model 注册,当 Model 更新就会去通知 View 啦 */
model.register(view);
model.notify();
};

/* 让 Model 更新数值并通知 View 更新视图 */
this.increase = function() {
model.add(1);
model.notify();
};

this.decrease = function() {
model.sub(1);
model.notify();
};
};
这里我们实例化 View 并向对应的 Model 实例注册,当 Model 发生变化时就去通知 View 做更新。
可以明显感觉到,MVC 模式的业务逻辑主要集中在 Controller,而前端的 View 其实已经具备了独立处理用户事件的能力,当每个事件都流经 Controller 时,这层会变得十分臃肿。而且 MVC 中 View 和 Controller 一般是一一对应的,捆绑起来表示一个组件,视图与控制器间的过于紧密的连接让 Controller 的复用性成了问题,如果想多个 View 共用一个 Controller 该怎么办呢?
前端 MVP 模式
MVP(Model-View-Presenter)是 MVC 模式的改良。和 MVC 的相同之处在于:Controller/Presenter 负责业务逻辑,Model 管理数据,View 负责显示。

在 MVC 里,View 是可以直接访问 Model 的。而 MVP 中的 View 并不能直接使用 Model,而是通过为 Presenter 提供接口,让 Presenter 去更新 Model,再通过观察者模式更新 View。
与 MVC 相比,MVP 模式通过解耦 View 和 Model,完全分离视图和模型使职责划分更加清晰;由于 View 不依赖 Model,可以将 View 抽离出来做成组件,它只需要提供一系列接口提供给上层操作。
// Model
myapp.Model = function() {
var val = 0;

this.add = function(v) {
if (val < 100) val += v;
};

this.sub = function(v) {
if (val > 0) val -= v;
};

this.getVal = function() {
return val;
};
};
Model 层依然是主要与业务相关的数据和对应处理数据的方法,很简单。
// View
myapp.View = function() {
var $num = $(‘#num’),
$incBtn = $(‘#increase’),
$decBtn = $(‘#decrease’);

this.render = function(model) {
$num.text(model.getVal() + ‘rmb’);
};

this.init = function() {
var presenter = new myapp.Presenter(this);

$incBtn.click(presenter.increase);
$decBtn.click(presenter.decrease);
};
};
MVP 定义了 Presenter 和 View 之间的接口,用户对 View 的操作都转移到了 Presenter。比如这里的 View 暴露 setter 接口(render 方法)让 Presenter 调用,待 Presenter 通知 Model 更新后,Presenter 调用 View 提供的接口更新视图。
// Presenter
myapp.Presenter = function(view) {
var _model = new myapp.Model();
var _view = view;

_view.render(_model);

this.increase = function() {
_model.add(1);
_view.render(_model);
};

this.decrease = function() {
_model.sub(1);
_view.render(_model);
};
};
Presenter 作为 View 和 Model 之间的“中间人”,除了基本的业务逻辑外,还有大量代码需要对从 View 到 Model 和从 Model 到 View 的数据进行“手动同步”,这样 Presenter 显得很重,维护起来会比较困难。如果 Presenter 对视图渲染的需求增多,它不得不过多关注特定的视图,一旦视图需求发生改变,Presenter 也需要改动。
前端 MVVM 模式
MVVM(Model-View-ViewModel)最早由微软提出。ViewModel 指 “Model of View”——视图的模型。

MVVM 把 View 和 Model 的同步逻辑自动化了。以前 Presenter 负责的 View 和 Model 同步不再手动地进行操作,而是交给框架所提供的数据绑定功能进行负责,只需要告诉它 View 显示的数据对应的是 Model 哪一部分即可。
我们使用 Vue 来完成这个栗子。
在 MVVM 中,我们可以把 Model 称为数据层,因为它仅仅关注数据本身,不关心任何行为(格式化数据由 View 的负责),这里可以把它理解为一个类似 json 的数据对象。
// Model
var data = {
val: 0
};
和 MVC/MVP 不同的是,MVVM 中的 View 通过使用模板语法来声明式的将数据渲染进 DOM,当 ViewModel 对 Model 进行更新的时候,会通过数据绑定更新到 View。
<!– View –>
<div id=”myapp”>
<div>
<span>{{val}}rmb</span>
</div>
<div>
<button v-on:click=”sub(1)”>-</button>
<button v-on:click=”add(1)”>+</button>
</div>
</div>
ViewModel 大致上就是 MVC 的 Controller 和 MVP 的 Presenter 了,也是整个模式的重点,业务逻辑也主要集中在这里,其中的一大核心就是数据绑定。与 MVP 不同的是,没有了 View 为 Presente 提供的接口,之前由 Presenter 负责的 View 和 Model 之间的数据同步交给了 ViewModel 中的数据绑定进行处理,当 Model 发生变化,ViewModel 就会自动更新;ViewModel 变化,Model 也会更新。
new Vue({
el: ‘#myapp’,
data: data,
methods: {
add(v) {
if(this.val < 100) {
this.val += v;
}
},
sub(v) {
if(this.val > 0) {
this.val -= v;
}
}
}
});
整体来看,比 MVC/MVP 精简了很多,不仅仅简化了业务与界面的依赖,还解决了数据频繁更新(之前用 jQuery 操作 DOM 很繁琐)的问题。因为在 MVVM 中,View 不知道 Model 的存在,ViewModel 和 Model 也察觉不到 View,这种低耦合模式可以使开发过程更加容易,提高应用的可重用性。
数据绑定

在 Vue 中,使用了双向绑定技术(Two-Way-Data-Binding),就是 View 的变化能实时让 Model 发生变化,而 Model 的变化也能实时更新到 View。其实双向数据绑定,可以简单地理解为一个模版引擎,但是会根据数据变更实时渲染。
有人还不要脸的申请了专利:

数据变更检测
不同的 MVVM 框架中,实现双向数据绑定的技术有所不同。目前一些主流的实现数据绑定的方式大致有以下几种:
手动触发绑定
手动触发指令绑定是比较直接的实现方式,主要思路是通过在数据对象上定义 get() 方法和 set() 方法,调用时手动触发 get () 或 set() 函数来获取、修改数据,改变数据后会主动触发 get() 和 set() 函数中 View 层的重新渲染功能。
脏检测机制
Angularjs 是典型的使用脏检测机制的框架,通过检查脏数据来进行 View 层操作更新。
脏检测的基本原理是在 ViewModel 对象的某个属性值发生变化时找到与这个属性值相关的所有元素,然后再比较数据变化,如果变化则进行 Directive 指令调用,对这个元素进行重新扫描渲染。
前端数据对象劫持
数据劫持是目前使用比较广泛的方式。其基本思路是使用 Object.defineProperty 和 Object.defineProperies 对 ViewModel 数据对象进行属性 get () 和 set() 的监听,当有数据读取和赋值操作时则扫描元素节点,运行指定对应节点的 Directive 指令,这样 ViewModel 使用通用的等号赋值就可以了。
Vue 就是典型的采用数据劫持和发布订阅模式的框架。

Observer 数据监听器:负责对数据对象的所有属性进行监听(数据劫持),监听到数据发生变化后通知订阅者。
Compiler 指令解析器:扫描模板,并对指令进行解析,然后绑定指定事件。
Watcher 订阅者:关联 Observer 和 Compile,能够订阅并收到属性变动的通知,执行指令绑定的相应操作,更新视图。

ES6 Proxy
之前我们说过 Proxy 实现数据劫持的方法:
总结来看,前端框架从直接 DOM 操作到 MVC 设计模式,然后到 MVP,再到 MVVM 框架,前端设计模式的改进原则一直向着高效、易实现、易维护、易扩展的基本方向发展。虽然目前前端各类框架也已经成熟并开始向高版本迭代,但是还没有结束,我们现在的编程对象依然没有脱离 DOM 编程的基本套路,一次次框架的改进大大提高了开发效率,但是 DOM 元素运行的效率仍然没有变。对于这个问题的解决,有的框架提出了 Virtual DOM 的概念。
Virtual DOM
MVVM 的前端交互模式大大提高了编程效率,自动双向数据绑定让我们可以将页面逻辑实现的核心转移到数据层的修改操作上,而不再是在页面中直接操作 DOM。尽管 MVVM 改变了前端开发的逻辑方式,但是最终数据层反应到页面上 View 层的渲染和改变仍是通过对应的指令进行 DOM 操作来完成的,而且通常一次 ViewModel 的变化可能会触发页面上多个指令操作 DOM 的变化,带来大量的页面结构层 DOM 操作或渲染。
比如一段伪代码:
<ul>
<li repeat=”list”>{{list.value}}</li>
</ul>

let viewModel = new VM({
data:{
list:[{value: 1},{value: 2},{value: 3}]
}
})
使用 MVVM 框架生成一个数字列表,此时如果需要显示的内容变成了 [{value: 1}, {value: 2}, {value: 3}, {value: 4}],在 MVVM 框架中一般会重新渲染整个列表,包括列表中无须改变的部分也会重新渲染一次。但实际上如果直接操作改变 DOM 的话,只需要在 <ul> 子元素最后插入一个新的 <li> 元素就可以了。但在一般的 MVVM 框架中,我们通常不会这样做。毫无疑问,这种情况下 MVVM 的 View 层更新模式就消耗了更多没必要的性能。
那么该如何对 ViewModel 进行改进,让浏览器知道实际上只是增加了一个元素呢?通过对比
[{value: 1},{value: 2},{value: 3}] 和 [{value: 1}, {value: 2}, {value: 3}, {value: 4}]
其实只是增加了一个 {value: 4},那么该怎样将这个增加的数据反映到 View 层上呢?可以将新的 Model data 和旧的 Model data 进行对比,然后记录 ViewModel 的改变方式和位置,就知道了这次 View 层应该怎样去更新,这样比直接重新渲染整个列表高效得多。
这里其实可以理解为,ViewModel 里的数据就是描述页面 View 内容的另一种数据结构标识,不过需要结合特定的 MVVM 描述语法编译来生成完整的 DOM 结构。
可以用 JavaScript 对象的属性层级结构来描述上面 HTML DOM 对象树的结构,当数据改变时,新生成一份改变后的 Elements,并与原来的 Elemnets 结构进行对比,对比完成后,再决定改变哪些 DOM 元素。

刚才例子里的 ulElement 对象可以理解为 VirtualDOM。通常认为,Virtual DOM 是一个能够直接描述一段 HTMLDOM 结构的 JavaScript 对象,浏览器可以根据它的结构按照一定规则创建出确定唯一的 HTML DOM 结构。整体来看,Virtual DOM 的交互模式减少了 MVVM 或其他框架中对 DOM 的扫描或操作次数,并且在数据发生改变后只在合适的地方根据 JavaScript 对象来进行最小化的页面 DOM 操作,避免大量重新渲染。
diff 算法
Virtual-DOM 的执行过程:
用 JS 对象模拟 DOM 树 -> 比较两棵虚拟 DOM 树的差异 -> 把差异应用到真正的 DOM 树上
在 Virtual DOM 中,最主要的一环就是通过对比找出两个 Virtual DOM 的差异性,得到一个差异树对象。
对于 Virtual DOM 的对比算法实际上是对于多叉树结构的遍历算法。但是找到任意两个树之间最小的修改步骤,一般会循环递归对节点进行依次对比,算法复杂度达到 O(n^3),这个复杂度非常高,比如要展示 1000 多个节点,最悲观要依次执行上十亿次的比较。所以不同的框架采用的对比算法其实是一个略简化的算法。
拿 React 来说,由于 web 应用中很少出现将一个组件移动到不同的层级,绝大多数情况下都是横向移动。因此 React 尝试逐层的对比两棵树,一旦出现不一致,下层就不再比较了,在损失较小的情况下显著降低了比较算法的复杂度。

前端框架的演进非常快,所以只有知道演进的原因,才能去理解各个框架的优劣,从而根据应用的实际情况来选择最合适的框架。对于其他技术也是如此。

正文完
 0