React 0.14:揭秘局部組件狀態陷阱

本文於 2015 年 10 月 29 日 發表在 Safari 在線書店的 Content - Highlights and Reviews、javascript、programming、Programming & Development、Tech、Web Development 類目下。

撰稿人 Richard Feldman

Richard Feldman 酷愛函數式編程,專註於突破瀏覽器端 UI 的限制。他就職於 NoRedInk,並且使用 Elm 和 React 打造了該教育系統。他同樣是即將由 Bleeding Edge Press出版的第二版「Developing a React Edge: The Javascript Library for User Interfaces」的作者之一。該書的第二版很快就會出現在 Safari 上。

一直以來,在 JavaScript UI 編程世界中,在多個組件中切分應用的狀態都是很尋常的事情,也就是說每個組件擁有自己的狀態。在過去的架構體系中這也基本是不可避免的,不過 React 和聲明式渲染的出現帶來了一個更加健壯的範式:單一原子狀態

現在越來越多的 JavaScript 庫拋棄了局部組件狀態轉而偏愛單一原子狀態,當然也隨之帶來了很多問題。我們如何控制嵌套組件讓其只能訪問單一原子狀態的特定部分?是否可以在不藉助組件內部狀態的情況下重用組件?我們能不能通過 React 0.14 的無狀態函數式組件獨立完成這樣的一個應用?

或許最重要的是:現在已經有足夠健康的生態來支撐單一原子狀態應用,是否局部組件狀態真的從一個最佳實踐淪落為一個誘人的陷阱了呢?

單一數據源

一個常規的 Web 應用到底需要多少個資料庫來存儲它的狀態?答案幾乎總是『僅需一個』。很多特殊用途的資料庫用於消息隊列和緩存(比如,Redis、Memcache),不過幾乎很少有 Web 應用龐大到需要切分應用的狀態跨資料庫存儲的。

切分資料庫並沒有什麼技術上的障礙,如果我們做了會怎樣呢?假設我們把用戶賬號信息存儲在一個 MySQL 資料庫中,他們的角色和許可權存儲在另一個 MySQL 資料庫,『最近動態』又存儲在一個單獨的 MySQL 資料庫。

這樣做有一個很明顯的好處。我們可以保證每一部分的數據都不會與其他部分混在一起。對每一個進行查詢操作,都很輕量和簡潔,因為不需要在其他的數據中花費時間索引。在某種概念下,這種嚴格的隔離很受推崇。

然而,它的弊端也很明顯。其一,它會引出同步性的問題。比如,在我們刪除一個用戶賬號的同時有人在另一個資料庫中查詢該用戶的『最近動態』會發生什麼?他很可能得到一些很糟糕的數據,除非我們能夠做到原子事務更新。如果這些值都在同一個資料庫中,這可能並沒有什麼,不過當狀態如此分布的時候會更加的困難並且容易出錯。

另外一個弊端是備份變得更難。不僅僅是備份一個單獨的資料庫,在發生故障時直接恢復它。取而代之的是需要備份和恢復很多資料庫。如果我們幾乎恢復了所有的資料庫,而僅有一個沒有恢復又會怎樣呢?那麼應用的狀態將會混亂,並且可能直到它宕機的時間足夠長並且產生了很多糟糕的數據污染了其他的數據時,我們才會意識到。

這些問題來源於我們的數據在業務上是天然地耦合在一起的:用戶賬號、他們的許可權以及他們的『最近動態』,而我們又需要在架構上將它們解耦。你喜歡或不喜歡,那些數據都會依賴於另一個,而將它們分開存儲只會增加串聯它們的難度。

我們在編寫前端代碼的過程中,當每個組件都有自己的局部狀態時,遇到了類似的挑戰。我們都知道『撤銷/恢復』成就了很棒的用戶體驗,就像數據『備份/恢復』一樣,想要在應用狀態在多個數據源中傳遞是很難的而且很容易出錯。也很容易出現組件之間的錯誤傳遞,導致應用狀態的混亂。比如一個眾所周知的案例,當一個消息通知組件顯示『一個未讀消息』時,而實際上消息列表組件認為所有的消息都是已讀狀態。

