Vue.js設計與實現之九-Object物件型別的響應式代理

語言: CN / TW / HK

1、寫在前面

在Javascript中,我們知道“萬物皆物件”,而物件的實際語義又是由物件的內部方法來指定的。所謂內部方法,指的是在對一個物件進行操作時在引擎內部呼叫的方法,這些方法對使用者是不可見的。

如何區分一個物件是普通物件還是函式呢?

可以通過內部方法和內部槽來區分物件,函式物件會部署方法[[call]],而普通物件不會。

2、Proxy的工作原理

當然,內部方法是具有多型性的,不同型別的物件部署相同的內部方法,卻有可能有不同的邏輯。

如果在建立代理物件時沒有指定對應的攔截方法,那麼就會通過代理物件訪問屬性值時,代理的內部方法(如[[Get]])會去呼叫原始物件的內部方法(如[[Get]])去獲取屬性值,這就會代理透明。

Proxy也是物件,在它身上也會部署許多內部方法,當我們通過代理物件去訪問屬性值時,會呼叫部署在代理物件上的內部方法[[Get]]。

Proxy物件的內部方法:

  • handler.apply()
  • handler.construct()
  • handler.defineProperty()
  • handler.deleteProperty()
  • handler.get()
  • handler.getOwnPropertyDescriptor()
  • handler.getPrototypeOf()
  • handler.has()
  • handler.isExtensible()
  • handler.ownKeys()
  • handler.preventExtensions()
  • handler.set()
  • handler.setPrototypeOf()

在被代理物件是函式時,會部署另外的兩個內部方法[[Call]]和[[Constructor]]。

當我們使用Proxy的deleteProperty()刪除屬性時,實際上是代理物件的內部方法和行為,改變的只是代理物件的屬性值。想要改變原始資料上的屬性值,必須通過Reflect.deleteProperty(target,key)來實現。

3、如何代理Object物件

在前面的文章中,使用get攔截方法對屬性的讀取操作,其實是片面的,因為使用in操作符檢查物件的屬性、使用for...in迴圈遍歷物件,都是物件的讀取操作。

讀取屬性

普通物件的所有讀取操作:

  • 訪問屬性: data.name。
  • 判斷物件或原型上是否存在指定的key: key in data。
  • 使用for...in遍歷物件: for(const key in data){}。

直接訪問屬性

const data = {
  name:"pingping"
}
const state = new Proxy(data,{
  get(target, key, receiver){
    //追蹤函式 建立副作用函式與代理物件的聯絡
    track(target, key);
    //返回屬性值
    Reflect.get(target, key, receiver);
  }
})

in操作符

const data = {
  name:"pingping"
}
const state = new Proxy(data,{
  has(target, key, receiver){
    //追蹤函式 建立副作用函式與代理物件的聯絡
    track(target, key);
    //返回屬性值
    Reflect.has(target, key, receiver);
  }
})

for...in

通過攔截ownKeys操作,可以實現對for...in迴圈的間接攔截,在ownKeys中只能獲取到目標物件target的所有鍵值,但是沒有和具體的鍵繫結。對此需要使用Symbol構造唯一的key值進行標識,即ITERATE_KEY。

const data = {
  name:"pingping"
}
const ITERATE_KEY = Symbol();
const state = new Proxy(data,{
  ownKeys(target){
    //追蹤函式 建立副作用函式與ITERATE_KEY的聯絡
    track(target, ITERATE_KEY);
    //返回屬性值
    Reflect.ownKeys(target);
  }
})

設定屬性

如果代理物件state只有一個屬性時,for...in迴圈只會執行一次,但是當state上添加了新的屬性,那麼for...in便會執行多次。這是因為給物件新增新的屬性時,會觸發與ITERATE_KEY相關聯的副作用函式重新執行。

const data = {
  name:"pingping"
}
const ITERATE_KEY = Symbol();
const state = new Proxy(data,{
  set(target, key, newVal){
    const res = Reflect.set(target, key, newVal, receiver);
    trigger(target, key);
    return res;
  },
  ownKeys(target){
    //追蹤函式 建立副作用函式與ITERATE_KEY的聯絡
    track(target, ITERATE_KEY);
    //返回屬性值
    Reflect.ownKeys(target);
  }
})
effect(()=>{
  for(const key in state){
    console.log(key);//name
  }
})

trigger函式:

function trigger(target, key){
  const depsMap = bucket.get(target);
  if(!depsMap) return;
  const effects = depsMap.get(key);
  const iterateEffects = depsMap.get(ITERATE_KEY);
  const effectsToRun = new Set();
  // 將與key相關聯的副作用函式新增到effectsToRun中
  effect && effects.forEach(effectFn=>{
    if(effectFn !== activeEffect){
      effectsToRun.add(effectFn);
    }
  });
  // 將與ITERATE_KEY相關聯的副作用函式新增到effectsToRun中
  iterateEffects && iterateEffects.forEach(effectFn=>{
    if(effectFn !== activeEffect){
      effectsToRun.add(effectFn);
    }
  });  
  effectsToRun.forEach(effectFn=>{
    if(effectFn.options.scheduler){
      effectFn.options.scheduler(effectFn);
    }else{
      effectFn();
    }
  });
}

