【Web技術】624- redux、mobx、concent 狀態管理方案對比

授權原創:幻魂,來自掘金。如需查看文中 Demo,點擊閱讀原文。

序言

reduxmobx本身是一個獨立的狀態管理框架,各自有自己的抽象api,以其他UI框架無關(react, vue...),本文主要說的和react搭配使用的對比效果,所以下文裏提到的reduxmobx暗含了react-reduxmobx-react這些讓它們能夠在react中發揮功能的綁定庫,而concent本身是爲了react貼身打造的開發框架,數據流管理只是作爲其中一項功能,附帶的其他增強react開發體驗的特性可以按需使用,後期會刨去concent裏所有與react相關聯的部分發布concent-core,它的定位纔是與reduxmobx 相似的。

所以其實將在本文裏登場的選手分別是

redux & react-redux

  • slogan
    JavaScript 狀態容器,提供可預測化的狀態管理

  • 設計理念
    單一數據源,使用純函數修改狀態

mobx & mobx-react

  • slogan:

簡單、可擴展的狀態管理
  • 設計理念

任何可以從應用程序狀態派生的內容都應該派生

concent

  • slogan:
    可預測、0入侵、漸進式、高性能的react開發方案

  • 設計理念
    相信融合不可變+依賴收集的開發方式是react的未來,增強react組件特性,寫得更少,做得更多。

介紹完三者的背景,我們的舞臺正式交給它們,開始一輪輪角逐,看誰到最後會是你最中意的範兒?

結果預覽

以下5個較量回合實戰演示代碼較多,此處將對比結果提前告知,方便粗讀看客可以快速瞭解。

store配置concentmboxredux
支持分離YesYesNo
無根Provider & 使用處無需顯式導入YesNoNo
reducer無thisYesNoYes
store數據或方法無需人工映射到組件YesYesNo

「redux counter示例」
「mobx counter示例」
「concent counter示例」


狀態修改concentmboxredux
基於不可變原則YesNoYes
最短鏈路YesYesNo
ui源頭可追蹤YesNoNo
無thisYesNoYes
原子拆分&合併提交Yes(基於lazy)Yes(基於transaction)No

依賴收集concentmboxredux
支持運行時收集依賴YesYesNo
精準渲染YesYesNo
無thisYesNoNo
只需一個api介入YesNoNo

「mobx 示例」
「concent 示例」


衍生數據concentmboxredux(reselect)
自動維護計算結果之間的依賴YesYesNo
觸發讀取計算結果時收集依賴YesYesNo
計算函數無thisYesNoYes

「redux computed示例」
「mobx computed示例」
「concent computed示例」


todo-mvc實戰
「redux todo-mvc」
「mobx todo-mvc」
「concent todo-mvc」

round 1 - 代碼風格初體驗

counter作爲demo界的靚仔被無數次推上舞臺,這一次我們依然不例外,來個counter體驗3個框架的開發套路是怎樣的,以下3個版本都使用create-react-app創建,並以「多模塊的方式」來組織代碼,力求接近真實環境的代碼場景。

redux(action、reducer)

通過models把按模塊把功能拆到不同的reducer裏,目錄結構如下

|____models             # business models
| |____index.js         # 暴露store
| |____counter          # counter模塊相關的action、reducer
| | |____action.js
| | |____reducer.js
| |____ ...             # 其他模塊
|____CounterCls         # 類組件
|____CounterFn          # 函數組件
|____index.js           # 應用入口文件

此處僅與redux的原始模板組織代碼,實際情況可能不少開發者選擇了rematchdva等基於redux做二次封裝並改進寫法的框架,但是並不妨礙我們理解counter實例。

構造counter的action

// code in models/counter/action
export const INCREMENT = "INCREMENT";

export const DECREMENT = "DECREMENT";

export const increase = number => {
  return { type: INCREMENT, payload: number };
};

export const decrease = number => {
  return {  type: DECREMENT, payload: number };
};

構造counter的reducer

// code in models/counter/reducer
import { INCREMENT, DECREMENT } from "./action";

export default (state = { count: 0 }, action) => {
  const { type, payload } = action;
  switch (type) {
    case INCREMENT:
      return { ...state, count: state.count + payload };
    case DECREMENT:
      return { ...state, count: state.count - payload };
    default:
      return state;
  }
};

合併reducer構造store,並注入到根組件

mport { createStore, combineReducers } from "redux";
import  countReducer  from "./models/counter/reducer";

const store = createStore(combineReducers({counter:countReducer}));

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById("root")
);

使用connect連接ui與store

import React from "react";
import { connect } from "react-redux";
import { increase, decrease } from "./redux/action";

