vue3 的響應式實現

響應式(Reactivity)

概念

響應性(Reactivity)是一種容許咱們以聲明式的方式去適應變化的編程範例。html

通俗來講,就是數據變化了,相應的視圖會更新(從新渲染)。前端

實現思路

  • 當值被訪問(touch)時觸發跟蹤 (track) 函數,收集依賴(collect as dependency)
  • 檢測值是否發生變化
  • 當值變化(setter)時用觸發 (trigger) 函數通知(notify)該值相關的依賴更新(re-render)

image.png

Vue2 的實現

基本原理

Object.defineProperty()

Object.defineProperty() - JavaScript | MDNvue

ES5 的 Object.defineProperty() 方法支持在一個對象 obj 上定義一個新屬性 prop,或者修改一個對象的現有屬性 prop,並返回此對象。react

  • 語法

Object.defineProperty(obj, prop, descriptor)git

  • 參數
    • obj:要定義屬性的對象。
    • prop:要定義或修改的屬性的名稱或 Symbol 。
    • descriptor:要定義或修改的屬性描述符。對象裏目前存在的屬性描述符有兩種主要形式:數據描述符 和 存取描述符。數據描述符 是一個具備值的屬性,該值能夠是可寫的,也能夠是不可寫的。存取描述符 是由 getter 函數和 setter 函數所描述的屬性。

存取描述符的關鍵鍵值

get

屬性的 getter 函數,默認爲 undefined。當訪問該屬性時,會調用此函數。執行時不傳入任何參數,可是會傳入 this 對象(因爲繼承關係,這裏的this並不必定是定義該屬性的對象)。該函數的返回值會被用做屬性的值。es6

set

屬性的 setter 函數,默認爲 undefined。當屬性值被修改時,會調用此函數。該方法接受一個參數(也就是被賦予的新值),會傳入賦值時的 this 對象。github

實現方案

遍歷數據 data 的全部屬性,經過 Object.defineProperty() 攔截並改寫(自定義)數據的屬性的 getter & setter 函數,從而在訪問對象屬性和設置 / 修改對象屬性的時候可以執行自定義的回調函數:在 getter 中進行依賴收集操做(track,訪問過該屬性的節點、組件、函數……都會被收集爲依賴 watcher),在 setter 中進行視圖更新操做(trigger,通知前面收集到的依賴觸發執行 & 視圖從新渲染)。編程

簡單實現

// 重寫數組的原型方法
let oldArrayPrototype = Array.prototype;
let proto = Object.create(oldArrayPrototype);
['push', 'pop', 'unshift', 'shift', 'sort', 'reverse', 'splice'].forEach(
  (method) => {
    proto[method] = function () {
      updateView();
      oldArrayPrototype[method].call(this, ...arguments);
    };
  }
);
// 監聽數據變化
function observer(target) {
  if (typeof target !== 'object' || target == null) {
    // 不是對象,沒法更改屬性值,直接返回
    return target;
  }

  // 數組,重寫原型方法
  if (Array.isArray(target)) {
    target.__proto__ = proto;
  }

  // 循環對象,從新定義屬性的 getter & setter
  for (let key in target) {
    defineReactive(target, key, target[key]);
  }
}

// 定義響應式
function defineReactive(obj, key, val) {
  observer(val);
  Object.defineProperty(obj, key, {
    get() {
      // 在這裏進行依賴收集(略)
      return val;
    },
    set(newVal) {
      if (newVal !== val) {
        observer(newVal);
        // 在這裏進行依賴觸發(略)
        updateView();
        val = newVal;
      }
    },
  });
}

function updateView() {
  console.log('視圖更新');
}
複製代碼

缺陷

  1. 影響性能(如增長首次渲染時間)、增長內存消耗,尤爲是數據層級很深時。
  • 緣由:默認會進行遞歸。
  1. 沒法監聽數組改變 length & 基於性能考量不支持監聽數組索引變化,vue2 在監測數組的變化時須要重寫 push, pop, unshift, shift, reverse, sort, splice 這 7 個能改變原數組的原型方法。

Vue響應式原理 - 關於Array的特別處理後端

爲何defineProperty不能檢測到數組長度的「變化」api

  • 緣由:
    1. 數組的 length 屬性具備如下初始化鍵值:
// 表示對象的屬性是否能夠被枚舉,如可否經過 for-in 循環返回該屬性。
enumberable: false

// 表示對象的屬性是否能夠被刪除,以及除 value 和 writable 特性(鍵值)外的其餘特性(如 get、set)是否能夠被修改。
configurable: false

// 表示對象的屬性值是否能夠被改變
writable: true
複製代碼
    • length 屬性初始爲 non-configurable,沒法刪除 / 修改 length 屬性,沒法改寫 length 屬性的 getter & setter 函數,所以,經過改變 length 而變化的數組長度不能被 Object.defineProperty() 監測到。
    • 而 push, pop, unshift, shift, splice 這幾個內置的方法在操做數組時,都會改變原數組 length 的值,而 Object.defineProperty() 不能監測到數組長度的變化,於是不會觸發視圖更新。
  1. 對於 reverse, sort 方法,沒有改變數組 length,改變的是數組的索引。數組的索引是能夠被 Object.defineProperty() 監測到的屬性,🌰

1.png

但 Vue2 沒有支持,官方回覆是由於性能問題,在性能和用戶體驗之間作了取捨。👇

2.png

  1. 對象上新增的屬性不能被攔截。
  • 緣由:Object.defineProperty() 須要指定對象具體的屬性名才能對其 getter 和 setter 進行攔截。
  • 補丁:Vue2 提供了一個 api:this.$set,使新增的屬性也擁有響應式的效果。可是須要判斷到底什麼狀況下須要用 $set,何時能夠直接觸發響應式。

Vue3 的實現

基本原理

Proxy

Proxy 是一個包含另外一個對象或函數並容許你對其進行攔截的對象。 Proxy - JavaScript | MDN

ES6 的 Proxy 對象用於建立一個對象的代理,從而實現對其基本操做的攔截 & 自定義。

  • 語法

const p = new Proxy(target, handler)

  • 參數
    • target:要使用 Proxy 包裝的目標對象(能夠是任何類型的對象,包括原生數組,函數,甚至另外一個代理)。
    • handler:一個一般以函數做爲屬性的對象,它包含有 Proxy 的各個捕獲器(trap),定義了在執行各類操做時代理 p 的行爲。全部的捕捉器是可選的。若是沒有定義某個捕捉器,那麼就會保留源對象的默認行爲。Vue3 用到的 traps:
      • handler.get():屬性讀取操做。
      • handler.set():屬性設置操做。
      • handler.deleteProperty():屬性 delete 操做。
      • handler.has():屬性 in 操做符。
      • handler.ownKeys()Object.getOwnPropertyNames 方法和 Object.getOwnPropertySymbols 方法的捕捉器。

Reflect

Reflect - ECMAScript 6入門

Reflect 對象與 Proxy 對象同樣,也是 ES6 爲了操做對象而提供的新 API。相比 Object 對象主要有以下特色 / 優點:

  1. 將 Object 對象的一些明顯屬於語言內部的方法(好比 Object.defineProperty),放到 Reflect 對象上。現階段,某些方法同時在 Object 和 Reflect 對象上部署,將來的新方法將只部署在 Reflect 對象上。也就是說,從 Reflect 對象上能夠拿到語言內部的方法。
  2. 返回結果更合理,不會報錯,操做失敗會返回 false。好比,Object.defineProperty(obj, name, desc) 在沒法定義屬性時,會拋出一個錯誤;而 Reflect.defineProperty(obj, name, desc) 則會返回 false。
  3. 方法都是函數式。Object 存在某些命令式操做,如 name in objdelete obj[name] ,而對應的 Reflect.has(obj, name)Reflect.deleteProperty(obj, name) 都是函數式操做。
  4. Reflect 對象的方法與 Proxy 對象的方法一一對應,只要是 Proxy 對象的方法,就能在 Reflect 對象上找到對應的方法。所以 Proxy 對象能夠方便地調用對應的 Reflect 方法,完成默認行爲,做爲修改行爲的基礎。

WeakMap

WeakMap - JavaScript | MDN

WeakMap 對象是一組鍵/值對的集合,其中的鍵是弱引用的。其鍵必須是對象,而值能夠是任意的。原生的 WeakMap 持有的是每一個鍵對象的「弱引用」,這意味着在沒有其餘引用存在時垃圾回收能正確進行。原生 WeakMap 的結構是特殊且有效的,其用於映射的 key 只有在其沒有被回收時纔是有效的。
WeakMap 鍵名所指向的對象,不計入垃圾回收機制,有助於防止內存泄漏。因此WeakMap 能夠實現往對象上添加數據,又不會干擾垃圾回收機制。

