redux、mobx、concent特性大比拼, 看后生如何对局前辈

18,993 阅读23分钟

❤ 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配置 concent mbox redux
支持分离 Yes Yes No
无根Provider & 使用处无需显式导入 Yes No No
reducer无this Yes No Yes
store数据或方法无需人工映射到组件 Yes Yes No

redux counter示例
mobx counter示例
concent counter示例


状态修改 concent mbox redux
基于不可变原则 Yes No Yes
最短链路 Yes Yes No
ui源头可追踪 Yes No No
无this Yes No Yes
原子拆分&合并提交 Yes(基于lazy) Yes(基于transaction) No

依赖收集 concent mbox redux
支持运行时收集依赖 Yes Yes No
精准渲染 Yes Yes No
无this Yes No No
只需一个api介入 Yes No No

mobx 示例
concent 示例


衍生数据 concent mbox redux(reselect)
自动维护计算结果之间的依赖 Yes Yes No
触发读取计算结果时收集依赖 Yes Yes No
计算函数无this Yes No Yes

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配置 concent mbox redux
支持分离 Yes Yes No
无根Provider & 使用处无需显式导入 Yes No No
reducer无this Yes No Yes
store数据或方法无需人工映射到组件 Yes Yes No

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,这样的方式对测试容器也更加友好。

状态修改 concent mbox redux
基于不可变原则 Yes No Yes
最短链路 Yes Yes No
ui源头可追踪 Yes No No
无this Yes No Yes
原子拆分&合并提交 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的盛放容器对象。

依赖收集 concent mbox redux
支持运行时收集依赖 Yes Yes No
精准渲染 Yes Yes No
无this Yes No No
只需一个api介入 Yes No No

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上获取,多了一个手动维护计算依赖的过程或映射挑选结果的过程,相信哪种方式是开发者更愿意使用的这个结果已经一目了然了。

衍生数据 concent mbox redux(reselect)
自动维护计算结果之间的依赖 Yes Yes No
触发读取计算结果时收集依赖 Yes Yes No
计算函数无this Yes No Yes

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

如果有关于concent的疑问,可以扫码加群咨询,会尽力答疑解惑,帮助你了解更多。