很多教程都把 React 作為一個 UI 庫來引入。這是很有道理的,因為 React 本身就是一個 UI 庫。就像官網上所說的那樣。
我曾經寫過關于構建用戶界面中遇到的挑戰的文章。但是本文將會用另外一種方式來講述 React —— 因為它更像是一種編程運行時。
本文不會教你任何有關如何創建界面的技巧。 但是它可能會幫你更加深入地理解 React 編程模型。
??注意:如果你還在學習 React ,請移步到官方文檔【https://reactjs.org/docs/getting-started.html#learn-react】進行學習
本文將會非常深入 —— 所以不適合初學者閱讀。 在本文中,我會從最佳原則的角度盡可能地闡述 React 編程模型。我不會解釋如何使用它 —— 而是講解它的工作原理。
本文面向有經驗的程序員,還有使用過其他 UI 庫,但在項目中權衡利弊之后最終選擇了 React 的人,我希望它會對你有所幫助!
一些人用了很多年 React 卻從沒有考慮過接下來我要講述的主題。 這絕對是以程序員而不是以設計者的角度來看待 React。但我認為站在兩個不同的角度來重新認識 React 并沒有什么壞處。
廢話少說,讓我們開始深入理解 React 吧!
宿主樹
一些程序輸出數字。另一些程序輸出詩詞。不同的語言和它們的運行時通常會對特定的一組用例進行優化, React 也不例外。
React 程序通常會輸出一個會隨時間變化的樹。 它有可能是 DOM 樹 、iOS 視圖層、PDF 原語 ,或者是 JSON 對象 。不過通常我們希望用它來展示 UI 。我們稱它為“宿主樹”,因為它往往是 React 之外宿主環境中的一部分 —— 就像 DOM 或 iOS 。宿主樹通常有它自己的命令式 API 。而 React 就是它上面的那一層。
所以 React 到底有什么用呢?非常抽象,它可以幫助你編寫可預測的,并且能夠操控復雜的宿主樹進而響應像用戶交互、網絡響應、定時器等外部事件的應用程序。
當一個專業的工具可以施加特定的約束,并且能從中獲益時,它就比一般的工具要好。React 就是這樣的典范,并且它堅持兩個原則:
穩定性。 宿主樹是相對穩定的,大多數情況的更新并不會從根本上改變其整體結構。如果應用程序每秒都會將其所有可交互的元素重新排列為完全不同的組合,那將會變得難以使用。那個按鈕去哪了?為什么我的屏幕在跳舞?
通用性。 宿主樹可以被拆分為外觀和行為一致的 UI 模式(例如按鈕、列表和頭像)而不是隨機的形狀。
這些原則恰好適用于大多數 UI 。 不過當輸出沒有穩定的“模式”時 React 并不適用。例如,React 也許可以幫你寫一個 Twitter 客戶端,但對于一個 3D 管道屏幕保護程序并沒有太大用處。
宿主實例
宿主樹由節點組成,我們稱之為“宿主實例”。
在 DOM 環境中,宿主實例就是我們通常所說的 DOM 節點 —— 就像當你調用 document.createElement('div') 時獲得的對象。在 iOS 中,宿主實例可以是從 JavaScript 到原生視圖唯一標識的值。
宿主實例有它們自己的屬性(例如 domNode.className 或者 view.tintColor )。它們也有可能將其他的宿主實例作為子項。
(這和 React 沒有任何聯系 — 因為我在講述宿主環境。)
通常會有原生 API 用于操控這些宿主實例。例如,在 DOM 環境中會提供像 appendChild、 removeChild、 setAttribute 等一系列的 API 。在 React 應用中,通常你不會調用這些 API ,因為那是 React 的工作。
渲染器
渲染器告訴 React 如何與特定的宿主環境通信,以及如何管理它的宿主實例。React DOM、React Native 甚至Ink【https://mobile.twitter.com/vadimdemedes/status/1089344289102942211】都可以被稱作 React 渲染器。你也可以創建自己的 React 渲染器【https://github.com/facebook/react/tree/master/packages/react-reconciler】。
React 渲染器能以下面兩種模式之一進行工作。
絕大多數渲染器都被用作“突變”模式。這種模式正是 DOM 的工作方式:我們可以創建一個節點,設置它的屬性,在之后往里面增加或者刪除子節點。宿主實例是完全可變的。
但 React 也能以”不變“模式工作。這種模式適用于那些并不提供像 appendChild 的 API 而是克隆雙親樹并始終替換掉頂級子樹的宿主環境。在宿主樹級別上的不可變性使得多線程變得更加容易。React Fabric 【https://facebook.github.io/react-native/blog/2018/06/14/state-of-react-native-2018】就利用了這一模式。
作為 React 的使用者,你永遠不需要考慮這些模式。我只想強調 React 不僅僅只是從一種模式轉換到另一種模式的適配器。它的用處在于以一種更好的方式操控宿主實例而不用在意那些低級視圖 API 范例。
React 元素
在宿主環境中,一個宿主實例(例如 DOM 節點)是最小的構建單元。而在 React 中,最小的構建單元是 React 元素。
React 元素是一個普通的 JavaScript 對象。它用來描述一個宿主實例。
React 元素是輕量級的,因為沒有任何宿主實例與它綁定在一起。同樣,它只是對你想要在屏幕上看到的內容的描述。
就像宿主實例一樣,React 元素也能形成一棵樹:
(注意:我省略了一些對此解釋不重要的屬性【https://overreacted.io/why-do-react-elements-have-typeof-property/】)
但是請記住 React 元素并不是永遠存在的 。它們總是在重建和刪除之間不斷循環。
React 元素具有不可變性。例如你不能改變 React 元素中的子元素或者屬性。如果你想要在稍后渲染一些不同的東西,需要從頭創建新的 React 元素樹來描述它。
我喜歡將 React 元素比作電影中放映的每一幀。它們捕捉 UI 在特定的時間點的樣子。它們永遠不會再改變。
入口
每一個 React 渲染器都有一個“入口”。正是那個特定的 API 讓我們告訴 React ,將特定的 React 元素樹渲染到真正的宿主實例中去。
例如,React DOM 的入口就是 ReactDOM.render :
當我們調用 ReactDOM.render(reactElement, domContainer) 時,我們的意思是:“親愛的 React ,將我的 reactElement 映射到 domContaienr 的宿主樹上去吧。“
React 會查看 reactElement.type (在我們的例子中是 button )然后告訴 React DOM 渲染器創建對應的宿主實例并設置正確的屬性:
在我們的例子中,React 會這樣做:
如果 React 元素在 reactElement.props.children 中含有子元素,React 會在第一次渲染中遞歸地為它們創建宿主實例。
協調
如果我們用同一個 container 調用 ReactDOM.render() 兩次會發生什么呢?
同樣,React 的工作是將 React 元素樹映射到宿主樹上去。確定該對宿主實例做什么來響應新的信息有時候叫做協調 。
有兩種方法可以解決它。簡化版的 React 會丟棄已經存在的樹然后從頭開始創建它:
但是在 DOM 環境下,這樣的做法效率很低,而且會丟失 focus、selection、scroll 等許多狀態。相反,我們希望 React 這樣做:
換句話說,React 需要決定何時更新一個已有的宿主實例來匹配新的 React 元素,何時該重新創建新的宿主實例。
這就引出了一個識別問題。React 元素可能每次都不相同,到底什么時候才該從概念上引用同一個宿主實例呢?
在我們的例子中,它很簡單。我們之前渲染了 <button> 作為第一個(也是唯一)的子元素,接下來我們想要在同一個地方再次渲染 <button> 。在宿主實例中我們已經有了一個 <button> 為什么還要重新創建呢?讓我們重用它。
這與 React 如何思考并解決這類問題已經很接近了。
如果相同的元素類型在同一個地方先后出現兩次,React 會重用已有的宿主實例。
這里有一個例子,其中的注釋大致解釋了 React 是如何工作的:
同樣的啟發式方法也適用于子樹。例如,當我們在 <dialog> 中新增兩個 <button> ,React 會先決定是否要重用 <dialog> ,然后為每一個子元素重復這個決定步驟。
條件
如果 React 在渲染更新前后只重用那些元素類型匹配的宿主實例,那當遇到包含條件語句的內容時又該如何渲染呢?
假設我們只想首先展示一個輸入框,但之后要在它之前渲染一條信息:
在這個例子中,<input> 宿主實例會被重新創建。React 會遍歷整個元素樹,并將其與先前的版本進行比較:
dialog → dialog :能重用宿主實例嗎?能 — 因為類型是匹配的。
input → p :能重用宿主實例嗎?不能,類型改變了! 需要刪除已有的 input 然后重新創建一個 p 宿主實例。
(nothing) → input :需要重新創建一個 input 宿主實例。
因此,React 會像這樣執行更新:
這樣的做法并不科學因為事實上 <input> 并沒有被 <p> 所替代 — 它只是移動了位置而已。我們不希望因為重建 DOM 而丟失了 selection、focus 等狀態以及其中的內容。
雖然這個問題很容易解決(在下面我會馬上講到),但這個問題在 React 應用中并不常見。而當我們探討為什么會這樣時卻很有意思。
事實上,你很少會直接調用 ReactDOM.render 。相反,在 React 應用中程序往往會被拆分成這樣的函數:
這個例子并不會遇到剛剛我們所描述的問題。讓我們用對象注釋而不是 JSX 也許可以更好地理解其中的原因。來看一下 dialog 中的子元素樹:
不管 showMessage 是 true 還是 false ,在渲染的過程中 <input> 總是在第二個孩子的位置且不會改變。
如果 showMessage 從 false 改變為 true ,React 會遍歷整個元素樹,并與之前的版本進行比較:
dialog → dialog :能夠重用宿主實例嗎?能 — 因為類型匹配。
(null) → p :需要插入一個新的 p 宿主實例。
input → input :能夠重用宿主實例嗎?能 — 因為類型匹配。
之后 React 大致會像這樣執行代碼:
這樣一來輸入框中的狀態就不會丟失了。
列表
比較樹中同一位置的元素類型對于是否該重用還是重建相應的宿主實例往往已經足夠。
但這只適用于當子元素是靜止的并且不會重排序的情況。在上面的例子中,即使 message 不存在,我們仍然知道輸入框在消息之后,并且再沒有其他的子元素。
而當遇到動態列表時,我們不能確定其中的順序總是一成不變的。
如果我們的商品列表被重新排序了,React 只會看到所有的 p 以及里面的 input 擁有相同的類型,并不知道該如何移動它們。(在 React 看來,雖然這些商品本身改變了,但是它們的順序并沒有改變。)
所以 React 會對這十個商品進行類似如下的重排序:
React 只會對其中的每個元素進行更新而不是將其重新排序。這樣做會造成性能上的問題和潛在的 bug 。例如,當商品列表的順序改變時,原本在第一個輸入框的內容仍然會存在于現在的第一個輸入框中 — 盡管事實上在商品列表里它應該代表著其他的商品!
這就是為什么每次當輸出中包含元素數組時,React 都會讓你指定一個叫做 key 的屬性:
key 給予 React 判斷子元素是否真正相同的能力,即使在渲染前后它在父元素中的位置不是相同的。
當 React 在 <form> 中發現 <p key="42"> ,它就會檢查之前版本中的 <form> 是否同樣含有 <p key="42"> 。即使 <form> 中的子元素們改變位置后,這個方法同樣有效。在渲染前后當 key 仍然相同時,React 會重用先前的宿主實例,然后重新排序其兄弟元素。
需要注意的是 key 只與特定的父親 React 元素相關聯,比如 <form> 。React 并不會去匹配父元素不同但 key 相同的子元素。(React 并沒有慣用的支持對在不重新創建元素的情況下讓宿主實例在不同的父元素之間移動。)
給 key 賦予什么值最好呢?最好的答案就是:什么時候你會說一個元素不會改變即使它在父元素中的順序被改變? 例如,在我們的商品列表中,商品本身的 ID 是區別于其他商品的唯一標識,那么它就最適合作為 key 。
組件
我們已經知道函數會返回 React 元素:
這些函數被叫做組件。它們讓我們可以打造自己的“工具箱”,例如按鈕、頭像、評論框等等。組件就像 React 的面包和黃油。
組件接受一個參數 — 對象哈希。它包含“props”(“屬性”的簡稱)。在這里 showMessage 就是一個 prop 。它們就像是具名參數一樣。
純凈
React 組件中對于 props 應該是純凈的。
通常來說,突變在 React 中不是慣用的。(我們會在之后講解如何用更慣用的方式來更新 UI 以響應事件。)
不過,局部的突變是絕對允許的:
當我們在函數組件內部創建 items 時不管怎樣改變它都行,只要這些突變發生在將其作為最后的渲染結果之前。所以并不需要重寫你的代碼來避免局部突變。
同樣地,惰性初始化是被允許的即使它不是完全“純凈”的:
只要調用組件多次是安全的,并且不會影響其他組件的渲染,React 并不關心你的代碼是否像嚴格的函數式編程一樣百分百純凈。在 React 中,冪等性【https://stackoverflow.com/questions/1077412/what-is-an-idempotent-operation】比純凈性更加重要。
也就是說,在 React 組件中不允許有用戶可以直接看到的副作用。換句話說,僅調用函數式組件時不應該在屏幕上產生任何變化。
遞歸
我們該如何在組件中使用組件?組件屬于函數因此我們可以直接進行調用:
然而,在 React 運行時中這并不是慣用的使用組件的方式。
相反,使用組件慣用的方式與我們已經了解的機制相同 — 即 React 元素。這意味著不需要你直接調用組件函數,React 會在之后為你做這件事情:
然后在 React 內部,你的組件會這樣被調用:
組件函數名稱按照規定需要大寫。當 JSX 轉換時看見 <Form> 而不是 <form> ,它讓對象 type 本身成為標識符而不是字符串:
我們并沒有全局的注冊機制 — 字面上當我們輸入 <Form> 時代表著 Form 。如果 Form在局部作用域中并不存在,你會發現一個 JavaScript 錯誤,就像平常你使用錯誤的變量名稱一樣。
因此,當元素類型是一個函數的時候 React 會做什么呢?它會調用你的組件,然后詢問組件想要渲染什么元素。
這個步驟會遞歸式地執行下去,更詳細的描述在這里 。總的來說,它會像這樣執行:
你: ReactDOM.render(<App />, domContainer)
React: App ,你想要渲染什么?
App :我要渲染包含 <Content> 的 <Layout> 。
React: <Layout> ,你要渲染什么?
Layout :我要在 <div> 中渲染我的子元素。我的子元素是 <Content> 所以我猜它應該渲染到 <div> 中去。
React: <Content> ,你要渲染什么?
<Content> :我要在 <article> 中渲染一些文本和 <Footer> 。
React: <Footer> ,你要渲染什么?
<Footer> :我要渲染含有文本的 <footer> 。
React: 好的,讓我們開始吧:
這就是為什么我們說協調是遞歸式的。當 React 遍歷整個元素樹時,可能會遇到元素的 type 是一個組件。React 會調用它然后繼續沿著返回的 React 元素下行。最終我們會調用完所有的組件,然后 React 就會知道該如何改變宿主樹。
在之前已經討論過的相同的協調準則,在這一樣適用。如果在同一位置的 type 改變了(由索引和可選的 key 決定),React 會刪除其中的宿主實例并將其重建。
控制反轉
你也許會好奇:為什么我們不直接調用組件?為什么要編寫 <Form /> 而不是 Form()?
React 能夠做的更好如果它“知曉”你的組件而不是在你遞歸調用它們之后生成的 React 元素樹。
這是一個關于控制反轉【https://en.wikipedia.org/wiki/Inversion_of_control】的經典案例。通過讓 React 調用我們的組件,我們會獲得一些有趣的屬性:
組件不僅僅只是函數。 React 能夠用在樹中與組件本身緊密相連的局部狀態等特性來增強組件功能。優秀的運行時提供了與當前問題相匹配的基本抽象。就像我們已經提到過的,React 專門針對于那些渲染 UI 樹并且能夠響應交互的應用。如果你直接調用了組件,你就只能自己來構建這些特性了。
組件類型參與協調。 通過 React 來調用你的組件,能讓它了解更多關于元素樹的結構。例如,當你從渲染 <Feed> 頁面轉到 Profile 頁面,React 不會嘗試重用其中的宿主實例 — 就像你用 <p> 替換掉 <button> 一樣。所有的狀態都會丟失 — 對于渲染完全不同的視圖時,通常來說這是一件好事。你不會想要在 <PasswordForm> 和<MessengerChat> 之間保留輸入框的狀態盡管 <input> 的位置意外地“排列”在它們之間。
React 能夠推遲協調。如果讓 React 控制調用你的組件,它能做很多有趣的事情。例如,它可以讓瀏覽器在組件調用之間做一些工作,這樣重渲染大體量的組件樹時就不會阻塞主線程【https://reactjs.org/blog/2018/03/01/sneak-peek-beyond-react-16.html】。想要手動編排這個過程而不依賴 React 的話將會十分困難。
更好的可調試性。如果組件是庫中所重視的一等公民,我們就可以構建豐富的開發者工具【https://github.com/facebook/react-devtools】,用于開發中的自省。
讓 React 調用你的組件函數還有最后一個好處就是惰性求值。讓我們看看它是什么意思。
惰性求值
當我們在 JavaScript 中調用函數時,參數往往在函數調用之前被執行。
這通常是 JavaScript 開發者所期望的因為 JavaScript 函數可能有隱含的副作用。如果我們調用了一個函數,但直到它的結果不知怎地被“使用”后該函數仍沒有執行,這會讓我們感到十分詫異。
但是,React 組件是相對純凈的。如果我們知道它的結果不會在屏幕上出現,則完全沒有必要執行它。
考慮下面這個含有 <Comments> 的 <Page> 組件:
<Page> 組件能夠在 <Layout> 中渲染傳遞給它的子項:
(在 JSX 中 <A><B /></A> 和 <A children={<B />} /> 相同。)
但是要是存在提前返回的情況呢?
如果我們像函數一樣調用 Commonts() ,不管 Page 是否想渲染它們都會被立即執行:
但是如果我們傳遞的是一個 React 元素,我們不需要自己執行 Comments :
讓 React 來決定何時以及是否調用組件。如果我們的的 Page 組件忽略自身的 childrenprop 且相反地渲染了 <h1>Please login</h1> ,React 不會嘗試去調用 Comments 函數。重點是什么?
這很好,因為它既可以讓我們避免不必要的渲染也能使我們的代碼變得不那么脆弱。(當用戶退出登錄時,我們并不在乎 Comments 是否被丟棄 — 因為它從沒有被調用過。)
狀態
我們先前提到過關于協調和在樹中元素概念上的“位置”是如何讓 React 知曉是該重用宿主實例還是該重建它。宿主實例能夠擁有所有相關的局部狀態:focus、selection、input 等等。我們想要在渲染更新概念上相同的 UI 時保留這些狀態。我們也想可預測性地摧毀它們,當我們在概念上渲染的是完全不同的東西時(例如從 <SignupForm> 轉換到 <MessengerChat>)。
局部狀態是如此有用,以至于 React 讓你的組件也能擁有它。 組件仍然是函數但是 React 用對構建 UI 有好處的許多特性增強了它。在樹中每個組件所綁定的局部狀態就是這些特性之一。
我們把這些特性叫做 Hooks 。例如,useState 就是一個 Hook 。
它返回一對值:當前的狀態和更新該狀態的函數。
數組的解構語法【https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Destructuring_assignment#Array_destructuring】讓我們可以給狀態變量自定義名稱。例如,我在這里稱它們為count和setCount,但是它們也可以被稱作banana和setBanana。在這些文字之下,我們會用setState來替代第二個值無論它在具體的例子中被稱作什么。
(你能在 React 文檔 中學習到更多關于 useState 和 其他 Hooks 的知識。)
一致性
即使我們想將協調過程本身分割成非阻塞【https://www.youtube.com/watch?v=mDdgfyRB5kg】的工作塊,我們仍然需要在同步的循環中對真實的宿主實例進行操作。這樣我們才能保證用戶不會看見半更新狀態的 UI ,瀏覽器也不會對用戶不應看到的中間狀態進行不必要的布局和樣式的重新計算。
這也是為什么 React 將所有的工作分成了”渲染階段“和”提交階段“的原因。渲染階段是當 React 調用你的組件然后進行協調的時段。在此階段進行干涉是安全的且在未來【https://reactjs.org/blog/2018/03/01/sneak-peek-beyond-react-16.html】這個階段將會變成異步的。提交階段就是 React 操作宿主樹的時候。而這個階段永遠是同步的。
緩存
當父組件通過 setState 準備更新時,React 默認會協調整個子樹。因為 React 并不知道在父組件中的更新是否會影響到其子代,所以 React 默認保持一致性。這聽起來會有很大的性能消耗但事實上對于小型和中型的子樹來說,這并不是問題。
當樹的深度和廣度達到一定程度時,你可以讓 React 去緩存【https://en.wikipedia.org/wiki/Memoization】子樹并且重用先前的渲染結果當 prop 在淺比較之后是相同時:
現在,在父組件 <Table> 中調用 setState 時如果 <Row> 中的 item 與先前渲染的結果是相同的,React 就會直接跳過協調的過程。
你可以通過 useMemo() Hook 【https://reactjs.org/docs/hooks-reference.html#usememo】獲得單個表達式級別的細粒度緩存。該緩存于其相關的組件緊密聯系在一起,并且將與局部狀態一起被銷毀。它只會保留最后一次計算的結果。
默認情況下,React 不會故意緩存組件。許多組件在更新的過程中總是會接收到不同的 props ,所以對它們進行緩存只會造成凈虧損。
原始模型
令人諷刺地是,React 并沒有使用“反應式”的系統來支持細粒度的更新。換句話說,任何在頂層的更新只會觸發協調而不是局部更新那些受影響的組件。
這樣的設計是有意而為之的。對于 web 應用來說交互時間【https://calibreapp.com/blog/time-to-interactive/】是一個關鍵指標,而通過遍歷整個模型去設置細粒度的監聽器只會浪費寶貴的時間。此外,在很多應用中交互往往會導致或小(按鈕懸停)或大(頁面轉換)的更新,因此細粒度的訂閱只會浪費內存資源。
React 的設計原則之一就是它可以處理原始數據。如果你擁有從網絡請求中獲得的一組 JavaScript 對象,你可以將其直接交給組件而無需進行預處理。沒有關于可以訪問哪些屬性的問題,或者當結構有所變化時造成的意外的性能缺損。React 渲染是 O(視圖大小) 而不是 O(模型大小) ,并且你可以通過windowing【https://react-window.now.sh/#/examples/list/fixed-size】顯著地減少視圖大小。
有那么一些應用細粒度訂閱對它們來說是有用的 — 例如股票代碼。這是一個極少見的例子,因為“所有的東西都需要在同一時間內持續更新”。雖然命令式的方法能夠優化此類代碼,但 React 并不適用于這種情況。同樣的,如果你想要解決該問題,你就得在 React 之上自己實現細粒度的訂閱。
注意,即使細粒度訂閱和“反應式”系統也無法解決一些常見的性能問題。例如,渲染一棵很深的樹(在每次頁面轉換的時候發生)而不阻塞瀏覽器。改變跟蹤并不會讓它變得更快 — 這樣只會讓其變得更慢因為我們執行了額外的訂閱工作。另一個問題是我們需要等待返回的數據在渲染視圖之前。在 React 中,我們用并發渲染【https://reactjs.org/blog/2018/03/01/sneak-peek-beyond-react-16.html】來解決這些問題。
批量更新
一些組件也許想要更新狀態來響應同一事件。下面這個例子是假設的,但是卻說明了一個常見的模式:
當事件被觸發時,子組件的 onClick 首先被觸發(同時觸發了它的 setState )。然后父組件在它自己的 onClick 中調用 setState 。
如果 React 立即重渲染組件以響應 setState 調用,最終我們會重渲染子組件兩次:
***進入React瀏覽器click事件處理過程***
Child(onClick)
-setState
-re-renderChild// ?? 不必要的重渲染Parent (onClick)
-setState
-re-renderParent
-re-renderChild***結束React瀏覽器click事件處理過程***
第一次 Child 組件渲染是浪費的。并且我們也不會讓 React 跳過 Child 的第二次渲染因為 Parent 可能會傳遞不同的數據由于其自身的狀態更新。
這就是為什么 React 會在組件內所有事件觸發完成后再進行批量更新的原因:
***進入React瀏覽器click事件處理過程***
Child(onClick)
-setState
Parent(onClick)
-setState
***Processingstateupdates***
-re-renderParent
-re-renderChild
***結束React瀏覽器click事件處理過程***
組件內調用 setState 并不會立即執行重渲染。相反,React 會先觸發所有的事件處理器,然后再觸發一次重渲染以進行所謂的批量更新。
批量更新雖然有用但可能會讓你感到驚訝如果你的代碼這樣寫:
如果我們將 count 初始值設為 0 ,上面的代碼只會代表三次 setCount(1) 調用。為了解決這個問題,我們給 setState 提供了一個 “updater” 函數作為參數:
、
React 會將 updater 函數放入隊列中,并在之后按順序執行它們,最終 count 會被設置成 3 并作為一次重渲染的結果。
當狀態邏輯變得更加復雜而不僅僅只是少數的 setState 調用時,我建議你使用 useReducer Hook 【https://reactjs.org/docs/hooks-reference.html#usereducer】來描述你的局部狀態。它就像 “updater” 的升級模式在這里你可以給每一次更新命名:
action 字段可以是任意值,盡管對象是常用的選擇。
調用樹
編程語言的運行時往往有調用棧【https://medium.freecodecamp.org/understanding-the-javascript-call-stack-861e41ae61d4】。當函數a()調用b(),b()又調用c()時,在 JavaScript 引擎中會有像[a, b, c]這樣的數據結構來“跟蹤”當前的位置以及接下來要執行的代碼。一旦c函數執行完畢,它的調用棧幀就消失了!因為它不再被需要了。我們返回到函數b中。當我們結束函數a的執行時,調用棧就被清空。
當然,React 以 JavaScript 運行當然也遵循 JavaScript 的規則。但是我們可以想象在 React 內部有自己的調用棧用來記憶我們當前正在渲染的組件,例如 [App, Page, Layout, Article /* 此刻的位置 */] 。
React 與通常意義上的編程語言進行時不同因為它針對于渲染 UI 樹,這些樹需要保持“活性”,這樣才能使我們與其進行交互。在第一次 ReactDOM.render() 出現之前,DOM 操作并不會執行。
這也許是對隱喻的延伸,但我喜歡把 React 組件當作 “調用樹” 而不是 “調用棧” 。當我們調用完 Article 組件,它的 React “調用樹” 幀并沒有被摧毀。我們需要將局部狀態保存以便映射到宿主實例的某個地方。
這些“調用樹”幀會隨它們的局部狀態和宿主實例一起被摧毀,但是只會在協調規則認為這是必要的時候執行。如果你曾經讀過 React 源碼,你就會知道這些幀其實就是 Fibers 【https://en.wikipedia.org/wiki/Fiber_(computer_science)】。
Fibers 是局部狀態真正存在的地方。當狀態被更新后,React 將其下面的 Fibers 標記為需要進行協調,之后便會調用這些組件。
上下文
在 React 中,我們將數據作為 props 傳遞給其他組件。有些時候,大多數組件需要相同的東西 — 例如,當前選中的可視主題。將它一層層地傳遞會變得十分麻煩。
在 React 中,我們通過 Context 解決這個問題。它就像組件的動態范圍 ,能讓你從頂層傳遞數據,并讓每個子組件在底部能夠讀取該值,當值變化時還能夠進行重新渲染:
當 SomeDeeplyNestedChild 渲染時, useContext(ThemeContext) 會尋找樹中最近的 <ThemeContext.Provider> ,并且使用它的 value 。
(事實上,React 維護了一個上下文棧當其渲染時。)
如果沒有 ThemeContext.Provider 存在,useContext(ThemeContext) 調用的結果就會被調用 createContext() 時傳遞的默認值所取代。在上面的例子中,這個值為 'light' 。
副作用
我們在之前提到過 React 組件在渲染過程中不應該有可觀察到的副作用。但是有些時候副作用確實必要的。我們也許需要進行管理 focus 狀態、用 canvas 畫圖、訂閱數據源等操作。
在 React 中,這些都可以通過聲明 effect 來完成:
如果可能,React 會推遲執行 effect 直到瀏覽器重新繪制屏幕。這是有好處的因為像訂閱數據源這樣的代碼并不會影響交互時間和首次繪制時間 。
(有一個極少使用的 Hook 能夠讓你選擇退出這種行為并進行一些同步的工作。請盡量避免使用它。)
effect 不只執行一次。當組件第一次展示給用戶以及之后的每次更新時它都會被執行。在 effect 中能觸及當前的 props 和 state,例如上文例子中的 count 。
effect 可能需要被清理,例如訂閱數據源的例子。在訂閱之后將其清理,effect 能夠返回一個函數:
React 會在下次調用該 effect 之前執行這個返回的函數,當然是在組件被摧毀之前。
有些時候,在每次渲染中都重新調用 effect 是不符合實際需要的。 你可以告訴 React 如果相應的變量不會改變則跳過此次調用:
但是,這往往會成為過早地優化并會造成一些問題如果你不熟悉 JavaScript 中的閉包是如何工作的話。
例如,下面的這段代碼是有 bug 的:
它含有 bug 因為 [] 代表著“不再重新執行這個 effect 。”但是這個 effect 中的 handleChange 是被定義在外面的。handleChange 也許會引用任何的 props 或 state :
如果我們不再讓這個 effect 重新調用,handleChange 始終會是第一次渲染時的版本,而其中的 count 也永遠只會是 0 。
為了解決這個問題,請保證你聲明了特定的依賴數組,它包含所有可以改變的東西,即使是函數也不例外:
取決于你的代碼,在每次渲染后 handleChange 都會不同因此你可能仍然會看到不必要的重訂閱。 useCallback 能夠幫你解決這個問題。或者,你可以直接讓它重訂閱。例如瀏覽器中的 addEventListener API 非常快,但為了在組件中避免使用它可能會帶來更多的問題而不是其真正的價值。
(你能在 React 文檔 中學到更多關于 useEffect 和其他 Hooks 的知識。)
自定義鉤子
由于 useState 和 useEffect 是函數調用,因此我們可以將其組合成自己的 Hooks :
自定義 Hooks 讓不同的組件共享可重用的狀態邏輯。注意狀態本身是不共享的。每次調用 Hook 都只聲明了其自身的獨立狀態。
(你能在 React 文檔 中學習更多關于構建自己的 Hooks 的內容。)
靜態使用順序
你可以把 useState 想象成一個可以定義“React 狀態變量”的語法。它并不是真正的語法,當然,我們仍在用 JavaScript 編寫應用。但是我們將 React 作為一個運行時環境來看待,因為 React 用 JavaScript 來描繪整個 UI 樹,它的特性往往更接近于語言層面。
假設 use 是語法,將其使用在組件函數頂層也就說得通了:
當它被放在條件語句中或者組件外時又代表什么呢?
React 狀態和在樹中與其相關的組件緊密聯系在一起。如果 use 是真正的語法當它在組件函數的頂層調用時也能說的通:
這和 import 聲明只在模塊頂層有用是一樣的道理。
當然,use 并不是真正的語法。 (它不會帶來很多好處,并且會帶來很多麻煩。)
然而,React 的確期望所有的 Hooks 調用只發生在組件的頂部并且不在條件語句中。這些 Hooks 的規則能夠被 linter plugin 所規范。有很多關于這種設計選擇的激烈爭論,但在實踐中我并沒有看到它讓人困惑。我還寫了關于為什么通常提出的替代方案不起作用的文章。
Hooks 的內部實現其實是鏈表 。當你調用 useState 的時候,我們將指針移到下一項。當我們退出組件的“調用樹”幀時,會緩存該結果的列表直到下次渲染開始。
這篇文章簡要介紹了 Hooks 內部是如何工作的。數組也許是比鏈表更好解釋其原理的模型:
(如果你對它感興趣,完整的代碼在這里【https://github.com/facebook/react/blob/master/packages/react-reconciler/src/ReactFiberHooks.js】。)
這大致就是每個 useState() 如何獲得正確狀態的方式。就像我們之前所知道的,“匹配”對 React 來說并不是什么新的知識 — 這與協調依賴于在渲染前后元素是否匹配是同樣的道理。
還有哪些遺漏
我們已經觸及到 React 運行時環境中幾乎所有重要的方面。如果你讀完了這篇文章,可能已經比 90% 的開發者更了解 React ,沒錯!
當然有一些內容我并沒有提到——主要是因為我們也不太清楚。目前 React 對多道渲染的支持并不太好,即當父組件進行渲染時需要子組件提供的信息。錯誤處理 API 【https://reactjs.org/docs/error-boundaries.html】目前也還沒有關于 Hooks 的內容。將來這兩個問題可能會一起解決。并發模式在目前看來并不穩定,也有很多關于 Suspense 該如何適應當前版本的有趣問題。也許我會在它們要完成的時候再來討論,并且 Suspense 已經準備好比 懶加載 能夠做的更多。
我認為 React API 的成功之處在于,即使在沒有考慮過上面這些大多數主題的情況下,你也能輕松使用它并且可以走的很遠。 在大多數情況下,像協調這樣好的默認特性啟發式地為我們做了正確的事情。在你忘記添加 key 這樣的屬性時,React 能夠好心提醒你。
如果你是一個癡迷于 UI 庫的書呆子,我希望這篇文章對你來說會很有趣的,并且深入闡明了 React 的工作原理。也許你會覺得 React 太過復雜,所以你不會再去深入理解它。不管怎樣,我都很樂意在 Twitter 上聽到你的聲音!感謝你的閱讀。