JavaScript 如何實現一個響應式系統

jiang_xin_yu發表於2024-04-24

JavaScript 如何實現一個響應式系統

第一階段目標

  1. 資料變化重新執行依賴資料的過程

第一階段問題

  1. 如何知道資料發生了變化
  2. 如何知道哪些過程依賴了哪些資料

第一階段問題的解決方案

  1. 我們可用參考現有的響應式系統(vue)
    1. vue2 是透過 Object.defineProperty實現資料變化的監控,詳細檢視 Vue2官網
    2. vue3 是透過Proxy實現資料變化的監控,詳細檢視 Vue3官網
  2. 本次示例使用Proxy實現資料監控,Proxy詳細資訊檢視官網
  3. 根據解決方案,需要改變第一階段目標為-> Proxy物件變化重新執行依賴資料的過程
  4. 問題變更->如何知道Proxy發生了變化
  5. 問題變更->如何知道哪些函式依賴了哪些Proxy

如何知道 Proxy 物件發生了變化,示例程式碼

//這裡傳入一個物件,返回一個Proxy物件,對Proxy物件的屬性的讀取和修改會觸發內部的get,set方法
function relyOnCore(obj) {
  if (typeof obj !== "object" || obj === null) {
    return obj;
  }
  return new Proxy(obj, {
    get(target, key, receiver) {
      return target[key];
    },
    set(target, key, value, receiver) {
      //這裡需要返回是否修改成功的Boolean值
      return Reflect.set(target, key, value);
    },
  });
}

資料監控初步完成,但是這裡只監控了屬性的讀取和設定,還有很多操作沒有監控,以及資料的 this 指向,我們需要完善它

//完善後的程式碼
export function relyOnCore(obj) {
  if (typeof obj !== "object" || obj === null) {
    return obj;
  }
  return new Proxy(obj, {
    get(target, key, receiver) {
      if (typeof target[key] === "object" && target[key] !== null) {
        //當讀取的值是一個物件,需要重新代理這個物件
        return relyOnCore(target[key]);
      }
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      return Reflect.set(target, key, value, receiver);
    },
    ownKeys(target) {
      return Reflect.ownKeys(target);
    },
    getOwnPropertyDescriptor(target, key) {
      return Reflect.getOwnPropertyDescriptor(target, key);
    },
    has(target, p) {
      return Reflect.has(target, p);
    },
    deleteProperty(target, key) {
      return Reflect.deleteProperty(target, key);
    },
    defineProperty(target, key, attributes) {
      return Reflect.defineProperty(target, key, attributes);
    },
  });
}

如何知道哪些函式依賴了哪些 Proxy 物件

問題:依賴 Proxy 物件的函式要如何收集

在收集依賴 Proxy 物件的函式的時候出現了一個問題: 無法知道資料在什麼環境使用的,拿不到對應的函式

解決方案

既然是因為無法知道函式的執行環境導致的無法找到對應函式,那麼我們只需要給函式一個固定的執行環境就可以知道函式依賴了哪些資料。

示例

//定義一個變數
export let currentFn;

export function trackFn(fn) {
  return function FnTrackEnv() {
    currentFn = FnTrackEnv;
    fn();
    currentFn = null;
  };
}

自此,我們的函式呼叫期間 Proxy 物件監聽到的資料讀取在 currentFn 函式內部發生的。

同樣,我們的目標從最開始的 資料變化重新執行依賴資料的過程 -> Proxy 物件變化重新執行依賴收集完成的函式

完善函式呼叫環境

直接給全域性變數賦值,在函式巢狀呼叫的情況下,這個依賴收集會出現問題

let obj1 = relyOnCore({ a: 1, b: 2, c: { d: 3 } });
function fn1() {
  let a = obj1.a;
  function fn2() {
    let b = obj1.b;
  }
  //這裡的c會無法收集依賴
  let c = obj1.c;
}

我們修改一下函式收集

export const FnStack = [];
export function trackFn(fn) {
  return function FnTrackEnv() {
    FnStack.push(FnTrackEnv);
    fn();
    FnStack.pop(FnTrackEnv);
  };
}

第二階段目標

  1. 在合適的時機觸發合適的函式

