一文帶你深入剖析vue3的響應式

2022-08-10 22:02:42
本篇文章帶你深度剖析vue3響應式(附腦圖),本文的目標是實現一個基本的vue3的響應式,包含最基礎的情況的處理。

本文你將學到

  • 一個基礎的響應式實現 ✅
  • Proxy ✅
  • Reflect ✅
  • 巢狀effect的實現 ✅
  • computed ✅
  • watch ✅
  • 淺響應與深響應 ✅
  • 淺唯讀與深唯讀 ✅
  • 處理陣列長度 ✅
  • ref ✅
  • toRefs ✅

1.png

一. 實現一個完善的響應式

所謂的響應式資料的概念,其實最主要的目的就是為資料繫結執行函數,當資料發生變動的時候,再次觸發函數的執行。(學習視訊分享:)

例如我們有一個物件data,我們想讓它變成一個響應式資料,當data的資料發生變化時,自動執行effect函數,使nextVal變數的值也進行變化:

// 定義一個物件
let data = {
  name: 'pino',
  age: 18
}

let nextVal
// 待繫結函數
function effect() {
  nextVal = data.age + 1
}

data.age++

上面的例子中我們將data中的age的值進行變化,但是effect函數並沒有執行,因為現在effect函數與data這個物件不能說是沒啥聯絡,簡直就是半毛錢的關係都沒有。

那麼怎麼才能使這兩個毫不相關的函數與物件之間產生關聯呢?

因為一個物件最好可以繫結多個函數,所以有沒有可能我們為data這個物件定義一個空間,每當data的值進行變化的時候就會執行這個空間裡的函數?

答案是有的。

1. Object.defineProperty()

js在原生提供了一個用於操作物件的比較底層的api:Object.defineProperty(),它賦予了我們對一個物件的讀取和攔截的操作。

Object.defineProperty()方法直接在一個物件上定義一個新屬性,或者修改一個已經存在的屬性, 並返回這個物件。

  Object.defineProperty(obj, prop, descriptor)

引數

obj 需要定義屬性的物件。 prop 需被定義或修改的屬性名。 descriptor (描述符) 需被定義或修改的屬性的描述符。

其中descriptor接受一個物件,物件中可以定義以下的屬性描述符,使用屬性描述符對一個物件進行攔截和控制:

  • value——當試圖獲取屬性時所返回的值。

  • writable——該屬性是否可寫。

  • enumerable——該屬性在for in迴圈中是否會被列舉。

  • configurable——該屬性是否可被刪除。

  • set()——該屬性的更新操作所呼叫的函數。

  • get()——獲取屬性值時所呼叫的函數。

另外,資料描述符(其中屬性為: enumerableconfigurablevaluewritable )與存取描述符(其中屬性為 enumerableconfigurableset()get() )之間是有互斥關係的。在定義了 set()get() 之後,描述符會認為存取操作已被 定義了,其中再定義 valuewritable 會引起錯誤。

 let obj = {
   name: "小花"
 }

 Object.defineProperty(obj, 'name', {
   // 屬性讀取時進行攔截
   get() { return '小明'; },
   // 屬性設定時攔截
   set(newValue) { obj.name = newValue; },
   enumerable: true,
   configurable: true
 });

上面的例子中就已經完成對一個物件的最基本的攔截,這也是vue2.x中對物件監聽的方式,但是由於Object.defineProperty()中存在一些問題,例如:

  • 一次只能對一個屬性進行監聽,需要遍歷來對所有屬性監聽

  • 對於物件的新增屬性,需要手動監聽

  • 對於陣列通過pushunshift方法增加的元素,也無法監聽

那麼vue3版本中是如何對一個物件進行攔截的呢?答案是es6中的Proxy

由於本文主要是vue3版本的響應式的實現,如果想要深入瞭解Object.defineProperty(),請移步:MDN Object.defineProperty

2. Proxy

proxyes6版本出現的一種對物件的操作方式,Proxy 可以理解成,在目標物件之前架設一層「攔截」,外界對該物件的存取,都必須先通過這層攔截,因此提供了一種機制,可以對外界的存取進行過濾和改寫。Proxy 這個詞的原意是代理,用在這裡表示由它來「代理」某些操作,可以譯為「代理器」。

通過proxy我們可以實現對一個物件的讀取,設定等等操作進行攔截,而且直接對物件進行整體攔截,內部提供了多達13種攔截方式。

  • get(target, propKey, receiver) :攔截物件屬性的讀取,比如 proxy.fooproxy['foo']

  • set(target, propKey, value, receiver) :攔截物件屬性的設定,比如 proxy.foo = vproxy['foo'] = v ,返回一個布林值。

  • has(target, propKey) :攔截 propKey in proxy 的操作,返回一個布林值。

  • deleteProperty(target, propKey) :攔截 delete proxy[propKey] 的操作,返回一個布林值。

  • ownKeys(target) :攔截 Object.getOwnPropertyNames(proxy)Object.getOwnPropertySymbols(proxy)Object.keys(proxy)for...in 迴圈,返回一個陣列。該方法返回目標物件所有自身的屬性的屬性名,而 Object.keys() 的返回結果僅包括目標物件自身的可遍歷屬性。

  • getOwnPropertyDescriptor(target, propKey) :攔截 Object.getOwnPropertyDescriptor(proxy, propKey) ,返回屬性的描述物件。

  • defineProperty(target, propKey, propDesc) :攔截 Object.defineProperty(proxy, propKey, propDesc)Object.defineProperties(proxy, propDescs) ,返回一個布林值。

  • preventExtensions(target) :攔截 Object.preventExtensions(proxy) ,返回一個布林值。

  • getPrototypeOf(target) :攔截 Object.getPrototypeOf(proxy) ,返回一個物件。

  • isExtensible(target) :攔截 Object.isExtensible(proxy) ,返回一個布林值。

  • setPrototypeOf(target, proto) :攔截 Object.setPrototypeOf(proxy, proto) ,返回一個布林值。如果目標物件是函數,那麼還有兩種額外操作可以攔截。

  • apply(target, object, args) :攔截 Proxy (代理) 範例作為函數呼叫的操作,比如 proxy(...args)proxy.call(object, ...args)proxy.apply(...)

  • construct(target, args) :攔截 Proxy (代理) 範例作為建構函式呼叫的操作,比如 new proxy(...args)