@connect(
  state => ({ count: state.counter.count }),// mapStateToProps
  dispatch => ({// mapDispatchToProps
    increase: () => dispatch(increase(1)),
    decrease: () => dispatch(decrease(1))
  }),
)
class Counter extends React.Component {
  render() {
    const { count, increase, decrease } = this.props;
    return (
      <div>
        <h1>Count : {count}</h1>
        <button onClick={increase}>Increase</button>
        <button onClick={decrease}>decrease</button>
      </div>
    );
  }
}

export default Counter;

上面的示例書寫了一個類組件,而針對現在火熱的hookredux v7也發佈了相應的apiuseSelectoruseDispatch

import * as React from "react";
import { useSelector, useDispatch } from "react-redux";
import * as counterAction from "models/counter/action";

const Counter = () => {
  const count = useSelector(state => state.counter.count);
  const dispatch = useDispatch();
  const increase = () => dispatch(counterAction.increase(1));
  const decrease = () => dispatch(counterAction.decrease(1));

  return (
    <>
      <h1>Fn Count : {count}</h1>
      <button onClick={increase}>Increase</button>
      <button onClick={decrease}>decrease</button>
    </>
  );
};

export default Counter;

渲染這兩個counter,「查看redux示例」

function App() {
  return (
      <div className="App">
        <CounterCls/>
        <CounterFn/>
      </div>
  );
}

mobx(store, inject)

當應用存在多個store時(這裏我們可以把一個store理解成redux裏的一個reducer塊,聚合了數據、衍生數據、修改行爲),mobx的store獲取方式有多種,例如在需要用的地方直接引入放到成員變量上

import someStore from 'models/foo';// 是一個已經實例化的store實例

@observer
class Comp extends React.Component{
    foo = someStore;
    render(){
        this.foo.callFn();//調方法
        const text = this.foo.text;//取數據
    }
}

我們此處則按照公認的最佳實踐來做,即把所有store合成一個根store掛到Provider上,並將Provider包裹整個應用根組件,在使用的地方標記inject裝飾器即可,我們的目錄結構最終如下,和redux版本並無區別

|____models             # business models
| |____index.js         # 暴露store
| |____counter          # counter模塊相關的store
| | |____store.js
| |____ ...             # 其他模塊
|____CounterCls         # 類組件
|____CounterFn          # 函數組件
|____index.js           # 應用入口文件

構造counter的store

import { observable, action, computed } from "mobx";

class CounterStore {
  @observable
  count = 0;

  @action.bound
  increment() {
    this.count++;
  }

  @action.bound
  decrement() {
    this.count--;
  }
}

export default new CounterStore();

合併所有store根store,並注入到根組件

// code in models/index.js
import counter from './counter';
import login from './login';

export default {
  counter,
  login,
}

// code in index.js
import React, { Component } from "react";
import { render } from "react-dom";
import { Provider } from "mobx-react";
import store from "./models";
import CounterCls from "./CounterCls";
import CounterFn from "./CounterFn";

render(    
    <Provider store={store}>
      <App />
    </Provider>, 
    document.getElementById("root")
);

創建一個類組件

import React, { Component } from "react";
import { observer, inject } from "mobx-react";

@inject("store")
@observer
class CounterCls extends Component {
  render() {
    const counter = this.props.store.counter;
    return (
      <div>
        <div> class Counter {counter.count}</div>
        <button onClick={counter.increment}>+</button>
        <button onClick={counter.decrement}>-</button>
      </div>
    );
  }
}

export default CounterCls;

創建一個函數組件

import React from "react";
import { useObserver, observer } from "mobx-react";
import store from "./models";

const CounterFn = () => {
  const { counter } = store;
  return useObserver(() => (
      <div>
        <div> class Counter {counter.count}</div>
        <button onClick={counter.increment}>++</button>
        <button onClick={counter.decrement}>--</button>
      </div>
  ));
};

export default CounterFn;

渲染這兩個counter,「查看mobx示例」

function App() {
  return (
      <div className="App">
        <CounterCls/>
        <CounterFn/>
      </div>
  );
}

concent(reducer, register)

concent和redux一樣,存在一個全局單一的根狀態RootStore,該根狀態下第一層key用來當做模塊命名空間,concent的一個模塊必需配置state,剩下的reducercomputedwatchinit是可選項,可以按需配置,如果把store所有模塊寫到一處,最簡版本的concent示例如下

import { run, setState, getState, dispatch } from 'concent';
run({
    counter:{// 配置counter模塊
        state: { count: 0 }, // 【必需】定義初始狀態, 也可寫爲函數 ()=>({count:0})
        // reducer: { ...}, // 【可選】修改狀態的方法
        // computed: { ...}, // 【可選】計算函數
        // watch: { ...}, // 【可選】觀察函數
        // init: { ...}, // 【可選】異步初始化狀態函數
    }
});

const count = getState('counter').count;// count is: 0
// count is: 1,如果有組件屬於該模塊則會被觸發重渲染
setState('counter', {count:count + 1});

