react-redux實踐總結

前端深夜告解室發表於2017-06-20

標題

作者:趙瑋龍 先後就職於麵包旅行,阿里體育,現在就職於美團。涉及技術範圍React, AngularJS, gulp, grunt, webpack, redux, canvas, node等,現在專注於前端react周邊技術棧研究

特此宣告:本篇文章都是圍繞react技術棧展開的如果你要跟我辯解是React,Vue,AngularJS之間區別,網路上文章數不勝數,可以移步別處.我假設你已經對react,redux有一些實戰經驗,基本的東西不會涉及

文章專著於如何儘量做到react-redux最佳實踐

redux的必要性:

Eric在Medium上發文噴setState
這件事情很早就引起熱議
其實無非就是setState的對於新手不友好以及文件的晦澀導致的
React抽象來說,就是一個公式
UI=f(state)
我們把最終繪製出來的UI當做一個函式f執行的結果,f就是React和我們基於React寫得程式碼,而f的輸入引數就是state。
作為React管理state的一個重要方法,setState肯定非常重要,如果只是簡單用法,也不會有任何問題,但是如果用得深,就會發現很……尷尬。
我剛開始接觸React的時候,就意識到React相當於一個jQuery的替代品,但是就像單獨依靠jQuery難以管理大型專案,所以也需要給配合使用的MVC框架找一個替代品,我選擇的替代品是Redux,我很早就將React和Redux配合使用;現在,回過頭來看看React的setState,發現坑真的不少,不禁感嘆自己還是挺走運的。
對setState用得深了,就容易犯錯,所以我們開門見山先把理解setState的關鍵點列出來。
  • setState不會立刻改變React元件中state的值;
  • setState通過引發一次元件的更新過程來引發重新繪製;
  • 多次setState函式呼叫產生的效果會合並。
    這幾個關鍵點其實是相互關聯的
setState不會立刻改變React元件中state的值(他是非同步觸發的,也就是考慮到列隊處理的必要性)
在React中,一個元件中要讀取當前狀態用是訪問this.state,但是更新狀態卻是用this.setState
如果需要同步我們還是更需要
函式式的setState用法
如果傳遞給this.setState的引數不是一個物件而是一個函式,那遊戲規則就變了。
這個函式會接收到兩個引數,第一個是當前的state值,第二個是當前的props,這個函式應該返回一個物件,這個物件代表想要對this.state的更改,換句話說,之前你想給this.setState傳遞什麼物件引數,在這種函式裡就返回什麼物件,不過,計算這個物件的方法有些改變,不再依賴於this.state,而是依賴於輸入引數state。
可以這麼寫一個函式。
function increment(state, props) {
  return {count: state.count + 1};
}複製程式碼
可以看到,同樣是把狀態中的count加1,但是狀態的來源不是this.state,而是輸入引數state。
對應incrementMultiple的函式就是這麼寫。
function incrementMultiple() {
  this.setState(increment);
  this.setState(increment);
  this.setState(increment);
}複製程式碼
對於多次呼叫函式式setState的情況,React會保證呼叫每次increment時,state都已經合併了之前的狀態修改結果。
簡單說,加入當前this.state.count的值是0,第一次呼叫this.setState(increment),傳給increment的state引數是0,第二呼叫時,state引數是1,第三次呼叫是,引數是2,最終incrementMultiple的效果,真的就是讓this.state.count變成了3,這個函式incrementMultiple終於實至名歸。
值得一提的是,在increment函式被呼叫時,this.state並沒有被改變,依然,要等到render函式被重新執行時(或者shouldComponentUpdate函式返回false之後)才被改變。
讓setState接受一個函式的API設計很棒!因為這符合函數語言程式設計的思想,讓開發者寫出沒有副作用的函式,我們的increment函式並不去修改元件狀態,只是把“希望的狀態改變”返回給React,維護狀態這些苦力活完全交給React去做。
正因為流程的控制權交給了React,所以React才能協調多個setState呼叫的關係。
讓我們再往前推進一步,試著如果把兩種setState的用法混用,那會有什麼效果?
我們把incrementMultiple改成這樣。
function incrementMultiple() {
  this.setState(increment);
  this.setState(increment);
  this.setState({count: this.state.count + 1});
  this.setState(increment);
}複製程式碼

在幾個函式式setState呼叫中插入一個傳統式setState呼叫(嗯,我們姑且這麼稱呼以前的setState使用方式),最後得到的結果是讓this.state.count增加了2,而不是增加4。

