Dive into Vue.js

百度外賣大前端技術團隊發表於2017-12-28

摘要

Vue.js作為先進的前端MVVM框架,在外賣已經廣泛應用在各業務線中。本文闡述了Vue.js作為前端MVVM框架的主要優勢,並從Vue.js的三個核心點:Observer, Watcher, Compiler出發,深入闡述Vue.js的設計與實現原理。

背景

Vue.js 是一個輕量級的前端 MVVM 框架,專注於web檢視(View)層的開發 。 自2013年以來,Vue.js 通過其可擴充套件的資料繫結機制、極低的上手成本、簡潔明瞭的API、高效完善的元件化設計等特性,吸引了越來越多的開發者。在github上已經有30,000+ star,且不斷在增長;在國內外都有廣泛的應用,社群和配套工具也在不斷完善,影響力日益擴大,與React 、AngularJS 這種「世界級框架」幾乎平起平坐。 外賣B端的FE同學比較早(0.10.x版本)就引入了 Vue.js 進行業務開發,經過一年多的實踐,積累了一定的理解。在此基礎上,我們希望去更深入地瞭解 Vue.js , 而不是一直停留在表面。所以「閱讀原始碼」成為了一項課外任務。 我個人從9月份開始閱讀Vue的原始碼,陸陸續續看了2個月,這裡是我的原始碼學習筆記。本篇文章希望從 Vue.js 1.0版本的設計和實現為主線,闡述自己閱讀原始碼的一些心得體會。

前端為什麼需要有MVVM框架?

前端刀耕火種的歷史在這裡就不贅述,在jquery 等DOM操作庫大行其道的年代,主要的開發痛點集中於:

當資料更新時,需要開發者主動地使用 DOM API 操作DOM;當DOM發生變化時,需要開發者去主動獲取DOM的資料,把資料同步或提交; 同樣的資料對映到不同的檢視時,需要新的一套DOM操作,複用性低; 大量的DOM操作使得業務邏輯繁瑣冗長,程式碼的可維護性差。 於是,問題的聚焦點就在於:

業務邏輯應該專注在運算元據(Model),更新DOM不希望有大量的顯式操作。 在資料和檢視間做到同步(資料繫結和更新監聽),不需要人為干預。 同樣的資料可以方便地對應多個檢視。 此外,還應該做到的一些特性:

方便地實現檢視邏輯(宣告式或命令式); 方便地建立和連線、複用元件; 管理狀態和路由。 MVVM框架可以很好地解決以上問題。通過 ViewModel 對 View 和 Model 進行橋接,而Model 和 ViewModel 之間的互動是雙向的,View 資料的變化會同步到 Model 中,Model 中的資料變化也會立即反應到 View 上,即我們通常所說的「雙向繫結」。這是不需要人為干涉的,所以開發者只需要關注業務邏輯,其他的DOM操作、狀態維護等都由MVVM框架來實現。

Why Vue?

Vue.js的優點主要體現在:

開發者的上手成本很低,開發體驗好。 如果使用過 angular 的同學就知道,裡面的API多如牛毛,而且還會要求開發者去熟悉類似 controller , directive ,dependency injection , digest cycle 這些概念; angular2 更是需要提前去了解 Typescript 、RxJS 等基礎知識; 要讓一個前端小白去搞定 React 的全家桶,ES6 + Babel , 函數語言程式設計 ,JSX , 工程化構建 這些也是必需要過的檻。 Vue.js 就沒有這些開發負擔,對開發者遮蔽了一系列複雜的概念,API從數量、設計上都十分精簡,很接地氣地支援js的各種方言,讓前端小白可以快速上手 — 當然,對於有一定經驗的同學,也可以使用流行的語言、框架、庫、工程化工具來做自由合理搭配。

博採眾長,整合各種優秀特性 — Vue.js 裡面有像 angular 這樣的雙向資料繫結,2.0版本也提供了像 React 這樣的 JSX,Virtual-DOM ,服務端同構 的特性;同時 vuex ,vue-router ,vue-cli 等配套工具也組成了一個完整的框架生態。

