聊一聊 Vue3 中響應(yīng)式原理

引言

Vue.js 3.0 "One Piece" 正式發(fā)布已經(jīng)有一段時間了,真可謂是千呼萬喚始出來啊!

相比于 Vue2.xVue3.0 在新的版本中提供了更好的性能、更小的捆綁包體積、更好的 TypeScript 集成、用于處理大規(guī)模用例的新 API

在發(fā)布之前,尤大大就已經(jīng)聲明了響應(yīng)式方面將采用 Proxy 對于之前的 Object.defineProperty 進(jìn)行改寫。其主要目的就是彌補(bǔ) Object.defineProperty 自身的一些缺陷,例如無法檢測到對象屬性的新增或者刪除,不能監(jiān)聽數(shù)組的變化等。

Vue3 采用了新的 Proxy 實現(xiàn)數(shù)據(jù)讀取和設(shè)置攔截,不僅彌補(bǔ)了之前 Vue2Object.defineProperty 的缺陷,同時也帶來了性能上的提升。

今天,我們就來盤一盤它,看看 Vue3 中響應(yīng)式是如何實現(xiàn)的。

Proxy ?

The Proxy object enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object.MDN

Proxy - 代理,顧名思義,就是在要訪問的對象之前增加一個中間層,這樣就不直接訪問對象,而是通過中間層做一個中轉(zhuǎn),通過操作代理對象,來實現(xiàn)修改目標(biāo)對象。

關(guān)于 Proxy 的更多的知識,可以參考我之前的一篇文章 —— 初探 Vue3.0 中的一大亮點——Proxy !,這里我就不在贅述。

reactive 和 effect 方法

Vue3 中響應(yīng)式核心方法就是 reactiveeffect , 其中 reactive 方法是負(fù)責(zé)將數(shù)據(jù)變成響應(yīng)式,effect 方法的作用是根據(jù)數(shù)據(jù)變化去更新視圖或調(diào)用函數(shù),與 react 中的 useEffect 有點類似~

其大概用法如下:

let { reactive, effect } = Vue;
let data = reactive({ name: 'Hello' });

effect(() => {
    console.log(data.name)
})

data.name = 'World';

默認(rèn)會執(zhí)行一次,打印 Hello , 之后更改了 data.name 的值后,會在觸發(fā)執(zhí)行一次,打印World

我們先看看 reactive 方法的實現(xiàn)~

reactive.js

首先應(yīng)該明確,我們應(yīng)該導(dǎo)出一個 reactive 方法,該方法有一個參數(shù) target,目的就是將 target 變成響應(yīng)式對象,因此返回值就是一個響應(yīng)式對象。

import {isObject} from "../shared/utils";
// Vue3 響應(yīng)式原理
// 響應(yīng)式方法,將 target 對象變成響應(yīng)式對象
export function reactive (target) {
    // 創(chuàng)建響應(yīng)式對象
    return createReactiveObject(target);
}

// 創(chuàng)建響應(yīng)式對象
function createReactiveObject (target) {
    // 不是對象,直接返回
    if ( !isObject(target) ) return target;
    // 創(chuàng)建 Proxy 代理
    const observed = new Proxy(target,{})
    return observed;
}

reactive 方法基本結(jié)構(gòu)就是如此,給定一個對象,返回一個響應(yīng)式對象。

其中 isObject 方法用于判斷是否是對象,不是對象不需要代理,直接返回即可。

reactive 方法的重點是 Proxy 的第二個參數(shù)handler,它承載監(jiān)控對象變化,依賴收集,視圖更新等各項重大責(zé)任,我們重點來研究這個對象。

handler.js

Vue3Proxyhandler 主要設(shè)置了 getsetdeletePropertyhasownKeys 這些屬性,即攔截了對象的讀取,設(shè)置,刪除,in 以及 Object.getOwnPropertyNames 方法和 Object.getOwnPropertySymbols 方法。

這里我們偷個懶,暫時就考慮 setget 操作。

handler.get()

get 獲取屬性比較簡單,我們先來看看這個,這里我們用一個方法創(chuàng)建 getHanlder

// 創(chuàng)建 get
function createGetter () {
  return function get (target, key, receiver) {
      // proxy + reflect
      const res = Reflect.get(target, key, receiver);  // target[key];

      // 如果是對象,遞歸代理
      if ( isObject(res) ) return reactive(res);

      console.log('獲取屬性的值:', 'target:', target, 'key:', key)

      return res;
  }
}

這里推薦使用了 Reflect.get 而并非 target[key]

可以發(fā)現(xiàn),Vue3 是在取值的時候才去遞歸遍歷屬性的,而非 Vue2 中一開始就遞歸 data 給每個屬性添加 Watcher,這也是 Vue3 性能提升之一。

handler.set()

同理 set 操作,我們也是用一個方法創(chuàng)建 setHandler