如果想要詳細瞭解proxy,請移步:es6.ruanyifeng.com/#docs/proxy…

 let obj = {
   name: "小花"
 }
 // 只使用get和set進行演示
 let obj2 = new Proxy(obj, {
   // 讀取攔截
   get: function (target, propKey) {
     return target[propKey]
   },
   // 設定攔截
   set: function (target, propKey, value) {
     // 此處的value為使用者設定的新值
     target[propKey] = value
   }
 });

3. 一個最簡單的響應式

有了proxy,我們就可以根據之前的思路實現一個基本的響應式功能了,我們的思路是這樣的:在物件被讀取時把函數收集到一個「倉庫」,在物件的值被設定時觸發倉庫中的函數。

由此我們可以寫出一個最基本的響應式功能:

// 定義一個「倉庫」,用於儲存觸發函數
let store = new Set()
// 使用proxy進行代理
let data_proxy = new Proxy(data, {
  // 攔截讀取操作
  get(target, key) {
    // 收集依賴函數
    store.add(effect)
    return target[key]
  },
  // 攔截設定操作
  set(target, key, newVal) {
    target[key] = newVal
    // 取出所有的依賴函數,執行
    store.forEach(fn => fn())
  }
})

我們建立了一個用於儲存依賴函數的「倉庫」,它是Set型別,然後使用proxy對物件data進行代理,設定了setget攔截函數,用於攔截讀取和設定操作,當讀取屬性時,將依賴函數effect儲存到「倉庫」中,當設定屬性值時,將依賴函數從「倉庫」中取出並重新執行。

還有一個小問題,怎麼觸發物件的讀取操作呢?我們可以直接呼叫一次effect函數,如果在effect函數中存在需要收集的屬性,那麼執行一次effect函數也是比較符合常理的。

// 定義一個物件
let data = {
  name: 'pino',
  age: 18
}

let nextVal
// 待繫結函數
function effect() {
  // 依賴函數在這裡被收集
  // 當呼叫data.age時,effect函數被收集到「倉庫」中
  nextVal = data.age + 1
  console.log(nextVal)
}
// 執行依賴函數
effect() // 19

setTimeout(()=>{
  // 使用proxy進行代理後,使用代理後的物件名
  // 觸發設定操作,此時會取出effect函數進行執行
  data_proxy.age++ // 2秒後輸出 20
}, 2000)

一開始會執行一次effect,然後函數兩秒鐘後會執行代理物件設定操作,再次執行effect函數,輸出20。

2.gif

此時整個響應式流程的功能是這樣的:

階段一,在屬性被讀取時,為物件屬性收集依賴函數:

3.png

階段二,當屬性發生改變時,再次觸發依賴函數

4.png

這樣就實現了一個最基本的響應式的功能。

4. 完善

問題一

其實上面實現的功能還有很大的缺陷,首先最明顯的問題是,我們把effect函數給固定了,如果使用者使用的依賴函數不叫effect怎麼辦,顯然我們的功能就不能正常執行了。

所以先來進行第一步的優化:抽離出一個公共方法,依賴函數由使用者來傳遞引數

我們使用effect函數來接受使用者傳遞的依賴函數:

// effect接受一個函數,把這個匿名函數當作依賴函數
function effect(fn) {
  // 執行依賴函數
  fn()
}

// 使用
effect(()=>{
  nextVal = data.age + 1
  console.log(nextVal)
})

但是effect函數內部只是執行了,在get函數中怎麼能知道使用者傳遞的依賴函數是什麼呢,這兩個操作並不在一個函數內啊?其實可以使用一個全域性變數activeEffect來儲存當前正在處理的依賴函數。

修改後的effect函數是這樣的:

let activeEffect // 新增

function effect(fn) {
  // 儲存到全域性變數activeEffect
  activeEffect = fn // 新增
  // 執行依賴函數
  fn()
}

// 而在get內部只需要�收集activeEffect即可
get(target, key) {
  store.add(activeEffect)
  return target[key]
},

呼叫effect函數傳遞一個匿名函數作為依賴函數,當執行時,首先會把匿名函數賦值給全域性變數activeEffect,然後觸發屬性的讀取操作,進而觸發get攔截,將全域性變數activeEffect進行收集。

問題二

從上面我們定義的物件可以看到,我們的物件data中有兩個屬性,上面的例子中我們只給age建立了響應式連線,那麼如果我現在也想給name建立響應式連線怎麼辦呢?那好說,那我們直接向「倉庫」中繼續新增依賴函數不就行了嗎。

其實這會帶來很嚴重的問題,由於 「倉庫」並沒有與被操作的目標屬性之間建立聯絡,而上面我們的實現只是將整個「倉庫」遍歷了一遍,所以無論哪個屬性被觸發,都會將「倉庫」中所有的依賴函數都取出來執行一遍,因為整個執行程式中可能有很多物件及屬性都設定了響應式聯絡,這將會帶來很大的效能浪費。所謂牽一髮而動全身,這種結果顯然不是我們想要的。

let data = {
  name: 'pino',
  age: 18
}

5.png

所以我們要重新設計一下「倉庫」的資料結構,目的就是為了可以在屬性這個粒度下和「倉庫」建立明確的聯絡。

就拿我們上面進行操作的物件來說,存在著兩層的結構,有兩個角色,物件data以及屬性name``age

let data = {
 name: 'pino',
 age: 18
}

他們的關係是這樣的:

data
       -> name
               -> effectFn

// 如果兩個屬性讀取了同一個依賴函數
data
       -> name
               -> effectFn
       -> age
               -> effectFn

// 如果兩個屬性讀取了不同的依賴函數
data
       -> name
               -> effectFn
       -> age
               -> effectFn1

// 如果是兩個不同的物件
data
       -> name
               -> effectFn
       -> age
               -> effectFn1
data2
       -> addr
               -> effectFn

接下來我們實現一下程式碼,為了方便呼叫,將設定響應式資料的操作封裝為一個函數reactive

let newObj = new Proxy(obj, {
  // 讀取攔截
  get: function (target, propKey) {
  },
  // 設定攔截
  set: function (target, propKey, value) {
  }
});

// 封裝為

function reactive(obj) {
  return new Proxy(obj, {
    // 讀取攔截
    get: function (target, propKey) {
    },
    // 設定攔截
    set: function (target, propKey, value) {
    }
  });
}
function reactive(obj) {
  return new Proxy(obj, {
    get(target, key) {
      // 收集依賴
      track(target, key)
      return target[key]
    },
    set(target, key, newVal) {
      target[key] = newVal
      // 觸發依賴
      trigger(target, key)
    }
  })
}

