1. 程式人生 > >Redux的全家桶與最佳實踐

Redux的全家桶與最佳實踐

edux 的第一次程式碼提交是在 2015 年 5 月底(也就是一年多前的樣子),那個時候 React 的最佳實踐還不是明晰,作為一個 View 層,有人會用 backbone 甚至是 angular 和它搭配,也有人覺得這層 View 功能已經足夠強大,簡單地搭配一些 utils 就直接上。後來便有了 FLUX 的演講,React 社群開始注意到這種新的類似函數語言程式設計的理念,Redux 也作為 FLUX 的一種變體開始受到關注,再後來順理成章地得到 React 的『欽點』,作者也加入了 Facebook 從事 React 的開發。生態圈經過了這一年的成熟,現在很多第三方庫已經非常完善,所以這裡想介紹一下目前 Redux 的一些最佳實踐。


 

一、複習一下 Redux 的基本概念

首先我們複習一下 Redux 的基本概念,如果你已經很熟悉了,就直接跳過這一章吧。

Redux 把介面視為一種狀態機,介面裡的所有狀態、資料都可以由一個狀態樹來描述。所以對於介面的任何變更都簡化成了狀態機的變化:

(State, Input) => NewState

這其中切分成了三個階段:

  1. action
  2. reducer
  3. store

所謂的 action,就是用一個物件描述發生了什麼,Redux 中一般使用一個純函式,即 actionCreator 來生成 action 物件。

// actionCreator => action
// 這是一個純函式,只是簡單地返回 action
function somethingHappened(data){
    return {
        type: 'foo',
        data: data
    }
}

隨後這個 action 物件和當前的狀態樹 state 會被傳入到 reducer 中,產生一個新的 state

//reducer(action, state) => newState
function reducer(action, state){
    switch(action.type){
        case 'foo':
            return { data: data };
        default:
            return state;
    }
}

store 的作用就是儲存 state,並且監聽其變化。

簡單地說就是你可以這樣產生一個 store :

import { createStore } from 'redux'

//這裡的 reducer 就是剛才的 Reducer 函式
let store = createStore(reducer);

然後你可以通過 dispatch 一個 action 來讓它改變狀態:

store.getState(); // {}
store.dispatch(somethingHappened('aaa'));
store.getState(); // { data: 'aaa'}

好了,這就是 Redux 的全部功能。對的,它就是如此簡單,以至於它本體只有 3KB 左右的程式碼,因為它只是實現了一個簡單的狀態機而已,任何稍微有點程式設計能力的人都能很快寫出這個東西。至於和 React 的結合,則需要 react-redux 這個庫,這裡我們就不講怎麼用了。


 

二、Redux 的一些痛點

大體上,Redux 的資料流是這樣的:

介面 => action => reducer => store => react => virtual dom => 介面

每一步都很純淨,看起來很美好對吧?對於一些小小的嘗試性質的 DEMO 來說確實很美好。但其實當應用變得越來越大的時候,這其中存在諸多問題:

  1. 如何優雅地寫非同步程式碼?(從簡單的資料請求到複雜的非同步邏輯)
  2. 狀態樹的結構應該怎麼設計?
  3. 如何避免重複冗餘的 actionCreator?
  4. 狀態樹中的狀態越來越多,結構越來越複雜的時候,和 react 的元件對映如何避免混亂?
  5. 每次狀態的細微變化都會生成全新的 state 物件,其中大部分無變化的資料是不用重新克隆的,這裡如何提高效能?

你以為我會在下面一一介紹這些問題是怎麼解決的?還真不是,這裡大部分問題的回答都可以在官方文件中看到:技巧 | Redux 中文文件,文件裡講得已經足夠詳細(有些甚至詳細得有些囉嗦了)。所以下面只挑 Redux 生態圈裡幾個比較成熟且流行的元件來講講。


 

三、Redux 非同步控制

