Mobx autorun 原理解析

xh4722發表於2021-12-20
本次分享主題為 "mobx autorun" 原理解析,主要分為以下幾個部分:
- 分析 "autorun" 的使用方式;
- 對比 "autorun" 與“釋出訂閱模式”的異同;
- 實現 "autorun" 函式;

通過從0到1實現 autorun 函式以後,你可以瞭解以下知識:

  • autorun 與可觀察物件的協作過程;
  • 為什麼使用 autorun 的時候,所提供的函式會立即執行一次?
  • 為什麼 autorun 不能跟蹤到非同步邏輯中的可觀察物件取值?

autorun 使用方式

// 宣告可觀察物件
const message = observable({
    title: 'title-01'
})

/* 執行autorun,傳入監聽函式 */
const dispose = autorun(() => {
    // 自動收集依賴,在依賴變更時執行註冊函式
    console.log(message.title)
})

// title-01
message.title = 'title-02'
// title-02

/* 登出autorun */
dispose()
/* 登出以後,autorun 不再監聽依賴變更 */
message.title = 'title-03'

autorun的使用流程如下:

  1. 宣告可觀察物件:autorun 僅會收集可觀察物件作為依賴;
  2. 執行autorun:

    • 傳入監聽函式並執行autorun;
    • autorun 會自動收集函式中用到的可觀察物件作為依賴;
    • autorun 返回一個登出函式,通過呼叫登出函式可以結束監聽函式;
  3. 修改可觀察物件:依賴變更,autorun自動執行監聽函式;
  4. 登出autorun:登出之後再變更可觀察物件將不再執行監聽函式;

autorun VS 釋出訂閱模式

    通過觀察 autorun 的使用方式可以看出來,autorun 與傳統的“釋出訂閱模式”很像。接下來我們對比下 autorun 與“釋出訂閱模式”的異同。

時序圖

“釋出訂閱模式”涉及如下三種活動:

  • 註冊:即訂閱;
  • 觸發:即釋出;
  • 登出:即取消訂閱;

用釋出訂閱者模式實現一次“註冊-觸發-登出”過程如下:

用autorun實現一次“註冊-觸發-登出”過程如下:

對比上述兩張時序圖,我們可以得出如下結論:

  1. 開發者視角看:

    • 在“釋出訂閱模式”中,開發者需要參與註冊、觸發和登出;
    • 在autorun模式中,開發者只需要參與註冊和登出,觸發由autorun自動實現;
  2. 物件視角看:

    • 在“釋出訂閱模式”中,物件不參與整個過程,物件是被動的;
    • 在autorun模式中,可觀察物件會參與事件的繫結和解綁,物件是主動的;
  3. 事件模型視角看:

    • 在“釋出訂閱模式”中,事件模型作為控制器排程整個過程;
    • 在autorun模式中,autorun和可觀察物件協同排程整個過程;
  4. 全域性視角看:

    • “釋出訂閱模式”內部流程簡單,但開發者使用複雜;
    • autorun模式內部流程複雜,但開發者使用簡單;

autorun 模式對“釋出訂閱模式”做了一次改進:將事件觸發自動化,從而減少開發成本。

Pros

autorun模式相比於“釋出訂閱模式”有以下好處:

  • autorun 將事件觸發自動化,減少開發成本,提高開發效率;

Cons

autorun模式相比於“釋出訂閱模式”有以下壞處:

  • autorun 將事件觸發自動化,增加了學習成本和理解成本;

如何實現auotorun?

    根據上面的分析我們知道autorun是“釋出訂閱模式”的改進版:將事件觸發自動化。這種自動化是從開發者的視角看的,即開發者在每次更新物件值之後無需再手動觸發一次事件模型;從物件視角看就是每次被賦值之後物件都會執行一次監聽函式:

我們可以得到“自動觸發”的以下資訊:

  • 觸發主體:可觀察物件,事件觸發由可觀察物件發起;
  • 觸發時機:屬性賦值,在可觀察物件的屬性被賦值時觸發事件;