// 如果定義了counter.reducer下定義了changeCount方法
// dispatch('counter/changeCount')

啓動concent載入store後,可在其它任意類組件或函數組件裏註冊其屬於於某個指定模塊或者連接多個模塊

import { useConcent, register } from 'concent';

function FnComp(){
    const { state, setState, dispatch } = useConcent('counter');
    // return ui ...
}

@register('counter')
class ClassComp extends React.Component(){
    render(){
        const { state, setState, dispatch } = this.ctx;
        // return ui ...
    }
}

但是推薦將模塊定義選項放置到各個文件中,以達到職責分明、關注點分離的效果,所以針對counter,目錄結構如下

|____models             # business models
| |____index.js         # 配置store各個模塊
| |____counter          # counter模塊相關
| | |____state.js       # 狀態
| | |____reducer.js     # 修改狀態的函數
| | |____index.js       # 暴露counter模塊
| |____ ...             # 其他模塊
|____CounterCls         # 類組件
|____CounterFn          # 函數組件
|____index.js           # 應用入口文件
|____runConcent.js      # 啓動concent

構造counter的statereducer

// code in models/counter/state.js
export default {
  count: 0,
}

// code in models/counter/reducer.js
export function increase(count, moduleState) {
  return { count: moduleState.count + count };
}

export function decrease(count, moduleState) {
  return { count: moduleState.count - count };
}

兩種方式配置store

  • 配置在run函數裏

import counter from 'models/counter';

run({counter});
  • 通過configure接口配置, run接口只負責啓動concent

// code in runConcent.js
import { run } from 'concent';
run();

// code in models/counter/index.js
import state from './state';
import * as reducer from './reducer';
import { configure } from 'concent';

configure('counter', {state, reducer});// 配置counter模塊

創建一個函數組件

import * as React from "react";
import { useConcent } from "concent";

const Counter = () => {
  const { state, dispatch } = useConcent("counter");
  const increase = () => dispatch("increase", 1);
  const decrease = () => dispatch("decrease", 1);

  return (
    <>
      <h1>Fn Count : {state.count}</h1>
      <button onClick={increase}>Increase</button>
      <button onClick={decrease}>decrease</button>
    </>
  );
};

export default Counter;

該函數組件我們是按照傳統的hook風格來寫,即每次渲染執行hook函數,利用hook函數返回的基礎接口再次定義符合當前業務需求的動作函數。

但是由於concent提供setup接口,我們可以利用它只會在初始渲染前執行一次的能力,將這些動作函數放置到setup內部定義爲靜態函數,避免重複定義,所以一個更好的函數組件應爲

import * as React from "react";
import { useConcent } from "concent";

export const setup = ctx => {
  return {
    // better than ctx.dispatch('increase', 1);
    increase: () => ctx.moduleReducer.increase(1),
    decrease: () => ctx.moduleReducer.decrease(1)
  };
};

const CounterBetter = () => {
  const { state, settings } = useConcent({ module: "counter", setup });
  const { increase, decrease } = settings;
  // return ui...
};

export default CounterBetter;

創建一個類組件,複用setup裏的邏輯

import React from "react";
import { register } from "concent";
import { setup } from './CounterFn';

@register({module:'counter', setup})
class Counter extends React.Component {
  render() {
    // this.state 和 this.ctx.state 取值效果是一樣的
    const { state, settings } = this.ctx;
     // return ui...
  }
}

export default Counter;

渲染這兩個counter,「查看concent示例」

function App() {
  return (
    <div className="App">
      <CounterCls />
      <CounterFn />
    </div>
  );
}

回顧與總結

此回合裏展示了3個框架對定義多模塊狀態時,不同的代碼組織與結構

  • redux通過combineReducers配合Provider包裹根組件,同時還收手寫mapStateToPropsmapActionToProps來輔助組件獲取store的數據和方法

  • mobx通過合併多個subStore到一個store對象並配合Provider包裹根組件,store的數據和方法可直接獲取

  • concent通過run接口集中配置或者configure接口分離式的配置,store的數據和方法可直接獲取

store配置concentmboxredux
支持分離YesYesNo
無根Provider & 使用處無需顯式導入YesNoNo
reducer無thisYesNoYes
store數據或方法無需人工映射到組件YesYesNo

round 2 - 狀態修改

3個框架對狀態的修改風格差異較大。redux裏嚴格限制狀態修改途徑,所以的修改狀態行爲都必須派發action,然後命中相應reducer合成新的狀態。

mobx具有響應式的能力,直接修改即可,但因此也帶來了數據修改途徑不可追溯的煩惱從而產生了mobx-state-tree來配套約束脩改數據行爲。

