本文將探究 React API 的演變及其背后的心智模型。從 mixins 到 hooks,再到 RSCs,了解整個過程中的權衡。我們將對 React 的過去、現在和未來有一個更清晰的了解,便于深入研究遺留代碼庫并評估其他技術如何采用不同的方法并做出不同的權衡。
React API 簡史
我們從面向對象的設計模式在 JS 生態系統中流行的時候開始,可以在早期的 React API 中看到這種影響。
Mixins
React.createClass API 是創建組件的原始方式。在 JAVAscript 支持原生類語法之前,React 就有自己的類表示。Mixins 是一種用于代碼重用的通用 OOP 模式,下面是一個簡化的例子:
function ShoppingCart() {
this.items = [];
}
var orderMixin = {
calculateTotal() {
// 從 this.items 計算
}
// .. 其他方法
}
Object.assign(ShoppingCart.prototype, orderMixin)
var cart = new ShoppingCart()
cart.calculateTotal()
JavaScript 不支持多重繼承,因此 mixin 是重用共享行為和擴充類的一種方式。那該如何在使用 createClass 創建的組件之間共享邏輯呢?Mixins 是一種常用的模式,它可以訪問組件的生命周期方法,允許我們組合邏輯、狀態等:
var SubscriptionMixin = {
getInitialState: function() {
return {
comments: DataSource.getComments()
};
},
// 當一個組件使用多個 mixin 時,React 會嘗試合并多個mixin的生命周期方法,因此每個都會被調用
componentDidMount: function() {
console.log('do something on mount')
},
componentWillUnmount: function() {
console.log('do something on unmount')
},
}
// 將對象傳遞給 createClass
var CommentList = React.createClass({
// 在 mixins 屬性下定義它們
mixins: [SubscriptionMixin, AnotherMixin, SomeOtherMixin],
render: function() {
var { comments, ...otherStuff } = this.state
return (
<div>
{comments.map(function(comment) {
return <Comment key={comment.id} comment={comment} />
})}
</div>
)
}
})
對于較小的應用,這種方式可以正常運行。但是,當 Mixin 應用到大型項目時,它們也有一些缺點:
- 名稱沖突:Mixin 具有共享的命名空間,當多個 Mixin 使用同一個方法或狀態的名稱時,會發生沖突。
- 隱式依賴關系:確定哪個 Mixin 提供了哪些功能或狀態比較麻煩。它們使用共享的屬性鍵相互交互,從而創建隱式耦合。
- 難以理解和調試:Mixin 通常使組件更難以理解和調試。例如,上面多個 Mixin 都可以對 getInitialState 結果有影響,使得跟蹤問題變得更加困難。
在感受到這些問題的痛苦之后,React 團隊發布了“Mixins Considered Harmful”,不鼓勵繼續使用這種模式。
高階組件
當 Javascript 中支持了原生類語法后,React 團隊就在 v15.5 中棄用了 createClass API,支持原生類。
在這個轉變過程中,我們仍然按照類和生命周期的思路來思考,因此沒有進行重大的心智模型轉變。現在可以擴展包含生命周期方法的 Reacts Component 類:
class MyComponent extends React.Component {
constructor(props) {
// 在組件掛載到 DOM 之前運行
// super 指的是父 Component 的構造函數
super(props)
}
componentWillMount() {}
componentDidMount(){}
componentWillUnmount() {}
componentWillUpdate() {}
shouldComponentUpdate() {}
componentWillReceiveProps() {}
getSnapshotBeforeUpdate() {}
componentDidUpdate() {}
render() {}
}
考慮到 mixin 的缺陷,我們該如何以這種編寫 React 組件的新方式來共享邏輯和副作用呢?
這時候,高階組件 (HOC) 就出現了,它的名字來源于高階函數的函數式編程概念。它成為了替代 Mixin 的一種流行方式,并出現在像 Redux 這樣的庫的 API 中,例如它的 connect 函數,用于將組件連接到 Redux 存儲。除此之外,還有 React Router 的 withRouter。
// 一個創建增強組件的函數,有一些額外的狀態、行為或 props
const EnhancedComponent = myHoc(MyComponent);
// HOC 的簡化示例
function myHoc(Component) {
return class extends React.Component {
componentDidMount() {
console.log('do stuff')
}
render() {
// 使用一些注入的 props 渲染原始組件
return <Component {...this.props} extraProps={42} />
}
}
}
高階組件對于在多個組件之間共享通用行為非常有用。它們使包裝的組件保持解耦和通用性,以便可以重用。然而,HOC 遇到了與 mixin 類似的問題:
- 名稱沖突:因為 HOC 需要轉發和傳播 ...this.props 到包裝的組件中,所以嵌套的 HOC 相互覆蓋可能會發生沖突。
- 難以靜態類型檢查:當多個嵌套的 HOC 將新的 props 注入到包裝的組件中時,正確的 props 輸入很難保證。
- 數據流模糊:對于 mixins,問題是“這個狀態從哪里來?”;對于 HOC,問題是“這些 props 從哪里來?”。因為它們是在模塊級別靜態組合的,所以很難跟蹤數據流。
除了這些陷阱之外,過度使用 HOC 還導致了深度嵌套和復雜的組件層次結構以及難以調試的性能問題。
Render props
render prop 模式作為 HOC 的替代品出現,這種模式由開源 API 如 React-Motion 和 downshift 以及構建 React Router 的開發人員推廣普及。
<Motion style={{ x: 10 }}>
{interpolatingStyle => <div style={interpolatingStyle} />}
</Motion>
主要思想就是將一個函數作為 props 傳遞給組件。然后組件會在內部調用該函數,并傳遞數據和方法,將控制反轉回函數以繼續渲染它們想要的內容。
與 HOC 不同,組合發生在 JSX 內部的運行時,而不是靜態模塊范圍內。它們沒有名稱沖突,因為很明確知道是從哪里來的,也更容易進行靜態類型檢查。
但是,當用作數據提供者時,它們可能會導致深度嵌套,創建一個虛假的組件層次結構:
<UserProvider>
{user => (
<UserPreferences user={user}>
{userPreferences => (
<Project user={user}>
{project => (
<IssueTracker project={project}>
{issues => (
<Notification user={user}>
{notifications => (
<TimeTracker user={user}>
{timeData => (
<TeamMembers project={project}>
{teamMembers => (
<RenderThangs renderItem={item => (
// ...
)}/>
)}
</TeamMembers>
)}
</TimeTracker>
)}
</Notification>
)}
</IssueTracker>
)}
</Project>
)}
</UserPreferences>
)}
</UserProvider>
這時,通常會將管理狀態的組件與渲染 UI 的組件分開來處理。隨著 Hooks 的出現,“容器”和“展示性”組件模式已經不再流行。但值得一提的是,這種模式在服務逇組件中有所復興。
目前,render props 仍然是創建可組合組件 API 的有效模式。
Hooks
Hooks 在 React 16.8 版本中成為了官方的重用邏輯的方式,鞏固了將函數組件作為編寫組件的推薦方式。
Hooks 讓在組件中重用和組合邏輯變得更加簡單明了。相比于類組件,在其中封裝并共享邏輯會更加棘手,因為它們可能分散在各種生命周期方法中的不同部分。
深度嵌套的結構可以被簡化和扁平化。搭配 TypeScript,Hook 也很容易進行類型化。
function Example() {
const user = useUser();
const userPreferences = useUserPreferences(user);
const project = useProject(user);
const issues = useIssueTracker(project);
const notifications = useNotification(user);
const timeData = useTimeTracker(user);
const teamMembers = useTeamMembers(project);
return (
<div>
{/* 渲染內容 */}
</div>
);
}
權衡利弊
使用 Hooks 帶來了很多好處,它們解決了類中的一些問題,但也需要付出一定的代價,下面來深入了解一下。
類 vs 函數
從組件消費者的角度來看,類組件到函數組件的轉變并沒有改變渲染 JSX 的方式。不過兩種方式的思想是不同的:
- 類與有狀態類的面向對象編程有著緊密聯系。
- 函數則與函數式編程以及純函數等概念有關聯。
React 中的組件概念,以及使用 JavaScript 實現它的方式,以及我們試圖使用現有術語來解釋它,都增加了學習 React 的開發人員建立準確思維模型的困難度。對理解的漏洞會導致代碼出現 bug。在這個過渡階段中,一些常見的問題包括設置狀態或獲取數據時的無限循環,以及讀取過時的 props 和 state。指令式響應事件和生命周期常常引入了不必要的狀態副作用,我們可能并不需要它們。
開發者體驗
在使用類組件時,有一套不同的術語,如 componenDid、componentWill、shouldComponent和將方法綁定到實例中。函數和 Hooks 通過移除外部類簡化了這一點,使我們能夠專注于渲染函數。每次渲染都會重新創建所有內容,因此需要能夠在渲染周期之間保留一些內容。useCallback 和 useMemo 這樣的 API 被引入就方便定義哪些內容應該在重新渲染之間保留下來。
在 Hooks 中需要明確管理依賴數組,再加上 hooks API 的語法復雜,對一些人來說富有挑戰性。對其他人來說,hooks 大大簡化了他們對 React 的思維模型和代碼的理解。
實驗性 React forget 旨在通過預編譯 React 組件來改善開發者體驗,從而消除手動記憶和管理依賴項數組,強調將事情明確化或嘗試在幕后處理事情之間的權衡。
將狀態和邏輯耦合到 React 中
許多狀態管理庫,如 Redux 或 MobX 將 React 應用的狀態和視圖分開處理。這與 React 最初作為MVC 中的“視圖”標語保持一致。隨著時間的推移,從全局的單塊式存儲向更多的位置遷移,特別是使用 render props 的“一切皆為組件”的想法,這也隨著轉向 hooks 得到了鞏固。
React 演進背后的原則
我們可以從這些模式的演變中學到什么呢?哪些啟發式可以指導我們做出有價值的權衡?
API 的用戶體驗
框架和庫必須同時考慮開發者體驗和最終用戶體驗。為開發者體驗而犧牲用戶體驗是一種錯誤的做法,但有時候一個會優先于另一個。
例如,css in JS庫 styled-components,在處理大量動態樣式時使用起來非常棒,但它們可能以最終用戶體驗為代價,我們需要對此進行權衡。
我們可以將 React 18 和 RSC 中的并發特性視為追求更好的最終用戶體驗的創新。這些就意味著更新用來實現組件的 API 和模式。函數的“snapshotting”屬性(閉包)使得編寫在并發模式下正常工作的代碼變得更加容易,服務端的異步函數是表達服務端組件的好方法。
API 優于實現
上面討論的 API 和模式都是從實現組件內部的角度出發的。雖然實現細節已經從 createClass 發展到了 ES6 類,再到有狀態函數。但“組件”這個更高級別的API概念,它可以是有狀態的并具有 effect,已經在整個演進過程中保持了穩定性:
return (
<ImplementedWithMixins>
<ComponentUsingHOCs>
<ThisUsesHooks>
<ServerComponentWoah />
</ThisUsesHooks>
</ComponentUsingHOCs>
</ImplementedWithMixins>
)
專注于正確的原語
在React中,組件模型讓我們可以用聲明式的方式來編寫代碼,并且可以方便地在本地進行處理。這使得代碼更加易于移植,可以更輕松地刪除、移動、復制和粘貼代碼,而不會意外破壞其中的任何隱藏的連接。遵循這個模型的架構和模式可以提供更好的可組合性,通常需要保持局部化,讓組件捕獲相關的關注點,并接受由此帶來的權衡。與這個模型不符的抽象化會使數據流變得模糊,并使跟蹤和調試變得難以理解和處理,從而增加了隱含的耦合。一個例子就是從類到 hooks 的轉換,將分布在多個生命周期事件中的邏輯打包成可組合的函數,可以直接放置在組件中的相應位置。
小結
考慮 React 的一個好方法是將其視為一個庫,它提供了一組可在其上構建的低級原語。React非常靈活,可以按照自己的方式來設計架構,這既是一種福音,也可能帶來一些問題。這也解釋了為什么像 Remix 和 Next 這樣的高級應用框架如此受歡迎,它們會在React基礎之上添加更強烈的設計意圖和抽象化。
React 的擴展心智模型
隨著 React 將其范圍擴展到客戶端之外,它提供了允許開發人員構建全棧應用的原語。在前端編寫后端代碼開辟了一系列新的模式和權衡。與之前的轉變相比,這些轉變更多的是對現有心智模型的擴展,而不是需要忘記之前的范式轉變。
在混合模型中,客戶端和服務端組件都對整體計算架構有所貢獻。在服務端做更多的事情有助于提高 web 體驗,它允許卸載計算密集型任務并避免通過網絡發送臃腫的包。但是,如果我們需要比完整的服務端往返延遲少得多的快速交互,則客戶端驅動的方法會更好。React 就是從該模型的僅客戶端部分演變而來的,但可以想象 React 首先從服務器開始,然后再添加客戶端部分。
了解全棧 React
混合客戶端和服務端需要知道邊界在模塊依賴圖中的位置。這樣就能夠更好地理解代碼在何時、何地以及如何運行。
為此,我們開始看到一種新的React模式,即指令(或類似于“use strict”、“use asm”或React Native中的“worklet”的編譯指示),它們可以改變其后代碼的含義。
理解“use client”
將此代碼放置在導入代碼之前的文件頂部,可以表明以下的代碼是“客戶端代碼”,標志著與僅在服務端上運行的代碼進行區分。其中導入的其他模塊(及其依賴項)被認為是客戶端包,通過網絡傳輸。
使用“use client”組件也可以在服務端運行。例如,作為生成初始 html 或作為靜態網站生成過程的一部分。
“use server”指令
Action 函數是客戶端調用在服務端存在的函數的方式。可以將“use server”放置在服務器組件的 Action 函數頂部,以告訴編譯器應該在服務端保留它。
// 在服務器組件內部
// 允許客戶端引用和調用這個函數
// 不發送給客戶端
// server (RSC) -> client (RPC) -> server (Action)
async function update(formData: FormData) {
'use server'
awAIt db.post.update({
content: formData.get('content'),
})
}
在 Next.js 中,如果一個文件頂部有“use server”,它告訴打包工具所有導出都是服務端 Action 函數,這確保函數不會包含在客戶端捆綁包中。
當后端和前端共享同一個模塊依賴圖時,有可能會意外地發送一堆不想要的客戶端代碼,或者更糟糕的是,意外將敏感數據導入到客戶端捆綁包中。為了確保這種情況不會發生,還有“server-only”包作為標記邊界的一種方式,以確保其后的代碼僅在服務端組件上使用。這些實驗性的指令和模式也正在其他框架中進行探索,超越了 React,并使用類似于server$
的語法來標記這種區別。
全棧組合
在這個轉變中,組件的抽象被提升到一個更高的層次,包括服務端和客戶端元素。這使得可以重用和組合整個全棧功能垂直切片的可能性。
// 可以想象可共享的全棧組件
// 封裝了服務端和客戶端的細節
<Suspense fallback={<LoadingSkelly />}>
<AIPoweredRecommendationThing
apiKey={proccess.env.AI_KEY}
promptContext={getPromptContext(user)}
/>
</Suspense>
這種強大的能力是建立在 React 之上的元框架中使用的高級打包工具、編譯器和路由器的基礎上的,因此付出的代價來自于其底層的復雜性。同時,作為前端開發者,我們需要擴展自己的思維模型,以理解將后端代碼與前端代碼寫在同一個模塊依賴圖中所帶來的影響。
總結
本文探討了很多內容,從mixin到服務端組件,探索了 React 的演變和每種范例的權衡。理解這些變化及其基礎原則是構建一個清晰的 React 思維模型的好方法。準確的思維模型使我們能夠高效地構建,并快速定位錯誤和性能瓶頸。