效能優秀。 Vue.js 在1.x版本的時候效能已經明顯優於同期基於 dirty check (條件性全量髒檢查) 的 angular 1.x ;總體上來說,Vue.js 1.x版本 的效能與React 的效能相近,而且 Vue.js 不需要像React 那樣去手動宣告shouldComponentUpdate 來優化狀態變更時的重新渲染的效能。Vue2.0版本使用了Virtual DOM + Dependency Tracking 方案,效能得到進一步優化。當然,不分場景的效能比較屬於耍流氓。 這個benchmark 對比了主流前端框架的效能,可以看出 Vue.js 的效能在大部分場景下都屬於業界頂尖。

Vue.js的繫結設計思路

根據上面篇幅的描述,MVVM框架工作的重中之重是建立 View 和 Model 之間的關係。也就是「繫結」。官方文件的附圖說明了這一點:

Dive into Vue.js

從上圖,只能得到一些基(cu)礎(qian)的認識:

Model是一個 POJO 物件,即簡單javascript物件。 View 通過 DOM Listener 和 Model 建立繫結關係。 Model 通過 Directives(指令),如 {{a}} , v-text="a" 與 View 建立繫結關係。 而實際上要做的工作還是很多的:

讓 Model 中的資料做到 Reactive ,即在狀態變更(資料變化)時,系統能做出響應。 Directives(指令) 混雜在一個html片段(fragment,或者你可以理解就是Vue例項中的 template )中,需要正確解析指令和表示式(expression),不同的指令需要對應不同的DOM更新方式,最易理解的例子就是 v-if 和 v-show ; Model 的更新觸發指令的檢視更新需要有一定的機制來保證; 在 DOM Listener 這塊,需要抹平不同瀏覽器的差異。 Vue.js 在實現「繫結」方面,為所有的指令(directives)都約定了 bind 和 update 方法,即:

解析完指令後,應該如何繫結資料 資料更新時,怎樣更新DOM Vue.js 的解決方案中,提出了幾個核心的概念:

Observer : 資料觀察者,對所有 Model 資料進行 defineReactive,即使所有 Model 資料在資料變更時,可以通知資料訂閱者。 Watcher : 訂閱者,訂閱並收到所有 Model 變化的通知,執行對應的指令(表示式)繫結函式 Dep : 訊息訂閱器,用於收集 Watcher , 資料變化時,通知訂閱者進行更新。 Compiler : 模板解析器,可對模板中的指令、表示式、屬性(props)進行解析,為檢視繫結相應的更新函式。 可見這裡面的核心思想是大家(特別FE同學)都很熟悉的「觀察者模式」。總體的設計思路如下:

Dive into Vue.js

回到剛才說的 bind 與 update,我們看看上述概念是如何工作的:

在初始化檢視,即繫結階段,Observer 獲取 new Vue() 中的data資料,通過Object.defineProperty 賦予 getter 和 setter; 對於陣列 形式的資料,通過劫持某些方法讓陣列在變動時也能得到通知。另外,Compiler 對DOM節點指令進行掃描和解析,並訂閱Watcher 來更新檢視。Watcher 在 訊息訂閱器 Dep 中進行管理。 在更新階段,當資料變更時,會觸發 setter 函式,立即會觸發相應的通知, 開始遍歷所有訂閱者,呼叫其指令的update方法,進行檢視更新。 OK,下面我們繼續深入看三個「核心點」,即 Observer, Compiler, Watcher 的實現原理。

資料觀察者 Observer 的實現原理

前面提到,Observer 的核心是對 Model(data) 中的資料進行 defineReactive。 這裡的實現如下:

Dive into Vue.js

Vue.js 在初始化data時,會先將data中的所有屬性代理到Vue例項下(方便使用 this 關鍵字來訪問),然後即呼叫 Observer 來進行資料觀察。 Observer會先將所有 data 中的屬性進行整體觀察,定義一個屬性__ob__ ,進行Object.defineProperty,即為 data 本身新增觀察器。 同樣,對於data中的每個屬性也會使用 ob 為每個屬性本身新增觀察器。 同理,當定義了類似如下的屬性值為POJO物件時,會去遞迴地 Object.defineProperty ;