function track(target, key) {
  // 如果沒有依賴函數,則不需要進行收集。直接return
  if (!activeEffect) return

  // 獲取target,也就是物件名,對應上面例子中的data
  let depsMap = store.get(target)
  if (!depsMap) {
    store.set(target, (depsMap = new Map()))
  }
  // 獲取物件中的key值,對應上面例子中的name或age
  let deps = depsMap.get(key)

  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  // 收集依賴函數
  deps.add(activeEffect)
}

function trigger(target, key) {
  // 取出物件對應的Map
  let depsMap = store.get(target)
  if(!depsMap) return
  // 取出key所對應的Set
  let deps = depsMap.get(key)
  // 執行依賴函數
  deps && deps.forEach(fn => fn());
}

我們將讀取操作封裝為了函數track,觸發依賴函數的動作封裝為了trigger方便呼叫,現在的整個「倉庫」結構是這樣的:

6.png

WeakMap

可能有人會問了,為什麼設定「倉庫」要使用WeakMap呢,我使用一個普通物件來建立不行嗎? -

WeakMap 結構與 Map 結構類似,也是用於生成鍵值對的集合。

WeakMapMap 的區別有兩點。

首先, WeakMap 只接受物件作為鍵名( null 除外),不接受其他型別的值作為鍵名。

const map = new WeakMap();
map.set(1, 2)
// TypeError: 1 is not an object!
map.set(Symbol(), 2)
// TypeError: Invalid value used as weak map key
map.set(null, 2)
// TypeError: Invalid value used as weak map key

上面程式碼中,如果將數值 1Symbol 值作為 WeakMap 的鍵名,都會報錯。

其次, WeakMap 的鍵名所指向的物件,不計入垃圾回收機制。

WeakMap 的設計目的在於,有時我們想在某個物件上面存放一些資料,但是這會形成對於這個物件的參照。請看下面的例子。

const e1 = document.getElementById('foo');
const e2 = document.getElementById('bar');
const arr = [
    [e1, 'foo 元素'],
    [e2, 'bar 元素'],
];

上面程式碼中, e1e2 是兩個物件,我們通過 arr 陣列對這兩個物件新增一些文字說明。這就形成了 arre1e2 的參照。

一旦不再需要這兩個物件,我們就必須手動刪除這個參照,否則垃圾回收機制就不會釋放 e1e2 佔用的記憶體。

// 不需要 e1 和 e2 的時候
// 必須手動刪除參照
arr [0] = null;
arr [1] = null;

上面這樣的寫法顯然很不方便。一旦忘了寫,就會造成記憶體洩露。

它的鍵名所參照的物件都是弱參照,即垃圾回收機制不將該參照考慮在內。因此,只要所參照的物件的其他參照都被清除,垃圾回收機制就會釋放該物件所佔用的記憶體。也就是說,一旦不再需要,WeakMap 裡面的鍵名物件和所對應的鍵值對會自動消失,不用手動刪除參照。

如果我們上文中target物件沒有任何參照了,那麼說明使用者已經不需要用到它了,這時垃圾回收器會自動執行回收,而如果使用Map來進行收集,那麼即使其他地方的程式碼已經對target沒有任何參照,這個target也不會被回收。

Reflect

在vue3中的實現方式和我們的基本實現還有一點不同就是在vue3中是使用Reflect來運算元據的,例如:

function reactive(obj) {
 return new Proxy(obj, {
   get(target, key, receiver) {
     track(target, key)
     // 使用Reflect.get操作讀取資料
     return Reflect.get(target, key, receiver)
   },
   set(target, key, value, receiver) {
     trigger(target, key)
     // 使用Reflect.set來操作觸發資料
     Reflect.set(target, key, value, receiver)
   }
 })
}

那麼為什麼要使用Reflect來運算元據呢,像之前一樣直接操作原物件不行嗎,我們先來看一下一種特殊的情況:

const obj = {
  foo: 1,
  get bar() {
    return this.foo
  }
}

effect依賴函數中通過代理物件p存取bar屬性:

effect(()=>{
  console.log(p.bar) // 1
})

可以分析一下這個過程發生了什麼,當effect函數被呼叫時,會讀取p.bar屬性,他發現p.bar屬性是一個存取器屬性,因此會執行getter函數,由於在getter函數中通過this.foo讀取了foo屬性的值,因此我們會認為副作用函數與屬性foo之間也會建立聯絡,當修改p.foo的值的時候因該也能夠觸發響應,使依賴函數重新執行才對,然而當修改p.foo的時候,並沒有觸發依賴函數:

p.foo++

實際上問題就出在bar屬性中的存取器函數getter上:

get bar() {
  // 這個this究竟指向誰?
  return this.foo
}

當通過代理物件p存取p.bar,這回觸發代理物件的get攔截函數執行:

const p = new Proxt(obj, {
  get(target, key) {
    track(target, key)
    return target[key]
  }
})

可以看到在get的攔截函數中,通過target[key]返回屬性值,其中target是原始物件obj,而key就是字串'bar',所以target[key]就相當於obj.bar。因此當我們使用p.bar存取bar屬性時,他的getter函數內的this其實指向原始物件obj,這說明我們最終存取的是obj.foo。所以在依賴函數內部通過原始物件存取他的某個屬性是不會建立響應聯絡的:

effect(()=>{
  // obj是原始資料,不是代理物件,不會建立響應聯絡
  obj.foo
})

那麼怎麼解決這個問題呢,這時候就需要用到 Reflect出場了。

先來看一下Reflect是啥:

Reflect函數的功能就是提供了存取一個物件屬性的預設行為,例如下面兩個操作是等價的:

const obj = { foo: 1 }

// 直接讀取
console.log(obj.foo) //1

// 使用Reflect.get讀取
console.log(Reflect.get(obj, 'foo')) // 1

實際上Reflect.get函數還能接受第三個函數,即制定接受者receiver,可以把它理解為函數呼叫過程中的this

const obj = { foo: 1 }

console.log(Reflect.get(obj, 'foo', { foo: 2 })) // 輸出的是 2 而不是 1

在這段程式碼中,指定了第三個引數receiver為一個物件{ foo: 2 },這是讀取到的值時receiver物件的foo屬性。

而我們上文中的問題的解決方法就是在操作物件資料的時候通過Reflect的方法來傳遞第三個引數receiver,它代表誰在讀取屬性:

const p = new Proxt(obj, {
  // 讀取屬性接收receiver
  get(target, key, receiver) {
    track(target, key)
    // 使用Reflect.get返回讀取到的屬性值
    return Reflect.get(target, key, receiver)
  }
})

當使用代理物件p存取bar屬性時,那麼receiver就是p,可以把它理解為函數呼叫中的this

