現在的 react 的狀態管理工具基本上分爲 redux 和 mobx 兩個流派,mobx 基本上你們都是使用官方的 mobx 庫,可是對於 redux 卻衍生數不勝數的 redux 框架。如redux-saga
, dva
, mirror
, rematch
等等,這麼多 redux 的框架一方面說明 redux 是如此流行,另外一方面也代表 redux 自身的先天不足,筆者本人也是從最初的刀耕火種時代一路走來。html
// action_constant.js
// action_creator.js
// action.js
// reducer.js
// store.js
// 再加上一堆的middleware
複製代碼
每次改一點業務動輒就須要改四五個文件,着實使人心累,並且不一樣業務對 redux 文件的組織方式也不一樣,用的按照組件進行組織,有的按照功能進行組織,每次看新的業務都得熟悉半天,對異步的支持也基本上就使用 redux-thunk、redux-promise 等,遇到複雜的異步處理,代碼十分的晦澀難懂。react
後來社區爲了不每次修改都要修改一堆文件和制定文件規範,推出了 ducks-modular-redux 規範,將每一個子 module 的文件都放置到一個文件裏,這樣大大簡化了平常開發中一些冗餘工做。git
// widgets.js
// Actions
const LOAD = 'my-app/widgets/LOAD';
const CREATE = 'my-app/widgets/CREATE';
const UPDATE = 'my-app/widgets/UPDATE';
const REMOVE = 'my-app/widgets/REMOVE';
// Reducer
export default function reducer(state = {}, action = {}) {
switch (action.type) {
// do reducer stuff
default: return state;
}
}
// Action Creators
export function loadWidgets() {
return { type: LOAD };
}
export function createWidget(widget) {
return { type: CREATE, widget };
}
export function updateWidget(widget) {
return { type: UPDATE, widget };
}
export function removeWidget(widget) {
return { type: REMOVE, widget };
}
// side effects, only as applicable
// e.g. thunks, epics, etc
export function getWidget () {
return dispatch => get('/widget').then(widget => dispatch(updateWidget(widget)))
}
複製代碼
筆者的以前維護的一個老項目至今仍然採用這種方式。github
duck modular proposal 雖然必定程度上減少了維護成本,但本質上並無減少每次開發業務的代碼量,異步等問題仍然沒有獲得解決,所以開始衍生出了一大堆的基於 redux 的框架,重點在於解決簡化樣板代碼量和複雜異步流程的處理。
樣板代碼簡化的思路基本上是一致的。咱們發現絕大部分的業務 model 都知足以下性質typescript
const model = createModel({
name: // 全局的key
state:xxx, // 業務狀態
reducers:xxx, // 同步的action
effects:xxxx, // 異步的action
computed: xxx // state的衍生數據
}
複製代碼
所以絕大部分框架的都採用了相似的定義,區別只在於語法和名稱有所不一樣redux
// dva.js
export default {
namespace: 'products',
state: [],
reducers: {
'delete'(state, { payload: id }) {
return state.filter(item => item.id !== id);
},
},
effects: {
*add(action, { call, put }) {
yield call(delay, 1000);
yield put({ type: 'minus' });
}
}
};
複製代碼
export const count = {
state: 0, // initial state
reducers: {
// handle state changes with pure functions
increment(state, payload) {
return state + payload
}
},
effects: (dispatch) => ({
// handle state changes with impure functions.
// use async/await for async actions
async incrementAsync(payload, rootState) {
await new Promise(resolve => setTimeout(resolve, 1000))
dispatch.count.increment(payload)
}
})
}
複製代碼
二者的區別主要在於對異步的處理,dva 選擇了用 generator,而 rematch 選擇了用 async/await。
首先咱們回顧一下 redux-thunk 裏是如何處理異步流的promise
const fetch_data = url => (dispatch, getState) =>{
dispatch({
type: 'loading',
payload: true
})
fetch(url).then((response) => {
dispatch({
type: 'data',
payload: response
})
dispatch({
type: 'loading',
payload: false
})
}).catch((err) => {
dispatch({
type: 'error',
payload: err.message
})
dispatch({
type: 'loading',
payload: false
})
})
}
複製代碼
一個簡單的拉取數據的邏輯就顯得如此繁雜,更別提如何將多個異步 action 組合起來構成更加複雜的業務邏輯了(我已經不知道咋寫了)
async/await 和 generator 的最大優勢在於 1. 其可使用看似同步的方式組織異步流程 2. 各個異步流程可以很容易的組合到一塊兒。具體使用哪個全看我的喜愛了。
如上面一樣的邏輯在 rematch 裏的寫法以下瀏覽器
const todo = createModel({
effects: ({todo}) => ({
async fetch_data(url) {
todo.setLoading(true);
try {
const response = fetch(url);
todo.setLoading(false);
}catch(err){
todo.setLoading(false);
todo.setError(err.message)
}
},
async serial_fetch_data_list(url_list){
const result = []
for(const url of url_list){
const resp = await todo.fetch_data(url);
result.push(resp);
}
return result;
}
})
})
複製代碼
得益於 async/await 的支持,如今不管是異步 action 自己的編寫仍是多個異步 action 的組合如今都不是問題了。安全
咱們如今的絕大部分新業務,基本上都仍是採用 rematch,相比以前純 redux 的開發體驗,獲得了很大的改善,可是仍然不是盡善盡美,仍然存在以下一些問題。markdown
9102 年了,Typescript 已經大大普及,稍微上點規模的業務,Typescript 的使用已是大勢所趨,Typescript 的好處就很少贅述,咱們基本上全部的業務都是使用 Typescript 進行開發,在平常開發過程當中基本上碰到的最大問題就是庫的支持。
俗話所說,Typescript 坑不太多(其實也多),庫的坑不太多,可是 Typescript 和庫結合者使用,坑就不少了。很不幸 Dva 和 Rematch 等都缺少對 Typescript 的良好支持,對平常業務開發形成了不小的影響,筆者就曾經針對如何修復 Rematch 的類型問題,寫過一篇文章 zhuanlan.zhihu.com/p/78741920 ,可是這仍然是個 hack 的辦法,dva 的 ts 支持就更差了,generator 的類型安全在 ts3.6 版本才得以充分支持(還有很多 bug),至今也沒看到一個能較完美支持 ts 的 dva 例子。
redux 能夠說是 Batteries Included 的標準反例了,爲了保證本身的純粹,一方面把異步處理這個髒活,所有交給了中間件,這致使搞出了一堆的第三方的異步處理方案,另外一方面其不肯作更高的抽象,致使須要編寫一堆的 boilerplate code 還致使了各類寫法。所以對於平常的業務開發來說,一個 Batteries Included 庫就足夠重要了,即保證了編碼規範,也簡化了業務方的使用。
Computed State 和 immutable 就是平常開發中很是重要的 feature,可是 rematch 把兩個功能都交給插件去完成,致使平常使用不夠方便和第三方插件的 TS 支持也不盡如人意。
現在 react 的狀態和業務邏輯基本上存在於三種形態
rematch 對 redux 的狀態管理方式基本上作到了最簡,可是其僅僅只能用於 redux 狀態的管理,對於 local state 的管理卻迫不得已。
對於大部分的簡單業務,local state 的管理並不麻煩,基本上就是控制一些彈窗的展現,loading 的展現,在用 class 組件來控制業務邏輯時,處理方式也較爲簡單
class App extends React.Component {
state = {
loading: false,
data: null,
err: null
}
async componentDidMount() {
this.setState({loading: true})
try {
const result = await service.fetch_data()
this.setState({
loading:false
})
}catch(err){
this.setState({loading: false, error: err.message})
}
}
render(){
if(this.state.loading){
return <div>loading....</div>
}else{
return <div>{this.sstate.data}</div>
}
}
}
複製代碼
這裏的組件其實同時扮演了三個角色
state = {
loading: false,
data: null,
err: null
}
複製代碼
async componentDidMount() {
this.setState({loading: true})
try {
const result = await service.fetch_data()
this.setState({
loading:false
})
}catch(err){
this.setState({loading: false, error: err.message})
}
}
複製代碼
render(){
if(this.state.loading){
return <div>loading....</div>
}else{
return <div>{this.sstate.data}</div>
}
}
複製代碼
這種作法有利有弊,好處在於其足夠的 locality, 由於狀態,狀態處理,渲染這幾部分是緊密關聯的,將它們放在一塊兒,閱讀代碼的看到這段代碼,很天然的就能看懂
可是一個組件放置了太多的功能就致使其複用很困難。
所以衍生出了不一樣的複用方式
第一種複用方式就是經過狀態容器組件和視圖組件將狀態 && 狀態處理與 view 的邏輯進行分離,
容器組件只負責處理狀態 && 狀態處理,視圖組件只負責展現的邏輯,這樣作法的最大好處在於視圖組件的複用極爲方便。
UI 組件庫可謂是這方面的極致了,咱們將一些經常使用視圖組件提取出來構成組件庫,大部分的 UI 組件,沒有狀態,或者一些非受控的組件有一些內部狀態。這種組件庫極大的簡化了平常的 UI 開發。上面的組件能夠重構以下
// 視圖組件
class Loading extends React.Component {
render(){
if(this.props.loading){
return <div>loading....</div>
}else{
return <div>{this.props.data}</div>
}
}
}
// 容器組件
class LoadingContainer extends React.Component {
state = {
loading: false,
data: null,
err: null
}
async componentDidMount() {
this.setState({loading: true})
try {
const result = await service.fetch_data()
this.setState({
loading:false
})
}catch(err){
this.setState({loading: false, error: err.message})
}
}
render(){
return <Loading {...this.state} /> // 渲染邏輯交給視圖組件
}
}
// app.js
<LoadingContainer>
複製代碼
視圖組件的複用很是方便,可是容器組件的複用就沒那麼簡單了。社區中衍生出了 HOC 和 renderProps 來解決狀態 && 狀態操做的複用
// Loading.js
class Loading extends React.Component {
render(){
if(this.props.loading){
return <div>loading....</div>
}else{
return <div>{this.props.data}</div>
}
}
}
export default withLoading(Loading);
// app.js
<Loading />
複製代碼
<WithLoading>
{(props) => {
<Loading {...props} />
}}
</WithLoading>
複製代碼
這兩種方式都存在必定的問題
對於高階組件,存在不少須要注意的地方,如 zh-hans.reactjs.org/docs/higher… ,帶來不小的心智負擔,對於新手並不友好,另外一個問題在於 HOC 對於 Typescript 的支持並不友好,實現一個 TS 友好的 HOC 組件有至關大的難度可參考 www.zhihu.com/question/27… 在平常使用第三方的支持高階組件庫也常常會碰到各類 TS 的問題。
而 renderProps 雖然必定程度上拜託了 HOC 存在的問題,可是其會形成 render props callback hell, 當咱們須要同時使用多個 renderprops 的時候, 就會編寫出以下代碼
這種代碼不管是對代碼的閱讀者,仍是調試 element 結構的時候,都會帶來不小的影響。
// hooks.js
function useLoading(){
const [loading, setLoading] = useState(false);
const [ error, setError] = useState(null);
const [ data,setData] = useState(null);
useEffect(() => {
setLoading(true);
fetch_data().then(resp => {
setLoading(false);
setData(resp);
}).catch(err => {
setLoading(false);
setError(err.message)
})
})
}
// Loading.js
function Loading(){
const [loading, error, data ] = useLoading();
if(loading){
return <div>loading....</div>
}else{
return <div>{data}</div>
}
}
複製代碼
hooks 的複用性特別強,事實上社區上已經積攢了不少的 hook 能夠直接使用,如能夠直接使用 github.com/alex-cory/u… 這個 hooks 來簡化代碼
function Loading(){
const { error, loading, data} = useHttp(url);
if(loading){
return <div>loading....</div>
}else{
return <div>{data}</div>
}
}
複製代碼
hooks 幾乎完美解決了狀態複用的問題,可是 hooks 自己也帶來了一些問題,
hooks 的心智負擔並不比 HOC 要少,zh-hans.reactjs.org/docs/hooks-… FAQ 的長度可見一斑,另外一個問題是 hook 只能使用在 function 裏,這意味着咱們須要在 function 裏組織業務代碼了
剛剛從 class 組件轉移到 hook 組件時,大部分人最早碰到的問題就是如何組織業務邏輯
class 裏的 method 自然的幫咱們作好了業務隔離
import React from 'react';
class App extends React.Component {
biz1 = () =>{
}
biz2= () =>{
this.biz3()
}
biz3= () =>{
}
render(){
return (
<div>
<button onClick={() => this.biz1()}>dobiz1</button>
<button onClick={() => this.biz2()}>dobiz2</button>
</div>
)
}
}
複製代碼
可是到了 function 裏,已經缺少 method 的這個抽象來幫咱們作業務隔離了,頗有可能寫成以下這種代碼
function App (){
const [state1, setState] = useState();
function biz1(){
}
biz1();
const [state2, setState2] = useState();
const biz2 = useCallback(() => {
biz3();
},[state1,state2])
biz2();
return (
<div>
<button onClick={() => biz1()}>dobiz1</button>
<button onClick={() => biz2()}>dobiz2</button>
</div>
)
function biz3(){
}
}
複製代碼
基本上是你想怎麼來就怎麼來,能夠有無數種寫法,本身寫的還好,其餘讀代碼的人就是一頭霧水了,想理清一段業務邏輯,就得反覆橫跳了。
固然也能夠指定一些編寫 hook 的規範如
function APP(){
// 這裏放各類hook
// 同步的業務邏輯
// render邏輯
// 業務邏輯定義
}
複製代碼
按照這種規範,上述代碼以下
function App (){
const [state1, setState] = useState();
const [state2, setState2] = useState();
biz0();
return (
<div>
<button onClick={() => biz2()}>dobiz1</button>
<button onClick={() => biz2()}>dobiz2</button>
</div>
)
function biz0(){
// 同步代碼
}
function biz1(){
// 異步代碼
}
function biz2(){
// 異步代碼
biz3()
}
function biz3(){
// utilty
}
}
複製代碼
這樣組織代碼的可讀性就好不少,可是這只是人爲約定,也沒有對應的 eslint 作保證,並且 biz 的定義也無法使用 useCallback 等工具了,仍然存在問題。
上面的討論咱們能夠看出,儘管 hooks 解決了狀態複用的問題,可是其代碼的組織和維護存在較多問題,如何解決 hooks 代碼的維護問題就成了個問題
rematch 的狀態管理比較規整,咱們所以能夠考慮將 local state 的狀態管理頁存放到全局的 redux 裏,但這樣會帶來一些問題
咱們雖然不能將狀態放在全局,咱們仍然能夠效仿 rematch 的方式,將組件拆分爲 view 和 model,view 負責純渲染,model 裏存放業務邏輯,藉助於 hooks,比較容易實現該效果,大體代碼結構以下
// models.ts
const model = {
state:{
data: null,
err: null,
loading: false
},
setState: action((state,new_state) => {
Object.assign(state,new_state)
}),
fetch_data: effects(async (actions) => {
const { setState } = actions;
setState({loading: true});
try {
const resp = await fetch();
setState({
loading: false,
data:resp
})
}catch(err){
setState({
loading: false,
err: err.mssage
})
}
})
}
// hooks.ts
import model from './model';
export const useLoading = createLocalStore(model);
// loading/ index.ts
import {useLoading} from './hooks';
export default () => {
const [state, actions] = useLoading();
return (<Loading {...state} {...actions} />)
}
const Loading = ({
err,
data,
loading,
fetch_data
}) => {
if(loading) return (<div>loading...</div)
if(err) return (<div>error:{err}</div>)
return <div onClick={fetch_data}>data:{data}</div>
}
複製代碼
代碼主要有三部分組成
model: 業務邏輯(狀態及狀態變化)
hooks: 根據 model 生成 useLoding hooks,實際控制的是從何處去獲取狀態
view: 使用根據 useLoading hooks 的返回的 state 和 action 進行渲染
這樣咱們的代碼組織就比較清晰,不太可能出現以前 hook 出現的混亂的狀況了
咱們發現至此咱們組件不管是 local state 仍是全局 state,寫法幾乎一致了,都是劃分爲了 modle 和 view,區別只在於狀態是存在全局仍是 local,若是咱們全局和 local 的 model 定義徹底一致,那麼將很容易實現狀態全局和 local 的切換,這實際上在業務中也比較常見,尤爲是在 spa 裏,剛開始某個頁面裏的狀態是 local 的,可是後來新加了個頁面,須要和這個頁面共享狀態,咱們就須要將這個狀態和新頁面共享,這裏能夠先將狀態提高至兩個頁面的公共父頁面裏(經過 Context), 或者直接提取到全局。因此此時對於組件,差異僅僅在於咱們的狀態從何讀取而已。
咱們經過 hook 就隔離了這種區別,當咱們須要將狀態切換至全局或者 context 或者 local 時並不須要修改 model,僅僅需修改讀取的 hook 便可
// hook.ts
import model from './model';
const useLocalLoading = createLocalStore(model); // 從local讀取狀態
const useConextLoading = createContextStore(model); // 從context讀取狀態
const useGlobalLoading = createStore(model); // 從redux裏讀取狀態
// loading.ts
export default () => {
const [state, actions] = useLocalLoading(); // 這裏能夠選用從何處讀取狀態
return <Loading {...state} {...actions} />
}
複製代碼
此時咱們的組件不管是狀態複用、UI 複用、仍是代碼組織上都達到了比較合理的水平,mobx 裏實際上已經採用了相似作法
咱們在編寫 model 的過程當中,effects 裏不可避免的須要調用 service 來獲取數據,這致使了咱們的 model 直接依賴了 service,這通常不會出現問題,可是當咱們作同構或者時就會出現問題。
由於瀏覽器端和服務端以及測試端的 service 差異很大,如瀏覽器端的 service 一般是 http 請求,而服務端的 service 則有多是 rpc 服務,且調用過程當中須要打日誌和一些 trace 信息而測試端多是一些 mock 的 http 服務。這致使了若是 model 直接依賴於 service 將沒法構建通用於服務端和瀏覽器端的 model,更好的處理方式應該是將 service 經過依賴注入的方式注入到 model,在建立 strore 的時候將 service 實際的進行注入
上面說的這些問題包括 Typescript 支持、Batteries Included、localStore 的支持、依賴注入的支持等,rematch| dva 等庫受限於歷史緣由,都不太可能支持,很幸運的是 github.com/ctrlplusb/e… 對上述均作了很好的支持。具體例子可參考 github.com/hardfist/ha…
disclaimer: 我和這庫沒啥關係,只是發現很符合個人需求,因此推薦一下
easy-peasy 的使用方式和 rematch 類似,但區別於 rematch 缺少對 hook 的內置支持(雖然也能支持 react-redux 的 hook 用法),且須要兼容 react-redux 的寫法,
easy-peasy 內置了對 hook 的支持且並不依賴 react-redux,而僅僅是對 react-redux 的用法作簡單兼容,致使了其能夠擺脫 rematch 現存的種種問題。
9102 年了,對 typescript 的支持對於一個庫應該成了基本需求,easy-peasy 很好的作到了這一點,其專門爲 TS 設計了一套 API,用於解決 TS 的支持問題 (內部使用了 ts-boolbelt 來解決類型推斷問題),簡單的使用 TS 定義一個 model 以下
export interface TodosModel {
todo_list: Item[]; // state
filter: FILTER_TYPE; // 同上
init: Action<TodosModel, Item[]>; // 同步action
addTodo: Action<TodosModel, string>; // 同上
setFilter: Action<TodosModel, FILTER_TYPE>; // 同上
toggleTodo: Action<TodosModel, number>;
addTodoAsync: Thunk<TodosModel, string>; // 異步
fetchTodo: Thunk<TodosModel, undefined, Injections>; // 異步並進行service的依賴注入
visible_todo: Computed<TodosModel, Item[]>; // computed state
}
複製代碼
定義好 model 的結構後,咱們在編寫 model 時藉助於 contextual typing 能夠享受到自動補全和類型檢查的功能了
業務中使用 model 也再也不是經過 HOC 的方式經過 connect 來讀取 state 和 action,而是直接經過內置的 hook 來解決狀態讀取問題,避免了對 connect 的類型兼容問題(rematch 對這裏的兼容很坑爹), 且保證了類型安全
區別於 rematch,easy-peasy 經過 immer 實現了對 immutable 的支持,同時內置了對 computed state 的支持,簡化了咱們業務的編寫
export const todo: TodosModel = {
todo_list: [
{
text: 'learn easy',
id: nextTodoId++,
completed: false
}
],
filter: 'SHOW_ALL' as FILTER_TYPE,
init: action((state, init) => {
state.todo_list = init;
}),
addTodo: action((state, text) => {
// 看似mutable,實際是immutable,經過immer實現了經過mutable的寫法,來實現了immutable結構
state.todo_list.push({
text,
id: nextTodoId++,
completed: false
});
}),
setFilter: action((state, filter) => {
state.filter = filter;
}),
toggleTodo: action((state, id) => {
const item = state.todo_list.filter(x => x.id === id)[0];
item.completed = !item.completed;
}),
addTodoAsync: thunk(async (actions, text) => {
await delay(1000);
actions.addTodo(text);
}),
fetchTodo: thunk(async function test(actions, payload, { injections }) {
const { get_todo_list } = injections;
const {
data: { todo_list }
} = await get_todo_list();
actions.init(todo_list);
}),
// 內置對computed的支持
visible_todo: computed(({ todo_list, filter }) => {
return todo_list.filter(x => {
if (filter === 'SHOW_ALL') {
return true;
} else if (filter === 'SHOW_COMPLETED') {
return x.completed;
} else {
return !x.completed;
}
});
})
};
複製代碼
easy peasy 的 model 定義不只適用於全局,也適用於 context 和 local,只須要經過 hook 進行切換便可
export const ContextCounter = () => {
const [state, actions] = useContextCounter();
return renderCounter(state, actions);
};
export const LocalCounter = () => {
const [state, actions] = useLocalCounter();
return renderCounter(state, actions);
};
export const ReduxCounter = () => {
const [state, actions] = useReduxCounter();
return renderCounter(state, actions);
};
複製代碼
easy peasy 同時經過 thunk 實現了依賴注入,且保證了依賴注入的類型安全
// src/store/index.ts
import {get_todo_list } from 'service'
export interface Injections {
get_todo_list: typeof get_todo_list;
} //定義注入的類型,供後續使用
export const store = createStore(models, {
injections: { // 注入service
get_todo_list
}
});
複製代碼
import { Injections } from '../store';
// 導入須要注入的類型
export interface TodosModel {
items: string[];
addTodo: Action<TodosModel, string>;
saveTodo: Thunk<TodosModel, string, Injections>; // 類型注入
}
複製代碼