YCK 的 React 原始碼解讀課 —— 先熱個身

yck發表於2019-04-24

這是我的 React 原始碼解讀課的第一篇文章,首先來說說為啥要寫這個系列文章:

  • 現在工作中基本都用 React 了,由此想了解下內部原理
  • 市面上 Vue 的原始碼解讀數不勝數,但是反觀 React 相關的卻寥寥無幾,也是因為 React 原始碼難度較高,因此我想來攻克這個難題
  • 自己覺得看懂並不一定看懂了,寫出來讓讀者看懂才是真懂了,因此我要把我讀懂的東西寫出來

這個系列文章預計篇數會超過十篇,React 版本為 16.8.6,以下是本系列文章你必須需要注意的地方:

  • 這是一門進階課,如果涉及到你不清楚的內容,請自行谷歌,另外最好具備 React 的開發能力
  • 這是一門講原始碼的課,只閱讀是不大可能真正讀懂的,需要輔以 Demo 和 Debug 才能真正理解程式碼的用途
  • 我 fork 了一份 16.8.6 版本的程式碼,並且會為讀過的程式碼加上詳細的中文註釋。等不及我文章的同學可以先行閱讀 我的倉庫並且在閱讀本系列文章的時候也請跟著閱讀我註釋的程式碼。因為版本不同可能會導致程式碼不同,並且我不會在文章中貼上大段的程式碼,只會對部分程式碼做更詳細的解釋,其他的程式碼可以跟著我的註釋閱讀
  • 閱讀原始碼最先遇到的問題會是不知道該從何開始,我這份程式碼註釋可以幫助大家解決這個問題,你只需要跟著我的 commit 閱讀即可
  • 不會對任何 DEV 環境下的程式碼做解讀,不會對所有程式碼進行解讀,只會解讀核心功能(即使這樣也會是一個大工程)
  • 最後再提及一遍,請務必文章和 程式碼 相結合來看,為了篇幅考慮我不會將所有的程式碼都貼上來,我拷貝的累,讀者看的也累

這篇文章內容不會很難,先給大家熱個身,請大家開啟 我的程式碼 並定位到 react 資料夾下的 src,這個資料夾也就是 React 的入口資料夾了。

YCK 的 React 原始碼解讀課 —— 先熱個身

開始進入正文前先說下這個系列中我的行文思路:1. 程式碼儘量通過圖片展示,既美觀又方便閱讀,反正不需要大家複製程式碼。2. 文章中只會講我認為重要或者有意思的程式碼,對於其他程式碼請自行閱讀我的倉庫,反正已經註釋好程式碼了。3. 對於流程長的函式呼叫會使用流程圖的方式來總結。4. 不會幹巴巴的只講程式碼,會結合實際來聊聊這些 API 能幫助我們解決什麼問題。

React.createElement

大家在寫 React 程式碼的時候肯定寫過 JSX,但是為什麼一旦使用 JSX 就必須引入 React 呢?

這是因為我們的 JSX 程式碼會被 Babel 編譯為 React.createElement,不引入 React 的話就不能使用 React.createElement 了。

<div id='1'>1</div>
// 上面的 JSX 會被編譯成這樣
React.createElement("div", {
  id: "1"
}, "1")
複製程式碼

那麼我們就先定位到 ReactElement.js 檔案閱讀下 createElement 函式的實現

export function createElement(type, config, children) {}
複製程式碼

首先 createElement 函式接收三個引數,具體代表著什麼相信大家可以通過上面 JSX 編譯出來的東西自行理解。

然後是對於 config 的一些處理:

YCK 的 React 原始碼解讀課 —— 先熱個身

這段程式碼對 ref 以及 key 做了個驗證(對於這種程式碼就無須閱讀內部實現,通過函式名就可以瞭解它想做的事情),然後遍歷 config 並把內建的幾個屬性(比如 refkey)剔除後丟到 props 物件中。

接下里是一段對於 children 的操作

YCK 的 React 原始碼解讀課 —— 先熱個身

首先把第二個引數之後的引數取出來,然後判斷長度是否大於一。大於一的話就代表有多個 children,這時候 props.children 會是一個陣列,否則的話只是一個物件。因此我們需要注意在對 props.children 進行遍歷的時候要注意它是否是陣列,當然你也可以利用 React.Children 中的 API,下文中也會對 React.Children 中的 API 進行講解。

最後就是返回了一個 ReactElement 物件

