3天學寫mvvm框架[一]:資料監聽

笨笨小撒發表於2019-03-02

此前為了學習Vue的原始碼,我決定自己動手寫一遍簡化版的Vue。現在我將我所瞭解到的分享出來。如果你正在使用Vue但還不瞭解它的原理,或者正打算閱讀Vue的原始碼,希望這些分享能對你瞭解Vue的執行原理有所幫助。

Proxy

首先我們將從資料監聽開始講起,對於這一部分的內容相信許多小夥伴都看過網上各種各樣的原始碼解讀了,不過當我自己嘗試去實現的時候,還是發現自己動手對於鞏固知識點非常重要。不過鑑於Vue3將使用Proxy來實現資料監聽,所以我這裡是通過Proxy來實現了。如果你還不瞭解js中的這部分內容,請先通過MDN來學習一下哦。

當然這一部分的程式碼很可能將與Vue2以及Vue3都不盡相同,不過核心原理都是相同的。

目標

今天我們的目標是讓以下程式碼如預期執行:

const data = proxy({ a: 1 });

const watcher = watch(() => {
  return data.a + data.b;
}, (oldVal, value) => {
  console.log('watch callback', oldVal, value);
});

data.b = 1; // console.log('watch callback', NaN, 2);
data.a += 1; // console.log('watch callback', 2, 3);
複製程式碼

我們將實現proxywatch兩個函式。proxy接受一個資料物件,並返回其通過Proxy生成的代理物件;watch方法接受兩個引數,前者為求值函式,後者為回撥函式。

因為這裡的求值函式需要使用到data.adata.b兩個資料,因此當兩者改變時,求值函式將重新求值,並觸發回撥函式。

原理介紹

為了實現以上目標,我們需要在求值函式執行時,記錄下其所依賴的資料,從而在資料發生改變時,我們就能觸發重新求值並觸發回撥了。

從另一個角度來說,每當我們從data中取它的ab資料時,我們希望能記錄下當前是誰在取這些資料。

這裡有兩個問題:

  • 何時進行記錄:如果你已經學習了Proxy的用法,那這裡的答案應當很明顯了,我們將通過Proxy來設定getter,每當資料被取出時,我們設定的getter將被呼叫,這時我們就可以
  • 記錄的目標是誰:我們只需要在呼叫一個求值函式之前用一個變數將其記錄下來,再呼叫這個求值函式,那麼在呼叫結束之前,觸發這些getter的應當都是這一求值函式。在求值完成後,我們再置空這一變數就行了

這裡需要注意的是,我們將編寫的微型mvvm框架不會包含計算屬性。由於計算屬性也是求值函式,因此可能會出現求值函式巢狀的情況(例如一個求值函式依賴了另一個計算屬性),這樣的話我們不能僅使用單一變數來記錄當前的求值函式,而是需要使用棧的結構,在求值函式執行前後進行入棧與出棧操作。對於這部分內容,感興趣的小夥伴不妨可以自己試試實現以下計算屬性哦。

使用Proxy建立getter與setter

首先我們實現一組最簡單的gettersetter,僅僅進行一個簡單的代理:

const proxy = function (target) {
  const data = new Proxy(target, {
    get(target, key) {
      return target[key];
    },
    set(target, key, value) {
      target[key] = value;
      return true;
    }
  });
  return data;
};
複製程式碼

對於最簡單的資料例如{ a: 1, b: 1 }上面的做法是行得通的。但對於複雜一些的資料呢?例如{ a: { b: 1 } },外層的資料a是通過getter取出的,但我們並沒有為a{ b: 1 }設定getter,因此對於獲取a.b我們將不得而知。因此,我們需要遞迴的遍歷資料,對於型別為物件的值遞迴建立gettersetter。同時不僅在初始化時,每當資料被設定時,我們也需要檢查新的值是否是物件:

const proxy = function (target) {
  for (let key in target) {
    const child = target[key];
    if (child && typeof child === 'object') {
      target[key] = proxy(child); 
    }
  }
  return _proxyObj(target);
};
const _proxyObj = function (target) {
  const data = new Proxy(target, {
    get(target, key) {console.log(1);
      return target[key];
    },
    set(target, key, value) {
      if (value && typeof value === 'object') {
        value = proxy(value);
      }
      target[key] = value;
      return true;
    }
  });
  return data;
};
複製程式碼

這裡要注意一點,typeof null也會返回"object",但我們並不應該將其作為物件遞迴處理。

Dep和DepCollector

Dep類

對於如下的求值函式:

() => {
  return data.a + data.b.c;
}
複製程式碼

將被記錄為:這個求值函式依賴於dataa屬性,依賴於datab屬性,以及data.bc屬性。對於這些依賴,我們將用Dep類來表示。

對於每個物件或者陣列形式的資料,我們將為其建立一個Dep例項。Dep例項將會有一個map鍵值對屬性,其鍵為屬性的key,而值是一個陣列,用來將相應的監聽者不重複地watcher記錄下來。

Dep例項有兩個方法:addnotifyaddgetter過程中通過鍵新增watchernotifysetter過程中觸發對應的watcher讓它們重新求值並觸發回撥:

class Dep {
  constructor() {
    this.map = {};
  }
  add(key, watcher) {
    if (!watcher) return;
    if (!this.map[key]) this.map[key] = new DepCollector();
    watcher.addDepId(this.map[key].id);
    if (this.map[key].includes(watcher)) return;
    this.map[key].push(watcher);
  }
  notify(key) {
    if (!this.map[key]) return;
    this.map[key].forEach(watcher => watcher.queue());
  }
}
複製程式碼

同時需要修改proxy方法,為資料建立Dep例項,並在gettercurrentWatcher指向當前在求值的Watcher例項)和setter過程中呼叫其addnotify方法:

const proxy = function (target) {
  const dep = target[KEY_DEP] || new Dep();
  if (!target[KEY_DEP]) target[KEY_DEP] = dep;
  for (let key in target) {
    const child = target[key];
    if (child && typeof child === 'object') {
      target[key] = proxy(child); 
    }
  }
  return _proxyObj(target, dep, target instanceof Array);
};
const _proxyObj = function (target, dep) {
  const data = new Proxy(target, {
    get(target, key) {
      if (key !== KEY_DEP) dep.add(key, currentWatcher);
      return target[key];
    },
    set(target, key, value) {
      if (key !== KEY_DEP) {
        if (value && typeof value === 'object') {
          value = proxy(value);
        }
        target[key] = value;
        dep.notify(key);
        return true;
      }
    }
  });
  return data;
};
複製程式碼

這裡我們用const KEY_DEP = Symbol('KEY_DEP');作為鍵將已經建立的Dep例項儲存到資料物件上,使得一個資料被多次proxy時能重用先前的Dep例項。

DepCollector類

DepCollector類僅僅是對陣列進行了一層包裝,這裡的主要目的是為每個DepCollector例項新增一個用以唯一表示的id,在介紹Watcher類的時候就會知道這個id有什麼用了:

let depCollectorId = 0;
class DepCollector {
  constructor() {
    const id = ++depCollectorId;
    this.id = id;
    DepCollector.map[id] = this;
    this.list = [];
  }
  includes(watcher) {
    return this.list.includes(watcher);
  }
  push(watcher) {
    return this.list.push(watcher);
  }
  forEach(cb) {
    this.list.forEach(cb);
  }
  remove(watcher) {
    const index = this.list.indexOf(watcher);
    if (index !== -1) this.list.splice(index, 1);
  }
}
DepCollector.map = {};
複製程式碼

陣列的依賴

