近兩年前端技術的發展如火如荼,大量的前端專案都在使用或轉向 Vue 和 React 的陣營,由前端渲染頁面的單頁應用佔比也越來越高,這就代表前端工作的複雜度也在直線上升,前端頁面上展示的資訊越來越多也越來越複雜。我們知道,任何狀態都需要進行管理,那麼今天我們來聊聊前端狀態管理。
Virtual DOM 及 React 誕生
在 Web 應用開發中,AngularJS 扮演了重要角色。然而 AngularJS 資料和檢視的雙向繫結基於髒檢測的機制,在效能上存在短板,任何資料的變更都會重繪整個檢視。但是,由狀態反應檢視、自動更新頁面的思想是先進的,為了解決效能上的問題,Facebook 的工程師們提出了 Virtual DOM 的思想。將 DOM 放到記憶體中,state 發生變化的時候,根據 state
生成新的 Virtual DOM,再將它和之前的 Virtual DOM 通過一個 diff
演算法進行對比,將被改變的內容在瀏覽器中渲染,避免了 JS 引擎頻繁呼叫渲染引擎的 DOM 操作介面,充分利用了 JS 引擎的效能。有了 Virtual DOM 的支援,React 也誕生了。
有了 React,state => view
的思想也就有了很好的實踐,但反過來呢,怎麼在 view
中合理地修改 state
成為了一個新的問題,為此,Facebook 提出了 Flux 思想。
Flux 思想
是的,Flux 不是某一個 JS 庫的名稱,而是一種架構思想,很多 JS 庫則是這種思想的實現,例如 Alt、Fluxible 等,它用於構建客戶端 Web 應用,規範資料在 Web 應用中的流動方式。
那麼這個和狀態管理有什麼關係呢?我們知道,React 只是一個檢視層的庫,並沒有對資料層有任何的限制,換言之任何檢視元件中都可能存在改變資料層的程式碼,而過度放權對於資料層的管理是不利的,另外一旦資料層出現問題將會很難追溯,因為不知道變更是從哪些元件發起的。另外,如果資料是由父元件通過 props
的方式傳給子元件的話,元件之間會產生耦合,違背了模組化的原則。
我們以 AngularJS 應用為例,在 AngularJS 中,controller
是一個包含於作用域 $scope
的閉包,而這個閉包對應了一個檢視模板,$scope
中的資料將會被渲染到模板中。但是一個模板可能會對應到多個 model
(當前 controller
的 $scope
,父級 $scope
,指令的 isolated scope
等),同樣,一個 model
也可能影響到多個模板的渲染。應用規模一旦變大,資料和檢視的關係很容易混亂,由於這個過程中資料和檢視會互相影響,思維的負擔也會增加。
而 Flux 的思維方式是單向的,將之前放權到各個元件的修改資料層的 controller
程式碼收歸一處,統一管理,元件需要修改資料層的話需要去觸發特定的預先定義好的 dispatcher
,然後 dispatcher
將 action
應用到 model
上,實現資料層的修改。然後資料層的修改會應用到檢視上,形成一個單向的資料流。打個比方,這就像是圖書館的管理,原來是開放式的,所有人可以隨意進出書庫借書還書,如果人數不多,這種方式可以減少流程,增加效率,一旦人數變多就勢必造成混亂。Flux 就像是給這個圖書館加上了一個管理員,所有借書還書的行為都需要委託管理員去做,管理員會規範對書庫的操作行為,也會記錄每個人的操作,減少混亂的現象。
主要 Flux 實現
Flux 的實現有很多,不同的實現也各有亮點,下面介紹一些比較流行的 Flux 的實現。
Flux
這應該是 Flux 的一個比較官方”的實現,顯得中規中矩,實現了 Flux 架構文件裡的基本概念。它的核心是 Dispatcher
,通過 Dispatcher
,使用者可以註冊需要相應的 action
型別,對不同的 action
註冊對應的回撥,以及觸發 action
並傳遞 payload
資料。
下面是一個簡單示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
const dispatcher = new Dispatcher() const store = {books: []} dispatcher.register((payload) => { if (payload.actionType === 'add-book') { store.books.push(payload.newBook) } }) dispatcher.dispatch({ actionType: 'add-book', newBook: { name: 'cookbook' } }) |
可以看到,只使用 Flux 提供的 Dispatcher
也是可以的,不過推薦使用 Flux 提供的一些基礎類來構建 store
,這些基礎類提供了一些方法可供呼叫,能更好的擴充套件資料層的功能,具體使用方法可以參考 Flux 文件。
Reflux
Reflux 是在 Flux 的基礎上編寫的一個 Flux 實現,從形式上看,去掉了顯式的 Dispatcher
,將 action
表現為函式的形式,構建一個 action
的方式為:
1 2 3 4 5 6 7 |
const addBook = Reflux.createAction({ actionName: 'add-book', sync: false, preEmit: function() {/*...*/}, // ... }) addBook({/*...*/}) |
另外,Reflux 相比 Flux 有一些區別,例如:
依賴
首先 Flux 不是一個庫,而是一種架構思想,不過要使用 Flux 還是要引入一個 Dispatcher
,而 Reflux 則提供了一整套庫供你使用,可以方便地通過 npm
來安裝。
元件監聽事件
在元件內監聽事件的寫法上,Flux 和 Reflux 也有一些區別,在 Flux 中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
const _books = {} const BookStore = assign({}, EventEmitter.prototype, { emitChange () { this.emit(CHANGE_EVENT) }, addChangeListener (callback) { this.on(CHANGE_EVENT, callback) }, removeChangeListener (callback) { this.removeListener(CHANGE_EVENT, callback) } }) const Book = React.createClass({ componentDidMount:function(){ bookStore.addChangeListener(this.onAddBook) } }) |
而在 Reflux 中,寫法有些不同,它通過在元件中引入 Mixin
的方式使得在元件中可呼叫 listenTo
這個方法:
1 2 3 4 5 6 |
var BookStore = React.createClass({ mixins: [Reflux.ListenerMixin], componentDidMount: function() { this.listenTo(bookStore, this.onAddBook) } }) |
Store 和 Action 的寫法
在 Flux 中,初始化一個 Store
以及編寫 Action
都是比較麻煩的,這導致了程式碼量的增加,可維護性也會降低,例如我們仍然要寫一個 Store
和對應的 Action
,建立 Store
的寫法在上面的示例中已經有了,而建立 Action
在兩者之間區別也很大,首先是 Flux:
1 2 3 4 5 6 7 8 9 |
const fluxActions = { addBook: function(book) { Dispatcher.handleViewAction({ actionType: 'ADD_BOOK', book }) }, // more actions } |
Reflux 和 Flux 相比就簡單很多:
1 2 3 4 |
const refluxActions = Reflux.createActions([ 'addBook', // more actions ]) |
之所以 Reflux 會簡單這麼多,是因為它可以在 Store
中直接註冊事件的回撥函式,而去掉了 Dispatcher
這一中間層,或者說將 Dispatcher
的功能整合進了 Store
中。
總的來看,Reflux 相當於是 Flux 的改進版,補全了 Flux 在 Store
上缺少的功能,並去掉了 Dispatcher
(實際上並不是去掉,而是和 Store
合併),減少了冗餘的程式碼。
Redux
Redux 實際上相當於 Reduce + Flux,和 Flux 相同,Redux 也需要你維護一個資料層來表現應用的狀態,而不同點在於 Redux 不允許對資料層進行修改,只允許你通過一個 Action
物件來描述需要做的變更。在 Redux 中,去掉了 Dispatcher
,轉而使用一個純函式來代替,這個純函式接收原 state tree
和 action
作為引數,並生成一個新的 state tree
代替原來的。而這個所謂的純函式,就是 Redux 中的重要概念 —— Reducer
。
在函數語言程式設計中,Reduce 操作的意思是通過遍歷一個集合中的元素並依次將前一次的運算結果代入下一次運算,並得到最終的產物,在 Redux 中,reducer
通過合併計算舊 state
和 action
並得到一個新 state
則反映了這樣的過程。
因此,Redux 和 Flux 的第二個區別則是 Redux 不會修改任何一個 state
,而是用新生成的 state
去代替舊的。這實際上是應用了不可變資料(Immutable Data),在 reducer
中直接修改原 state
是被禁止的,Facebook 的 Immutable 庫可以幫助你使用不可變資料,例如構建一個可以在 Redux 中使用的 Store
。
下面是一個用 Redux 構建應用的狀態管理的示例:
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 27 28 29 30 |
const { List } = require('immutable') const initialState = { books: List([]) } import { createStore } from 'redux' // action const addBook = (book) => { return { type: ADD_BOOK, book } } // reducer const books = (state = initialState, action) => { switch (action.type) { case ADD_BOOK: return Object.assign({}, state, { books: state.books.push(action.book) }) } return state } // store const bookStore = createStore(books, initialState) // dispatching action store.dispatch(addBook({/* new book */})) |
Redux 的工作方式遵循了嚴格的單向資料流原則,從上面的程式碼示例中可以看出,整個生命週期分為:
- 在
store
中呼叫dispatch
,並傳入action
物件。action
物件是一個描述變化的普通物件,在示例中,它由一個creator
函式生成。 - 接下來,
store
會呼叫註冊store
時傳入的reducer
函式,並將當前的state
和action
作為引數傳入,在reducer
中,通過計算得到新的state
並返回。 store
將reducer
生成的新state
樹儲存下來,然後就可以用新的state
去生成新的檢視,這一步可以藉助一些庫的幫助,例如官方推薦的 React Redux。
如果一個應用規模比較大的話,可能會面臨 reducer
過大的問題。這時候我們可以對 reducer
進行拆分,例如使用 combineReducers
,將多個 reducer
作為引數傳入,生成新的 reducer
。當觸發一個 action
的時候,新 reducer
會觸發原有的多個 reducer
:
1 2 3 4 5 6 7 8 9 |
const book(state = [], action) => { // ... return newState } const author(state = {}, action) => { // ... return newState } const reducer = combineReducers({ book, author }) |
關於 Redux 的更多用法,可以仔細閱讀文件,這裡就不多介紹了。
React 技術棧中可用的狀態管理庫還有更多,例如 Relay,不過它需要配合 GraphQL,在沒有 GraphQL 的支援下不好引入,這裡就不多贅述了(其實是我沒有研究過)。
關於 React 中類 Flux 架構的狀態管理工具我們就先聊到這裡,接下來我們會聊到其他框架技術棧中的狀態管理工具,看看它們會有什麼特點。
Vuex
我們業務中使用 Vue 的比例是最高的,說到 Vue 中的狀態管理就不得不提到 Vuex。Vuex 也是基於 Flux 思想的產品,所以在某種意義上它和 Redux 很像,但又有不同,下面通過 Vuex 和 Redux 的對比來看看 Vuex 有什麼區別。
首先,和 Redux 中使用不可變資料來表示 state
不同,Vuex 中沒有 reducer
來生成全新的 state
來替換舊的 state
,Vuex 中的 state
是可以被修改的。這麼做的原因和 Vue 的執行機制有關係,Vue 基於 ES5 中的 getter/setter
來實現檢視和資料的雙向繫結,因此 Vuex 中 state
的變更可以通過 setter
通知到檢視中對應的指令來實現檢視更新。
另外,在 Vuex 中也可以記錄每次 state
改變的具體內容,state
的變更可被記錄與追蹤。例如 Vue 的官方除錯工具中就整合了 Vuex 的除錯工具,使用起來和 Redux 的除錯工具很相似,都可以根據某次變更的 state
記錄實現檢視快照。
上面說到,Vuex 中的 state
是可修改的,而修改 state
的方式不是通過 actions
,而是通過 mutations
。一個 mutation
是由一個 type
和與其對應的 handler
構成的,type
是一個字串型別用以作為 key
去識別具體的某個 mutation
,handler
則是對 state
實際進行變更的函式。
1 2 3 4 5 6 7 8 9 10 11 |
// store const store = { books: [] } // mutations const mutations = { [ADD_BOOKS](state, book) { state.books.push(book) } } |
那麼 action
呢?Vuex 中的 action
也是 store
的組成部分,它可以被看成是連線檢視與 state
的橋樑,它會被檢視呼叫,並由它來呼叫 mutation handler
,向 mutation
傳入 payload
。
這時問題來了,Vuex 中為什麼要增加 action
這一層呢,是多此一舉嗎?
當然不是,在知乎上有這樣一個問題可以當做很好的栗子:Vue.js中ajax
請求程式碼應該寫在元件的methods
中還是Vuex的actions
中?這個問題的答案並不唯一,但通過這個問題可以很好的說明一個 Vuex 的概念——mutation
必須是同步函式,而 action
可以包含任意的非同步操作。
回到這個問題本身,如果在檢視中不進行非同步操作(例如呼叫後端 API)只是觸發 action
的話,非同步操作將會在 action
內部執行:
1 2 3 4 5 |
const actions = { addBook({ commit }) { request.get(BOOK_API).then(res => commit(ADD_BOOK, res.body.new_book)) } } |
可以看出,這裡的狀態變更相當於是 action
產生的副作用,mutation
的作用是將這些副作用記錄下來,這樣就形成了一個完整資料流閉環,資料流的順序如下:
- 在檢視中觸發
action
,並根據實際情況傳入需要的引數。 - 在
action
中觸發所需的mutation
,在mutation
函式中改變state
。 - 通過
getter/setter
實現的雙向繫結會自動更新對應的檢視。
MobX
MobX 是一個比較新的狀態管理庫,它的前身是 Mobservable,實際上 MobX 相當於是 Mobservable 的 2.0 版本。它的上升勢頭很猛,在 React 社群中很受關注,在不久前剛結束的 React Conf 2017 中也有相關的分享(需梯子):Preethi Kasireddy – MobX vs Redux: Comparing the Opposing Paradigms – React Conf 2017。
如果閱讀視訊有一定的困難,建議閱讀這篇文章。
Mobx 和 Redux 相比,差別就比較大了。如果說 Redux 吸收併發揚了很多函數語言程式設計思想的話,Mobx 則更多體現了物件導向及的特點。MobX 的特點總結起來有以下幾點:
Observable
:它的state
是可被觀察的,無論是基本資料型別還是引用資料型別,都可以使用 MobX 的(@)observable
來轉變為observable value
。Reactions
:它包含不同的概念,基於被觀察資料的更新導致某個計算值(computed values),或者是傳送網路請求以及更新檢視等,都屬於響應的範疇,這也是響應式程式設計(Reactive Programming)在 JavaScript 中的一個應用。Actions
:它相當於所有響應的源頭,例如使用者在檢視上的操作,或是某個網路請求的響應導致的被觀察資料的變更。
和 Redux 對單向資料流的嚴格規範不同,Mobx 只專注於從 store
到 view
的過程。在 Redux 中,資料的變更需要監聽(可見上文 Redux 示例程式碼),而 Mobx 的資料依賴是基於執行時的,這點和 Vuex 更為接近。它的 store
組織起來大概像這樣:
1 2 3 4 5 6 7 |
class BookStore { books = [] @observable admin = '' @computed get availableBooks() { return this.books.filter(book => !book.isAvailable); } } |
和 Vuex 一樣,比較直觀。
而在修改資料方面,Mobx 的操作成本是最低的,它的 store
基於 class
實現,因此可以直接進行修改,不需要像 Vuex 一樣觸發 mutation
或是和 Redux 一樣呼叫 reducer
並返回新的 state
,對開發更友好。
那麼 Mobx 是怎麼將資料和檢視關聯起來的呢?我們知道,在 React 中,元件是由無狀態函式(stateless function)渲染的,我們只要在元件中加入 mobx-react 這個包提供的 (@)observer
函式(或使用 ES7 decorator
語法),就可以在 store
被改變時自動 re-render
引用了相應資料的 React 元件。
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 27 28 29 30 31 |
import React, {Component} from 'react' import ReactDOM from 'react-dom' import {observer} from 'mobx-react' @observer class BookStoreView extends Component { render() { return ( <div> <ul> {this.props.bookStore.books.map(book => <BookView book={book} author={book.author} /> )} </ul> </div> ) } } const BookView = observer(({book}) => <li> <input type="checkbox" checked={book.isAvailable} onClick={() => book.isAvailable = !book.isAvailable} />{book.title} </li> ) const store = new BookStore(); ReactDOM.render(<BookStoreView bookStore={store} />, document.getElementById('app')); |
可以看到,所有運算元據的方式在元件中直接進行。
雖然 Mobx 提供了便捷的程式碼書寫方式,但這樣容易造成 store
被隨意修改,在專案規模比較大的時候,像 Vuex 和 Redux 一樣對修改資料的入口進行限制可以提高安全性。在 Mobx 2.2 之後的版本中可以通過 useStrict
限制只能通過 action
對資料進行修改。
上文提到,Mobx 只專注於從 store
到 view
的過程,所以業務邏輯的規劃沒有一定的標準遵循,社群目前也沒有很好的最佳實踐,需要開發者們在實際開發中積累經驗,規劃好程式碼。
總結
前文的評論中有人提到,「 怎麼都是在說 React 」其實並不是作者本人偏愛 React,而且實際上在大前端內部 React 技術棧所佔比例並不算高。我們對待各個技術棧的態度是不站隊,而是以包容的心態學習不同技術的優點。
雖然大家對 React 的看法一度褒貶不一,但不可否認的是 Facebook 的技術團隊確實給前端界帶來了很大的技術衝擊,狀態管理這一塊的架構思想大多是由 React 社群所引導的,其他社群或多或少都受到了 React 的影響,並在他們的基礎上加以改進。前端技術也正是在這樣的氛圍中不斷髮展的。
另一方面,狀態管理的研究並不是前端領域獨有的問題,實際上前端狀態管理的很多思想都是借鑑於成熟很多的軟體開發體系。相對於軟體開發,前端還是一個很新的領域,只有多學習其他領域的優秀經驗前端界才能發展得更好。