原因也很簡單,因為React會依次合併所有setState產生的效果,雖然前兩個函式式setState呼叫產生的效果是count加2,但是半路殺出一個傳統式setState呼叫,一下子強行把積攢的效果清空,用count加1取代。

這麼看來,傳統式setState的存在,會把函式式setState拖下水啊!只要有一個傳統式的setState呼叫,就把其他函式式setState呼叫給害了。

如果說setState這兒API將來如何改進,也許就該完全採用函式為引數的呼叫方法,廢止物件為引數的呼叫方法。

當然,React近期肯定不會有這樣的驚世駭俗的改變,但是大家可以先嚐試函式式setState用法,這才是setState的未來。

當然這還不是問題所在這裡

關注react的人都知道,facebook提出的flux單向資料流控制,業界也出現了很多類似的flux資料流實現方式

他們其實起到的作用無非是如何去在全域性狀態下不再讓你的組建分而治之,而是具有統一管理state的能力

現在業界比較火的是Mobx和Redux

先對比下兩個庫的實現方式:

那麼具體到這兩種模型,又有一些特定的優缺點呈現出來

先談談 Redux 的優勢:

  • 資料流流動很自然,因為任何 dispatch 都會導致廣播,需要依據物件引用是否變化來控制更新粒度。
  • 如果充分利用時間回溯的特徵,可以增強業務的可預測性與錯誤定位能力。
  • 時間回溯代價很高,因為每次都要更新引用,除非增加程式碼複雜度,或使用 immutable。
  • 時間回溯的另一個代價是 action 與 reducer 完全脫節,資料流過程需要自行腦補。原因是可回溯必然不能保證引用關係。
  • 引入中介軟體,其實主要為了解決非同步帶來的副作用,業務邏輯或多或少參雜著 magic。
  • 但是靈活利用中介軟體,可以通過約定完成許多複雜的工作。
  • 對 typescript 支援困難。

Mobx:

  • 資料流流動不自然,只有用到的資料才會引發繫結,區域性精確更新,但免去了粒度控制煩惱。
  • 沒有時間回溯能力,因為資料只有一份引用。
  • 自始至終一份引用,不需要 immutable,也沒有複製物件的額外開銷。
  • 沒有這樣的煩惱,資料流動由函式呼叫一氣呵成,便於除錯。
  • 業務開發不是腦力活,而是體力活,少一些 magic,多一些效率。
  • 由於沒有 magic,所以沒有中介軟體機制,沒法通過 magic 加快工作效率(這裡 magic 是指 action 分發到 reducer 的過程)。
  • 完美支援 typescript。

如何來確認這兩個庫的適用場景,實際中如果你的資料結構足夠複雜那麼還是redux帶來的靈活性以及資料管理模式更加自然,

mobx上手會更快,如果資料結構一般則比較建議這種方式

那麼當我們確認redux符合複雜業務場景後(後臺業務一般都是複雜業務場景的必發處)

如何善於利用redux為我們帶來更好的開發體驗和可維護性高的程式碼是這次探討的重點

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

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

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

如何優雅地寫非同步程式碼?(從簡單的資料請求到複雜的非同步邏輯)

狀態樹的結構應該怎麼設計?

狀態樹中的狀態越來越多,結構越來越複雜的時候,和 react #### 的元件對映如何避免混亂?

每次狀態的細微變化都會生成全新的 state #### 物件,其中大部分無變化的資料是不用重新克隆的,這裡如何提高效能?

如何拆分reducer?

state如何解耦選擇資料分片呢?

我只能從業務中一一示範,當然並不是上面的問題都解決了,而且以一個更好的方式解決了,這裡只做到拋磚引玉的作用

官方文件裡介紹了一種很樸素的非同步控制中介軟體 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);
    };
}

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

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

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

舉個簡單的例子

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

function upload(data){
    return dispatch => {
        // view
        dispatch({ type: 'SHOW_WAITING_MODAL' });
        // upload
        api.upload(data)
            .then(res => {
            // 成功
            dispatch({ type: 'PRELOAD_IMAGES', data: res.images });
            dispatch({ type: 'HIDE_WAITING_MODAL' });
            })
        .catch(err => {
            // 錯誤
            dispatch({ type: 'SHOW_ERROR', data: err });
            dispatch({ type: 'HIDE_WAITING_MODAL' });
            setTimeout(_ => dispatch({ type: 'HIDE_ERROR' }), 2000);
        })
    }
}複製程式碼

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

下面我們來看看如果換成 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)方法』,比如以下幾個

  • put(產生一個 action)
  • call(阻塞地呼叫一個函式)
  • fork(非阻塞地呼叫一個函式)
  • take(監聽且只監聽一次 action)
  • delay(延遲)
  • 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);
  }
}複製程式碼