YCK 的 React 原始碼解讀課 —— 先熱個身

內部程式碼很簡單,核心就是通過 $$typeof 來幫助我們識別這是一個 ReactElement,後面我們可以看到很多這樣類似的型別。另外我們需要注意一點的是:通過 JSX寫的 <APP /> 代表著 ReactElementAPP 代表著 React Component。

以下是這一小節的流程圖內容:

YCK 的 React 原始碼解讀課 —— 先熱個身

ReactBaseClasses

上文中講到了 APP 代表著 React Component,那麼這一小節我們就來閱讀元件相關也就是 ReactBaseClasses.js 檔案下的程式碼。

其實在閱讀這部分原始碼之前,我以為程式碼會很複雜,可能包含了很多元件內的邏輯,結果內部程式碼相當簡單。這是因為 React 團隊將複雜的邏輯全部丟在了 react-dom 資料夾中,你可以把 react-dom 看成是 React 和 UI 之間的膠水層,這層膠水可以相容很多平臺,比如 Web、RN、SSR 等等。

該檔案包含兩個基本元件,分別為 ComponentPureComponent,我們先來閱讀 Component 這部分的程式碼。

YCK 的 React 原始碼解讀課 —— 先熱個身

建構函式 Component 中需要注意的兩點分別是 refsupdater,前者會在下文中專門介紹,後者是元件中相當重要的一個屬性,我們可以發現 setStateforceUpdate 都是呼叫了 updater 中的方法,但是 updater 是 react-dom 中的內容,我們會在之後的文章中學習到這部分的內容。

另外 ReactNoopUpdateQueue 也有一個單獨的檔案,但是內部的程式碼看不看都無所謂,因為都是用於報警告的。

接下來我們來閱讀 PureComponent 中的程式碼,其實這部分的程式碼基本與 Component 一致

YCK 的 React 原始碼解讀課 —— 先熱個身

PureComponent 繼承自 Component,繼承方法使用了很典型的寄生組合式。

另外這兩部分程式碼你可以發現每個元件都有一個 isXXXX 屬性用來標誌自身屬於什麼元件。

以上就是這部分的程式碼,接下來的一小節我們將會學習到 refs 的一部分內容。

Refs

refs 其實有好幾種方式可以建立:

  • 字串的方式,但是這種方式已經不推薦使用
  • ref={el => this.el = el}
  • React.createRef

這一小節我們來學習 React.createRef 相關的內容,其餘的兩種方式不在這篇文章的討論範圍之內,請先定位到 ReactCreateRef.js 檔案。

YCK 的 React 原始碼解讀課 —— 先熱個身

內部實現很簡單,如果我們想使用 ref,只需要取出其中的 current 物件即可。

另外對於函式元件來說,是不能使用 ref 的,如果你不知道原因的話可以直接閱讀 文件

當然在之前也是有取巧的方式的,就是通過 props 的方式傳遞 ref,但是現在我們有了新的方式 forwardRef 去解決這個問題。

具體程式碼見 forwardRef.js 檔案,同樣內部程式碼還是很簡單

YCK 的 React 原始碼解讀課 —— 先熱個身

這部分程式碼最重要的就是我們可以在引數中獲得 ref 了,因此我們如果想在函式元件中使用 ref 的話就可以把程式碼寫成這樣:

const FancyButton = React.forwardRef((props, ref) => (
  <button ref={ref} className="FancyButton">
    {props.children}
  </button>
))
複製程式碼

ReactChildren

這一小節會是這篇文章中最複雜的一部分,可能需要自己寫個 Demo 並且 Debug 一下才能真正理解原始碼為什麼要這樣實現。

首先大家需要定位到 ReactChildren.js 檔案,這部分程式碼中我只會介紹關於 mapChildren 函式相關的內容,因為這部分程式碼基本就貫穿了整個檔案了。

如果你沒有使用過這個 API,可以先自行閱讀 文件

對於 mapChildren 這個函式來說,通常會使用在組合元件設計模式上。如果你不清楚什麼是組合元件的話,可以看下 Ant-design,它內部大量使用了這種設計模式,比如說 Radio.GroupRadio.Button,另外這裡也有篇 文件 介紹了這種設計模式。

我們先來看下這個函式的一些神奇用法

React.Children.map(this.props.children, c => [[c, c]])
複製程式碼