concent的修改完完全全遵循react的修改入口setState風格,在此基礎之上進而封裝dispatchinvokesync系列api,且無論是調用哪一種api,都能夠不只是追溯數據修改完整鏈路,還包括觸發數據修改的源頭。

redux(dispatch)

同步的action

export const changeFirstName = firstName => {
  return {
    type: CHANGE_FIRST_NAME,
    payload: firstName
  };
};

異步的action,藉助redux-thunk來完成

// code in models/index.js, 配置thunk中間件
import  thunk  from "redux-thunk";
import { createStore, combineReducers, applyMiddleware } from "redux";
const store = createStore(combineReducers({...}), applyMiddleware(thunk));

// code in models/login/action.js
export const CHANGE_FIRST_NAME = "CHANGE_FIRST_NAME";

const delay = (ms = 1000) => new Promise(r => setTimeout(r, ms));
// 工具函數,輔助寫異步action
const asyncAction = asyncFn => {
  return dispatch => {
    asyncFn(dispatch).then(ret => {
      if(ret){
        const [type, payload] = ret;
        dispatch({ type, payload });
      }
    }).catch(err=>alert(err));
  };
};

export const asyncChangeFirstName = firstName => {
  return asyncAction(async (dispatch) => {//可用於中間過程多次dispatch
    await delay();
    return [CHANGE_FIRST_NAME, firstName];
  });
};

mobx版本(this.XXX)

同步action與異步action

import { observable, action, computed } from "mobx";

const delay = (ms = 1000) => new Promise(r => setTimeout(r, ms));

class LoginStore {
  @observable firstName = "";

  @observable lastName = "";

  @action.bound
  changeFirstName(firstName) {
    this.firstName = firstName;
  }

  @action.bound
  async asyncChangeFirstName(firstName) {
    await delay();
    this.firstName = firstName;
  }

  @action.bound
  changeLastName(lastName) {
    this.lastName = lastName;
  }
}

export default new LoginStore();

直接修改

const LoginFn = () => {
  const { login } = store;
  const changeFirstName = e => login.firstName = e.target.value;
  // ...    
}

通過action修改

const LoginFn = () => {
  const { login } = store;
  const const changeFirstName = e => login.changeFirstName(e.target.value);
  // ...    
}

concent(dispatch,setState,invoke,sync)

concent裏不再區分actionreducer,ui直接調用reducer方法即可,同時reducer方法可以是同步也可以是異步,支持相互任意組合和lazy調用,大大減輕開發者的心智負擔。

同步reducer與異步reducer

// code in models/login/reducer.js
const delay = (ms = 1000) => new Promise(r => setTimeout(r, ms));

export function changeFirstName(firstName) {
  return { firstName };
}

export async function asyncChangeFirstName(firstName) {
  await delay();
  return { firstName };
}

export function changeLastName(lastName) {
  return { lastName };
}

可任意組合的reducer,屬於同一個模塊內的方法可以直接基於方法引用調用,且reducer函數並非強制一定要返回一個新的片斷狀態,僅用於組合其他reducer也是可以的。

// reducerFn(payload:any, moduleState:{}, actionCtx:IActionCtx)
// 當lazy調用此函數時,任何一個函數出錯了,中間過程產生的所有狀態都不會提交到store
export async changeFirstNameAndLastName([firstName, lastName], m, ac){
    await ac.dispatch(changeFirstName, firstName);
    await ac.dispatch(changeFirstName, lastName);
    // return {someNew:'xxx'};//可選擇此reducer也返回新的片斷狀態
}

// 視圖處
function UI(){
    const ctx useConcent('login');
    // 觸發兩次渲染
    const normalCall = ()=>ctx.mr.changeFirstNameAndLastName(['first', 'last']);
    // 觸發一次渲染
    const lazyCall = ()=>ctx.mr.changeFirstNameAndLastName(['first', 'last'], {lazy:true});
    
    return (
        <>
            <button onClick={handleClick}> normalCall </button>
            <button onClick={handleClick}> lazyCall </button>
        </>
    )
}

「lazyReducer示例」

非lazy調用流程

lazy調用流程

當然了,除了reducer,其他3種方式都可以任意搭配,且和reducer一樣擁有同步狀態到其他屬於同一個模塊且對某狀態有依賴的實例上

  • setState

function FnUI(){
    const {setState} = useConcent('login');
    const changeName = e=> setState({firstName:e.target.name});
    // ... return ui
}

@register('login')
class ClsUI extends React.Component{
    changeName = e=> this.setState({firstName:e.target.name})
    render(){...}
}
  • invoke

function _changeName(firstName){
    return {firstName};
}

function FnUI(){
    const {invoke} = useConcent('login');
    const changeName = e=> invoke(_changeName, e.target.name);
    // ... return ui
}

@register('login')
class ClsUI extends React.Component{
    changeName = e=> this.ctx.invoke(_changeName, e.target.name)
    render(){...}
}
  • sync