但是我在專案中並沒有適用redux-saga一個是因為會增加組員的學習成本,一個是程式碼迭代過快造成的落差

所以我在程式碼中把請求非同步處理封裝成一個簡單的只有開始,成功,和錯誤處理的機制

import 'whatwg-fetch'
import handleError from './handleError'

// 設定一個symbol型別做為唯一的屬性名
export const CALL_API = Symbol('call_api')

const API_HOST = process.env.API_HOST || 'http://localhost:8080/pc'

export default store => next => action => {
  const callApi = action[CALL_API]
  if (typeof callApi === 'undefined') {
    return next(action)
  }

  // 獲取action中引數
  let { endpoint,
        types: [requestType, successType, failureType],
        method,
        body,
        ...options
      } = callApi
  let finalBody = body

  if (method) {
    options.method = method.toUpperCase()
  }
  if (typeof body === 'function') {
    finalBody = body(store.getState())
  }
  if (finalBody) {
    options.body = JSON.stringify(finalBody)
    options.headers = { 'content-type': 'application/json', 'agent': 'pc' }
  } else {
    options.headers = { 'cache-control': 'no-cache', 'agent': 'pc' }
  }
  // 替換action標記方法
  const actionWith = data => {
    const finalAction = Object.assign({}, action, data)
    delete finalAction[CALL_API]
    return finalAction
  }

  next(actionWith({ type:requestType }))

  return fetch(`${API_HOST}${endpoint}`,{
    credentials: 'include',
    ...options,
  })
  .then(response => {
    if (response.status === 204) {
      return { response }
    }
    const type = response.headers.get('content-type')
    if (type && type.split(';')[0] === 'application/json') {
      return response.json().then(json => ({ json, response }))
    }
    return response.text().then(text => ({ text, response }))
  })
  .then(({ json, text, response }) => {
    if (response.ok) {
      if (json) {
        if (json.status === 200 && json.data) {
          next(actionWith({ type: successType, payload: json.data }))
        } else if (json.status === 500) {
          next(actionWith({ type: successType, payload: json.msg }))
        } else {
          next(actionWith({ type: successType }))
        }
      }
    } else {
      if (json) {
        let error = { status: response.status }
        if (typeof json === 'object') {
          error = { ...error, ...json }
        } else {
          error.msg = json
        }
        throw error
      }
      const error = {
        name: 'FETCH_ERROR',
        status: response.status,
        text,
      }
      throw error
    }
  })
  .catch((error) => {
    next(actionWith({ type: failureType, error }))
    handleError(error)
  })
}複製程式碼

我們可以利用symbol定一個我們需要處理的機制然後去處理每次返回的結果,只是用到了redux-thunk 作為一個thunk函式去返回有副作用的請求

結構狀態state應該如何去設計呢?

我們考慮到官方給出的建議用entities去維護我們所需要的資料,因為業務中表單居多,並且表單複雜,

考慮到適用場景我們會根據reducer的概念去講解

reducer就是實現(state, action) => newState的純函式,也就是真正處理state的地方。值得注意的是,Redux並不希望你修改老的state,而且通過直接返回新state的方式去修改。

在講如何設計reducer之前,先介紹幾個術語:

reducer:實現(state, action) -> newState的純函式,可以根據場景分為以下好幾種

  • root reducer:根reducer,作為createStore的第一個引數
  • slice reducer:分片reducer,相對根reducer來說的。用來操作state的一部分資料。多個分片reducer可以合併成一個根reducer
  • higher-order reducer:高階reducer,接受reducer作為引數的函式/返回reducer作為返回值的函式。
  • case function:功能函式,接受指定action後的更新邏輯,可以是簡單的reducer函式,也可以接受其他引數。

reducer的最佳實踐主要分為以下幾個部分

  • 抽離工具函式,以便複用。
  • 抽離功能函式(case function),精簡reducer宣告部分的程式碼。
  • 根據資料類別拆分,維護多個獨立的slice reducer。
  • 合併slice reducer。
  • 通過crossReducer在多個slice reducer中共享資料。
  • 減少reducer的模板程式碼。

接下來,我們詳細的介紹每個部分

如何抽離工具函式?

抽離工具函式,幾乎在任何一個專案中都需要。要抽離的函式需要滿足以下條件:

純淨,和業務邏輯不耦合
功能單一,一個函式只實現一個功能

由於reducer都是對state的增刪改查,所以會有較多的重複的基礎邏輯,針對reducer來抽離工具函式,簡直恰到好處。