實現方案

用 Proxy 代理數據,建立響應式對象,攔截其 getter 和 setter 函數;依賴該數據 / 屬性的方法(稱爲反作用 effect)默認會先執行一次,觸發所依賴屬性的 get 方法,在 getter 函數中進行依賴收集(track,把當前屬性與當前的 effect 創建聯繫,即映射表);當屬性變化時,會觸發其 set 方法,在 setter 函數中進行更新(trigger,依次觸發映射表中依賴當前屬性的 effect)。

關鍵方法

reactive

把數據變爲響應式,遍歷 & 自定義對象全部屬性的 getter & setter 函數,返回 proxy 對象。

effect

  • effect 方法本質是一個高階函數(入參或出參是函數),默認會當即執行傳入的函數(此時會觸發內部函數響應式對象的 get 方法,從而觸發依賴收集),在依賴的數據變化時會再執行。
  • 含義:反作用(數據變化會觸發相應的回調),至關於 Vue2 中的 watcher。

具體實現

reactive

// 判斷是不是對象
function isObject(val) {
  return typeof val === 'object' && val !== null;
}

// 1.響應式的核心方法
function reactive(target) {
  // 建立響應式對象
  return createReactiveObject(target);
}

let toProxy = new WeakMap(); //弱引用映射表,es6;放的是 「原對象:代理後的對象」
// 防止被代理過的對象再次被代理
let toRaw = new WeakMap(); // 「代理後的對象:原對象」

// 判斷當前對象有無某屬性
function hasOwn(target, key) {
  return target.hasOwnProperty(key);
}

// 建立響應式對象
function createReactiveObject(target) {
  if (!isObject(target)) {
    // 不是對象,直接返回
    return target;
  }

  let proxy = toProxy.get(target);
  if (proxy) {
    // 若是 target 已經有相應的代理後的對象,直接返回以前代理過的結果便可
    return proxy;
  }
  if (toRaw.has(target)) {// 判斷 target 是否已是 reactive 對象
    // target 已是代理後的對象了,則無需再次代理
    return target;
  }

  const baseHandler = {
    // reflect 優勢:不會報錯 & 會有返回值;之後會替代 Object
    get(target, key, receiver) {
      // target:原對象, key:屬性, receiver:當前的代理對象 proxy(target 被代理後的對象)
      console.log('獲取');
      let res = Reflect.get(target, key, receiver);
      // res 是當前獲取到的值
      return isObject(res) ? reactive(res) : res; // 按需實現遞歸
    },
    set(target, key, value, receiver) {
      // 識別是 修改屬性 or 新增屬性
      let hadKey = hasOwn(target, key); //判斷這個屬性之前有沒有
      let oldValue = target[key];
      let res = Reflect.set(target, key, value, receiver);
      if (!hadKey) {
        console.log('新增屬性');
        console.log('設置');
      } else if (value !== oldValue) {
        // 屏蔽無心義的修改(即修改先後值相同)
        console.log('修改屬性');
        console.log('設置');
      }

      return res;
    },
    deleteProperty(target, key) {
      console.log('刪除');
      let res = Reflect.deleteProperty(target, key);
      return res;
    },
  };
  // 建立觀察者
  let observer = new Proxy(target, baseHandler); // es6
  toProxy.set(target, observer);
  toRaw.set(observer, target);

  return observer;
}
複製代碼

effect

// reactive 中函數 createReactiveObject 的 baseHandler 修改以下
const baseHandler = {
    get(target, key, receiver) {
      let res = Reflect.get(target, key, receiver);
      // 收集依賴(把屬性 & 對應的 effect 創建聯繫),即 訂閱【把當前的 key 與 effect 對應起來】
      track(target, key); // 若是目標上的 key 變化了,從新讓數組中的 effect 執行便可
      return isObject(res) ? reactive(res) : res; // 按需實現遞歸
    },
    set(target, key, value, receiver) {
      let hadKey = hasOwn(target, key);
      let oldValue = target[key];
      let res = Reflect.set(target, key, value, receiver);
      if (!hadKey) {
        trigger(target, 'add', key);
      } else if (value !== oldValue) {
        trigger(target, 'edit', key);
      }

      return res;
    },
    deleteProperty(target, key) {
      console.log('刪除');
      let res = Reflect.deleteProperty(target, key);
      return res;
    },
  };
