React 原始碼分析 Part2 - 同步模式下基礎的 Mount 與 Update
在開始 Part2 之前,之前的文章算是起了一個頭,非常希望能夠拋轉引玉,所以也會盡量更新之前的磚,丟擲更多的磚。
ofollow,noindex" target="_blank">Heaven:展望 React 17,回顧 React 往事在開始今天的內容前,上一篇文章的有一些補充可以今天先寫下:
主要的
- 不是支援任意的 Promise 作為 type(原因見 PR,簡單來說加了兩個限制,必須使用 Reac.lazy 包裹,返回的 Promise resolve 的必須是一個 ES module): Lazy components must use React.lazy
2. Hide timed-out children instead of deleting them so their state is preserved : 超過指定的 duration 之後 ,在之前會顯示 Suspense 裡面非同步元件的非非同步的 siblings,現在改了,siblings 也不顯示了(之前提過這種體驗是不好的,但是說的是未超過 duration 的情況),而是顯示 fallback(等到 resolve 的時候再一起顯示), 但是為了保留 siblings 的狀態,所以用切換 display 的方式。
其它的( 可能的 )改動
context 的「回撥地獄」的改進 :(猜測就是 static contextType 的方式 ,當然,這個只針對單個 context 的情況,並不是用來替代現在的 render prop context API 的,多個的情況仍然需要用 render prop context API)
Context.set(newGlobalValue) :context 的變化可能引起整個 App 重新整理的改進(增加全域性的 set 方法,而不是自己去 setState)
Suspense 放在 16.6 裡面了 (因為是 feature 而不是 breaking change),另外 小改了下之前的用法 。17 的話應該是 這些 或者 React Fire 相關的東西,feature 相關的因為都是新增,不涉及 breaking change,所以以 unstable_ 的方式放 16.6 裡面 export 出來也沒什麼問題。
好了,之前的先補充這麼多。還有的就放在下篇文章啦,畢竟接下來我們還是需要進入今天的標題的嘛。
我們的標題是「同步模式下基礎的 Mount 與 Update」,這點出了今天分析的一個前提,「同步」(雖然正式版叫 ConcurrentMode,不再是以前的 AsyncMode,原因主要是覺得有歧義,具體可以看 Dan 的 twitter)。為什麼要先講「同步」模式下呢,主要是因為雖然目前 ConcurrentMode 預設已經有 export,但是生產環境下基本所有的還是用的同步模式。另一方面,分析同步模式的流程,也有助於我們理解「非同步」模式下的流程。當然了,還有一個原因是因為非同步太難了,支離破碎的理解片段,先搞清楚同步...
大家都知道,就像 webpack 打包一樣,啃原始碼肯定也需要一個切入點,事件、相容、context、RN,無關測試,build 等等等等相關的東西在入手的時候肯定需要捨去。對應 React 來說,自然是從 ReactDOM.render
入手。

那麼,DOMRenderer 又是什麼東西呢?

當然,這個結構不是新鮮的事啦,事實上在正式完全的採用 Fiber 的 16 版本前的 15 版本,就已經是這個結構了:


所以我們可以看到,之前這些部分是和框架綁在一起的,框架自己用也許還不容易發現問題,但身處開源的生態內,問題就會慢慢暴露出來。
所以當有人想自己開發一個 renderer(基於 Fiber),也就是開發 iamdustan/tiny-react-renderer 的時候,但是又不想自己寫一遍 Reconciler 的演算法(複雜,重複且沒有必要),於是想使用 React 「成熟」的現成的邏輯。但是之前 Reconciler 是 和框架耦合在一起的 (見上圖),於是便有了拆分解耦的想法(造福大眾)。於是便有了 這個拆分的 PR (可以看到這兩個人是同一個人)。
所以目前(包括之前,只是自己用沒事,別人用比較麻煩)的 renderer 基本都是基於 react-reconciler 來實現的,包括測試用的 renderer,RN 的 renderer 以及開源的一些 renderer 等等。
當然,如果你看最新的程式碼,可能就會發現又有些許不同了,目前 Fiber 的 renderer 又和 reconciler 耦合在一起了(不是通過 config 的形式 runtime 生成了),主要是因為在 runtime 去構造 React 自己的 renderer 效能上肯定還是沒有靜態的好,但是 react-reconciler 已經單獨發包了且有人在用了,肯定不能撤回,邏輯上也不能這樣撿了芝麻丟了西瓜嘛。所以最後還是 先只針對 React 自己把 renderer 靜態化 。最終還是會移到 react-renderer 裡面去的(repo 裡搜尋 to renderer
關鍵字即可)。
所以我們可以 簡單的 總結下:
- 排程器(scheduler):決定什麼時候該做什麼任務
- 協調器(reconciler):對兩棵樹進行 「diff」,找出需要更新的部分
- 渲染器(renderer):「拿到」上一步的結果,進行實際的與宿主環境繫結相關的(如瀏覽器)的渲染
好的,瞭解了這點(就可以忘了...,暫時沒什麼用),回到我們的主題。Mount 與 Update。
Tip:以下內容 可能 和原始碼有耦合,謹遵醫囑服用...

