vue2.0與3.0響應式原理機制

大鵬_yp發表於2021-05-24

vue2.0響應式原理 - defineProperty

這個原理老生常談了,就是攔截物件,給物件的屬性增加set 和 get方法,因為核心是defineProperty所以還需要對陣列的方法進行攔截

一、變化追蹤

  • 把一個普通 JavaScript 物件傳給 Vue 例項的 data 選項,Vue 將遍歷此物件所有的屬性,並使用 Object.defineProperty 把這些屬性全部轉為 getter/setter。
  • Object.defineProperty 是僅 ES5 支援,且無法 shim 的特性,這也就是為什麼 Vue 不支援 IE8 以及更低版本瀏覽器的原因。
  • 使用者看不到 getter/setter,但是在內部它們讓 Vue 追蹤依賴,在屬性被訪問和修改時通知變化。
  • 每個元件例項都有相應的 watcher 例項物件,它會在元件渲染的過程中把屬性記錄為依賴,之後當依賴項的 setter 被呼叫時,會通知 watcher 重新計算,從而致使它關聯的元件得以更新。

原理:在初次渲染的過程中就會呼叫物件屬性的getter函式,然後getter函式通知wather物件將之宣告為依賴,依賴之後,如果物件屬性發生了變化,那麼就會呼叫settter函式來通知watcher,watcher就會在重新渲染元件,以此來完成更新。

二、變化檢測問題

Vue 不能檢測到物件屬性的新增或刪除。由於 Vue 會在初始化例項時對屬性執行 getter/setter 轉化過程,所以屬性必須在 data 物件上存在才能讓 Vue 轉換它,這樣才能讓它是響應的。

var vm = new Vue({
   el: '#app',
   data:{
     a:1,
     k: {}
    }
  })

// `vm.a` 是響應的

vm.b = 2
// `vm.b` 是非響應的

對於已經建立的例項,Vue 不允許動態新增根級別的響應式 property。但是,可以使用 Vue.set(object, propertyName, value) 方法向巢狀物件新增響應式 property。

Vue.set(vm.someObject, 'b', 2)

//您還可以使用 vm.$
set 例項方法,這也是全域性 Vue.set 方法的別名: this.$set(this.someObject,'b',2)

// 代替 `Object.assign(this.someObject, { a: 1, b: 2 })`
this.someObject = Object.assign({}, this.someObject, { a: 1, b: 2 })

Vue 不能檢測以下陣列的變動:

  1. 當你利用索引直接設定一個陣列項時,例如:vm.items[indexOfItem] = newValue
  2. 當你修改陣列的長度時,例如:vm.items.length = newLength
// Vue.set
Vue.set(vm.items, indexOfItem, newValue)
// Array.prototype.splice
vm.items.splice(indexOfItem, 1, newValue)

vm.$set(vm.items, indexOfItem, newValue)
vm.items.splice(newLength)

三、宣告響應式屬性

由於 Vue 不允許動態新增根級響應式屬性,所以你必須在初始化例項前宣告根級響應式屬性,可以為一個空值

如果你在 data 選項中未宣告 message,Vue 將警告你渲染函式在試圖訪問的屬性不存在。

var vm = new Vue({
  data: {
    // 宣告 message 為一個空值字串
    message: ''
  },
  template: '<div>{{ message }}</div>'
})
// 之後設定 `message`
vm.message = 'Hello!'

四、非同步更新佇列

Vue 在更新 DOM 時是非同步執行的。

只要觀察到資料變化,Vue 將開啟一個佇列,並緩衝在同一事件迴圈中發生的所有資料改變。如果同一個 watcher 被多次觸發,只會一次推入到佇列中。

Vue 在內部嘗試對非同步佇列使用原生的 Promise.then 和 MutationObserver,如果執行環境不支援,會採用 setTimeout(fn, 0) 代替。

五、攔截

Object.defineProperty缺點

  • 無法監聽陣列的變化
  • 需要深度遍歷,浪費記憶體

對物件進行攔截