我們需要解決如下問題:

  • 封裝可觀察物件:讓普通物件的屬性具有繫結和解綁監聽函式的能力;
  • 代理物件屬性的取值方法,在每次屬性賦值時將監聽函式繫結到物件屬性上;
  • 代理物件屬性的賦值方法,在每次屬性取值時執行一次監聽函式;
  • 解綁監聽函式:需要提供一套機制解綁可觀察物件屬性上的監聽函式;

封裝可觀察物件

【需求說明】
    為了讓物件的屬性具有繫結和解綁監聽函式的能力,我們需要將普通物件封裝成可觀察物件:

  1. 可觀察物件屬性支援繫結監聽函式;
  2. 可觀察物件屬性支援解綁監聽函式;

【程式碼示例】
    通過呼叫observable方法可以使物件的所有屬性都具備繫結和解綁事件的能力:

const message = observable({
    title: 'title-01'
})

【方案設計】

  1. 定義一個ObservableValue物件,用於將物件的屬性封裝成可觀察屬性:
class ObservableValue {
    observers = []
    value = undefined
    constructor(value) {
       this.value = value
    }
    addObserver(observer) {
        this.observers.push(observer)
    }
    removeObserver(observer) {
        const index = this.observers.findIndex(o => o === observer)
        this.observers.splice(index, 1)
    }
    trigger() {
        this.observers.forEach(observer => observer())
    }
}
  1. 為了減少對原始物件的侵入性,將observable擴充套件的功能限制在物件的一個不可列舉的symbol屬性中:
const $mobx = Symbol("mobx administration")
function observable(instance) {
    const mobxAdmin = {}
    Object.defineProperty(instance, $mobx, {
        enumerable: false,
        writable: true,
        configurable: true,
        value: mobxAdmin,
    });
    ...
}
  1. 將原始物件的所有屬性封裝成ObservableValue並賦值到mobxAdmin中;
...
function observable(instance) {
    const mobxAdmin = {}
    ...
    for(const key in instance) {
        const value = instance[key]
        mobxAdmin[key] = new ObservableValue(value)
    }
}
  1. 將原始物件所有屬性的取值和賦值都代理到 $mobx 中:
...
function observable(instance) {
    ...
    for(const key in instance) {
        Object.defineProperty(instance, key, {
            configurable: true,
            enumerable: true,
            get() {
                return instance[$mobx][key].value;
            },
            set(value) {
                instance[$mobx][key].value = value;
            },
        })
    }
    ...
}

繫結監聽函式與物件

【需求說明】
    現在我們已經有能力將普通物件上封裝成可觀察物件了。接下來我們實現如何將監聽函式繫結到可觀察物件上。
【程式碼示例】

autorun(() => {
    console.log(message.title)
})

【方案設計】
    通過autorun的使用示例,我們可以得到如下資訊:

  1. 監聽函式作為引數傳遞給autorun函式;
  2. 物件的取值操作發生在監聽函式內;

我們需要做的是在物件取值的時候將當前正在執行的監聽函式繫結到物件的屬性上:

...
function observable(instance) {
    ...
    for(const key in instance) {
        Object.defineProperty(instance, key, {
            ...
            get() {
                const observableValue = instance[$mobx][key]
                // 得到當前正在執行的監聽函式
                const observer = getCurrentObserver()
                if(observer) {
                    observableValue.addObserver(observer)
                }
                return observableValue.value;
            },
            ...
        })
    }
    ...
}

如何得到當前正在執行的監聽函式?
    物件的取值代理定義在observable中,但是監聽函式的執行卻是在autorun中,那要如何在 observable 中拿到 autorun 的執行時資訊呢??

答案就是:共享變數
observable和autorun都執行在mobx中,可以在mobx中定義一個共享變數管理全域性狀態:

共享變數
讓我們宣告一個可以管理“當前正在執行的監聽函式”的共享變數:

const globalState = {
  trackingObserver: undefined,
};

讓我們使用共享變數實現監聽函式與可觀察物件的繫結:
設定“當前正在執行的監聽函式”