第二階段問題

  1. 在什麼時間觸發函式
  2. 到達觸發時間時,應該觸發什麼函式

第一個問題:在什麼時間觸發函式

必然是在修改資料完成之後觸發函式

第二個問題:應該觸發什麼函式

當操作會改變函式讀取的資訊的時候,需要重新執行函式。因此,我們需要建立一個對映關係

{
  //物件
  "obj": {
    //屬性
    "key": {
      //對屬性的操作
      "handle": ["fn"] //對應的函式
    }
  }
}

在資料改變的時候,我們只需要根據對映關係,迴圈執行 handle 內的函式

資料讀取和函式建立聯絡

我們可以建立一個函式用於建立這種聯絡

export function track(object, handle, key, fn) {}

這個函式接收 4 個引數,object(物件),handle(對資料的操作型別) key(操作了物件的什麼屬性),fn(需要關聯的函式)

我們現在來建立對映關係

export const ObjMap = new WeakMap();
export const handleType = {
  GET: "GET",
  SET: "SET",
  Delete: "Delete",
  Define: "Define",
  Has: "Has",
  getOwnPropertyDescriptor: "getOwnPropertyDescriptor",
  ownKeys: "ownKeys",
};

export function track(object, handle, key, fn) {
  setObjMap(object, key, handle, fn);
}

function setObjMap(obj, key, handle, fn) {
  if (!ObjMap.has(obj)) {
    ObjMap.set(obj, new Map());
  }
  setKeyMap(obj, key, handle, fn);
}

const setKeyMap = (obj, key, handle, fn) => {
  let keyMap = ObjMap.get(obj);
  if (!keyMap.has(key)) {
    keyMap.set(key, new Map());
  }
  setHandle(obj, key, handle, fn);
};

const setHandle = (obj, key, handle, fn) => {
  let keyMap = ObjMap.get(obj);
  let handleMap = keyMap.get(key);
  if (!handleMap.has(handle)) {
    handleMap.set(handle, new Set());
  }
  setFn(obj, key, handle, fn);
};
const setFn = (obj, key, handle, fn) => {
  let keyMap = ObjMap.get(obj);
  let handleMap = keyMap.get(key);
  let fnSet = handleMap.get(handle);
  fnSet.add(fn);
};

現在已經實現了資料和函式之間的關聯只需要在讀取資料時呼叫這個方法去收集依賴就可以,程式碼如下:

export function relyOnCore(obj) {
  if (typeof obj !== "object" || obj === null) {
    return obj;
  }
  return new Proxy(obj, {
    get(target, key, receiver) {
      track(target, handleType.GET, key, FnStack[FnStack.length - 1]);
      if (typeof target[key] === "object" && target[key] !== null) {
        return relyOnCore(target[key]);
      }
      return Reflect.get(target, key, receiver);
    },
    //....這裡省略剩餘程式碼
  });
}

接下來我們需要建立資料改變->影響哪些資料的讀取之間的關聯

export const TriggerToTrackMap = new Map([
  [handleType.SET, [handleType.GET, handleType.getOwnPropertyDescriptor]],
  [
    handleType.Delete,
    [
      handleType.GET,
      handleType.ownKeys,
      handleType.Has,
      handleType.getOwnPropertyDescriptor,
    ],
  ],
  [handleType.Define, [handleType.ownKeys, handleType.Has]],
]);

建立這樣關聯後,我們只需要在資料變動的時候,根據對映關係去尋找需要重新執行的函式就可以實現響應式。

export function trigger(object, handle, key) {
  let keyMap = ObjMap.get(object);
  if (!keyMap) {
    return;
  }
  let handleMap = keyMap.get(key);
  if (!handleMap) {
    return;
  }
  let TriggerToTrack = TriggerToTrackMap.get(handle);
  let fnSet = new Set();
  TriggerToTrack.forEach((handle) => {
    let fnSetChiren = handleMap.get(handle);
    if (fnSetChiren) {
      fnSetChiren.forEach((fn) => {
        if (fn) {
          fnSet.add(fn);
        }
      });
    }
  });
  fnSet.forEach((fn) => {
    fn();
  });
}

總結

以上簡易的實現了響應式系統,只是粗略的介紹瞭如何實現,會存在一些 bug

相關文章