從零開始用 proxy 實現 Mobx

dynamic-object 只對外暴露了三個 api:observable observe Action,分別是 動態化對象變化監聽懶追蹤輔助函數javascript

下面以開發角度描述實現思路,同時做爲反思,若是有更優的思路,我會隨時更新。java

1. 術語解釋

本庫包含許多抽象概念,爲了簡化描述,使用固定單詞指代,約定以下:react

單詞 含義
observable dynamic-object 提供的最重要功能,將對象動態化的函數
observe 監聽其回調函數中當前訪問到的 observable 化的對象的修改,並在值變化時從新出發執行
observer 指代 observe 中的回調函數

2. 整體思路

若是單純的實現 observable,使用 proxy 很簡單,能夠徹底監聽對象的變化,難點在於如何在 observe 中執行依賴追蹤,並當 observable 對象觸發 set 時,觸發對應 observe 中的 observergit

每一個 observable 對象觸發 get 時,都將當前所在的 object + key 與當前 observer 對應關係存儲起來,當其被 set 時,拿到對應的 observer 執行便可。github

咱們必須依賴持久化變量才能作到這一點,由於 observableset 過程,與 observerget 的過程是分開的。typescript

3. 定義持久化變量

變量名 類型 含義
proxies WeakMap 全部代理對象都存儲於此,當重複執行 observable 或訪問對象子屬性時,若是已是 proxy 就從 proxies 中取出返回
observers WeakMap >> 任何對象的 key 只要被 get,就會被記錄在這裏,同時記錄當前的 observer,當任意對象被 set 時,根據此 map 查詢全部綁定的 observer 並執行,就達到 observe 的效果了
currentObserver Observer 當前的 observer。當執行 observe 時,當前 observer 指向其第一個回調函數,這樣當代理被訪問時,保證其綁定的 observer 是其當前所在的回調函數。

4. 從 observable 函數開始

對於 observable(obj),按照如下步驟分析:redux

4.1. 去重

若是傳入的 obj 自己已經是 proxy,也就是存在於 proxies,直接返回 proxies.get(obj)。這種狀況考慮到可能將對象 observable 執行了屢次。(proxies 保存原對象與代理各一份,保證傳入的是已代理的原對象,仍是代理自己,均可以被查找到)api

4.2. new Proxy

若是沒有重複,new Proxy 生成代理返做爲返回值。代理涉及到三處監聽處理:get set deleteProperty緩存

4.3. get 處理

get(target, key, receiver)複製代碼

先判斷 currentObserver 是否爲空,若是爲空,說明是在 observer 以外訪問了對象,此時不作理會。babel

若是 currentObserver 不爲空,將 object + key -> currentObserver 的映射記錄到 observers 對象中。同時爲 currentObserver.observedKeys 添加當前的映射引用,當 unobserve 時,須要讀取 observer.observedKeys 屬性,將 observers 中全部此 observer 的依賴關係刪除。

最後,若是 get 取的值不是對象(typeof obj !== "object"),那麼是基本類型,直接返回便可。若是是對象,那麼:

  1. 若是在 proxies 存在,直接返回 proxy 引用。eg: const name = obj.name,這時 name 變量也是一個代理,其依賴也可追蹤。
  2. 若是在 proxies 不存在,將這個對象從新按照如上流程處理一遍,這就是惰性代理,好比訪問到 a.b.c,那麼會分別將 a b c 各走一遍 get 處理,這樣不管其中哪一環,都是代理對象,可追蹤,相反,若是 a 對象還存在其餘字段,由於沒有被訪問到,因此不會進行處理,其值也不是代理,由於沒有訪問的對象也不必追蹤。

4.4. set 處理

set(target, key, value, receiver)複製代碼

若是新值與舊值不一樣,或 key === "length" 時,就認爲產生了變化,找到當前 object + key 對應的 observers 隊列依次執行便可。有兩個注意點:

  1. 執行前先將當前執行的 observer 綁定關係清空:由於 observer 時會觸發新一輪綁定,這樣實現了條件的動態綁定。
  2. 執行前設置 currentObserver 爲當前 observer,再執行 observer 時就能夠將 set 正確綁定上。

4.5 deleteProperty

刪除屬性時,直接觸發對應 observer

4.6 Map WeakMap Set WeakSet 的狀況

這些類型的特色是有明確封裝方法,其實更容易設置追蹤,此次不使用 proxy,而是複寫這些對象的方法,在 get set 中加上鉤子。

5. observe 函數