function autorun(observer) {
   globalState.trackingObserver = observer
   observer()
   globalState.trackingObserver = undefined
}

分析上述程式碼我們可以知道:

  1. 呼叫autorun以後需要立即執行一次監聽函式,用於繫結監聽函式和物件;
  2. 在監聽函式執行結束後會立即清除trackingObserver;

這兩點可以分別解釋mobx文件中的以下說明:

  1. 當使用 autorun 時,所提供的函式總是立即被觸發一次;
  2. “過程(during)” 意味著只追蹤那些在函式執行時被讀取的 observable 。

得到並繫結“當前正在執行的監聽函式”

...
function observable(instance) {
    ...
    for(const key in instance) {
        Object.defineProperty(instance, key, {
            ...
            get() {
                const observableValue = instance[$mobx][key]
                const observer = globalState.trackingObserver
                if(observer) {
                    observableValue.addObserver(observer)
                }
                return observableValue.value;
            },
            ...
        })
    }
    ...
}

觸發“監聽函式”

...
function observable(instance) {
    ...
    for(const key in instance) {
        Object.defineProperty(instance, key, {
            ...
            set(value) {
                instance[$mobx][key].value = value;
                instance[$mobx][key].trigger()
            },
        })
    }
    ...
}

【用例測試】

const message = observable({
  title: "title-01",
});

autorun(() => {
  console.log(message.title);
});

message.title = "title-02";
message.title = "title-03";

解綁監聽函式與物件

【需求說明】
    將監聽函式從可觀察物件上解綁,解綁以後物件賦值操作將不再執行監聽函式。
【程式碼示例】

const dispose = autorun(() => {
    console.log(message.title)
})

dispose()

【方案設計】
    解綁函式從所有可觀察物件的監聽列表中移除監聽函式:

function autorun(observer) {
    ...
    function dispose() {
        // 得到所有可觀察物件
        const observableValues = getObservableValues();
        (observableValues || []).forEach(item => {
            item.removeObserver(observer)
        }
    }
    
    return dispose
}

如何在autorun中獲取“所有繫結了監聽函式的物件”?
    繫結監聽函式的操作在observable中,但是解綁監聽函式的操作卻是在autorun中,那要如何在 autorun 中拿到 observable 的相關資訊呢??
    沒錯,答案還是:共享變數
    我們之前使用的 globalState.trackingObserver 繫結的是監聽函式本身,我們可以對它進行一些封裝,讓它可以收集“所有繫結了監聽函式的物件”。為了說明它不再是僅僅代表監聽函式,我們將它重新命名為 trackingDerivation。
共享變數

const globalState = {
    trackingDerivation: undefined
}

封裝 trackingDerivation

function autorun(observer) {
    const derivation = {
        observing: [],
        observer
    }
    globalState.trackingDerivation = observer
    observer()
    globalState.trackingDerivation = undefined
}

在這裡我們宣告瞭一個 derivation 物件,它有以下屬性:

  1. observing:代表所有繫結了監聽函式的可觀察物件;
  2. observer:監聽函式;

設定“繫結了監聽函式的物件”

...
function observable(instance) {
    ...
    for(const key in instance) {
        Object.defineProperty(instance, key, {
            ...
            get() {
                const observableValue = instance[$mobx][key]
                const derivation = globalState.trackingDerivation
                if(derivation) {
                    observableValue.addObserver(derivation.observer)
                    derivation.observing.push(observableValue)
                }
                return observableValue.value;
            },
            ...
        })
    }
    ...
}

獲取並解綁“所有繫結了監聽函式的物件”

function autorun(observer) {
    const derivation = {
        observing: [],
        observer
    }
    ...
    function dispose() {
        const observableValues = derivation.observing;
        (observableValues || []).forEach(item => {
            item.removeObserver(observer)
        })
        derivation.observing = []
    }
    
    return dispose
}

【用例測試】

const message = observable({
  title: "title-01",
});

const dispose = autorun(() => {
  console.log(message.title);
});

message.title = "title-02";
dispose()
message.title = "title-03";

參考資料

相關文章