function observer(target){
// 如果不是物件資料型別直接返回即可
    if(typeof target !== 'object'){
        return target
    }
    // 重新定義key
    for(let key in target){
        defineReactive(target,key,target[key])
    }
}
//更新
function update(){
    console.log('update view')
}
function defineReactive(obj,key,value){
  
  // 校驗----物件巢狀物件,遞迴劫持
    observer(value); 

    Object.defineProperty(obj,key,{
        get(){
            // 在get 方法中收集依賴
            return value
        },
        set(newVal){
            if(newVal !== value){
                observer(value);
                update(); // 在set方法中觸發更新
            }
        }
    })
}
let obj = {name:'youxuan'}
observer(obj);
obj.name = 'webyouxuan';

陣列方法劫持

let oldProtoMehtods = Array.prototype;
let proto = Object.create(oldProtoMehtods);
['push','pop','shift','unshift'].forEach(method=>{
    Object.defineProperty(proto,method,{
        get(){
            update();
            oldProtoMehtods[method].call(this,...arguments)
        }
    })
})
function observer(target){
if(typeof target !== 'object'){ return target } // 如果不是物件資料型別直接返回即可 if(Array.isArray(target)){ Object.setPrototypeOf(target,proto); // 給陣列中的每一項進行observr for(let i = 0 ; i < target.length;i++){ observer(target[i]) } return }; // 重新定義key for(let key in target){ defineReactive(target,key,target[key]) } }

Vue3.0資料響應機制 - Proxy

首先熟練一下ES6中的 ProxyReflect 及 ES6中為我們提供的 MapSet兩種資料結構。

Proxy

  用於建立一個物件的代理,從而實現基本操作的攔截和自定義(如屬性查詢、賦值、列舉、函式呼叫等)。

語法:const p = new Proxy(target, handler)

引數

  target:要使用 Proxy 包裝的目標物件(可以是任何型別的物件,包括原生陣列,函式,甚至另一個代理)。
  handler:一個通常以函式作為屬性的物件,各屬性中的函式分別定義了在執行各種操作時代理 p 的行為。
const handler = {
    get: function(obj, prop) {
        return prop in obj ? obj[prop] : 37;
    }
};

const p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;

console.log(p.a, p.b);      // 1, undefined
console.log('c' in p, p.c); // false, 37

Reflect 

是一個內建的物件,它提供攔截 JavaScript 操作的方法

1、Reflect.deleteProperty(targetpropertyKey)

  作為函式的delete操作符,相當於執行 delete target[name]

2、Reflect.set(targetpropertyKeyvalue[, receiver])

  將值分配給屬性的函式。返回一個Boolean,如果更新成功,則返回true

3、Reflect.get(targetpropertyKey[, receiver])

  獲取物件身上某個屬性的值,類似於 target[name]。

先應用再說原理:

let p = Vue.reactive({name:'youxuan'});
Vue.effect(()=>{ // effect方法會立即被觸發
    console.log(p.name);
})
p.name = 'webyouxuan';; // 修改屬性後會再次觸發effect方法

一、reactive方法實現

通過proxy 自定義獲取、增加、刪除等行為

1) 物件操作

// 1、宣告響應式物件
function reactive(target) {
    return createReactiveObject(target);
}
// 是否是物件型別
function isObject(target) {
    return typeof target === 'object' && target !== null;
}
// 2、建立
function createReactiveObject(target) {
    // 判斷target是不是物件,不是物件不必繼續
    if (!isObject(target)) {
        return target;
    }
    const handlers = {
        get(target, key, receiver) { // 取值
            console.log('獲取')
            let res = Reflect.get(target, key, receiver);
            return res;
        },
        set(target, key, value, receiver) { // 更改 、 新增屬性
            console.log('設定')
            let result = Reflect.set(target, key, value, receiver);
            return result;
        },
        deleteProperty(target, key) { // 刪除屬性
            console.log('刪除')
            const result = Reflect.deleteProperty(target, key);
            return result;
        }
    }
    // 開始代理
    observed = new Proxy(target, handlers);
    return observed;
}

let p = reactive({ name: 'youxuan' });
console.log(p.name); // 獲取
p.name = 'webyouxuan'; // 設定
delete p.name; // 刪除

深層代理

由於我們只代理了第一層物件,所以對age物件進行更改是不會觸發set方法的,但是卻觸發了get方法,這是由於 p.age會造成 get操作

let p = reactive({ name: "123", age: { num: 10 } });
p.age.num = 11

