原始碼學習VUE之Watcher
我們在前面推導過程中實現了一個簡單版的watcher。這裡面還有一些問題
class Watcher { constructors(component, getter, cb){ this.cb = cb // 對應的回撥函式,callback this.getter = getter; this.component = component; //這就是執行上下文 } //收集依賴 get(){ Dep.target = this; this.getter.call(this.component) if (this.deep) { traverse(value) } Dep.target = null; } update(){ this.cb() } }
同步非同步更新
所謂的同步更新是指當觀察的主體改變時立刻觸發更新。而實際開發中這種需求並不多,同一事件迴圈中可能需要改變好幾次state狀態,但檢視view只需要根據最後一次計算結果同步渲染就行(react中的setState就是典型)。如果一直做同步更新無疑是個很大的效能損耗。這就要求watcher在接收到更新通知時不能全都立刻執行callback。我們對程式碼做出相應調整
constructors(component, getter, cb, options){ this.cb = cb // 對應的回撥函式,callback this.getter = getter; this.id = UUID() // 生成一個唯一id this.sync = options.sync; //預設一般為false this.vm = component; //這就是執行上下文 this.value = this.getter() // 這邊既收集了依賴,又儲存了舊的值 } update(){ if(this.sync){ //如果是同步那就立刻執行回撥 this.run(); }else{ // 否則把這次更新快取起來 //但是就像上面說的,非同步更新往往是同一事件迴圈中多次修改同一個值, // 那麼一個wather就會被快取多次。因為需要一個id來判斷一下, queueWatcher(this) } } run: function(){ //獲取新的值 var newValue = this.getter(); this.cb.call(this.vm, newValue, this.value) }
這裡的一個要注意的地方是,考慮到極限情況,如果正在更新佇列中wather時,又塞入進來該怎麼處理。因此,加入一個flushing
來表示佇列的更新狀態。如果加入的時候佇列正在更新狀態,這時候分兩種情況:
- 這個watcher已經更新過, 就把這個watcher再放到當前執行的下一位,當前watcher處理完,立即處理這個最新的。
- 這個watcher還沒有處理,就找到這個wather在佇列中現有的位置,並再把新的放在後面。
let flushing = false; let has = {}; // 簡單用個物件儲存一下wather是否已存在 function queueWatcher (watcher) { const id = watcher.id if (has[id] == null) { has[id] = true // 如果之前沒有,那麼就塞進去吧,如果有了就不用管了 if (!flushing) { queue.push(watcher) } else { let i = queue.length - 1 while (i > index && queue[i].id > watcher.id) { i-- } queue.splice(i + 1, 0, watcher) } // ... 等同一事件迴圈結束後再依次處理佇列裡的watcher。具體程式碼放到後面nexttick部分再說 } } }
這麼設計不無道理。我們之所以為了將wather放入佇列中,就是為了較少不必要的操作。考慮如下程式碼
data: {
a: 1
},
computed: {
b: function(){
this.a + 1
}
}
methods: {
act: function(){
this.a = 2;
// do someting
this.a = 1
}
}
在act操作中,我們先改變a,再把它變回來。我們理想狀況下是a沒變,b也不重新計算。這就要求,b的wather執行update的時候要拿到a最新的值來計算。這裡就是1。如果佇列中a的watehr已經更新過,那麼就應該把後面的a的wather放到當前更新的wather後面,立即更新。這樣可以保證後面的wather用到a是可以拿到最新的值。同理,如果a的wather還沒有更新,那麼把新的a的wather放的之前的a的wather的下一位,也是為了保證後面的wather用到a是可以拿到最新的值。
computed
之所以把計算屬性拿出愛單獨講,是因為
- 計算屬性存在按需載入的情況
- 與render和$watcher相比,計算屬性a可能依賴另一個計算屬性b。
按需載入
所謂的按需計算顧名思義就是用到了才會計算,即呼叫了某個計算屬性的get方法。在前面的方法中,我們在class Watcher的constructor中直接呼叫了getter方法收集依賴,這顯然是不符合按需載入的原則的。
依賴收集
實際開發中,我們發現一個計算屬性往往由另一個計算屬性得來。如,
computed: {
a: function(){
return this.name;
},
b: function(){
return this.a + "123";
}
}
對於a而言,它是b的依賴,因此有必要在a的wather執行update操作時也更新b,也就意味著,a的watcher裡需要收集著b的依賴。而收集的時機是執行b的回撥時,this.a呼叫了a的get方法的時候在computed部分,已經對計算屬性的get方法進行了改寫
function computedGetter () {
const watcher = this._computedWatchers && this._computedWatchers[key]
if (watcher) {
//呼叫一個計算屬性的get方法時,會在watcher中收集依賴。
watcher.depend()
return watcher.evaluate()
}
}
我們再修改一下wather程式碼:
class Watcher {
constructors(component, getter, cb, options){
this.cb = cb
this.getter = getter;
this.id = UUID()
this.sync = options.sync;
this.vm = component;
if(options){
this.computed = options.computed //由於是對計算屬性特殊處理,那肯定要給個識別符號以便判斷
}
this.dirty = this.computed // for computed watchers
if(this.computed){
// 對於計算屬性computed而言,我們需要關心preValue嗎? *********************
this.value = undefined
// 如果是計算屬性,就要收集依賴
//同時根據按需載入的原則,這邊不會手機依賴,主動執行回撥函式。
this.dep = new Dep()
}else{
this.value = this.get(); //非計算屬性是通過呼叫getter方法收集依賴。
}
}
update(){
//計算屬性在update時候需要更新依賴
if(this.computed){
//有些我們並不急著知道最新結果,因此可以先把dom渲染好再計算這一部分。
if (this.dep.subs.length === 0) {
//簡單做個標記,表示這個資料“髒”了,需要被更新
this.dirty = true
} else {
//對於計算屬性而言,被watch和在dom渲染中使用到的肯定是要立刻更新。
this.getAndInvoke(() => {
this.dep.notify()
})
}
}
}
if(this.sync){
this.run();
}else{
queueWatcher(this)
}
}
run: function(){
//獲取新的值
this.getAndInvoke(this.cb)
}
// 新增depend方法,收集計算屬性的依賴
depend () {
if (this.dep && Dep.target) {
this.dep.depend()
}
}
}
//這裡不再直接呼叫callback方法,在呼叫之前先將新舊兩個值進行比較,如果兩者不一樣,再呼叫callback進行更新
getAndInvoke (cb) {
//拿到新值
const value = this.get()
if (value !== this.value || //基本型別的值直接比較
// 物件沒辦法直接比較,因此都進行計算
isObject(value)) {
// set new value
const oldValue = this.value
this.value = value
this.dirty = false
cb.call(this.vm, value, oldValue)
}
}
//不要忘了還要返回當前computed的最新的值
//由於可能不是立即更新的,因此根據dirty再判斷一下,如果資料髒了,呼叫get再獲取一下
evaluate () {
if (this.dirty) {
this.value = this.get()
this.dirty = false
}
return this.value
}
在繫結依賴之前(computed的get被觸發一次),computed用到的data資料改變是不會觸發computed的重新計算的。
路徑解析
對於render和computed想要收集依賴,我們只需要執行一遍回撥函式就行,但是對於$watch方法,我們並不關心他的回撥是什麼,而更關心我們需要監聽哪個值。這裡的需求多種多樣,比如單個值監聽,監聽物件的某個屬性(.),比如多個值混合監聽(&&, ||)等。這就需要對監聽的路徑進行解析。
constructors(component, expOrFn, cb, options){
this.cb = cb
this.id = UUID()
this.sync = options.sync;
this.vm = component;
if(options){
this.computed = options.computed
}
if(typeof expOrFn === "function"){
// render or computed
this.getter = expOrFn
}else{
this.getter = this.parsePath();
}
if(this.computed){
this.value = undefined
this.dep = new Dep()
}else{
this.value = this.get(); //非計算屬性是通過呼叫getter方法收集依賴。
}
}
parsePath: function(){
// 簡單的路徑解析,如果都是字串則不需要解析
if (/[^\w.$]/.test(path)) {
return
}
// 這邊只是簡單解析了子屬性的情況
const segments = path.split('.')
return function (obj) {
for (let i = 0; i < segments.length; i++) {
if (!obj) return
obj = obj[segments[i]]
}
return obj
}
}
總結
我們在watcher乞丐版的基礎上,根據實際需求推匯出了更健全的watcher版本。下面是完整程式碼
class Watcher {
constructors(component, getter, cb, options){
this.cb = cb
this.getter = getter;
this.id = UUID()
this.sync = options.sync;
this.vm = component;
if(options){
this.computed = options.computed //由於是對計算屬性特殊處理,那肯定要給個識別符號以便判斷
}
if(typeof expOrFn === "function"){
// render or computed
this.getter = expOrFn
}else{
this.getter = this.parsePath();
}
this.dirty = this.computed // for computed watchers
if(this.computed){
// 對於計算屬性computed而言,我們需要關心preValue嗎? *********************
this.value = undefined
// 如果是計算屬性,就要收集依賴
//同時根據按需載入的原則,這邊不會手機依賴,主動執行回撥函式。
this.dep = new Dep()
}else{
this.value = this.get(); //非計算屬性是通過呼叫getter方法收集依賴。
}
}
update(){
//計算屬性在update時候需要更新依賴
if(this.computed){
//有些我們並不急著知道最新結果,因此可以先把dom渲染好再計算這一部分。
if (this.dep.subs.length === 0) {
//簡單做個標記,表示這個資料“髒”了,需要被更新
this.dirty = true
} else {
//對於計算屬性而言,被watch和在dom渲染中使用到的肯定是要立刻更新。
this.getAndInvoke(() => {
this.dep.notify()
})
}
}
}
if(this.sync){
this.run();
}else{
queueWatcher(this)
}
}
run: function(){
//獲取新的值
this.getAndInvoke(this.cb)
}
// 新增depend方法,收集計算屬性的依賴
depend () {
if (this.dep && Dep.target) {
this.dep.depend()
}
}
}
//這裡不再直接呼叫callback方法,在呼叫之前先將新舊兩個值進行比較,如果兩者不一樣,再呼叫callback進行更新
getAndInvoke (cb) {
//拿到新值
const value = this.get()
if (value !== this.value || //基本型別的值直接比較
// 物件沒辦法直接比較,因此都進行計算
isObject(value)) {
// set new value
const oldValue = this.value
this.value = value
this.dirty = false
cb.call(this.vm, value, oldValue)
}
}
//不要忘了還要返回當前computed的最新的值
//由於可能不是立即更新的,因此根據dirty再判斷一下,如果資料髒了,呼叫get再獲取一下
evaluate () {
if (this.dirty) {
this.value = this.get()
this.dirty = false
}
return this.value
}
可以看到,基本vue的實現一樣了。VUE中有些程式碼,比如teardown
方法,清除自身的訂閱資訊我並沒有加進來,因為沒有想到合適的應用場景。這種逆推的過程我覺得比直接讀原始碼更有意思。直接讀原始碼並不難,但很容易造成似是而非的情況。邏輯很容易理解,但是真正為什麼這麼寫,一些細節原因很容易漏掉。但是不管什麼框架都是為了解決實際問題的,從需求出發,才能更好的學習一個框架,並在自己的工作中加以借鑑。借VUE的生命週期圖進行展示
區域性圖:
從區域性圖裡可以看出,vue收集依賴的入口只有兩個,一個是在載入之前處理$wacth方法,一個是render生成虛擬dom。而對於computed,只有在使用到時才會收集依賴。如果我們在watch和render中都沒有使用,而是在methods中使用,那麼載入的過程中是不會計算這個computed的,只有在呼叫methods中方法時才會計算。