官方文件裡介紹了一種很樸素的非同步控制中介軟體 redux-thunk(如果你還不瞭解中介軟體的話請看 Middleware | Redux 中文文件,事實上 redux-thunk 的程式碼很簡單,簡單到只有幾行程式碼:

function createThunkMiddleware(extraArgument) {
    return ({ dispatch, getState }) => next => action => {
        if (typeof action === 'function') {
            return action(dispatch, getState, extraArgument);
        }
        return next(action);
    };
}

它其實只幹了一件事情,判斷 actionCreator 返回的是不是一個函式,如果不是的話,就很普通地傳給下一個中介軟體(或者 reducer);如果是的話,那麼把 dispatchgetStateextraArgument 作為引數傳入這個函式裡,實現非同步控制。

比如我們可以這樣寫:

//普通action
function foo(){
    return {
        type: 'foo',
        data: 123
    }
}

//非同步action
function fooAsync(){
    return dispatch => {
        setTimeout(_ => dispatch(123), 3000);
    }
}

但這種簡單的非同步解決方法在應用變得複雜的時候,並不能滿足需求,反而會使 action 變得十分混亂。

舉個比較簡單的例子,我們現在要實現『圖片上傳』功能,使用者點選開始上傳之後,顯示出載入效果,上傳完畢之後,隱藏載入效果,並顯示出預覽圖;如果發生錯誤,那麼顯示出錯誤資訊,並且在2秒後消失。

用普通的 redux-thunk 是這樣寫的:

function upload(data){
    return dispatch => {
    	// 顯示出載入效果
    	dispatch({ type: 'SHOW_WAITING_MODAL' });
    	// 開始上傳
    	api.upload(data)
    	    .then(res => {
    		// 成功,隱藏載入效果,並顯示出預覽圖
	    	dispatch({ type: 'PRELOAD_IMAGES', data: res.images });
	    	dispatch({ type: 'HIDE_WAITING_MODAL' });
	    	})
	    .catch(err => {
	    	// 錯誤,隱藏載入效果,顯示出錯誤資訊,2秒後消失
	    	dispatch({ type: 'SHOW_ERROR', data: err });
	    	dispatch({ type: 'HIDE_WAITING_MODAL' });
	    	setTimeout(_ => dispatch({ type: 'HIDE_ERROR' }), 2000);
	    })
    }
}

這裡的問題在於,一個非同步的 upload action 執行過程中會產生好幾個新的 action,更可怕的是這些新的 action 也是包含邏輯的(比如要判斷是否錯誤),這直接導致非同步程式碼中到處都是 dispatch(action),是很不可控的情況。如果還要進一步考慮取消、超時、佇列的情況,就更加混亂了。

所以我們需要更強大的非同步流控制,這就是 GitHub - yelouafi/redux-saga: An alternative side effect model for Redux apps。下面我們來看看如果換成 redux-saga 的話會怎麼樣:

import { take, put, call, delay } from 'redux-saga/effects'
// 上傳的非同步流
function *uploadFlow(action) {
	// 顯示出載入效果
  	yield put({ type: 'SHOW_WAITING_MODAL' });
  	// 簡單的 try-catch
  	try{
  	    const response = yield call(api.upload, action.data);
	    yield put({ type: 'PRELOAD_IMAGES', data: response.images });
	    yield put({ type: 'HIDE_WAITING_MODAL' });
  	}catch(err){
  	    yield put({ type: 'SHOW_ERROR', data: err });
	    yield put({ type: 'HIDE_WAITING_MODAL' });
	    yield delay(2000);
	  	yield put({ type: 'HIDE_ERROR' });
  	} 	
}


function* watchUpload() {
  yield* takeEvery('BEGIN_REQUEST', uploadFlow)
}

是不是規整很多呢?redux-saga 允許我們使用簡單的 try-catch 來進行錯誤處理,更神奇的是竟然可以直接使用 delay 來替代 setTimeout 這種會造成回撥和巢狀的不優雅的方法。

本質上講,redux-sage 提供了一系列的『副作用(side-effects)方法』,比如以下幾個:

  1. put(產生一個 action)
  2. call(阻塞地呼叫一個函式)
  3. fork(非阻塞地呼叫一個函式)
  4. take(監聽且只監聽一次 action)
  5. delay(延遲)
  6. race(只處理最先完成的任務)

並且通過 Generator 實現對於這些副作用的管理,讓我們可以用同步的邏輯寫一個邏輯複雜的非同步流。

下面這個例子出自於官方文件,實現了一個對於請求的佇列,即讓程式同一時刻只會進行一個請求,其它請求則排隊等待,直到前一個請求結束:

import { buffers } from 'redux-saga';
import { take, actionChannel, call, ... } from 'redux-saga/effects';

function* watchRequests() {
  // 1- 建立一個針對請求事件的 channel
  const requestChan = yield actionChannel('REQUEST');
  while (true) {
    // 2- 從 channel 中拿出一個事件
    const {payload} = yield take(requestChan);
    // 3- 注意這裡我們使用的是阻塞的函式呼叫
    yield call(handleRequest, payload);
  }
}

function* handleRequest(payload) { ... }

更多關於 redux-saga 的內容,請參考Read Me | redux-saga(中文文件:自述 | Redux-saga 中文文件)。


 

四、提高 selector 的效能

把 react 與 redux 結合的時候,react-redux 提供了一個極其重要的方法:connect,它的作用就是選取 redux store 中的需要的 state 與 dispatch, 交由 connect 去繫結到 react 元件的 props 中:

import { connect } from 'react-redux';
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'

// 我們需要向 TodoList 中注入一個名為 todos 的 prop
// 它通過以下這個函式從 state 中提取出來:
const mapStateToProps = (state) => {
    // 下面這個函式就是所謂的selector
    todos: state.todos.filter(i => i.completed)
    // 其它props...
}

const mapDispatchToProps = (dispatch) => {
	onTodoClick: (id) => {
		dispatch(toggleTodo(id))
	}
}

// 繫結到元件上
const VisibleTodoList = connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList

在這裡需要指定哪些 state 屬性被注入到 component 的 props 中,這是通過一個叫 selector 的函式完成的。

上面這個例子存在一個明顯的效能問題,每當元件有任何更新時都會呼叫一次 state.todos.filter來計算 todos,但我們實際上只需要在 state.todos 變化時重新計算即可,每次更新都重算一遍是非常不合適的做法。下面介紹的這個 reselect 就能幫你省去這些沒必要的重新計算。

你可能會注意到,selector 實際上就是一個『純函式』

selector(state) => some props

而純函式是具有可快取性的,即對於同樣的輸入引數,永遠會得到相同的輸出值(如果對這個不太熟悉的同學可以參考我之前寫的JavaScript函數語言程式設計(一) - 一隻碼農的技術日記 - 知乎專欄,reselect 的原理就是如此,每次呼叫 selector 函式之前,它會判斷引數與之前快取的是否有差異,若無差異,則直接返回快取的結果,反之則重新計算:

import { createSelector } from 'reselect';

var state = {
    a: 100
}

var naiveSelector = state => state.a;

// mySelector 會快取輸入 a 對應的輸出值
var mySelector = createSelector(
	naiveSelector, 
	a => {
	   console.log('做一次乘法!!!');
	   return a * a;
	}
)

console.log(mySelector(state));	// 第一次計算,需要做一次乘法
console.log(mySelector(state));	// 輸入值未變化,直接返回快取的結果
console.log(mySelector(state));	// 同上
state.a = 5;							// 改變 a 的值
console.log(mySelector(state));	// 輸入值改變,做一次乘法
console.log(mySelector(state));	// 輸入值未變化,直接返回快取的結果
console.log(mySelector(state));	// 同上

上面的輸出值是:

做一次乘法!!!
10000
10000
10000
做一次乘法!!!
25
25
25

之前那個關於 todos 的範例可以這樣改,就可以避免 todos 陣列被重複計算的效能問題:

import { createSelector } from 'reselect';
import { connect } from 'react-redux';
import { toggleTodo } from '../actions'
import TodoList from '../components/TodoList'

const todoSelector = createSelector(
	state => state.todos,
	todos => todos.filter(i => i.completed)
)

const mapStateToProps = (state) => {
    todos: todoSelector
    // 其它props...
}

const mapDispatchToProps = (dispatch) => {
	onTodoClick: (id) => {
		dispatch(toggleTodo(id))
	}
}

// 繫結到元件上
const VisibleTodoList = connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

export default VisibleTodoList

更多可以參考 GitHub - reactjs/reselect: Selector library for Redux


 

五、減少冗餘程式碼

redux 中的 action 一般都類似這樣寫:

function foo(data){
	return {
		type: 'FOO',
		data: data
	}
}

//或者es6寫法:
var foo = data => ({ type: 'FOO', data})

當應用越來越大之後,action 的數量也會大大增加,為每個 action 物件顯式地寫上 type 和 data 或者其它屬性會造成大量的程式碼冗餘,這一塊是完全可以優化的。

比如我們可以寫一個最簡單的 actionCreator:

function actionCreator(type){
    return function(data){
	return {
	    type: type,
	    data: data
	}
    }
}

var foo = actionCreator('FOO');
foo(123); // {type: 'FOO', data: 123} 

redux-actions 就可以為我們做這樣的事情,除了上面這種樸素的做法,它還有其它比較好用的功能,比如它提供的 createActions 方法可以接受不同型別的引數,以產生不同效果的 actionCreator,下面這個範例來自官方文件:

import { createActions } from 'redux-actions';

const { actionOne, actionTwo, actionThree } = createActions({
  // 函式型別
  ACTION_ONE: (key, value) => ({ [key]: value }),

  // 陣列型別
  ACTION_TWO: [
    (first) => first,               // payload
    (first, second) => ({ second }) // meta
  ],

  // 最簡單的字串型別
}, 'ACTION_THREE');

actionOne('key', 1));
//=>
//{
//  type: 'ACTION_ONE',
//  payload: { key: 1 }
//}

actionTwo('Die! Die! Die!', 'It\'s highnoon~');
//=>
//{
//  type: 'ACTION_TWO',
//  payload: ['Die! Die! Die!'],
//  meta: { second: 'It\'s highnoon~' }
//}

actionThree(76);
//=>
//{
//  type: 'ACTION_THREE',
//  payload: 76,
//}

更多可以參考 GitHub - acdlite/redux-actions: Flux Standard Action utilities for Redux.


 

六、更多

其實還有太多 Redux 生態圈中的輪子沒拿出來講,比如:

  1. GitHub - paularmstrong/normalizr: Normalizes nested JSON according to a schema(可以用於正規化化狀態樹)
  2. GitHub - acdlite/redux-rx: RxJS utilities for Redux.(用於引入 RxJS 的響應式程式設計思想)
  3. GitHub - acdlite/redux-promise: FSA-compliant promise middleware for Redux.(Redux 中介軟體,用於處理 Promise)