這是我的 React 原始碼解讀課的第一篇文章,首先來說說為啥要寫這個系列文章:
- 現在工作中基本都用 React 了,由此想了解下內部原理
- 市面上 Vue 的原始碼解讀數不勝數,但是反觀 React 相關的卻寥寥無幾,也是因為 React 原始碼難度較高,因此我想來攻克這個難題
- 自己覺得看懂並不一定看懂了,寫出來讓讀者看懂才是真懂了,因此我要把我讀懂的東西寫出來
這個系列文章預計篇數會超過十篇,React 版本為 16.8.6,以下是本系列文章你必須需要注意的地方:
- 這是一門進階課,如果涉及到你不清楚的內容,請自行谷歌,另外最好具備 React 的開發能力
- 這是一門講原始碼的課,只閱讀是不大可能真正讀懂的,需要輔以 Demo 和 Debug 才能真正理解程式碼的用途
- 我 fork 了一份 16.8.6 版本的程式碼,並且會為讀過的程式碼加上詳細的中文註釋。等不及我文章的同學可以先行閱讀 我的倉庫,並且在閱讀本系列文章的時候也請跟著閱讀我註釋的程式碼。因為版本不同可能會導致程式碼不同,並且我不會在文章中貼上大段的程式碼,只會對部分程式碼做更詳細的解釋,其他的程式碼可以跟著我的註釋閱讀
- 閱讀原始碼最先遇到的問題會是不知道該從何開始,我這份程式碼註釋可以幫助大家解決這個問題,你只需要跟著我的 commit 閱讀即可
- 不會對任何 DEV 環境下的程式碼做解讀,不會對所有程式碼進行解讀,只會解讀核心功能(即使這樣也會是一個大工程)
- 最後再提及一遍,請務必文章和 程式碼 相結合來看,為了篇幅考慮我不會將所有的程式碼都貼上來,我拷貝的累,讀者看的也累
這篇文章內容不會很難,先給大家熱個身,請大家開啟 我的程式碼 並定位到 react 資料夾下的 src,這個資料夾也就是 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
的一些處理:
這段程式碼對 ref
以及 key
做了個驗證(對於這種程式碼就無須閱讀內部實現,通過函式名就可以瞭解它想做的事情),然後遍歷 config
並把內建的幾個屬性(比如 ref
和 key
)剔除後丟到 props 物件中。
接下里是一段對於 children
的操作
首先把第二個引數之後的引數取出來,然後判斷長度是否大於一。大於一的話就代表有多個 children
,這時候 props.children
會是一個陣列,否則的話只是一個物件。因此我們需要注意在對 props.children
進行遍歷的時候要注意它是否是陣列,當然你也可以利用 React.Children
中的 API,下文中也會對 React.Children
中的 API 進行講解。
最後就是返回了一個 ReactElement
物件
內部程式碼很簡單,核心就是通過 $$typeof
來幫助我們識別這是一個 ReactElement
,後面我們可以看到很多這樣類似的型別。另外我們需要注意一點的是:通過 JSX寫的 <APP />
代表著 ReactElement
,APP
代表著 React Component。
以下是這一小節的流程圖內容:
ReactBaseClasses
上文中講到了 APP
代表著 React Component,那麼這一小節我們就來閱讀元件相關也就是 ReactBaseClasses.js 檔案下的程式碼。
其實在閱讀這部分原始碼之前,我以為程式碼會很複雜,可能包含了很多元件內的邏輯,結果內部程式碼相當簡單。這是因為 React 團隊將複雜的邏輯全部丟在了 react-dom 資料夾中,你可以把 react-dom 看成是 React 和 UI 之間的膠水層,這層膠水可以相容很多平臺,比如 Web、RN、SSR 等等。
該檔案包含兩個基本元件,分別為 Component
及 PureComponent
,我們先來閱讀 Component
這部分的程式碼。
建構函式 Component
中需要注意的兩點分別是 refs
和 updater
,前者會在下文中專門介紹,後者是元件中相當重要的一個屬性,我們可以發現 setState
和 forceUpdate
都是呼叫了 updater
中的方法,但是 updater
是 react-dom 中的內容,我們會在之後的文章中學習到這部分的內容。
另外 ReactNoopUpdateQueue
也有一個單獨的檔案,但是內部的程式碼看不看都無所謂,因為都是用於報警告的。
接下來我們來閱讀 PureComponent
中的程式碼,其實這部分的程式碼基本與 Component
一致
PureComponent
繼承自 Component
,繼承方法使用了很典型的寄生組合式。
另外這兩部分程式碼你可以發現每個元件都有一個 isXXXX
屬性用來標誌自身屬於什麼元件。
以上就是這部分的程式碼,接下來的一小節我們將會學習到 refs
的一部分內容。
Refs
refs 其實有好幾種方式可以建立:
- 字串的方式,但是這種方式已經不推薦使用
ref={el => this.el = el}
React.createRef
這一小節我們來學習 React.createRef
相關的內容,其餘的兩種方式不在這篇文章的討論範圍之內,請先定位到 ReactCreateRef.js 檔案。
內部實現很簡單,如果我們想使用 ref
,只需要取出其中的 current
物件即可。
另外對於函式元件來說,是不能使用 ref
的,如果你不知道原因的話可以直接閱讀 文件。
當然在之前也是有取巧的方式的,就是通過 props
的方式傳遞 ref
,但是現在我們有了新的方式 forwardRef
去解決這個問題。
具體程式碼見 forwardRef.js 檔案,同樣內部程式碼還是很簡單
這部分程式碼最重要的就是我們可以在引數中獲得 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.Group
、Radio.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
內部到底是如何實現的。
這段程式碼有意思的部分是引入了物件重用池的概念,分別對應 getPooledTraverseContext
和 releaseTraverseContext
中的程式碼。當然這個概念的用處其實很簡單,就是維護一個大小固定的物件重用池,每次從這個池子裡取一個物件去賦值,用完了就將物件上的屬性置空然後丟回池子。維護這個池子的用意就是提高效能,畢竟頻繁建立銷燬一個有很多屬性的物件會消耗效能。
接下來我們來學習 traverseAllChildrenImpl
中的程式碼,這部分的程式碼需要分為兩塊來講
這部分的程式碼相對來說簡單點,主體就是在判斷 children
的型別是什麼。如果是可以渲染的節點的話,就直接呼叫 callback
,另外你還可以發現在判斷的過程中,程式碼中有使用到 $$typeof
去判斷的流程。這裡的 callback
指的是 mapSingleChildIntoContext
函式,這部分的內容會在下文中說到。
這部分的程式碼首先會判斷 children
是否為陣列。如果為陣列的話,就遍歷陣列並把其中的每個元素都遞迴呼叫 traverseAllChildrenImpl
,也就是說必須是單個可渲染節點才可以執行上半部分程式碼中的 callback
。
如果不是陣列的話,就看看 children
是否可以支援迭代,原理就是通過 obj[Symbol.iterator]
的方式去取迭代器,返回值如果是個函式的話就代表支援迭代,然後邏輯就和之前的一樣了。
講完了 traverseAllChildrenImpl
函式,我們最後再來閱讀下 mapSingleChildIntoContext
函式中的實現。
bookKeeping
就是我們從物件池子裡取出來的東西,然後呼叫 func
並且傳入節點(此時這個節點肯定是單個節點),此時的 func
代表著 React.mapChildren
中的第二個引數。
接下來就是判斷返回值型別的過程:如果是陣列的話,還是迴歸之前的程式碼邏輯,注意這裡傳入的 func
是 c => c
,因為要保證最終結果是被攤平的;如果不是陣列的話,判斷返回值是否是一個有效的 Element,驗證通過的話就 clone 一份並且替換掉 key
,最後把返回值放入 result
中,result
其實也就是 mapChildren
的返回值。
至此,mapChildren
函式相關的內容已經解析完畢,還不怎麼清楚的同學可以通過以下的流程圖再複習一遍。
其餘內容
前面幾小節的內容已經把 react 資料夾下大部分有意思的程式碼都講完了,其他就剩餘了一些邊邊角角的內容。比如 memo
、context
、hooks
、lazy
,這部分程式碼有興趣的可以直接自行閱讀,反正內容都還是很簡單的,難的部分都在 react-dom 資料夾中。
最後
閱讀原始碼是一個很枯燥的過程,但是收益也是巨大的。如果你在閱讀的過程中有任何的問題,都歡迎你在評論區與我交流,當然你也可以在倉庫中提 Issus。
另外寫這系列是個很耗時的工程,需要維護程式碼註釋,還得把文章寫得儘量讓讀者看懂,最後還得配上畫圖,如果你覺得文章看著還行,就請不要吝嗇你的點贊。
下一篇文章就會是 Fiber 相關的內容,並且會分成幾篇文章來講解。
最後,覺得內容有幫助可以關注下我的公眾號 「前端真好玩」咯,會有很多好東西等著你。