複製代碼
// 2.依賴收集(發佈訂閱)
// 取值會觸發 get,get 觸發 track(track 裏存映射表,最外層是個 WeakMap);設置值時觸發 set,set 觸發 trigger,取出 effect 執行,更新視圖

// 棧:先進後出
let activeEffectStacks = []; // 保存 reactiveEffect

// 依賴的數據結構應該以下
// {
// target: {
// key: [fn, fn, fn,...] // 一個屬性可能對應多個反作用(即有多個 effect 都依賴這個屬性)【應去重,因此用 Set 數據結構】
// }
// }

let targetSMap = new WeakMap(); // 集合 和 hash 表

function track(target, key) {
  //若這個 target 中的 key 變化了,就執行棧中的方法
  let effect = activeEffectStacks[activeEffectStacks.length - 1];
  if (effect) {
    // 有對應關係,才建立關聯【如下爲動態建立依賴關係】
    let depsMap = targetSMap.get(target);
    if (!depsMap) {
      // 首次沒有,設置一個並設默認值
      targetSMap.set(target, (depsMap = new Map()));
    }

    // 取對象的 key 對應的反作用數組
    let deps = depsMap.get(key);
    if (!deps) {
      depsMap.set(key, (deps = new Set()));
    }
    if (!deps.has(effect)) {
      deps.add(effect);
    }
  }
}

function trigger(target, type, key) {
  let depsMap = targetSMap.get(target);
  if (depsMap) {
    // 有才須要觸發
    let deps = depsMap.get(key);
    if (deps) {
      // 將當前 key 對應的 effect 依次執行
      deps.forEach((effect) => effect());
    }
  }
}

// 響應式——反作用
function effect(fn) {
  // 須要把 fn 這個函數 變成 響應式的函數
  let reactiveEffect = createReactiveEffect(fn);
  // 反作用 默認會先執行一次
  reactiveEffect();
}

function createReactiveEffect(fn) {
  let reactiveEffect = function () {
    // 建立的響應式的 effect
    return run(reactiveEffect, fn); // 2個目的:一、執行 fn;二、把這個 reactiveEffect 存到棧中
  };
  return reactiveEffect;
}

// 運行 fn & 把 effect 存起來
function run(effect, fn) {
  try {
    activeEffectStacks.push(effect);
    fn(); // 和 vue2 同樣,利用 js 的單線程
  } finally {
    // 即便前面報錯,這裏也會執行
    activeEffectStacks.pop();
  }
}
複製代碼

ref

  • ref 中能夠用相似 _isRef 字段來判斷是否爲 ref 類型
  • reactive 中 get 函數須要判斷 res 是否爲 ref 對象,如果則直接返回 value
// 若是傳入 ref 的是一個對象,將調用 reactive 方法進行深層響應轉換。
const convert = (raw) => (isObject(raw) ? reactive(raw) : raw); 

function ref(raw) {
  raw = convert(raw);
  const v = {
    _isRef: true,
    get value() {
      track(v, '');
      return raw;
    },
    set value(newValue) {
      raw = convert(newValue);
      trigger(v, '');
    },
  };
  return v;
}
複製代碼

computed

  • 返回一個 ref 對象
  • 原始值 value 應該存放在閉包內,使用 dirty 字段決定是否被緩存
  • 依賴觸發 trigger 時,不會當即執行 effect,而是執行 effect options 中的 scheduler
function effect(fn, options = {}) {
  const effect = createReactiveEffect(fn, options);
  if (!options.lazy) {
    effect();
  }
  return effect;
}

function createReactiveEffect(fn, options) {
  const effect = function () {
    return run(effect, fn);
  };
  effect.scheduler = options.scheduler;
  return effect;
}

