【譯】Redux + React 應用程序架構的 3 條規範(內附實例)
原文地址:http://jaysoo.ca/2016/02/28/organizing-redux-application/
隨著應用程序的增長,通常我們就會發現文件結構和組織對於應用程序代碼的可維護性來說就會變得非常重要。
在這篇文章里,我會介紹自己在項目中親自實踐的三條組織規則。通過遵循這些規則,你的應用程序代碼將會變得更加易讀,而且你會發現自己不用再把時間浪費在文件導航,頻繁重構以及 Bug 修復上了。
我希望這些建議,可以給那些想要改善應用結構卻不知從何入手的開發者們提供幫助。
項目結構的三條規則
接下來的內容就是關於構建一個項目的一些基本規則。需要注意的是,這些規則本身是跟框架和語言無關的,所以你在所有的情況下都應該可以遵循這些規則。但在這裡,我們是以 React 和 Redux 為例,熟悉這些框架將會很有幫助。
規則 #1: 基於特性進行組織
首先讓我們來看看不該做什麼,常見的一種方式就是根據對象角色來組織項目結構。
Redux + React:
actions/ todos.jscomponents/ todos/ TodoItem.js ...constants/ actionTypes.jsreducers/ todos.jsindex.jsrootReducer.js
AngularJS:
controllers/directives/services/templates/index.js
Ruby on Rails:
app/ controllers/ models/ views/
將類似的對象(Controller 和 Controller,Component 和 Component)組織在一起,這看似合情合理,但伴隨著應用的增長,這種結構將會不利於擴展。
每當你添加或修改特性的時候,你就會開始注意到某些部分的對象傾向於同時發生改變。將這些對象歸在一起可以共同構成一個特性模塊。比如說,在一個 Todo 應用里,每當你改變 reducers/todos.js 文件,很可能你也會改變 actions/todos.js 和 components/todos/*.js。
相反,為了不再把時間浪費在瀏覽目錄去尋找跟 todos 有關的文件,還是將它們放到同一個地方會明顯比較好。
一種更好的 React + Redux 項目文件目錄:
todos/ components/ actions.js actionTypes.js constants.js index.js reducer.jsindex.jsrootReducer.js
注意:我將會在文章後面的部分詳細描述這些文件的具體內容。
在一個大型項目當中,根據特性組織文件可以讓你專註於近在手邊兒的特性,而不會不得已而去擔心整個項目的導航。這就意味著,如果我需要修改 todos 相關的東西,我可以單獨工作在這個模塊而不用考慮應用的其他部分。從感覺上來說,這就像是在主應用程序裡面創建了另外一個應用程序。
從表面上來看,根據特性組織似乎看起來像是一種基於美學的考慮。但是,就如我們在接下來的兩個規則中所看到的那樣,這種構建項目的方式將會幫助簡化你的應用程序代碼。
規則 #2: 設計嚴格的模塊邊界
Rich Hickey 在他的 Ruby Conf 2012 演講 Simplicity Matters 中,將複雜度定義為一種編織(或交織)的東西。當你把模塊耦合在一起,你將會從代碼當中看到某種跟現實中的繩結或者辮子一樣的形態。
項目結構的複雜相關度就是,當你把一個對象靠近於另外一個對象,將其耦合到一起的障礙就會顯著減少。
作為示例,讓我們來給 TODO 應用添加一個新特性:我們想要根據 project 來管理 TODO 列表。這就意味著我們將要創建一個名為 projects 的新模塊。
projects/ components/ actions.js actionTypes.js reducers.js index.jstodos/index.js
現在,projects 模塊顯然會依賴於 todos。在這種情況下,嚴格約束,以及僅耦合於由 todos/index.js 所暴露的「公共」介面就變得非常重要。
BAD
import actions from "../todos/actions";import TodoItem from "../todos/components/TodoItem";
GOOD
import todos from "../todos";const { actions, TodoItem } = todos;
另外一件事就是避免跟其他模塊的狀態相耦合。比如說,在 projects 模塊內部,我們需要從 todos 的狀態裡面獲取信息從而渲染組件。那麼 todos 模塊就最好能給 projects 模塊暴露一個介面用於查詢信息,而不是讓這個組件和 todos 狀態交織在一起。
BAD
const ProjectTodos = ({ todos }) => ( <div> {todos.map(t => <TodoItem todo={t}/>)} </div>);// Connect to todos stateconst ProjectTodosContainer = connect( // state is Redux state, props is React component props. (state, props) => { const project = state.projects[props.projectID]; // This couples to the todos state. BAD! const todos = state.todos.filter( t => project.todoIDs.includes(t.id) ); return { todos }; })(ProjectTodos);
GOOD
import { createSelector } from "reselect";import todos from "../todos";// Same as beforeconst ProjectTodos = ({ todos }) => ( <div> {todos.map(t => <TodoItem todo={t}/>)} </div>);const ProjectTodosContainer = connect( createSelector( (state, props) => state.projects[props.projectID], // Let the todos module provide the implementation of the selector. // GOOD! todos.selectors.getAll, // Combine previous selectors, and provides final props. (project, todos) => { return { todos: todos.filter(t => project.todoIDs.includes(t.id)) }; } ))(ProjectTodos);
在「GOOD」的例子當中,projects 模塊並不用關心 todos 模塊內部的狀態。這是非常有用的,因為我們可以自由地改變 todos 狀態的結構,而不用擔心破壞其他依賴模塊。當然,我們依舊需要維護我們的 selector 契約,但另一種選擇則必須從一大堆不相干的組件中進行搜索並依次對其重構。
通過人為地設計嚴格的模塊邊界,我們可以簡化應用代碼,並且反過來增加應用的可維護性。無需涉及其他模塊的內部,我們應當思考模塊之間契約的形式和維護。
既然項目已經根據特性組織而成,並且在每個特性之間也有了清晰的邊界,那麼接下來就是我想要涉及的最後一件事:循環依賴。
規則 #3: 避免循環依賴
「循環依賴是很糟糕的」,這應該不用太費口舌就能讓你相信我說的話。但是,如果沒有適當的項目結構的話,還是會很容易就掉進了這個坑裡。
大多數時候,依賴在一開始的時候都是無害的。我們可能會認為 projects 模塊需要根據 todos 的 actions 來 reduce 一些狀態。如果我們沒有根據特性分組的話,然後我們就會在一個全局的 actionTypes.js 文件當中看到一個包含所有 action 類型的清單,這對我們來說,就很容易找到並且無需考慮就可以獲取我們所需要的(在當時)。
假設,在 todos 內部我們又想要根據 projects 的 action 類型來 reduce 狀態。如果我們已經有了一個全局的 actionTypes.js 文件的話,這應該已經足夠簡單了。但是很快我們就會明白,要是我們有了清晰的模塊邊界的話這些就不足掛齒了。為了說明原因,來看看以下的實例。
循環依賴示例
Given:
a.js
import b from "./b";export const name = "Alice";export default () => console.log(b);
b.js
import { name } from "./a";export default `Hello ${name}!`;
那麼接下來的代碼會發生什麼呢?
import a from "./a";a(); // ???
我們可能會期待 「Hello Alice!」 會被列印出來,但其實 a() 會輸出 「Hello undefined!」。這是因為 a 的命名導出,在 a 是由 b 引入的時候並不可用(由於循環引用)。
這裡隱含的意思就是,我們不能同時讓 projects 依賴於 todos 內部的 action 類型,並且 todos 又依賴於 projects 內部的 action 類型。你可以使用聰明的方式繞過這種限制,但要是你繼續這樣下去的話,我保證你會在將來的時候被坑的!
不要製造毛團!
換句話來說,製造循環依賴,你就是在用最糟糕的方式在打著繩子的結。想像一下一個模塊就是一縷頭髮,然後模塊之間相互依賴著形成了一個巨大的,混亂的毛團。
不論什麼時候,你想要使用這塊毛團中的一個小模塊,你都別無選擇只能陷入這種巨大的混亂當中。而且更糟糕的是,當你需要修改毛團當中的某些東西,要想不破壞其他東西的話就變得很難了。
遵循規則 #2,你就很難會製造出這種循環依賴了。不要與之對抗,而是用這份精力來適當地分解你的模塊。
深入的實例和規範推薦
接下來我想要深入到 Redux 和 React 應用當中不同文件的具體內容。這部分將會特別針對這些框架,要是你不感興趣的話可以隨你便跳過去。:)
讓我們來重新看看我們的 TODO 應用。(我在示例當中添加了 constants,model,以及 selectors)
todos/ components/ actions.js actionTypes.js constants.js index.js model.js reducer.js selectors.jsindex.jsrootReducer.js
我們將會根據他們的職責來拆分這些模塊。
模塊 index 和 常量
模塊 index 就是負責維護模塊的公共 API。這是模塊和模塊之間相互進行交互而暴露的地方。
一個最小化的 Redux + React 應用應該就會如下所示。
// todos/constants.js// This will be used later in our root reducer and selectorsexport const NAME = "todos";
// todos/index.jsimport * as actions from "./actions";import * as components from "./components";import * as constants from "./constants";import reducer from "./reducer";import * as selectors from "./selectors";export default { actions, components, constants, reducer, selectors };
注意:這跟 Ducks 架構有所類似。
Action 類型 & Action Creators
Action 類型在 Redux 當中只是一些字元串常量。唯一修改的地方就是我給每個類型都加上了 todos/ 前綴,以便於給這個模塊創造一個命名空間。這就避免了跟應用中其他模塊的名字發生衝突。
// todos/actionTypes.jsexport const ADD = "todos/ADD";export const DELETE = "todos/DELETE";export const EDIT = "todos/EDIT";export const COMPLETE = "todos/COMPLETE";export const COMPLETE_ALL = "todos/COMPLETE_ALL";;export const CLEAR_COMPLETED = "todos/CLEAR_COMPLETED";
至於 action creators,跟往常的 Redux 應用沒什麼太大改變。
// todos/actions.jsimport t from "./actionTypes";export const add = (text) => ({ type: t.ADD, payload: { text }});// ...
注意到我並沒有必要去使用 addTodo,因為我已經在這個 todos 模塊裡面了。在其他模塊里我也就可以像下面這樣使用一個 action creator。
import todos from "todos";// ...todos.actions.add("Do that thing");
Model
這個 model.js 文件是我想要存放一些跟模塊的狀態相關的東西的地方。
如果你使用 TypeScript 或者 Flow 的話,這將會尤其有用。
// todos/model.jsexport type Todo = { id?: number; text: string; completed: boolean;};// This is the model of our module state (e.g. return type of the reducer)export type State = Todo[];// Some utility functions that operates on our modelexport const filterCompleted = todos => todos.filter(t => t.completed);export const filterActive = todos => todos.filter(t => !t.completed);
Reducers
對於 reducers 來說,每個模塊都應該跟以前一樣只維護自己的狀態。但是這兒有一種特殊的耦合應當被解決,即一個模塊的 reducer 通常不會去決定它在哪裡被裝載到整個應用狀態原子當中。
這是不確定的,因為它意味著我們的模塊 selectors(我們接下來會涉及到)將會間接地耦合到根 reducer 當中。反過來,整個模塊的組件也將會被耦合到根 reducer 中來。
我們可以通過授權給 todos 模塊來解決這個問題,讓這個模塊來決定應該在哪裡被裝載到狀態原子。
// rootReducer.jsimport { combineReducers } from "redux";import todos from "./todos";export default combineReducers({ [todos.constants.NAME]: todos.reducer});
這就可以移除我們的 todos 模塊和根 reducer 之間的耦合。當然,你也不一定要通過這種方式。其他的選擇也包括依賴命名約定(比如,將 todos 模塊狀態裝載到使用 todos 作為 key 的狀態原子底下),或者你也可以使用模塊工廠函數而不是依賴於靜態 key。
然後 reducer 就可能長得跟下面一樣。
// todos/reducer.jsimport t from "./actionTypes";import { State } from "./model";const initialState: State = [{ text: "Use Redux", completed: false, id: 0}];export (state = initialState, action: any): State => { switch (action.type) { case t.ADD: return [ // ... ]; // ... }};
Selectors
Selectors 提供了從模塊狀態中查詢數據的一種方式。雖然它們不再像往常的 Redux 項目中所命名的那樣,但是它們永遠都是存在的。
connect 的第一個參數就是一個 selector,從狀態原子當中選擇想要的值,並且返回一個對象表示為一個組件的 props。
我想要強烈建議將公共的 selectors 放到這個 selectors.js 文件當中,以便於它們既可以在這個模塊裡面被複用,也可能被應用的其他模塊所用到。
我非常推薦你去看看 reselect,因為它提供了一種方式,可以用來構建可組合的 selectors,並且能夠自動 memoized。
// todos/selectors.jsimport { createSelector } from "reselect";import _ from "lodash";import { NAME } from "./constants";import { filterActive, filterCompleted } from "./model";export const getAll = state => state[NAME];export const getCompleted = _.compose(filterCompleted, getAll);export const getActive = _.compose(filterActive, getAll);export const getCounts = createSelector( getAll, getCompleted, getActive, (allTodos, completedTodos, activeTodos) => ({ all: allTodos.length, completed: completedTodos.length, active: activeTodos.length }));
Components
最後,我們有了自己的 React 組件。我建議你在組件當中儘可能地使用共享的 selectors。其中一個好處就是可以很輕鬆地對從 state 到 props 的 mapping 進行單元測試,而不用依賴於組件的測試。
這兒就有一個 TODO 列表組件的例子:
import { createStructuredSelector } from "reselect";import { getAll } from "../selectors";import TodoItem from "./TodoItem";const TodoList = ({ todos }) => ( <div> todos.map(t => <TodoItem todo={t}/>) </div>);export default connect( createStructuredSelector({ todos: getAll }))(TodoList);
這就是按照我所推薦規範的內容了。但是在我們結束之前,還有最後一個我想要討論的主題:如何發現項目壞味道。
項目結構的石蕊測試
對我們來說,用於發現我們的代碼壞味道的工具很重要。從經驗上來看,僅僅因為一個項目從開始的時候很整潔,但這並不意味著它會一直如此。因此,我想要提出一種簡單的方法用於發現項目結構的壞味道。
每隔一段時間,從你的應用當中挑選一個模塊,並且嘗試將它抽取成一個外部模塊(比如,一個 NodeJS 模塊,Ruby gem 等等)。你不用實際這麼去做,但至少像那樣去思考。如果你不用花太多 efforts 就可以完成抽取,那麼你就知道這個模塊已經被很好得分解了。在這裡的 「effort」 並沒有被下定義,所以你還是需要自己去衡量(無論是主觀或者客觀)。
在你的應用程序當中,跟其他模塊一起試驗一下。記下從實驗當中所找到的任何問題:循環依賴,模塊邊界不清晰,等等。
基於你的發現,無論你選擇採取何種操作都取決於你。畢竟,軟體行業就是一個與折衷息息相關的行業。但至少這應該會讓你對自己的項目結構有更深入的了解。
收尾
項目結構並不是一個特別令人興奮的話題討論。然而,這又是非常重要的。
這篇文章所描述的三條規則就是:
- 基於特性組織
- 設計嚴格的模塊邊界
- 避免循環依賴
無論你是否正在使用 Redux 和 Redux,我都非常推薦你在自己的軟體項目當中遵循這些規則。
推薦閱讀:
※[上海] 招前端,移動端,小程序開發(多圖)
※React 源碼剖析系列 - 不可思議的 react diff
※Vue2技術棧歸納與精粹
※2.2 webpack