騰訊DeepOcean原創文章:dopro.io/vue-mvvm-re…
1、MVVM模式
MVVM的設計思想:關注Model(資料)的變化,讓MVVM框架去自動更新DOM的狀態,比較主流的實現有:angular的(髒值檢測)vue的(資料劫持->釋出訂閱模式)我們重點了解vue(資料劫持->釋出訂閱模式)的實現方式,讓我們從操作DOM的繁瑣操作中解脫出來2、核心方法Object.defineProperty的理解
我們先簡單看一下這個方法它也是用來實現我們資料劫持(資料監聽)的關鍵方法,我們知道vue框架是不相容IE6~8低版本的,主要是因為它的用到了ES5中的這個Object.defineProperty的方法,而且這個方法暫時沒有很好的降級方案。var a = {};
Object.defineProperty(a, 'b', {
value: 123, // 設定的屬性值
writable: false, // 是否只讀
enumerable: false, // 是否可列舉
configurable: false //
});
console.log(a.b); //123複製程式碼
方法使用很簡單,它接受三個引數,而且都是必填的
- 第一個引數:目標物件
- 第二個引數:需要定義的屬性或方法的名字。
- 第三個引數:目標屬性所擁有的特性
- value:屬性的值。
- writable:如果為 false ,屬性的值就不能被重寫,只能為只讀了。
- enumerable:是否可列舉,預設是false不可列舉的(通常設定為true)
- configurable:總開關,一旦為false,就不能再設定其他的( value , writable , enumerable)
- get():函式,獲取屬性值時執行的方法(不可以和writable、value屬性共存)
- set():函式,設定屬性值時執行的方法(不可以和writable、value屬性共存)
// 常用定義
var obj = {};
Object.defineProperty(obj, 'school', {
enumerable: true,
get: function() {
// 獲取屬性值時會呼叫get方法
},
set: function(newVal) {
// 設定屬性值時會呼叫set方法
return newVal
}
});複製程式碼
我們通過這個Object.defineProperty這個方法,可以實現對定義的引用資料型別的實現監聽,被方法監聽後的物件,裡面定義的值發生被獲取和設定操作的時候,會分別觸發Object.defineProperty裡面引數三的get和set方法。
3、資料劫持
在瞭解完了Object.defineProperty方法後,我們現在要通過它來實現我們的資料劫持功能Obaerve的方法,看如下程式碼
function MyVue(options = {}) {
// 將所有的屬性掛載到$options身上
this.$options = options;
// 獲取到data資料(Model)
var data = this._data = this.$options.data;
// 劫持資料
observe(data)
}
// 給需要觀察的物件都新增 Object.defineProperty 的監聽
function Observe(data) {
for (let key in data) {
let val = data[key];
// 遞迴 =》來實現深層的資料監聽
observe(val)
Object.defineProperty(data, key, {
enumerable: true,
get() {
return val
},
set(newval) {
if (val === newval) { //設定的值是否和以前是一樣的,如果是就什麼都不做
return
}
val = newval // 這裡要把新設定的值也在新增一次資料劫持來實現深度響應,
observe(newval);
}
})
}
}
function observe(data) {
// 這裡做一下資料型別的判斷,只有引用資料型別才去做資料劫持
if (typeof data != 'object') return
return new Observe(data)
}複製程式碼
1)以上程式碼做了這些事情,先定義了初始換建構函式MyVue我們通過它來獲取到我們傳進來的資料data和我們定義的DOM節點範圍,然後把data傳進定好的資料劫持方法observe
2)Observe實現了對資料的監聽整體邏輯,這裡有個細節點,沒有直接用建構函式Observe去劫持我們的資料,而是寫多了一個observe的小方法用來new Observe,並且在裡面做了引用資料型別的判斷。這樣做的目的是為了方便遞迴來實現資料結構的深層監聽,因為我們的data結構肯定是複雜多樣的,例如下面程式碼
// 這裡資料結構巢狀很多,要實現深層的資料監聽採用了遞迴的實現方式
data: { a: {b:2} , c:{q:12,k:{name:'binhemori'}} , f:'mvvvm',o:[12,5,9,8]}複製程式碼
3)這裡還需要注意的是我們在set方法裡面有再一次把設定的新值,執行了一遍observe方法,是為了實現深度響應,因為在賦值的時候可能會賦值一個引用資料型別的值,我們知道vue有個特點,是不能新增不存在的屬性和不能存在屬性沒有get和set方法的,如果賦值進來的新屬性值是引用資料型別,就會把我們原先執行過資料劫持方法的物件地址給替換掉,而新物件是沒有經過資料劫持的就是沒有get和set的方法,所以我們在設定新值的時候需要在重新給他執行一遍observe資料劫持,確保開發者不管怎樣去設定值的時候都能被劫持到
說了這麼多,我們來使用一下看看有沒有實現對資料的劫持(資料監聽)吧
<div id="app">
<div> <div>這裡的資料1======<span style="color: red;">{{a.b}}</span></div> <div>這裡是資料2======<span style="color: green;">{{c}}</span></div> </div> <input type="text" v-model="a.b" value=""> </div> <!-- 引入自己定義的mvvm模組 --> <script src="./mvvm.js"></script> 複製程式碼
<script type="text/javascript"> var myvue = new MyVue({ el: '#app', data: { a: { b: '你好' }, c: 12, o: [12, 5, 9, 8] } })
</script>複製程式碼
可以看到對我們所定義的data中的資料都已經有了get和set方法了,到這裡我們對data中資料的變化都是可以監聽的到了
4、資料代理
資料代理,我們用過vue的都知道,在實際使用中是能直接通過例項+屬性(vm.a)直接獲取到資料的,而我們上面的程式碼要獲取到資料還需要這樣myvue._data.a這樣來獲取到資料,中間多了一個 _data 的環節,這樣使用起來不是很方便的,下面我們來實現讓我們的例項this來代理( _data)資料,從而實現 myvue.a 這樣的操作可以直接獲取到資料
function MyVue(options = {}) {
// 將所有的屬性掛載到$options身上
this.$options = options;
// 獲取到data資料(Model)
var data = this._data = this.$options.data;
observe(data);
// this 就代理資料 this._data
for (const key in data) {
Object.defineProperty(this, key, {
enumerable: true,
get() {
// this.a 這裡取值的時候 實際上是去_data中的值
return this._data[key]
},
set(newVal) {
// 設定值的時候其實也是去改this._data.a的值
this._data[key] = newVal
}
})
}
}複製程式碼
以上程式碼實現了我們的資料代理,就是在構建例項的時候,把data中的資料遍歷一次出來,依次加到我們this上,加的過程中也不要忘記新增Object.defineProperty,只要是資料我們都需要新增監聽。如下圖我們已經實現了對資料的代理
5、編譯模板(Compile)
我們已經完成對資料劫持也實現了this對資料的代理,那麼接下來要做的就是怎樣把資料編譯到我們的DOM節點上面,也就是讓檢視層(view)要展示我們的資料了
// 將資料和節點掛載在一起
function Compile(el, vm) {
// el表示替換的範圍
vm.$el = document.querySelector(el);
// 這裡注意我們沒有去直接操作DOM,而是把這個步驟移到記憶體中來操作,這裡的操作是不會引發DOM節點的迴流
let fragment = document.createDocumentFragment(); // 文件碎片
let child;
while (child = vm.$el.firstChild) {
// 將app的內容移入記憶體中
fragment.appendChild(child);
}
replace(fragment)
function replace(fragment) {
Array.from(fragment.childNodes).forEach(function (node) { //迴圈每一層
let text = node.textContent;
let reg = /\{\{(.*)\}\}/g;
// 這裡做了判斷只有文字節點才去匹配,而且還要帶{{***}}的字串
if (node.nodeType === 3 && reg.test(text)) {
// 把匹配到的內容拆分成陣列
let arr = RegExp.$1.split('.');
let val = vm;
// 這裡對我們匹配到的定義陣列,會依次去遍歷它,來實現對例項的深度賦值
arr.forEach(function (k) { // this.a.b this.c
val = val[k]
})
// 用字串的replace方法替換掉我們獲取到的資料val
node.textContent = text.replace(/\{\{(.*)\}\}/, val)
}
// 這裡做了判斷,如果有子節點的話 使用遞迴
if (node.childNodes) {
replace(node)
}
})
}
// 最後把編譯完的DOM加入到app元素中
vm.$el.appendChild(fragment)
}複製程式碼
以上程式碼實現我們對資料的編譯Compile如下圖,可以看到我們把獲取到el下面所有的子節點都儲存到了文件碎片 fragment 中暫時儲存了起來(放到記憶體中),因為這裡要去頻繁的操作DOM和查詢DOM,所以移到記憶體中操作
- 1)先用while迴圈,先把 el 中所有的子節點都新增到文件碎片中fragment.appendChild(child);
- 2)然後我們通過replace方法去遍歷文件中所有的子節點,將他們文字節點中(node.nodeType = 3)帶有{{}} 語法中的內容都獲取到,把匹配到的值拆分成陣列,然後遍歷依次去data中查詢獲取,遍歷的節點如果有子節點的話繼續使用replace方法直到反回undefined
- 3)獲取到資料後,用replace方法替換掉文字中{{}}的整塊內容,然後在放回el元素中vm.$el.appendChild(fragment),
6、關聯檢視(view)與資料(model)
在成功的將我們的資料繫結到了DOM節點之後,要實現我們的檢視層(view)跟資料層(model)的關聯,現在實際上還沒有關聯起來,因為無法通過改資料值來引發檢視的變化,實現這步之前先聊一下JS中比較常用的設計模式釋出訂閱模式也是vue實現雙向資料繫結的很關鍵的一步
釋出訂閱模式(又稱觀察者模式)
我們先簡單手動實現一個(就是一個陣列關係)
// 釋出訂閱
function Dep() {
this.subs = []
}
// 訂閱
Dep.prototype.addSub = function (sub) {
this.subs.push(sub)
}
// 通知
Dep.prototype.notify = function (sub) {
this.subs.forEach(item => item.update())
}
// watcher是一個類,通過這個類建立的函式都會有update的方法
function Watcher(fn) {
this.fn = fn;
}
Watcher.prototype.update = function () {
this.fn()
}複製程式碼
這裡用Dep方法來實現訂閱和通知,在這個類中有addSub(新增)和notify(通知)兩個方法,我們把將要做的事情(函式)通過addSub新增進陣列裡,等時機一到就notify通知裡面所有的方法執行
大家會發現為什麼要另外定義一個建立函式的方法watcher,而不是直接把方法扔到addSub中好,這樣不是多此一舉嘛?其實這樣做的有它的目的,其中一個好處就是我們通過watcher建立的函式都會有一個update執行的方法可以方便我們呼叫。而另外一個用處我下面會講到,先把它運用起來吧
function replace(fragment) {
Array.from(fragment.childNodes).forEach(function (node) { let text = node.textContent;
let reg = /\{\{(.*)\}\}/g;
if (node.nodeType === 3 && reg.test(text)) {
let arr = RegExp.$1.split('.');
let val = vm; arr.forEach(function (k) { val = val[k] })
// 在這裡運用了Watcher函式來新增要操作的事情 new Watcher(vm, RegExp.$1, function (newVal) { node.textContent = text.replace(/\{\{(.*)\}\}/, newVal) }) 複製程式碼
node.textContent = text.replace(/{{(.*)}}/, val) }
if (node.childNodes) { replace(node) } }) }複製程式碼
可以看到我們把定義函式的方法watcher加到了replace方法裡面,但是這裡的watcher更剛寫編寫的多了兩個形參vm、RegExp.$1,而且寫法也新增了一些內容,因為當new Watcher的時候會引發發生幾個操作,來看程式碼:
// vm做資料代理的地方
function MyVue(options = {}) {
this.$options = options;
var data = this._data = this.$options.data;
observe(data);
for (const key in data) {
Object.defineProperty(this, key, {
enumerable: true,
get() {
return this._data[key]
},
set(newVal) {
this._data[key] = newVal
}
})
}
}
// 資料劫持函式
function Observe(data) {
let dep = new Dep();
for (let key in data) {
let val = data[key];
observe(val)
Object.defineProperty(data, key, {
enumerable: true,
get() {
/* 獲取值的時候 Dep.target
對於著 watcher的例項,把他建立的例項加到訂閱佇列中
*/
Dep.target && dep.addSub(Dep.target);
return val
},
set(newval) {
if (val === newval) {
return
}
val = newval;
observe(newval);
// 設定值的時候讓所有的watcher.update方法執行即可觸發所有資料更新
dep.notify()
}
})
}
}
function Watcher(vm, exp, fn) {
this.fn = fn;
// 這裡我們新增了一些內容,用來可以獲取對於的資料
this.vm = vm;
this.exp = exp;
Dep.target = this
let val = vm;
let arr = exp.split('.');
/* 執行這一步的時候操作的是vm.a,
而這一步操作其實就是操作的vm._data.a的操作,
會觸發this代理的資料和_data上面的資料
*/
arr.forEach(function (k) {
val = val[k]
})
Dep.target = null;
}
// 這裡是設定值操作
Watcher.prototype.update = function () {
let val = this.vm;
let arr = this.exp.split('.');
arr.forEach(function (k) {
val = val[k]
})
this.fn(val) //這裡面要傳一個新值
}複製程式碼
這裡開始會有點繞,一定要理解好運算元據的時候會觸發的那個例項上面資料的get和set,操作的是那個資料這個思維
1)首先看在Watcher建構函式中新增了一些私有屬性分別代表:
- Dep.target = this(在建構函式Dep.target臨時儲存著watcher的當前例項)
- this.vm = vm(vm = myvue例項)
- this.exp = exp(exp = 匹配的查詢的物件”a.b”是字串型別的值)
arr.forEach(function (k) {
// arr = [a,b]
val = val[k]
})複製程式碼
- 第一次迴圈的時候是vm[a] = {b:12},取到了a這個物件,然後在賦值回去就是把當前的val變成了a這個物件
- 第二次迴圈的時候val已經變成了 a物件,此時的k變成了b,val就變成了:a[b] = 12
get() {
// 走到這裡的時候 Dep.target 已經儲存了 watcher的當前例項例項,把他建立的例項加到訂閱佇列中 Dep.target && dep.addSub(Dep.target); return val },
// 把要做的更新檢視層的操作方法用Watcher定義好,裡面已經定義好了要操作的物件
new Watcher(vm, RegExp.$1, function (newVal) { node.textContent = text.replace(/\{\{(.*)\}\}/, newVal) }) 複製程式碼
Watcher.prototype.update = function () {
let val = this.vm;
let arr = this.exp.split('.'); arr.forEach(function (k) { val = val[k] }) this.fn(val) // 把對於的新值傳遞到方法裡面
}複製程式碼
這裡因為加多了一層 vm.a 這樣的資料代理,所以邏輯有點繞,記住這句話就好理解操作 vm.a 代理資料上面值的時候,其實就是操作的vm._data中的資料所以會觸發兩個地方的get和set方法,好說這麼多,我們來看是否實現資料變動觸發檢視層的變化吧
這裡就實現了資料的變更觸發檢視層的更新操作了
7、input雙向資料繫結的實現
最後一步就來實現檢視層的變更觸發資料結構的變更操作,上面我們已經把檢視與資料關聯最核心的程式碼講解了,剩下檢視變更觸發資料變更就比較好實現了
<div id="app">
<div>
<div>這裡的資料1======<span style="color: red;">{{a.b}}</span></div>
<div>這裡是資料2======<span style="color: green;">{{c}}</span></div>
</div>
<input type="text" v-model="a.b" value="">
</div>
<!-- 引入自己定義的mvvm模組 -->
<script src="./mvvm.js"></script>
<script type="text/javascript">
var myvue = new MyVue({
el: '#app',
data: { a: { b: '你好' }, c: 12, o: [12, 5, 9, 8] }
})
</script>複製程式碼
// 獲取所有元素節點
if (node.nodeType === 1) {
let nodeAttr = node.attributes
Array.from(nodeAttr).forEach(function (attr) {
let name = attr.name; // v-model="a.b" let exp = attr.value; // a.b 複製程式碼
if (name.indexOf('v-') >= 0) {
let val = vm;
let arr = exp.split('.'); arr.forEach(function (n) { val = val[n] })
// 這個還好處理,取到對應的值設定給input.value就好 node.value = val; }
// 這裡也要定義一個Watcher,因為資料變更的時候也要變更帶有v-model屬性名的值 new Watcher(vm, exp, function (newVal) { node.value = newVal })
// 這裡是檢視變化的時候,變更資料結構上面的值 node.addEventListener('input', function (e) {
let newVal = e.target.value
if (name.indexOf('v-') >= 0) {
let val = vm;
let arr = exp.split('.'); arr.forEach(function (k,index) { if (typeof val[k] === 'object') { val = val[k] } else{
if (index === arr.length-1) { val[k] = newVal } } }) } }) }) }複製程式碼
上面程式碼對資料變更觸發檢視層變更的邏輯更上一節一樣即可,主要是node.addEventListener('input')這裡設定資料的問題,其實原理跟第六節關聯檢視(view)與資料(model)的邏輯一樣,有一定需要注意的是這邊加了一個引用資料型別的判斷,不然他的迴圈會到最底層的資料型別值(也就是基礎資料型別) 1)這裡判斷到取到的不是物件資料型別,不做替換操作 (val = val[k]) 2)判斷是不是已經最後一個層級了index === arr.length-1,如果是的話直接把input中的值賦值進當前資料中即可
arr.forEach(function (k,index) {
if (typeof val[k] === 'object') {
// 如果有巢狀的話就繼續查詢
val = val[k]
} else{
if (index === arr.length-1) {
// 查詢到最後一個後直接賦值
val[k] = newVal
}
}
})複製程式碼
以上是整個mvvm雙向資料繫結的簡單實現原理,內容有些哪裡解釋不通順的地方或有更好的意見歡迎留言:)