對於陣列的變動,例如呼叫pushpopsplice等方法或直接通過下邊設定陣列中的元素時,將發生改變的陣列對應的下標以及length都將作為key觸發我們的getter,這是Proxy很強大的地方,但我們不需要這麼細緻的監聽陣列的變動,而是統一觸發一個陣列發生了變化的事件就可以了。

因此我們將建立一個特殊的key——KEY_DEP_ARRAY來表示這一事件:

const KEY_DEP_ARRAY = Symbol('KEY_DEP_ARRAY');

const proxy = function (target) {
  const dep = target[KEY_DEP] || new Dep();
  if (!target[KEY_DEP]) target[KEY_DEP] = dep;
  for (let key in target) {
    const child = target[key];
    if (child && typeof child === 'object') {
      target[key] = proxy(child); 
    }
  }
  return _proxyObj(target, dep, target instanceof Array);
};
const _proxyObj = function (target, dep, isArray) {
  const data = new Proxy(target, {
    get(target, key) {
      if (key !== KEY_DEP) dep.add(isArray ? KEY_DEP_ARRAY : key, currentWatcher);
      return target[key];
    },
    set(target, key, value) {
      if (key !== KEY_DEP) {
        if (value && typeof value === 'object') {
          value = proxy(value);
        }
        target[key] = value;
        dep.notify(isArray ? KEY_DEP_ARRAY : key);
        return true;
      }
    }
  });
  return data;
};
複製程式碼

小結

這裡我們用一張圖進行一個小結:

3天學寫mvvm框架[一]:資料監聽

只要能理清觀察者、資料物件、以及DepDepCollector之間的關係,那這一部分就不會讓你感到困惑了。

Watcher

接下來我們需要實現Watcher類,我們需要完成以下幾個步驟:

  • Watcher建構函式將接收一個求值函式以及一個回撥函式
  • Watcher例項將實現eval方法,此方法將呼叫求值函式,同時我們需要維護當前的watcher例項currentWatcher
  • queue方法將呼叫queueWatcher,使得Watcher例項的evalnextTick中被呼叫。
  • 實現addDepIdclearDeps方法,前者使Watcher例項記錄與DepCollector的依賴關係,後者使得Watcher可以在重新求值後或銷燬時清理與DepCollector的依賴關係。
  • 最後我們實現watch方法,它將呼叫Watcher建構函式。

為什麼在重新求值後我們需要清理依賴關係呢?

想象這樣的函式:

() => {
  return data.a ? data.b : data.c;
}
複製程式碼

因為a的值改變,將改變這個求值函式依賴於b還是c

又或者:

const data = proxy({ a: { b: 1 } });
const oldA = data.a;

watch(() => {
  return data.a.b;
}, () => {});

data.a = { b: 2 };
複製程式碼

由於data.a已被整體替換,因此我們將為其生成新的Dep,以及為data.a.b生成新的DepCollector。此時我們再修改oldA.b,不應該再觸發我們的Watcher例項,因此這裡是要進行依賴的清理的。

最終程式碼如下:

let watcherId = 0;
class Watcher {
  constructor(func, cb) {
    this.id = ++watcherId;
    this.func = func;
    this.cb = cb;
  }

  eval() {
    this.depIds = this.newDepIds;
    this.newDepIds = {};
    pushWatcher(this);
    this.value = this.func(); // 快取舊的值
    popWatcher();
    this.clearDeps();
  }

  addDepId(depId) {
    this.newDepIds[depId] = true;
  }

  clearDeps() { // 移除已經無用的依賴
    for (let depId in this.depIds) {
      if (!this.newDepIds[depId]) {
        DepCollector.map[depId].remove(this);
      }
    }
  }

  queue() {
    queueWatcher(this);
  }