// 創(chuàng)建 set
function createSetter () {
    return function set (target, key, value, receiver) {
        // 設(shè)置屬性值
        const res = Reflect.set(target, key, value, receiver);  
        return res;
    }
}

Reflect.set 會返回一個 Boolean 值,用于判斷屬性是否設(shè)置成功。

完事后將 handler 導(dǎo)出,然后在 reactive 中引入即可。

const get = createGetter();
const set = createSetter();

// 攔截普通對象和數(shù)組
export const mutableHandler = {
    get,
    set
}

測試幾組對象貌似沒啥問題,其實是有一個坑,這個坑也跟數(shù)組有關(guān)。

  let { reactive } = Vue;
  // 代理數(shù)組
  let arr = [1,2,3]
  let proxy = reactive(arr)
  // 添加元素
  proxy.push(4)

如上例子,如果我們選擇代理數(shù)組,在 setHandler 中打印其 keyvalue 的話會得到 3 4length 4 這兩組值:

  • 第一組表示給數(shù)組索引為 3 的位置新增一個 4 的值
  • 第二組表示將數(shù)組的 length 改為 4

如果不作處理,那么會導(dǎo)致如果更新視圖的話,則會觸發(fā)兩次,這肯定是不允許的,因此,我們需要將區(qū)分新增和修改這兩種操作。

Vue3 中是通過判斷 target 是否存在該屬性來區(qū)分是新增還是修改操作,需要借助一個工具方法 —— hasOwnProperty

// 判斷自身是否包含某個屬性
function hasOwnProperty (target,key) {
    return Object.prototype.hasOwnProperty.call(target,key);
}

這里我們將上述的 createSetter 方法修改如下:

function createSetter () {
  return function set (target, key, value, receiver) {
      // 需要判斷修改屬性還是新增屬性,如果原始值于新設(shè)置的值一樣,則不作處理
      const hasKey = hasOwnProperty(target, key);
      // 獲取原始值
      const oldVal = target[key];
      const res = Reflect.set(target, key, value, receiver);    // target[key]=value;
        
      if ( !hasKey ) { 
          // 新增屬性
          console.log('新增了屬性:', 'key:', key, 'value:', value);
      } else if ( hasChanged(value, oldVal) ) { 
          // 原始值于新設(shè)置的值不一樣,修改屬性值
          console.log('修改了屬性:', 'key:', key, 'value:', value)
      }

      // 值未發(fā)生變化,不作處理
      return res;
  }
}

如此一來,我們調(diào) push 方法的時候,就只會觸發(fā)一次更新了,非常巧妙的避免了無意義的更新操作。

effect.js

光上述構(gòu)造響應(yīng)式對象并不能完成響應(yīng)式的操作,我們還需要一個非常重要的方法 effect,它會在初始化執(zhí)行的時候存儲跟其有關(guān)的數(shù)據(jù)依賴,當(dāng)依賴數(shù)據(jù)發(fā)生變化的時候,則會再次觸發(fā) effect 傳遞的函數(shù)。

其基本雛形如下,入?yún)⑹且粋€函數(shù),還有個可選參數(shù) options 方便后面計算屬性等使用,暫時不考慮:

// 響應(yīng)式副作用方法
export function effect (fn,options = {}) {
    // 創(chuàng)建響應(yīng)式 effect
    const reactiveEffect = createReactiveEffect(fn, options);
    
    // 默認(rèn)執(zhí)行一次
    reactiveEffect()
}

createReactiveEffect 就是為了將 fn 變成響應(yīng)式函數(shù),監(jiān)控數(shù)據(jù)變化,執(zhí)行 fn 函數(shù),因此該函數(shù)是一個高階函數(shù)。

let activeEffect;   // 當(dāng)前 effect
const effectStack = []; // effect 棧

// 創(chuàng)建響應(yīng)式 effect
function createReactiveEffect (fn, options) {
    // 創(chuàng)建的響應(yīng)式函數(shù)
    const reactiveEffect = function () {
        // 防止不停更改屬性導(dǎo)致死循環(huán)
        if ( !effectStack.includes(reactiveEffect) ) {
            try {
                effectStack.push(reactiveEffect);
                // 將當(dāng)前 effect 存儲到 activeEffect
                activeEffect = reactiveEffect;      
                // 運行 fn 函數(shù)
                return fn();
            } finally {
                // 執(zhí)行完清空
                effectStack.pop();
                activeEffect = effectStack[effectStack.length - 1];
            }
        }
    }
    return reactiveEffect;
}

createReactiveEffect 將原來的 fn 轉(zhuǎn)變成一個 reactvieEffect , 并將當(dāng)前的 effect 掛到全局的 activeEffect 上,目的是為了一會與當(dāng)前所依賴的屬性做好對應(yīng)關(guān)系。