對於上述程式碼,map 也就是 mapChildren 函式來說返回值是 [c, c, c, c]。不管你第二個引數的函式返回值是幾維巢狀陣列,map 函式都能幫你攤平到一維陣列,並且每次遍歷後返回的陣列中的元素個數代表了同一個節點需要複製幾次。

如果文字描述有點難懂的話,就來看程式碼吧:

<div>
    <span>1</span>
    <span>2</span>
</div>
複製程式碼

對於上述程式碼來說,通過 c => [[c, c]] 轉換以後就變成了

<span>1</span>
<span>1</span>
<span>2</span>
<span>2</span>
複製程式碼

接下里我們進入正題,來看看 mapChildren 內部到底是如何實現的。

YCK 的 React 原始碼解讀課 —— 先熱個身

這段程式碼有意思的部分是引入了物件重用池的概念,分別對應 getPooledTraverseContextreleaseTraverseContext 中的程式碼。當然這個概念的用處其實很簡單,就是維護一個大小固定的物件重用池,每次從這個池子裡取一個物件去賦值,用完了就將物件上的屬性置空然後丟回池子。維護這個池子的用意就是提高效能,畢竟頻繁建立銷燬一個有很多屬性的物件會消耗效能。

接下來我們來學習 traverseAllChildrenImpl 中的程式碼,這部分的程式碼需要分為兩塊來講

YCK 的 React 原始碼解讀課 —— 先熱個身

這部分的程式碼相對來說簡單點,主體就是在判斷 children 的型別是什麼。如果是可以渲染的節點的話,就直接呼叫 callback,另外你還可以發現在判斷的過程中,程式碼中有使用到 $$typeof 去判斷的流程。這裡的 callback 指的是 mapSingleChildIntoContext 函式,這部分的內容會在下文中說到。

YCK 的 React 原始碼解讀課 —— 先熱個身

這部分的程式碼首先會判斷 children 是否為陣列。如果為陣列的話,就遍歷陣列並把其中的每個元素都遞迴呼叫 traverseAllChildrenImpl,也就是說必須是單個可渲染節點才可以執行上半部分程式碼中的 callback

如果不是陣列的話,就看看 children 是否可以支援迭代,原理就是通過 obj[Symbol.iterator] 的方式去取迭代器,返回值如果是個函式的話就代表支援迭代,然後邏輯就和之前的一樣了。

講完了 traverseAllChildrenImpl 函式,我們最後再來閱讀下 mapSingleChildIntoContext 函式中的實現。

YCK 的 React 原始碼解讀課 —— 先熱個身

bookKeeping 就是我們從物件池子裡取出來的東西,然後呼叫 func 並且傳入節點(此時這個節點肯定是單個節點),此時的 func 代表著 React.mapChildren 中的第二個引數。

接下來就是判斷返回值型別的過程:如果是陣列的話,還是迴歸之前的程式碼邏輯,注意這裡傳入的 funcc => c,因為要保證最終結果是被攤平的;如果不是陣列的話,判斷返回值是否是一個有效的 Element,驗證通過的話就 clone 一份並且替換掉 key,最後把返回值放入 result 中,result 其實也就是 mapChildren 的返回值。

至此,mapChildren 函式相關的內容已經解析完畢,還不怎麼清楚的同學可以通過以下的流程圖再複習一遍。

YCK 的 React 原始碼解讀課 —— 先熱個身

其餘內容

前面幾小節的內容已經把 react 資料夾下大部分有意思的程式碼都講完了,其他就剩餘了一些邊邊角角的內容。比如 memocontexthookslazy,這部分程式碼有興趣的可以直接自行閱讀,反正內容都還是很簡單的,難的部分都在 react-dom 資料夾中。

最後

閱讀原始碼是一個很枯燥的過程,但是收益也是巨大的。如果你在閱讀的過程中有任何的問題,都歡迎你在評論區與我交流,當然你也可以在倉庫中提 Issus。

另外寫這系列是個很耗時的工程,需要維護程式碼註釋,還得把文章寫得儘量讓讀者看懂,最後還得配上畫圖,如果你覺得文章看著還行,就請不要吝嗇你的點贊。

下一篇文章就會是 Fiber 相關的內容,並且會分成幾篇文章來講解。

最後,覺得內容有幫助可以關注下我的公眾號 「前端真好玩」咯,會有很多好東西等著你。

YCK 的 React 原始碼解讀課 —— 先熱個身

相關文章