{
    data: {
        a: {
            b: "c"
        }
    },
    d: [1, 2, 3]
}
複製程式碼

那麼,當定義的屬性值為陣列時,在陣列本身通過方法變化時,也需要監聽陣列的改變。 通過javascript運算元組的變化無外乎以下幾種方式:

通過 push , pop 等陣列原生方法進行改變; 通過length屬性進行改變,如 arr.length = 0; 通過角標賦值, 如 arr[0] = 1 。 對於陣列原生的方法,我們需要在使用這些方法時同時觸發事件,讓系統知道這個陣列改變了。那麼,我們通常會想到去「劫持」陣列本身的方法。但是顯然,我們不能直接去覆寫 Array.prototype , 這樣的全域性覆寫顯然會對其他不需要響應式資料的陣列操作產生影響。 Vue.js 的思路在於,當監測到一個data屬性值是Array時,去覆寫這個屬性值陣列的 proto 屬性,即只覆寫響應式資料的原型變數。核心實現如下:

function Observer(value) {
    this.value = value
    this.dep = new Dep()
    _.define(value, '__ob__', this)
    // 如果判斷當前值是Array, 進行劫持操作
    if (_.isArray(value)) {
        var augment = _.hasProto
            ? protoAugment
            : copyAugment
        // 在這裡,arrayMethods是進行了劫持操作後的陣列原型
        // augment的作用即是覆寫原型方法
        augment(value, arrayMethods, arrayKeys)
        this.observeArray(value)
    } else {
        // 遞迴地defineProperty
        this.walk(value)
    }
}
複製程式碼

對於直接改變陣列length來修改陣列、角標賦值,顯然不能直接劫持。這時一種實現方式是把原生的 Array 做上層包裝,變成一個Array like Object, 再在這裡面進行defineProperty, 這樣可以搞定length和角標賦值。但是這樣做的弊端是,每次在使用陣列時都需要顯式去呼叫這個物件,如:

var a = new ObservableArray([1, 2, 3, 4]);
複製程式碼

這樣顯然增加了開發者上手成本,而且改變length可以通過splice來實現;所以 Vue.js 並沒有實現此功能,是一種正確的取捨。 對於角標賦值,還是有一定的使用場景,所以 Vue.js 擴充套件了 $set 和 $remove 方法來實現。 這兩種方法實質還是在使用可被劫持的 splice,而被劫持的方法可以觸發檢視更新。

example1.items[0] = { childMsg: 'Changed!'} // 不能觸發檢視更新
example1.items.$set(0, { childMsg: 'Changed!'}) // 可以觸發檢視更新
複製程式碼
  • 監測Object物件的改變,有一個提案期的 Object.observe() 方法,但現在已經被瀏覽器標準廢棄,各瀏覽器均不再支援。同樣有一個非標準的監視方法Object.watch() 被Firefox 支援,但不具備通用性。
  • 監測陣列物件的改變,同樣有一個提案性的Array.observe()。它不僅能監視陣列方法,還可以監視角標賦值等變化。但是這個提案也已經被廢棄。
  • 理論上,使用 ES6 的 Proxy 物件也可以進行 get 和 set 的攔截, 但瀏覽器支援情況並不好,實用性不高。
  • 所以,當前的條件下 Object.defineProperty 仍是最好的選擇 — 當然,在IE8及更低版本瀏覽器盛行的年代,基於此特性的MVVM框架就很難大規模被普及。

解析器Compiler的實現

Compiler 的作用主要是解析傳入的元素 el 或模板 template ,建立對應的DOMFragment,提取指令(directive)並執行指令相關方法,並對每個指令生成Watcher。 主要的入口點是掛載在Vue.prototype下的 _compile 方法(實際內容在instance/lifecycle.js, Vue1.x的不同版本位置略有不同)。 整個 _compile 方法的流程如下:

Dive into Vue.js