馬上執行當前回調 observer,執行規則與 4.4 小節的 observers 隊列執行機制相同。

有人會有疑惑,爲何 observe 要當即執行內部回調呢?若是初始化不不輸出,結果可能會好看一些:

import { observable, observe } from "dynamic-object"

const dynamicObj = observable({
    a: 1
})

observe(() => {
    console.log('a:', dynamicObj.a)
})

dynamicObj.a = 2複製代碼

以上會輸出兩次,分別是 a: 1a: 2。另外,可能會以爲這樣與 react 結合,會不會致使初始化時增長沒必要要的渲染?

這兩個都是很好的問題,但結論是:初始化執行是必要的:

  1. 若是初始化不執行,就沒有辦法執行初始數據綁定,那麼後續的賦值徹底找不到對應的 observer 是什麼(除非作靜態分析,但稍稍複雜些就不可能了)。
  2. 結合 react 時,經過生命週期 mixins 來覆寫 render 函數,將初始化的 observe 綁定與後續 render 函數分離,達到首次 render 是 observe 初始化觸發,後續 render 依靠依賴追蹤自動觸發 的效果,在 dynamic-react 章節會有深刻介紹。

6. Action

Action 是用於寫標準 action 的裝飾器,有如下兩種寫法:

@Action setUserName() {..}
Action(setUserName)複製代碼

起做用是將回調函數中發生的變動臨時存儲起來,當執行完時統一觸發,而且同一個 observer 的屢次 set 行爲只會觸發一次,而且執行時,獲取到的是最終值,全部值的中間變化過程都會被忽略。

好比: 當 dynamicObj.a 初始值爲 1 時,下面的代碼不會觸發 observer 執行:

Action(()=> {
  dynamicObj.a = 2
  dynamicObj.a = 1
})複製代碼

7. 調用棧深度統計

要達到上面效果,須要額外定義一個持久化變量 trackingDeep,每次 Action 執行時,這個變量先自增 1,執行 observer 時,若是 trackingDeep 不爲 0,就把 observer 存儲在隊列中,當回調函數執行完後,深度減 1,開始執行存儲的隊列,一樣,若是深度不爲 1 就跳過,深度爲 0 就執行。

咱們假象這種場景:

class Test {
  @Action setUser(info) {
    this.userStore.account = info.account
    this.setName(info.name)
  }

  @Action setName(name) {
    this.userStore.name = name
  }
}複製代碼

當調用 setUser 時,其內部又調用了 setName,那麼執行 setUser 時,trackingDeep 爲 1,以後又執行到 setName 使得 trackingDeep 變成 2,內層 Action 執行完畢,trackingDeep 變回 1,此時隊列不會執行,調用棧回退到 setName 後,trackingDeep 終於變成 0,隊列執行,此時observer 僅觸發了一次。

Tips: 這裏有個優化點,當 trackingDeep 不爲 0 時,終止 dynamic-object 的依賴收集行爲。這麼作的好處是,當 react render 函數中,同步調用 action 時,不會綁定到這個 action 用到的變量。

7.1 缺點

Action 的概念存在一個嚴重的缺點(但不致命),同時也是 mobx 庫一直沒有解決的問題,那就是對於異步 action 迫不得已(除非爲異步 action 分段使用 Action,這也是 mobx 官方推薦的方式,也有 babel 插件來解決,但這樣很 hack)。

咱們思考以下代碼:

class Test {
  @Action async getUser() {
    this.isLoading = true
    const result = await fetch()
    this.isLoading = false
    this.user = result 
  }
}複製代碼

首先咱們不但願它是忽略中間態的,不然初始將 isLoading 設置爲 true 就沒有意義了。

比較好的途徑是,將這個異步 action 觸發的 observer 塞入到隊列中,每當遇到 await 就執行並清空隊列,同時還能夠支持 timeout 設定,好比設置爲 100ms 時,若是 fetch 函數在 100ms 內執行完畢,就不會執行以前的隊列,達到肉眼沒法識別的間隔內不觸發 loading 的效果。

理想很美好,惋惜難點不在如何實現如上的設定,而是咱們沒辦法將隊列分隔開,考慮以下代碼:

handleClick() {
  this.props.Test.getUser()
  this.props.Test.getArticle()
}複製代碼

getUsergetArticle 都是異步的,若是咱們將緩存隊列共用一個,那麼 getArticle 執行到 await 時,順便會邪惡的把 getUser 隊列中 observer 給執行了,縱使 getUserawait 尚未結束(可能出現 loading 在數據還沒加載完成就消失)。