在上面trigger函式中,在新增屬性時,除了將與key值直接相關聯的副作用函式取出來執行外,還需要將那些與ITERATE_KEY相關聯的副作用函式也取出來執行。

在上面的程式碼中,對於代理物件新增新的屬性而言,是可以這樣做的,但是對於修改現有物件的現有屬性是不可行的。因為在修改現有屬性值,不會對for...in迴圈造成影響,無論如何修改值都只會執行一次迴圈。對此,不需要觸發副作用函式的重新執行,否則會造成額外的效能開銷。

那麼,應該如何處理呢?

事實上,無論是在現有物件新增屬性還是修改現有屬性,都是使用set攔截函式來實現攔截的。所以,我們可以將上面程式碼片段進行整合,在進行設定操作攔截的時候進行判斷,判斷當前物件上是否有該屬性。

  • 如果是新增屬性,則多次執行觸發ITERATE_KEY相關聯的副作用函式執行。
  • 如果是修改屬性,則不需要觸發ITERATE_KEY相關聯的副作用函式執行。
const TriggerType = {
  SET:"SET",
  ADD:"ADD"
};
const state = new Proxy(data,{
  set(target, key, newVal){
    const type = Object.prototype.hasOwnProperty.call(target,key) ? TriggerType.SET : TriggerType.ADD;
    const res = Reflect.set(target, key, newVal, receiver);
    // 傳入判斷當前是否新增屬性
    trigger(target, key, type);
    return res;
  }
})
function trigger(target, key, type){
  const depsMap = bucket.get(target);
  if(!depsMap) return;
  const effects = depsMap.get(key);
  const effectsToRun = new Set();
  // 將與key相關聯的副作用函式新增到effectsToRun中
  effect && effects.forEach(effectFn=>{
    if(effectFn !== activeEffect){
      effectsToRun.add(effectFn);
    }
  }); 
  if(type === TriggerType.ADD){
    const iterateEffects = depsMap.get(ITERATE_KEY);
    // 將與ITERATE_KEY相關聯的副作用函式新增到effectsToRun中
    iterateEffects && iterateEffects.forEach(effectFn=>{
      if(effectFn !== activeEffect){
        effectsToRun.add(effectFn);
      }
    });
  } 
  effectsToRun.forEach(effectFn=>{
    if(effectFn.options.scheduler){
      effectFn.options.scheduler(effectFn);
    }else{
      effectFn();
    }
  });
}

刪除屬性

在代理物件商,刪除屬性可以通過delete進行刪除,那麼delete操作符依賴Proxy物件內部方法deleteProperty。同樣的,在刪除指定屬性時,需要先檢查當前屬性是否在物件自身上,然後再考慮Reflect.deleteProperty函式完成屬性的刪除。

既然是操作代理物件的屬性刪除,那麼就會觸發trigger的依賴收集操作,副作用函式會重新執行。物件屬性的數目變少,那麼就會影響for...in迴圈的次數,會觸發與ITERATE_KEY相關聯的副作用函式的重新執行。

const TriggerType = {
  SET:"SET",
  ADD:"ADD",
  DELETE:"DELETE"
};
const state = new Proxy(data, {
  deleteProperty(target, key){
    // 檢查當前要刪除的屬性是否在物件上
    const hadKey = Object.property.hasOwnProperty.call(target, key);
    // 使用`Reflect.deleteProperty`函式完成屬性的刪除
    const res = Reflect.deleteProperty(target, key);   
    if(res && hadKey){
      //只有刪除成功才會觸發更新
      trigger(target, key, "DELETE");
    }
  }
})
function trigger(target, key, type){
  const depsMap = bucket.get(target);
  if(!depsMap) return;
  const effects = depsMap.get(key);
  const effectsToRun = new Set();
  // 將與key相關聯的副作用函式新增到effectsToRun中
  effect && effects.forEach(effectFn=>{
    if(effectFn !== activeEffect){
      effectsToRun.add(effectFn);
    }
  });  
  if(type === TriggerType.ADD || type === TriggerType.DELETE){
    const iterateEffects = depsMap.get(ITERATE_KEY);
    // 將與ITERATE_KEY相關聯的副作用函式新增到effectsToRun中
    iterateEffects && iterateEffects.forEach(effectFn=>{
      if(effectFn !== activeEffect){
        effectsToRun.add(effectFn);
      }
    });
  } 
  effectsToRun.forEach(effectFn=>{
    if(effectFn.options.scheduler){
      effectFn.options.scheduler(effectFn);
    }else{
      effectFn();
    }
  });
}

4、合理觸發響應

在前面的文字中,從規範的角度詳細地介紹瞭如何實現物件代理,與此同時,處理了很多邊界條件。需要明確知道操作型別才能觸發響應,但是在觸發響應時也要看是否合理,在值沒有發生變化時就不需要觸發響應。