首先執行 transclude() , 實際是在處理template標籤或 options.template 字串,將其解析為DOMFragment, 拿到 el 物件。 其次執行_initElement(el), 將拿到的el物件進行例項掛載。 接著是CompileRoot(el, options) ,解析當前根例項DOM上的屬性(attrs); 然後執行Compile(el, options),解析template,返回一個link Funtion(compositeLinkFn)。 最後執行這個compositeLinkFn,建立 Compile(el, options) 的具體流程如下:

Dive into Vue.js

首先,compile過程的基礎函式是compileNode, 在檢測到當前節點有子節點的時候,遞迴地呼叫compileNode即對DOM樹進行了遍歷解析。 接著對節點進行判斷,使用comileElement 或 compileTextNode 進行解析。 我們看到最終compile的結果return了一個compositeLinkFn, 這個函式的作用是把指令例項化,將指令與新建元素建立連線,並將元素替換到DOM樹中。 compositeLinkFn會先執行通過comileElement 或 compileTextNode 產出的Linkfn 來建立指令物件。 在指令物件初始化時,不但呼叫了指令的bind, 還定義了 this._update 方法,並建立了 Watcher,把 this._update 方法(實際對應指令的更新方法)作為 Watcher 的回撥函式。 這裡把 Directive 和 Watcher 做了關聯,當 Watcher 觀察到指令表示式值變化時,會呼叫 Directive 例項的 _update 方法,最終去更新 DOM 節點。 以compileTextNode為例,寫一段虛擬碼表示這個過程:

// compile結束後返回此函式
function compositeLinkFn(arguments) {
    linkAndCapture()
    // 返回解綁指令函式,這裡不深究。
    return makeUnlinkFn(arguments)
}
function linkAndCapture(arguments) {
    // 建立指令物件
    linkFn()
    // 遍歷 directives 呼叫 dirs[i]._bind 方法對單個directive做一些繫結操作
    // 這裡會去例項化單個指令,執行指令的bind()函式,並建立Watcher
    dirs[i]._bind()
}
// 解析TextNode節點,返回了linkFn
function compileTextNode(node) {
    // 對節點資料進行解析,生成tokens
    var tokens = textParser.parse(node.data)
    createFragment()
    // 建立token的描述,作為後續生成指令的依據
    setTokenDescriptor()
    /**
   do other things
   **/
    return linkFn(tokens, ...);
}
// linkFn遍歷token,遍歷執行_bindDir, 傳入token描述
function linkFn() {
    tokens.forEach(function (token) {
        if (token.html) replaceHtml();
        vm._bindDir(token.discriptor)
    })
}
// 根據token描述建立指令新例項
Vue.prototype._bindDir = function (descriptor) {
    this._directives.push(new Directive(descriptor))
}
複製程式碼

至此,compiler 的工作就結束了。

Watcher訂閱監聽的實現

Watcher的職責

在上述compiler的實現中,最後一步用於建立Watcher:

// 為每個directive指令建立一個watcher dirs[i]._bind() Directive.prototype._bind = function () { ... // 建立Watcher部分 var watcher = this._watcher = new Watcher( this.vm, this.expression, this._update, // callback { filters: this.filters, twoWay: this.twoWay, deep: this.deep, preProcess: preProcess, postProcess: postProcess, scope: this._scope } ) } 接收的引數是vm例項、expression表示式、 callback回撥函式和相應的Watcher配置, 其中包含了上下文資訊: this._scope。 每個指令都會有一個watcher, 實時去監控表示式的值,如果發生變化,則通知指令執行 _update 函式去更新對應的DOM。那麼我們可以想到,watcher主要做的工作是:

  • 解析表示式,如 v-show="a.b.c > 1" ; 表示式需要轉換成函式來求值;
  • 自動收集依賴。

watcher的實現

這部分工作的實現如下:

Dive into Vue.js

使用狀態機進行路徑解析

這裡的parse Expression使用了路徑狀態機(state machine)進行路徑的高效解析。 詳細程式碼見 parsers/path.js 部分。 這裡所謂的「路徑」就是指一個物件的屬性訪問路徑:

a = {
    b: {
        c: 'd'
    }
}
複製程式碼

在這裡, ‘d’的訪問路徑即是 a.b.c, 解析後為['a', 'b', 'c']。 如一個表示式 a[0].b.c, 解析後為 ['a', '0', 'b', 'c']。 表示式a[b][c]則解析為 ['a', '*b', '*c']。 解析的目的是進行compileGetter, 即 getter 函式; 解析為陣列原因是,可以方便地還原new Function()構造中正確的字串。

exports.compileGetter = function (path) {
    var body = 'return o' + path.map(formatAccessor).join('')
    return new Function('o', body)
}
function formatAccessor(key) {
    if (identRE.test(key)) { // identifier
        return '.' + key
    } else if (+key === key >>> 0) { // bracket index
        return '[' + key + ']'
    } else if (key.charAt(0) === '*') {
        return '[o' + formatAccessor(key.slice(1)) + ']'
    } else { // bracket string
        return '["' + key.replace(/"/g, '\\"') + '"]'
    }
}
複製程式碼

如一段表示式:

<p>{{list[0].text}}</p>
解析後path為["list", "0", "text"], getter函式的生成結果為:
複製程式碼
(function (o/**/) {
    return o.list[0].text
})
複製程式碼

把正確的上下文傳入此函式即可正確取值。 Vue.js 僅在路徑字串中帶有 [ 符號時才會使用狀態機進行匹配;其他情況下認為它是一個simplePath, 如a.b.c,直接使用上述的formatAccessor轉換即可。 狀態機的工作原理如下:

Dive into Vue.js

裡面的邏輯比較複雜,可以簡單地描述為:

  • Vue.js 裡面維護了一套狀態機機制,每解析一個字元,均匹配對應的狀態;
  • 如當前的字元索引是0,那麼就會有一個「當前狀態」的模式(mode),這個模式只允許下一個字元屬於特定的狀態模式。舉例,如 ][ 這樣的表示式顯然不合理, "]"字元所在的狀態,決定了下個字元不能為 "[" 這樣的
  • 字元,否則就會退出解析;
  • 接下來的索引去根據「當前狀態」的模式看自己是否屬於一個合理的狀態;
  • 如果屬於一個合理的狀態,先設定當前狀態的模式為當前字元匹配的狀態模式;
  • 再呼叫相關的方法(action)來處理。例如,list[0]這個表示式,在處理"l", "i", "s", "t" 時,只是在執行 append action , 生成"list" ; 直到遇到 "[" , 執行 push action , 把"list"字串推入結果中。

Vue.js 的狀態機設計可以看勾三股四總結的這張圖。

快取系統

想象一個場景:當存在著大量的路徑(path)需要解析時,很可能會有大量重複的情況。如上面所述,狀態機解析是一個比較繁瑣的過程。那麼就需要一個快取系統,一旦路徑表示式命中快取,即可直接獲取,而不用再次解析。 快取系統的設計應該考慮以下幾點:

快取的資料應是有限的。否則容易資料過多記憶體溢位。 設定資料儲存條數應結合實際情況,通過測試給出。 快取資料達到上限時,如果繼續有資料存入,應該有相應的策略去清除現有快取。 Vue.js 在快取系統上直接使用了js-lru專案。這是一個LRU(Least Recently Used)演算法的實現。核心思路如下:

基礎資料結構為js實現的一個雙向連結串列。 cache物件有頭尾,即一個head(最少被使用的項)和tail(最近被使用的項)。 快取中的每一項均有newer和older的指標。 快取中找資料使用object key進行查詢。 具體實現如下圖:

Dive into Vue.js

由圖理解非常簡單:

  • 獲取快取項B,把B插入變為tail, D和B建立 newer,older 關係,A和C建立newer, older關係;
  • 設定快取項E,把E插入變為tail, D和E建立 newer,older 關係;
  • 達到快取上限時,刪除快取項A(head),把B變成head。

快取系統的其他實現,可以參考wikipedia上的Cache replacement policies。 依賴收集 (Dependency Collection) 讓我們回到Watcher的建構函式:

function Watcher(vm, expOrFn, cb, options) {
    //...
    // 解析表示式,得到getter和setter函式
    var res = parseExpression(arguments)
    this.getter = res.get
    this.setter = res.get
    // 設定Dep.target為當前Watcher例項
    Dep.target = this
    // 呼叫getter函式
    try {
        value = this.getter.call(scope, scope)
    } catch (e) {
        //...
    }
}
複製程式碼

這裡面又有什麼玄機呢?回顧一下 Observer 的 defineReactive :

function defineReactive(obj, key, val) {
    var dep = new Dep()
    var childOb = Observer.create(val)
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get: function metaGetter() {
            // 如果Dep.target存在,則進行依賴收集
            if (Dep.target) {
                dep.depend()
                if (childOb) {
                    childOb.dep.depend()
                }
            }
            return val
        },
        set: function metaSetter(newVal) {
            if (newVal === val) return
            val = newVal
            childOb = Observer.create(newVal)
            dep.notify()
        }
    })
}
複製程式碼

