博客 / 詳情

返回

Mobx autorun 原理解析

本次分享主題為 "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";

參考資料

  • 完整示例代碼
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.