所以我們改造一下reactive函數的實現:

function reactive(obj) {
 return new Proxy(obj, {
   get(target, key, receiver) {
     track(target, key)
     return Reflect.get(target, key, receiver)
   },
   set(target, key, value, receiver) {
     trigger(target, key)
     Reflect.set(target, key, value, receiver)
   }
 })
}

擴充套件

Proxy -> get()

get 方法用於攔截某個屬性的讀取操作,可以接受三個引數,依次為目標物件、屬性名和 proxy (代理) 範例本身(嚴格地說,是操作行為所針對的物件),其中最後一個引數可選。

Reflect.get(target, name, receiver)

Reflect.get 方法查詢並返回 target 物件的 name 屬性,如果沒有該屬性,則返回 undefined

var myObject = {
foo: 1,
bar: 2,
get baz() {
  return this.foo + this.bar;
},
}

Reflect.get(myObject, 'foo') // 1
Reflect.get(myObject, 'bar') // 2
Reflect.get(myObject, 'baz') // 3

如果 name 屬性部署了讀取函數( getter ),則讀取函數的 this 繫結 receiver

var myObject = {
foo: 1,
bar: 2,
get baz() {
  return this.foo + this.bar;
},
};

var myReceiverObject = {
foo: 4,
bar: 4,
};

Reflect.get(myObject, 'baz', myReceiverObject) // 8

如果第一個引數不是物件, Reflect.get 方法會報錯。

Reflect.get(1, 'foo') // 報錯
Reflect.get(false, 'foo') // 報錯

Reflect.set(target, name, value, receiver)

Reflect.set 方法設定 target 物件的 name 屬性等於 value

var myObject = {
foo: 1,
set bar(value) {
  return this.foo = value;
},
}

myObject.foo // 1

Reflect.set(myObject, 'foo', 2);
myObject.foo // 2

Reflect.set(myObject, 'bar', 3)
myObject.foo // 3

如果 name 屬性設定了賦值函數,則賦值函數的 this 繫結 receiver

var myObject = {
foo: 4,
set bar(value) {
  return this.foo = value;
},
};

var myReceiverObject = {
foo: 0,
};

Reflect.set(myObject, 'bar', 1, myReceiverObject);
myObject.foo // 4
myReceiverObject.foo // 1

注意,如果 Proxy 物件和 Reflect 物件聯合使用,前者攔截賦值操作,後者完成賦值的預設行為,而且傳入了 receiver ,那麼 Reflect.set 會觸發 Proxy.defineProperty 攔截。

let p = {
a: 'a'
};

let handler = {
set(target, key, value, receiver) {
  console.log('set');
  Reflect.set(target, key, value, receiver)
},
defineProperty(target, key, attribute) {
  console.log('defineProperty');
  Reflect.defineProperty(target, key, attribute);
}
};

let obj = new Proxy(p, handler);
obj.a = 'A';
// set
// defineProperty

上面程式碼中, Proxy.set 攔截裡面使用了 Reflect.set ,而且傳入了 receiver ,導致觸發 Proxy.defineProperty 攔截。這是因為 Proxy.setreceiver 引數總是指向當前的 Proxy 範例(即上例的 obj ),而 Reflect.set 一旦傳入 receiver ,就會將屬性賦值到 receiver 上面(即 obj ),導致觸發 defineProperty 攔截。如果 Reflect.set 沒有傳入 receiver ,那麼就不會觸發 defineProperty 攔截。

let p = {
a: 'a'
};

let handler = {
set(target, key, value, receiver) {
  console.log('set');
  Reflect.set(target, key, value)
},
defineProperty(target, key, attribute) {
  console.log('defineProperty');
  Reflect.defineProperty(target, key, attribute);
}
};

let obj = new Proxy(p, handler);
obj.a = 'A';
// set

如果第一個引數不是物件, Reflect.set 會報錯。

Reflect.set(1, 'foo', {}) // 報錯
Reflect.set(false, 'foo', {}) // 報錯

到這裡,一個非常基本的響應式的功能就完成了,整體程式碼如下:

// 定義倉庫
let store = new WeakMap()
// 定義當前處理的依賴函數
let activeEffect

function effect(fn) {
  // 將操作包裝為一個函數
  const effectFn = ()=> {
    activeEffect = effectFn
    fn()
  }
  effectFn()
}

function reactive(obj) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      // 收集依賴
      track(target, key)
      return Reflect.get(target, key, receiver)

    },
    set(target, key, newVal, receiver) {
      // 觸發依賴
      trigger(target, key)
      Reflect.set(target, key, newVal, receiver)
    }
  })
}

function track(target, key) {
  // 如果沒有依賴函數,則不需要進行收集。直接return
  if (!activeEffect) return

  // 獲取target,也就是物件名
  let depsMap = store.get(target)
  if (!depsMap) {
    store.set(target, (depsMap = new Map()))
  }
  // 獲取物件中的key值
  let deps = depsMap.get(key)

  if (!deps) {
    depsMap.set(key, (deps = new Set()))
  }
  // 收集依賴函數
  deps.add(activeEffect)
}

function trigger(target, key) {
  // 取出物件對應的Map
  let depsMap = store.get(target)
  if (!depsMap) return
  // 取出key所對應的Set
  const effects = depsMap.get(key)
  // 執行依賴函數
  // 為避免汙染,建立一個新的Set來進行執行依賴函數
  let effectsToRun = new Set()

  effects && effects.forEach(effectFn => {
      effectsToRun.add(effectFn)
  })

  effectsToRun.forEach(effect => effect())
}

二. 巢狀effect

在日常的工作中,effect函數並不是單獨存在的,比如在vue的渲染函數中,各個元件之間互相巢狀,那麼他們在元件中所使用的effect是必然會發生巢狀的:

effect(function effectFn1() {
  effect(function effectFn1() {
    // ...
  })
})

當元件中發生巢狀時,此時的渲染函數:

effect(()=>{
  Father.render()

  //巢狀子元件
  effect(()=>{
    Son.render()
  })
})

但是此時我們實現的effect並沒有這個能力,執行下面這段程式碼,並不會出現意料之中的行為:

const data = { foo: 'pino', bar: '在幹啥' }
// 建立代理物件
const obj = reactive(data)

let p1, p2;
// 設定obj.foo的依賴函數
effect(function effect1(){
  console.log('effect1執行');
  // 巢狀,obj.bar的依賴函數
  effect(function effect2(){
    p2 = obj.bar

    console.log('effect2執行')
  })
  p1 = obj.foo
})

