實現一個簡易的響應式系統

流汗去發表於2019-01-26

不管你有沒有用過 Vue,你都會經常聽到 Vue 是一個響應式的庫。最近看了一下 Vue 的原始碼,實現了一個簡易版的響應式系統。

首先,看下面的例子

<template>
  <div id="app">
    <p class="name">{{fullName}}</p>
  </div>
</template>

<script>
var vm = new Vue({
  el: `#app`,
  data: {
    firstName: `liu`,
    lastName: `laohan`
  },
  computed: {
    fullName() {
      return this.firstName + ` ` + this.lastName
    }
  }
})
</script>
複製程式碼

瞭解過 Vue 的都知道,當 firstName 或者 lastName 的值發生變化時,fullName 的值都會發生變化,並且檢視也會更新。

你可能會好奇,Vue 是怎麼知道 firstName 或者 lastName 發生變化,並且也保證 fullName 也得到變化的呢?

這跟我們平時看到的 javascript 的執行並不相同阿,例如

let firstName = `liu`;
let lastName = `laohan`;
let fullName = firstName + ` ` + lastName;

lastName = `datou`;

console.log(`name: ${fullName}`);
複製程式碼

你可能想都不用想就知道上面的 log 會列印出什麼內容

>> name: liu laohan
複製程式碼

但在 Vue 中,我們希望的是 lastName 的內容發生變化時,fullName 的值也更新,即希望上面的 log 會列印出

>> name: liu datou
複製程式碼

不幸的是,上面的 js 並不會是響應式的,如果我們希望 fullName 的內容是響應式的,還需要做一些其他的事情。

現在,我們要解決的問題就是把計算 fullName 的過程保留起來,然後在 lastName 發生變化時,再次執行一次這個計算的過程。

計算 fullName 的過程,其實也就是一個函式嘛,我們可以實現如下

let firstName = `liu`;
let lastName = `laohan`;
let fullName = ``;

target = () => {
  fullName = firstName + ` ` + lastName;
};

record();
target();
複製程式碼

這樣子,我們就把計算 fullName 的過程封裝在一個匿名函式中,然後呼叫 record 函式。

record 函式的實現方式也很簡單

let storage = [];

function record() {
  storage.push(target);
}
複製程式碼

現在我們把計算的過程儲存在變數 storage 中了。當 lastName 的值發生變化時,我們只需要 replay 一下就可以了

lastName = `datou`;
console.log(fullName); // => liu laohan
replay();
console.log(fullName); // => liu datou
複製程式碼

看起來是不是很簡單,很容易的就實現了我們要達到的效果。 完整程式碼如下

let firstName = `liu`;
let lastName = `laohan`;
let fullName = ``;
let target = null;
let storage = [];

function record() {
  storage.push(target);
}

function replay() {
  storage.forEach(cb => cb());
}

target = () => {
  fullName = firstName + ` ` + lastName;
};

record();
target();

lastName = `datou`;
console.log(fullName); // => liu laohan
replay();
console.log(fullName); // => liu datou
複製程式碼

大概總結一下,其實就是以下兩點:

  1. 記錄目標值的計算過程,如上述的 target 函式,記錄 fullName 的求值過程
  2. 在影響目標值的變數(如 firstName、lastName)發生變化時,重新計算目標值

可以看到,上面的實現方式是很簡單粗暴的。如果之後 lastName 的值再次變化時,要想 fullName 的值還是響應的,那就要如下:

lastName = `dahei`;
replay();
複製程式碼

每次變數 lastName 發生變化時,都要在後面跟著呼叫 replay 函式。這會使程式碼看起來很冗餘。

接下來,我們嘗試著用別的方式來實現響應式系統。

在之前,我們已經可以實現 fullName 隨著 lastName 值變化而變化,如果我們想在 firstName 改變時,fullName 的值也跟著變化。按照上面的實現,增加一些程式碼,也是可以實現。只是你會發現,每次改變變數時,都要手動地在後面加一行 replay() 是很繁瑣的。而作為一個喜歡偷懶的人來說,這很讓人反感。

