redux、mobx、concent特性大比拼, 看後生如何對局前輩

鍾正楷發表於2020-08-24

❤ star me if you like concent ^_^

序言

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原本的渲染機制吧,當某一個元件發生狀態改變時,如果它的自定義元件沒有人工維護shouldComponentUpdate判斷時,總是會從上往下全部渲染一遍,而reduxcconnect介面接管了shouldComponentUpdate行為,當一個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 ^_^

Edit on CodeSandbox
https://codesandbox.io/s/concent-guide-xvcej

Edit on StackBlitz
https://stackblitz.com/edit/cc-multi-ways-to-wirte-code

相關文章