在這段程式碼中,定義了代理物件obj,裡面有兩個屬性foobar,然後定義了收集foo的依賴函數,在依賴函數的內部又定義了bar的依賴函數。 在理想狀態下,我們希望依賴函數與屬性之間的關係如下:

obj
        -> foo
                -> effect1
        -> bar
                -> effect2

當修改obj.foo的值的時候,會觸發effect1函數執行,由於effect2函數在effect函數內部,所以effect2函數也會執行,而當修改obj.bar時,只會觸發effect2函數。接下來修改一下obj.foo

const data = { foo: 'pino', bar: '在幹啥' }
// 建立代理物件
const obj = reactive(data)

let p1, p2;
// 設定obj.foo的依賴函數
effect(function effect1(){
  console.log('effect1執行');
  // 巢狀,obj.bar的依賴函數
  effect(function effect2(){
    p2 = obj.bar

    console.log('effect2執行')
  })
  p1 = obj.foo
})

// 修改obj.foo的值
obj.foo = '前來買瓜'

看一下執行結果:

7.png

可以看到effect2函數竟然執行了兩次?按照之前的分析,當obj.foo被修改後,應當觸發effect1這個依賴函數,但是為什麼會effect2會被再次執行呢? 來看一下我們effect函數的實現:

function effect(fn) {
  // 將依賴函數進行包裝
  const effectFn = ()=> {
    activeEffect = effectFn
    fn()
  }
  effectFn()
}

其實在這裡就已經很容易看出問題了,在接受使用者傳遞過來的值時,我們直接將activeEffect這個全域性變數進行了覆蓋!所以在內部執行完後,activeEffect這個變數就已經是effect2函數了,而且永遠不會再次變為effect1,此時再進行收集依賴函數時,永遠收集的都是effect2函數。

那麼如何解決這種問題呢,這種情況可以借鑑棧結構來進行處理,棧結構是一種後進先出的結構,在依賴函數執行時,將當前的依賴函數壓入棧中,等待依賴函數執行完畢後將其從棧中彈出,始終activeEffect指向棧頂的依賴函數。

// 增加effect呼叫棧
const effectStack = [] // 新增

function effect(fn) {
  let effectFn = function () {
    activeEffect = effectFn
    // 入棧
    effectStack.push(effectFn) // 新增
    // 執行函數的時候進行get收集
    fn()
    // 收集完畢後彈出
    effectStack.pop() // 新增
    // 始終指向棧頂
    activeEffect = effectStack[effectStack.length - 1] // 新增
  }

  effectFn()
}

8.png

此時兩個屬性所對應的依賴函數便不會發生錯亂了。

三. 避免無限迴圈

如果現在將effect函數中傳遞的依賴函數改一下:

// 定義一個物件
let data = {
  name: 'pino',
  age: 18
}
// 將data更改為響應式物件
let obj = reactive(data)

effect(() => {
  obj.age++
})

在這段程式碼中,我們將代理物件objage屬性執行自增操作,但是執行這段程式碼,卻發現竟然棧溢位了?這是怎麼回事呢?

9.png

其實在effect中處理依賴函數時,obj.age++的操作其實可以看做是這樣的:

effect(()=>{
  // 等式右邊的操作是先執行了一次讀取操作
  obj.age = obj.age + 1
})

這段程式碼的執行流程是這樣的:首先讀取obj.foo的值,這會觸發track函數進行收集操作,也就是將當前的依賴函數收集到「倉庫」中,接著將其加1後再賦值給obj.foo,此時會觸發trigger操作,即把「倉庫」中的依賴函數取出並執行。但是此時該依賴函數正在執行中,還沒有執行完就要再次開始下一次的執行。就會導致無限的遞迴呼叫自己。

解決這個問題,其實只需要在觸發函數執行時,判斷當前取出的依賴函數是否等於activeEffect,就可以避免重複執行同一個依賴函數。

function trigger(target, key) {
  // 取出物件對應的Map
  let depsMap = store.get(target)
  if (!depsMap) return
  // 取出key所對應的Set
  const effects = depsMap.get(key)
  // // 執行依賴函數
  // 因為刪除又新增都在同一個deps中,所以會產生無限執行
  let effectsToRun = new Set()

  effects && effects.forEach(effectFn => {
    // 如果trigger出發執行的副作用函數與當前正在執行的副作用函數相同,則不觸發執行
    if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
    }
  })

  effectsToRun.forEach(effect => effect())
}

四.computed

computed是vue3中的計算屬性,它可以根據傳入的引數進行響應式的處理:

const plusOne = computed(() => count.value + 1)

根據computed的用法,我們可以知道它的幾個特點:

  • 懶執行,值變化時才會觸發

  • 快取功能,如果值沒有變化,就會返回上一次的執行結果 在實現這兩個核心功能之前,我們先來改造一下之前實現的effect函數。

怎麼能使effect函數變成懶執行呢,比如計算屬性的這種功能,我們不想要他立即執行,而是希望在它需要的時候才執行。

這時候我們可以在effect函數中傳遞第二個引數,一個物件,用來設定一些額外的功能。

function effect(fn, options = {}) { // 修改

  let effectFn = function () {
    activeEffect = effectFn
    effectStack.push(effectFn)
    fn()
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]
  }
  // 只有當非lazy的時候才直接執行
  if(!options.lazy) {
    effectFn()
  }
  // 將依賴函陣列為返回值進行返回
  return effectFn // 新增
}

這時,如果傳遞了lazy屬性,那麼該effect將不會立即執行,需要手動進行執行:

const effectFn = effect(()=>{
  console.log(obj.foo)
}, { lazy: true })

// 手動執行
effectFn()

但是如果我們想要獲取手動執行後的值呢,這時只需要在effect函數中將其返回即可。

function effect(fn, options = {}) {

  let effectFn = function () {
    activeEffect = effectFn
    effectStack.push(effectFn)
    // 儲存返回值
    const res = fn() // 新增
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]

    return res // 新增
  }
  // 只有當非lazy的時候才直接執行
  if(!options.lazy) {
    effectFn()
  }
  // 將依賴函陣列為返回值進行返回
  return effectFn
}

接下來開始實現computed函數:

function computed(getter) {
  // 建立一個可手動呼叫的依賴函數
  const effectFn = effect(getter, {
    lazy: true
  })
  // 當物件被存取的時候才呼叫依賴函數
  const obj = {
    get value() {
      return effectFn()
    }
  }

  return obj
}

但是此時還做不到對值進行快取和對比,增加兩個變數,一個儲存執行的值,另一個為一個開關,表示「是否可以重新執行依賴函數」:

function computed(getter) {
  // 定義value儲存執行結果
  // isRun表示是否需要執行依賴函數
  let value, isRun = true; // 新增
  const effectFn = effect(getter, {
    lazy: true
  })

  const obj = {
    get value() {
      // 增加判斷,isRun為true時才會重新執行
      if(isRun) {  // 新增
        // 儲存執行結果
        value = effectFn() // 新增
        // 執行完畢後再次重置執行開關
        isRun = false // 新增
      }

      return value
    }
  }

  return obj
}

但是上面的實現還有一個問題,就是好像isRun執行一次後好像永遠都不會變成true了,我們的本意是在資料發生變動的時候需要再次觸發依賴函數,也就是將isRun變為true,實現這種效果,需要我們為options再傳遞一個函數,用於使用者自定義的排程執行

function effect(fn, options = {}) {

  let effectFn = function () {
    activeEffect = effectFn
    effectStack.push(effectFn)
    const res = fn() 
    effectStack.pop()
    activeEffect = effectStack[effectStack.length - 1]

    return res 
  }
  // 掛載使用者自定義的排程執行器
  effectFn.options = options // 新增

  if(!options.lazy) {
    effectFn()
  }
  return effectFn
}

接下來需要修改一下trigger如果傳遞了scheduler這個函數,那麼只執行scheduler這個函數而不執行依賴函數:

function trigger(target, key) {
  let depsMap = store.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  let effectsToRun = new Set()

  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn)
    }
  })

  effectsToRun.forEach(effect => {
    // 如果存在排程器scheduler,那麼直接呼叫該排程器,並將依賴函數進行傳遞
    if(effectFn.options.scheduler) { // 新增
      effectFn.options.scheduler(effect) // 新增
    } else {
      effect()
    }
  })
}

那麼在computed中就可以實現重置執行開關isRun的操作了:

function computed(getter) {
  // 定義value儲存執行結果
  // isRun表示是否需要執行依賴函數
  let value, isRun = true; // 新增
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if(!isRun) {
        isRun = true
      }
    }
  })

  const obj = {
    get value() {
      // 增加判斷,isRun為true時才會重新執行
      if(isRun) {  // 新增
        // 儲存執行結果
        value = effectFn() // 新增
        // 執行完畢後再次重置執行開關
        isRun = false // 新增
      }

      return value
    }
  }

  return obj
}

computed傳入的依賴函數中的值發生改變時,會觸發響應式物件的trigger函數,而計算屬性建立響應式物件時傳入了scheduler,所以當資料改變時,只會執行scheduler函數,在scheduler函數內我們將執行開關重置為true,再下次存取資料觸發get函數時,就會重新執行依賴函數。這也就實現了當資料發生改變時,會再次觸發依賴函數的功能了。

為了避免計算屬性被另外一個依賴函數呼叫而失去響應,我們還需要為計算屬性單獨進行繫結響應式的功能,形成一個effect巢狀。

function computed(getter) {
  let value, isRun = true; 
  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if(!isRun) {
        isRun = true
        // 當計算屬性依賴的響應式資料發生變化時,手動呼叫trigger函數觸發響應
        trigger(obj, 'value') // 新增
      }
    }
  })

  const obj = {
    get value() {
      if(isRun) { 
        value = effectFn()
        isRun = false 
      }
      // 當讀取value時,手動呼叫track函數進行追蹤
          track(obj, 'value')
      return value
    }
  }

  return obj
}

五. watch

先來看一下watch函數的用法,它的用法也非常簡單:

watch(obj, ()=>{
  console.log(改變了)
})

// 修改資料,觸發watch函數
obj.age++

watch接受兩個引數,第一個引數為繫結的響應式資料,第二個引數為依賴函數,我們依然可以沿用之前的思路來進行處理,利用effect以及scheduler來改變觸發執行時機。

function watch(source, fn) {
  effect(
    // 遞迴讀取物件中的每一項,變為響應式資料,繫結依賴函數
        ()=> bindData(source),
    {
      scheduler() {
        // 當資料發生改變時,呼叫依賴函數
        fn()
      }
    }
  )
}
// readData儲存已讀取過的資料,防止重複讀取
function bindData(value, readData = new Set()) {
  // 此處只考慮物件的情況,如果值已被讀取/值不存在/值不為物件,那麼直接返回
  if(typeof value !== 'object' || value == null || readData.has(value)) return
  // 儲存已讀取物件
  readData.add(value)
  // 遍歷物件
  for(const key in value) {
    // 遞迴進行讀取
    bindData(value[key], readData)
  }
  return value
}

watch函數還有另外一種用法,就是除了接收物件,還可以接受一個getter函數,例如:

watch(
    ()=> obj.age,
    ()=> {
      console.log('改變了')
    } 
)

這種情況下只需要將使用者傳入的getter將我們自定義的bindData替代即可:

function watch(source, fn) {
  let getter = typeof source === 'function' ? source : (()=> bindData(source))

  effect(
    // 執行getter
        ()=> getter(),
    {
      scheduler() {
        // 當資料發生改變時,呼叫依賴函數
        fn()
      }
    }
  )
}

其實watch函數還有一個很重要的功能:就是在使用者傳遞的依賴函數中可以獲取新值和舊值,但是我們目前還做不到這一點。實現這個功能我們可以設定前文中的lazy屬性來實現。 來回顧一下lazy屬性:設定了lazy之後一開始不會執行依賴函數,手動執行時會返回執行結果:

function watch(source, fn) {
  let getter = typeof source === 'function' ? source : (()=> bindData(source))
  // 定義新值與舊值
  let newVal, oldVal; // 新增
  const effectFn = effect(
    // 執行getter
        ()=> getter(),
    {
      lazy: true,
      scheduler() {
        // 在scheduler重新執行依賴函數,得到新值
        newVal = effectFn() // 新增
        fn(newVal, oldVal) // 新增
        // 執行完畢後更新舊值
        oldVal = newVal // 新增
      }
    }
  )
  // 手動呼叫依賴函數,取得舊值
  oldVal = effectFn() // 新增
}

此外,watch函數還有一個功能,就是可以自定義執行時機,比如immediate屬性,他會在建立時立即執行一次:

watch(obj, ()=>{
  console.log('改變了')
}, {
  immediate: true
})

我們可以把scheduler封裝為一個函數,以便在不同的時機去呼叫他:

function watch(source, fn, options = {}) {
  let getter = typeof source === 'function' ? source : (()=> bindData(source))

  let newVal, oldVal; 
  const run = () => { // 新增
    newVal = effectFn()
    fn(newVal, oldVal)
    oldVal = newVal
  }
  const effectFn = effect(
        ()=> getter(),
    {
      lazy: true,
      // 使用run來執行依賴函數
      scheduler: run  // 修改
    }
  )
  // 當immediate為true時,立即執行一次依賴函數
  if(options.immediate) { // 新增
    run() // 新增
  } else {
    oldVal = effectFn() 
  }
}

watch函數還支援其他的執行呼叫時機,這裡只實現了immediate

六. 淺響應與深響應

深響應和淺響應的區別:

const obj = reatcive({ foo: { bar: 1} })

effect(()=>{
  console.log(obj.foo.bar)
})

// 修改obj.foo.bar的值,並不能觸發響應
obj.foo.bar = 2

因為之前實現的攔截,無論對於什麼型別的資料都是直接進行返回的,如果實現深響應,那麼首先應該判斷是否為物件型別的值,如果是物件型別的值,應當遞迴呼叫reactive方法進行轉換。

// 接收第二個引數,標記為是否為淺響應
function createReactive(obj, isShallow = false) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      // 存取raw時,返回原物件
      if(key === 'raw') return target
      track(target, key)

      const res = Reflect.get(target, key, receiver)
      // 如果是淺響應,直接返回值
      if(isShallow) {
        return res
      }
      // 判斷res是否為物件並且不為null,迴圈呼叫reatcive
      if(typeof res === 'object' && res !== null) {
        return reatcive(res)
      }
      return res
    },
    // ...省略其他
  })

將建立響應式物件的方法抽離出去,通過傳遞isShallow引數來決定是否建立深響應/淺響應物件。

// 深響應
function reactive(obj) {
  return createReactive(obj)
}

// 淺響應
function shallowReactive(obj) {
  return createReactive(obj, true)
}

七. 淺唯讀與深唯讀

有時候我們並不需要對值進行修改,也就是需要值為唯讀的,這個操作也分為深唯讀和淺唯讀,首先需要在createReactive函數中增加一個引數isReadOnly,代表是否為唯讀屬性。

// 淺唯讀
function shallowReadOnly(obj) {
  return createReactive(obj, true, true)
}

// 深唯讀
function readOnly(obj) {
  return createReactive(obj, false, true)
}
set(target, key, newValue, receiver) {
  // 是否為唯讀屬性,如果是則列印警告資訊並直接返回
  if(isReadOnly) {
    console.log(`屬性${key}是唯讀的`)
    return false
  }

  const oldVal = target[key]
  const type = Object.prototype.hasOwnProperty.call(target, key) ? triggerType.SET : triggerType.ADD
  const res = Reflect.set(target, key, newValue, receiver)
  if (target === receiver.raw) {
    if (oldVal !== newValue && (oldVal === oldVal || newValue === newValue)) {
      trigger(target, key, type)
    }
  }

  return res
}

如果為唯讀屬性,那麼也不需要為其建立響應聯絡 如果為唯讀屬性,那麼在進行深層次遍歷的時候,需要呼叫readOnly函數對值進行包裝

function createReactive(obj, isShallow = false, isReadOnly = false) {
  return new Proxy(obj, {
    get(target, key, receiver) {
      // 存取raw時,返回原物件
      if (key === 'raw') return target

      //只有在非唯讀的時候才需要建立響應聯絡
      if(!isReadOnly) {
        track(target, key)
      }

      const res = Reflect.get(target, key, receiver)
      // 如果是淺響應,直接返回值
      if (isShallow) {
        return res
      }
      // 判斷res是否為物件並且不為null,迴圈呼叫creative
      if (typeof res === 'object' && res !== null) {
        // 如果資料為唯讀,則呼叫readOnly對值進行包裝
        return isReadOnly ? readOnly(res) : creative(res)
      }
      return res
    },
  })
}

八. 處理陣列

陣列的索引與length

如果運算元組時,設定的索引值大於陣列當前的長度,那麼要更新陣列的length屬性,所以當通過索引設定元素值時,可能會隱式的修改length的屬性值,因此再j進行觸發響應時,也應該觸發與length屬性相關聯的副作用函數重新執行。

const arr = reactive(['foo']) // 陣列原來的長度為1

effect(()=>{
  console.log(arr.length) //1
})

// 設定索引為1的值,會導致陣列長度變為2
arr[1] = 'bar'

在判斷操作型別時,新增對陣列型別的判斷,如果代理目標是陣列,那麼對於操作型別的判斷作出處理:

如果設定的索引值小於陣列的長度,就視為SET操作,因為他不會改變陣列長度,如果設定的索引值大於當前陣列的長度,那麼應該被視為ADD操作。

// 定義常數,便於修改
const triggerType = {
  ADD: 'add',
  SET: 'set'
}

set(target, key, newValue, receiver) {
  if(isReadOnly) {
    console.log(`屬性${key}是唯讀的`)
    return false
  }

  const oldVal = target[key]

  // 如果目標物件是陣列,檢測被設定的索引值是否小於陣列長度
  const type = Array.isArray(target) && (Number(key) > target.length ? triggerType.ADD : triggerType.SET)
  const res = Reflect.set(target, key, newValue, receiver)

  trigger(target, key, type)

  return res
},
function trigger(target, key, type) {
  const depsMap = store.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  let effectsToRun = new Set()

  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })

  // 當操作型別是ADD並且目標物件時陣列時,應該取出執行那些與 length 屬性相關的副作用函數
  if(Array.isArray(target) && type === triggerType.ADD) {
    // 取出與length相關的副作用函數
    const lengthEffects = deps.get('length')

    lengthEffects && lengthEffects.forEach(effectFn => {
      if (effectFn !== activeEffect) {
        effectsToRun.add(effectFn)
      }
    })
  }

  effectsToRun.forEach(effect => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effect)
    } else {
      effect()
    }
  })

}

還有一點:其實修改陣列的length屬性也會隱式的影響陣列元素:

const arr = reactive(['foo'])

effect(()=>{
  // 存取陣列的第0個元素
  console.log(arrr[0]) // foo
})

// 將陣列的長度修改為0,導致第0個元素被刪除,因此應該觸發響應
arr.length = 0

如上所示,在副作用函數內部存取了第0個元素,然後將陣列的length屬性修改為0,這回隱式的影響陣列元素,及所有的元素都會被刪除,所以應該觸發副作用函數重新執行。