對此,在修改set攔截函式的程式碼時,在呼叫trigger函式觸發響應前,需要檢查值是否發生真實改變。

const data = {
  name:"pingping"
};
const state = new Proxy(data,{
  set(target, key, newVal, receiver){
  // 先獲取舊值
  const oldVal = target[key];  
  const type = Object.prototype.hasOwnProperty.call(target, key) ? "SET" : "ADD";
  const res = Reflect.set(target, key, newVal, receiver);
  if(oldVal !== newVal){
    trigger(target, key, type);
  }
  return res
})
effect(()=>{
  console.log(state.name);
});
state.name = "onechuan";

在呼叫set攔截函式時,需要先獲取oldVal與新值newVal進行比較,只有二者不全等的時候才會觸發響應。當時,當oldVal和newVal的值都為NaN時,使用全等進行比較得到的是false。

NaN === NaN //false
NaN !== NaN //true

我們看到NaN值的比較值,當data.num的初始值為NaN時,後續修改其值為NaN作為新值,此時還是使用全等比較,得到NaN !== NaN值為true,就會觸發響應函式,導致不必要的更新。對此需要先判斷oldVal和newVal的值都不為NaN,那麼需要加上判斷oldVal === oldVal || newVal === newVal,其實等價於Number.isNaN(newVal) || Number.isNaN(oldVal)。

為了方便使用,我們對物件的代理進行函式封裝。

function reactive(){
  return new Proxy(data,{
    set(target, key, newVal, receiver){
    // 先獲取舊值
    const oldVal = target[key];
    const type = Object.prototype.hasOwnProperty.call(target, key) ? "SET" : "ADD";
    const res = Reflect.set(target, key, newVal, receiver);
    if(oldVal !== newVal && (oldVal === oldVal || newVal === newVal)){
      trigger(target, key, type);
    }
    return res
  })
}

這樣,在使用時:

const obj = {};
const data = {
  name:"pingping"
}
const parent = reactive(data);
const child = reactive(obj);
//使用parent物件作為child的原型物件
Object.setPrototypeOf(child, parent);
effect(()=>{
  console.log(child.name);//pingping
});
//修改了child.name的值
child.name = "onechuan";//會導致副作用函式重新執行兩次

在上面的程式碼中,會導致副作用函式重新執行兩次。其實做的處理就是分別使用Proxy對obj和data進行代理,並將parent物件作為child的原型物件。在副作用函式中讀取child.name的值時,會觸發child代理物件的get攔截函式,而攔截函式的實現是Reflect.get(obj, "name", receiver)。

但是呢,child物件本身上本不存在name屬性,對此就會去獲取物件的原型parent並呼叫原型的[[Get]]方法得到結果parent.name的值。而parent本身又是響應式資料,對此在副作用函式中訪問parent.name的值,會導致副作用函式被收集並建立響應聯絡。parent.name和child.name都會觸發副作用函式的依賴收集,即都與副作用函式建立了聯絡。

重新分析下上面的程式碼,當child.name = 2被執行時,會呼叫child物件的set攔截函式,而在set攔截函式內部實現是Reflect.get(target, key, newVale, receiver)完成預設設定行為。由於child和其所代理的物件obj上沒有name屬性,則會去原型parent上進行尋找,即導致parent代理物件的set攔截函式被執行。

而在讀取child.name的值時,副作用函式不僅會被child.name觸發執行,還會被parent.name所收集,對此在parent代理物件的set攔截函式被執行時,會觸發副作用函式重新執行。對此,副作用函式被執行了兩次。

那麼,應該如何避免執行兩次副作用函式呢?

其實,我們需要區分兩次副作用函式執行是誰觸發的,其實只需要確定recevier是不是target的代理物件,然後將parent.name觸發的副作用函式執行進行遮蔽即可。

function reactive(){
  return new Proxy(data,{
    get(target, key, receiver){
      // 代理物件可以通過raw屬性訪問資料
      if(key === "raw"){
        return target
      }
      track(target, key);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, newVal, receiver){
    // 先獲取舊值
    const oldVal = target[key];
    const type = Object.prototype.hasOwnProperty.call(target, key) ? "SET" : "ADD";
    const res = Reflect.set(target, key, newVal, receiver);
   // target === receiver.raw可以說明receiver是target的代理物件
   if(target === receiver.raw){
      if(oldVal !== newVal && (oldVal === oldVal || newVal === newVal)){
      trigger(target, key, type);
    }
   }
    return res
  })
}

在上面程式碼中,我們新增判斷條件target === receiver.raw,只有的那個其為true,即recevier是target的代理物件時觸發更新,就可以遮蔽由於原型引起的更新,從而避免不必要的更新操作。

5、寫在最後

上篇文章中介紹了好哥們Proxy和Reflect的作用,這篇文章介紹了Proxy如何實現對Object物件的代理,分別對代理物件的設值、取值、刪除屬性等操作進行了介紹。還討論了,如何合理觸發副作用函式重新執行,以及遮蔽由原型更新引起的副作用函式不必要的重新執行。