get改進方案

  這裡我們將p.age取到的物件再次進行代理,這樣在去更改值即可觸發set方法

get(target, key, receiver) {
      // 取值
    console.log("獲取");
    let res = Reflect.get(target, key, receiver);

  // 懶代理,只有當取值時再次做代理
  return isObject(res)? reactive(res) : res; }

2)陣列操作

Proxy預設可以支援陣列,包括陣列的長度變化以及索引值的變化
產生問題:
let p = reactive([1,2,3,4]);
p.push(5);

會觸發兩次set方法,第一次更新的是陣列中的第4項,第二次更新的是陣列的length

 遮蔽掉多次觸發:
set(target, key, value, receiver) {
    // 更改、新增屬性
    let oldValue = target[key]; // 獲取上次的值
    let hadKey = hasOwn(target,key); // 看這個屬性是否存在
    let result = Reflect.set(target, key, value, receiver);
    if(!hadKey){ // 新增屬性
        console.log('更新 新增')
    }else if(oldValue !== value){ // 修改存在的屬性
        console.log('更新 修改')
    }
    // 當呼叫push 方法第一次修改時陣列長度已經發生變化
    // 如果這次的值和上次的值一樣則不觸發更新
    return result;
}

解決重複使用reactive情況

// 情況1.多次代理同一個物件
let arr = [1,2,3,4];
let p = reactive(arr);
reactive(arr);

// 情況2.將代理後的結果繼續代理
let p = reactive([1,2,3,4]);
reactive(p);

 通過hash表的方式來解決重複代理的情況

const toProxy = new WeakMap(); // 存放被代理過的物件
const toRaw = new WeakMap(); // 存放已經代理過的物件
function reactive(target) {
  // 建立響應式物件
  return createReactiveObject(target);
}
function isObject(target) {
  return typeof target === "object" && target !== null;
}
function hasOwn(target,key){
  return target.hasOwnProperty(key);
}
function createReactiveObject(target) {
  if (!isObject(target)) {
    return target;
  }
  let observed = toProxy.get(target);
  if(observed){ // 判斷是否被代理過
    return observed;
  }
  if(toRaw.has(target)){ // 判斷是否要重複代理
    return target;
  }
  const handlers = {
    get(target, key, receiver) {
      // 取值
      console.log("獲取");
      let res = Reflect.get(target, key, receiver);
      return isObject(res) ? reactive(res) : res;
    },
    set(target, key, value, receiver) {
      let oldValue = target[key];
      let hadKey = hasOwn(target,key);
      let result = Reflect.set(target, key, value, receiver);
      if(!hadKey){
        console.log('更新 新增')
      }else if(oldValue !== value){
        console.log('更新 修改')
      }
      return result;
    },
    deleteProperty(target, key) {
      console.log("刪除");
      const result = Reflect.deleteProperty(target, key);
      return result;
    }
  };
  // 開始代理
  observed = new Proxy(target, handlers);
  toProxy.set(target,observed);
  toRaw.set(observed,target); // 做對映表
  return observed;
}

 二、effect實現

effect意思是副作用,此方法預設會先執行一次。如果資料變化後會再次觸發此回撥函式。

let user= {name:'大鵬'}
let p = reactive(user);
effect(()=>{
    console.log(p.name);  // 大鵬
})

實現方法

function effect(fn) {
  const effect = createReactiveEffect(fn); // 建立響應式的effect
  effect(); // 先執行一次
  return effect;
}
const activeReactiveEffectStack = []; // 存放響應式effect
function createReactiveEffect(fn) {
  const effect = function() {
    // 響應式的effect
    return run(effect, fn);
  };
  return effect;
}
function run(effect, fn) {
    try {
      activeReactiveEffectStack.push(effect);
      return fn(); // 先讓fn執行,執行時會觸發get方法,可以將effect存入對應的key屬性
    } finally {
      activeReactiveEffectStack.pop(effect);
    }
}

當呼叫fn()時可能會觸發get方法,此時會觸發track