單一的數據來源完美解決了數據源上的同步問題。伴隨 React 而來的『不可變數據』和『聲明式渲染』從根本上解決了這些問題。越來越多的前端架構正在擁抱使用單一數據來源來表示應用的狀態,並且得到了如後端資料庫一樣的待遇。

來自 Mozilla 的 James Long 已經使用 React 藉助 Dan Abramov 的 Redux 架構在開發 Firefox 開發者工具中擁抱單一原子狀態,實現了按照時序熱載入的特性。David Nolen 為 ClojureScript 而寫的 Om 庫藉助單一原子狀態讓邏輯像『撤銷/恢復』一樣簡單,Sean Grove 也嘗到了甜頭,在 Glint 中很容易地添加了『撤銷/恢復』功能。在 NoRedInk 我們高興地用著 Elm 架構,在這些單一原子狀態的基礎上 Laszlo Pandy 構建了最原始的 Time-Traveling 調試器。

儘管在前端架構的動向上看單一原子狀態的好處越來越清晰,但社區中關於這一新領域中存在問題的討論一直沒有停止過。我們如何維持局部組件狀態的好處,精確地控制那一段代碼可以訪問哪個狀態?以及我們該如何在不讓每個組件擁有自身狀態的情況下創建可復用的組件?

隨著新技術的到來,這些問題也變得出奇的簡單。

可復用的無狀態組件

考慮一個摺疊組件。當用戶點擊某一特定的區域,如果該區域處於摺疊狀態它應該展開,反之亦然。

很明顯,這裡包含了狀態。我們需要記錄哪些區域處於展開以及摺疊狀態,以便渲染組件,以及當用戶點擊指定區域時應該如何做。如果摺疊組件不能調用自身的狀態,我們該如何做呢?

在後端,經常會有很多可復用的庫用於基於狀態的任務,比如用戶授權。然而,這些庫並不會與一個自己的 MySQL 資料庫打包在一起;相反,應用通常會結合現有的資料庫通過一個 API 來使用它們,因為用於授權的庫通常被設計為委託狀態存儲而不是將狀態打包起來。

在前端,這一思想同樣適用。

我們的摺疊組件需要狀態做兩件事情:渲染以及處理用戶的動作。它需要知道當前狀態(即哪部分被摺疊和展開)以便渲染,並且在用戶點擊時它需要改變狀態(即改變展開的和摺疊的部分)。

過去,處理這類需求最常見的方式就是給它一系列的局部狀態,然後在渲染時去讀取它,在用戶點擊時修改它。

在單一原子狀態中,存在委託機制:摺疊組件的 API 接收用於渲染的所必須的展開或摺疊數據作為參數,以及狀態更新函數(expandSection()、collapseSection() 等)用於響應用戶的輸入。所以當用戶點擊摺疊區域並且該區域要展開時,它會調用給它傳遞的 expandSection() 函數,隨之更新摺疊組件的單一原子狀態。

不僅僅是這種委派的方式,它還提供了更加強大的方式用於控制哪些組件能夠影響狀態的哪個部分。父組件可以為子組件提供『讀許可權』(提供當前狀態的一部分作為參數)而不提供『寫許可權』(為子組件提供了函數可用於更新單一原子狀態的指定部分),反過來也一樣。你甚至可以根據當前狀態選擇性地提供訪問許可權。

這種架構會為我們的終端用戶帶來哪些不一樣的東西?

假設我們想要達到這樣一個用戶體驗:當用戶刷新頁面時,摺疊組件依舊處於用戶操作後的展開或摺疊狀態。我們可以輕易地使用 localStorage 來持久化必要的信息。不過相對於單一原子狀態而言,使用局部狀態來實現的難點又在哪裡呢?

使用局部組件狀態時,每次用戶點擊摺疊組件時,我們一定要去更新 localStorage 以持久化數據。如果我們忘記了,即便只有一次,狀態就會不同步。最後我們會得到一個混亂的狀態,頁面所展示的東西和用戶的交互不一致。而且這種問題很難追蹤到。

