用故事解讀 MobX 源碼(二)computed
- 2019 年 10 月 10 日
- 筆記
溫馨提示:因微信中外鏈都無法點擊,請通過文末的」 「閱讀原文」 到技術部落格中完整查閱版;(本文整理自技術部落格)
- 初衷:以系列故事的方式展現源碼邏輯,儘可能以易懂的方式講解 MobX 源碼;
- 本系列文章:
- 《【用故事解讀 MobX源碼(一)】 autorun》
- 《【用故事解讀 MobX源碼(二)】 computed》
- 《【用故事解讀 MobX源碼(三)】 shouldCompute》
- 《【用故事解讀 MobX 源碼(四)】裝飾器 和 Enhancer》
- 《【用故事解讀 MobX 源碼(五)】 Observable》
- 文章編排:每篇文章分成兩大段,第一大段以簡單的偵探系列故事的形式講解(所涉及人物、場景都以 MobX 中的概念為原型創建),第二大段則是相對於的源碼講解。
- 本文基於 MobX 4 源碼講解
在寫本文的時候,由於 MobX 以及升級到 4.x,API 有較大的變化,因此後續的文章默認都將基於 4.x 以上版本進行源碼閱讀。
前一篇文章仍然以 mobx v3.5.1 的源碼,autorun
邏輯在新版中沒有更改,因此源碼邏輯仍舊一致。
A. Story Time
1、 場景
為了多維度掌控嫌疑犯的犯罪特徵數據,你(警署最高長官)想要獲取並實時監控張三的 貸款數額、存貸比(存款和貸款兩者比率) 的變化。
於是你就擬定了新的命令給執行官 MobX:
var bankUser = mobx.observable({ income: 3, debit: 2 }); var divisor = mobx.computed(() => { return bankUser.income / bankUser.debit; }); mobx.autorun(() => { console.log('張三的貸款:', bankUser.debit, ';張三的存貸比: ' + divisor); });
相比上一次的命令,除了監控張三貸款這項直接的指標,還需要監控 貸款比(divisor
) 這項間接指標。
執行官 MobX 稍作思忖,要完成這個任務比之前的要難一點點,需要費一點兒精力。

執行官 MobX 稍作思忖
不過,這也難不倒能力強大的 MobX 執行官,一番策略調整之後,重新拿出新的執行方案。部署實施之後,當張三去銀行存款、貸款後,這些變化都實時回饋出來了:

實時回饋計算值
2、部署方案
這次的部署和前一次相差不大,除了需要讓觀察員 O2(監視 income
)參與進來之外,考慮到警署最高長官所需的 存貸比 (divisor
),還得派出另一類職員 —— 會計師:
- 會計師:此類職員專門負責計算,從事 數據的再加工(此項任務中,就是搜集數據並計算 存貸比)

會計師角色
會計師是一個很有意思的角色,要想理解他們,必須得思考他們的數據「從哪兒來?到哪裡去?」 這兩個問題:
- 從哪兒來:從觀察員那兒獲取,也可以從其他會計師那兒獲取;
- 到哪兒去:所生產的數據,要麼是被探長消費,要麼被其他會計師所用;(當然,沒有人消費他所生產的數據也是可能的,不過這就得追究 MobX 執行官的責任了,浪費了人力資源)
引入了會計師角色之後,MobX 執行官重新繪製了部署計劃圖:

重新繪製部署計劃圖
解釋一下此計劃圖的意思:
- 明確此次任務是 當張三賬戶存款或者貸款變更時,列印其貸款數額(`debit`)和存貸比(`divisor`):
() => { console.log('張三的貸款:', bankUser.debit, ';張三的存貸比: ' + divisor); }
- 將任務指派給執行組中的探長 R1
- 派遣 2 名觀察組中的觀察員 O1、O2 分別監察張三賬戶的
bankUser.income
屬性和bankUser.debit
屬性; - 派遣計算組中的會計師 C1 計算張三的貸款比,其所需數值來源於觀察員 O1、O2;
- 探長 R1 任務中所需的「張三的賬戶存款」 數值從觀察員 O2 那兒獲取;所需的 「張三的存貸比」 數值從會計師 C1 那兒獲取;
- 同時架設數據情報室,方便資訊交換;
2.1、部署細節
因為還是 autorun
命令,所以仍然執行 A計劃方案(詳情參考上一篇《【用故事解讀 MobX源碼(一)】 autorun》)MobX 執行官的部署方案從整體上看是一樣的,考慮到多了會計師這個角色的參與,所以特意在探長 獲取存貸比(divisor
) 邏輯處空出一部分留給會計師讓它自由發揮:

會計師角色位置
這樣做,MobX 執行官也為了在實際行動中向他的警署長官證實該 A計劃方案 的確擁有「良好的擴展性」。
解開這層新增的會計師計算邏輯 「面紗」,圖示如下:

會計師計算邏輯圖
你會發現歷史總是驚人的相似,新增的會計師執行計算任務的邏輯其實 探長 執行任務的邏輯是一樣的,下圖中我特意用 相同的序號(不同的顏色形狀)標示 出,序號所對應含義如下:
- 設置成 正在執勤人員
- 開始執行任務
- 從觀察員或會計師那兒獲取執行任務所需的數值,並同他們取得聯繫,
- 計算任務執行完成後,更新與觀察員 O1、觀察員 O2 之間的聯繫;

會計師執行計算任務的邏輯和探長几乎一致
此執行計算任務的邏輯,如果不告訴觀察員的話,觀察員還以為又來了一名「探長」上級。
從部署圖裡我們可以看出會計師具有兩面性;
- 對探長而言:會計師和觀察員地位差不多,都屬於「下級」,都需要將自己的資訊及時回饋給探長;
- 對觀察員而言:會計師是屬於 「上級」,擁有部分類似探長執行任務權力,只不過其任務類型只能是 計算類型的任務,執行任務結束之後,像探長那樣和觀察員互相關聯起來,方便下一次的運算;
自從有了會計師的參與,探長還是那個探長,但他的下級已經不是之前的下級了。藉助 A計劃任務的執行,會計師 C1 在上報計算值的時候,會順水推舟地執行計算任務,同時更新他的 」關係網「。
2.2、 懶惰的會計師
會計師有一個特性就是比較懶:就算觀察員所觀察到的值變更了,他們也不會立即重新計算,而只在必要的時候(比如當上級前來索取時)才會重新計算。
舉個例子,當觀察員 O1 發現張三的賬戶存款從原來的 3 變成 6 :
bankUser.income = 6;
這個時候會觸發一系列的 「漣漪」:
- ① 觀察員 O1 先註冊事務,相當於到數據情報室」上班打卡「,聲明這次事件由 觀察員 O1 主導
- ② 告知其上級,也就是會計師 C1 ,說是張三存款(
income
)有變更 - ③ 會計師 C1 獲知消息後,」慵懶地「調整自己的狀態
- ④ 隨後會計師 C1 繼續往上級彙報,告知本會計師的值有更改(注意,此時會計師只是告訴上級自己的值有更改這一事實,但並沒有執行計算任務 !)
- ⑤ 探長 R1 接收到會計師的回饋後,就向 MobX 執行官申請要執行任務!因為其下級會計師 C1 彙報說值有更改,說明這個時候應該要重新執行任務啦~
- ⑥ 執行官 MobX 調閱數據情報室資訊一看,發現目前觀察員 O1 正在執行事務,就讓探長 R1 再等等,現在不是執行任務的最佳時機,等到事務結束再說。
- ⑦ 不一會兒觀察員 O1 完成了自己的職責,」下班打卡「,在數據情報室中註銷事務
- ⑧ 這個時候,執行官 MobX 才讓探長 R1 開始執行任務
將上面的文字轉換成流程圖,可以清晰看到各角色在這次「漣漪」中所起到的作用:

會計師惰性求值
這裡需要注意 3 點:
- 當觀察員O1 彙報張三存款有更改的時候,會計師 C1 並沒有立即重新計算值哦,僅僅是更改自身的狀態;
- 會計師告知上級(探長 R1)自己有值更改,探長申請執行任務,不過 MobX 執行官並沒有允許他這麼做,而是讓他先等待一下,因為此時 觀察員 O1 還在彙報工作。等觀察員 O1 工作彙報完畢,這個時候才讓探長執行任務。因為有可能有其他計算組職員也正在響應該觀察值的更改,事情一件一件來,不要著急,這和 debounce 思想一致,減少不必要的計算。
- 只有在最後探長執行任務時 需要用到會計師的值的時候,會計師才會去執行計算操作。這就是典型的惰性求值思維。
會計師這種拖延到 只有被需要的時候才進行計算 的行為,有沒有讓你回憶起學生時代寒假結束前一天瘋狂補作業的場景?

瘋狂補作業的場景
2.3、避免不必要的計算
當執行官 MobX 拿著這份執行報告送達給你(警署最高長官),閱覽完畢:」不錯,這套方案的確部分證實了你之前所言的可擴展性。但隨著職員的引入,運轉機構逐漸龐大,如何避免不必要的開銷的呢?「
」長官您高瞻遠矚,這的確是一個問題。在井然有序的規則下,個別職員的運作效率的確會打折扣。因此避免職員不必要的計算開銷,也是在我方案部署規劃之內。正如您所見,上述方案中會計師的『惰性』、探員在事務之後再進行任務等機制,都是基於優化性能所採取的措施。「 執行官 MobX 稍作停頓,繼續道,」為了更好地闡述這套運行方案的性能優化機制,我明天呈上一份報告,好讓您得以全面了解。「
」Good Job!期待你的報告「。
那麼,執行官 MobX 是憑藉什麼機制減少開銷的呢?且聽下回分解。 (本節完,未完待續)
B. Source Code Time
本節部分,仍然是就著上面的」故事「來講 MobX 中的源碼。
先羅列本文故事中新出現的 會計師 角色與 MobX 源碼概念映射關係:

會計師角色映射關係
探長、執行官等角色的映射關係,參考上一篇《【用故事解讀 MobX源碼(一)】 autorun》

會計師對應於 MobX 中的 ComputedValue
本文的重點內容就是 computedvalue 的部分源碼(它在 autorun
等場景中的應用)
autorun
(A 計劃)的源碼在上一節講過,這裡不再贅述。我們僅僅講解一下 computedValue 在 autorun
中的表現。
1、會計師,請開始你的表演
在故事中我們講到過,當探長向會計師索要計算值的時候,此時懶惰的會計師為了 」應付交差「,這時候才開始計算,其計算的過程和探長執行的任務流程幾乎一致。
從源碼角度去看一下其中的原因。
當探長執行任務:
() => { console.log('張三的貸款:', bankUser.debit, ';張三的存貸比: ' + divisor); }
任務中也涉及 bankUser.debit
變數和 divisor
變數;其中在獲取 bankUser.debit
變數之時會讓觀察員 O2 觸發 reportObserved
方法,這個上一篇文章著重講過,此處就不詳細展開了;而請求 divisor
數值的時候,則會觸發該值的 valueOf()
方法 —— 即調用會計師(computedValue)的 valueOf()
方法。
為什麼調用就觸發 valueOf()
方法呢?請看下方的「知識點」備註?
======== 插播知識點 ========= 任何原始值還是對象其實都包含
valueOf()
或toString()
方法,valueOf()
會返回最適合該對象類型的原始值,toString()
將該對象的原始值以字元串形式返回。 這兩個方法一般是交由 JS 去隱式調用,以滿足不同的運算情況。比如在數值運算(如a + b
)里會優先調用valueOf()
,而在字元串運算(如alert(c
))里,會優先調用toString()
方法 順帶附上兩篇 參考文章
- js中 toString 和 valueOf 的區別?:知乎問答
- valueOf() vs. toString() in Javascript:SF 上的回答,非常詳盡地告訴你其執行結果
======== 完畢 ==========
一旦調用調用會計師的 valueOf 方法:
valueOf(): T { return toPrimitive(this.get()) }
其實就是調用 this.get() 方法,我們瞧一眼源碼;