為什麼要把 Mount 和 Update 拿到一起來說呢,不僅是因為他們相關性很高。也是因為在原始碼的大部分處理上,Mount 和 Update 並沒有太大的區別,Mount 也是 Update。
無論是 Mount,還是 Update(element 的更新),還是 setState(state 的更新),背後的機制都是走的 UpdateQueue 。無非是前兩者的 payload 是 { element }
,後者是 newState
。
ok,UpdateQueue 以後單獨在談,我們還是聚焦在 Mount 本身上來。要將這樣一個結構渲染到 DOM 上,要怎麼做呢?
上一篇文章我們談到了 workLoop,在同步模式下,workLoop 是不會去校驗是否該 yield 的,也就是說,任務雖然被劃分為了多次迴圈去執行,但仍舊是同步的。對於這樣一個任務,在 render 階段(沒有 side effect 操作),我們從我們的 app-root
這個 fiber 開始:

從第一次 performUnitWork
開始:

fiber 並不直接儲存 children,它儲存的是 child(即第一個子節點 fiber)。至於 children 是儲存在 props 的 children 屬性上。
總的來說,從第一個 Fiber 開始,workloop 開始處理,然後一個迴圈完成後處理它的 child(如果有),如果 child 還有 child,則遞推。如果沒有 child,則 complete 這個 work,然後處理它的 sibling。和原來的遞迴的鳥瞰圖基本是一致的,關鍵在於 next 的選取上
在 perform 階段只負責給 fiber 打上各種 effectTag
標記,而實際對應的 DOM 操作放到 complete 階段的末尾去進行。
當然,對於 child 裡還有 child 的,那就以此類推,即肯定是層次最深的 child 最先 complete,然後再是上一層。
建立 DOM ,DOM 的插入,移動,更新,刪除是在 complete 階段先在 js 或者說 Fiber 的層面進行的,還沒有反應到真實的 DOM 上。即 append 是先在 js 裡全部 append 好,在 complete 階段的最後再一下 append 到 真實 DOM 裡的,即 commit 階段。
如果還有 sibling 需要處理,則 return 這個 sibling,然後交給 performWork 去處理,因為completeWork 只負責 complete 而不負責 perform。
如果沒用 sibling 需要處理了,那麼接下來應該 complete wipFiber 本身了,本身處理完了之後處理父 fiber,一直處理到 root fiber 整個 complete 階段就完成了。直接 return null 回到 performUnitOfWork,即指定 next 為 null,也就代表整個過程完畢了。因為只要是 complete 相關的就應該一直在 completeUnitOfWork 裡處理下去。否則就應該 return(比如上面的 return)然後交給 performUnitOfWork 去處理。
同步的情況下,如果有 finishedWork,說明是之前非同步留下來的,直接 commit,如果沒有 finishedWork,那麼 renderRoot 後直接 commit。非同步的情況下,如果有 finishedWork,說明是之前非同步留下來的,直接 commit。如果沒有 finishedWork,那麼 renderRoot 後需要判斷是否需要 yield,不是的情況下才 commit。
workLoop 完了之後 completeRoot,如果不是 _defer 的(通過 BatchRoot 建立的,Part1 文章裡有提到)則直接 commitRoot。
commitRoot 也分為兩次不同的迴圈體去處理,一次是執行插入,更新,刪除, ref 以及 unMount,二次是執行 Mount 和 Update 生命週期鉤子。
commitRoot 會先跑 getSnapshotBeforeUpdate,然後執行真實的 DOM 操作,最後執行生命週期鉤子和 ref。並且把 current 改成 wipFiber(因為 wipFiber 已經處理完了,應該變成 current 了)。
cDM 實際上就是利用的和 setState 的 callback 一樣的機制,即它們的 effectTag 都是 CALLBACK。