一旦有了單一原子狀態,一個摺疊動作可以從狀態的擁有者委派給它,這是很簡單的事情。每次狀態發生改變,我們將整個原子都存到 localStorage 中。這樣絕不會出現不同步的問題,並且我們的應用架構僅需要維護一個原子,所有的 UI 組件都會工作,包括可復用的摺疊組件等。

這也的確是我們在 NoRedInk 使用 Elm 創建可復用的摺疊組件所使用的方式。它的 API 被設計成可以為單一原子狀態委派狀態而不是擁有自己的狀態,讓我們用到了兩個世界的好處:組件復用和單一原子狀態。

示例:React 0.14 中的無狀態函數式組件

最新發布的 React 0.14 提出了無狀態函數式組件,它指出:

在絕大多數 React 代碼中,絕大多數組件都被寫成無狀態、簡單地組合成其他的組件等等。這種通過創建多個簡單組件然後組合成一個大應用的設計模式被提倡。

下面是那篇文章中的一個類似的例子:

// 一個使用 ES2015 (ES6)箭頭函數寫成的函數式組件var Aquarium = (props) => { var fish = getFish(props.species); return {fish};};

我們可以運行一下這個示例。用戶打開或關閉魚缸燈光的功能該如何實現呢?我們可以使用局部組件狀態,每一個魚缸都會有一個狀態來表示燈的開關狀態,但是讓我們用委派單一組件狀態來試試。

下面在 jsFiddle 中展示了該示例:

示例中有一個典型的 React 組件位於組件樹的根部,我們稱之為 App。它的職責僅僅是控制應用的單一原子狀態,它的子組件負責按需更新狀態。

注意,Aquarium 和它的子組件 Tank 都是無狀態的;我們可以明確這一點,因為它們使用了 0.14 的無狀態函數式組件的方式,甚至不支持局部組件狀態!不要灰心,魚缸這個類在需要的時候更新整個應用的狀態並沒有問題,因為它的父組件提供了setLightOn 方法用於選擇性地更新。

這裡我們可以看到局部狀態的概念上的好處,那就是可以精細地控制哪些組件能夠訪問哪個狀態。而這一點可以通過簡單的函數傳遞來實現,而不必去犧牲單一原子狀態的好處。Tank 不需要訪問整個應用的狀態;僅僅需要知道它有一個魚缸,能對魚缸做的就是打開或關閉它的照明。

簡而言之,我們簡單地通過選擇性地傳遞參數避免了將組件的內容與應用狀態的其他部分混合在一起!

這可能是一種不尋常的做事方式,不過請記住這給我們帶來了什麼:如果我們想要序列化整個應用的狀態並將它存到某個地方,或者切換到前一個狀態,都將是不費吹灰之力。跨應用狀態的不同部分來更新事務也就變得微不足道了,因而爭用狀態也很容易避免。

對大多數無狀態組件來講,第二版的『Developing a React Edge』會詳細的討論,當然還包括 React 0.14 中的新特性。我也同樣希望單一原子狀態會成為在布拉迪斯拉發市舉辦的 Reactive2015 中的一個議題,會有很多關於單一原子狀態的演講,包括 Redux,Om 以及 Elm。

總結

儘管局部組件狀態很誘人,而單一原子狀態的好處絕對強大到不可拒絕:

  • 單一數據源使得操作事務更新更加簡單並且避開了爭用狀態

  • 快速持久化和檢索狀態的序列,不再需要大量的判斷邏輯

  • 調試器能夠可靠地跨整個 UI 狀態按照時序調試
  • 『撤銷/恢復』成為一個可以輕易實現的特性

我們能夠使用委派和傳遞函數來創建一個可復用的無狀態組件,可以像有狀態的組件那樣精細地控制狀態的訪問許可權,即便是局部組件狀態的好處也可以輕易地移植到單一原子狀態的領域中。

所以,它很可能就是前端開發界的下一次革命,我們避免了局部組建狀態的陷阱並且嘗到了後端開發者一直在享用的甜頭。

原文: React 0.14: Why Local Component State is a Trap

關注微博:前端外刊評論
推薦閱讀:

Python進階:函數式編程實例(附代碼)
國內有沒有學校講Lisp或者函數式編程呢?

TAG:React | 函数式编程 |