function computed(getterOrOptions) {
  const getter = isFunction(getterOrOptions)
    ? getterOrOptions
    : getterOrOptions.get;
  const setter = isFunction(getterOrOptions) ? () => {} : getterOrOptions.set;
  let value;
  let dirty = true;
  let v;
  const runner = effect(getter, {
    lazy: true,
    scheduler: () => {
      dirty = true;
      trigger(v, '');
    },
  });
  v = {
    _isRef: true,
    get value() {
      if (dirty) {
        value = runner();
        dirty = false;
      }
      track(v, '');
      return value;
    },
    set value(newValue) {
      setter(newValue);
    },
  };
  return v;
}
複製代碼

應用

API 特性 適用場景
reactive - 接收一個普通對象而後返回該普通對象的響應式代理。
- 響應式轉換是「深層的」:會影響對象內部全部嵌套的屬性。返回的代理對象 不等於 原始對象。建議僅使用代理對象而避免依賴原始對象。
只能用於代理非基本數據類型 object。
toRefs 能夠將一個響應型對象(reactive object) 轉化爲普通對象(plain object),同時又把該對象中的每個屬性轉化成對應的響應式屬性(ref)。 保留被解構的響應式對象(reactive object)的響應式特性(reactivity)【響應式對象被解構後會丟失響應性】,e.g. ...toRefs(data)
ref - 接受一個參數值並返回一個響應式且可改變的 ref 對象。ref 對象擁有一個指向內部值的單一 property.value。
- 若是傳入 ref 的是一個對象,將調用 reactive 方法進行深層響應轉換。
- 使用 ref api 時,數據變成了對象,值就是 value 屬性的值,若是數據自己就是對象,依然會多一層 value 結構,而 reactive 沒有這些反作用。
- 通常用於給 js 基本數據類型添加響應性(也支持非基本類型的 object)
- 基本數據類型共 7 個,只能使用 ref:String,Number,BigInt,Boolean,Symbol,Null,Undefined
watch - 監聽特定的 data 源,並在單獨的回調函數中定義反作用。默認狀況下,它也是惰性的——即,回調僅在監聽源發生更改時調用。
- options:
-- immediate:表示是否在第一次渲染的時候執行這個函數。
-- deep:若是咱們監聽一個對象,是否要看這個對象裏面屬性的變化。
- 監聽:若是某一 / 多個屬性變化,就去執行回調函數。
- 惰性地執行反作用。
- 更具體地說明應觸發偵聽器從新運行的狀態;在數據變化的回調中執行異步操做或者開銷很大的時候使用。
- 訪問偵聽狀態的先前值和當前值。
computed - 使用 getter 函數,併爲從 getter 返回的值返回一個不變的響應式 ref 對象,不能直接對 computed 返回值的 value 屬性賦值。
- 也可使用具備 get 和 set 函數的對象來建立可寫的 ref 對象。
- 計算屬性,用於須要監聽一 / 多個值而且生成一個新的屬性時。
- 會根據依賴自動緩存,若是依賴不變,這個值就不會從新計算。

優勢

  1. 性能(如首次渲染時間)、內存消耗等方面都優於 Vue2。
  • 緣由:Vue3 只有訪問到(get)某個屬性時纔會對其下一層(如有)作響應式,而 Vue一、2 都是在首次渲染就對全部狀態遍歷(一次性層層遞歸)攔截作響應式。
  1. 能夠監聽數組改變 length。
  • 緣由:MDN-Proxy中提到:target 是被 Proxy 代理虛擬化的對象。它常被做爲代理的存儲後端。根據目標驗證關於對象不可擴展性或不可配置屬性的不變量(保持不變的語義)。Array.length 就是不可配置的屬性,故Proxy能夠監聽原數組中長度的變化。
  1. 對象上原有屬性 & 新增屬性均可以攔截。
  • 緣由:Proxy 攔截的是整個對象 data,監聽了 data 上任意屬性的訪問 & 設置,不須要指定要攔截的屬性 key。而 Object.defineProperty() 只能實現對對象的屬性進行劫持,因此對於對象上的方法或者新增、刪除的屬性無能爲力。

缺點

兼容性,IE 11 及如下版本不兼容 ES6 的 Proxy

Reference

深刻響應性原理 | Vue.js

從零實現Vue3.0響應式源碼(正式版)

Vue3 的響應式和之前有什麼區別,Proxy 無敵?

  • More Detail

vue3.0 響應式原理(超詳細)

深刻響應式原理 | Vue.js 技術揭祕

1.1萬字從零解讀Vue3.0源碼響應式系統-前端開發博客

相關文章
相關標籤/搜索