隨著應用程式的增長,通常我們就會發現檔案結構和組織對於應用程式程式碼的可維護性來說就會變得非常重要。
在這篇文章裡,我會介紹自己在專案中親自實踐的三條組織規則。通過遵循這些規則,你的應用程式程式碼將會變得更加易讀,而且你會發現自己不用再把時間浪費在檔案導航,頻繁重構以及 Bug 修復上了。
我希望這些建議,可以給那些想要改善應用結構卻不知從何入手的開發者們提供幫助。
專案結構的三條規則
接下來的內容就是關於構建一個專案的一些基本規則。需要注意的是,這些規則本身是跟框架和語言無關的,所以你在所有的情況下都應該可以遵循這些規則。但在這裡,我們是以 React 和 Redux 為例,熟悉這些框架將會很有幫助。
規則 #1: 基於特性進行組織
首先讓我們來看看不該做什麼,常見的一種方式就是根據物件角色來組織專案結構。
Redux + React:
1 2 3 4 5 6 7 8 9 10 11 12 |
actions/ todos.js components/ todos/ TodoItem.js ... constants/ actionTypes.js reducers/ todos.js index.js rootReducer.js |
AngularJS:
1 2 3 4 5 |
controllers/ directives/ services/ templates/ index.js |
Ruby on Rails:
1 2 3 4 |
app/ controllers/ models/ views/ |
將類似的物件(Controller 和 Controller,Component 和 Component)組織在一起,這看似合情合理,但伴隨著應用的增長,這種結構將會不利於擴充套件。
每當你新增或修改特性的時候,你就會開始注意到某些部分的物件傾向於同時發生改變。將這些物件歸在一起可以共同構成一個特性模組。比如說,在一個 Todo 應用裡,每當你改變 reducers/todos.js 檔案,很可能你也會改變 actions/todos.js 和 components/todos/*.js。
相反,為了不再把時間浪費在瀏覽目錄去尋找跟 todos 有關的檔案,還是將它們放到同一個地方會明顯比較好。
一種更好的 React + Redux 專案檔案目錄:
1 2 3 4 5 6 7 8 9 |
todos/ components/ actions.js actionTypes.js constants.js index.js reducer.js index.js rootReducer.js |
注意:我將會在文章後面的部分詳細描述這些檔案的具體內容。
在一個大型專案當中,根據特性組織檔案可以讓你專注於近在手邊兒的特性,而不會不得已而去擔心整個專案的導航。這就意味著,如果我需要修改 todos 相關的東西,我可以單獨工作在這個模組而不用考慮應用的其他部分。從感覺上來說,這就像是在主應用程式裡面建立了另外一個應用程式。
從表面上來看,根據特性組織似乎看起來像是一種基於美學的考慮。但是,就如我們在接下來的兩個規則中所看到的那樣,這種構建專案的方式將會幫助簡化你的應用程式程式碼。
規則 #2: 設計嚴格的模組邊界
Rich Hickey 在他的 Ruby Conf 2012 演講 Simplicity Matters 中,將複雜度定義為一種編織(或交織)的東西。當你把模組耦合在一起,你將會從程式碼當中看到某種跟現實中的繩結或者辮子一樣的形態。
專案結構的複雜相關度就是,當你把一個物件靠近於另外一個物件,將其耦合到一起的障礙就會顯著減少。
作為示例,讓我們來給 TODO 應用新增一個新特性:我們想要根據 project 來管理 TODO 列表。這就意味著我們將要建立一個名為 projects 的新模組。
1 2 3 4 5 6 7 8 |
projects/ components/ actions.js actionTypes.js reducers.js index.js todos/ index.js |
現在,projects 模組顯然會依賴於 todos。在這種情況下,嚴格約束,以及僅耦合於由 todos/index.js 所暴露的「公共」介面就變得非常重要。
BAD
1 2 |
import actions from '../todos/actions'; import TodoItem from '../todos/components/TodoItem'; |
GOOD
1 2 |
import todos from '../todos'; const { actions, TodoItem } = todos; |
另外一件事就是避免跟其他模組的狀態相耦合。比如說,在 projects 模組內部,我們需要從 todos 的狀態裡面獲取資訊從而渲染元件。那麼 todos 模組就最好能給 projects 模組暴露一個介面用於查詢資訊,而不是讓這個元件和 todos 狀態交織在一起。
BAD
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
const ProjectTodos = ({ todos }) => ( <div> {todos.map(t => <TodoItem todo={t}/>)} </div> ); // Connect to todos state const 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
import { createSelector } from 'reselect'; import todos from '../todos'; // Same as before const 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
1 2 3 4 5 |
import b from './b'; export const name = 'Alice'; export default () => console.log(b); |
b.js
1 2 3 |
import { name } from './a'; export default `Hello ${name}!`; |
那麼接下來的程式碼會發生什麼呢?
1 2 3 |
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)
1 2 3 4 5 6 7 8 9 10 11 |
todos/ components/ actions.js actionTypes.js constants.js index.js model.js reducer.js selectors.js index.js rootReducer.js |
我們將會根據他們的職責來拆分這些模組。
模組 index 和 常量
模組 index 就是負責維護模組的公共 API。這是模組和模組之間相互進行互動而暴露的地方。
一個最小化的 Redux + React 應用應該就會如下所示。
1 2 3 4 |
// todos/constants.js // This will be used later in our root reducer and selectors export const NAME = 'todos'; |
1 2 3 4 5 6 7 8 |
// todos/index.js import * 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/ 字首,以便於給這個模組創造一個名稱空間。這就避免了跟應用中其他模組的名字發生衝突。
1 2 3 4 5 6 7 |
// todos/actionTypes.js export 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 應用沒什麼太大改變。
1 2 3 4 5 6 7 8 9 |
// todos/actions.js import t from './actionTypes'; export const add = (text) => ({ type: t.ADD, payload: { text } }); // ... |
注意到我並沒有必要去使用 addTodo,因為我已經在這個 todos 模組裡面了。在其他模組裡我也就可以像下面這樣使用一個 action creator。
1 2 3 4 5 |
import todos from 'todos'; // ... todos.actions.add('Do that thing'); |
Model
這個 model.js 檔案是我想要存放一些跟模組的狀態相關的東西的地方。
如果你使用 TypeScript 或者 Flow 的話,這將會尤其有用。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// todos/model.js export 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 model export const filterCompleted = todos => todos.filter(t => t.completed); export const filterActive = todos => todos.filter(t => !t.completed); |
Reducers
對於 reducers 來說,每個模組都應該跟以前一樣只維護自己的狀態。但是這兒有一種特殊的耦合應當被解決,即一個模組的 reducer 通常不會去決定它在哪裡被裝載到整個應用狀態原子當中。
這是不確定的,因為它意味著我們的模組 selectors(我們接下來會涉及到)將會間接地耦合到根 reducer 當中。反過來,整個模組的元件也將會被耦合到根 reducer 中來。
我們可以通過授權給 todos 模組來解決這個問題,讓這個模組來決定應該在哪裡被裝載到狀態原子。
1 2 3 4 5 6 7 |
// rootReducer.js import { combineReducers } from 'redux'; import todos from './todos'; export default combineReducers({ [todos.constants.NAME]: todos.reducer }); |
這就可以移除我們的 todos 模組和根 reducer 之間的耦合。當然,你也不一定要通過這種方式。其他的選擇也包括依賴命名約定(比如,將 todos 模組狀態裝載到使用 todos 作為 key 的狀態原子底下),或者你也可以使用模組工廠函式而不是依賴於靜態 key。
然後 reducer 就可能長得跟下面一樣。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// todos/reducer.js import 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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
// todos/selectors.js import { 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 列表元件的例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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,我都非常推薦你在自己的軟體專案當中遵循這些規則。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式