钟正楷

钟正楷 查看完整档案

北京编辑北京化工大学  |  计算机科学与技术 编辑腾讯  |  高级前端工程师 编辑 www.zzkai.com 编辑
编辑
_ | |__ _ _ __ _ | '_ \| | | |/ _` | | |_) | |_| | (_| | |_.__/ \__,_|\__, | |___/ 该用户太懒什么也没留下

个人动态

钟正楷 发布了文章 · 9月19日

[Concent速成] (1) 定义和共享模块状态

image

开源不易,感谢你的支持,❤ star concent^_^

序言

[Concent速成]是一个帮助新手极速入门concent的系列文章,0障碍地学习和理解concent状态管理思路。

虽然学习和使用过reduxmbox之类的状态管理库,阅读此篇文章会更容易理解,但是没有使用过任何状态管理库的用户也能极速入门concent,真正的0障碍学会使用它并接入到你的react应用里。

注意上面强调了0障碍,包括了学会使用和接入应用两个方面,为了达到此目的,api要足够简单,简单到什么程度呢?简单到无以复加,简单到和react保持100%一致,让新手无需理解额外的概览,以react组件的编写方式就能接入状态管理,但是呢也保留了更高级的抽象接口,让老手可以按照redux的模式去组织代码。

来吧,展示!本期讲解的关键api,包括一个3个顶层apirunuseConcentregister,一个实例上下文apisetState,学会使用这4个api,你就已经会使用concent做为你的状态管理方案了。

Hello world

所有的框架都会以Hello world作为引导,我们此处也不例外,看看concent版本的Hello world是多么的简单。

run 定义模块

concent和redux一样,有一个全局单一的状态树,是一个普通的json对象,不过第一层key规划为模块名,来帮助用户按照业务场景将状态切分为多个模块,便于分开管理。

此处我们需要用到run接口启动concent并载入模块配置,配置一个名为hello的模块,并为其定义状态

import { run } from 'concent';

run({
  hello: {
    state: { greeting: 'Hello world' },
  },
});

register 注册类组件

定义好了模块,我们的组件需要消费模块的状态,对于类组件,使用register即可

import { register } from 'concent';

@register('hello')
class HelloCls extends React.Component{
  state = { greeting: '' };
  changeGreeting = (e)=> this.setState({greeting: e.target.value})
  render(){
    return <input value={this.state.greeting} onChange={this.changeGreeting} />
  }
}

上诉代码用register接口将HelloCls组件注册属于hello模块,concent将向当前组件this上注入一个实例上下文ctx,用于读取数据和调用修改方法,同时也默默替换了this上的statesetState,方便用户可以0改动原组件的代码,仅使用register装饰一下类组件即可接入状态管理,这就是0障碍学会使用并接入到react应用的基础,对于初学者来说,你会写react组件,就已经会使用了concent,没有任何额外的学习成本。

this.state === this.ctx.state; // true
this.setState === this.ctx.setState; // true

上述代码里,还声明了一个类成员变量state等于 { greeting: '' },因为greeting和模块状态里的重名了,所以首次渲染之前它的值将被替换为模块里的Hello world,实际上这里可以不声明这个类成员变量state,写上它只是为了保证删除register装饰器这个组件也能正常工作,而不会得到一个undefinedgreeting初始值。

useConcent 注册函数组件

使用useConcent接口注册当前组件所属模块,useConcent会返回当前组件的实例上下文对象ctx,等同于上面类组件的this.ctx,我们只需要解构它取出statesetState即可。

import { useConcent } from 'concent';

function HelloFn(){
  const { state, setState } = useConcent('hello');
  const changeGreeting = (e)=> setState({greeting: e.target.value})
  return <input value={state.greeting} onChange={changeGreeting} />
}

渲染组件

最后我们看下完整的代码吧,我们发现顶层无Provider之类的组件包裹根组件,因为concent没有依赖React Context api实现状态管理,而是自己独立维护了一个独立的全局上下文,所以你在已有的项目里接入concent是非常容易的,即插即用,无需任何额外的改造。

由于HelloClsHelloFn组件都属于hello模块,它们中的任意一个实例修改模块状态,concent会将其存储到store,并同步到其它同属于hello模块的实例上,状态共享就是这么简单。

import ReactDOM from 'react-dom';
import { run } from 'concent';
import { register, useConcent } from 'concent';

run({/** 略 */});

@register('hello')
class HelloCls extends React.Component{/** 略 */}

function HelloFn(){/** 略 */}

const App = ()=>(
  <div>
     <HelloCls />
     <HelloFn />
  </div>
);

ReactDOM.render(App, document.getElementById('root'));

点我查看源码

依赖收集

无论是类组件还是函数组件,拿到state对象已被转换为一个Proxy代理对象,负责收集当前渲染的数据依赖,所以如果是有条件判断的读取状态,推荐采用延迟解构的写法,让每一次渲染都锁定最小的依赖列表,减少冗余渲染,获得更好的性能。

function HelloFn(){
  const { state, setState, syncBool } = useConcent({module:'hello', state:{show:true}});
  const changeGreeting = (e)=> setState({greeting: e.target.value});
  // 当show为true时,当前实例的依赖是['greeting'],其他任意地方修改了greeting值都会触发当前实例重渲染
  // 当show为false时,当前实例无依赖,其他任意地方修改了greeting值不会影响当前实例重渲染
  return (
      <>
    {state.show?<input value={state.greeting} onChange={changeGreeting} />:'no input'}
    <button onClick={syncBool('show')}>toggle show</button>
    </>
  );
}

跨多个模块消费模块状态

当组件需要消费多个模块的数据时,可使用connect参数来声明要连接的多个模块。

使用connect参数连接多个模块

如下面示例,连接bar和baz两个模块,通过ctx.connectedState获取目标模块状态:

@register({connect:['bar', 'baz']})
class extends React.Component{
  render(){
    const { bar, baz } = this.ctx.connectedState;
  }
}
connectedState拿到的模块状态依然存在着依赖收集行为,所以如果存在条件渲染语句,推荐延迟解构写法

使用setModuleState修改状态

通过调用实例上下文apictx.setModuleState修改目标模块状态

changeName = e=> this.ctx.setModuleState('bar', {name: e.target.value})

结语

此文仅仅演示了最最基础的api用法,帮助你快速上手concent,如果你已经是老司机,特别是vue3 one piece已宣布正式发布的这个关头,如果你非常的不屑一顾这样笨拙的代码组织方式,暂先不要急着否定它,且打开官网看一下其他特性,一定有你喜欢的亮点,包括为react 量身定制的composition api,模块级别的reducercomputedwatchlifecycle等等新特性,后面的速成会一一提到。

Concent携带一整套完整的方案,支持渐进式的开发react组件,即不干扰react本身的开发哲学和组件形态,同时也能够获得巨大的性能收益,这意味着我们可以至下而上的增量式的迭代,状态模块的划分,派生数据的管理,事件模型的分类,业务代码的分隔都可以逐步在开发过程勾勒和剥离出来,其过程是丝滑柔顺的,也允许我们至上而下统筹式的开发,一开始吧所有的领域模型和业务模块抽象的清清楚楚,同时在迭代过程中也能非常快速的灵活调整而影响整个项目架构.

查看原文

赞 3 收藏 1 评论 0

钟正楷 发布了文章 · 8月16日

[Concent小课堂]认识组合api,换个姿势撸更清爽的react

开源不易,感谢你的支持,❤ star me if you like concent ^_^

这里有一份收集中的状态管理清单,欢迎有兴趣的朋友了解^_^
awesome-state

序言

composition api(组合api) 和 optional api(可选api) 两种组织代码的方式,相信大家在vue3各种相关的介绍文里已经了解到不少了,它们可以同时存在,并非强制你只能使用哪一种,但组合api两大优势的确让开发者们更倾向于使用它来替代可选api。

  • 以函数为基础单位来打包可复用逻辑,并注入到任意组件,让视图和业务解耦更优雅
  • 让相同功能的业务更加紧密的放置到一起,不被割裂开,提高开发与维护体验

以上两点在react里均被hook优雅的解决了,那么相比hook,组合api还具有什么优势呢?这里就不卖关子了,相信已有小伙伴在尤大大介绍组合api时已经知道,组合api是静态定义的,解决了hook必需每次渲染都重新生成临时闭包函数的性能问题,也没有了hook里闭包旧值陷阱,人工检测依赖等编码体验问题。

但是,react是all in js的编码方式,所以只要我们敢想、敢做,一切优秀的编程模型都可以吸纳进来,接下来我们用原生hook和concent的setup并通过实例和讲解,来彻底解决尤大提到的这个关于hook的痛点吧^_^

react hook

我们在此先设计一个传统的计数器,要求如下

  • 有一个小数,一个大数
  • 有两组加、减按钮,分别对小数大数做操作,小数按钮加减1,大数按钮加减100
  • 计数器初次挂载时拉取欢迎问候语
  • 当小数达到100时,按钮变为红色,否则变为绿色
  • 当大数达到1000时,按钮变为紫色,否则变为绿色
  • 当大数达到10000时,上报大数的数字
  • 计算器卸载时,上报当前的数字

为了完成此需求,我们需要用到以下5把钩子

useState

过完需求,我们需要用到第一把钩子useState来做组件首次渲染的状态初始化

function Counter() {
  const [num, setNum] = useState(6);
  const [bigNum, setBigNum] = useState(120);
}

useCallback

如需使用缓存函数,则要用到第二把钩子useCallback,此处我们使用这把钩子来定义加减函数

  const addNum = useCallback(() => setNum(num + 1), [num]);
  const addNumBig = useCallback(() => setBigNum(bigNum + 100), [bigNum]);

useMemo

如需用到缓存的计算结果,则要用到第三把钩子useMemo,此处我们使用这把钩子来计算按钮颜色

 const numBtnColor = useMemo(() => {
    return num > 100 ? 'red' : 'green';
  }, [num]);
  const bigNumBtnColor = useMemo(() => {
    return bigNum > 1000 ? 'purple' : 'green';
  }, [bigNum]);

useEffect

处理函数的副作用则需用到第四把钩子useEffect,此处我们用来处理一下两个需求

  • 当大数达到10000时,上报大数的数字
  • 计算器卸载时,上报当前的数字
  useEffect(() => {
    if (bigNum > 10000) api.report('reach 10000')
  }, [bigNum])
  useEffect(() => {
    return ()=>{
      api.reportStat(num, bigNum)
    }
  }, [])

useRef

上面使用清理函数的useEffect写法在IDE是会被警告的,因为内部使用了num, bigNum变量(不写依赖会陷入闭包旧值陷阱),所以要求我们声明依赖

可是如果为了避免IDE警告,我们改为如下方式显然不是我们表达的本意,我们只是想组件卸载时报告一下数字,而不是每一轮渲染都触发清理函数

  useEffect(() => {
    return ()=>{
      api.reportStat(num, bigNum)
    }
  }, [num, bigNum])

这个时候我们需要第5把钩子useRef,来帮忙我们固定依赖了,所以正确的写法是

  const ref = useRef();// ref是一个固定的变量,每一轮渲染都指向同一个值
  ref.current = {num, bigNum};// 帮我们记住最新的值
  useEffect(() => {
    return () => {
      const {num, bigNum} = ref.current;
      reportStat(num, bigNum);
    };
  }, [ref]);

完整的计数器

使完5把钩子,我们完整的组件如下

function Counter() {
  const [num, setNum] = useState(88);
  const [bigNum, setBigNum] = useState(120);
  const addNum = useCallback(() => setNum(num + 1), [num]);
  const addNumBig = useCallback(() => setBigNum(bigNum + 100), [bigNum]);
  const numBtnColor = useMemo(() => {
    return num > 100 ? "red" : "green";
  }, [num]);
  const bigNumBtnColor = useMemo(() => {
    return bigNum > 1000 ? "purple" : "green";
  }, [bigNum]);
  useEffect(() => {
    if (bigNum > 10000) report("reach 10000");
  }, [bigNum]);

  const ref = useRef();
  ref.current = {num, bigNum};
  useEffect(() => {
    return () => {
      const {num, bigNum} = ref.current;
      reportStat(num, bigNum);
    };
  }, [ref]);

  // render ui ...
}

当然我们可以基于hook可定制的特性,将这段代码单独抽象为一个钩子,这样的话只需将数据和方法导出,以便让多种ui表达的Counter组件可以复用,同时也做到ui与业务隔离,利于维护。

function useMyCounter(){
  // .... 略
  return { num, bigNum. addNum, addNumBig, numBtnColor, bigNumBtnColor}
}

concent setup

hook函数在每一轮渲染期间一定是需要全部重新执行一遍的,所以不可避免的在每一轮渲染期间都会产生大量的临时闭包函数,如果我们能省掉他们,的确能帮gc减轻一些回收压力的,现在我们来看看使用setup改造完毕后的Counter会是什么样子吧。

使用concent非常简单,只需要在根组件之前,先使用runapi启动即可,因此处我们没有模块定义,直接调用就可以了。

import { run } from 'concent';

run();// 先启动,在render
ReactDOM.render(<App />, rootEl)

接着我们将以上逻辑稍加改造,全部包裹到setup内部,setup函数内部的逻辑只会被执行一次,需要用到的由渲染上下文ctx提供的api有initStatecomputedeffectsetState,同时配合setState调用时还需要读取的状态state,也由ctx获得。

function setup(ctx) {// 渲染上下文
  const { initState, computed, effect, state, setState } = ctx;
  // setup仅在组件首次渲染之前执行一次,我们可在内部书写相关业务逻辑
}

initState

initState用于初始化状态,替代了useState,当我们的组件状态较大时依然可以不用考虑如何切分状态粒度。

initState({ num: 6, bigNum: 120 });

此处也支持函数是写法初始化状态

initState(()=>({ num: 6, bigNum: 120 }));

computed

computed用于定义计算函数,从参数列表里解构时就确定了计算的输入依赖,相比useMemo,更直接与优雅。

// 仅当num发生变化时,才触发此计算函数
computed('numBtnColor', ({ num }) => (num > 100 ? 'red' : 'green'));

此处我们需要定义两个计算函数,可以用你计算对象描述体来配置计算函数,这样只需调用一次computed即可

computed({
  numBtnColor: ({ num }) => num > 100 ? 'red' : 'green',
  bigNumBtnColor: ({ bigNum }) => bigNum > 1000 ? 'purple' : 'green',
});

effect

effect的用法和useEffect是一模一样的,区别仅仅是依赖数组仅传入key名称即可,同时effect内部将函数组件和类组件的生命周期进行了统一封装,用户可以将业务不做任何修改便迁移到类组件身上

effect(() => {
  if (state.bigNum > 10000) api.report('reach 10000')
}, ['bigNum'])
effect(() => {
  // 这里可以书写首次渲染完毕时需要做的事情
  return () => {
      // 卸载时触发的清理函数
    api.reportStat(state.num, state.bigNum)
  }
}, []);

setState

用于修改状态,我们在setup内部基于setState定义完方法后,然后返回即可,接着我们可以在任意使用此setup的组件里,通过ctx.settings拿到这些方法句柄便可调用

function setup(ctx) {// 渲染上下文
  const { state, setState } = ctx;
  return {// 导出方法
    addNum: () => setState({ num: state.num + 1 }),
    addNumBig: () => setState({ bigNum: state.bigNum + 100 }),
  }
}

完整的Setup Counter

基于上述几个api,我们最终的Counter的逻辑代码如下

