繼 Scalable Frontend 2 — Common Patterns 第三篇,繼續翻譯記錄。javascript
原文:Scalable Frontend #3 — The State Layercss
狀態樹,實際上就是單一來源html
在處理用戶界面時,不管咱們使用的應用程序的規模有多大,必需要管理顯示給用戶或由用戶更改的狀態。來源多是從 API 獲取的列表、從用戶的輸入得到、來自本地存儲的數據等等。無論這些數據來自何處,咱們都必須對其進行處理,並使用持久化方法使其保持同步,不管是遠程服務器仍是瀏覽器存儲。前端
準確地說,這就是咱們所說的 本地狀態 (local state),咱們應用程序使用和依賴的特定的一部分數據。有不少緣由能夠解釋爲何、什麼時候、何地更新和使用狀態,若是咱們不恰當地管理它,它可能很快失控。即便是一張簡單的報名表單,也可能須要處理不少狀態:vue
噢!聽起來很棘手,對吧?java
在本文中,咱們將討論如何以合理的方式管理本地狀態,始終牢記代碼庫的可擴展性和架構設計原則,以免狀態層和其它層之間的耦合。應用程序的其他部分不該該知道狀態層或正在使用的庫,若是有的話。咱們只須要告訴視圖層如何從狀態中獲取數據,以及如何分發 actions ,它們將調用組成咱們應用程序行爲的用例。react
在過去的幾年中,JavaScript 社區中出現了許多用於管理本地狀態的庫,這些庫之前主要由雙向數據綁定競爭者控制,例如 Backbone、Ember 和 Angular 。直到隨着 Flux 和 React 的出現,單向數據流才變得流行起來,人們意識到 MVC 對於前端應用程序並不很適用。隨着在前端開發中大量採用函數式編程技術,咱們能夠理解爲何像 Redux 這樣的庫如此流行並影響了整整一代的狀態管理庫。git
若是你想要更多瞭解這個主題,這裏有個很好的關於 Flux 思惟模式的演示。
如今,有好幾個流行的狀態管理庫,其中一些特定於某些生態系統,好比 NgRx 是用於 Angular 。爲了熟悉起見,咱們將使用 Redux ,可是本文中提到的全部概念適用於全部的庫,甚至沒有庫的狀況。記住這一點,你應該使用最適合你和你的團隊的方案。不要由於一個庫處處有宣傳,使用它就感到有壓力。若是對你適用,那就去用吧。github
在現代前端應用程序的狀態管理中,咱們會發現這四個是最多見的對象類型。用 actions 將事件從反作用的影響中分離出來的想法並不新鮮。事實上,這些公民都是基於成熟的想法,例如 event sourcing、CQRS 和 mediator 設計模式。vuex
它們共同運做的方式是,經過集中存儲和更改狀態的方式,限制在一個地方並分發 actions(又稱事件)來觸發狀態更改。一旦更改應用於狀態,咱們會通知對其感興趣的部分,它們會更新本身以反映新的狀態。這是單向數據流循環。
循環
Actions 一般被實現爲具備兩個屬性的對象:type
屬性和傳遞給 store 執行對應操做的數據 data
。例如,觸發建立用戶的 action 多是如下格式:
{ type: 'CREATE_USER', userData: { name: 'Aragorn', birthday: '03/01/2931' } }
須要注意的是,type
屬性的實現因所使用的狀態管理庫而異,但大多數狀況下它都是一個字符串。另外,請記住,示例中的 action 自己並不建立用戶;它只是一條消息,告訴 store 使用 userData
建立用戶。
Action 建立者是把建立 action 對象抽象爲一個可複用單元的函數
可是,若是咱們須要從代碼中的多個位置觸發相同的 action ,好比測試套件或另外一個文件,該怎麼辦?咱們如何使其可重用,並對分發它的單元隱藏 action 類型?咱們使用 action 建立者!Action 建立者是把建立 action 對象抽象爲一個可複用單元的函數。咱們前面的示例能夠由下面的 action 建立者封裝:
const createUser = (userData) => ({ type: 'CREATE_USER', userData });
如今,每當咱們須要分發 CREATE_USER
的 action 時,咱們導入這個函數並使用它來建立將分發到咱們 store 的 action 對象。
store 是咱們真實狀態的惟一來源,是咱們存儲和修改狀態的惟一的地方。每次更改狀態時,咱們都會向 store 分發一個 action ,描述想要執行的更改,並在須要時提供額外的信息(分別對應示例中的 type 和 userData)。這意味着咱們永遠不該該在同一位置使用和更改狀態,而是讓 store 來更新狀態。在這種模式的大多數實現中,咱們都會 訂閱 ,當 store 執行變動時會獲得相應通知,以便對更改作出反應。
store 是咱們真實狀態的惟一來源。
好了,如今咱們知道 store 能夠用於兩個主要目的:分發 actions 和向訂閱者觸發事件。在 React 應用程序中,一般用 Redux 建立 store ,使用 react-redux’ connect 分發 actions(mapDispatchToProps
)和監聽變動(mapStateToProps
)。但咱們也能夠用一個根組件,使用 Context API 來存儲狀態,相應的使用 Context.Consumer 來分發 actions 和監聽變動。或者咱們能夠用一個更簡單的方式:狀態提高。對於 Vue ,有一個跟 Redux 很相似的庫 Vuex ,咱們使用 dispatch 觸發 actions ,用 mapState 來監聽 store 。一樣的,咱們能夠用 @ngrx/store 在 Angular 應用程序中作一樣的事情。
儘管存在差別,但全部這些庫都有一個共同的理念:單向循環。每次須要更新狀態時,咱們都會將 actions 發送到 store ,而後進行執行並通知監聽者。千萬不要回頭或跳過這些步驟。
但 store 如何更新狀態並處理每一個 action 類型?這就是 reducers 派上用場的地方。老實說,它們並不老是被稱爲「reducers」,例如,在 Vuex 中,它們被稱爲「mutations」。但中心思想是同樣的:一個獲取應用程序當前狀態和正在處理的 action ,返回一個全新的狀態,或者使用設置器對當前狀態進行修改的函數。store 將更新委託給此函數,而後將新狀態通知給監聽者。這就結束了循環!
每一個 reducer 都應該可以處理咱們應用中的任何 action 。
在結束這部分以前,有一條很是重要的規則須要強調:每一個 reducer 都應該可以處理咱們應用中的任何 action 。換句話說,一個 action 能夠同時由多個 reducer 處理。所以,這條規則容許單個 action 在狀態的不一樣部分觸發多個更改。這裏有個很好的例子:在一個 AJAX 請求完成後,咱們能夠在 reducer X 中的根據響應更新本地狀態,在 reducer Y 中隱藏提示器,甚至在 reducer Z 中顯示一條成功的消息,其中每一個 reducer 都有更新狀態不一樣部分的單一責任。
當咱們開始編寫應用程序時,總會想到一些問題:
這些問題恐怕沒有正確的答案。咱們惟一有把握的是一些特定於庫的規則,這些規則規定了如何更新狀態。例如,在 Redux 中,reducer 函數應該是單一肯定的,而且具備 (state,action) => state
簽名。
也就是說,咱們能夠遵循一些實踐來擺脫複雜性並提升 UI 性能,其中的一些是通用的,適用於咱們選擇的任何狀態管理技術。其它的一些則適用於像 Redux 這樣的特定的工具,與具備很強函數特性的輔助函數結合,用來分解 reducer 邏輯。
在深刻研究以前,我建議你先查看你用來管理狀態的庫的文檔。在大多數狀況下,你會發現你不知道的高級技術和輔助方法,甚至本篇文章中沒有介紹,但更適用你正在使用的狀態管理方案的概念。除此以外,你能夠查看第三方庫,或者本身構建函數來實現這一點。
狀態指的是咱們須要管理的數據,形態指的是咱們如何構造和組織這些數據。形態與數據源無關,但與咱們如何構造 reducer 邏輯密切有關。
一般,這個形態是用一個普通的 JavaScript 對象表示,它造成了初始狀態樹,但也可使用任何其它值,好比純數字、數組或字符串。對象的優勢是容許將狀態組織和劃分爲有意義的片斷,其中根對象的每一個鍵都一個子樹,能夠表示公共或部分數據。在包含文章和做者的基本博客應用程序中,狀態的形態可能以下所示:
{ articles: [ { id: 1, title: 'Managing all state in one reducer', author: { id: 1, name: 'Iago Dahlem Lorensini', email: 'iagodahlemlorensini@gmail.com' }, }, { id: 2, title: 'Using combineReducers to manage reducer logic', author: { id: 2, name: 'Talysson de Oliveira Cassiano', email: 'talyssonoc@gmail.com' }, }, { id: 3, title: 'Normalizing the state shape', author: { id: 1, name: 'Iago Dahlem Lorensini', email: 'iagodahlemlorensini@gmail.com' }, }, ], }
請注意,articles
是狀態的頂級鍵,它造成了一個表明相同概念數據的子樹。咱們在每篇文章中也都有一個嵌套的子樹來表示做者。通常來講,咱們應該避免嵌套數據,由於它增長了 reducer 的複雜性。
Redux 的這篇文檔介紹瞭如何根據你的定義域層和應用程序狀態,將數據類型構造到狀態形態上。即便你沒有使用 Redux ,也去看看!數據管理對於任何類型的應用程序來講都是司空見慣的,對於學習如何對數據進行分類並組織造成你的狀態形態,那是一篇很是好的文章。
上一個示例的狀態形態中只顯示了一個鍵,但實際應用程序一般有多個定義域要表示,這意味着一個 reducer 函數中將有更多的更新邏輯。然而,這違背了一個重要的規則:reducer 函數應該精簡且聚焦(單一責任原則),以便易於閱讀、理解和維護。
在 Redux 中,咱們能夠經過內置的 combineReducers
函數實現這一點。這個函數接受一個對象,其中每一個鍵表示狀態的一個子樹,並返回一個帶有描述名稱的組合 reducer 函數。讓咱們將 authors
和 articles
的 reducer 合併到一個 rootReducer
中:
import { combineReducers } from 'redux' const authorsReducer = (state, action) => newState const articlesReducer = (state, action) => newState const rootReducer = combineReducers({ authors: authorsReducer, articles: articlesReducer, })
傳遞給 combineReducer
的鍵將用於造成狀態的最終形態,其數據將由與各自鍵相關聯的 reducer 函數進行轉換。所以,若是咱們傳遞 authors
鍵和 authorsReducer
函數,rootReducer
返回的將是由 authorsReducer
函數管理的 state.authors
。
當咱們更深刻的拆分 reducer 函數時,合併 reducers 也很棒。假設 articlesReducer
須要處理這種狀況:跟蹤在獲取文章的過程當中發生的錯誤。因此如今咱們狀態中 articles
的鍵將再也不是一個數組,而是一個以下的對象:
{ isLoading: false, error: null, list: [] // <- this is the array of articles itself }
咱們能夠在 articlesReducer
內部處理這種新狀況,但在同一個地方咱們會有更多的聲明要處理。幸運的是,這能夠經過將 articlesReducer
分解成更小的部分來解決:
const isLoadingReducer = (state, action) => newState const errorReducer = (state, action) => newState const listReducer = (state, action) => newState const articlesReducer = combineReducers({ isLoading: isLoadingReducer, error: errorReducer, list: listReducer, })
除了 combinerReducers
,還有其它方法能夠分解 reducer 邏輯,但咱們將說明轉交給 Redux 文檔,文檔對可複用技術例如高階 reducer 、切片 reducer ,和減小樣板代碼的方法進行了很好的描述。請注意,這些方法也適用於 VueX 模塊(本文將再次說起)和 NgRx 。
你注意到在咱們的博客示例中,每篇文章都嵌套了一個做者嗎?不幸的是,當一個做者關聯多篇文章時,這會致使數據重複,這樣使更新做者的行爲成爲一場噩夢,由於咱們須要確保重複的做者數據也獲得更新。更糟糕的是,因爲沒必要要的從新渲染,性能會降低。
但有一個解決方案:咱們能夠像數據庫那樣歸一化關聯的數據。該技術關鍵在於爲每一個數據類型或定義域提供一個「表」,經過它們的 ID 引用關聯的實體。Redux 建議以下:
byId
的對象中,實體的 ID 做爲鍵,實體做爲值,allIds
的 ID 數組,表示實體的順序。在咱們的示例中,在對數據進行標準化後,咱們會獲得以下結果:
{ articles: { byId: { '1': { id: '1', title: 'Managing all state in one reducer', author: '1', }, '2': { id: '2', title: 'Using combineReducers to manage reducer logic', author: '2', }, '3': { id: '3', title: 'Normalizing the state shape', author: '1', }, }, allIds: ['1', '2', '3'], }, authors: { byId: { '1': { id: '1', name: 'Iago Dahlem Lorensini', email: 'iagodahlemlorensini@gmail.com', }, '2': { id: '2', name: 'Talysson de Oliveira Cassiano', email: 'talyssonoc@gmail.com', } }, allIds: ['1', '2'], }, }
這種結構更輕量。因爲沒有重複項,做者只在一個地方進行更新,所以觸發的 UI 更新更少。咱們的 reducer 更簡單,對應項一致且便於查找。
開始歸一化數據時一個常見問題是:
如何將這些數據的關聯部分塑形成咱們的狀態?
雖然沒有硬性規定,但一般將定義域的「表」放在名爲 entities 的頂級對象中。在咱們的文章示例中,將會是這樣的:
{ currentUser: {}, entities: { articles: {}, authors: {}, }, ui: {}, }
那麼 API 發送的數據怎麼處理?由於數據一般以嵌套格式返回,因此在存儲到狀態樹以前須要對其進行歸一化。咱們可使用 Normalizer 庫來實現這一點,它容許根據定義模式類型和關係來返回歸一化的數據。去查看他們的文檔,瞭解更多關於它用法的詳細信息。
對於使用較小應用程序或不想使用庫的一些人,能夠經過如下幾個函數手動實現歸一化:
replaceRelationById
函數用嵌套對象的 ID 替換自身,extractRelation
函數從主要實體中提取嵌套對象,byId
函數按照 ID 對實體進行分組,allIds
函數收集全部的 ID 。因此讓咱們建立這些函數:
const replaceRelationById = (entities, relation, idKey = 'id') => entities.map(item => ({ ...item, [relation]: item[relation][idKey], })) const extractRelation = (entities, relation) => entities.map(entity => entity[relation]) const byId = (entities, idKey = 'id') => entities .reduce((obj, entity) => ({ ...obj, [entity[idKey]]: entity, }), {}) const allIds = (entities, idKey = 'id') => [...new Set(entities.map(entity => entity[idKey]))]
很簡單,對吧?如今咱們須要從相應的 reducer 中調用這些函數。讓咱們以第一篇文章的結構爲例:
const articlesReducer = (state = initialState, action) => { switch (action.type) { case 'RECEIVE_DATA': const articles = replaceRelationById(action.data, 'author') return { ...state, byId: byId(articles), allIds: allIds(articles), } default: return state } } const authorsReducer = (state = initialState, action) => { switch (action.type) { case 'RECEIVE_DATA': const authors = extractRelation(action.data, 'author') return { ...state, byId: byId(authors), allIds: allIds(authors), } default: return state } }
在 action 分發後,咱們將對 articles 表中的 ID 進行了歸一化,沒有嵌套數據,authors
表也將被歸一化,沒有任何重複。
在以前的文章中,咱們討論適用於定義域層、應用層、基礎設施層和輸入層的模式。如今讓咱們討論一下保持狀態層合理和易於理解的模式。它們中的一些只在特定的狀況下使用。
有時咱們須要的不只僅是從狀態中提取數據:咱們可能須要經過過濾或分組來計算派生狀態,以便在派生數據變化時從新渲染視圖。例如,若是咱們按已完成的項篩選 TODO 列表,若是未完成的項進行了更新,咱們不須要從新渲染視圖,對吧?另外,直接在用戶端計算數據會使數據與其形態相耦合,若是咱們須要重構狀態,其反作用就是咱們還須要更新用戶端的代碼。這正是咱們用選擇器能夠避免的問題。
選擇器顧名思義是選擇與特定上下文相關數據的函數。它們接收整個狀態的一部分做爲參數,並按照使用者的指望進行計算。讓咱們回到以 React+Redux 爲例的 TODO 列表。使用選擇器先後的代碼是什麼樣的?
/* view/todo/TodoList.js */ const TodoList = ({ todos, filter }) => ( <ul> { todos .filter((todo) => todo.state === filter) .map((todo) => <li key={todo.id}>{ todo.text }</li> ) } </ul> ); const mapStateToProps = ({ todos, filter }) => ({ todos, filter }); export default connect(mapStateToProps)(TodoList);
/* state/todos.js */ import * as Todo from '../domain/todo'; export const getTodosByFilter = (todos, filter) => ( // notice that we isolate the domain rule into the domain/todo entity // so if the shape of the todo object changes it will only affect our entity file, not here :) todos.filter((todo) => Todo.hasState(todo, filter)) ); // --------------------------------- /* view/todo/TodoList.js */ import { getTodosByFilter } from '../../state/todos'; const TodoList = ({ todos }) => ( <ul> { todos .map((todo) => <li key={todo.id}>{ todo.text }</li> ) } </ul> ); const mapStateToProps = ({ todos, filter }) => ({ todos: getTodosByFilter(todos, filter) }); export default connect(mapStateToProps)(TodoList);
咱們能夠看到,重構後的組件不知道集合中存在什麼類型的 TODO ,由於咱們將此邏輯提取到一個名爲 getTodosByFilter
的選擇器中。這正是選擇器的做用所在,因此當你注意到組件對你的狀態瞭解得太多時,考慮下使用選擇器。
當你注意到組件對你的狀態瞭解得太多時,考慮下使用選擇器。
選擇器還爲咱們提供了利用一種稱爲 memoization 的性能改進技術的可能性,只要原始數據保持完整,就能夠避免從新渲染和從新計算數據。在 Redux 中,咱們可使用 reselect 庫來實現記憶化的選擇器,你能夠在 Redux 文檔 中閱讀相關信息。
若是你使用的是 Vuex ,已經有一種內置的選擇器實現方法名爲 getter
。你會發現 「getters」與 Redux 選擇器的思惟方式徹底相同。NgRx 也有一個選擇器功能,它甚至能夠爲你執行記憶化!
若是你想知道在哪裏放置你的選擇器,繼續閱讀,你很快就會發現!
咱們說過架構與文件組織不是同一回事,但文件組織能反映架構這是很好的,還記得這句話嗎?鴨子模式正是關於這一點:它遵循了 Common Closure Principe (CCP) 的定義,即:
一個包中的類應該是針對相同類型的變動。影響一個包的變動會影響包中全部的類。— Robert Martin
一個鴨子(或模塊)是一個彙集了屬於同一功能特性的 reducer、actions、action 建立者和選擇器的文件,這樣若是咱們須要添加或更改一個新的 action ,就只須要改動一個文件。
等等,這種模式是針對 Redux 應用程序的嗎?固然不是!儘管 Ducks 這個名字的靈感來自 Redux 這個詞,可是咱們能夠按照它的思惟方式來使用任何咱們想要的狀態管理方法,即便不使用庫。
對於 Redux 用戶來講,這裏有關於使用 ducks 方法的文檔。對於 Vuex 應用程序,有一個叫作 modules 的東西,它基於相同的思想,但對 Vuex 來講更「原生」,由於它是核心 API 的一部分。若是你用 Angular 和 NgRx ,有一個基於 Ducks 的提議,叫作 NgRx Ducks 。
但有個缺陷。Ducks 方式建議咱們在 duck 文件的頂部保留 action 名稱,對吧?這可能不是最好的決策,由於這將使來自其它文件的 reducer 很難處理咱們應用程序的 任何 action ,由於它們將被迫重寫 action 的名稱。咱們能夠爲應用程序的全部 action 名建立一個單獨的文件來避免這個問題,每一個 duck 均可以導入和使用這個文件。此文件將按功能對 action 名稱進行分組,併爲每一個 action 名指定導出。舉個例子:
export const AUTH = { SIGN_IN_REQUEST: 'SIGN_IN_REQUEST', SIGN_IN_SUCCESS: 'SIGN_IN_SUCCESS', SIGN_IN_ERROR: 'SIGN_IN_ERROR', } export const ARTICLE = { LOAD_ARTICLE_REQUEST: 'LOAD_ARTICLE_REQUEST', LOAD_ARTICLE_SUCCESS: 'LOAD_ARTICLE_SUCCESS', LOAD_ARTICLE_ERROR: 'LOAD_ARTICLE_ERROR', } export const EDITOR = { UPDATE_FIELD: 'UPDATE_FIELD', ADD_TAG: 'ADD_TAG', REMOVE_TAG: 'REMOVE_TAG', RESET: 'RESET', }
import { AUTH } from './actionTypes' export const reducer = (state, action) => { switch (action.type) { // ... case AUTH.SIGN_IN_SUCCESS: // <- same action for different reducers return { ...state, user: action.user, } // ... } }
import { AUTH } from './actionTypes' export const reducer = (state, action) => { switch (action.type) { // ... case AUTH.SIGN_IN_SUCCESS: // <- same action for different reducers case AUTH.SIGN_IN_ERROR: return { ...state, showSpinner: false, } // ... } }
有時使用咱們的狀態變量來管理多個布爾值或多個條件,以找出應該渲染的內容,可能過於複雜。一個表單組件可能須要考慮多種可能性:
你能想象咱們會用多少個布爾值嗎?可能會產生相似這樣的結果:
{ (isTouched || isSubmited) && !isValid && <ErrorMessage errors={errors} /> } { isValid && isSubmited && !errors && <Spinner /> }
當咱們試圖使用數據來定義應該呈現的內容時,咱們一般會獲得這樣的代碼,因此咱們添加了一堆布爾變量,並試圖以一種合理的方式來協調它們——結果證實是很是困難的。可是,若是咱們試圖把全部這些可能性歸類爲一些明確的、名副其實的狀態呢?想一想看,咱們的接口將始終處於如下狀態之一:
請注意,從任何給定的狀態,都有咱們沒法轉換到的狀態。咱們不能從「Invalid」轉變到「Submitting」,但咱們能夠從「Invalid」轉變到「Valid」,而後再轉變到「Submitting」。
狀態機的理念是定義一組可能的狀態以及它們之間的轉換。
這種狀況用計算機科學的概念來解釋更爲合適,稱爲有限狀態機,或是爲這種狀況專門建立的一種變體,稱爲狀態圖。狀態機的理念是定義一組可能的狀態以及它們之間的轉換。
在咱們的示例中,狀態機將會是這個樣子:
它看起來可能很複雜,但請注意,狀態和轉換的良好定義能夠提升代碼的清晰度,從而更容易以明確和簡潔的方式添加新狀態。如今咱們的條件將只關心當前狀態,咱們將再也不須要處理複雜的布爾表達式:
{ (currentState === States.INVALID) && <ErrorMessage errors={errors} /> } { (currentState === States.SUBMITTING) && <Spinner /> }
好了,那麼咱們如何在代碼中實現狀態機呢?首先要明白的是,它沒必要是一個複雜的實現。咱們能夠有一個表示當前狀態名稱的普通字符串,咱們的 reducer 處理的每個 action 更新該字符串。舉個例子:
import Auth from '../domain/auth'; import { AUTH } from './actionTypes'; const States = { PRISTINE: 'PRISTINE', VALID: 'VALID', INVALID: 'INVALID', SUBMITTING: 'SUBMITTING', SUCCESS: 'SUCCESS' }; const initialState = { currentState: States.PRISTINE, data: {} }; export const reducer = (state = initialState, action) => { switch(action.type) { case AUTH.UPDATE_AUTH_FIELD: const newData = { ...state.data, ...action.data }; return { ...state, // ... data: newData, currentState: Auth.isValid(newData) ? States.VALID : States.INVALID }; case AUTH.SUBMIT_SIGN_IN: if(state.currentState === States.INVALID) { return state; // makes it impossible to submit if it's invalid } return { ...state, // ... currentState: States.SUBMITTING }; case AUTH.SIGN_IN_SUCCESS: return { ...state, // ... currentState: States.SUCCESS }; } return state; };
但有時咱們須要更大的具備更多狀態和轉換的狀態機,或者出於其它緣由咱們只須要一個特定的工具。對於這些狀況,咱們可使用相似 XState 的東西。請記住,狀態機對狀態管理是不可知的,所以不管使用 Redux、Context API、Vuex、NgRx,甚至沒有庫,咱們均可以擁有它們!
若是你想了解更多,在這篇文章的最後有幾個連接,提供了關於狀態機和狀態圖的更多信息。
即便遵循一個好的架構,在開發咱們的前端應用程序時也有一些誘人的陷阱須要避免。咱們說 誘人 是由於儘管它們看起來無害,但它們有很大的潛力最終致使反噬。咱們來談談關於狀態層的一些注意事項。
你還記得本系列的第一篇文章嗎?當時咱們談到之後端應用程序中的控制器的方式處理 actions ,不在其中包含業務規則,並將工做委託給用例?讓咱們回到這個話題,但首先,讓咱們定義一下咱們所說的「有反作用的 actions」是什麼意思。
當某些操做的結果影響到本地環境以外的一些東西時,就會產生反作用。在咱們的例子中,讓咱們考慮一個反作用,當一個 action 不只僅是改變本地狀態時,好比還發送一個 AJAX 請求或者將數據持久化到 LocalStorage 。若是咱們的應用程序使用 Redux Thunk、Redux Saga、Vuex Actions、NgRx Effects ,甚至是執行請求的特殊類型的 action ,那就是咱們所指的。
使 actions 相似於控制器的緣由是它們都暗含告終果。它們執行整個用例和它們的反作用,這就是爲何咱們不復用控制器,也不該該複用帶有反作用的 action 。咱們試圖爲不一樣的目的複用同一個 action 時,咱們也會繼承它的全部反作用,這是不可取的,由於它使代碼更難理解。讓咱們用一個例子來簡化一下。
想象一個 loadProducts
action 經過 AJAX 加載一個產品列表,並在請求的過程當中顯示一個提示器(在咱們的例子中將使用一個 Redux Thunk action):
const loadProductsAction = () => (dispatch, _, container) => { dispatch(showSpinner()); container.loadProducts({ onSuccess: (products) => { dispatch(receiveProducts(products)); dispatch(hideSpinner()); }, onError: (error) => { dispatch(loadProductsError(error)); dispatch(hideSpinner()); } }); };
好的,可是如今咱們想不時地從新加載這個列表,使它始終保持最新,因此第一個念頭就是複用這個操做,對吧?若是咱們但願在後臺進行更新而不顯示提示器,該怎麼辦?有人可能會說,能夠爲此添加一個 withSpinner
標誌,因此咱們這樣作:
const loadProductsAction = ({ withSpinner }) => (dispatch, _, container) => { if(withSpinner) { dispatch(showSpinner()); } container.loadProducts({ onSuccess: (products) => { dispatch(receiveProducts(products)); if(withSpinner) { dispatch(hideSpinner()); } }, onError: (error) => { dispatch(loadProductsError(error)); if(withSpinner) { dispatch(hideSpinner()); } } }); };
這已經變得很奇怪了,由於在使用標誌時須要考慮一些複用,可是讓咱們暫時忽略它。
如今,若是咱們但願爲成功的狀況觸發另外一個不一樣的 action ,咱們應該怎麼作?也將其做爲參數傳遞?咱們越是試圖讓一個 action 通用,它就越複雜,越不聚焦,你能發現這個嗎?咱們怎樣才能解決這個問題,而且仍然複用這個 action ?最好的答案是:咱們不用。
抵制複用有反作用 actions 的衝動。
對於這樣的狀況,抵制複用有反作用 actions 的衝動!它們的複雜性最終會變的難以忍受、難以理解和難以測試。相反,嘗試建立兩個利用相同用例的明確 actions :
const loadProductsAction = () => (dispatch, _, container) => { dispatch(showSpinner()); container.loadProducts({ onSuccess: (products) => { dispatch(receiveProducts(products)); dispatch(hideSpinner()); }, onError: (error) => { dispatch(loadProductsError(error)); dispatch(hideSpinner()); } }); };
const refreshProductsAction = () => (dispatch, _, container) => { container.loadProducts({ onSuccess: (products) => { dispatch(refreshProducts(products)); }, onError: (error) => { dispatch(loadProductsError(error)); } }); };
好極了!如今咱們能夠看到這兩個 actions ,並確切地知道它們應該在何時使用。
注意,當一個有反作用的 action 使用另外一個也有反作用的 action 時,一樣也適用。咱們不該該這樣作,由於調用 action 將繼承被調用 action 的全部反作用。
咱們已經知道複用 actions 會使代碼更難理解。如今想象一下,咱們的組件依賴於這些 action 反作用的返回值。聽起來不算太糟,對吧?
但這會使咱們的代碼更難理解。假設咱們正在調試一個獲取產品的 action 。調用這個 action 後,咱們意識到已獲取了此產品的評論列表,但咱們不知道它來自何處,並且咱們肯定它不是來自 action 自己。如今變的愈來愈複雜了,不是嗎?
// action const loadProduct = (id) => (dispatch, _, container) => { container.loadProduct({ onSuccess: (product) => dispatch(loadProductSuccess(product)), onError: (error) => dispatch(loadProductError(error)), }) } // component componentDidMount() { const { productId, loadProduct, loadComments } = this.props loadProduct(productId) .then(() => loadComments(productId)) }
咱們將 actions 看成控制器,可是咱們會在後端應用程序中鏈式調用控制器嗎?我不這麼認爲。
永遠不要依賴 actions 返回的鏈式回調,也不要作任何其它相似的事情。若是應該在 action 調用完成後完成某些事情,則 action 自己應該處理它。
所以,做爲第二條規則,永遠不要依賴 actions 返回的鏈式回調,也不要作任何其它相似的事情。若是應該在 action 調用完成後完成某些事情,則 action 自己應該處理它——除非它是另外一層的責任,好比重定向(這其實是視圖層的責任,咱們將在本系列的下一篇文章中討論),你的 actions 應該是應用程序的入口點,因此不要將重定向調用散佈到全部組件上。
// action const loadProduct = (id) => (dispatch, _, container) => { container.loadProduct(id, { onSuccess: (product, comments) => dispatch(loadProductSuccess(product, comments)), onError: (error) => dispatch(loadProductError(error)), }) } // component componentDidMount() { const { productId, loadProduct } = this.props loadProduct(productId) }
有時,咱們須要將原始數據轉換爲人類可讀的值,例如價格和日期。假設咱們有一個產品模型,並收到相似於 {name:'product name',price:14.9}
的東西,其中包含一個普通數字形式的價格。如今咱們的工做是在向用戶展現以前格式化這些數據。
因此要記住,當一個值能夠用一個純函數(也就是說,給定相同的輸入,咱們老是獲得相同的輸出,)進行轉換時,咱們其實不須要將它存儲到咱們的狀態中;咱們能夠在這個值將顯示給用戶的地方調用一個轉換函數。在 React 視圖中,它將像 <p>{formatPrice(product.price)}</p>
這樣簡單。
咱們常常看到開發人員存儲 formatPrice(product.price)
的返回值,這可能會致使缺陷。若是咱們想要將此值發送回服務器,或者咱們須要用它在前端進行計算,會發生什麼狀況?在這種狀況下,咱們須要將它轉換回一個普通的數字,這是不理想的,不存儲它能夠徹底避免這些。
有人可能會說,在渲染中屢次調用函數可能會影響性能,但使用諸如記憶化之類的技術,咱們會避免每次都對其進行處理。所以,性能不是不作的藉口。可使用 mem 這樣的簡單庫,也能夠將此函數調用抽象到一個組件中,像這樣 <FormatPrice>{product.price}</FormatPrice>
並使用自帶的 React.memo 函數。但請記住,只有當你的函數須要密集處理時,才須要記憶化。
這篇文章比預期的要長一點,但咱們很高興地說,這篇文章和上一篇文章一塊兒,涵蓋了咱們用來開發可擴展前端應用程序的最多見模式。
固然,現代應用程序還有其它問題須要處理,如身份驗證、錯誤處理和樣式,這些將在之後的文章中討論。在下一篇文章中,咱們將討論視圖層和狀態層之間的交互,以及在保持它們解耦的同時,如何使它們相互依賴,還有路由相關。再見!