「更多關於sync, 查看App2-1-sync.js文件」

function FnUI(){
    const {sync, state} = useConcent('login');
    return  <input value={state.firstName} onChange={sync('firstName')} />
}

@register('login')
class ClsUI extends React.Component{
    changeName = e=> this.ctx.invoke(_changeName, e.target.name)
    render(){
        return  <input value={this.state.firstName} onChange={this.ctx.sync('firstName')} />
    }
}

還記得我們在round 2開始比較前對concent提到了這樣一句話:「能夠不只是追溯數據修改完整鏈路,還包括觸發數據修改的源頭」,它是何含義呢,因爲每一個concent組件的ctx都擁有一個唯一idccUniqueKey標識當前組件實例,它是按{className}_{randomTag}_{seq}自動生成的,即類名(不提供是就是組件類型$$CClass, $$CCFrag, $$CCHook)加隨機標籤加自增序號,如果想刻意追蹤修改源頭ui,則人工維護tagccClassKey既可,再配合上concent-plugin-redux-devtool就能完成我們的目標了。

function FnUI(){
    const {sync, state, ccUniqueKey} = useConcent({module:'login', tag:'xxx'}, 'FnUI');
    // tag 可加可不加,
    // 不加tag,ccUniqueKey形如: FnUI_xtst4x_1
    // 加了tag,ccUniqueKey形如: FnUI_xxx_1
}

@register({module:'login', tag:'yyy'}, 'ClsUI')
class ClsUI extends React.Component{...}

接入concent-plugin-redux-devtool後,可以看到任何動作修改Action裏都會包含一個字段ccUniqueKey

回顧與總結

這一個回合我們針對數據修改方式做了全面對比,從而讓開發者瞭解到從concent的角度來說,爲了開發者的編碼體驗做出的各方面巨大努力。

針對狀態更新方式, 對比redux,當我們的所有動作流程壓到最短,無action-->reducer這樣一條鏈路,無所謂的存函數還是副作用函數的區分(rematchdva等提取的概念),把這些概念交給js語法本身,會顯得更加方便和清晰,你需要純函數,就寫export function,需要副作用函數就寫export async function

對比mobx,一切都是可以任何拆開任意組合的基礎函數,沒有this,徹底得面向FP,給一個input預期output,這樣的方式對測試容器也更加友好。

狀態修改concentmboxredux
基於不可變原則YesNoYes
最短鏈路YesYesNo
ui源頭可追蹤YesNoNo
無thisYesNoYes
原子拆分&合併提交Yes(基於lazy)Yes(基於transaction)No

round 3 - 依賴收集

這個回合是非常重量級的一個環節,依賴收集讓ui渲染可以保持最小範圍更新,即精確更新,所以vue某些測試方面會勝出react,當我們爲react插上依賴收集的翅膀後,看看會有什麼更有趣的事情發生吧。

再開始聊依賴收集之前,我們覆盤一下react原本的渲染機制吧,當某一個組件發生狀態改變時,如果它的自定義組件沒有人工維護shouldcomponent判斷時,總是會從上往下全部渲染一遍,而reduxcconnect接口接管了shouldcomponent行爲,當一個action觸發了動作修改時,所有connect過的組件都會將上一刻mapStateToProps得到的狀態和當前最新mapStateToProps得到的狀態做淺比較,從而決定是否要刷新包裹的子組件。

到了hook時代,提供了React.memo來用戶阻斷這種"株連式"的更新,但是需要用戶儘量傳遞primitive類型數據或者不變化的引用給props,否則React.memo的淺比較會返回false。

但是redux存在的一個問題是,如果視圖裏某一刻已經不再使用某個狀態了,它不該被渲染卻被渲染了,mobx攜帶得基於運行時獲取到ui對數據的最小訂閱子集理念優雅的解決了這個問題,但是concent更近一步將依賴收集行爲隱藏的更優雅,用戶不需要不知道observable等相關術語和概念,某一次渲染你取值有了點這個值的依賴,而下一次渲染沒有了對某個stateKey的取值行爲就應該移出依賴,這一點vue做得很好,爲了讓react擁有更優雅、更全面的依賴收集機制,concent同樣做出了很多努力。

redux版本(不支持)

解決依賴收集不是redux誕生的初衷,這裏我們只能默默的將它請到候選區,參與下一輪的較量了。

mobx版本(computed,useObserver)

利用裝飾器或者decorate函數標記要觀察的屬性或者計算的屬性

import { observable, action, computed } from "mobx";

const delay = (ms = 1000) => new Promise(r => setTimeout(r, ms));

class LoginStore {
  @observable firstName = "";

  @observable lastName = "";

  @computed
  get fullName(){
    return `${this.firstName}_${this.lastName}`
  }