function setup(ctx) {// 渲染上下文
  const { initState, computed, effect, state, setState } = ctx;
  // 初始化数据
  initState({ num: 6, bigNum: 120 });
  // 定义计算函数
  computed({
    // 参数列表解构时就确定了计算的输入依赖
    numBtnColor: ({ num }) => num > 100 ? 'red' : 'green',
    bigNumBtnColor: ({ bigNum }) => bigNum > 1000 ? 'purple' : 'green',
  });
  // 定义副作用
  effect(() => {
    if (state.bigNum > 10000) api.report('reach 10000')
  }, ['bigNum'])
  effect(() => {
    return () => {
      api.reportStat(state.num, state.bigNum)
    }
  }, []);

  return {// 导出方法
    addNum: () => setState({ num: state.num + 1 }),
    addNumBig: () => setState({ bigNum: state.bigNum + 100 }),
  }
}

定义完核心的业务逻辑,紧接着,我们可在任意函数组件内部使用useConcent装配我们定义好的setup来使用它了,useConcent会返回一个渲染上下文(和setup函数参数列表里指的是同一个对象引用,有时我们也称实例上下文),我们可按需获从ctx上取出目标数据和方法,针对此示例,我们可以导出
state(数据),settings(setup打包返回的法法),refComputed(实例的计算函数结果容器)这3个key来使用即可。

import { useConcent } from 'concent';

function NewCounter() {
  const { state, settings, refComputed } = useConcent(setup);
  // const { num, bigNum } = state;
  // const { addNum, addNumBig } = settings;
  // const { numBtnColor, bigNumBtnColor } = refComputed;
}

我们上面提到setup同样可以装配给类组件,使用register即可,需要注意的是装配后的类组件,可以从this.ctx上直接获取concent为其生成的渲染上下文,同时呢this.statethis.ctx.state是等效的,this.setStatethis.ctx.setState也是等效的,方便用户代码0改动即可接入concent使用。

import { register } from 'concent';

@register(setup)
class NewClsCounter extends Component{
  render(){
   const { state, settings, refComputed } = this.ctx;
  }
}

结语

对比原生hook,setup将业务逻辑固定在只会被执行一次的函数内部,提供了更友好的api,且同时完美兼容类组件与函数组件,让用户可以逃离hook的使用规则烦恼(想想看 useEffect 配合 useRef,是不是都有不小的认知成本?),而不是将这些约束学习障碍转嫁给用户, 同时对gc也更加友好了,相信大家都已默认了hookreact的一个重要发明,但是其实它不是针对用户的,而是针对框架的,用户其实是不需要了解那些烧脑的细节与规则的,而对于concent用户来说,其实只需一个钩子开启一个传送门,即可在另一个空间内部实现所有业务逻辑,而且这些逻辑同样可以复用到类组件上。

