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

2,618 阅读12分钟
这个来自之前做的培训,删减了一些业务相关的,参考了很多资料(参考资料列表),谢谢前辈们,么么哒 😘

随着前端技术的发展,前端框架也在不断的改变。

操作DOM时代

DOM(Document Object Model,文档对象模型)将 HTML 文档表达为树结构,并定义了访问和操作 HTML 文档的标准方法。

image.png | center | 486x266

前端开发基本上都会涉及到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的必要性在逐渐降低。youmightnotneedjquery.com/

渐渐地,SPA(Single Page Application,单页面应用)开始被广泛认可,整个应用的内容都在一个页面中并完全通过异步交互来加载不同的内容,这时候使用 jQuery 直接操作DOM的方式就不容易管理了,页面上事件的绑定会变得混乱,在这种情况下,迫切需要一个可以自动管理页面上DOM和数据之间交互操作的框架。

MV* 模式

MVC,MVP和MVVM都是常见的软件架构设计模式(Architectural Pattern),它通过分离关注点来改进代码的组织方式。

单纯从概念上,很难区分和感受出来这三种模式在前端框架中有什么不同。我们通过一个例子来体会一下:有一个可以对数值进行加减操作的组件:上面显示数值,两个按钮可以对数值进行加减操作,操作后的数值会更新显示。

image.png | center | 512x360

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 模式

image.png | center | 500x320

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负责显示。

image.png | center | 500x320

在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"——视图的模型。

image.png | center | 500x320

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,这种低耦合模式可以使开发过程更加容易,提高应用的可重用性。

数据绑定

image.png | center | 500x320

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

有人还不要脸的申请了专利:

image.png | center | 747x757

数据变更检测

不同的MVVM框架中,实现双向数据绑定的技术有所不同。目前一些主流的实现数据绑定的方式大致有以下几种:

手动触发绑定

手动触发指令绑定是比较直接的实现方式,主要思路是通过在数据对象上定义get()方法和set()方法,调用时手动触发get ()或set()函数来获取、修改数据,改变数据后会主动触发get()和set()函数中View层的重新渲染功能。

脏检测机制

Angularjs是典型的使用脏检测机制的框架,通过检查脏数据来进行View层操作更新。

脏检测的基本原理是在ViewModel对象的某个属性值发生变化时找到与这个属性值相关的所有元素,然后再比较数据变化,如果变化则进行Directive 指令调用,对这个元素进行重新扫描渲染。

前端数据对象劫持

数据劫持是目前使用比较广泛的方式。其基本思路是使用 Object.defineProperty 和 Object.defineProperies 对ViewModel数据对象进行属性get ()和set()的监听,当有数据读取和赋值操作时则扫描元素节点,运行指定对应节点的Directive指令,这样ViewModel使用通用的等号赋值就可以了。

Vue就是典型的采用数据劫持和发布订阅模式的框架。

image.png | center | 827x256

  • 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元素。

image.png | left | 827x581

刚才例子里的 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尝试逐层的对比两棵树,一旦出现不一致,下层就不再比较了,在损失较小的情况下显著降低了比较算法的复杂度。

image.png | center | 377x199

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