const targetMap = new WeakMap();
function track(target,type,key){
    // 檢視是否有effect
    const effect = activeReactiveEffectStack[activeReactiveEffectStack.length-1];
    if(effect){
        let depsMap = targetMap.get(target);
        if(!depsMap){ // 不存在map
            targetMap.set(target,depsMap = new Map());
        }
        let dep = depsMap.get(target);
        if(!dep){ // 不存在set
            depsMap.set(key,(dep = new Set()));
        }
        if(!dep.has(effect)){
            dep.add(effect); // 將effect新增到依賴中
        }
    }
}

當更新屬性時會觸發trigger執行,找到對應的儲存集合拿出effect依次執行、

function trigger(target,type,key){
    const depsMap = targetMap.get(target);
    if(!depsMap){
        return
    }
    let effects = depsMap.get(key);
    if(effects){
        effects.forEach(effect=>{
            effect();
        })
    }
}

我們發現如下問題

  新增了值,effect方法並未重新執行,因為push中修改length已經被我們遮蔽掉了觸發trigger方法,所以當新增項時應該手動觸發length屬性所對應的依賴。

let school = [1,2,3];
let p = reactive(school);
effect(()=>{
    console.log(p.length);
})
p.push(100);

解決

function trigger(target, type, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) {
    return;
  }
  let effects = depsMap.get(key);
  if (effects) {
    effects.forEach(effect => {
      effect();
    });
  }
  // 處理如果當前型別是增加屬性,如果用到陣列的length的effect應該也會被執行
  if (type === "add") {
    let effects = depsMap.get("length");
    if (effects) {
      effects.forEach(effect => {
        effect();
      });
    }
  }
}

三、ref實現

ref可以將原始資料型別也轉換成響應式資料,需要通過.value屬性進行獲取值

function convert(val) {
  return isObject(val) ? reactive(val) : val;
}
function ref(raw) {
  raw = convert(raw);
  const v = {
    _isRef:true, // 標識是ref型別
    get value() {
      track(v, "get", "");
      return raw;
    },
    set value(newVal) {
      raw = newVal;
      trigger(v,'set','');
    }
  };
  return v;
}

問題又來了我們再編寫個案例

  這樣做的話豈不是每次都要多來一個.value,這樣太難用了

let r = ref(1);
let c = reactive({
    a:r
});
console.log(c.a.value);

解決

get方法中判斷如果獲取的是ref的值,就將此值的value直接返回即可

let res = Reflect.get(target, key, receiver);
if(res._isRef){
  return res.value
}

四、computed實現

computed 實現也是基於 effect 來實現的,特點是computed中的函式不會立即執行,多次取值是有快取機制的

let a = reactive({name:'youxuan'});
let c = computed(()=>{
  console.log('執行次數')
  return a.name +'webyouxuan';
})
// 不取不執行,取n次只執行一次
console.log(c.value);
console.log(c.value);
function computed(getter){
  let dirty = true;
  const runner = effect(getter,{ // 標識這個effect是懶執行
    lazy:true, // 懶執行
    scheduler:()=>{ // 當依賴的屬性變化了,呼叫此方法,而不是重新執行effect
      dirty = true;
    }
  });
  let value;
  return {
    _isRef:true,
    get value(){
      if(dirty){
        value = runner(); // 執行runner會繼續收集依賴
        dirty = false;
      }
      return value;
    }
  }
}

修改effect方法

function effect(fn,options) {
  let effect = createReactiveEffect(fn,options);
  if(!options.lazy){ // 如果是lazy 則不立即執行
    effect();
  }
  return effect;
}
function createReactiveEffect(fn,options) {
  const effect = function() {
    return run(effect, fn);
  };
  effect.scheduler = options.scheduler;
  return effect;
}

trigger時判斷

deps.forEach(effect => {
  if(effect.scheduler){ // 如果有scheduler 說明不需要執行effect
    effect.scheduler(); // 將dirty設定為true,下次獲取值時重新執行runner方法
  }else{
    effect(); // 否則就是effect 正常執行即可
  }
});
let a = reactive({name:'youxuan'});
let c = computed(()=>{
  console.log('執行次數')
  return a.name +'webyouxuan';
})
// 不取不執行,取n次只執行一次
console.log(c.value);
a.name = 'zf10'; // 更改值 不會觸發重新計算,但是會將dirty變成true

console.log(c.value); // 重新呼叫計算方法

 

相關文章