Object.defineProperty

如果你沒有聽過或者不瞭解 Object.defineProperty() ,可以看看 這裡
Object.defineProperty()方法允許我們修改一個物件的現有屬性,我們要用到的就是屬性描述符中的 getset

get: 一個給屬性提供 getter 的方法,如果沒有 getter 則為 undefined。當訪問該屬性時,該方法會被執行
set: 一個給屬性提供 setter 的方法,如果沒有 setter 則為 undefined。當屬性值修改時,觸發執行該方法。

如上所述,當我們訪問一個物件的屬性時,getter 方法會被呼叫,當修改一個屬性的值時,setter 方法會被呼叫。可以看看下面的一個 demo:

const info = {
  firstName: `liu`,
  lastName: `laohan`,
};

Object.defineProperty(info, `lastName`, {
  get() {
    console.log(`getter`);
  },
  set() {
    console.log(`setter`);
  },
});

info.lastName; // => getter
info.lastName = `datou`; // => setter
複製程式碼

可以看到,當我們訪問屬性時,get 方法被呼叫了。而修改屬性的值時, set 方法就被呼叫了。
你可能會疑問,這對實現響應式系統有什麼用呢?還記得上面我們每次修改 lastName 時都在後面跟著呼叫 replay() 嗎,這就是因為我們不知道什麼時候 lastName 的值改變了,現在我們就可以通過 set 方法是否被呼叫而知道屬性值是否發生變化了。那既然 get 方法被呼叫了,就表示這個屬性是被訪問了,是不是我們也可以通過在 get 方法裡儲存依賴這個屬性的目標值的計算過程呢,答案是肯定的。

const info = {
  firstName: `liu`,
  lastName: `laohan`,
};

Object.keys(info).forEach(key => {
  let internalValue = info[key];
  Object.defineProperty(info, key, {
    get() {
      // todo: 儲存依賴該屬性的目標值的計算過程,如之前的record
      return internalValue;
    },
    set(val) {
      internalValue = val;
      // todo: 重新呼叫目標值的計算過程,如之前的 replay
    },
  });
});
複製程式碼

現在,差的就是在 get 和 set 方法裡面實現類似我們之前的 record 和 replay 方法了。
在這個 demo 中,直接使用上面的方法也不會有什麼問題。但為了有更好的複用,我們換種實現方式。

class Dep {
  constructor() {
    this.subs = [];
  }
  depend() {
    if (target && !!this.subs.includes(target)) {
      this.subs.push(target);
    }
  }
  notify() {
    this.subs.forEach(sub => sub());
  }
}
複製程式碼

這次,我們就把目標值的計算過程儲存到 subs 陣列中了,然後用 notify 代替之前的 replay。
在實際應用中,target 的值會發生變化的。比如在 vue 中,target 有時候是更新元件,有時候是更新 computed 的值。所以在這裡用一個 watcher 函式封裝一下 target 的行為。

watcher(() => {
  fullName = firstName + lastName;
});
複製程式碼

把上面的幾個點整合在一起,完整的程式碼就是

const info = {
  firstName: `liu`,
  lastName: `laohan`,
};

let target = null;

class Dep {
  constructor() {
    this.subs = [];
  }
  depend() {
    if (target && !!this.subs.includes(target)) {
      this.subs.push(target);
    }
  }
  notify() {
    this.subs.forEach(sub => sub());
  }
}

Object.keys(info).forEach(key => {
  let internalValue = info[key];

  const dep = new Dep();

  Object.defineProperty(info, key, {
    get() {
      dep.depend();
      return internalValue;
    },
    set(val) {
      internalValue = val;
      dep.notify();
    },
  });
});

function watcher(func) {
  target = func;
  target();
  target = null;
}

watcher(() => {
  const fullName = info.firstName + info.lastName;
});
複製程式碼

參考連結

Build a Reactivity System

歡迎討論

地址

相關文章