V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
V2EX 提问指南
fantasticsoul
V2EX  ›  问与答

react-control-center, 全新的状态管理体验,简单且强大

  •  
  •   fantasticsoul · 2019-02-28 13:59:29 +08:00 · 1119 次点击
    这是一个创建于 2124 天前的主题,其中的信息可能已经有所发展或是发生改变。

    react-control-center

    目录回顾


    前言

    最初的 react

    react 用户最初接触接触 react 时,一定被洗脑了无数次下面几句话

    • 数据驱动视图
    • 单向数据流
    • 组件化

    它们体现着 react 的精髓,最初的时候,我们接触的最原始的也是最多的触发 react 视图渲染就是setState,这个函数打开了通往 react 世界的大门,因为有了setState,我们能够赋予组件生命,让它们按照我们开发者的意图动起来了。
    渐渐的我们发现,当我们的单页面应用组件越来越多的时候,它们各自的状态形成了一个个孤岛,无法相互之间优雅的完成合作,我们越来越需要一个集中式的状态管理方案,于是 facebook 提出了 flux 方案,解决庞大的组件群之间状态不统一、通信复杂的问题

    状态管理来了

    仅接着社区优秀的 flux 实现涌现出来,最终沉淀下来形成了庞大用户群的有reduxmbox等,本文不再这里比较 cc 与它们之间的具体差异,因为cc其实也是基于 flux 实现的方案,但是cc最大的特点是直接接管了setState,以此为根基实现整个react-control-center的核心逻辑,所以cc是对react入侵最小且改写现有代码逻辑最灵活的方案,整个cc内核的简要实现如下

    可以看到上图里除了setState,还有dispatcheffect,以及 3 个点,因为 cc 触发有很多种,这里只提及setStatedispatcheffect这 3 种能覆盖用户 99%场景的方法,期待读完本文的你,能够爱上cc


    setState,在线示例代码 在线示例代码 2

    一个普通的 react 组件诞生了,

    以下是一个大家见到的最最普通的有状态组件,视图里包含了一个名字显示和 input 框输入,让用户输入新的名字

    class Hello extends React.Component {
      constructor(props) {
        super(props);
        this.state = { name:'' };
      }
      changeName = (e)=>{
        this.setState({name:e.currentTarget.value});
      }
      render() {
        const {name} = this.state;
        return (
          <div className="hello-box">
            <div>{this.props.title}</div>
            <input value={name} onChange={this.changeName} />hello cc, I am {name} 
          </div>
        )
      }
    }
    
    class App extends React.Component {
      constructor(props) {
        super(props);
      }
      render() {
        return (
          <div className="app-box">
           <Hello title="normal instance"/>
          </div>
        )
      }
    }
    
    ReactDOM.render(<App />, document.getElementById('app'));
    

    如图所示

    改造为 cc 组件

    事实上声明一个 cc 组件非常容易,将你的 react 组件注册到 cc,其他就交给 cc 吧,这里我们先在程序的第一行启动 cc,声明一个store

    cc.startup({
      store:{name:'zzk'}
    });
    

    使用cc.register注册Hello为 CC 类

    const CCHello = cc.register('Hello',{sharedStateKeys:'*'})(Hello);
    

    然后让我们渲染出CCHello 吧

    class App extends React.Component {
      constructor(props) {
        super(props);
      }
      render() {
        return (
          <div className="app-box">
           <Hello title="normal instance"/>
           <CCHello title="cc instance1"/>
           <CCHello title="cc instance2"/>
          </div>
        )
      }
    }
    
    ReactDOM.render(<App />, document.getElementById('app'));
    

    渲染出 CCHello 上面动态图中我们可以看到几点<CCHello /><Hello />表现不一样的地方

    • 初次添加一个<CCHello />的时候,input 框里直接出现了 zzk 字符串
    • 添加了 3 个<CCHello />后,对其中输入名字后,另外两个也同步渲染了

    为什么 CC 组件会如此表现呢,接下来我们聊聊register

    register,普通组件通往 cc 世界的桥梁

    我们先看看 register 函数签名解释,因为 register 函数式如此重要,所以我尽可能的解释清楚每一个参数的意义,但是如果你暂时不想了解细节,可以直接略过这段解释,不妨碍你阅读后面的内容哦^_^,了解跟多关于 register 函数的解释

    /****
     * @param {string} ccClassKey cc 类的名称,你可以使用多个 cc 类名注册同一个 react 类,但是不能用同一个 cc 类名注册多个 react 类
     * ' - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - -'
     * @param {object} registerOption 注册的可选参数
     * ' - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - -'
     * @param {string} [registerOption.module] 声明当前 cc 类属于哪个模块,默认是`$$default`模块
     * ' - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - -'
     * @param {Array<string>|string} [registerOption.sharedStateKeys] 
     * 定义当前 cc 类共享所属模块的哪些 key 值,默认空数组,写为`*`表示观察并共享所属模块的所有 key 值变化
     * ' - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - -'
     * @param {Array<string>|string} [registerOption.globalStateKeys] 
     * 定义当前 cc 类共享 globa 模块的哪些 key 值,默认空数组,写为`*`表示观察并共享 globa 模块的所有 key 值变化
     * ============   !!!!!!  ============
     * 注意 key 命名重复问题,因为一个 cc 实例的 state 是由 global state、模块 state、自身 state 合成而来,
     * 所以 cc 不允许 sharedStateKeys 和 globalStateKeys 有重复的元素
     * ' - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - -'
     * @param {object} [registerOption.stateToPropMapping] { (moduleName/keyName)/(alias), ...}
     * 定义将模块的 state 绑定到 cc 实例的$$propState 上,默认'{}'
     * ' - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - -'
     * @param {object} [registerOption.isPropStateModuleMode] 
     * 默认是 false,表示 stateToPropMapping 导出的 state 在$$propState 是否需要模块化展示
     * ' - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - -'
     * @param {string} [registerOption.reducerModule]
     * 定义当前 cc 类的 reducer 模块,默认和'registerOption.module'相等
     * ' - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - -'
     * @param {string} [registerOption.extendInputClass] 
     * 是否直接继承传入的 react 类,默认是 true,cc 默认使用反向继承的策略来包裹你传入的 react 类,这以为你在 cc 实例可以通过'this.'直接呼叫任意 cc 实例方法,如果可以设置'registerOption.extendInputClass'为 false,cc 将会使用属性代理策略来包裹你传入的 react 类,在这种策略下,所有的 cc 实例方法只能通过'this.props.'来获取。
     * 跟多的细节可以参考 cc 化的 antd-pro 项目的此组件 https://github.com/fantasticsoul/rcc-antd-pro/blob/master/src/routes/Forms/BasicForm.js
     * ' - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - -'
     * @param {string} [registerOption.isSingle] 该 cc 类是否只能实例化一次,默认是 false
     * 如果你只允许当前 cc 类被实例化一次,这意味着至多只有一个该 cc 类的实例能存在
     * 你可以设置'registerOption.isSingle'为 true,这有点类似 java 编码里的单例模式了^_^
     * ' - - - - - - - - - - - - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - -'
     * @param {string} [registerOption.asyncLifecycleHook] 是否是 cc 类的生命周期函数异步化,默认是 false
     * 我们可以在 cc 类里定义这些生命周期函数'$$beforeSetState'、'$$afterSetState'、'$$beforeBroadcastState',
     * 他们默认是同步运行的,如果你设置'registerOption.isSingle'为 true,
     * cc 将会提供给这些生命周期函数 next 句柄放在他们参数列表的第二位,
     *  * ============   !!!!!!  ============
     * 你必须调用 next,否则当前 cc 实例的渲染动作将会被永远阻塞,不会触发新的渲染
     * ```
     * $$beforeSetState(executeContext, next){
     *   //例如这里如果忘了写'next()'调用 next, 将会阻塞该 cc 实例的'reactSetState'和'broadcastState'等操作~_~
     * }
     * ```
     */
    

    通过register函数我们来解释上面遗留的两个现象的由来

    • 初次添加一个<CCHello />的时候,input 框里直接出现了 zzk 字符串.

    因为我们注册HelloCCHello的时候,语句如下
    const CCHello = cc.register('Hello',{sharedStateKeys:'*'})(Hello);
    没有声明任何模块,所以CCHello属于$$default模块,定义了sharedStateKeys*
    表示观察和共享$$default模块的整个状态,所以在starup里定义的storename就被同步到CCHello

    • 添加了 3 个<CCHello />后,对其中输入名字后,另外两个也同步渲染了

    因为对其中一个<CCHello />输入名字时,
    其他两个<CCHello/>他们也属于'$$default'模块,也共享和观察name的变化,
    所以其实任意一个<CCHello />的输入,cc 都会将状态广播到其他两个<CCHello />

    多模块话组织状态树

    前面文章我们介绍cc.startup时说起推荐用户使用多模块话启动cc,所以我们稍稍改造一下starup启动参数,让我们的不仅仅只是使用 cc 的内置模块$$default$$global。 定义两个新的模块foobar,可以把他们的 state 定义成一样的。

    cc.startup({
      isModuleMode:true,
      store:{
        $$default:{
          name:'zzk of $$default',
          info:'cc',
        },
        foo:{
          name:'zzk of foo',
          info:'cc',
        },
        bar:{
          name:'zzk of bar',
          info:'cc',
        }
      }
    });
    

    Hello类为输入新注册 2 个 cc 类HelloFooHelloBar,然后渲染他们看看效果吧

    const CCHello = cc.register('Hello',{sharedStateKeys:'*'})(Hello);
    const HelloFoo = cc.register('HelloFoo',{module:'foo',sharedStateKeys:'*'})(Hello);
    const HelloBar= cc.register('HelloBar',{module:'bar',sharedStateKeys:'*'})(Hello);
    
    class App extends React.Component {
      constructor(props) {
        super(props);
      }
      render() {
        return (
          <div className="app-box">
           <Hello title="normal instance"/>
            <CCHello title="cc instance1 of module $$default"/>
            <CCHello title="cc instance1 of module $$default"/>
            <br />
            <HelloFoo title="cc instance3 of module foo"/>
            <HelloFoo title="cc instance3 of module foo"/>
            <br />
            <HelloBar title="cc instance3 of module bar"/>
            <HelloBar title="cc instance3 of module bar"/>
          </div>
        )
      }
    }
    

    多个模块的 Hello 以上我们演示了用同一个 react 类注册为观察着不同模块 state 的 cc 类,可以发现尽管视图是一样的,但是他们的状态在模块化的模式下被相互隔离开了,这也是为什么推荐用模块化方式启动 cc,因为业务的划分远远不是两个内置模块就能表达的

    让一个模块被被另外的 react 类注册

    上面我们演示了用同一个 react 类注册到不同的模块,下面我们写另一个 react 类Wow来观察$$default模块

    class Wow extends React.Component {
      constructor(props) {
        super(props);
        this.state = { name:'' };
      }
      render() {
        const {name} = this.state;
        return (
          <div className="wow-box">
            wow {name} <input value={name} onChange={(e)=>this.setState({name:e.currentTarget.value})} />
          </div>
        )
      }
    }
    

    Wow 来了


    dispatch,更灵活的 setState

    在线示例代码

    让业务逻辑和视图渲染逻辑彻底分离

    我们知道,视图渲染代码和业务代码混在一起,对于代码的重构或者维护是多么的不友好,所以尽管 cc 提供setState来改变状态,但是我们依然推荐dispatch方式来使用 cc,让业务逻辑和视图渲染逻辑彻底分离

    定义 reducer

    我们在启动 cc 时,为 foo 模块定义一个和 foo 同名的 reducer 配置在启动参数里

      reducer:{
        foo:{
          changeName({payload:name}){
            return {name};
          }
        }
      }
    

    现在让我们修改Hello类用dispatch去修改 state 吧,可以声明派发 foo 模块的 reducer 去生成新的 state 并修改 foo,当 state 模块和 reducer 模块重名时,可以用简写方式

      changeName = (e)=>{
         const name = e.currentTarget.value;
        //this.setState({name});
        this.$$dispatch('foo/changeName', payload:name);
        //等价与 this.$$dispatch('foo/foo/changeName', payload:name);
        //等价于 this.$$dispatch({ module: 'foo', reducerModule:'foo',type: 'changeName', payload: name });
      }
    

    Wow 来了

    对模块精确划分

    上面贴图中,我们看到当我们修改<HelloFoo/>实例里的 input 的框的时候,<HelloFoo/>如我们预期那样发生了变化,但是我们在<HelloBar/>或者<CCHello/>里输入字符串时,他们没有变化,却触发了<HelloFoo/>发生,这是为什么呢?
    我们回过头来看看Hello类里的this.$$dispatch函数,指定了状态模块是foo,所以这里就出问题了
    让我们去掉this.$$dispatch里的状态模块,修改为总是用foo这个 reducerModule 模块的函数去生成新的 state,但是不指明具体的目标状态模块,这样 cc 实例在发起$$this.dispatch调用时就会默认去修改当 cc 类所属的状态模块

      changeName = (e)=>{
         const name = e.currentTarget.value;
        //this.setState({name});
        //不指定 module,只指定 reducerModule,cc 实例调用时会去修改自己默认的所属状态模块的状态
        this.$$dispatch({reducerModule:'foo',type: 'changeName', payload: name });
      }
    

    Wow 来了 上图的演示效果正如我们的预期效果,三个注册到不同的模块的 cc 组件使用了同一个 recuder 模块的方法去更新状态。 让我们这里总结下 cc 查找 reducer 模块的规律

    • 不指定 state 模块和 reducer 模块时,cc 发起$$dispatch调用的默认寻找的目标 state 模块和目标 reducer 模块就是当前 cc 类所属的目标 state 模块和目标 reducer 模块
    • 只指定 state 模块不指定 reducer 模块时,默认寻找的目标 state 模块和目标 reducer 模块都是指定的 state 模块
    • 不指定 state 模块,只指定 reducer 模块时,默认寻找的目标 state 模块是当前 cc 类所属的目标 state 模块,寻找的 reducer 模块就是指定的 reducer 模块
    • 两者都指定的时候,cc 严格按照用户的指定值去查询 reducer 函数和修改指定目标的 state 模块

    cc 这里灵活的把 recuder 模块这个概念也抽象出来,为了方便用户按照自己的习惯归类各个修改状态函数。
    大多数时候,用户习惯把 state module 的命名和 reducer module 的命名保持一致,但是 cc 允许你定义一些额外的 recuder module,这样具体的 reducer 函数归类方式就很灵活了,用户可按照自己的理解去做归类

    dispatch,发起副作用调用

    我们知道,react 更新状态时,一定会有副作用产生,这里我们加一个需求,更新 foo 模块的 name 时,通知 bar 模块也更新 name 字段,同时上传一个 name 到后端,拿后端返回的结果更新到$$default模块的 name 字段里,让我们小小改造一下 changeName 函数

    async function mockUploadNameToBackend(name) {
      return 'name uploaded'
    }
    
    
        changeName: async function ({ module, dispatch, payload: name }) {
          if (module === 'foo') {
            await dispatch('bar/foo/changeName', name);
            const result = await mockUploadNameToBackend(name);
            await dispatch('$$default/foo/changeName', result);
            return { name };
          } else {
            return { name };
          }
        }
    

    dispatch cc 支持 reducer 函数可以是 async 或者 generator 函数,其实 reducer 函数的参数 excutionContext 可以解构出moduleeffectxeffectstatemoduleStateglobalStatedispatch等参数, 我们在 reducer 函数发起了其他的副作用调用

    dispatch 内部,组合其他 dispatch

    cc 并不强制要求所有的 reducer 函数返回一个新的 state,所以我们可以利用 dispatch 发起调用组合其他的 dispatch
    基于上面的需求,我们再给自己来下一个这样的需求,当 foo 模块的实例输入的是666的时候,把``foobar的所有实例的那么重置为恭喜你中奖 500 万了,我们保留原来的 changeName,新增一个函数changeNameWithAwardawardYou,然后组件里调用changeNameWithAward`

        awardYou: function ({dispatch}) {
          const award = '恭喜你中奖 500 万';
          Promise.all(
            [
              dispatch('foo/changeName', award),
              dispatch('bar/foo/changeName', award)
            ]
          );
        },
        changeNameWithAward: async function ({ module, dispatch, payload: name }) {
          console.log('changeNameWithAward', module, name);
          if (module === 'foo' && name === '666') {
            dispatch('foo/awardYou');
          } else {
            console.log('changeName');
            dispatch(`${module}/foo/changeName`, name);
          }
        }
    

    dispatch2 我们可以看到awardYou里并没有返回新的 state,而是并行调用 changeName。 cc 基于这样的组合 dispatch 理念可以让你跟灵活的组织代码和重用已有的 reducer 函数

    effect,最灵活的 setState

    不想用dispatchreducer组合拳?试试effect

    effect其实和dispatch是一样的作用,生成新的 state,只不过不需要指定 reducerModule 和 type 让 cc 从 reducer 定义里找到对应的函数执行逻辑,而是直接把函数交给 effect 去执行
    让我们在Hello组件里稍稍改造一下,当 name 为 888 的时候,不调用$$dispatch而是调用$$effect

        function myChangeName(name, prefix) {
          return { name: `${prefix}${name}` };
        }
    
      changeName = (e) => {
        const name = e.currentTarget.value;
        // this.setState({name});
        // this.$$dispatch('foo/changeName', name);
        if(name==='888'){
            const currentModule = this.cc.ccState.module;
            //add prefix 888
            this.$$effect(currentModule, myChangeName, name, '8');
        }else{
          this.$$dispatch({reducerModule:'foo',type: 'changeNameWithAward', payload: name });  
        }
      }
    

    dispatch2 effect 必须指定具体的模块,如果想自动默认使用当前实例的所属模块可以写为

    this.$invoke(myChangeName, name, '8');
    

    dispatch 使用 effect ?同样可以

    上面我们演示 recuder 函数时有提到 executionContext 里可以解构出effect,所以用户可以在 reducher 函数里一样的使用 effect

    awardYou:function ({dispatch, effect}) {
      const award = '恭喜你中奖 500 万';
      await Promise.all([
        dispatch('foo/changeName', award),
        dispatch('bar/foo/changeName', award)
      ]);
      await effect('bar',function(info){
          return {info}
      },'wow cool');
    }
    

    effect 使用 dispatch 呢?同样可以

    想用在 effect 内部使用dispatch,需要使用 cc 提供的xeffect函数,默认把用户自定义函数的第一位参数占用了,传递 executionContext 给第一位参数

        async function myChangeName({dispatch, effect}, name, prefix) {
          //call effect or dispatch as you expected
          return { name: `${prefix}${name}` };
        }
        
        changeName = (e) => {
            const name = e.currentTarget.value;
            this.$$xeffect(currentModule, myChangeName, name, '8');
      }
    

    状态广播

    状态广播延迟

    该参数大多时候用户都不需要用到,cc 可以为setState$$dispatcheffect都可以设置延迟时间,单位是毫秒,侧面印证 cc 是的状态过程存在,这里我们设置当输入是222时,3 秒延迟广播状态, (备注,不设定时,cc 默认是-1,表示不延迟广播)

        this.setState({name});
        ---> 可以修改为如下代码,备注,第二位参数是 react.setState 的 callback,cc 做了保留 
        this.setState({name}, null, 3000);
        
        this.$$effect(currentModule, myChangeName, name, 'eee');
        ---> 可以修改为如下代码,备注,$$xeffect 对应的延迟函数式$$lazyXeffect
        this.$$lazyEffect(currentModule, myChangeName, 3000, name, 'eee');
        
        this.$$dispatch({ reducerModule: 'foo', type: 'changeNameWithAward', payload: name });
        ---> 可以修改为如下代码,备注,$$xeffect 对应的延迟函数式$$lazyXeffect
         this.$$dispatch({ lazyMs:3000, reducerModule: 'foo', type: 'changeNameWithAward', payload: name });
    

    dispatch2


    类 vue

    关于 emit

    cc 允许用户对 cc 类实例定义$$on$$onIdentity,以及调用$$emit$$emitIdentity$$off
    我们继续对上面的需求做扩展,当用户输入999时,发射一个普通事件999,输入9999时,发射一个认证事件名字为9999证书为9999,我们继续改造Hello类,在 componentDidMount 里开始监听

        componentDidMount(){
            this.$$on('999',(from, wording)=>{
              console.log(`%c${from}, ${wording}`,'color:red;border:1px solid red' );
            });
            if(this.props.ccKey=='9999'){
              this.$$onIdentity('9999','9999',(from, wording)=>{
                console.log(`%conIdentity triggered,${from}, ${wording}`,'color:red;border:1px solid red' );
              });
            }
         } 
         
        changeName = (e) => {
            // ......
            if(name === '999'){
              this.$$emit('999', this.cc.ccState.ccUniqueKey, 'hello');
            }else if(name === '9999'){
              this.$$emitIdentity('9999', '9999', this.cc.ccState.ccUniqueKey, 'hello');
            }
        }
    

    注意哦,你不需要在 computeWillUnmount 里去$$off 事件,这些 cc 都已经替你去做了,当一个 cc 实例销毁时,cc 会取消掉它的监听函数,并删除对它的引用,防止内存泄露 emit

    关于 computed

    我们可以对 cc 类定义$$computed 方法,对某个 key 或者多个 key 的值定义 computed 函数,只有当这些 key 的值发生变化时,cc 会触发计算这些 key 对应的 computed 函数,并将其缓存起来
    我们在 cc 类定义的 computed 描述对象计算出的值,可以从this.$$refComputed里取出计算结果,而我们在启动时为模块的 state 定义的 computed 描述对象计算出的值,可以从this.$$moduleComputed里取出计算结果,特别地,如果我们为$$global模块定义了 computed 描述对象,可以从this.$$globalComputed里取出计算结果
    现在我们为类定义 computed 方法,将输入的值反转,代码如下

    $$computed() {
      return {
        name(name) {
          return name.split('').reverse().join('');
        }
      }
    }
    

    computed

    关于 ccDom

    cc 默认采用的是反向继承的方式包裹你的 react 类,所以在 reactDom 树看到的组件非常干净,不会有多级包裹 ccdom

    关于顶层函数和 store

    现在,你可以打开 console,输入cc.,可以直接呼叫dispatchemitsetState等函数,让你快速验证你的渲染逻辑,输入 sss,查看整个 cc 的状态树结构


    结语

    好了,基本上 cc 驱动视图渲染的 3 个基本函数介绍就到这里了,cc 只是提供了最最基础驱动视图渲染的方式,并不强制用户使用哪一种,用户可以根据自己的实际情况摸索出最佳实践
    因为 cc 接管了 setState,所以 cc 可以不需要包裹<Provider />,让你的可以快速的在已有的项目里使用起来,

    具体代码点此处

    线上演示点此处,注:线上演示代码不完整,最完整的运行此项目

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   870 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 26ms · UTC 20:31 · PVG 04:31 · LAX 12:31 · JFK 15:31
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.