  @computed
  get nickName(){
    return `${this.firstName}>>nicknick`
  }

  @computed
  get anotherNickName(){
    return `${this.nickName}_another`
  }
}

export default new LoginStore();

ui裏使用了觀察狀態或者結算結果時,就產生了依賴

  • 僅對計算結果有依賴,類組件寫法

@inject("store")
@observer
class LoginCls extends Component {
  state = {show:true};
  toggle = ()=> this.setState({show:!this.state.show})
  render() {
    const login = this.props.store.login;
    return (
      <>
        <h1>Cls Small Comp</h1>
        <button onClick={this.toggle}>toggle</button>
        {this.state.show ? <div> fullName:{login.fullName}</div>: ""}
      </>
    )
  }
}
  • 僅對計算結果有依賴,函數組件寫法

import { useObserver } from "mobx-react";

// show爲true時,當前組件讀取了fullName,
// fullName由firstName和lastName計算而出
// 所以他的依賴是firstName、lastName
// 當show爲false時,當前組件無任何依賴
export const LoginFnSmall = React.memo((props) => {
  const [show, setShow] = React.useState(true);
  const toggle = () => setShow(!show);
  const { login } = store;

  return useObserver(() => {
    return (
      <>
        <h1>Fn Small Comp</h1>
        <button onClick={toggle}>toggle</button>
        {show ? <div> fullName:{login.fullName}</div>: ""}
      </>
    )
  });
});

對狀態有依賴和對計算結果有依賴無任何區別,都是在運行時從this.props.login上獲取相關結果就產生了ui對數據的依賴關係。

「查看mobx示例」

concent(state,moduleComputed)

無需任何裝飾器來標記觀察屬性和計算結果,僅僅是普通的json對象和函數,運行時階段被自動轉爲Proxy對象。

計算結果依賴

// code in models/login/computed.js
// n: newState, o: oldState, f: fnCtx

// fullName的依賴是firstName lastName
export function fullName(n, o, f){
  return `${n.firstName}_${n.lastName}`;
}

// nickName的依賴是firstName
export function nickName(n, o, f){
  return `${n.firstName}>>nicknick`
}

// anotherNickName基於nickName緩存結果做二次計算,而nickName的依賴是firstName
// 所以anotherNickName的依賴是firstName,注意需將此函數放置到nickName下面
export function anotherNickName(n, o, f){
  return `${f.cuVal.nickName}_another`;
}
  • 僅對計算結果有依賴,類組件寫法

@register({ module: "login" })
class _LoginClsSmall extends React.Component {
  state = {show:true};
  render() {
    const { state, moduleComputed: mcu, syncBool } = this.ctx;

    // show爲true時實例的依賴爲firstName+lastName
    // 爲false時,則無任何依賴
    return (
      <>
        <h1>Fn Small Comp</h1>
        <button onClick={syncBool("show")}>toggle</button>
        {state.show ? <div> fullName:{mcu.fullName}</div> : ""}
      </>
    );
  }
}
  • 僅對計算結果有依賴,函數組件寫法

export const LoginFnSmall = React.memo(props => {
  const { state, moduleComputed: mcu, syncBool } = useConcent({
    module: "login",
    state: { show: true }
  });

  return (
    <>
      <h1>Fn Small Comp</h1>
      <button onClick={syncBool("show")}>toggle</button>
      {state.show ? <div> fullName:{mcu.fullName}</div> : ""}
    </>
  );
});

mobx一樣,對狀態有依賴和對計算結果有依賴無任何區別,在運行時從ctx.state上獲取相關結果就產生了ui對數據的依賴關係,每一次渲染concent都在動態的收集當前實例最新的依賴,在實例didUpdate階段移出已消失的依賴。

  • 生命週期依賴

concent的架構裏是統一了類組件和函數組件的生命週期函數的,所以當某個狀態被改變時,對此有依賴的生命週期函數會被觸發,並支持類與函數共享此邏輯

export const setupSm = ctx=>{
  // 當firstName改變時,組件渲染渲染完畢後會觸發
  ctx.effect(()=>{
    console.log('fisrtName changed', ctx.state.fisrtName);
  }, ['firstName'])
}