會計師(computedValue)的 valueOf() 方法
1.1、 重量級計算 還是 輕量級 計算?
這裡有個分叉點,根據 globalState.inBatch
決定到底是啟用 重量級計算 還是 輕量級計算:
- 當
globalState.inBatch
值大於 0,說明會計師被上級徵調(處於上級事務中),比如此案例中,陷於 A 計劃(autorun
)的會計師,在上級探長 R1 需要查閱計算值時候,就會進入重量級計算模式 - 當會計師無上級徵調的時候,
globalState.inBatch
值為 0,就會進入輕量級計算模式,簡化計算的邏輯。
但無論輕量級還是重量級計算,都會涉及到調用 computeValue() 方法來執行計算任務。
調用的時候,如果是 重量級計算 則 track
這個 bool 值為 true,否則track
值為 false。

區分輕量級和重量級計算的差別
計算值有個屬性,this.derivation
就是會計師要計算數值時所依據的計算表達式,也就是而我們定義會計師時所傳入的匿名函數:
() => { return bankUser.income / bankUser.debit; }
無論是 重量級計算 模式還是 輕量級計算 模式,最終都是會調用該計算表達式獲取計算值。
重量級計算 模式和 輕量級計算 模式兩者的差別只是在於前者在執行該計算表達式之前會設置很多環境,後者直接就按這個表達式計算數值返回。
在上述的故事中,由於探長 R1 人物的存在,會計師會執行 重量級計算 模式,接下來的源碼分析也走這條分支路線。( 輕量級計算 模式的情況當做課後思考題)。
1.2、像探長學習
在 重量級計算的時候,computeValue(true)
就會走和 探長 操作模式一樣 trackDerivedFunction
步驟。沒錯,探長和會計師調用的就是同一個方法,所以他們在執行任務的時候,行為痕迹是一樣的,沒毛病。

重量級計算時和探長執行的計算過程類似
如果忘記
trackDerivedFunction
方法內容,請查看 《【用故事解讀 MobX源碼(一)】 autorun》的 」2.2.2、trackDerivedFunction「 部分
只不過會計師只能執行計算類的任務(純函數)罷了,探長可以執行任意類型的任務。
和探長一樣,會計師執行計算任務完畢之後調用 bindDependencies
將綁定 觀察員 O1 和 觀察員 O2 ;而在執行計算之後,會計師會調用 propagateChangeConfirmed
方法,更改自己和上級 探長 的狀態 —— 這說明,對探長而言,會計師就相當於 觀察員的角色,在探長執行任務結束後像觀察員一樣需要上報自己的計算值,並和 探長 取得聯繫;
這麼看會計師還真 」牆頭草,兩邊倒」。
至此,會計師這個角色以較低的成本就能完美地整合進執行官 MobX 所部署的 A 集合部署方案中。??
2、 響應觀察值的變化
一旦張三的賬戶存款(income
)發生變化,將會觸發 MobX 所提供的 reportChanged 方法:
public reportChanged() { startBatch() propagateChanged(this) endBatch() }
注意這裡的
startBatch
和endBatch
方法,說明觀察員 O1 發起事務了。
2.1、傳遞變化的資訊
我們知道(不知道的請閱讀上一篇文章)該 reportChanged()
方法中的 propagateChanged()
會觸發上級的 onBecomeStale()
方法。
觀察員 O1 此時的上級是 會計師 C1,其所定義的 onBecomeStale 如下:
onBecomeStale() { propagateMaybeChanged(this) }
看一下 propagateMaybeChanged(this) 源碼,也比較簡單,主要做了兩件事情,① 會計師會調整自身的狀態;②然後觸發其上級(探長 R1)的 onBecomeStale()
方法。

調用 onBecomeStale 方法
可見觀察員 01 會引起會計師 C1 的響應,而會計師會引起探長 R1 的響應,這種響應「漣漪」就是通過下級觸發上級的 onBecomeStale
方法形成的連鎖反應。
不同上級(比如會計師和探長)的
onBecomeStale
定義不同。
探長的這個 onBecomeStale
方法在上一篇文章的 「3、響應觀察值的變化 – propagateChanged」 中我們講過,探長將請求 MobX 請求重新執行一遍 A 計劃方案。
然而,MobX 拒絕了這次請求,讓他再等待一下。??
這是因為在 runReactions 方法中:
if (globalState.inBatch > 0 || globalState.isRunningReactions) return
由於此時 inBatch
是 1(因為觀察員執行了 startBatch()
),所以會直接 return 掉。
直到觀察員執行 endBatch() 的時候,除了會結束本次的上報事務,同時執行官 MobX 會重新執行 runReactions
方法,讓久等的探長去執行任務:

endBatch 來結束本次事務
探長在執行任務的時候,就會列印張三的貸款(debit
)、存貸比(divisor
)了。
2.2、雖然懶,但是懶得有技巧
綜上,當張三存款(income
)變更,就能讓 A 計劃(autorun
)自動運行,探長會列印張三的貸款(debit
)、存貸比(divisor
)。
這裡需要提及一下,關於會計師重新計算的時機,是在探長執行 shouldCompute 的時候,探長發現會計師值 陳舊 了,就讓會計師重新計算:

在 shouldCompute 中進行精細化判斷
看看這裡,對計算值而言,isComputedValue()
(如果是計算值)返回 true,就會執行 obj.get()
方法,這個方法剛才剛講過,會讓會計師執行 重量型計算操作,更新自己的計算值。
所以,這次計算時機並非等到探長執行任務時(真正用到該值)的時候才讓其重新計算,和第一次 autorun
的時機不一致。
估計這是 MobX 考慮到會計師的值肯定需要更新的(已經確定要被探長 R1 用到),還有可能會被其他上級引用,既然遲早要更新的,那就儘可能將更新前置,這樣在整體上能降低成本。
更新完之後,在探長執行任務的時候,會計師彙報自己是最新的值了,就不用再重新計算一遍。
雖然懶,但是懶得有技巧。
至此,有關會計師的源碼解讀已經差不多,後續有想到的再補充。
3、其他說明
本文為了方便說明,所以單獨使用 mobx.computed
方法定義計算值,平時使用中更多則是直接應用在 對象中屬性 上,使用 get 語法:
var bankUser = mobx.observable({ income: 3, debit: 2, get divisor() { return this.income / this.debit; } });
這僅僅是寫法上不一樣,源碼分析的思路是一致的。
4、小測試
4.1、測試1
問題:當我們更改張三貸款數額 bankUser.debit = 4;
時,請從源碼角度解答 MobX 的執行流程是如何的?
參考答案提示:
reportChanged() => propagateChanged() => propagateMaybeChanged() => runReaction() => track() => get() => computeValue() => bindDependencies()
4.2、測試2
問題:如果不存在 autorun
(即沒有探長參與,僅有觀察員和會計師),此時僅改變張三存款數值:
var bankUser = mobx.observable({ income: 3, debit: 2 }); var divisor = mobx.computed(() => { return bankUser.income / bankUser.debit; }); bankUser.income = 6; // 請問此時的執行情況是什麼樣的? console.log('張三的存貸比:', divisor)
請問會計師會重新計算數值么?此時這套系統的執行情況又會是怎麼樣的呢?
參考答案提示:會計師此時執行 輕量級計算模式。
5、小結
此篇文章講解 MobX 中 計算值 (computedValue) 的概念,類比故事中的會計師角色。總結一下 計算值 (computedValue)的特徵:
- 計算值是基於現有狀態或其他計算值衍生出的數值,一般是通過 純函數 的方式衍生而得。
- 一旦觀察值更改之後,計算值是能夠重新執行計算,不過並非立即執行,而是 惰性 的 ———— 只有在必要的時候才會執行計算。
- 對觀察值而言,計算值和
autorun
(或reaction
) 很像,之所以相似是在 執行任務 時都涉及到調用trackDerivedFunction
方法;而對autorun
(或reaction
)而言,計算值和觀察值很相,都是數據提供者。
正如 官方文檔 而言,計算值是高度優化過的,所以儘可能應用他們。

官方文檔對計算值的說明
下一篇文章將探討 MobX 中與 autorun
和 computed
相關的計算性能優化的機制,看看 MobX 如何平衡複雜場景下狀態管理時的效率和性能。
完