我們必須要將依賴屬性構(gòu)造成 { prop : [effect,effect] } 這種結(jié)構(gòu),才能保證依賴屬性變化的時候,依次去觸發(fā)與之相關(guān)的 effect,因此,需要在 get 屬性的時候,做屬性的依賴收集,將屬性與 effect 關(guān)聯(lián)起來。

依賴收集 —— track

在獲取對象的屬性時,會觸發(fā) getHandler ,再次做屬性的依賴收集,即 Vue2 中的發(fā)布訂閱。

setHandler 中獲取屬性的時候,做一次 track(target, key) 操作。

整個 track 的數(shù)據(jù)結(jié)構(gòu)大概是這樣

/** 
* 最外層是 WeakMap,其 key 是 target 對象,值是一個 map
* map 中包含 target 的屬性,key 為每一個屬性 , 值為屬性對應(yīng)的 `effect` 
*/
     key               val(map)
{name : 'chris}     {  name : Set(effect,effect) , age : Set() }

目的就是將 targetkeyeffect 之間做好對應(yīng)的關(guān)系映射。

const targetMap = new WeakMap();
// 依賴收集
export function tract(target,key){
    // activeEffect 為空
    if ( activeEffect === undefined ) {
        return; // 說明取值的屬性,不依賴于 effect
    }

    // 判斷 target 對象是否收集過依賴
    let depsMap = targetMap.get(target);
    // 不存在構(gòu)建
    if ( !depsMap ) {
        targetMap.set(target, (depsMap = new Map()));
    }

    // 判斷要收集的 key 中是否收集過 effect
    let dep = depsMap.get(key);
    // 不存在則創(chuàng)建
    if ( !dep ) {
        depsMap.set(key, (dep = new Set()));
    }

    // 如果未收集過當(dāng)前依賴則添加
    if ( !dep.has(activeEffect) ) {
        dep.add(activeEffect);
    }
}

打印 targetMap 的結(jié)構(gòu)如下:

targetMap

**觸發(fā)更新 —— trigger **

上述已經(jīng)完成了依賴收集,剩下就是監(jiān)控數(shù)據(jù)變化,觸發(fā)更新操作,即在 setHandler 中添加 trigger 觸發(fā)操作。

// 觸發(fā)更新
export function trigger (target, type, key) {
    // 獲取 target 的依賴
    const depsMap = targetMap.get(target);
    // 沒有依賴收集,直接返回
    if ( !depsMap ) return;

    // 獲取 effects
    const effects = new Set();

    // 添加 key 對應(yīng)的 effect
    const add = (effectsToAdd) => {
        if ( effectsToAdd ) {
            effectsToAdd.forEach(effect => {
                effects.add(effect)
            })
        }
    }

    // 執(zhí)行單個 effect
    const run = (effect) => {
        effect && effect()
    }

    // 獲取 key 對應(yīng)的 effect
    if ( key !== null ) {
        add(depsMap.get(key));
    }

    if ( type === 'add' ) { // 對數(shù)組新增會觸發(fā) length 對應(yīng)的依賴
        let effects = depsMap.get(Array.isArray(target) ? 'length' : '');
        add(effects);
    }

    // 觸發(fā)更新
    effects.forEach(run);
}

這樣一來,獲取數(shù)據(jù)的時候通過 track 進(jìn)行依賴收集,更新數(shù)據(jù)的時候再通過 trigger 進(jìn)行更新,就完成了整個數(shù)據(jù)的響應(yīng)式操作。

再回頭看看我們先前提到的例子:

let { effect, reactive } = Vue;

let data = reactive({ name: 'Hello' })
effect(() => {
    console.log(data.name, '  ***** effect *****  ');
})

data.name = 'World'

控制臺會依次打印 Hello ***** effect ***** 以及 World ***** effect *****, 分別是首次渲染觸發(fā)跟更新數(shù)據(jù)重渲染觸發(fā),至此功能實現(xiàn)!

總結(jié)

整體來說,Vue3 相比于 Vue2 在很多方面都做了調(diào)整,數(shù)據(jù)的響應(yīng)式只是冰山一角,但是可以看出尤大團(tuán)隊非常巧妙的利用了 Proxy 的特點以及 es6 的數(shù)據(jù)結(jié)構(gòu)和方法。另外,Composition API 的模式跟 React 在某些程度上有異曲同工之妙,這種設(shè)計模式讓我們在實際開發(fā)使用中更加的方法快捷,值得我們?nèi)W(xué)習(xí),加油!

最后附上倉庫地址 github,歡迎各位大佬批評斧正~

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,835評論 6 534
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,676評論 3 419
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,730評論 0 380
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,118評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 71,873評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,266評論 1 324
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,330評論 3 443
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,482評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,036評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 40,846評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,025評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,575評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,279評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,684評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,953評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,751評論 3 394
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 48,016評論 2 375