然而並非所有的對length屬性值的修改都會影響陣列中的已有元素,如果設定的length屬性為100,這並不會影響第0個元素,當修改屬性值時,只有那些索引值大於等於新的length屬性值的元素才需要觸發響應。

呼叫trigger函數時傳入新值:

set(target, key, newValue, receiver) {
  if(isReadOnly) {
    console.log(`屬性${key}是唯讀的`)
    return false
  }

  const oldVal = target[key]

  // 如果目標物件是陣列,檢測被設定的索引值是否小於陣列長度
  const type = Array.isArray(target) && (Number(key) > target.length ? triggerType.ADD : triggerType.SET)
  const res = Reflect.set(target, key, newValue, receiver)

  // 將新的值進行傳遞,及觸發響應的新值
  trigger(target, key, type, newValue) // 新增

  return res
}

判斷新的下標值與需要操作的新的下標值進行判斷,因為陣列的key為下標,所以副作用函數蒐集器是以下標作為key值的,當length發生變動時,只需要將新值與每個下標的key判斷,大於等於新的length值的需要重新執行副作用函數。

10.png

如上圖所示,Map為根據陣列的key,也就是id組成的Map結構,他們的每一個key都對應一個Set,用於儲存這個key下面的所有的依賴函數。

length屬性發生變動時,應當取出所有key值大於等於length值的所有依賴函數進行執行。

function trigger(target, key, type, newValue) {
  const depsMap = store.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)
  let effectsToRun = new Set()

  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })

  // 如果操作目標是陣列,並且修改了陣列的length屬性
  if(Array.isArray(target) && key === 'length') {
    // 對於索引值大於或等於新的length元素
    // 需要把所有相關聯的副作用函數取出並新增到effectToRun中待執行
    depsMap.forEach((effects, key)=>{
      // key 與 newValue均為陣列下標,因為陣列中key為index
      if(key >= newValue) {
        effects.forEach(effectFn=>{
          if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
          }
        })
      }
    })
  }

  // ...省略
}

本文的實現陣列這種資料結構只考慮了針對長度發生變化的情況。

九. ref

由於Proxy的代理目標是非原始值,所以沒有任何手段去攔截對原始值的操作:

let str = 'hi'
// 無法攔截對值的修改
str = 'pino'

解決方法是:使用一個非原始值去包裹原始值:

function ref(val) {
  // 建立一個物件對原始值進行包裹
  const wrapper = {
    value: val
  }
  // 使用reactive函數將包裹物件程式設計響應式資料並返回
  return reactive(wrapper)
}

如何判斷是使用者傳入的物件還是包裹物件呢?

const ref1 = ref(1)
const ref2 = reactive({ value: 1 })

只需要在包裹物件內部定義一個不可列舉且不可寫的屬性:

function ref(val) {
  // 建立一個物件對原始值進行包裹
  const wrapper = {
    value: val
  }
  // 定義一個屬性值__v_isRef,值為true,代表是包裹物件
  Object.defineProperty(wrapper, '_isRef', {
    value: true
  })
  // 使用reactive函數將包裹物件程式設計響應式資料並返回
  return reactive(wrapper)
}

十. 響應丟失問題與toRefs

在使用...解構賦值時會導致響應式丟失:

const obj = reactive({ foo: 1, bar: 2 })

// 將響應式資料展開到一個新的物件newObj
const newObj = {
  ...obj
}
// 此時相當於:
const newObj = {
  foo: 1,
  bar: 2
}

effect(()=>{
  //在副作用函數中通過新物件newObj讀取foo屬性值
  console.log(newObj.foo)
})

// obj,foo並不會觸發響應
obj.foo = 100

首先建立一個響應式物件obj,然後使用展開運運算元得到一個新物件newObj,他是一個普通物件,不具有響應式的能力,所以修改obj.foo的值不會觸發副作用函數重新更新。

解決方法:

const newObj = {
  foo: {
    // 用於返回其原始的響應式物件
    get value() {
      return obj.foo
    }
  },
  bar: {
    get value() {
      return obj.bar
    }
  }
}

將單個值包裝為一個物件,相當於存取該屬性的時候會得到該屬性的getter,在getter中返回原始的響應式物件。

相當於解構存取newObj.foo === obj.foo

{
  get value() {
    return obj.foo
  }
}

toRefs

function toRefs(obj) {
  let res = {}
  // 處理整個物件時,將屬性依次進行遍歷,呼叫toRef進行轉化
  for(let key in obj) {
    res[key] = toRef(obj, key)
  }
  return res
}


function toRef(obj, key) {
  const wrapper = {
    // 允許讀取值
    get value() {
      return obj[key]
    },
    // 允許設定值
    set value(val) {
      obj[key] = val
    }
  }
  // 標誌為ref物件
  Object.defineProperty(wrapper, '_isRef', {
    value: true
  })
  return wrapper
}

使用toRefs處理整個物件,在toRefs這個函數中迴圈處理了物件所包含的所有屬性。

  const newObj = { ...toRefs(obj) }

當設定value屬性值的時候,最終設定的是響應式資料的同名屬性值。

一個基本的vue3響應式就完成了,但是本文所實現的依然是閹割版本,有很多情況都沒有進行考慮,還有好多功能沒有實現,比如:攔截 MapSet,陣列的其他問題,物件的其他問題,其他api的實現,但是上面的實現已經足夠讓你理解vue3響應式原理實現的核心了,這裡還有很多其他的資料需要推薦,比如阮一峰老師的es6教學,對於vue3底層原理的實現,許多知識依然是需要回顧和複習,檢視原始底層的實現,再比如霍春陽老師的《vue.js的設計與實現》這本書,這本書目前我也只看完了一半,但是截止到目前我認為這本書對於學習vue3的原理是非常深入淺出,鞭辟入裡的,本文的許多例子也是借鑑了這本書。

最後當然是需要取讀一讀原始碼,不過在讀原始碼之前能夠先了解一下實現的核心原理,再去看原始碼是事半功倍的。希望大家都能早日學透原始碼,面試的時候能夠對答如流,工作中遇到的問題也能從原理層面去理解和更好地解決!

目前我也在實現一個mini-vue,截止到目前只實現了響應式部分,而且與本文的實現方式有所不同,後續還會繼續實現編譯和虛擬DOM部分,歡迎star!

k-vue:

https://github.com/konvyi/k-vue

如果想學習《vue.js的設計與實現》這本書這本書,那麼請關注下面這個連結作為參考,裡面包含了根據具體的問題的功能進行拆分實現,同樣也只實現了響應式的部分!

vue3-analysis:

https://github.com/konvyi/vue3-analysis

(學習視訊分享:、)