可見, Watcher 把 Dep.target 設定成當前Watcher例項, 並主動呼叫了 getter,那麼此時必然會進入 dep.depend() 函式。 dep.depend() 實際執行了 Watcher.addDep() :

Watcher.prototype.addDep = function (dep) {
    var id = dep.id
    if (!this.newDeps[id]) {
        this.newDeps[id] = dep
        if (!this.deps[id]) {
            this.deps[id] = dep
            dep.addSub(this)
        }
    }
}
複製程式碼

可以看出,Watcher 把 dep 設定為當前例項的依賴,同時 dep 設定(新增)當前 Watcher為一個訂閱者。至此完成了依賴收集。 從上面 defineReactive 中的 setter 函式也可知道,當資料改變時,Dep 進行通知 (dep.notify()), 遍歷所有的訂閱者(Watcher), 將其推入非同步佇列,使用訂閱者的update方法,批量地更新DOM。 至此 Watcher 的工作就完成了。

  • 實際上,Watcher的依賴收集機制也是實現 computed properties ( 計算屬性)的基礎;核心都是劫持 getter , 觸發通知,收集依賴。
  • Vue.js 初期對於計算屬性,強制要求開發者設定 getter 方法,後期直接在 computed 屬性中搞定,對開發者很友好。
  • 推薦看一下這篇文章:資料的關聯計算。

Vue.js的其他黑魔法

由於篇幅所限,本文討論的內容主要在Observer, Compiler, Watcher 這些核心模組上;但實際上,Vue.js 原始碼(或歷史原始碼)中還有大量的其他優秀實現,如:

  • batcher 非同步佇列
  • v-for 中的DOM diff演算法
  • exp parser 曾經借鑑了 artTemplate 模板引擎
  • template parser 借鑑了 jquery
  • transition過渡系統設計

等等。其他的程式碼解耦、工程化、測試用例等也是非常好的學習例子。 此外,如果是對 Vue.js 的原始碼演進過程比較熟悉的同學,就會發現 Vue.js 的核心思想是(借用尤大自己的表述):

“把高大上的思想變得平易近人”

從框架概念、開發體驗、api設計、全家桶設計等多個方面,Vue.js 都不斷地往友好和簡潔方向努力,這也是現在這麼火爆的原因吧。

如何閱讀開源專案的原始碼?

最後,想把一些讀原始碼的體驗和各位同學分享:

  • 不要一上來就看最新版本的實現,因為很難看懂。反而去看最初的實現,容易瞭解作者的核心理念。
  • 帶著問題和測試用例去看原始碼。
  • 多使用除錯工具,跑各種測試流程,光看就能看懂…除非你是人肉編譯機。
  • 找一個(或多個)小夥伴和你一起看原始碼,多交流,效果比一個人好很多。
  • 持之以恆,如果不保持閱讀持續性,很容易遺忘和失去學習興趣。
  • 持續性地總結,好記性不如爛筆頭,真正從你自己總結出來的,才是被你吸收的知識。

以上,共勉。

相關文章