把 React 作爲 UI 運行時來使用 [每日前端夜話0x22]
京程一燈 前端先鋒
每日前端夜話0x22
每日前端夜話,陪你聊前端。
每天晚上18:00準時推送。
正文共:15247 字
預計閱讀時間: 38 分
翻譯:瘋狂的技術宅
原文:https://overreacted.io/react-as-a-ui-runtime/
很多教程都把 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//