redux-react實踐總結
前言
今日早讀文章由美團點評 @ 趙瑋龍投稿分享。
正文從這開始~
假設你已經對react,redux有一些實戰經驗,基本的東西不會涉及,文章專著於如何盡量做到react-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。
可以這麼寫一個函數:
functionincrement(state,props){
return{count:state.count+1};
}
可以看到,同樣是把狀態中的count加1,但是狀態的來源不是this.state,而是輸入參數state。
對應incrementMultiple的函數就是這麼寫。
functionincrementMultiple(){
this.setState(increment);
this.setState(increment);
this.setState(increment);
}
對於多次調用函數式setState的情況,React會保證調用每次increment時,state都已經合并了之前的狀態修改結果。
值得一提的是,在increment函數被調用時,this.state並沒有被改變,依然,要等到render函數被重新執行時(或者shouldComponentUpdate函數返回false之後)才被改變。
讓setState接受一個函數的API設計很棒!因為這符合函數式編程的思想,讓開發者寫出沒有副作用的函數,我們的increment函數並不去修改組件狀態,只是把「希望的狀態改變」返回給React,維護狀態這些苦力活完全交給React去做。
正因為流程的控制權交給了React,所以React才能協調多個setState調用的關係。
讓我們再往前推進一步,試著如果把兩種setState的用法混用,那會有什麼效果?
我們把incrementMultiple改成這樣。
原因也很簡單,因為React會依次合并所有setState產生的效果,雖然前兩個函數式setState調用產生的效果是count加2,但是半路殺出一個傳統式setState調用,一下子強行把積攢的效果清空,用count加1取代。
這麼看來,傳統式setState的存在,會把函數式setState拖下水啊!只要有一個傳統式的setState調用,就把其他函數式setState調用給害了。
如果說setState這兒API將來如何改進,也許就該完全採用函數為參數的調用方法,廢止對象為參數的調用方法。
當然,React近期肯定不會有這樣的驚世駭俗的改變,但是大家可以先嘗試函數式setState用法,這才是setState的未來。
當然這還不是全部的問題所在,全局的state狀態,可以使得多個組建共享狀態才是我們所期望的
關注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 的代碼很簡單,簡單到只有幾行代碼:
functioncreateThunkMiddleware(extraArgument){
return({dispatch,getState})=>next=>action=>{
if(typeofaction=== function ){
returnaction(dispatch,getState,extraArgument);
}
returnnext(action);
};
}
比如我們可以這樣寫:
//普通action
functionfoo(){
return{
type: foo ,
data:123
}
}
//非同步action
functionfooAsync(){
returndispatch=>{
setTimeout(_=>dispatch(123),3000);
}
}
但這種簡單的非同步解決方法在應用變得複雜的時候,並不能滿足需求,反而會使 action 變得十分混亂。
舉個簡單的例子
用普通的 redux-thunk 是這樣寫的:
functionupload(data){
returndispatch=>{
// 顯示出載入效果
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),是很不可控的情況。如果還要進一步考慮取消、超時、隊列的情況,就更加混亂了。
下面我們來看看如果換成 redux-saga 的話會怎麼樣:
import{take,put,call,delay}from redux-saga/effects
// 上傳的非同步流
function*uploadFlow(action){
// 顯示出載入效果
yieldput({type: SHOW_WAITING_MODAL });
// 簡單的 try-catch
try{
constresponse=yieldcall(api.upload,action.data);
yieldput({type: PRELOAD_IMAGES ,data:response.images});
yieldput({type: HIDE_WAITING_MODAL });
}catch(err){
yieldput({type: SHOW_ERROR ,data:err});
yieldput({type: HIDE_WAITING_MODAL });
yielddelay(2000);
yieldput({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
constrequestChan=yieldactionChannel( REQUEST );
while(true){
// 2- 從 channel 中拿出一個事件
const{payload}=yieldtake(requestChan);
// 3- 注意這裡我們使用的是阻塞的函數調用
yieldcall(handleRequest,payload);
}
}
但是我在項目中並沒有適用redux-saga一個是因為會增加組員的學習成本,一個是代碼迭代過快造成的落差
所以我在代碼中把請求非同步處理封裝成一個簡單的只有開始,成功,和錯誤處理的機制的中間件。
import whatwg-fetch
importhandleErrorfrom ./handleError
// 設定一個symbol類型做為唯一的屬性名
exportconstCALL_API=Symbol( call_api )
constAPI_HOST=process.env.API_HOST|| http://localhost:8080/pc
exportdefaultstore=>next=>action=>{
constcallApi=action[CALL_API]
if(typeofcallApi=== undefined ){
returnnext(action)
}
// 獲取action中參數
let{endpoint,
types:[requestType,successType,failureType],
method,
body,
...options
}=callApi
letfinalBody=body
if(method){
options.method=method.toUpperCase()
}
if(typeofbody=== 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標記方法
constactionWith=data=>{
constfinalAction=Object.assign({},action,data)
deletefinalAction[CALL_API]
returnfinalAction
}
next(actionWith({type:requestType}))
returnfetch(`${API_HOST}${endpoint}`,{
credentials: include ,
...options,
})
.then(response=>{
if(response.status===204){
return{response}
}
consttype=response.headers.get( content-type )
if(type&&type.split( ; )[]=== application/json ){
returnresponse.json().then(json=>({json,response}))
}
returnresponse.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}))
}elseif(json.status===500){
next(actionWith({type:successType,payload:json.msg}))
}else{
next(actionWith({type:successType}))
}
}
}else{
if(json){
leterror={status:response.status}
if(typeofjson=== object ){
error={...error,...json}
}else{
error.msg=json
}
throwerror
}
consterror={
name: FETCH_ERROR ,
status:response.status,
text,
}
throwerror
}
})
.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來抽離工具函數,簡直恰到好處。
// 比如對象更新,淺拷貝
exportconstupdateObject=(oldObj,newObj)=>{
returnassign({},oldObj,newObj);
}
// 比如對象更新,深拷貝
exportconstdeepUpdateObject=(oldObj,newObj)=>{
returndeepAssign({},oldObj,newObj);
}
工具函數抽離出來,建議放到單獨的文件中保存。
如何抽離casefunction功能函數?
不要被什麼casefunction嚇到,直接給你看看代碼你就清楚了,也是體力活,目的是為了讓reducer的分支判斷更清晰。
// 抽離前,所有代碼都揉到slice reducer中,不夠清晰
functionappreducer(state=initialState,action){
switch(action.type){
case ADD_TODO :
...
...
returnnewState;
case TOGGLE_TODO :
...
...
returnnewState;
default:
returnstate;
}
}
// 抽離後,將所有的state處理邏輯放到單獨的函數中,reducer的邏輯格外清楚
functionaddTodo(state,action){
...
...
returnnewState;
}
functiontoggleTodo(state,action){
...
...
returnnewState;
}
functionappreducer(state=initialState,action){
switch(action.type){
case ADD_TODO :
returnaddTodo(state,action);
case TOGGLE_TODO :
returntoggleTodo(state,action);
default:
returnstate;
}
}
case function就是指定action的處理函數,是最小粒度的reducer。
抽離case function,可以讓slice reducer的代碼保持結構上的精簡。
如何設計slice reducer?
我們需要對state進行拆分處理,然後用對應的slice reducer去處理對應的數據,比如article相關的數據用articlesReducer去處理,paper相關的數據用papersReducer去處理。
這樣可以保證數據之間解耦,並且讓每個slice reducer保持代碼清晰並且相對獨立。
比如業務中有shopInfo和bankInfol兩個類別的數據,我們拆分state並扁平化改造
{
// 扁平化
entities:{
productId:{
shopInfo:{},
bankInfo:{}
}
},
// 按類別拆分數據
shopReducer:{
list:[productId]
},
bankInfoReducer:{
list:[productId]
}
}
為了對state.bankInfo和state.shopInfo分別進行管理,我們設計兩個slice reducer
constinitialState={
isloading:false,
productId:null,
error: ,
}
exportconstselectAccountInfo=state=>state.shopInfoReducer
exportdefault(state=initialState,action)=>{
switch(action.type){
caseallTypes.SHOPINFO_REQ:
return{
...state,
isloading:true,
}
caseallTypes.SHOPINFO_SUCCESS:
return{
...state,
isloading:false,
productId:action.payload.productId,
}
default:
returnstate
}
注意一下,這裡的解構對於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嗎?當然一點都不優雅。。。。
關於本文
作者:@趙瑋龍
先後就職於麵包旅行、阿里體育。熟悉技術棧React,AngularJs,gulp,grunt,webpack,redux,node 等。現在專註於react周邊技術棧研發


※【第994期】字型大小與行高
※總是一知半解的Event Loop
※美團金融大前端團隊招各級別前端工程師
※手把手教你用ngrx管理Angular狀態(下)
※手把手教你用ngrx管理Angular狀態(上)
TAG:前端早讀課 |
※詳解react、redux、react-redux之間的關係
※React, Redux 和 React Router 速查表
※Nike 打造 Air Force 1 「Nautical Redux」 系列
※如何使用react-redux-form填充動態默認值
※2億用戶背後的Flutter應用框架Fish Redux
※《俠盜獵車5》Redux畫面MOD更新 物理效果進步
※英國車廠 Redux 打造 BMW E30 M3 性能強化改裝版本
※你知道什麼時候以及什麼時候不使用Redux