不知不覺接觸前端的時間已經過去半年了,越來越發覺對知識的學習不應該只停留在會用的層面,這在我學jQuery的一段時間後便有這樣的體會。
雖然jQuery只是一個JS的程式碼庫,只要會一些JS的基本操作學習一兩天就能很快掌握jQuery的基本語法並熟練使用,但是如果不瞭解jQUery庫背後的實現原理,相信只要你一段時間不再使用jQuery的話就會把jQuery忘得一乾二淨,這也許就是知其然不知其所以然的後果。
最近在學vue的時候又再一次經歷了這樣的困惑,雖然能夠比較熟練的掌握vue的基本使用,也能夠對MV*模式、資料劫持、雙向資料繫結、資料代理侃上兩句。但是要是稍微深入一點就有點吃力了。所以這幾天痛下決心研究大量技術文章(起初嘗試看早期原始碼,無奈vue與jQuery不是一個層級的,相比於jQuery,vue是真正意義上的前端框架。只能無奈棄坑轉而看技術部落格),對vue也算有了一個管中窺豹的認識。最後嘗試實踐一下自己學到的知識,基於資料代理、資料劫持、模板解析、雙向繫結實現了一個小型的vue框架。
-------------------------------------------------- 分割線,下面介紹vue的具體實現。
溫馨提示:文章是按照每個模組的實現依賴關係來進行分析的,但是在閱讀的時候可以按照vue的執行順序來分析,這樣對初學者更加的友好。推薦的閱讀順序為:實現VMVM、資料代理、實現Observe、實現Complie、實現Watcher。
原始碼連結,由於只實現了v-model,v-on,v-bind等比較小的功能,所以更便於理解和掌握vue的實現過程。如果對您有幫助的話,希望點一下star。
功能演示如下所示:
資料代理
以下面這個模板為例,要替換的根元素“#mvvm-app”內只有一個文字節點#text,#text的內容為{{name}}。我們就以下面這個模板詳細瞭解一下VUE框架的大體實現流程。
<body>
<div id="mvvm-app">
{{name}}
</div>
<script src="./js/observer.js"></script>
<script src="./js/watcher.js"></script>
<script src="./js/compile.js"></script>
<script src="./js/mvvm.js"></script>
<script>
let vm = new MVVM({
el: "#mvvm-app",
data: {
name: "hello world"
},
})
</script>
</body>
資料代理
1、什麼是資料代理
在vue裡面,我們將資料寫在data物件中。但是我們在訪問data裡的資料時,既可以通過vm.data.name訪問,也可以通過vm.name訪問。這就是資料代理:在一個物件中,可以動態的訪問和設定另一個物件的屬性。
2、實現原理
我們知道靜態繫結(如vm.name = vm.data.name)可以一次性的將結果賦給變數,而使用Object.defineProperty()方法來繫結則可以通過set和get函式實現賦值的中間過程,從而實現資料的動態繫結。具體實現如下:
let obj = {}; let obj1 = { name: 'xiaoyu', age: 18, } //實現origin物件代理target物件 function proxyData(origin,target){ Object.keys(target).forEach(function(key){ Object.defineProperty(origin,key,{//定義origin物件的key屬性 enumerable: false, configurable: true, get: function getter(){ return target[key];//origin[key] = target[key]; }, set: function setter(newValue){ target[key] = newValue; } }) }) }
vue中的資料代理也是通過這種方式來實現的。
function MVVM(options) { this.$options = options || {}; var data = this._data = this.$options.data; var _this = this;//當前例項vm // 資料代理 // 實現 vm._data.xxx -> vm.xxx Object.keys(data).forEach(function(key) { _this._proxyData(key); }); observe(data, this); this.$compile = new Compile(options.el || document.body, this); } MVVM.prototype = { _proxyData: function(key) { var _this = this; if (typeof key == 'object' && !(key instanceof Array)){//這裡只實現了對物件的監聽,沒有實現陣列的 this._proxyData(key); } Object.defineProperty(_this, key, { configurable: false, enumerable: true, get: function proxyGetter() { return _this._data[key]; }, set: function proxySetter(newVal) { _this._data[key] = newVal; } }); }, };
實現Observe
1、雙向資料繫結
- 資料變動 ---> 檢視更新
- 檢視更新 ---> 資料變動
要想實現當資料變動時檢視更新,首先要做的就是如何知道資料變動了,可以通過Object.defineProperty()函式監聽data物件裡的資料,當資料變動了就會觸發set()方法。所以我們需要實現一個資料監聽器Observe,來對資料物件中的所有屬性進行監聽,當某一屬性資料發生變化時,拿到最新的資料通知繫結了該屬性的訂閱器,訂閱器再執行相應的資料更新回撥函式,從而實現檢視的重新整理。
當設定this.name = 'hello vue'時,就會執行set函式,通知訂閱器裡的訂閱者執行相應的回撥函式,實現資料變動,對應檢視更新。
function observe(data){ if (typeof data != 'object') { return ; } return new Observe(data); } function Observe(data){ this.data = data; this.walk(data); } Observe.prototype = { walk: function(data){ let _this = this; for (key in data) { if (data.hasOwnProperty(key)){ let value = data[key]; if (typeof value == 'object'){ observe(value); } _this.defineReactive(data,key,data[key]); } } }, defineReactive: function(data,key,value){ Object.defineProperty(data,key,{ enumerable: true,//可列舉 configurable: false,//不能再define get: function(){ console.log('你訪問了' + key);return value; }, set: function(newValue){ console.log('你設定了' + key); if (newValue == value) return; value = newValue; observe(newValue);//監聽新設定的值 } }) } }
2、實現一個訂閱器
要想通知訂閱者,首先得要有一個訂閱器(統一管理所有的訂閱者)。為了方便管理,我們會為每一個data物件的屬性都新增一個訂閱器(new Dep)。
訂閱器裡存著的是訂閱者Watcher(後面會講到),由於訂閱者可能會有多個,我們需要建立一個陣列來維護。一旦資料變化,就會觸發訂閱器的notify()方法,訂閱者就會呼叫自身的update方法實現檢視更新。
function Dep(){ this.subs = []; } Dep.prototype = { addSub: function(sub){this.subs.push(sub); }, notify: function(){ this.subs.forEach(function(sub) { sub.update(); }) } }
每次響應屬性的set()函式呼叫的時候,都會觸發訂閱器,所以程式碼補充完整。
Observe.prototype = { //省略的程式碼未作更改 defineReactive: function(data,key,value){ let dep = new Dep();//建立一個訂閱器,會被閉包在key屬性的get/set函式內,因此每個屬性對應唯一一個訂閱器dep例項 Object.defineProperty(data,key,{ enumerable: true,//可列舉 configurable: false,//不能再define get: function(){ console.log('你訪問了' + key); return value; }, set: function(newValue){ console.log('你設定了' + key); if (newValue == value) return; value = newValue; observe(newValue);//監聽新設定的值 dep.notify();//通知所有的訂閱者 } }) } }
實現Complie
compile主要做的事情是解析模板指令,將模板中的data屬性替換成data屬性對應的值(比如將{{name}}替換成data.name值),然後初始化渲染頁面檢視,並且為每個data屬性新增一個監聽資料的訂閱者(new Watcher),一旦資料有變動,收到通知,更新檢視。
遍歷解析需要替換的根元素el下的HTML標籤必然會涉及到多次的DOM節點操作,因此不可避免的會引發頁面的重排或重繪,為了提高效能和效率,我們把根元素el下的所有節點轉換為文件碎片fragment
進行解析編譯操作,解析完成,再將fragment
新增回原來的真實dom節點中。
- 注:文件碎片本身也是一個節點,但是當將該節點append進頁面時,該節點標籤作為根節點不會顯示html文件中,其裡面的子節點則可以完全顯示。
Compile解析模板,將模板內的子元素#text新增進文件碎片節點fragment。
function Compile(el,vm){ this.$vm = vm;//vm為當前例項 this.$el = document.querySelector(el);//獲得要解析的根元素 if (this.$el){ this.$fragment = this.nodeToFragment(this.$el); this.init(); this.$el.appendChild(this.$fragment); } } Compile.prototype = { nodeToFragment: function(el){ let fragment = document.createDocumentFragment(); let child; while (child = el.firstChild){ fragment.appendChild(child);//append相當於剪下的功能 } return fragment; }, };
compileElement方法將遍歷所有節點及其子節點,進行掃描解析編譯,呼叫對應的指令渲染函式進行資料渲染,並呼叫對應的指令更新函式進行繫結,詳看程式碼及註釋說明:
因為我們的模板只含有一個文字節點#text,因此compileElement方法執行後會進入_this.compileText(node,reg.exec(node.textContent)[1]);//#text,'name'
Compile.prototype = { nodeToFragment: function(el){ let fragment = document.createDocumentFragment(); let child; while (child = el.firstChild){ fragment.appendChild(child);//append相當於剪下的功能 } return fragment; }, init: function(){ this.compileElement(this.$fragment); }, compileElement: function(node){ let childNodes = node.childNodes; const _this = this; let reg = /\{\{(.*)\}\}/g; [].slice.call(childNodes).forEach(function(node){ if (_this.isElementNode(node)){//如果為元素節點,則進行相應操作 _this.compile(node); } else if (_this.isTextNode(node) && reg.test(node.textContent)){ //如果為文字節點,並且包含data屬性(如{{name}}),則進行相應操作 _this.compileText(node,reg.exec(node.textContent)[1]);//#text,'name' } if (node.childNodes && node.childNodes.length){ //如果節點內還有子節點,則遞迴繼續解析節點 _this.compileElement(node); } }) }, compileText: function(node,exp){//#text,'name' compileUtil.text(node,this.$vm,exp);//#text,vm,'name' },
};
CompileText()函式實現初始化渲染頁面檢視(將data.name的值通過#text.textContent = data.name顯示在頁面上),並且為每個DOM節點新增一個監聽資料的訂閱者(這裡是為#text節點新增一個Wather)。
let updater = { textUpdater: function(node,value){ node.textContent = typeof value == 'undefined' ? '' : value; }, } let compileUtil = { text: function(node,vm,exp){//#text,vm,'name' this.bind(node,vm,exp,'text'); }, bind: function(node,vm,exp,dir){//#text,vm,'name','text' let updaterFn = updater[dir + 'Updater']; updaterFn && updaterFn(node,this._getVMVal(vm,exp)); new Watcher(vm,exp,function(value){ updaterFn && updaterFn(node,value) }); console.log('加進去了'); } };
現在我們完成了一個能實現文字節點解析的Compile()函式,接下來我們實現一個Watcher()函式。
實現Watcher
我們前面講過,Observe()函式實現data物件的屬性劫持,並在屬性值改變時觸發訂閱器的notify()通知訂閱者Watcher,訂閱者就會呼叫自身的update方法實現檢視更新。
Compile()函式負責解析模板,初始化頁面,並且為每個data屬性新增一個監聽資料的訂閱者(new Watcher)。
Watcher訂閱者作為Observer和Compile之間通訊的橋樑,所以我們可以大致知道Watcher的作用是什麼。
主要做的事情是:
- 在自身例項化時往訂閱器(dep)裡面新增自己。
- 自身必須有一個update()方法 。
- 待屬性變動dep.notice()通知時,能呼叫自身的update()方法,並觸發Compile中繫結的回撥。
先給出全部程式碼,再分析具體的功能。
//Watcher function Watcher(vm, exp, cb) { this.vm = vm; this.cb = cb; this.exp = exp; this.value = this.get();//初始化時將自己新增進訂閱器 }; Watcher.prototype = { update: function(){ this.run(); }, run: function(){ const value = this.vm[this.exp]; //console.log('me:'+value); if (value != this.value){ this.value = value; this.cb.call(this.vm,value); } }, get: function() { Dep.target = this; // 快取自己 var value = this.vm[this.exp] // 訪問自己,執行defineProperty裡的get函式 Dep.target = null; // 釋放自己 return value; } } //這裡列出Observe和Dep,方便理解 Observe.prototype = { defineReactive: function(data,key,value){ let dep = new Dep(); Object.defineProperty(data,key,{ enumerable: true,//可列舉 configurable: false,//不能再define get: function(){ console.log('你訪問了' + key); //說明這是例項化Watcher時引起的,則新增進訂閱器 if (Dep.target){ //console.log('訪問了Dep.target'); dep.addSub(Dep.target); } return value; }, }) } } Dep.prototype = { addSub: function(sub){this.subs.push(sub); }, }
我們知道在Observe()函式執行時,我們為每個屬性都新增了一個訂閱器dep,而這個dep被閉包在屬性的get/set函式內。所以,我們可以在例項化Watcher時呼叫this.get()函式訪問data.name屬性,這會觸發defineProperty()函式內的get函式,get
方法執行的時候,就會在屬性的訂閱器dep
新增當前watcher例項,從而在屬性值有變化的時候,watcher例項就能收到更新通知。
那麼Watcher()函式中的get()函式內Dep.taeger = this又有什麼特殊的含義呢?我們希望的是在例項化Watcher時將相應的Watcher例項新增一次進dep訂閱器即可,而不希望在以後每次訪問data.name屬性時都加入一次dep訂閱器。所以我們在例項化執行this.get()函式時用Dep.target = this來標識當前Watcher例項,當新增進dep訂閱器後設定Dep.target=null。
實現VMVM
MVVM作為資料繫結的入口,整合Observer、Compile和Watcher三者,通過Observer來監聽自己的model資料變化,通過Compile來解析編譯模板指令,最終利用Watcher搭起Observer和Compile之間的通訊橋樑,達到資料變化 -> 檢視更新;檢視互動變化(input) -> 資料model變更的雙向繫結效果。
function MVVM(options) { this.$options = options || {}; var data = this._data = this.$options.data; var _this = this; // 資料代理 // 實現 vm._data.xxx -> vm.xxx Object.keys(data).forEach(function(key) { _this._proxyData(key); }); observe(data, this); this.$compile = new Compile(options.el || document.body, this); }
學習連結
以下是vue的分析文章,對我理解vue起到很大的幫助。感謝作者對自己知識的分享。
vue 原始碼分析之如何實現 observer 和 watcher