// 比如物件更新,淺拷貝
export const updateObject = (oldObj, newObj) => {
    return assign({}, oldObj, newObj);
}
// 比如物件更新,深拷貝
export const deepUpdateObject = (oldObj, newObj) => {
    return deepAssign({}, oldObj, newObj);
}複製程式碼

工具函式抽離出來,建議放到單獨的檔案中儲存。

如何抽離 case function 功能函式?

不要被什麼case function嚇到,直接給你看看程式碼你就清楚了,也是體力活,目的是為了讓reducer的分支判斷更清晰

// 抽離前,所有程式碼都揉到slice reducer中,不夠清晰
function appreducer(state = initialState, action) {
    switch (action.type) {
        case 'ADD_TODO':
            ...
            ...
            return newState;
        case 'TOGGLE_TODO':
            ...
            ...
            return newState;
        default:
            return state;
    }
}

// 抽離後,將所有的state處理邏輯放到單獨的函式中,reducer的邏輯格外清楚
function addTodo(state, action) {
    ...
    ...
    return newState;
}
function toggleTodo(state, action) {
    ...
    ...
    return newState;
}
function appreducer(state = initialState, action) {
    switch (action.type) {
        case 'ADD_TODO':
            return addTodo(state, action);
        case 'TOGGLE_TODO':
            return toggleTodo(state, action);
        default:
            return state;
    }
}複製程式碼

case function就是指定action的處理函式,是最小粒度的reducer。

抽離case function,可以讓slice reducer的程式碼保持結構上的精簡。

#### 如何設計slice reducer?

我們需要對state進行拆分處理,然後用對應的slice reducer去處理對應的資料,比如article相關的資料用articlesReducer去處理,paper相關的資料用papersReducer去處理。

這樣可以保證資料之間解耦,並且讓每個slice reducer保持程式碼清晰並且相對獨立。

比如業務中有shopInfo和bankInfol兩個類別的資料,我們拆分state並扁平化改造

export default (state = initialState, action) => {
  switch (action.type) {
    case allTypes.SHOPINFO_REQ:
      return {
        ...state,
        isloading: true,
      }
    case allTypes.SHOPINFO_SUCCESS:
      return {
        ...state,
        isloading: false,
        productId: action.payload.productId,
      }
    default:
      return state
}複製程式碼

注意一下這裡的解構對於shopinfo來說他並不感知到state的存在對於他來說他就是shop

那麼這裡的select對於要渲染的組建來講是一個道理,我們不敢知如何在組建中渲染,只是選擇我們這個分片中的資料

由於我們的state進行了扁平化改造,所以我們需要在case function中進行normalizr化。

根據state的拆分,設計出對應的slice reducer,讓他們對自己的資料分別管理,這樣後程式碼更便於維護,但也引出了兩個問題。

拆分多個slice reducer,但createStore只能接受一個reducer作為引數,所以我們怎麼合併這些slice reducer呢?

每個slice reducer只負責管理自身的資料,對state並不知情。那麼shop怎麼去改變state.entities的資料呢?

這兩個問題,分別引出了兩部分內容,分別是:slice reducer合併、slice reducer資料共享。

如何合併多個slice reducer?

redux提供了combineReducer方法,可以用來合併多個slice reducer,返回root reducer傳遞給createStore使用。直接上程式碼,非常簡單。

combineReducers({
    entities: entitiesreducer,

    // 對於shopReducer來說,他接受(state, action) => newState,
    // 其中的state,是shop,也就是state.shopinfo
    // 它並不能獲取到state的資料,更不能獲取到state.papers的資料
    shopinfo: shopinfoReducer,
    bankinfo: bankinfoReducer
})複製程式碼

傳遞給combineReducer的是key-value 鍵值對,其中鍵表示傳遞到對應reducer的資料,也就是說:slice reducer中的state並不是全域性state,而是state.articles/state.papers等資料。

結語

如果解決多個slice reducer間共享資料的問題?

slice reducer本質上是為了實現專門資料專門管理,讓資料管理更清晰。那麼slice reducer間如何共享資料呢?

如何在一個回傳資料中拿到另一個共享資料的的資料呢?透傳給一個reducer嗎?當然一點都不優雅。。。。預計下次再講

最後,團隊為了招聘方便,整了個公眾號,主要是一些招聘資訊,團隊資訊,所有的技術文章在公眾號裡也可以看到,對了,如果你想去美團其他團隊,我們也可以幫你內推哦 ~

二維碼
二維碼

相關文章