  run() {
    const oldVal = this.value;
    this.eval(); // 重新計算並收集依賴
    this.cb(oldVal, this.value);
  }
}
let currentWatcheres = []; // 棧,computed屬性
let currentWatcher = null;
const pushWatcher = function (watcher) {
  currentWatcheres.push(watcher);
  currentWatcher = watcher;
};
const popWatcher = function (watcher) {
  currentWatcheres.pop();
  currentWatcher = currentWatcheres.length > 0 ? currentWatcheres[currentWatcheres.length - 1] : null;
};
const watch = function (func, cb) {
  const watcher = new Watcher(func, cb);
  watcher.eval();
  return watcher;
};
複製程式碼

queueWatcher與nextTick

nextTick會將回撥加入一個陣列中,如果當前沒有還預定延時執行,則請求延時執行,在執行時依次執行陣列中所有的回撥。

延時執行的實現方式有很多,例如requestAnimationFramesetTimeout或者是node.js的process.nextTicksetImmediate等等,這裡不做糾結,使用requestIdleCallback

const nextTickCbs = [];
const nextTick = function (cb) {
  nextTickCbs.push(cb);
  if (nextTickCbs.length === 1) {
    requestIdleCallback(() => {
      nextTickCbs.forEach(cb => cb());
      nextTickCbs.length = 0;
    });
  }
};
複製程式碼

queueWatcher方法會將watcher加入待處理列表中(如果它尚不在這個列表中)。

整個待處理列表將按照watcherid進行排序。這點暫時是用不著的,但如果存在計算屬性等使用者建立的watcher或是元件概念,我們希望從父元件其向下更新元件,或是使用者建立的watcher優先於元件渲染的watcher執行,那麼我們就需要維護這樣的順序。

最後,如果flushSchedulerQueue尚未通過nextTick加入延時執行,則將其加入:

const queue = [];
let has = {};
let waiting = false;
let flushing = false;
let index = 0;
const queueWatcher = function (watcher) {
  const id = watcher.id;
  if (has[id]) return;
  has[id] = true;
  if (!flushing) {
    queue.push(watcher);
  } else {
    const i = queue.length - 1;
    while (i > index && queue[i].id > watcher.id) {
      i--;
    }
    queue.splice(i + 1, 0, watcher);
  }
  if (waiting) return;
  waiting = true;
  nextTick(flushSchedulerQueue);
};

const flushSchedulerQueue = function () {
  flushing = true;
  let watcher, id;

  queue.sort((a, b) => a.id - b.id);

  for (index = 0; index < queue.length; index++) {
    watcher = queue[index];
    id = watcher.id;
    has[id] = null;
    watcher.run();
  }

  index = queue.length = 0;
  has = {};
  waiting = flushing = false;
};
複製程式碼

Proxy和defineProperty的比較

Vue2使用了defineProperty,而Vue3將使用Proxy

Proxy作為新的特性,有其強大之處,例如對於陣列也可以直接代理,而此前需要攔截陣列方法例如push等,而對於arr[2] = 3或者obj.newProp = 3這樣陣列元素和物件新屬性的直接設定都無法處理,需要提供Vue.set這樣的幫助函式。

不過需要注意,defineProperty是原地替換的,而Proxy並不是,例如:

const target = { a: 1 };
const data = new Proxy(target, { ... });

target.a = 2; // 不會觸發setter
data.a = 3; // 修改data才會觸發setter
複製程式碼

你還可以嘗試...

在我的簡陋的程式碼的基礎上,你可以嘗試進一步實現計算屬性,給Watcher類新增銷燬方法,用不同的方式實現nextTick,或是新增一些容錯性與提示。如果使用時不小心,queueWatch可能會因為計算屬性的互相依賴而陷入死迴圈,你可以嘗試讓你的程式碼發現並處理這一問題。

如果仍感到迷惑,不妨閱讀Vue的原始碼,無論是整體的實現還是一些細節的處理都能讓我們受益匪淺。

總結

今天我們實現了DepDepCollectpr以及Watcher類,並最終實現了proxywatch兩個方法,通過它們我們可以對資料新增監聽,從而為響應式模板打下基礎。

下一次,我們將自己動手完成模板的解析工作。


參考:

程式碼:TODO

相關文章