Vue--$watch()原始碼分析
這一段時間工作上不是很忙,所以讓我有足夠的時間來研究一下VueJs還是比較開心的 (只要不加班怎麼都開心),說到VueJs總是讓人想到雙向繫結,MVVM,模組化,等牛逼酷炫的名詞,而通過近期的學習我也是發現了Vue一個很神奇的方法$watch,第一次嘗試了下,讓我十分好奇這是怎麼實現的,
為什麼變數賦值也會也會觸發回撥?這背後又有什麼奇淫巧技?懷著各種問題,我看到了一位大牛,楊川寶的文章,但是我還是比較愚笨,看了三四遍,依然心存疑惑,最終在楊大牛的GitHub又看了許久,終於有了眉目,本篇末尾,我會給上鍊接
在正式介紹$watch方法之前,我有必要先介紹一下實現基本的$watch方法所需要的知識點,並簡單介紹一下方便理解:
1) Object.defineProperty ( obj, key , option) 方法
這是一個非常神奇的方法,同樣也是$watch以及實現雙向繫結的關鍵
總共引數有三個,其中option中包括 set(fn), get(fn), enumerable(boolean), configurable(boolean)
set會在obj的屬性被修改的時候觸發,而get是在屬性被獲取的時候觸發,(其實屬性的每次賦值,每次取值,都是呼叫了函式)
2) Es6 知識,例如Class,()=>, const, let,這些也都比較基礎,但是如果不知道的話,還是還是推薦瞭解一下;
3) 面向物件程式設計,例如Object.keys,constructor,call,以及各種花式this指向,不過這些方法,也不是特別難理解,稍加搜尋,OK的。
下面簡單介紹一下$watch方法的使用
其使用方法如下:
1 //----/VUE.JS 2 const v = new Vue({ 3 data:{ 4 a:1, 5 b:{ 6 c:3 7 } 8 } 9 }) 10 // 例項方法$watch,監聽屬性"a" 11 v.$watch("a",()=>console.log("你修改了a")) 12 //當Vue例項上的a變化時$watch的回撥 13 setTimeout(()=>{ 14 v.a = 2 15 // 設定定時器,修改a 16 },1000)
怎麼樣?是不是很簡單,而且很有用?下面我來簡單的實現一下$watch這個方法;
從例項化Vue物件開始,到呼叫$watch方法,再到屬性變化,觸發回撥,我分為三個階段
首先第一個階段
new Vue(options)
想要實現watch,當例項化Vue物件的時候,有下面三個函式,需要被呼叫
1 class Vue { //Vue物件 2 constructor (options) { 3 this.$options=options; 4 let data = this._data=this.$options.data; 5 Object.keys(data).forEach(key=>this._proxy(key)); 6 // 拿到data之後,我們迴圈data裡的所有屬性,都傳入代理函式中 7 observe(data,this); 8 } 9 $watch(expOrFn, cb, options){ //監聽賦值方法 10 new Watcher(this, expOrFn, cb); 11 // 傳入的是Vue物件 12 } 13 14 _proxy(key) { //代理賦值方法 15 // 當未開啟監聽的時候,屬性的賦值使用的是代理賦值的方法 16 // 而其主要的作用,是當我們訪問Vue.a的時候,也就是Vue例項的屬性時,我們返回的是Vue.data.a的屬性而不是Vue例項上的屬性 17 var self = this 18 Object.defineProperty(self, key, { 19 configurable: true, 20 enumerable: true, 21 get: function proxyGetter () { 22 return self._data[key] 23 // 返回 Vue例項上data的對應屬性值 24 }, 25 set: function proxySetter (val) { 26 self._data[key] = val 27 } 28 }) 29 } 30 }
以上是一個Class Vue ,這個Vue類身上有三個方法,分別是constructor (例項化預設方法),$watch(也就是我們今天要實現的方法)和一個_proxy(代理)方法
constructor :
當Vue被例項化,並傳入引數(options)的時候,constructor 就會被呼叫,並且接收Vue例項化的引數options,這個函式在這裡做的事情,就是,對傳進來的data進行加工 第一步做的,就是讓Vue物件,和引數data,產生一個關聯,好讓你可以通過,this.a , 或者vm.a 來操作data屬性,建立關聯之後,迴圈data的所有鍵名,將其傳入到_proxy方法,到這裡constructor方法的主要作用就結束了,什麼?你說還有observe方法?這個屬於另一個方向,稍後會做出解釋;
$watch
這個方法你一看就會明白,只是例項化Watcher物件,而Watcher物件裡還有其他的什麼方法,稍後我會介紹;
_proxy
這個方法是一個代理方法,接收一個鍵名,作用的物件是Vue物件,具體的作用嘛,不知道大家有沒有想過,這個:
1 //首先我們例項化Vue物件 2 var vm = Vue({ 3 data:{ 4 a:1, 5 msg:'今天學習watch' 6 } 7 }) 8 console.log(vm.msg) //列印 '今天學習watch' 9 // 理論上來說,msg和a,應該是data上的屬性,但是卻可以通過vm.msg直接拿到
原因就在於,_proxy 這個方法身上,我們可以看到defineProperty方法作用的物件,是self,也就是Vue物件,而get方法裡,return出來的卻是self._data[key], _data在上面的方法當中,已經和引數data相等了,所以當我們訪問Vue.a的時候,get方法返回給我們的,是Vue._data.a。
但是,表面上,他只是為了修改取值和賦值的方法,而且就算我用Vue.data.a取值,又能怎麼樣?但是實際上,叫它代理方法,不是沒有原因的,當watch事件開啟了監聽屬性的時候,變數的set和get方法當然是有watch來控制,畢竟人家還有回撥要搞嘛,但是,當我不需要watch的時候,怎麼辦呢?
那當然就是這個代理函式加上的set和get方法來代理沒有watch時候的取值和賦值的方法啦。
下面我來說一下,剛才漏掉的,opserve(data,this)
當我們在new Vue的時候,傳進去的data很可能包括子物件,例如在使用Vue.data.a = {a1:1 , a2:2 }的時候,這種情況是十分常見的,但是剛才的_proxy函式只是迴圈遍歷了key,如果我們要給物件的子物件增加set和get方法的時候,最好的方法就是遞迴;
方法也很簡單,如果有屬性值 == object,那麼久把他的屬性值拿出來,遍歷一次,如果還有,繼續遍歷,程式碼如下:
1 class Observer{ //物件Observer 2 constructor(value) {//value 就是Vue例項上的data 3 this.value = value 4 this.dep = new Dep() 5 //Dep物件是聯絡Watcher物件和觸發監聽回撥的物件,稍後會有描述 6 this.walk(value) 7 } 8 //遞迴。。讓每個字屬性可以observe 9 walk(value){ 10 Object.keys(value).forEach(key=>this.convert(key,value[key])) 11 } 12 convert(key, val){ //這裡的 key value 是Vue例項data的每個鍵值對 13 defineReactive(this.value, key, val)//this.value 就是Vue例項的data 14 } 15 } 16 17 18 19 20 function defineReactive (obj, key, val) {//類似_proxy方法,迴圈增加set和get方法,只不過增加了Dep物件和遞迴的方法 var dep = new Dep() 21 var childOb = observe(val) 22 //這裡的val已經是第一次傳入的物件所包含的屬性或者物件,會在observe進行篩選,決定是否繼續遞迴 23 Object.defineProperty(obj, key, {//這個defineProperty方法,作用物件是每次遞迴傳入的物件,會在Observer物件中進行分化 24 enumerable: true, 25 configurable: true, 26 get: ()=>{ 27 if(Dep.target){//這裡判斷是否開啟監聽模式(呼叫watch) 28 dep.addSub(Dep.target)//呼叫了,則增加一個Watcher物件 29 } 30 return val//沒有啟用監聽,返回正常應該返回val 31 }, 32 set:newVal=> {var value = val 33 if (newVal === value) {//新值和舊值相同的話,return 34 return 35 } 36 val = newVal 37 childOb = observe(newVal) 38 //這裡增加observe方法的原因是,當我們給屬性賦的值也是物件的時候,同樣要遞迴增加set和get方法 39 dep.notify() 40 //這個方法是告訴watch,你該行動了 41 } 42 }) 43 } 44 function observe (value, vm) {//遞迴控制函式 45 if (!value || typeof value !== 'object') {//這裡判斷是否為物件,如果不是物件,說明不需要繼續遞迴 46 return 47 } 48 return new Observer(value)//遞迴 49 }
這裡寫的比較亂(不會寫註釋啊!!),不過沒關係,你只需要知道,Opserver物件是使用defineReactive方法迴圈給引數value設定set和get方法,同時順便調了observe方法做了一個遞迴判斷,看看是否要從Opserver物件開始再來一遍。就是這樣,至於dep物件,大可不必關心。
到這裡,new Vue所執行的階段就告一段落,仍然留下了一些坑,例如Dep,不過馬上就會展示出來
因為Dep起到連線的作用,所以在new Watcher之前,有必要讓你們看一下:
1 class Dep { 2 constructor() { 3 this.subs = [] //Watcher佇列陣列 4 } 5 addSub(sub){ 6 this.subs.push(sub) //增加一個Watcher 7 } 8 notify(){ 9 this.subs.forEach(sub=>sub.update()) //觸發Watcher身上的update回撥(也就是你傳進來的回撥) 10 } 11 } 12 Dep.target = null //增加一個空的target,用來存放Watcher
new Watcher
Dep物件身上的方法和作用,大體在上面的註釋寫的比較清楚,很多涉及到Watcher物件,那麼下面我就來介紹一下Watcher物件,在開始的時候,我們已經知道,Vue物件身上的一個方法,$watch,而這個方法做的事情也不是別的,正是new Watcher物件,那麼上程式碼:
1 //-----WATCHER 2 class Watcher { // 當使用了$watch 方法之後,不管有沒有監聽,或者觸發監聽,都會執行以下方法 3 constructor(vm, expOrFn, cb) { 4 this.cb = cb //呼叫$watch時候傳進來的回撥 5 this.vm = vm 6 this.expOrFn = expOrFn //這裡的expOrFn是你要監聽的屬性或方法也就是$watch方法的第一個引數(為了簡單起見,我們這裡不考慮方法,只考慮單個屬性的監聽) 7 this.value = this.get()//呼叫自己的get方法,並拿到返回值 8 } 9 update(){ // 還記得Dep.notify方法裡迴圈的update麼? 10 this.run() 11 } 12 run(){//這個方法並不是例項化Watcher的時候執行的,而是監聽的變數變化的時候才執行的 13 const value = this.get() 14 if(value !==this.value){ 15 this.value = value 16 this.cb.call(this.vm)//觸發你傳進來的回撥函式,call的作用,我就不說了 17 } 18 } 19 get(){ 20 Dep.target = this //將Dep身上的target 賦值為Watcher物件 21 const value = this.vm._data[this.expOrFn];//這裡拿到你要監聽的值,在變化之前的數值 22 // 宣告value,使用this.vm._data進行賦值,並且觸發_data[a]的get事件 23 Dep.target = null 24 return value 25 } 26 }
class Watcher在例項化的時候,重點在於get方法,我們來分析一下,get方法首先把Watcher物件賦值給Dep.target,隨後又有一個賦值,
const value = this.vm._data[this.exOrFn], 這個賦值的過程,是一個關鍵點,要知道,我們之前所做的都是什麼,不就是修改了Vue物件的data(_data)的所有屬性的get和set事件麼? 而Vue物件也作為第一個引數,傳給了Watcher物件,此時此刻,這個this.vm._data裡的所有屬性,在取值的時候,都會觸發之前增加的get方法,此時,我們再來看一下get方法是什麼?
1 get: ()=>{ 2 if(Dep.target){ //觸發這個get事件之前,我們剛剛對Dep.target賦值為Watcher物件 3 dep.addSub(Dep.target)//這裡會把我們剛賦值的Dep.target(也就是Watcher物件)新增到監聽佇列裡 4 } 5 return val 6 } 7 }
在吧Watcher物件放再Dep.subs陣列中之後,new Watcher物件所執行的任務就告一段落,此時我們有:
Dep.subs陣列中,已經添加了一個Watcher物件,
Dep物件身上有notify方法,來觸發subs佇列中的Watcher的update方法,
Watcher物件身上有update方法可以呼叫run方法觸發最終我們傳進去的回撥,
可是你會覺得,雖然方法都這麼齊全,所謂萬事具備只欠東風,那麼如何觸發Dep.notify方法,來層層回撥,找到Watcher的run呢?
答案就在set方法中的最後一行
1 set:newVal=> { 2 var value = val 3 if (newVal === value) { 4 return 5 } 6 val = newVal 7 childOb = observe(newVal) 8 dep.notify()//觸發Dep.subs中所有Watcher.update方法 9 }
別問我set方法怎麼觸發,當然是你修改了你所監聽的那個值的時候啦,
到這裡,就是我對簡易版的$watch方法的理解,不過終歸是簡易版,除了回味程式碼的思路,更感慨尤大的水平之高,在寫部落格的時候,也沒有太好的思路,不知道怎麼寫更容易懂(反正也沒人看),可能你看了這篇部落格之後,仍然對$watch的實現還有問題,當然歡迎指正和提問,雖然是Vue1的實現方法,但是思路是不會過時的,另外推薦,楊川寶大大的文章和GitHub