有人說,將 getUsergetArticle 隊列分開不就好了嗎?是的,但目前 javascript 還作不到這一點,見此處討論。不管是 defineProperty 仍是 proxy,都沒法在 set 觸發時,知道本身是從哪一個閉包中被觸發的。只知道觸發的對象,以及被訪問的 key,是沒辦法將 getUser getArticle
放在不一樣隊列執行 observer 的。

目前個人作法與 mobx 同樣,async 函數會打破 Action 的庇護,失去了收集後統一執行的特性,但保證了程序的正確運行。目前的解決方法是,爲同步區域再套一層 Action,或者乾脆將異步與同步分開寫!

說實話,這個問題被 redux 用概念巧妙規避了,咱們必須將這個函數拆成兩個 dispatch。回頭想一想,若是咱們也這麼作,也徹底能夠規避這個問題,拆成兩個 action 便可!但我但願有一天,能找到完美的解決方法。
另外但願表達一點,redux 的成功在於定義了許多概念與規則,只要咱們遵照,就能寫出維護性很棒的代碼,其實 oo 思想也是同樣!咱們在使用 oo 時,將對 fp 的耐心拿出來,同樣能寫出維護性很棒的代碼。

8. dynamic-react

dynamic-react 是 dynamic-object 在 react 上的應用,相似於 mobx-react 相比於 mobx。實現思路與 mobx-react 很接近,可是簡化了許多。

dynamic-react 只暴露了兩個接口 ProviderConnect,分別用於 數據初始化綁定更新與依賴注入

8.1 從 Provider 開始

Provider 將接收到的全部參數全局透傳到組件,所以實現很簡單,將接收到的全部字段存在 context 中便可。

8.2 Connect 的依賴注入

這個裝飾器用於 react 組件,分別提供了綁定更新與依賴注入的功能。

因爲 dynamic-react 是與 dynamic-object 結合使用的,所以會將全量 store 數據注入到 react 組件中,因爲依賴追蹤的特性,不會形成沒必要要的渲染。

注入經過高階組件方式,從 context 中取出 Provider 階段注入的值,直接灌給自組件便可,注意組件自身的 props 須要覆蓋注入數據:

export default function Connect(componentClass: any): any {
    return class InjectWrapper extends React.Component<any, any>{
        // 取 context
        static contextTypes = {
            dyStores: React.PropTypes.object
        }

        render() {
            return React.createElement(componentClass, {
                ...this.context.dyStores,
                ...this.props,
            })
        }
    }
}複製代碼

8.3 Connect 的綁定更新

見如上代碼,咱們經過拿到當前子組件的實例:componentClass.prototype || componentClass 將其生命週期函數重寫爲,先執行自定義函數鉤子,再執行其自身,並且自定義函數鉤子綁定上當前 this,能夠在自定義勾子修改當前實例的任意字段,後續重寫 render 也是依賴此實現的。

8.3.1 willMount 生命週期鉤子

最重要階段是在 willMount 生命週期完成的,由於對於 observer 來講,只要在初始化時綁定了引用,以後更新都是從 observe 中自動觸發的。

總體思路是複寫 render 方法:

  1. 在第一次執行時,經過 observe 包裹住原始 render 方法執行,所以綁定了依賴,將此時 render 結果直接返回便可。
  2. 非第一次執行,是由第一次執行時 observe 自動觸發的(或者 state、props 傳參變化,這些無論),此時能夠肯定是由數據流變更致使的刷新,所以能夠調用 componentWillReact 生命週期。而後調用 forceUpdate 生命週期,由於重寫了 render 的緣故,視圖不會自動刷新。
  3. 由 state、props 變化致使的刷新,只要返回原始 render 便可。

注意第一次調用時,不管如何會觸發一次 observer,爲了忽略這次渲染,咱們設置一個是否渲染的 flag,當 observer 渲染了,普通 render 就再也不執行,由此避免 observe 初始化一定執行一次帶來初始渲染兩次的問題。

8.3.2 其餘生命週期鉤子

componentWillUnmountunobserve 掉當前組件的依賴追蹤,給 shouldComponentUpdate 加上 pureRender,以及在 componentDidMountcomponentDidUpdate 時通知 devTools 刷新,這裏與 mobx-react 實現思路徹底一致。

9. 寫在最後

最後給出 dynamic-object 的項目地址,歡迎提出建議和把玩。

相關文章
相關標籤/搜索