// 類組件裏使用
export const LoginFnSmall = React.memo(props => {
  console.log('Fn Comp ' + props.tag);
  const { state, moduleComputed: mcu, sync } = useConcent({
    module: "login",setup: setupSm, state: { show: true }
  });
  //...
}

// 函數組件裏使用
@register({ module: "login", setup:setupSm })
class _LoginClsSmall extends React.Component {...}

「查看concent示例」

「查看更多關於ctx.effect」

回顧與總結

在依賴收集這一個回合,concent的依賴收集形式、和組件表達形式,和mobx區別都非常大,整個依賴收集過程沒有任何其他多餘的api介入, 而mbox需用computed修飾getter字段,在函數組件需要使用useObserver包狀態返回UI,concent更注重一切皆函數,在組織計算代碼的過程中消除的this這個關鍵字,利用fnCtx函數上下文傳遞已計算結果,同時顯式的區分statecomputed的盛放容器對象。

依賴收集concentmboxredux
支持運行時收集依賴YesYesNo
精準渲染YesYesNo
無thisYesNoNo
只需一個api介入YesNoNo

round 4 - 衍生數據

還記得mobx的口號嗎?任何可以從應用程序狀態派生的內容都應該派生,揭示了一個的的確確存在且我們無法逃避的問題,大多數應用狀態傳遞給ui使用前都會伴隨着一個計算過程,其計算結果我們稱之爲衍生數據。

我們都知道在vue裏已內置了這個概念,暴露了一個可選項computed用於處理計算過程並緩存衍生數據,react並無此概念,redux也並不提供此能力,但是redux開放的中間件機制讓社區得以找到切入點支持此能力,所以此處我們針對redux說到的計算指的已成爲事實上的流行標準庫reslect.

mobxconcent都自帶計算支持,我們在上面的「依賴收集」回合裏已經演示了mobxconcent的衍生數據代碼,所以此輪僅針對redux書寫衍生數據示例

redux(reselect)

redux最新發布v7版本,暴露了兩個api,useDispatchuseSelector,用法以之前的mapStateToStatemapDispatchToProps完全對等,我們的示例裏會用類組件和函數組件都演示出來。

定義selector

import { createSelector } from "reselect";

// getter,僅用於取值,不參與計算
const getFirstName = state => state.login.firstName;
const getLastName = state => state.login.lastName;

// selector,等同於computed,手動傳入計算依賴關係
export const selectFullName = createSelector(
  [getFirstName, getLastName],
  (firstName, lastName) => `${firstName}_${lastName}`
);

export const selectNickName = createSelector(
  [getFirstName],
  (firstName) => `${firstName}>>nicknick`
);

export const selectAnotherNickName = createSelector(
  [selectNickName],
  (nickname) => `${nickname}_another`
);

類組件獲取selector

import React from "react";
import { connect } from "react-redux";
import * as loginAction from "models/login/action";
import {
  selectFullName,
  selectNickName,
  selectAnotherNickName
} from "models/login/selector";

@connect(
  state => ({
    firstName: state.login.firstName,
    lastName: state.login.lastName,
    fullName: selectFullName(state),
    nickName: selectNickName(state),
    anotherNickName: selectAnotherNickName(state),
  }), // mapStateToProps
  dispatch => ({
    // mapDispatchToProps
    changeFirstName: e =>
      dispatch(loginAction.changeFirstName(e.target.value)),
    asyncChangeFirstName: e =>
      dispatch(loginAction.asyncChangeFirstName(e.target.value)),
    changeLastName: e => dispatch(loginAction.changeLastName(e.target.value))
  })
)
class Counter extends React.Component {
  render() {
    const {
      firstName,
      lastName,
      fullName,
      nickName,
      anotherNickName,
      changeFirstName,
      asyncChangeFirstName,
      changeLastName
    } = this.props;
    return 'ui ...'
  }
}

export default Counter;

函數組件獲取selector

import * as React from "react";
import { useSelector, useDispatch } from "react-redux";
import * as loginAction from "models/login/action";
import {
  selectFullName,
  selectNickName,
  selectAnotherNickName
} from "models/login/selector";

const Counter = () => {
  const { firstName, lastName } = useSelector(state => state.login);
  const fullName = useSelector(selectFullName);
  const nickName = useSelector(selectNickName);
  const anotherNickName = useSelector(selectAnotherNickName);
  const dispatch = useDispatch();
  const changeFirstName = (e) => dispatch(loginAction.changeFirstName(e.target.value));
  const asyncChangeFirstName = (e) => dispatch(loginAction.asyncChangeFirstName(e.target.value));
  const changeLastName = (e) => dispatch(loginAction.changeLastName(e.target.value));

  return 'ui...'
  );
};

export default Counter;

「redux衍生數據在線示例」

mobx(computed裝飾器)

見上面依賴收集的實例代碼,此處不再重敘。

concent(moduleComputed直接獲取)

見上面依賴收集的實例代碼,此處不再重敘。

回顧與總結

相比mobx可以直接從this.pops.someStore獲取,concent可以直接從ctx.moduleComputed上獲取,多了一個手動維護計算依賴的過程或映射挑選結果的過程,相信哪種方式是開發者更願意使用的這個結果已經一目瞭然了。

衍生數據concentmboxredux(reselect)
自動維護計算結果之間的依賴YesYesNo
觸發讀取計算結果時收集依賴YesYesNo
計算函數無thisYesNoYes