亲爱的客官看了这么多,还不赶紧上手试试,以下提供了两种写法的链接,供你把玩😀

  • one more thing

    上诉两个hook Counter如果想做状态共享,我们需要改造代码接入redux或者自建Context,但是在concent的开发模式下,setup无需任何改造,仅仅只需要提前声明一个模块,然后注册组件内属于该模块即可,这种丝滑般的迁移过程可以让用户灵活应对各种复杂场景。

    import { run } from 'concent';
    
    run({
      counter:{
        state: { num:88, bigNum: 120 },
      },
      //reducer: {...}, // 如操作数据流程复杂,可再将业务提升到此处
    })
    
    // 对于函数组件
    useConcent({setup});
    //  ---> 改为
    useConcent({setup, module:'counter'})
    
    // 对于函数组件
    @register({setup});
    //  ---> 改为
    @register({setup, module:'counter'});

    往期文章

    ❤ 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

    查看原文

    赞 1 收藏 1 评论 0

    钟正楷 发布了文章 · 6月15日

    细聊Concent & Recoil , 探索react数据流的新开发模式

    rvc3.png

    开源不易,感谢你的支持,❤ star me if you like concent ^_^

    序言

    之前发表了一篇文章 redux、mobx、concent特性大比拼, 看后生如何对局前辈,吸引了不少感兴趣的小伙伴入群开始了解和使用 concent,并获得了很多正向的反馈,实实在在的帮助他们提高了开发体验,群里人数虽然还很少,但大家热情高涨,技术讨论氛围浓厚,对很多新鲜技术都有保持一定的敏感度,如上个月开始逐渐被提及得越来越多的出自facebook的最新状态管理方案 recoil,虽然还处于实验状态,但是相必大家已经私底下开始欲欲跃试了,毕竟出生名门,有fb背书,一定会大放异彩。

    不过当我体验完recoil后,我对其中标榜的精确更新保持了怀疑态度,有一些误导的嫌疑,这一点下文会单独分析,是否属于误导读者在读完本文后自然可以得出结论,总之本文主要是分析ConcentRecoil的代码风格差异性,并探讨它们对我们将来的开发模式有何新的影响,以及思维上需要做什么样的转变。

    数据流方案之3大流派

    目前主流的数据流方案按形态都可以划分以下这三类

    • redux流派

    redux、和基于redux衍生的其他作品,以及类似redux思路的作品,代表作有dva、rematch等等。

    • mobx流派

    借助definePerperty和Proxy完成数据劫持,从而达到响应式编程目的的代表,类mobx的作品也有不少,如dob等。

    • Context流派

    这里的Context指的是react自带的Context api,基于Context api打造的数据流方案通常主打轻量、易用、概览少,代表作品有unstated、constate等,大多数作品的核心代码可能不超过500行。

    到此我们看看Recoil应该属于哪一类?很显然按其特征属于Context流派,那么我们上面说的主打轻量对
    Recoil并不适用了,打开其源码库发现代码并不是几百行完事的,所以基于Context api做得好用且强大就未必轻量,由此看出facebookRecoil是有野心并给予厚望的。

    我们同时也看看Concent属于哪一类呢?Concentv2版本之后,重构数据追踪机制,启用了defineProperty和Proxy特性,得以让react应用既保留了不可变的追求,又享受到了运行时依赖收集和ui精确更新的性能提升福利,既然启用了defineProperty和Proxy,那么看起来Concent应该属于mobx流派?

    事实上Concent属于一种全新的流派,不依赖react的Context api,不破坏react组件本身的形态,保持追求不可变的哲学,仅在react自身的渲染调度机制之上建立一层逻辑层状态分发调度机制,defineProperty和Proxy只是用于辅助收集实例和衍生数据对模块数据的依赖,而修改数据入口还是setState(或基于setState封装的dispatch, invoke, sync),让Concent可以0入侵的接入react应用,真正的即插即用和无感知接入。

    即插即用的核心原理是,Concent自建了一个平行于react运行时的全局上下文,精心维护这模块与实例之间的归属关系,同时接管了组件实例的更新入口setState,保留原始的setState为reactSetState,所有当用户调用setState时,concent除了调用reactSetState更新当前实例ui,同时智能判断提交的状态是否也还有别的实例关心其变化,然后一并拿出来依次执行这些实例的reactSetState,进而达到了状态全部同步的目的。

    Recoil初体验

    我们以常用的counter来举例,熟悉一下Recoil暴露的四个高频使用的api

    • atom,定义状态
    • selector, 定义派生数据
    • useRecoilState,消费状态
    • useRecoilValue,消费派生数据

    定义状态

    外部使用atom接口,定义一个key为num,初始值为0的状态

    const numState = atom({
      key: "num",
      default: 0
    });

    定义派生数据

    外部使用selector接口,定义一个key为numx10,初始值是依赖numState再次计算而得到

    const numx10Val = selector({
      key: "numx10",
      get: ({ get }) => {
        const num = get(numState);
        return num * 10;
      }
    });

    定义异步的派生数据

    selectorget支持定义异步函数

    需要注意的点是,如果有依赖,必需先书写好依赖在开始执行异步逻辑
    const delay = () => new Promise(r => setTimeout(r, 1000));
    
    const asyncNumx10Val = selector({
      key: "asyncNumx10",
      get: async ({ get }) => {
        // !!!这句话不能放在delay之下, selector需要同步的确定依赖
        const num = get(numState);
        await delay();
        return num * 10;
      }
    });

    消费状态

    组件里使用useRecoilState接口,传入想要获去的状态(由atom创建而得)

    const NumView = () => {
      const [num, setNum] = useRecoilState(numState);
    
      const add = ()=>setNum(num+1);
    
      return (
        <div>
          {num}<br/>
          <button onClick={add}>add</button>
        </div>
      );
    }

    消费派生数据

    组件里使用useRecoilValue接口,传入想要获去的派生数据(由selector创建而得),同步派生数据和异步派生数据,皆可通过此接口获得

    const NumValView = () => {
      const numx10 = useRecoilValue(numx10Val);
      const asyncNumx10 = useRecoilValue(asyncNumx10Val);
    
      return (
        <div>
          numx10 :{numx10}<br/>
        </div>
      );
    };

    渲染它们查看结果

    暴露定义好的这两个组件, 查看在线示例

    export default ()=>{
      return (
        <>
          <NumView />
          <NumValView />
        </>
      );
    };

    顶层节点包裹React.SuspenseRecoilRoot,前者用于配合异步计算函数需要,后者用于注入Recoil上下文

    const rootElement = document.getElementById("root");
    ReactDOM.render(
      <React.StrictMode>
        <React.Suspense fallback={<div>Loading...</div>}>
          <RecoilRoot>
            <Demo />
          </RecoilRoot>
        </React.Suspense>
      </React.StrictMode>,
      rootElement
    );

    Concent初体验

    如果读过concent文档(还在持续建设中...),可能部分人会认为api太多,难于记住,其实大部分都是可选的语法糖,我们以counter为例,只需要使用到以下两个api即可

    • run,定义模块状态(必需)、模块计算(可选)、模块观察(可选)
    运行run接口后,会生成一份concent全局上下文
    • setState,修改状态

    定义状态&修改状态

    以下示例我们先脱离ui,直接完成定义状态&修改状态的目的

    import { run, setState, getState } from "concent";
    
    run({
      counter: {// 声明一个counter模块
        state: { num: 1 }, // 定义状态
      }
    });
    
    console.log(getState('counter').num);// log: 1
    setState('counter', {num:10});// 修改counter模块的num值为10
    console.log(getState('counter').num);// log: 10

    我们可以看到,此处和redux很类似,需要定义一个单一的状态树,同时第一层key就引导用户将数据模块化管理起来.

    引入reducer

    上述示例中我们直接掉一个呢setState修改数据,但是真实的情况是数据落地前有很多同步的或者异步的业务逻辑操作,所以我们对模块填在reducer定义,用来声明修改数据的方法集合。

    import { run, dispatch, getState } from "concent";
    
    const delay = () => new Promise(r => setTimeout(r, 1000));
    
    const state = () => ({ num: 1 });// 状态声明
    const reducer = {// reducer声明
      inc(payload, moduleState) {
        return { num: moduleState.num + 1 };
      },
      async asyncInc(payload, moduleState) {
        await delay();
        return { num: moduleState.num + 1 };
      }
    };
    
    run({
      counter: { state, reducer }
    });

    然后我们用dispatch来触发修改状态的方法

    因dispatch会返回一个Promise,所以我们需要用一个async 包裹起来执行代码
    import { dispatch } from "concent";
    
    (async ()=>{
      console.log(getState("counter").num);// log 1
      await dispatch("counter/inc");// 同步修改
      console.log(getState("counter").num);// log 2
      await dispatch("counter/asyncInc");// 异步修改
      console.log(getState("counter").num);// log 3
    })()

    注意dispatch调用时基于字符串匹配方式,之所以保留这样的调用方式是为了照顾需要动态调用的场景,其实更推荐的写法是

    import { dispatch } from "concent";
    
    (async ()=>{
      console.log(getState("counter").num);// log 1
      await dispatch(reducer.inc);// 同步修改
      console.log(getState("counter").num);// log 2
      await dispatch(reducer.asyncInc);// 异步修改
      console.log(getState("counter").num);// log 3
    })()

    接入react

    上述示例主要演示了如何定义状态和修改状态,那么接下来我们需要用到以下两个api来帮助react组件生成实例上下文(等同于与vue 3 setup里提到的渲染上下文),以及获得消费concent模块数据的能力

    • register, 注册类组件为concent组件
    • useConcent, 注册函数组件为concent组件
    import { register, useConcent } from "concent";
    
    @register("counter")
    class ClsComp extends React.Component {
      changeNum = () => this.setState({ num: 10 })
      render() {
        return (
          <div>
            <h1>class comp: {this.state.num}</h1>
            <button onClick={this.changeNum}>changeNum</button>
          </div>
        );
      }
    }
    
    function FnComp() {
      const { state, setState } = useConcent("counter");
      const changeNum = () => setState({ num: 20 });
      
      return (
        <div>
          <h1>fn comp: {state.num}</h1>
          <button onClick={changeNum}>changeNum</button>
        </div>
      );
    }

    注意到两种写法区别很小,除了组件的定义方式不一样,其实渲染逻辑和数据来源都一模一样。

    渲染它们查看结果

    在线示例

    const rootElement = document.getElementById("root");
    ReactDOM.render(
      <React.StrictMode>
        <div>
          <ClsComp />
          <FnComp />
        </div>
      </React.StrictMode>,
      rootElement
    );

    对比Recoil,我们发现没有顶层并没有Provider或者Root类似的组件包裹,react组件就已接入concent,做到真正的即插即用和无感知接入,同时api保留为与react一致的写法。

    组件调用reducer

    concent为每一个组件实例都生成了实例上下文,方便用户直接通过ctx.mr调用reducer方法

    mr 为 moduleReducer的简写,直接书写为ctx.moduleReducer也是合法的
    //  --------- 对于类组件 -----------
    changeNum = () => this.setState({ num: 10 })
    // ===> 修改为
    changeNum = () => this.ctx.mr.inc(10);// or this.ctx.mr.asynCtx()
    
    //  --------- 对于函数组件 -----------
    const { state, mr } = useConcent("counter");// useConcent 返回的就是ctx
    const changeNum = () => mr.inc(20);// or ctx.mr.asynCtx()

    异步计算函数

    run接口里支持扩展computed属性,即让用户定义一堆衍生数据的计算函数集合,它们可以是同步的也可以是异步的,同时支持一个函数用另一个函数的输出作为输入来做二次计算,计算的输入依赖是自动收集到的。

     const computed = {// 定义计算函数集合
      numx10({ num }) {
        return num * 10;
      },
      // n:newState, o:oldState, f:fnCtx
      // 结构出num,表示当前计算依赖是num,仅当num发生变化时触发此函数重计算
      async numx10_2({ num }, o, f) {
        // 必需调用setInitialVal给numx10_2一个初始值,
        // 该函数仅在初次computed触发时执行一次
        f.setInitialVal(num * 55);
        await delay();
        return num * 100;
      },
      async numx10_3({ num }, o, f) {
        f.setInitialVal(num * 1);
        await delay();
        // 使用numx10_2再次计算
        const ret = num * f.cuVal.numx10_2;
        if (ret % 40000 === 0) throw new Error("-->mock error");
        return ret;
      }
    }
    
    // 配置到counter模块
    run({
      counter: { state, reducer, computed }
    });

    上述计算函数里,我们刻意让numx10_3在某个时候报错,对于此错误,我们可以在run接口的第二位options配置里定义errorHandler来捕捉。

    run({/**storeConfig*/}, {
        errorHandler: (err)=>{
            alert(err.message);
        }
    })

    当然更好的做法,利用concent-plugin-async-computed-status插件来完成对所有模块计算函数执行状态的统一管理。

    import cuStatusPlugin from "concent-plugin-async-computed-status";
    
    run(
      {/**storeConfig*/},
      {
        errorHandler: err => {
          console.error('errorHandler ', err);
          // alert(err.message);
        },
        plugins: [cuStatusPlugin], // 配置异步计算函数执行状态管理插件
      }
    );

    该插件会自动向concent配置一个cuStatus模块,方便组件连接到它,消费相关计算函数的执行状态数据

    function Test() {
      const { moduleComputed, connectedState, setState, state, ccUniqueKey } = useConcent({
        module: "counter",// 属于counter模块,状态直接从state获得
        connect: ["cuStatus"],// 连接到cuStatus模块,状态从connectedState.{$moduleName}获得
      });
      const changeNum = () => setState({ num: state.num + 1 });
      
      // 获得counter模块的计算函数执行状态
      const counterCuStatus = connectedState.cuStatus.counter;
      // 当然,可以更细粒度的获得指定结算函数的执行状态
      // const {['counter/numx10_2']:num1Status, ['counter/numx10_3']: num2Status} = connectedState.cuStatus;
    
      return (
        <div>
          {state.num}
          <br />
          {counterCuStatus.done ? moduleComputed.numx10 : 'computing'}
          {/** 此处拿到错误可以用于渲染,当然也抛出去 */}
          {/** 让ErrorBoundary之类的组件捕捉并渲染降级页面 */}
          {counterCuStatus.err ? counterCuStatus.err.message : ''}
          <br />
          {moduleComputed.numx10_2}
          <br />
          {moduleComputed.numx10_3}
          <br />
          <button onClick={changeNum}>changeNum</button>
        </div>
      );
    }

    ![]https://raw.githubusercontent...

    查看在线示例

    精确更新

    开篇我说对Recoli提到的精确更新保持了怀疑态度,有一些误导的嫌疑,此处我们将揭开疑团

    大家知道hook使用规则是不能写在条件控制语句里的,这意味着下面语句是不允许的

    const NumView = () => {
      const [show, setShow] = useState(true);
      if(show){// error
        const [num, setNum] = useRecoilState(numState);
      }
    }

    所以用户如果ui渲染里如果某个状态用不到此数据时,某处改变了num值依然会触发NumView重渲染,但是concent的实例上下文里取出来的statemoduleComputed是一个Proxy对象,是在实时的收集每一轮渲染所需要的依赖,这才是真正意义上的按需渲染和精确更新。

    const NumView = () => {
      const [show, setShow] = useState(true);
      const {state} = useConcent('counter');
      // show为true时,当前实例的渲染对state.num的渲染有依赖
      return {show ? <h1>{state.num}</h1> : 'nothing'}
    }

    点我查看代码示例

    当然如果用户对num值有ui渲染完毕后,有发生改变时需要做其他事的需求,类似useEffect的效果,concent也支持用户将其抽到setup里,定义effect来完成此场景诉求,相比useEffect,setup里的ctx.effect只需定义一次,同时只需传递key名称,concent会自动对比前一刻和当前刻的值来决定是否要触发副作用函数。

    conset setup = (ctx)=>{
      ctx.effect(()=>{
        console.log('do something when num changed');
        return ()=>console.log('clear up');
      }, ['num'])
    }
    
    function Test1(){
      useConcent({module:'cunter', setup});
      return <h1>for setup<h1/>
    }

    更多关于effect与useEffect请查看此文

    current mode

    关于concent是否支持current mode这个疑问呢,这里先说答案,concent是100%完全支持的,或者进一步说,所有状态管理工具,最终触发的都是setStateforceUpdate,我们只要在渲染过程中不要写具有任何副作用的代码,让相同的状态输入得到的渲染结果幂,即是在current mode下运行安全的代码。

    current mode只是对我们的代码提出了更苛刻的要求。

    // bad
    function Test(){
       track.upload('renderTrigger');// 上报渲染触发事件
       return <h1>bad case</h1>
    }
    
    // good
    function Test(){
       useEffect(()=>{
          // 就算仅执行了一次setState, current mode下该组件可能会重复渲染,
          // 但react内部会保证该副作用只触发一次
          track.upload('renderTrigger');
       })
       return <h1>bad case</h1>
    }
    

    我们首先要理解current mode原理是因为fiber架构模拟出了和整个渲染堆栈(即fiber node上存储的信息),得以有机会让react自己以组件为单位调度组件的渲染过程,可以悬停并再次进入渲染,安排优先级高的先渲染,重度渲染的组件会切片为多个时间段反复渲染,而concent的上下文本身是独立于react存在的(接入concent不需要再顶层包裹任何Provider), 只负责处理业务生成新的数据,然后按需派发给对应的实例(实例的状态本身是一个个孤岛,concent只负责同步建立起了依赖的store的数据),之后就是react自己的调度流程,修改状态的函数并不会因为组件反复重入而多次执行(这点需要我们遵循不该在渲染过程中书写包含有副作用的代码原则),react仅仅是调度组件的渲染时机,而组件的中断重入针对也是这个渲染过程。

    所以同样的,对于concent

    const setup = (ctx)=>{
      ctx.effect(()=>{
         // effect是对useEffect的封装,
         // 同样在current mode下该副作用也只触发一次(由react保证)
          track.upload('renderTrigger');
      });
    }
    
    // good
    function Test2(){
       useConcent({setup})
       return <h1>good case</h1>
    }
    

    同样的,依赖收集在current mode模式下,重复渲染仅仅是导致触发了多次收集,只要状态输入一样,渲染结果幂等,收集到的依赖结果也是幂等的。

    // 假设这是一个渲染很耗时的组件,在current mode模式下可能会被中断渲染
    function HeavyComp(){
      const { state } = useConcent({module:'counter'});// 属于counter模块
    
     // 这里读取了num 和 numBig两个值,收集到了依赖
     // 即当仅当counter模块的num、numBig的发生变化时,才触发其重渲染(最终还是调用setState)
     // 而counter模块的其他值发生变化时,不会触发该实例的setState
      return (
        <div>num: {state.num} numBig: {state.numBig}</div>
      );
    }

    最后我们可以梳理一下,hook本身是支持把逻辑剥离到用的自定义hook(无ui返回的函数),而其他状态管理也只是多做了一层工作,引导用户把逻辑剥离到它们的规则之下,最终还是把业务处理数据交回给react组件调用其setStateforceUpdate触发重渲染,current mode的引入并不会对现有的状态管理或者新生的状态管理方案有任何影响,仅仅是对用户的ui代码提出了更高的要求,以免因为current mode引发难以排除的bug

    为此react还特别提供了React.Strict组件来故意触发双调用机制, https://reactjs.org/docs/stri... 以引导用户书写更符合规范的react代码,以便适配将来提供的current mode。

    react所有新特性其实都是被fiber激活了,有了fiber架构,衍生出了hooktime slicingsuspense以及将来的Concurrent Mode,class组件和function组件都可以在Concurrent Mode下安全工作,只要遵循规范即可。

    摘取自: https://reactjs.org/docs/stri...

    Strict mode can’t automatically detect side effects for you, but it can help you spot them by making them a little more deterministic. This is done by intentionally double-invoking the following functions:

    • Class component constructor, render, and shouldComponentUpdate methods
    • Class component static getDerivedStateFromProps method
    • Function component bodies
    • State updater functions (the first argument to setState)
    • Functions passed to useState, useMemo, or useReducer

    所以呢,React.Strict其实为了引导用户写能够在Concurrent Mode里运行的代码而提供的辅助api,先让用户慢慢习惯这些限制,循序渐进一步一步来,最后再推出Concurrent Mode

    结语

    Recoil推崇状态和派生数据更细粒度控制,写法上demo看起来简单,实际上代码规模大之后依然很繁琐。

    // 定义状态
    const numState = atom({key:'num', default:0});
    const numBigState = atom({key:'numBig', default:100});
    // 定义衍生数据
    const numx2Val = selector({
      key: "numx2",
      get: ({ get }) => get(numState) * 2,
    });
    const numBigx2Val = selector({
      key: "numBigx2",
      get: ({ get }) => get(numBigState) * 2,
    });
    const numSumBigVal = selector({
      key: "numSumBig",
      get: ({ get }) => get(numState) + get(numBigState),
    });
    
    // ---> ui处消费状态或衍生数据
    const [num] = useRecoilState(numState);
    const [numBig] = useRecoilState(numBigState);
    const numx2 = useRecoilValue(numx2Val);
    const numBigx2 = useRecoilValue(numBigx2Val);
    const numSumBig = useRecoilValue(numSumBigVal);

    Concent遵循redux单一状态树的本质,推崇模块化管理数据以及派生数据,同时依靠Proxy能力完成了运行时依赖收集追求不可变的完美整合。

    run({
      counter: {// 声明一个counter模块
        state: { num: 1, numBig: 100 }, // 定义状态
        computed:{// 定义计算,参数列表里解构具体的状态时确定了依赖
           numx2: ({num})=> num * 2,
           numBigx2: ({numBig})=> numBig * 2,
           numSumBig: ({num, numBig})=> num + numBig,
         }
      },
    });
    
    // ---> ui处消费状态或衍生数据,在ui处结构了才产生依赖
    const { state, moduleComputed, setState } = useConcent('counter') 
    const { numx2, numBigx2, numSumBig} = moduleComputed;
    const { num, numBig } = state;

    所以你将获得:

    • 运行时的依赖收集 ,同时也遵循react不可变的原则
    • 一切皆函数(state, reducer, computed, watch, event...),能获得更友好的ts支持
    • 支持中间件和插件机制,很容易兼容redux生态
    • 同时支持集中与分形模块配置,同步与异步模块加载,对大型工程的弹性重构过程更加友好

    ❤ 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

    查看原文

    赞 10 收藏 6 评论 2

    钟正楷 发布了文章 · 4月10日

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

    ❤ 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

    查看原文

    赞 10 收藏 9 评论 2

    钟正楷 发布了文章 · 2019-10-17

    聊一聊状态管理&Concent设计理念

    状态管理是一个前端界老生常谈的话题了,所有前端框架的发展历程中都离不开状态管理的迭代与更替,对于react来说呢,整个状态管理的发展也随着react架构的变更和新特性的加入而不停的做调整,作为一个一起伴随react成长了快5年的开发者,经历过reflux、redux、mobx,以及其他redux衍生方案dva、mirror、rematch等等后,我觉得它们都不是我想要的状态管理的终极形态,所以为了打造一个和react结合得最优雅、使用起来最简单、运行起来最高效的状态管理方案,踏上了追梦旅途。

    为何需要状态管理

    为何需要在前端引用里引入状态管理,基本上大家都达成了共识,在此我总结为3点:

    • 随着应用的规模越来越大,功能越来越复杂,组件的抽象粒度会越来越细,在视图中组合起来后层级也会越来越深,能够方便的跨组件共享状态成为迫切的需求。
    • 状态也需要按模块切分,状态的变更逻辑背后其实就是我们的业务逻辑,将其抽离出来能够彻底解耦ui和业务,有利于逻辑复用,以及持续的维护和迭代。
    • 状态如果能够被集中的管理起来,并合理的派发有利于组件按需更新,缩小渲染范围,从而提高渲染性能

    已有状态管理方案现状

    react

    遵循react不可变思路的状态管理方案,无论从git的star排名还是社区的繁荣度,首推的一定是redux这个react界状态管理一哥,约束使用唯一路径reducer纯函数去修改store的数据,从而达到整个应用的状态流转清晰、可追溯。

    image.png

    mbox

    遵循响应式的后期之秀mbox,提出了computedreaction的概念,其官方的口号就是任何可以从应用程序状态派生的内容都应该派生出来,通过将原始的普通json对象转变为可观察对象,我们可以直接修改状态,mbox会自动驱动ui渲染更新,因其响应式的理念和vue很相近,在react里搭配mobx-react使用后,很多人戏称mobx是一个将react变成了类vue开发体验的状态管理方案。

    image.png

    当然因为mbox操作数据很方便,不满足大型应用里对状态流转路径清晰可追溯的诉求,为了约束用户的更新行为,配套出了一个mobx-state-tree,总而言之,mobx成为了响应式的代表。

    其他

    剩下的状态管理方案,主要有3类。

    一类是不满足redux代码冗余啰嗦,接口不够友好等缺点,进而在redux之上做2次封装,典型的代表国外的有如rematch,国内有如dvamirror等,我将它们称为redux衍生的家族作品,或者是解读了redux源码,整合自己的思路重新设计一个库,如final-stateretalkhydux等,我将它们称为类redux作品。

    一类是走响应式道路的方案,和mobx一样,劫持普通状态对象转变为可观察对象,如dob,我将它们称为类mobx作品。

    剩下的就是利用react context api或者最新的hook特性,主打轻量,上手简单,概念少的方案,如unstated-nextreactnsmoxreact-model等。

    我心中的理想方案

    上述相关的各种方案,都各自在一定程度上能满足我们的需求,但是对于追求完美的水瓶座程序猿,我觉得它们终究都不是我理想的方案,它们或小而美、或大而全,但还是不够强,不够友好,所以决定开始自研状态管理方案。

    我知道小和 美、全、强本身是相冲突的,我能接受一定量的大,gzip后10kb到20kb都是我接受的范围,在此基础上,去逐步地实现美、全、强,以便达到以下目的,从而体现出和现有状态管理框架的差异性、优越性。

    • 让新手使用的时候,无需了解新的特性api,无感知状态管理的存在,使其遁于无形之中,仅按照react的思路组织代码,就能享受到状态管理带来的福利。
    • 让老手可以结合对状态管理的已有认知来使用新提供的特性api,还原各种社区公认的最佳实践,同时还能向上继续探索和提炼,挖掘状态管理带来的更多收益。
    • react有了hook特性之后,让class组件和function组件都能够享有一致的思路、一致的api接入状态管理,不产生割裂感。
    • 在保持以上3点的基础上,让用户能够使用更精简且更符合思维直觉的组织方式书写代码,同时还能够获得巨大的性能提升收益。

    为了达成以上目标,立项concent,将其定义为一个可预测、零入侵、渐进式、高性能的增强型状态管理方案,期待能把他打磨成为一个真真实实让用户用起来感觉到美丽、全面、强大的框架。

    说人话就是:理解起来够简单、代码写起来够优雅、工程架构起来够健壮、性能用起来够卓越...... ^_^

    concent.png

    可预测

    react是一个基于pull based来做变化侦测的ui框架,对于用户来说,需要显式的调用setState来让react感知到状态变化,所以concent遵循react经典的不可变原则来体现可预测,不使用劫持对象将转变为可观察对象的方式来感知状态变化(要不然又成为了一个类mobx......), 也不使用时全局pub&sub的模式来驱动相关视图更新,同时还要配置各种reselectredux-saga等中间件来解决计算缓存、异步action等等问题(如果这样,岂不是又迈向了一个redux全家桶轮子的不归路..... )

    吐槽一下:redux粗放的订阅粒度在组件越来越多,状态越来越复杂的时候,经常因为组件订阅了不需要的数据而造成冗余更新,而且各种手写mapXXXToYYY很烦啊有木有啊有木有,伤不起啊伤不起......

    零入侵

    上面提到了期望新手仅按照react的思路组织代码,就能够享受到状态管理带来的福利,所以必然只能在setState之上做文章,其实我们可以把setState当做一个下达渲染指令重要入口(除此之外,还有forceUpdate)。

    setState,下达更新指令

    仔细看看上图,有没有发现有什么描述不太准确的地方,我们看看官方的setState函数签名描述:

    setState<K extends keyof S>(
        state: ((prevState: Readonly<S>, props: Readonly<P>) => (Pick<S, K> | S | null)) | (Pick<S, K> | S | null),
        callback?: () => void
    ): void;

    通过签名描述,我们可以看出传递给setState的是一个部分状态(片段状态),实际上我们在调用setState也是经常这么做的,修改了谁就传递对应的stateKey和值。

    传递部分状态

    react自动将部分状态合并到原来的整个状态对象里从而覆盖掉其对应的旧值,然后驱动对应的视图更新。

    merge partial state

    所以我只要能够让setState提交的状态给自己的同时,也能够将其提交到store并分发到其他对应的实例上就达到了我的目的。

    set state Intelligently

    显而易见我们需要劫持setState,来注入一些自己的逻辑,然后再调用原生setState

    //伪代码实现
    class Foo extends Component{
      constructor(props, context){
        this.state = { ... };
        this.reactSetState = this.setState.bind(this);
        this.setState = (partialState, callback){
          //commit partialState to store .....
          this.reactSetState(partialState, callback);
        }
      }
    }

    当然作为框架提供者,肯定不会让用户在constructor去完成这些额外的注入逻辑,所以设计了两个关键的接口runregisterrun负责载入模块配置,register负责注册组件设定其所属模块,被注册的组件其setState就得到了增强,其提交的状态不仅能够触发渲染更新,还能够直接提交到store,同时分发到这个模块的其他实例上。

    store虽然是一颗单一的状态树,但是实际业务逻辑是由很多模块的,所以我将store的第一层key当做模块名(类似命名空间),这样就产生了模块的概念
    //concent代码示意
    import { run, register } from 'concent';
    
    run({
      foo:{//foo模块定义
        state:{
          name: 'concent',
        }
      }
    })
    
    @register('foo')
    class Foo extends Component {
      changeName = ()=> {
        this.setState({ name: e.currentTarget.value });//修改name
      }
      render(){
        const { name } = this.state;//读取name
        return <input value={name} onChange={this.changeName} />
      }
    }

    在线示例代码见此处

    现在我们来看看上面这段代码,除了没有显示的在Foo组件里声明state,其他地方看起来是不是给你一种感觉:这不就是一个地地道道的react组件标准写法吗?concent将接入状态管理的成本降低到了几乎可忽略不计的地步。

    当然,也允许你在组件里声明其他的非模块状态,这样的话它们就相当于私有状态了,如果setState提交的状态既包含模块的也包含非模块的,模块状态会被当做sharedState提取出来分发到其他实例,privName仅提交给自己。

    @register('foo')
    class Foo extends Component {
      state = { privName: 'i am private, not from store' };
      fooMethod = ()=>{
        //name会被当做sharedState分发到其他实例,privName仅提交给自己
        this.setState({name: 'newName', privName: 'vewPrivName' });
      }
      render(){
        const { name, privName } = this.state;//读取name, privName
      }
    }

    在这样的模式下,你可以在任何地方实例化多个Foo,任何一个实例改变name的值,其他实例都会被更新,而且你也不需要在顶层的根组件处包裹类似Provider的辅助标签来注入store上下文。

    之所以能够达到此效果,得益于concent的核心工作原理依赖标记引用收集状态分发,它们将在下文叙述中被逐个提到。

    渐进式

    能够通过作为setState作为入口接入状态管理,且还能区分出共享状态和私有状态,的确大大的提高了我们操作模块数据的便利性,但是这样就足够用和足够好了吗?

    更细粒度的控制数据消费

    组件对消费模块状态的粒度并不总是很粗的和模块直接对应的关系,即属于模块foo的组件CompA可能只消费模块foo里的f1f2f3三个字段对应的值,而属于模块foo的组件CompB可能只消费模块foo里另外的f4f5f6三个字段对应的值,我们当然不期望CompA的实例只修改了f2f3时却触发了的CompB实例渲染。

    大多数时候我们期望组件和模块保持的是一对一的关系,即一个组件只消费某一个模块提供的数据,但是现实情况的确存在一个组件消费多个模块的数据。

    所以针对register接口,我们需要传入更多的信息来满足更细粒度的数据消费需求

    • 通过module标记组件属于哪个具体的模块
    这是一个可选项,不指定的话就让其属于内置的$$default模块(一个空模块),有了module,就能够让concent在其组件实例化之后将模块的状态注入到实例的state上了。
    • 通过watchedKeys标记组件观察所属模块的stateKey范围
    这是一个可选项,不传入的话,默认就是观察所属模块所有stateKey的变化,通过watchedKeys来定义一个stateKey列表,控制同模块的其他组件提交新状态时,自己需不需要被渲染更新。
    • 通过connect标记连接的其他模块
    这是一个可选项,让用户使用connect参数去标记连接的其他模块,设定在其他模块里的观察stateKey范围。
    • 通过ccClassKey设定当前组件类名
    这是一个可选项,设定后方便在react dom tree上查看具名的concent组件节点,如果不设定的话,concent会自动更根据其moduleconnect参数的值算出一个,此时注册了同一个模块标记了相同connect参数的不同react组件在react dom tree上看到的就是相同的标签名字。

    通过以上register提供的这些关键参数为组件打上标记,完成了concent核心工作原理里很重要的一环:依赖标记,所以当这些组件实例化后,它们作为数据消费者,身上已经携带了足够多的信息,以更细的粒度来消费所需要的数据。

    store的角度看类与模块的关系

    image.png

    实例的state作为数据容器已经盛放了所属模块的状态,那么当使用connect让组件连接到其他多个模块时,这些数据又该怎么注入呢?跟着这个问题我们回想一下上面提到过的,某个实例调用setState时提交的状态会被concent提取出其所属模块状态,将它作为sharedState精确的分发到其他实例。

    能够做到精确分发,是因为当这些注册过的组件在实例化的时候,concent就会为其构建了一个实例上下文ctx,一个实例对应着一个唯一的ctx,然后concent这些ctx引用精心保管在全局上下文ccContext里(一个单例对象,在run的时候创建),所以说组件的实例化过程完成了concent核心工作原理里很重要的一环:引用收集,当然了,实例销毁后,对应的ctx也会被删除。

    有了ctx对象,concent就可以很自然将各种功能在上面实现了,上面提到的连接了多个模块的组件,其模块数据将注入到ctx.connectedState下,通过具体的模块名去获取对应的数据。

    ctx.png

    我们可以在代码里很方便的构建跨多个模块消费数据的组件,并按照stateKey控制消费粒度

    //concent代码示意
    import { run, register, getState } from 'concent';
    
    run({
      foo:{//foo模块定义
        state:{
          name: 'concent',
          age: 19,
          info: { addr: 'bj', mail: 'xxxx@qq.com' },
        }
      },
      bar: { ... },
      baz: { ... },
    })
    
    //不设定watchedKeys,观察foo模块所有stateKey的值变化
    //等同于写为 @register({module:'foo', watchedKeys:'*' })
    @register('foo')
    class Foo1 extends Component { ... }
    
    //当前组件只有在foo模块的'name', 'info'值发生变化时才触发更新
    //显示的设定ccClassKey名称,方便查看引用池时知道来自哪个类
    @register({module:'foo', watchedKeys:['name', 'info'] }, 'Foo2')
    class Foo2 extends Component { ... }
    
    //连接bar、baz两个模块,并定义其连接模块的watchKeys
    @register({
      module:'foo', 
      watchedKeys:['name', 'info'] ,
      connect: { bar:['bar_f1', 'bar_f2'], baz:'*' }
    }, 'Foo2')
    class Foo2 extends Component {
      render(){
        //获取到bar,baz两个模块的数据
        const { bar, baz } = this.ctx.connectedState;
      }
     }

    上面提到了能够做到精确分发是因为concent将实例的ctx引用做了精心保管,何以体现呢?因为concent为这些引用做了两层映射关系,并将其存储在全局上下文里,以便高效快速的索引到相关实例引用做渲染更新。

    • 按照各自所属的不同模块名做第一层归类映射。
    模块下存储的是一个所有指向该模块的ccClassKey类名列表, 当某个实例提交新的状态时,通过它携带者的所属模块,直接一步定位到这个模块下有哪些类存在。
    • 再按照其各自的ccClassKey类名做第二层归类映射。
    ccClassKey下存储的就是这个cc类对应的上下文对象ccClassContext,它包含很多关键字段,如refs是已近实例好的组件对应的ctx引用索引数组,watchedKeys是这个cc类观察key范围。

    上面提到的ccClassContext是配合concent完成状态分发的最重要的元数据描述对象,整个过程只需如下2个步骤:

    • 1 实例提交新状态时第一步定位到所属模块下的所有ccClassKey列表,
    • 2 遍历列表读取并分析ccClassContext对象,结合其watchedKeys条件约束,尝试将提交的sharedState通过watchedKeys进一步提取出符合当前类实例更新条件的状态extractedState,如果提取出为空,就不更新,反之则将其refs列表下的实例ctx引用遍历,将extractedState发送给对应的reactSetState入口,触发它们的视图渲染更新。

    工作原理

    解耦ui和业务

    有如开篇的我们为什么需要状态管理里提到的,状态的变更逻辑背后其实就是我们的业务逻辑,将其抽离出来能够彻底解耦ui和业务,有利于逻辑复用,以及持续的维护和迭代。

    所以我们漫天使用setState怼业务逻辑,业务代码和渲染代码交织在一起必然造成我们的组件越来越臃肿,且不利于逻辑复用,但是很多时候功能边界的划分和模块的数据模型建立并不是一开始能够定义的清清楚楚明明白白的,是在不停的迭代过程中反复抽象逐渐沉淀下来的

    所以concent允许这样多种开发模式存在,可以自上而下的一开始按模块按功能规划好store的reducer,然后逐步编码实现相关组件,也可以自下而上的开发和迭代,在需求或者功能不明确时,就先不抽象reducer,只是把业务写在组件里,然后逐抽离他们,也不用强求中心化的配置模块store,而是可以自由的去中心化配置模块store,再根据后续迭代计划轻松的调整store的配置。

    新增reducer定义

    import { run } from 'concent';
    run({
      counter: {//定义counter模块
        state: { count: 1 },//state定义,必需
        reducer: {//reducer函数定义,可选
          inc(payload, moduleState) {
            return { count: moduleState.count + 1 }
          },
          dec(payload, moduleState) {
            return { count: moduleState.count - 1 }
          }
        },
      },
    })

    通过dispatch修改状态

    import { register } from 'concent';
    //注册成为Concent Class组件,指定其属于counter模块
    @register('counter')
    class CounterComp extends Component {
      render() {
        //ctx是concent为所有组件注入的上下文对象,携带为react组件提供的各种新特性api
        return (
          <div>
            count: {this.state.count}
            <button onClick={() => this.ctx.dispatch('inc')}>inc</button>
            <button onClick={() => this.ctx.dispatch('dec')}>dec</button>
          </div>
        );
      }
    }

    因为concent的模块除了state、reducer,还有watch、computed和init 这些可选项,支持你按需定义。

    cc-modulepng

    所以不管是全局消费的business model、还是组件或者页面自己维护的component modelpage model,都推荐进一步将model写为文件夹,在内部定义state、reducer、computed、watch、init,再导出合成在一起组成一个完整的model定义。

    src
    ├─ ...
    └─ page
    │  ├─ login
    │  │  ├─ model //写为文件夹
    │  │  │  ├─ state.js
    │  │  │  ├─ reducer.js
    │  │  │  ├─ computed.js
    │  │  │  ├─ watch.js
    │  │  │  ├─ init.js
    │  │  │  └─ index.js
    │  │  └─ Login.js
    │  └─ product ...
    │  
    └─ component
       └─ ConfirmDialog
          ├─ model
          └─ index.js

    这样不仅显得各自的职责分明,防止代码膨胀变成一个巨大的model对象,同时reducer独立定义后,内部函数相互dispatch调用时可以直接基于引用而非字符串了。

    // code in models/foo/reducer.js
    export function changeName(name) {
      return { name };
    }
    
    export async function  changeNameAsync(name) {
      await api.track(name);
      return { name };
    }
    
    export async function changeNameCompose(name, moduleState, actionCtx) {
      await actionCtx.setState({ loading: true });
      await actionCtx.dispatch(changeNameAsync, name);//基于函数引用调用
      return { loading: false };
    }

    高性能

    现有的状态管理方案,大家在性能的提高方向上,都是基于缩小渲染范围来处理,做到只渲染该渲染的区域,对react应用性能的提升就能产生不少帮助,同时也避免了人为的去写shouldComponentUpdate函数。

    那么对比redux,因为支持key级别的消费粒度控制,从状态提交那一刻起就知道更新哪些实例,所以性能上能够给你足够的保证的,特别是对于组件巨多,数据模型复杂的场景,cocent一定能给你足够的信心去从容应对,我们来看看对比mboxconcent做了哪些更多场景的探索。

    renderKey,更精确的渲染范围控制

    每一个组件的实例上下文ctx都有一个唯一索引与之对应,称之为ccUniqueKey,每一个组件在其实例化的时候如果不显示的传入renderKey来重写的话,其renderKey默认值就是ccUniqueKey,当我们遇到模块的某个stateKey是一个列表或者map时,遍历它生产的视图里各个子项调用了同样的reducer,通过id来达到只修改自己数据的目的,但是他们共享的是一个stateKey,所以必然观察这个stateKey的其他子项也会被触发冗余渲染,而我们期望的结果是:谁修改了自己的数据,就只触发渲染谁。

    如store的list是一个长列表,每一个item都会渲染成一个ItemView,每一个ItemView都走同一个reducer函数修改自己的数据,但是我们期望修改完后只能渲染自己,从而做到更精确的渲染范围控制

    render-key.png

    基于renderKey机制,concent可以轻松办到这一点,当你在状态派发入口处标记了renderKey时,concent会直接命中此renderKey对应的实例去触发渲染更新。

    无论是setStatedispatch,还是invoke,都支持传入renderKey

    render-key

    react组件自带的key用于diff v-dom-tree 之用,concent的renderKey用于控制实例定位范围,两者有本质上的区别,以下是示例代码,在线示例代码点我查看

    // store的一个子模块描述
    {
      book: {
        state: {
          list: [
            { name: 'xx', age: 19 },
            { name: 'xx', age: 19 }
          ],
          bookId_book_: { ... },//map from bookId to book
        },
        reducer: {
          changeName(payload, moduleState) {
            const { id, name } = payload;
            const bookId_book_ = moduleState.bookId_book_;
            const book = bookId_book_[id];
            book.name = name;//change name
    
            //只是修改了一本书的数据
            return { bookId_book_ };
          }
        }
      }
    }
    
    @register('book')
    class ItemView extends Component {
      changeName = (e)=>{
        this.props.dispatch('changeName', e.currentTarget.value);
      }
      changeNameFast = (e)=>{
        // 每一个cc实例拥有一个ccUniqueKey 
        const ccUniqueKey = this.ctx.ccUniqueKey;
        // 当我修改名称时,真的只需要刷新我自己
        this.props.dispatch('changeName', e.currentTarget.value, ccUniqueKey);
      }
      render() {
        const book = this.state.bookId_book_[this.props.id];
        //尽管我消费是subModuleFoo的bookId_book_数据,可是通过id来让我只消费的是list下的一个子项
    
        //替换changeName 为 changeNameFast达到我们的目的
        return <input value={ book.name } onChange = { changeName } />
      }
    }
    
    @register('book')
    class BookItemContainer extends Component {
      render() {
        const books = this.state.list;
        return (
          <div>
            {/** 遍历生成ItemView */}
            {books.map((v, idx) => <ItemView key={v.id} id={v.id} />)}
          </div >
        )
      }
    }

    因concent对class组件的hoc默认采用反向继承策略做包裹,所以除了渲染范围降低和渲染时间减少,还将拥有更少的dom层级。

    lazyDispatch,更细粒度的渲染次数控制

    concent里,reducer函数和setState一样,提倡改变了什么就返回什么,且书写格式是多样的。

    • 可以是普通的纯函数
    • 可以是generator生成器函数
    • 可以是async & await函数

    可以返回一个部分状态,可以调用其他reducer函数后再返回一个部分状态,也可以啥都不返回,只是组合其他reducer函数来调用。对比redux或者redux家族的方案,总是合成一个新的状态是不是要省事很多,且纯函数和副作用函数不再区别对待的定义在不同的地方,仅仅是函数声明上做文章就可以了,你想要纯函数,就声明为普通函数,你想要副作用函数,就声明为异步函数,简单明了,符合阅读思维。

    基于此机制,我们的reducer函数粒度拆得很细很原子,每一个都负责独立更新某一个和某几个key的值,以便更灵活的组合它们来完成高度复用的目的,让代码结构上变优雅,让每一个reducer函数的职责更得更小。

    //reducer fns
    export async function updateAge(id){
      // ....
      return {age: 100};
    }
    
    export async function trackUpdate(id){
      // ....
      return {trackResult: {}};
    }
    
    export async function fetchStatData(id){
      // ....
      return {statData: {}};
    }
    
    // compose other reducer fns
    export async function complexUpdate(id, moduleState, actionCtx) {
      await actionCtx.dispatch(updateAge, id);
      await actionCtx.dispatch(trackUpdate, id);
      await actionCtx.dispatch(fetchStatData, id);
    }

    虽然代码结构上变优雅了,每一个reducer函数的职责更小了,但是其实每一个reducer函数其实都会触发一次更新。

    reducer函数的源头触发是从实例上下文ctx.dispatch或者全局上下文cc.dispatch(or cc.reducer)开始的,呼叫某个模块的某个reducer函数,然后在其reducer函数内部再触发的其他reducer函数的话,其实已经形成了一个调用链,链路上的每一个返回了状态值的reducer函数都会触发一次渲染更新,如果链式上有很多reducer函数,会照常很多次对同一个视图的冗余更新。

    触发reducer的源头代码

    // in your view
    <button onClick={()=> ctx.dispatch('complexUpdate', 2)}>复杂的更新</button>

    更新流程如下所示

    dispatch.png

    针对这种调用链提供lazy特性,以便让用户既能满意的把reducer函数更新状态的粒度拆分得很细,又保证渲染次数缩小到最低。

    看到此特性,mbox使用者是不是想到了transaction的概念,是的你的理解没错,某种程度上它们所到到的目的是一样的,但是在concent里使用起来更加简单和优雅。

    现在你只需要将触发源头做小小的修改,用lazyDispatch替换掉dispatch就可以了,reducer里的代码不用做任何调整,concent将延迟reducer函数调用链上所有reducer函数触发ui更新的时机,仅将他们返回的新部分状态按模块分类合并后暂存起来,最后的源头函数调用结束时才一次性的提交到store并触发相关实例渲染。

    // in your view
    <button onClick={()=> ctx.lazyDispatch('complexUpdate', 2)}>复杂的更新</button>

    lazy-dispatch

    查看在线示例代码

    现在新的更新流程如下图

    image.png

    当然lazyScope也是可以自定义的,不一定非要在源头函数上就开始启用lazy特性。

    // in your view
    const a=  <button onClick={()=> ctx.dispatch('complexUpdateWithLoading', 2)}>复杂的更新</button>
    
    // in your reducer
    export async function complexUpdateWithLoading(id, moduleState, actionCtx) {
      //这里会实时的触发更新
      await actionCtx.setState({ loading: true });
    
      //从这里开始启用lazy特性,complexUpdate函数结束前,其内部的调用链都不会触发更新
      await actionCtx.lazyDispatch(complexUpdate, id);
    
      //这里返回了一个新的部分状态,也会实时的触发更新
      return { loading: false };
    }

    delayBroadcast,更主动的降低渲染次数频率

    针对一些共享状态,当某个实例高频率的改变它的时候,使用delayBroadcast主动的控制此状态延迟的分发到其它实例上,从而实现更主动的降低渲染次数频率

    delay

    function ImputComp() {
      const ctx = useConcent('foo');
      const { name } = ctx.state;
      const changeName = e=> ctx.setState({name: e.currentTarget.value});
      //setState第四位参数是延迟分发时间
      const changeNameDelay = e=> ctx.setState({name: e.currentTarget.value}, null, null, 1000);
      return (
        <div>
          <input  value={name} onChange={changeName} />
          <input  value={name} onChange={changeName} />
        </div>
      );
    }
    
    function App(){
      return (
        <>
          <ImputComp />
          <ImputComp />
          <ImputComp />
        </>
      );
    }

    查看在线示例代码

    增强react

    前面我们提到的ctx对象,是增强react的“功臣”,因为每个实例上都有一个concent为之构造的ctx对象,在它之下新增很多新功能、新特性就很方便了。

    新特性加入

    如上面关于模块提到了computedwatch等关键词,读到它们的读者,一定留了一些疑问吧,其实它们出现的动机和使用体验是和vue的一样的。

    • computed定义各个stateKey的值发生变化时,要触发的计算函数,并将其结果缓存起来,仅当stateKey的值再次变化时,才会触发计。了解更多关于computed
    • watch定义各个stateKey的值发生变化时,要触发的回调函数,仅当stateKey的值再次变化时,才会触发,通常用于一些异步的任务处理。了解更多关于watch

    我如果从setState的本质来解释,你就能够明白这些功能其实自然而然的就提供给用户使用了。

    setState传入的参数是partialState,所以concent一开始就知道是哪些stateKey发生了变化,自然而然我们只需要暴露一个配置computedwatch的地方,那么当实例提交新的部分状态时,增强后setState就自然能够去触发相关回调了。

    enhance set state.png

    setup赋予组件更多能力

    上面提到的computedwatch值针对模块的,我们需要针对实例单独定制computedwatch的话该怎么处理呢?

    setup是针对组件实例提供的一个非常重要的特性,在类组件和函数组件里都能够被使用,它会在组件首次渲染之前会被触发执行一次,其返回结果收集在ctx.settings里,之后便不会再被执行,所以可以在其中定义实例computed、实例watch、实例effect等钩子函数,同时也可以自定义其他的业务逻辑函数并返回,方便组件使用。

    基于setup执行时机的特点,相当于给了组件一个额外的空间,一次性的为组件定义好相关的个性化配置,赋予组件更多的能力,特别是对于函数组件,提供useConcent来复制了register接口的所有能力,其返回结果收集在ctx.settings里的特点让函数组件能够将所有方法一次性的定义在setup里,从而避免了在函数组件重复渲染期间反复生成临时闭包函数的弱点,减少gc的压力。

    使用useConcent只是为了让你还是用经典的dispatch&&reducer模式来书写核心业务逻辑,并不排斥和其他工具钩子函数(如useWindowSize等)一起混合使用。

    让我们setup吧!!!看看setup带来的魔力,其中effect钩子函数完美替代了useEffect了解更多关于setup

    const setup = ctx => {
      //count变化时的副作用函数,第二位参数可以传递多个值,表示任意一个发生变化都将触发此副作用
      ctx.effect(() => {
        console.log('count changed');
      }, ['count']);
      //每一轮渲染都会执行
      ctx.effect(() => {
        console.log('trigger every render');
      });
      //仅首次渲染执行的副作用函数
      ctx.effect(() => {
        console.log('trigger only first render');
      }, []);
    
      //定义实例computed,因每个实例都可能会触发,优先考虑模块computed
      ctx.computed('count', (newVal, oldVal, fnCtx)=>{
        return newVal*2;
      });
    
     //定义实例watch,区别于effect,执行时机是在组件渲染之前
     //因每个实例都可能会触发,优先考虑模块watch
      ctx.watch('count', (newVal, oldVal, fnCtx)=>{
        //发射事件
        ctx.emit('countChanged', newVal);
        api.track(`count changed to ${newVal}`);
      });
    
      //定义事件监听,concent会在实例销毁后自动将其off掉
      ctx.on('changeCount', count=>{
        ctx.setState({count});
      });
    
      return {
        inc: () => setCount({ count: ctx.state.count + 1 }),
        dec: () => setCount({ count: ctx.state.count - 1 }),
      };
    }

    得益于setup特性和所有concent实例都持有上线文对象ctx,类组件和函数组件将实现100%的api调用能力统一,这就意味着两者编码风格高度一致,相互转换代价为0。

    接入setup的函数组件

    import { useConcent } from 'concent';
    
    function HooklFnComp() {
      //setup只会在初次渲染前调用一次
      const ctx = useConcent({ setup, module:'foo' });
      const { state , settings: { inc, dec }  } = ctx;
    
      return (
        <div>
          count: {state.count}
          <button onClick={inc}>+</button>
          <button onClick={dec}>-</button>
        </div>
      );
    }

    接入setup的类组件

    @register('foo')
    class ClassComp extends React.Component() {
      $$setup(ctx){
        //复用刚才的setup定义函数, 这里记得将结果返回
        return setup(ctx);
      }
    
      render(){
        const ctx = this.ctx;
        //ctx.state 等同于 this.state
        const { state , settings: { inc, dec }  } = ctx;
    
        return (
          <div>
            count: {state.count}
            <button onClick={inc}>+</button>
            <button onClick={dec}>-</button>
          </div>
        );
      }
    
    }

    查看在线示例代码

    能力得到增强后,可以自由的按场景挑选合适的方式更新状态

    @register("foo")
    class HocClassComp extends Component {
      render() {
        const { greeting } = this.state; // or this.ctx.state
        const {invoke, sync, set, dispatch} = this.ctx;
    
        // dispatch will find reducer method to change state
        const changeByDispatch = e => dispatch("changeGreeting", evValue(e));
        // invoke cutomized method to change state
        const changeByInvoke = e => invoke(changeGreeting, evValue(e));
        // classical way to change state, this.setState equals this.ctx.setState
        const changeBySetState = e => this.setState({ greeting: evValue(e) });
        // make a method to extract event value automatically
        const changeBySync = sync('greeting');
        // similar to setState by give path and value
        const changeBySet = e=> set('greeting', evValue(e));
    
        return (
          <>
            <h1>{greeting}</h1>
            <input value={greeting} onChange={changeByDispatch} /><br />
            <input value={greeting} onChange={changeByInvoke} /><br />     
            <input value={greeting} onChange={changeBySetState} /><br />
            <input value={greeting} onChange={changeBySync} /><br />
            <input value={greeting} onChange={changeBySet} />
          </>
        );
      }
    }

    查看在线示例代码

    下图是一个完整的concent组件生命周期示意图:

    ins.png

    支持中间件与插件

    一个好的框架应该是需要提供一些可插拔其他库的机制来弹性的扩展额外能力的,这样有利于用户额外的定制一些个性化需求,从而促进框架周边的生态发展,所以一开始设计concent,就保留了中间件与插件机制,允许定义中间件拦截所有的数据变更提交记录做额外处理,也支持自定义插件接收运行时的各种信号,增强concent能力。

    image.png

    定义中间件并使用

    一个中间就是一个普通函数
    import { run } from 'concent';
    const myMiddleware = (stateInfo, next)=>{
      console.log(stateInfo);
      next();//next一定不能忘记
    }
    
    run(
      {...}, //store config
      {
        middlewares: [ myMiddleware ] 
      }
    );

    定义插件并使用

    一个插件就是一个必需包含install方法的普通对象
    import { cst, run } from 'concent';
    
    const myPlugin = {
      install: ( on )=>{
        //监听来自concent运行时的各种信号,并做个性化处理
        on(cst.SIG_FN_START, (data)=>{
          const { payload, sig } = data;
          //code here
        })
      }
    
      return { name: 'myPlugin' }//必需返回插件名
    }

    现基于插件机制已提供如下插件

    image.png

    拥抱现有的react生态

    当然concent不会去造无意义的轮子,依然坚持拥抱现有的react生态的各种优秀资源,如提供的react-router-concent,桥接了react-router将其适配到concent应用里。

    全局暴露history对象,享受编程式的导航跳转。

    import React, { Component } from 'react'
    import ReactDOM from 'react-dom'
    import { BrowserRouter, Switch, Route } from 'react-router-dom';
    import { ConnectRouter, history, Link } from 'react-router-concent';
    import { run, register } from 'concent';
    
    run();
    
    class Layout extends Component {
      render() {
        console.log('Layout Layout');
        return (
          <div>
            <div onClick={() => history.push('/user')}>go to user page</div>
            <div onClick={() => history.push('/user/55')}>go to userDetail page</div>
            {/** 可以基于history主动push,也可以使用Link */}
            <Link to="/user" onClick={to => alert(to)}>to user</Link>
            <div onClick={() => history.push('/wow')}>fragment</div>
            <Route path="/user" component={User_} />
            <Route path="/user/:id" component={UserDetail_} />
            <Route path="/wow" component={F} />
          </div>
        )
      }
    }
    
    const App = () => (
      <BrowserRouter>
        <div id="app-root-node">
          <ConnectRouter />
          <Route path="/" component={Layout} />
        </div>
      </BrowserRouter>
    )
    ReactDOM.render(<App />, document.getElementById('root'));

    点我查看在线示例

    结语&思考

    concent的工作机制核心是依赖标记引用收集状态分发,通过构建全局上下文和实例上下文,并让两者之间产生互动来实现状态管理的诉求,并进一步的实现组件能力增强。

    理论上基于此原理,可以为其他同样基于pull based更新机制的ui框架实现状态管理,并让他们保持一致的api调用能力和代码书写风格,如小程序this.setDataomithis.update

    同时因为concent提供了实例上下文对象ctx来升级组件能力,所以如果我们提出一个目标:可以让响应式不可变共存,看起来是可行的,只需要再附加一个和state对等的可观察对象在ctx上,假设this.ctx.data就是我们构建的可观察对象,然后所提到的响应式需要做到针对不同平台按不同策略处理,就能达到共存的目的了。

    • 针对本身就是响应式的框架如angualrvue,提供this.ctx.data去直接修改状态相当于桥接原有的更新机制,而reducer返回的状态最终还是落到this.ctx.data去修改来驱动视图渲染。
    • 针对pull based的框架如react,提供this.ctx.data只是一种伪的响应式,在this.ctx.data收集到的变更最终还是落到this.setState去驱动视图更新,但是的确让用户使用起来觉得是直接操作了数据就驱动了视图的错觉。

    所以如果实现了这一层的统一,是不是concent可以用同样的编码方式去书写所有ui框架了呢?

    当然,大一统的愿望是美好的,可是真的需要将其实现吗?各框架里的状态管理方案都已经很成熟,个人有限的精力去做实现这份愿景必然又是选择了一条最最艰辛的路,所以这里只是写出一份个人对让响应式不可变共存的的思考整理,给各位读者提供一些参考意见去思考状态管理和ui框架之间的发展走向。

    如果用一句诗形容状态管理与ui框架,个人觉得是

    金风玉露一相逢,便胜却人间无数。

    两者相互成就对方,相互扶持与发展,见证了这些年各种状态库的更替。

    目前concent暂时只考虑与react做整合,致力于提高它们之间的默契度,期望逐步的在大哥redux而二哥mobx的地盘下,占领一小块根据地生存下来,如果读者你喜欢此文,对concent有意,欢迎来star,相信革命的火种一定能够延续下去,concent的理念一定能走得更远。

    查看原文

    赞 0 收藏 0 评论 0

    钟正楷 发布了文章 · 2019-09-02

    使用concent,体验一把渐进式地重构react应用之旅

    传统的redux项目里,我们写在reducer里的状态一定是要打通到store的,我们一开始就要规划好state、reducer等定义,有没有什么方法,既能够快速享受ui与逻辑分离的福利,又不需要照本宣科的从条条框框开始呢?本文从普通的react写法开始,当你一个收到一个需求后,脑海里有了组件大致的接口定义,然后丝滑般的接入到concent世界里,感受渐进式的快感以及全新api的独有魅力吧!

    power your react

    需求来了

    上周天气其实不是很好,记得下了好几场雨,不过北京总部大厦的隔音太好了,以致于都没有感受到外面的风雨飘摇,在工位上正在思索着整理下现有代码时,接到一个普通的需求,大致是要实现一个弹窗。

    • 左侧有一个可选字段列表,点击任意一个字段,就会进入右侧。
    • 右侧有一个已选字段列表,该列表可以上下拖拽决定字段顺序决定表格里的列字段显示顺序,同时也可以删除,将其恢复到可选择列表。
    • 点击保存,将用户的字段配置存储到后端,用户下次再次使用查看该表格时,使用已配置的显示字段来展示。

    这是一个非常普通的需求,我相信不少码神看完后,脑海里已经把代码雏形大致写完了吧,嘿嘿,但是还请耐性看完本篇文章,来看看在concent的加持下,你的react应用将如何变得更加灵活与美妙,正如我们的slogan:

    concent, power your react

    准备工作

    产品同学期望快速见到一般效果原型,而我希望原型是可以持续重构和迭代的基础代码,当然要认真对待了,不能为了交差而乱写一版,所以要快速整理需求并开始准备工作了。

    因为项目大量基于antd来书写UI,听完需求后,脑海里冒出了一个穿梭框模样的组件,但因为右侧是一个可拖拽列表,查阅了下没有类似的组件,那就自己实现一个吧,初步整理下,大概列出了以下思路。

    • 组件命名为ColumnConfModal,基于antdModal, Card实现布局,antdList来实现左侧的选择列表,基于react-beautiful-dnd的可拖拽api来实现右侧的拖拽列表。

    ui布局

    • 因为这个弹窗组件在不同页面被不同的table使用,传入的列定义数据是不一样的,所以我们使用事件的方式,来触发打开弹窗并传递表格id,打开弹窗后获取该表格的所有字段定义,以及用户针对表哥的已选择字段数据,这样把表格元数据的初始化工作收敛在ColumnConfModal内部。
    • 基于表格左右两侧的交互,大致定义一下内部接口

    1 moveToSelectedList(移入到已选择列表 )
    2 moveToSelectableList(移入到可选择列表)
    3 saveSelectedList(保存用户的已选择列表)
    4 handleDragEnd(处理已选择列表顺序调整完成时)
    5 其他略.....

    UI 实现

    因为注册为concent组件后天生拥有了emit&on的能力,而且不需要手动offconcent在实例销毁前自动就帮你解除其事件监听,所以我们可以注册完成后,很方便的监听openColumnConf事件了。

    我们先抛弃各种store和reducer定义,快速的基于class撸出一个原型,利用register接口将普通组件注册为concent组件,伪代码如下

    import { register } from 'concent';
    
    class ColumnConfModal extends React.Component {
      state = {
        selectedColumnKeys: [],
        selectableColumnKeys: [],
        visible: false,
      };
      componentDidMount(){
        this.ctx.on('openColumnConf', ()=>{
          this.setState({visible:true});
        });
      }
      moveToSelectedList = ()=>{
        //code here
      }
      moveToSelectableList = ()=>{
        //code here
      }
      saveSelectedList = ()=>{
        //code here
      }
      handleDragEnd = ()=>{
        //code here
      }
      render(){
        const {selectedColumnKeys, selectableColumnKeys, visible} = this.state;
        return (
          <Modal title="设置显示字段" visible={state._visible} onCancel={settings.closeModal}>
            <Head />
            <Card title="可选字段">
              <List dataSource={selectableColumnKeys} render={item=>{
                //...code here
              }}/>
            </Card>
            <Card title="已选字段">
              <DraggableList dataSource={selectedColumnKeys} onDragEnd={this.handleDragEnd}/>
            </Card>
          </Modal>
        );
      }
    }
    
    // es6装饰器还处于实验阶段,这里就直接包裹类了
    // 等同于在class上@register( )来装饰类
    export default register( )(ColumnConfModal)

    可以发现,这个类的内部和传统的react类写法并无区别,唯一的区别是concent会为每一个实例注入一个上下文对象ctx来暴露concentreact带来的新特性api。

    消灭生命周期函数

    因为事件的监听只需要执行一次,所以例子中我们在componentDidMount里完成了事件openColumnConf的监听注册。

    根据需求,显然的我们还要在这里书写获取表格列定义元数据和获取用户的个性化列定义数据的业务逻辑

      componentDidMount() {
        this.ctx.on('openColumnConf', () => {
          this.setState({ visible: true });
        });
    
        const tableId = this.props.tid;
        tableService.getColumnMeta(`/getMeta/${tableId}`, (columns) => {
          userService.getUserColumns(`/getUserColumns/${tableId}`, (userColumns) => {
            //根据columns userColumns 计算selectedList selectableList
          });
        });
      }

    所有的concent实例可以定义setup钩子函数,该函数只会在初次渲染前调用一次。

    现在让我们来用setup代替掉此生命周期

      //class 里定义的setup加$$前缀
      $$setup(ctx){
        //这里定义on监听,在组件挂载完毕后开始真正监听on事件
        ctx.on('openColumnConf', () => {
          this.setState({ visible: true });
        });
    
        //标记依赖列表为空数组,在组件初次渲染只执行一次
        //模拟componentDidMount
        ctx.effect(()=>{
          //service call balabala.....
        }, []);
      }

    如果已熟悉hook的同学,看到setup里的effectapi语法是不是和useEffect有点像?

    effectuseEffect的执行时机是一样的,即每次组件渲染完毕之后,但是effect只需要在setup调用一次,相当于是静态的,更具有性能提升空间,假设我们加一个需求,每次vibible变为false时,上报后端一个操作日志,就可以写为

        //依赖列表填入key的名称,表示当这个key的值发生变化时,触发副作用
        ctx.effect( ctx=>{
          if(!ctx.state.visible){
            //当前最新的visible已是false,上报
          }
        }, ['visible']);

    关于effect就点到为止,说得太多扯不完了,我们继续回到本文的组件上。

    提升状态到store

    我们希望组件的状态变更可以被记录下来,方便观察数据变化,so,我们先定义一个store的子模块,名为ColumnConf

    定义其sate为

    // code in ColumnConfModal/model/state.js
    export function getInitialState() {
      return {
        selectedColumnKeys: [],
        selectableColumnKeys: [],
        visible: false,
      };
    }
    
    export default getInitialState();

    然后利用concentconfigure接口载入此配置

    // code in ColumnConfModal/model/index.js
    import { configure } from 'concent';
    import state from './state';
    
    // 配置模块ColumnConf
    configure('ColumnConf', {
      state,
    });
    注意这里,让model跟着组件定义走,方便我们维护model里的业务逻辑。

    整个store已经被concent挂载到了window.sss下,为了方便查看store,当当当当,你可以打开console,直接查看store各个模块当前的最新数据。

    window.sss

    然后我们把class注册为'配置模ColumnConf的组件,现在class里的state声明可以直接被我们干掉了。

    import './model';//引用一下model文件,触发model配置到concent
    
    @register('ColumnConf')
    class ColumnConfModal extends React.Component {
      // state = {
      //   selectedColumnKeys: [],
      //   selectableColumnKeys: [],
      //   visible: false,
      // };
      render(){
        const {selectedColumnKeys, selectableColumnKeys, visible} = this.state;
      }
    }

    大家可能注意到了,这样暴力的注释掉,render里的代码会不会出问题?放心吧,不会的,concent组件的state和store是天生打通的,同样的setState也是和store打通的,我们先来安装一个插件concent-plugin-redux-devtool

    import ReduxDevToolPlugin from 'concent-plugin-redux-devtool';
    import { run } from 'concent';
    
    // storeConfig配置略,详情可参考concent官网
    run(storeConfig, {
        plugins: [ ReduxDevToolPlugin ]
    });

    注意哦,concent驱动ui渲染的原理和redux完全不一样的,核心逻辑部分也不是在redux之上做包装,和redux一点关系都没有的^_^,这里只是桥接了redux-dev-tool插件,来辅助做状态变更记录的,小伙伴们千万不要误会,没有reduxconcent一样能够正常运作,但是由于concent提供完善的插件机制,为啥不利用社区现有的优秀资源呢,重复造无意义的轮子很辛苦滴(⊙﹏⊙)b......

    现在让我们打开chrome的redux插件看看效果吧。

    state tree

    上图里是含有大量的ccApi/setState,是因为还有不少逻辑没有抽离到reducerdispatch/***模样的type就是dispatch调用了,后面我们会提到。

    这样看状态变迁是不是要比window.sss好多了,因为sss只能看当前最新的状态。

    这里既然提到了redux-dev-tool,我们就顺道简单了解下,concent提交的数据长什么样子吧

    action

    上图里可以看到5个字段,renderKey是用于提高性能用的,可以先不作了解,这里我们就说说其他四个,module表示修改的数据所属的模块名,committedState表示提交的状态,sharedState表示共享到store的状态,ccUniqueKey表示触发数据修改的实例id。

    为什么要区分committedStatesharedState呢?因为setState调用时允许提交自己的私有key的(即没有在模块里声明的key),所以committedState是整个状态都要再次派发给调用者,而sharedState是同步到store后,派发给同属于module值的其他cc组件实例的。

    这里就借用官网一张图示意下:

    cc-core

    所以我们可以在组件里声明其他非模块的key,然后在this.state里获取到了

    @register('ColumnConf')
    class ColumnConfModal extends React.Component {
       state = {
            _myPrivKey:'i am a private field value, not for store',
       };
      render(){
          //这里同时取到了模块的数据和私有的数据
        const {selectedColumnKeys, selectableColumnKeys, visible, _myPrivKey} = this.state;
      }
    }

    解耦业务逻辑与UI

    虽然代码能够正常工作,状态也接入了store,但是我们发现class已经变得臃肿不堪了,利用setState怼固然快和方便,但是后期维护和迭代的代价就会慢慢越来越大,让我们把业务抽到reduder

    export function setLoading(loading) {
      return { loading };
    };
    
    /** 移入到已选择列表 */
    export function moveToSelectedList() {
    }
    
    /** 移入到可选择列表 */
    export function moveToSelectableList() {
    }
    
    /** 初始化列表 */
    export async function initSelectedList(tableId, moduleState, ctx) {
      //这里可以不用基于字符串 ctx.dispatch('setLoading', true) 去调用了,虽然这样写也是有效的
      await ctx.dispatch(setLoading, true);
      const columnMeta = await tableService..getColumnMeta(`/getMeta/${tableId}`);
      const userColumsn = await userService.getUserColumns(`/getUserColumns/${tableId}`);
      //计算 selectedColumnKeys selectableColumnKeys 略
    
      //仅返回需要设置到模块的片断state就可以了
      return { loading: false, selectedColumnKeys, selectableColumnKeys };
    }
    
    /** 保存已选择列表 */
    export async function saveSelectedList(tableId, moduleState, ctx) {
    }
    
    export function handleDragEnd() {
    }

    利用concentconfigure接口把reducer也配置进去

    // code in ColumnConfModal/model/index.js
    import { configure } from 'concent';
    import * as reducer from 'reducer';
    import state from './state';
    
    // 配置模块ColumnConf
    configure('ColumnConf', {
      state,
      reducer,
    });

    还记得上面的setup吗,setup可以返回一个对象,返回结果将收集在settiings里,现在我们稍作修改,然后来看看class吧,世界是不是清静多了呢?

    import { register } from 'concent';
    
    class ColumnConfModal extends React.Component {
      $$setup(ctx) {
        //这里定义on监听,在组件挂载完毕后开始真正监听on事件
        ctx.on('openColumnConf', () => {
          this.setState({ visible: true });
        });
    
        //标记依赖列表为空数组,在组件初次渲染只执行一次
        //模拟componentDidMount
        ctx.effect(() => {
          ctx.dispatch('initSelectedList', this.props.tid);
        }, []);
    
        return {
          moveToSelectedList: (payload) => {
            ctx.dispatch('moveToSelectedList', payload);
          },
          moveToSelectableList: (payload) => {
            ctx.dispatch('moveToSelectableList', payload);
          },
          saveSelectedList: (payload) => {
            ctx.dispatch('saveSelectedList', payload);
          },
          handleDragEnd: (payload) => {
            ctx.dispatch('handleDragEnd', payload);
          }
        }
      }
      render() {
        //从settings里取出这些方法
        const { moveToSelectedList, moveToSelectableList, saveSelectedList, handleDragEnd } = this.ctx.settings;
      }
    }

    爱class,爱hook,让两者和谐共处

    react社区轰轰烈烈推动了Hook革命,让大家逐步用Hook组件代替class组件,但是本质上Hook逃离了this,精简了dom渲染层级,但是也带来了组件存在期间大量的临时匿名闭包重复创建。

    来看看concent怎么解决这个问题的吧,上面已提到setup支持返回结果,将被收集在settiings里,现在让稍微的调整下代码,将class组件吧变身为Hook组件吧。

    import { useConcent } from 'concent';
    
    const setup = (ctx) => {
      //这里定义on监听,在组件挂载完毕后开始真正监听on事件
      ctx.on('openColumnConf', (tid) => {
        ctx.setState({ visible: true, tid });
      });
    
      //标记依赖列表为空数组,在组件初次渲染只执行一次
      //模拟componentDidMount
      ctx.effect(() => {
        ctx.dispatch('initSelectedList', ctx.state.tid);
      }, []);
    
      return {
        moveToSelectedList: (payload) => {
          ctx.dispatch('moveToSelectedList', payload);
        },
        moveToSelectableList: (payload) => {
          ctx.dispatch('moveToSelectableList', payload);
        },
        saveSelectedList: (payload) => {
          ctx.dispatch('saveSelectedList', payload);
        },
        handleDragEnd: (payload) => {
          ctx.dispatch('handleDragEnd', payload);
        }
      }
    }
    
    const iState = { _myPrivKey: 'myPrivate state', tid:null };
    
    export function ColumnConfModal() {
      const ctx = useConcent({ module: 'ColumnConf', setup, state: iState });
      const { moveToSelectedList, moveToSelectableList, saveSelectedList, handleDragEnd } = ctx.settings;
      const { selectedColumnKeys, selectableColumnKeys, visible, _myPrivKey } = ctx.state;
    
      // return your ui
    }

    在这里要感谢尤雨溪老师的这篇Vue Function-based API RFC,给了我很大的灵感,现在你可以看到所以的方法的都在setup里定义完成,当你的组件很多的时候,给gc减小的压力是显而易见的。

    由于两者的写法高度一致,从classHook是不是非常的自然呢?我们其实不需要争论该用谁更好了,按照你的个人喜好就可以,就算某天你看class不顺眼了,在concent的代码风格下,重构的代价几乎为0。

    使用组件

    上面我们定义了一个on事件openColumnConf,那么我们在其他页面里引用组件ColumnConfModal时,当然需要触发这个事件打开其弹窗了。

    import { emit } from 'concent';
    
    class Foo extends React.Component {
      openColumnConfModal = () => {
        //如果这个类是一个concent组件
        this.ctx.emit('openColumnConfModal', 3);
        //如果不是则可以调用顶层api emit
        emit('openColumnConfModal', 3);
      }
      render() {
        return (
          <div>
            <button onClick={this.openColumnConfModal}>配置可见字段</button>
            <Table />
              <ColumnConfModal />
          </div>
        );
      }
    }

    上述写法里,如果有其他很多页面都需要引入ColumnConfModal,都需要写一个openColumnConfModal,我们可以把这个打开逻辑抽象到modalService里,专门用来打开各种弹窗,而避免在业务见到openColumnConfModal这个常量字符串

    //code in service/modal.js
    import { emit } from 'concent';
    
    export function openColumnConfModal(tid) {
      emit('openColumnConfModal', tid);
    }

    现在可以这样使用组件来触发事件调用了

    import * as modalService from 'service/modal';
    
    class Foo extends React.Component {
      openColumnConfModal = () => {
        modalService.openColumnConfModal(6);
      }
      render() {
        return (
          <div>
            <button onClick={this.openColumnConfModal}>配置可见字段</button>
            <Table />
            <ColumnConfModal />
          </div>
        );
      }
    }

    结语

    以上代码在任何一个阶段都是有效的,想要了解渐进式重构的在线demo可以点这里,更多在线示例列表点这里

    由于本篇主题主要是介绍渐进式重构组件,所以其他特性诸如synccomputed$watch、高性能杀手锏renderKey等等内容就不在这里展开讲解了,留到下一篇文章,敬请期待。

    如果看官觉得喜欢,就来点颗星星呗,concent致力于为react带来全新的编码体验和功能强化,敬请期待更多的特性和生态周边。

    查看原文

    赞 3 收藏 2 评论 0

    钟正楷 发布了文章 · 2019-07-17

    为react赋能的concent是什么,何以值得一试

    welcome star

    你的star将是我最大的精神鼓励,欢迎star🥺🥺🥺

    concent是一个专为react提供状态管理服务的框架,提炼现有各大框架的精华,以及社区公认的最佳实践,通过良好的模块设计,既保证react的最佳性能又允许用户非常灵活的解耦UI逻辑与业务逻辑的关系,从整体上提高代码的可读性可维护性可扩展性

    concent携带以下特性

    • 核心api少且简单,功能强大,上手容易,入侵小,容易调试
    • 提供全局模块化的单一数据源
    • 支持0入侵的方式,渐进式的重构已有react代码
    • 对组件扩展了事件总线、computed、watch、双向绑定等特性
    • 完美支持function组件
    • 基于引用定位和状态广播,支持细粒度的状态订阅,渲染效率出众
    • 支持中间件,可以扩展你的个性化插件处理数据变更
    • 支持react 0.10+任意版本;

    为用户提供更舒适和简单的react编码体验

    精心的模块设计理念

    state

    concent对模块的定义是经过对实际业务场景反复思考和推敲,最终得出的答案,首先,数据是模块的灵魂,承载着对你的功能模块的最基础的字符描述,离开数据,一切上层业务功能都是空谈,所以state是模块里的必包含的定义。

    reducer

    修改数据的方式灵活度是concent提供给用户惊喜之一,因为concent的核心是通过接管setState做状态管理,所以用户接入concent那一刻可以无需立即改造现有的代码就能够享受到状态管理的好处,同样的,concent也支持用户定义reducer函数修改状态,这也是推荐的最佳实践方式,可以彻底解耦UI渲染与业务逻辑,因为reducer函数本质上只是setState的变种写法,所以强调的是总是返回需要更新的片段状态,而且由于concent支持reducer函数之间相互调用,任意组合,所以可以允许用户按需任意切割reducer函数对状态的更新粒度,然后形成链式调用关系,然后通过dispatch句柄来触发reducer函数

    cc-dispatch

    如果链式调用层级过深,会造成很多次渲染,从上图中可以看出有3个函数返回新的片段状态,造成3次渲染,所以concent也同样提供lazyDispatch句柄来让用户可以有多一种选择来触发对reducer函数的调用,concent会在调动过程中自动缓存当前调用链上所有属于同一个模块的状态并做合并,直到调用链结束,才做一次性的提交

    cc-lazy-dispatch

    computed

    computed提供一个入口定义需要对发生变化的key做计算的函数,通常来说,大部分state的数据并非是UI渲染直接需要的数据,我们通常需要对其做一些格式化或者转换操作,但是这些操作其实没有必要再每次渲染前都做一遍,computed将只对发生了变化的key计算并将其结果缓存起来。

    watch

    watchcomputed最大的不同是,不需要返回一个具体的结果,通常用于在关心某些key变化时,做一些异步操作,就可以对这些key定义watch函数

    init

    我们知道state的定义是同步的,init允许用户有一次对state做异步获取并改写的机会,注意,如果此时存在着该模块的实例,改写了模块的状态后,concent会自动将这些状态广播到对应的实例上,同样的,如果不存在,在有些的该模块的实例生成时,这些实例将同步到模块最新的状态,所以当我们有一些状态不是需要依赖实例挂载上且触发componentDidMount来获取的时候,就可以将状态的初始化提升到模块的init

    cc-lazy-dispatch

    灵活的模块和组件映射关系

    模块是先于组件存在的概念,当我们有了模块的定义后,便可以对组件提供强有力的支持,concent里通过register函数将react组件注册为concent组件(也称之为concent类)

    cc-lazy-dispatch

    注册的时候,可以指定专属的模块,理论来说,我们应该保持组件与模块干净的对应关系,即一个组件专属于某个模块,消费的是该模块的数据,操作的所属模块的reducer函数,但是实际场景可能有不少组件都是跨多个模块消费和修改数据的,所以concent也允许用户通过connect定义来指定组件连接的其他模块,唯一不同的是调用句柄默认带的上下文是指向自己专属模块的,如果需要调用其他模块的方法,则需要显示指定模块名

    @register('Foo', {module:'foo', connect:{bar:'*'}})
    class Foo extends Component(){
      onNameChange = (name)=>{
        this.$$dispatch('changeName', name);//默认调用的foo模块reducer里的changeName方法
    
        this.$$dispatch('bar/changeName', name);//指定bar模块, 调用bar模块的reducer里的changeName方法修改bar模块的数据
      }
    }

    cc-ccclass-module
    对于CcClass来说,因为调用setState就能够修改store,所以数据是直接注入到state里的,对于其他模块的数据,是注入到connectedState,这样既保持了所属模块和其他模块的数据隔离,又能够让用户非常方便消费多个模块的数据。
    cc-class-and-instance-state

    所以整体来说,组件与store之间将构成一张关系明确和清晰的结构网,有利于用户为大型的react工程初期整齐的划分业务模块,中期灵活的调整模块定义
    cc-class-and-store

    更友好的function支持

    hook提案落地后,现有的react社区,已经从class component慢慢转向function component写法,但是正如Vue Function-based API RFC所说,hook显而易见的要创建很多临时函数和产生大量闭包的问题,以及通过提供辅助函数useMemo/useCallback等来解决过度更新或者捕获了过期值等问题,提出了setup方案,每个组件实例只会在初始化时调用一次 ,状态通过引用储存在 setup() 的闭包内。

    综合上述的setup思路和好处,concent针对react的函数组件引入setup机制并对其做了更优的改进,同样在在组件实例化时只调用一次,可以定义各种方法并返回,这些方法将收集在上下文的settings对象里,还额外的允许setup里定义effectcomputedwatch函数(当然,这些是实例级别的computedwatch了)

    在线示例

    UI定义

    const AwardPanelUI = (props) => {
      return (
        <div style={stBox}>
          {/** 其他略 */}
          <div>displayBonus: {props.displayBonus}</div>
        </div>
      );
    };

    setup定义

    const setup = ctx => {
      //定义副作用,第二位参数写空数组,表示只在组件初次挂载完毕后执行一次
      ctx.defineEffect(ctx => {
        ctx.dispatch('init');
        //返回清理函数,组件卸载时将触发此函数
        return () => ctx.dispatch('track', 'user close award panel')
      }, []);
    
      /** 也支持函数式写法
        ctx.defineWatch(ctx=>{
          return {...}
        });
       */
      ctx.defineWatch({
        //key inputCode的值发生变化时,触发此观察函数
        'inputCode':(nevVal)=> ctx.setState({msg:'inputCode 变为 '+nevVal })
      });
      ctx.defineComputed({
        //key inputCode的值发生变化时,触发此计算函数
        'inputCode':(newVal)=>`${newVal}_${Date.now()}`
      });
    
      //定义handleStrChange方法
      const handleStrChange = (e) => {
        const inputCode = e.currentTarget.value;
    
        //两种写法等效
        ctx.dispatch('handleInputCodeChange', inputCode);
        // ctx.reducer.award.handleInputCodeChange(inputCode);
      }
    
      //定义init函数
      const init = ctx.reducer.award.init;
      //const init = ()=> ctx.dispatch('init');
    
      //setup会将返回结果放置到settings
      return { handleStrChange, init };
    }

    mapProps定义

    //函数组件每次渲染前,mapProps都会被调用,帮助用户组装想要的props数据
    const mapProps = ctx => {
      //将bonus的计算结果取出
      const displayBonus = ctx.moduleComputed.bonus;
      //将settings里的 handleStrChange方法、init方法 取出
      const { handleStrChange, init } = ctx.settings;
      //将inputCode取出
      const { inputCode, awardList, mask, msg } = ctx.moduleState;
    
      //从refConnectedComputed获取实例对模块key的计算值
      const { inputCode:cuInputCode } = ctx.refComputed.award;
    
      //该返回结果会映射到组件的props上
      return { msg, cuInputCode, init, mask, inputCode, awardList, displayBonus, handleStrChange }
    }

    连接函数组件

    const AwardPanel = connectDumb({setup, mapProps, module:'award'})(AwardPanelUI);

    hook真的是答案吗

    有了setup的支持,可以将这些要用到方法提升为静态的上下文api,而不需要反复重定义,也不存在大量的临时闭包问题,同时基于函数式的写法,可以更灵活的拆分和组合你的U代码与业务代码,同时这些setup函数,经过进一步抽象,还可以被其他地方复用。

    同时函数式编程也更利于typescript做类型推导,concent对函数组件友好支持,让用户可以在classfunction之间按需选择,concent还允许定义state来做局部状态管理,所以经过connectDumb包裹的function组件,既能够读写本地状态,又能够读写store状态,还有什么更好的理由非要使用hook不可呢?

    const AwardPanel = connectDumb({
      //推荐写为函数式写法,因为直接声明对象的话,concent也会对其做深克隆操作
      //state:()=>({localName:1});
      state:{localName:1},
      setup, 
      mapProps, 
      connect:{award:'*'}
    })(AwardPanelUI);
    
    //code in setup
    const setup = ctx =>{
      const changeLocalName = name => ctx.setState({localName});
      return {changeLocalName};
    }
    
    //code in mapProps
    const mapProps = ctx =>{
      const localName = ctx.state.localName;
      return {localName}; 
    }

    更加注重使用体验的架构

    concent接入react应用是非常轻松和容易的,对于已存在的react应用,不需要你修改现有的react应用任何代码,只需要先将concent启动起来,就可以使用了,不需要在顶层包裹Provider之类的组件来提供全局上下文,因为启动concent之后,concent自动就维护着一个自己的全局上下文,所以你可以理解concentreact应用是一个平行的关系,而非嵌套或者包裹的关系,唯一注意的是在渲染react应用之前,优先将concent启动就可以了。
    cc-struct

    分离式的模块配置

    concent并非要求用户在启动时就配置好各个模块的定义,允许用户定义某些组件时,调用configure函数配置模块,这将极大提高定义page model或者component model的编码体验。

    .
    |____page
    | |____Group
    | | |____index.js
    | | |____model//定义page model
    | |   |____reducer.js //可选
    | |   |____index.js
    | |   |____computed.js //可选
    | |   |____state.js //必包含
    | |   |____watch.js //可选
    | |   |____init.js //可选
    | |
    | |____...//各种page组件定义
    |
    |____App.css
    |____index.js
    |____utils
    | |____...
    |____index.css
    |____models// 各种业务model的定义
    | |____home
    | | |____reducer.js
    | | |____index.js
    | | |____computed.js
    | | |____state.js
    |
    |____components
    | |____Nav.js
    |
    |____router.js
    |____logo.png
    |____assets
    | |____...
    |____run-cc.js //启动concent,在入口index.js里第一行就调用
    |____App.js
    |____index.js //项目入口文件
    |____services
    | |____...
    

    以上图代码文件组织结构为例,page组件Group包含了一个自己的model,在model/index.js里完成定义模块到concent的动作,

    // code in page/Group/model/index.js
    import state form './state';
    import * as reducer form './reducer';
    import * as computed form './computed';
    import * as watch form './watch';
    import init form './init';
    import {configure} from 'concent';
    
    //配置模块到`concent`里,命名为'group'
    configure('group', {state, reducer, computed, watch, init});

    在Group组件对外暴露前,引入一下model就可以了

    import './model';
    
    @register('GroupUI', {module:'group'})
    export default class extends Component {
    
    }

    这种代码组织方式为用户发布携带完整model定义的concent组件到npm成为了可能,其他用户只需安装它的concent应用里,安装了该组件就能直接使用该组件,甚至不使用组件的UI逻辑,只是注册他新写的组件到该组件携带的模块里,完完全全复用模块的除了ui的其他所有定义。

    模块克隆

    对于已有的模块,有的时候我们想完全的复用里面的所有定义但是运行时是彻底隔离的,如果用最笨的方法,就是完全copy目标模块下的所有代码,然后起一个新的名字,配置到concent就好了,可是如果有10个、20个甚至更多的组件想复用逻辑但是保持运行时隔离怎么办呢?显然复制多份代码是行不通的,concent提供cloneModule函数帮助你完成此目的,实际上cloneModule函数只是对state做了一个深拷贝,其他的因为都是函数定义,所以只是让新模块指向那些函数的引用。

    基于cloneModule可以在运行时任意时间调用的特性,你甚至可以写一个工厂函数,动态创解绑定了新模块的组件!

    //makeComp.js
    import existingModule from './demoModel';
    import { register, cloneModule } from 'concent';
    
    const module_comp_= {};//记录某个模块有没有对应的组件
    
    class Comp extends Component(){
      //......
    }
    
    export makeComp(module, CompCcClassName){
      let TargetComp = module_comp_[module];
      if(TargetComp) return TargetComp;
    
      //先基于已有模块克隆新模块
      cloneModule(module, existingModule);
    
      //因为module是不能重复的,ccClassName也是不能重复的,
      //所有用户如果没有显式指定ccClassName值的话,可以默认ccClassName等于module值
      const ccClassName = CompCcClassName || module;
    
      //注册Comp到新模块里
      TargetComp = register(ccClassName, {module})(Comp);
      //缓存起来
      module_comp_[module] = TargetComp;
    
      return TargetComp;
    }
    

    concent组件工作流程

    concent组件并非魔改了react组件,只是在react组件上做了一层语法糖封装,整个react组件的生命周期依然需要被你了解,而concentDumb将原生命周期做了巧妙的抽象,才得以使用defineEffectdefineWatchdefineComputed等有趣的功能而无需在关注类组件的生命周期,无需再和this打交道,让函数组件和类组件拥有完全对等的功能。
    cc-process

    对比主流状态管理方案

    我们知道,现有的状态框架,主要有两大类型,一个是redux为代表的基于对数据订阅的模式来做状态全局管理,一种是以mobx为代表的将数据转变为可观察对象来做主动式的变更拦截以及状态同步。

    vs redux

    我们先聊一聊redux,这个当前react界状态管理的一哥。

    redux难以言语的reducer

    写过redux的用户,或者redux wrapper(如dvarematch等)的用户,都应该很清楚 redux的一个约定:reducer必需是纯函数,如果状态改变了,必需解构原state返回一个新的state

    // fooReducer.js
    export default (state, action)=>{
      switch(action.type){
        case 'FETCH_BOOKS':
          return {...state, ...action.payload};
        default:
          return state;
      }
    }

    纯函数没有副作用,容易被测试的特点被提起过很多次,我们写着写着,对于actionCreatorreducer,有了两种流派的写法,

    • 一种是将异步的请求逻辑以及请求后的数据处理逻辑,都放在actionCreator写完了,然后将数据封装为payload,调用dispatch,

    讲数据派发给对应的reducer

    此种流派代码,慢慢变成reducer里全是解构payload然后合成新的state并返回的操作,业务逻辑全部在
    actionCreator里了,此种有一个有一个严重的弊端,因为业务逻辑全部在actionCreator里,reducer函数里的type值全部变成了一堆类似CURD的命名方式,saveXXModelupdateXXModelXXFieldsetXXXdeleteXXX等看起来已经和业务逻辑全然没有任何关系的命名,大量的充斥在reducer函数里,而我们的状态调试工具记录的type值恰恰全是这些命名方式,你在调试工具里看到变迁过程对应的type列表,只是获取到了哪些数据被改变了的信息,但全然不知这些状态是从哪些地方派发了payload导致了变化,甚至想知道是那些UI视图的什么交互动作导致了状态的改变,也只能从代码的reducertype关键字作为搜索条件开始,反向查阅其他代码文件。
    • 还有一种是让actionCreator尽可能的薄,派发同步的action就直接return,异步的action使用thunk函数或者redux-saga等第三方库做处理,拿到数据都尽可能早的做成action对象,派发到reducer函数里,
    此种模式下,我们的actionCreator薄了,做的事情如其名字一样,只是负责产生action对象,同时因为我们对数据处理逻辑在reducer里了,我们的type值可以根据调用方的动机或者场景来命名了,如formatTimestamphandleNameChangedhandelFetchedBasicData等,但是由于redux的架构导致,你的ui触发的动作避免不了的要要经过两个步骤,一步经过actionCreator生成action,第2步进过经过reducer处理payload合成新的state,所以actionCreator的命名和reducerType的命名通常为了方便以后阅读时能够带入上下文信息很有可能会变成一样的命名,如fetchProductList,在actionCreator里出现一遍,然后在reducerType又出现一遍

    concent化繁为简的reducer

    concent里reducer担任的角色就是负责返回一个新的片段视图,所以你可以认为它就是一个partialStateGenerator函数,你可以声明其为普通函数

    //code in fooReducer.js
    function fetchProductList(){
    }

    也可以是async函数或者generator函数

    async function fetchProductList(){
    }

    如果,你的函数需要几步请求才能完成全部的渲染,但是每一步都需要及时触发视图更新,concent允许你自由组合函数,如果同属于一个模块里的reducer函数,相互之间还可以直接基于函数签名来调用

    function _setProductList(dataList){
      return {dataList};
    }
    
    //获取产品列表计基础数据
    async function fetchProductBasicData(payload, moduleState, ctx){
      const dataList = await api.fetchProductBasicData();
      return {dataList};//返回数据,触发渲染
      // or ctx.dispatch(_setProductList, dataList);
    }
    
    //获取产品列表计统计数据,统计数据较慢,分批拉取 (伪代码)
    async function fetchProductStatData(payload, moduleState, ctx){
      const dataList = moduleState.dataList;
      //做分批拉取统计数据的ids,逻辑略...... 
      const batchIdsList = [];
      const len = batchIds.length;
    
      for(let i=0; i<len; i++){
        const ids = batchIdsList[i];
        const statDataList = await api.fetchProductBasicData(ids);
    
        //逻辑略...... 游标开始和结束,改变对应的data的统计数据
        let len = statDataList.length;
        for(let j=0; j<len; j++){
          dataList[j+cursor].stat = statDataList[j];//赋值统计数据
        }
        await ctx.dispatch(_setProductList, dataList);//修改dataList数据,触发渲染
      }
    }
    
    //一个完整的产品列表既包含基础数据、也包含统计数据,分两次拉取,其中统计数据需要多次分批拉取
    async function fetchProductList(payload, moduleState, ctx){
      await ctx.dispatch(fetchProductBasicData);
      await ctx.dispatch(fetchProductStatData);
    }

    现在你只需要视图实例里调用$$dispatch触发更新即可

    //属于product模块的实例调用
    this.$$dispatch('fetchProductList');
    
    //属于其他模块的实例调用
    this.$$dispatch('product/fetchProductList');

    可以看到,这样的代码组织方式,更符合调用者的使用直觉,没有多余的操作,相互调用或者多级调用,可以按照开发者最直观的思路组织代码,并且很方便后期不停的调整后者重构模块里的reducer。

    concent强调返回欲更新的片段状态,而不是合成新的状态返回,从工作原理来说,因为concent类里标记了观察key信息,reducer提交的状态越小、越精准,就越有利于加速查找到关心这些key值变化的实例,还有就是concent是允许对key定义watchcomputed函数的,保持提交最小化的状态不会触发一些冗余的watchcomputed函数逻辑;从业务层面上来说,你返回的新状态是需要符合函数名描述的,我们直观的解读一段函数时,大体知道做了什么处理,最终返回一个什么新的片段状态给concent,是符合线性思维的^_^,剩下的更新UI的逻辑就交给concent吧。

    可能读者留意到了,redux所提倡的纯函数容易测试、无副作用的优势呢?在concent里能够体现吗,其实这一点担心完全没有必要,因为你观察上面的reducer示例代码可以发现,函数有无副作用,完全取决于你声明函数的方式,async(or generator)就是副作用函数,否则就是纯函数,你的ui里可以直接调用纯函数,也可以调用副作用函数,根据你的使用场景具体决定,函数名就是type,没有了actionCreator是不是世界清静了很多?

    进一步挖掘reducer本质,上面提到过,对于concent来说,reducer就是partialStateGenerator函数,所以如果讨厌走dispatch流派的你,可以直接定义一个函数,然后调用它,而非必需要放置在模块的reducer定义下。

    function inc(payload, moduleState, ctx){
      ctx.dispatch('bar/recordLog');//这里不使用await,表示异步的去触发bar模块reducer里的recordLog方法
      return {count: moduleState.count +1 };
    }
    
    @register('Counter', 'counter')(Counter)
    class Counter extends Component{
      render(){
        return <div onClick={()=> this.$$invoke(inc}>count: {this.state.count}</div>
      }
    }

    concent不仅书写体验友好,因为concent是以引用收集为基础来做状态管理,所以在concent提供的状态调试工具里可以精确的定位到每一次状态变更提交了什么状态,调用了哪些方法,以及由哪些实例触发。
    cc-core

    redux复杂的使用体验

    尽管redux核心代码很简单,提供composeReducersbindActionCreators等辅助函数,作为桥接reactreact-redux提供connect函数,需要各种手写mapStateToPropsmapDispatchToProps等操作,整个流程下来,其实已经让代码显得很臃肿了,所以有了dvarematchredux wrapper做了此方面的改进,化繁为简,但是无论怎么包装,从底层上看,对于redux的更新流程来说,任何一个action派发都要经过所有的reducerreducer返回的状态都要经过所有connect到此reducer对应状态上的所有组件,经过一轮浅比较(这也是为什么redux一定要借助解构语法,返回一个新的状态的原因),决定要不要更新其包裹的子组件。

    const increaseAction = {
      type: 'increase'
    };
    
    const mapStateToProps = state => {
      return {value: state.count}
    };
    
    const mapDispatchToProps = dispatch => {
      return {
        onIncreaseClick: () => dispatch(increaseAction);
      }
    };
    
    
    const App = connect(
      mapStateToProps,
      mapDispatchToProps
    )(Counter);
    

    concent简单直接的上手体验

    注册成为concent类的组件,天生就有操作store的能力,而且数据将直接注入state

    //Counter里直接可以使用this.$$dispatch('increase')
    class Counter extends Component{
      render(){
        return <div onClick={()=> this.$$dispatch('increase')}>count: {this.state.count}</div>
      }
    }
    
    const App = register('Counter', 'counter')(Counter);

    你可以注意到,concent直接将$$dispatch方法,绑定在this上,因为concent默认采用的是反向继承策略来包裹你的组件,这样产生更少的组件嵌套关系从而使得Dom层级更少。

    store的state也直接注入了this上,这是因为从setState调用开始,就具备了将转态同步到store的能力,所以注入到state也是顺其自然的事情。

    当然concent也允许用户在实例的state上声明其他非store的key,这样他们的值就是私有状态了,如果用户不喜欢state被污染,不喜欢反向继承策略,同样的也可以写为

    class Counter extends Component{
      constructor(props, context){
        super(props, context);
        this.props.$$attach(this);
      }
      render(){
        return(
          <div onClick={()=> this.props.$$dispatch('increase')}>
            count: {this.props.$$connectedState.counter.count}
          </div>
        )
      }
    }
    
    const App = register('Counter', {connect:{counter:'*'}, isPropsProxy:true} )(Counter);

    vs mobx

    mobx是一个函数响应式编程的库,提供的桥接库mobx-reactreact变成彻底的响应式编程模式,因为mobx将定义的状态的转变为可观察对象,所以
    用户只需要修改数据,mobx会自动将对应的视图更新,所以有人戏称mobxreact变成类似vue的编写体验,数据自动映射视图,无需显示的调用setState了。

    本质上来说,所有的mvvm框架都在围绕着数据和视图做文章,react把单项数据流做到了极致,mobxreact打上数据自动映射视图的补丁,提到自动映射,自动是关键,框架怎么感知到数据变了呢?mobx采用和vue一样的思路,采用push模式来做变化侦测,即对数据gettersetter做拦截,当用户修改数据那一刻,框架就知道数据变了,而react和我们当下火热的小程序等采用的pull模式来做变化侦测,暴露setStatesetData接口给用户,让用户主动提交变更数据,才知道数据发生了变化。

    concent本质上来说没有改变react的工作模式,依然采用的是pull模式来做变化侦测,唯一不同的是,让pull的流程更加智能,当用户的组件实例创建好的那一刻,concent已知道如下信息:

    • 实例属于哪个模块
    • 实例观察这个模块的哪些key值变化
    • 实例还额外连接其他哪些模块

    同时实例的引用将被收集到并保管起来,直到卸载才会被释放。

    所以可以用0改造成本的方式,直接将你的react代码接入到concent,然后支持用户可以渐进式的分离你的ui和业务逻辑。

    需要自动映射吗

    这里我们先把问题先提出来,我们真的需要自动映射吗?

    当应用越来越大,模块越来越多的时候,直接修改数据导致很多不确定的额外因素产生而无法追查,所以vue提供了vuex来引导和规范用户在大型应用的修改状态的方式,而mobx也提供了mobx-state-tree来约束用户的数据修改行为,通过统一走action的方式来让整个修改流程可追查,可调试。

    改造成本

    所以在大型的应用里,我们都希望规范用户修改数据的方式,那么concent从骨子里为react而打造的优势将体现出来了,可以从setState开始享受到状态管理带来的好处,无需用户接入更多的辅助函数和大量的装饰器函数(针对字段级别的定义),以及完美的支持用户渐进式的重构,优雅的解耦和分离业务逻辑与ui视图,写出的代码始终还是react味道的代码。

    结语

    concent围绕react提供一种了更舒适、更符合阅读直觉的编码体验,同时新增了更多的特性,为书写react组件带来更多的趣味性和实用性,不管是传统的class流派,还是新兴的function流派,都能够在concent里享受到统一的编码体验。

    依托于concent的以下3点核心工作原理:

    • 引用收集
    • 观察key标记
    • 状态广播

    基于引用收集和观察key标记,就可以做到热点更新路径缓存,理论上来说,某一个reducer如果返回的待更新片段对象形状是不变的,初次触发渲染的时候还有一个查找的过程(尽管已经非常快),后面的话相同的reducer调用都可以直接命中并更新,有点类似v8里的热点代码缓存,不过concent缓存的reducer返回数据形状和引用之间的关系,所以应用可以越运行越快,尤其是那种一个界面上百个组件,n个模块的应用,将体现出更大的优势,这是下一个版本concent正在做的优化项,为用户带来更快的性能表现和更好的编写体验是concent始终追求的目标。

    彩蛋 Ant Design Pro powered by concent 🎉🎉🎉

    尽管concent有一套自己的标准的开发方式,但是其灵活的架构设计非常的容易与现有的项目集成,此案例将concent接入到antd-pro(js版本的最后一个版本2.2.0),源代码业务逻辑没有做任何改动,只是做了如下修改,lint-staged验收通过:

    • 在src目录下加入runConcent脚本
    • models 全部替换为concent格式定义的,因为umi会自动读取model文件夹定义注入到dva里,所以所有concent相关的model都放在了model-cc文件夹下
    • 组件层的装饰器,全部用concent替换了dva,并做了少许语法的修改
    • 引入concent-plugin-loading插件,用于自动设置reducer函数的开始和结束状态
    • 引入react-router-concent,用于连接react-routerconcent
    • 引入concent-middleware-web-devtool(第一个可用版本,比较简陋^_^),用于查看状态concent状态变迁过程
    注意,运行期项目后,可以打开console,输入sss,查看store,输入cc.dispatchcc.reducer.**直接触发调用,更多api请移步concent官网文档查看,更多antd-pro知识了解请移步antd-pro官网

    如何运行

    • 下载源代码
    git clone git@github.com:concentjs/antd-pro-concent.git
    • 进入根目录,安装依赖包
    npm i
    • 运行和调试项目
    npm start
    默认src目录下放置的是concent版本的源代码,如需运行dva版本,执行npm run start:old,切换为concent,执行npm run start:cc

    其他

    happy coding, enjoy concent ^_^
    欢迎star
    _
    <div align="center">

    An out-of-box UI solution for enterprise applications as a React boilerplate.

    查看原文

    赞 3 收藏 1 评论 0

    钟正楷 关注了专栏 · 2019-05-10

    腾讯新闻前端团队

    TNFE 腾讯新闻前端团队,专业的前端技术专栏,一线的前端技术文章。

    关注 4708

    钟正楷 分享了头条 · 2019-03-10

    react-control-center 对话 redux(家族),后生何以挑战前辈,挑战权威重来都不会一键容易的事情,我们看看最后的解决怎样

    赞 0 收藏 0 评论 0

    钟正楷 分享了头条 · 2019-03-09

    新版本cc发布了,新增CcFragment, 让你体验更优雅的无状态组件书写方式

    赞 0 收藏 0 评论 0

    认证与成就

    • 获得 31 次点赞
    • 获得 1 枚徽章 获得 0 枚金徽章, 获得 0 枚银徽章, 获得 1 枚铜徽章

    擅长技能
    编辑

    开源项目 & 著作
    编辑

    • concent

      渐进式&高性能的react数据管理方案

    注册于 2019-01-14
    个人主页被 2.1k 人浏览