前端技術演進(五):現代前端互動框架

姜小抖發表於2018-12-14
這個來自之前做的培訓,刪減了一些業務相關的,參考了很多資料(參考資料列表),謝謝前輩們,麼麼噠 ?

隨著前端技術的發展,前端框架也在不斷的改變。

操作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

前端框架的演進非常快,所以只有知道演進的原因,才能去理解各個框架的優劣,從而根據應用的實際情況來選擇最合適的框架。對於其他技術也是如此。


相關文章