Vue.js做爲先進的前端MVVM框架,在外賣已經普遍應用在各業務線中。本文闡述了Vue.js做爲前端MVVM框架的主要優點,並從Vue.js的三個核心點:Observer, Watcher, Compiler出發,深刻闡述Vue.js的設計與實現原理。javascript
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版本的設計和實現爲主線,闡述本身閱讀源碼的一些心得體會。html
前端刀耕火種的歷史在這裏就不贅述,在jquery 等DOM操做庫大行其道的年代,主要的開發痛點集中於:前端
當數據更新時,須要開發者主動地使用 DOM API 操做DOM;當DOM發生變化時,須要開發者去主動獲取DOM的數據,把數據同步或提交; 一樣的數據映射到不一樣的視圖時,須要新的一套DOM操做,複用性低; 大量的DOM操做使得業務邏輯繁瑣冗長,代碼的可維護性差。 因而,問題的聚焦點就在於:vue
業務邏輯應該專一在操做數據(Model),更新DOM不但願有大量的顯式操做。 在數據和視圖間作到同步(數據綁定和更新監聽),不須要人爲干預。 一樣的數據能夠方便地對應多個視圖。 此外,還應該作到的一些特性:java
方便地實現視圖邏輯(聲明式或命令式); 方便地建立和鏈接、複用組件; 管理狀態和路由。 MVVM框架能夠很好地解決以上問題。經過 ViewModel 對 View 和 Model 進行橋接,而Model 和 ViewModel 之間的交互是雙向的,View 數據的變化會同步到 Model 中,Model 中的數據變化也會當即反應到 View 上,即咱們一般所說的「雙向綁定」。這是不須要人爲干涉的,因此開發者只須要關注業務邏輯,其餘的DOM操做、狀態維護等都由MVVM框架來實現。node
Vue.js的優勢主要體如今:jquery
開發者的上手成本很低,開發體驗好。 若是使用過 angular 的同窗就知道,裏面的API多如牛毛,並且還會要求開發者去熟悉相似 controller , directive ,dependency injection , digest cycle 這些概念; angular2 更是須要提早去了解 Typescript 、RxJS 等基礎知識; 要讓一個前端小白去搞定 React 的全家桶,ES6 + Babel , 函數式編程 ,JSX , 工程化構建 這些也是必須要過的檻。 Vue.js 就沒有這些開發負擔,對開發者屏蔽了一系列複雜的概念,API從數量、設計上都十分精簡,很接地氣地支持js的各類方言,讓前端小白能夠快速上手 — 固然,對於有必定經驗的同窗,也可使用流行的語言、框架、庫、工程化工具來作自由合理搭配。git
博採衆長,集成各類優秀特性 — Vue.js 裏面有像 angular 這樣的雙向數據綁定,2.0版本也提供了像 React 這樣的 JSX,Virtual-DOM ,服務端同構 的特性;同時 vuex ,vue-router ,vue-cli 等配套工具也組成了一個完整的框架生態。github
性能優秀。 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 的性能在大部分場景下都屬於業界頂尖。web
根據上面篇幅的描述,MVVM框架工做的重中之重是創建 View 和 Model 之間的關係。也就是「綁定」。官方文檔的附圖說明了這一點:
從上圖,只能獲得一些基(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同窗)都很熟悉的「觀察者模式」。整體的設計思路以下:
回到剛纔說的 bind 與 update,咱們看看上述概念是如何工做的:
在初始化視圖,即綁定階段,Observer 獲取 new Vue() 中的data數據,經過Object.defineProperty 賦予 getter 和 setter; 對於數組 形式的數據,經過劫持某些方法讓數組在變更時也能獲得通知。另外,Compiler 對DOM節點指令進行掃描和解析,並訂閱Watcher 來更新視圖。Watcher 在 消息訂閱器 Dep 中進行管理。 在更新階段,當數據變動時,會觸發 setter 函數,當即會觸發相應的通知, 開始遍歷全部訂閱者,調用其指令的update方法,進行視圖更新。 OK,下面咱們繼續深刻看三個「核心點」,即 Observer, Compiler, Watcher 的實現原理。
前面提到,Observer 的核心是對 Model(data) 中的數據進行 defineReactive。 這裏的實現以下:
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 的做用主要是解析傳入的元素 el 或模板 template ,建立對應的DOMFragment,提取指令(directive)並執行指令相關方法,並對每一個指令生成Watcher。 主要的入口點是掛載在Vue.prototype下的 _compile 方法(實際內容在instance/lifecycle.js, Vue1.x的不一樣版本位置略有不一樣)。 整個 _compile 方法的流程以下:
首先執行 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) 的具體流程以下:
首先,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的職責
在上述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主要作的工做是:
這部分工做的實現以下:
這裏的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轉換便可。 狀態機的工做原理以下:
裏面的邏輯比較複雜,能夠簡單地描述爲:
Vue.js 的狀態機設計能夠看勾三股四總結的這張圖。
想象一個場景:當存在着大量的路徑(path)須要解析時,極可能會有大量重複的狀況。如上面所述,狀態機解析是一個比較繁瑣的過程。那麼就須要一個緩存系統,一旦路徑表達式命中緩存,便可直接獲取,而不用再次解析。 緩存系統的設計應該考慮如下幾點:
緩存的數據應是有限的。不然容易數據過多內存溢出。 設定數據存儲條數應結合實際狀況,經過測試給出。 緩存數據達到上限時,若是繼續有數據存入,應該有相應的策略去清除現有緩存。 Vue.js 在緩存系統上直接使用了js-lru項目。這是一個LRU(Least Recently Used)算法的實現。核心思路以下:
基礎數據結構爲js實現的一個雙向鏈表。 cache對象有頭尾,即一個head(最少被使用的項)和tail(最近被使用的項)。 緩存中的每一項均有newer和older的指針。 緩存中找數據使用object key進行查找。 具體實現以下圖:
由圖理解很是簡單:
緩存系統的其餘實現,能夠參考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 屬性中搞定,對開發者很友好。
- 推薦看一下這篇文章:數據的關聯計算。
因爲篇幅所限,本文討論的內容主要在Observer, Compiler, Watcher 這些核心模塊上;但實際上,Vue.js 源碼(或歷史源碼)中還有大量的其餘優秀實現,如:
等等。其餘的代碼解耦、工程化、測試用例等也是很是好的學習例子。 此外,若是是對 Vue.js 的源碼演進過程比較熟悉的同窗,就會發現 Vue.js 的核心思想是(借用尤大本身的表述):
「把高大上的思想變得平易近人」
從框架概念、開發體驗、api設計、全家桶設計等多個方面,Vue.js 都不斷地往友好和簡潔方向努力,這也是如今這麼火爆的緣由吧。
最後,想把一些讀源碼的體驗和各位同窗分享:
以上,共勉。