對應我們的這個例子而言,workLoop 會從 'foo' 開始去處理,此時 'foo' 的 sibling 已經是 'div' 了,'div' 的 sibling 已經是 'bar' 了,而 'foo' Fiber 的 alternate 為 null,即 current 也是 null(說明這不是一個更新,而是 Mount)。
處理完 'foo' 後(由於 'foo' 是一個沒有 child 的 Fiber,所以直接 completeWork),然後去處理 'foo' 的 sibling,即 div。
complete 的時候就按照順序對這個fiber(先子,然後同級,然後回到這個 fiber),進行真實的 DOM 的 append,完了之後會把建立的 dom 賦值給 fiber 的 stateNode。
每個 Fiber 的 index 屬性代表了自己在對應的那個 Children 中所在的 index。
至此,Mount 的大概流程基本就是這樣,下面我們看看更常見也是更重要的 Update。

其實流程基本和 Mount 是類似的(通過 call stack 可以看到呼叫的函式基本是一樣的),自然也是走 workLoop,只是之前 Mount 的話不需要進行比較,刪除這些操作。
那麼自然 Update 最重要的就是「比較」了,這裡主要討論 多個 child 的情況 ,單個的很簡單,如果新的的第一個 child 的 key 和之前的第一個 child 的 key 一樣,那麼把當前的除了第一個 child 以外的 child 都刪掉(打上 tag),如果不一樣,那麼把當前的所有 child 都刪掉(打上 tag),每刪一個再比較一次 key。
多個 child 會通過兩次不同的迴圈的來處理。
- 第一輪比較:先按照從上到下的順序一一對比,即第一個比第一個,第二個比第二個,這時會比較舊的節點和對應的新的節點的 key 是否相同,如果相同則直接更新。不相同則 return null,等會會進行第二輪比較。
- 第二輪比較:建立一個 Map,以舊的節點的 key(沒有則以 fiber 的 index)作為 key。value 為對應的 fiber。從第一輪沒有直接進行更新的開始(也就說第一輪直接更新了的是不會參與第二輪的,沒有必要),然後迴圈拿著新的節點的 key(沒有則以 index)作為 key 去 map 裡面找,如果沒有則直接插入。如果有,則還需要細分比較,比如是 DOM 的話看是否都是同一型別(比如 div,span,文字節點等),如果是則直接複用當前的 fiber 進行更新,如果不是則建立新的 fiber 再更新。
第二輪比較完成後,應該返回新的 child(即第一個 child,前面已經說過不是返回陣列,因為這是連結串列,多個 child 通過 sibling 進行聯絡而不是直接返回陣列)。還需要注意的是,在第二輪的過程中已經過濾掉了 child 是 null 或者 undefined 的情況。
在這次更新完之後,current 就指向了 wipFiber,而 wipFiber 的 alternate 就指向了 current。所以在這之後,current 的 updateQueue.baseState.element.children
是 null,div,null(既然叫 current,肯定是代表更新後的狀態嘛),且 firstUpdate 已經為 null 了(因為在更新過程中執行了更新後就被清除了)。
而 current 的 alternate(實際上就是下一次更新中的 wipFiber,這就是 fiber 的複用的一部分體現)的 updateQueue.baseState.element.children
還是 'foo',div,'bar'(因為之前的更新根本沒有動這個,動的是 wipFiber,所以最後保留下來的還是原始的狀態)且 firstUpdate 的 payload 的 element 的 children 為 null,'div','null'(即第一次更新所需要做的的改變)。
3. 所以總的來說:類似 double buffering 的這種機制,能夠讓我們處理完所有的更新後,在下一次更新時,還能知道上一次的更新中的中間細節(通過在下一次更新中檢視 current.alternate 即 wipFiber 即可),雖然大部分情況下不需要知道(大部分情況下在開始處理之前,wipFiber 的updateQueue 就會被 current 的 updateQueue 覆蓋了,即和 current 保持一致)。
ok,在真實的環境中,在上面的更新之後,非常有可能還有下面的更新:

你可能會說,這個和之前的更新應該沒有什麼區別吧?是的,流程肯定是一樣的。但是我們可能就會有 兩個疑問 了:
-
這種情況,即新的 children 裡面有從
null
變為非null
的節點, 我們需要給這個元素加 key 嗎? 因為在實際的 DOM 裡面之前的更新後就只有一個 div 了(兩個null
肯定在 DOM 裡是沒有的),而之前是null
(假設實際情況是this.state.show && <span>foo2</span>
), 我們需要給 span 加 key 嗎?在不瞭解之前,我們可能會說是。因為我們會覺得新的 DOM 裡面只有一個了, 他下次比較的時候會不會拿著左邊的 div 和右邊的 'foo2' 去比啊? (因為 null 沒了,第一個就是 div 了,不是第一個比第一個嗎)
所以我們要加嗎? 肯定是不需要啦。 我們希望的是第二個 div 去比 div,這樣 type 沒有變,能對上( 即便我們沒有 key ),而且從結構上來說也更符合直覺,因為我們並不是只寫了一個 div,我們是用的 this.state.show && <span>foo</span>
,我們在結構上也是一致的。
所以為什麼不需要呢?因為 這裡有 index 機制 。具體的來說,在我們這個例子中,開始比較的時候,currentFirstChild 是左側的 div( 不是 null ,實際上這個就是 fiber 的 child 屬性),而 這個 div 的 index 是 1( 還記得我們之前的 fiber 的 index 代表在 children 中的 index 嗎)。到了第二次更新的時候,這個 index 在迴圈一開始就比在迴圈體裡用的每次迴圈累加 1 的 index(初始值為 0)要大。
當發現 fiber 的 index 比迴圈的 index 要大的時候,那麼在下一輪或者多輪迴圈中 還是處理 oldFiber (即 nextOldFiber = oldFiber
),並且把 oldFiber 置為了 null(因為本身就應該是 null 嘛,就像我們這裡的 null 一樣)。
為什麼要在下一輪或者多輪迴圈中還是處理 oldFiber 呢?因為你想呀,oldFiber 上一個或者多個兄弟節點是 null,說明更新後的 children 裡有節點和這個 null 很可能就是對應的啊,也就是說這個 null 實際上也是對應一個節點的(比如我們例子裡的 'foo2'),只是它現在是 null,之後可能就不是 null 了,最常見的就是 { this.state.show && <...> }
這種情況對吧。
所以說,拿 null 的下一個或者多個之後的節點(即這裡的 oldFiber,即左側的 div)去和新的 children 裡與這個 null 的位置對應的節點去比(即 'foo2')。 多半是沒有價值的 ,不能最大化利用的。 所以正常的來說,應該拿 oldFiber 去和新的 children 中與這個 oldFiber 對應的節點(即右側的 div)也就是 null 的下一個或者多個之後的節點(即非 null 的節點)去比。 這樣才是 真正的「一一對應」 的關係。
所以我們需要 在下一輪迴圈中仍然處理 oldFiber 而不是 oldFiber.sibling 。當然了,也不應該直接 continue 跳到下一輪迴圈去,因為雖然本輪迴圈的對比不會使 oldFiber 和對應的新的 children 的節點進行比較, 但是仍然是有必要的 。因為新的 children 中與 oldFiber 對應的節點的上一個或者多個節點(即 'foo2') 自身肯定也是需要插入的 。
這樣就是比較合理的方式了。所以結論是這樣的 case 我們是不需要加 key 的。同時這也引入了我們的第二個疑問(前面說的一共有兩個嘛)。
2. 哈哈,肯定有人猜陣列的問題,肯定不是啦,陣列的問題官方文件已經講得很清楚了。我們要說的是另一個問題:
<Foo />--------->>>>>><Bar /> <Bar />--------->>>>>><Foo />
如果是這樣的情況,比如:
let eles; if (this.state.xx) { eles = ( <div className="xxx"> <Foo /> <Bar /> </div> ) } else { eles = ( <div className="xxx"> <Bar /> <Foo /> </div> ) }
這種情況下,我們需要給 Bar 和 Foo 加 key 嗎?Yes, 因為一旦找到匹配的,就不會再來比較其它的了。 ( 不匹配的時候會返回 null,從而會跳出前面說的第一次輪的迴圈在第二次迴圈通過 Map 去找 key 來繼續比較 )。
具體來說是因為類似這樣的情況,更新的時候, 他們被認為是可以當做一一對應的 ,Foo 和 Bar 的 key 是一致的(都是 null) ,但是 它們的 type 不同 (一個是 Foo 一個是 Bar) 那麼就會建立 Bar 並直接進行替換 ( 並且會把之前的刪除掉 (打上 tag)),但是我們知道,理想的情況或者我們期待的情況是更新而不是替換刪除。所以這種情況下,給她們都賦值一個顯式的 key 就是非常有必要的了。 https:// twitter.com/Heaven_xz/s tatus/1045703806539853824
插個題外話,如果用過比較老的版本的 React 的話,在 React Devtools 能夠發現我們的元件例項被 React 加上了隱式的 key 。 很早前就已經去掉了 ,用 key 都為 null 來代替了。
最後筆者自己 fork 了一份 react,在看原始碼的過程中會添加了一些自己的註釋(僅代表個人的隨(瞎)筆(寫)記錄,錯誤的概率極大。。), 如果大家有興趣可以看看 ,歡迎交流 !
嗯,大概的內容就是這樣,講得不是很好,因素比較多,爭取下次有進步 :)