round 5 - 實戰TodoMvc

上面4個回合結合了一個個鮮活的代碼示例,綜述了3個框架的特點與編碼風格,相信讀者期望能有更加接近生產環境的代碼示例來看出其差異性吧,那麼最後讓我們以TodoMvc來收尾這次特性大比拼,期待你能夠更多的瞭解並體驗concent,開啓 「不可變」 & 「依賴收集」 的react編程之旅吧。

redux-todo-mvc

「查看redux-todo-mvc演示」

action 相關

reducer 相關

computed 相關

mobx-todo-mvc

「查看mobx-todo-mvc演示」

action 相關

computed 相關

concent-todo-mvc

「查看concent-todo-mvc演示」

reducer相關

computed相關

end

最後讓我們用一個最簡版本的concent應用結束此文,未來的你會選擇concent作爲你的react開發武器嗎?

import React from "react";
import "./styles.css";
import { run, useConcent, defWatch } from 'concent';

run({
  login:{
    state:{
      name:'c2',
      addr:'bj',
      info:{
        sex: '1',
        grade: '19',
      }
    },
    reducer:{
      selectSex(sex, moduleState){
        const info = moduleState.info;
        info.sex = sex;
        return {info};
      }
    },
    computed: {
      funnyName(newState){
        // 收集到funnyName對應的依賴是 name
        return `${newState.name}_${Date.now()}`
      },
      otherFunnyName(newState, oldState, fnCtx){
        // 獲取了funnyName的計算結果和newState.addr作爲輸入再次計算
        // 所以這裏收集到otherFunnyName對應的依賴是 name addr
        return `${fnCtx.cuVal.funnyName}_${newState.addr}`
      }
    },
    watch:{
      // watchKey name和stateKey同名,默認監聽name變化
      name(newState, oldState){
        console.log(`name changed from ${newState.name} to ${oldState.name}`);
      },
      // 從newState 讀取了addr, info兩個屬性的值,當前watch函數的依賴是 addr, info
      // 它們任意一個發生變化時,都會觸發此watch函數
      addrOrInfoChanged: defWatch((newState, oldState, fnCtx)=>{
        const {addr, info} = newState;
        if(fnCtx.isFirstCall)return;// 僅爲了收集到依賴,不執行邏輯
        console.log(`addr is${addr}, info is${JSON.stringify(info)}`);
      }, {immediate:true})
    }
  }
})

function UI(){
  console.log('UI with state value');
  const {state, sync, dispatch} = useConcent('login');
  return (
    <div>
      name:<input value={state.name} onChange={sync('name')} />
      addr:<input value={state.addr} onChange={sync('addr')} />
      <br />
      info.sex:<input value={state.info.sex} onChange={sync('info.sex')} />
      info.grade:<input value={state.info.grade} onChange={sync('info.grade')} />
      <br />
      <select value={state.info.sex} onChange={(e)=>dispatch('selectSex', e.target.value)}>
        <option value="male">male</option>
        <option value="female">female</option>
      </select>
    </div>
  );
}

function UI2(){
  console.log('UI2 with comptued value');
  const {state, moduleComputed, syncBool} = useConcent({module:'login', state:{show:true}});
  return (
    <div>
      {/* 當show爲true的時候,當前組件的依賴是funnyName對應的依賴 name */}
      {state.show? <span>dep is name: {moduleComputed.funnyName}</span> : 'UI2 no deps now'}
      <br/><button onClick={syncBool('show')}>toggle show</button>
    </div>
  );
}

function UI3(){
  console.log('UI3 with comptued value');
  const {state, moduleComputed, syncBool} = useConcent({module:'login', state:{show:true}});
  return (
    <div>
      {/* 當show爲true的時候,當前組件的依賴是funnyName對應的依賴 name addr */}
      {state.show? <span>dep is name,addr: {moduleComputed.otherFunnyName}</span> : 'UI3 no deps now'}
      <br/><button onClick={syncBool('show')}>toggle show</button>
    </div>
  );
}

export default function App() {
  return (
    <div className="App">
      <h3>try click toggle btn and open console to see render log</h3>
      <UI />
      <UI />
      <UI2 />
      <UI3 />
    </div>
  );
}

❤ star me if you like concent ^_^

1. JavaScript 重溫系列(22篇全)

2. ECMAScript 重溫系列(10篇全)

3. JavaScript設計模式 重溫系列(9篇全)

4. 正則 / 框架 / 算法等 重溫系列(16篇全)

5. Webpack4 入門(上)|| Webpack4 入門(下)

6. MobX 入門(上) ||  MobX 入門(下)

7. 59篇原創系列彙總

回覆“加羣”與大佬們一起交流學習~

點擊“閱讀原文”查看70+篇原創文章

點這,與大家一起分享本文吧~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章