React API 和代碼重用的演變!

本文將探究 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 應用到大型項目時,它們也有一些缺點:

在感受到這些問題的痛苦之後,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 還導致了深度嵌套和複雜的組件層次結構以及難以調試的性能問題。

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 思維模型的好方法。準確的思維模型使我們能夠高效地構建,並快速定位錯誤和性能瓶頸。

參考:https://frontendmastery.com/posts/the-evolution-of-react-patterns/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/qwRHiGsY4NRbBlnAcb0RVg