react-control-center 对话 redux(家族),后生何以挑战前辈?
发布于 5 年前 作者 fantasticsoul 6709 次浏览 来自 分享

cc-vs-redux.jpg

没有对比就没有伤害,react-control-center vs redux

以下会把react-control-center 简称为cc

对比项/项目 redux react-control-center 结果
git start 43k 43 没错,少了一个k,彻底完败
开源时间 2015年 2018年12月 早出生4年
作者 Dan Abramov 无名小辈 惨无人道的完败
架构实现 flux flux 平手
插件生态 redux-dev-tool等 无,未来提供 cc需要时间追赶?
中间件机制 提供 提供 平手
可对接的UI框架 react、vue、angular或其他 专注于react redux可以用他的基础库桥接其他UI框架,cc仅仅专注于react,此项无结果
代码组织 严格按照action、reducer的思路写出很多模板代码,所以无数redux wrapper出来了,让你更优雅的写redux,写副作用 天生内置一系列强悍的技术api,reducer和action合为一体,甚至你都不需要感知到reducer的存在 不做评论,请各位看官看完在下结论

各位看官看到这里,肯定感慨良多,如果借用三体里的比喻,cc是地球文明的话,redux就是三体文明,或者再提升到哥者文明…
简直就是托马斯回旋翻滚360度转体720度然后脸部着地的完败,但?真的是这样吗,还请你此刻不要把鼠标挪到关闭按钮上,先把下文读完,看看cc用什么来挑战redux,甚至整个redux家族。


回顾下,redux给予了我们什么

一个全局统一的状态树

redux内部维护着一个big object,官方称之为state tree或者store,这一棵参天大树携带的数据作为整个单页面应用的数据源,利用内置的中间件功能,结合提供的redux-dev-tool以及immutableJs提供的数据不可变特性,每次修改数据都会生成一个新的state记录在redux-dev-tool里,让我们在开发模式下实现了状态可追溯。

规范的数据修改方式

redux世界里约束了我们一定要通过派发一个action对象去修改state,生成action的函数称之为actionCreateor,修改state的函数称为reducer

题外话,关于reducer为什么称为reducer,我们引用下官网的原话:
It’s called a reducer because it’s the type of function you would pass to Array.prototype.reduce(reducer, ?initialValue)
翻译出来大概就是:之所以将函数叫为reducer,是因为这种函数与被传入 Array.prototype.reduce(reducer, ?initialValue)里的回调函数reducer属于相同的类型。

所以不管命名怎样,我们已经达成了共识,action就是一个用type描述要使用什么reducer函数以及用payload描述传递什么数据给reducer函数的普通json对象,reducer函数负责把最新的状态传递给store,store负责把发生了变化的状态下发到各个关心这些变化的子组件上。
整理一下,三大核心概念actionreducerstore,会有如下关系

action (type,paylaod)
  |______reducer(new state)
            |______store
                     |______render UI

cc给予了我们什么

一个模块化的的单一状态树

cc一开始就推荐用户按模块切分自己的状态,然后启动时将这些模块话的state交给cc,cc将它们合并出一个单一的状态树,当然针对这一点很多redux wrapper也做了改进,如dva提供了namespace让你的转态拥有自己的命名空间。

更灵活的修改数据的api

注册到cccc class,如果你仅仅像传统的方式一样使用setState去改变数据来驱动视图渲染,那么看起来和普通react class真的是没有什么不同之处的,但是cc class自生上下文携带了几个很重要的信息,即module表示属于哪个模块,sharedStateKeys表示共享这个模块的哪些key的状态,既然是共享,就意味着当前cc实例改变了这个key的值,cc会把它广播到其他同样属于这个模块并共享这个keycc class的实例,当然了,其他cc实例改变了这个key的值也会广播到当前实例并触发其渲染,cc内核的工作流程大致如下图所示:

可以看到此种模式下,cc彻底解决了redux里几个问题

  • action命名膨胀,redux里提倡的reducer是纯函数,每次返回的一定是一个全新的state,因为redux需要只是利用浅比较的方式知道状态有没有发生变化,所以我们通常会看到如下代码
// code in counter-actions.js
export function inc(){
    return {type:'INC_COUNT'}
}
export function dec(){
    return {type:'INC_COUNT'}
}

// code in counter-reducer.js
export default function reducer(initState, action){
    const {type, payload} = action;
    switch(type){
        case 'INC_COUNT':
            return {...state, count:initState.count+1};
        case 'DEC_COUNT':
            return {...state, count:initState.count-1};
        default:
            return initState;
    }
}

cc是接管了react最原始的setState函数做扩展,就像react.setState(partialState, callback)描述的一样,所以对于cc来说,真的只需要一个片段state就够了,cc通过分析用户提交的partialState,足以知道用户的此次操作改变了哪些状态,所以我们的Counter可以写为

//也可以简写为@cc.r('Counter',{m:'counter', s:'*'})
@cc.register('Counter',{module:'counter', sharedStateKeys:'*'})
class Counter extends Component{
    inc = ()=> { this.setState({count:this.state+1}) }
    dec = ()=> { this.setState({count:this.state-1}) }
    render(){
        const {count} = this.state;
        return (
            <div>
                <button onClick={this.inc}>inc</button>
                <button>dec</button>
                {count}
            </div>
        );
    }
}

如果你的App实例化了多个Counter,他们将共享count

render(){
    return (
        <div>
            <Counter />
            <Counter />
            <Counter />
        </div>
    );
}

实际上你可能发现一个问题,redux严格约定的action type可以用来追溯是什么动作改变了state啊!可是真的想想,仅靠action type能够知道什么动作改变了state就够了吗?在一个大型的负责项目里,通常你是需要知道具体到那个UI改变了状态,但是你会发现有很多UI都会派发同一个action type,这要怎么追,为每一个动作都命名一个不一样的action type但是其实操作的数据和修改的动作是一摸一样的?
cc里你只要为组件标记一个ccKey就够了,你可以写一个简答的中间件函数打印,cc会告诉你此次修改的所有细节,后期提供的cc-dev-tool会结合immutableJs来构建一个可追溯的状态历史

cc.startup(
    {
        //...
        middlewares: [
            function myMiddleware1(context, next){
                //ccKey, fnName, module, calledBy, state等
                console.log(context);
                next();
            }
        ]
    }
)
  • 副作用代码难以编写和复用,尽管有redux-saga之类的来解决此类问题,可是我们重新审视一下cc的设计,天生的对副作用的代码书写是友好的。 然后抛弃setState,使用dispatch来改变状态,在cc class内部可以使用this.$$dispatch(action:Action|String, payload?:any)来完成,注意一点哦,因为上面说到了,对于cc来说只需要提交一个partialState就够了,所以实际上actionCreatorreducer被精简为一体了,在ccreducer函数负责接到状态,然后返回一个新的partialState就够了。
//code in Counter class
inc() => this.$$dispatch({type:'inc'});
dec()=> this.$$dispatch('dec');

//我们启动cc配置的reducer如下
cc.startup({
    //...
    reducer:{
        inc({state, payload, dispatch}){
            return {count: state.count+1};
        },
        dec({state}){
            return {count: state.count+1};
        }
    }
})

说好的副作用书写友好在哪里呢?我们留意的可以看到reducer函数参数列表里还解构出其他东东,比如dispatch,来来来,让我们提个需求,新增一个按钮,点击这个按钮时,先加10,然后过2秒钟自动减5,然后再过3秒直接变成100,因为在cc的recuder里是不强制一定要返回一个新的partialState的,不返回只是不会触发渲染而已,但是解构出来的dispatch是一个组合复用其他reducer函数的哦,让我们清爽的实现这个需求。

async function sleep(ms=1000){
    return new Promise((resolve)=>setTimeout(resolve, ms));
}

//reducer修改如下
reducer:{
    inc({state, payload:count=1, dispatch, effect}){
        return {count: state.count+count};
    },
    dec({state, payload:count=1}){
        return {count: state.count-count};
    },
    async funnyInc({await}){
        await dispatch('inc', 10);
        await sleep(5);
        await dispatch('dec', 5);
    }
}

//Counter里
funnyInc() => this.$$dispatch('funnyInc');

//render里
<button onClick={this.funnyInc}>funnyInc1</button>

//甚至你可以使用$$domDispatch,来减少这样没有必要的函数定义
<button data-cct="funnyInc" onClick={this.$$domDispatch}>funnyInc1</button>

现在你可以放心喝一口茶了,看到界面上会如你所想的工作,组合现有的reducer函数是一件多么轻松惬意的事情,注意哦dispatch返回一个Promise,某些场景时机你可能不需要await,这个就取决于具体业务了。
我们进一步思考下中间件函数打印出的东西里有ccKey, fnName, module, calledBy, state等,以及图中提到的effect,我们所有做的事情只是返回一个新的partialState,一定需要走dispatchreducer这种模式吗?当然是否,cc提供effect(moduleName:String, userFunction:Function, ...args)就是让你直接调用自己的业务函数,返回一个新的partialState就好了,那么我们funnyInc可以改写为:

async function sleep(ms=1000){
    return new Promise((resolve)=>setTimeout(resolve, ms));
}
async function inc(prevCount, count=1){
    return {count: prevCount+1};
}
async function dec(prevCount, dec=1){
     return {count: prevCount-1};
}
async function myFunnyInc({effect, dispatch, state}, count){
    await effect('count', inc, state.count, 10);
    await sleep(5);
    await effect('count', dec, state.count, 5);
}

//Counter里, 注意此处用的$$xeffect,用户自定义的函数参数列表第一位会是cc注入的ExecutionObject,里面可以解构出相关其他句柄和数据
funnyInc() => this.$$xeffect('count', myFunnyInc);

<button onClick={this.funnyInc}>funnyInc1</button>

如果用户留意的话,发现上面$$xeffect调用的用户自定义函数的第一位参数里也解构出了dispatch,如果你再往上看,会发现reducer方法里也解构出了effectxeffect,如你想所想,他们可以混合使用,你可以在reducer里用effect,也可以在effect调用的函数使用dispatch,能够完美的工作起来,事实上你可能再想…这样穿插的调用,还怎么保证状态可追踪?你可能忘了,任何调用cc都会知道上下文,由那个cc实例最初发起调用,使用了什么方式setStatedispatcheffect或者其他,如果是dispatchtype是什么,如果是effect,调用的自定义函数名字是什么等,真正让你从源头知道是从那里开始,走了一个怎样的流程,改变了那些状态,是不是够你追溯了呢?

  • 更加优雅的组件间通信,让我们仔细想想,redux真正的算是解决了组件间通信吗?基于状态去做?让我们看看cc里是怎么实现的
@cc.r('Counter',{m:'counter', s:'*'})
class Counter extends Component{
    componentDidMount(){
        const id = this.props.id;
        this.$$on('cool',(p1, p2)=>{
            //做你任意想做的事吧
            alert(p1+p2+id);
        })
        this.$$onIdentity('cool', id, (p1, p2)=>{
            alert(p1+p2+id);
        })
    }
}

//App render里
      <div>
         <button onClick={()=>this.$$emit('cool','normal ', 'emit')}>emit</button>
        <button onClick={()=>this.$$emitIdentity('cool', '1' ,'identity ', 'emit')}>emitIdentity</button>
        <Counter id="1"/>
        <Counter id="2"/>
        <Counter id="3"/>
        <Counter id="4"/>
        <Counter id="5"/>
      </div>

当你点击emit按钮时,5个<Counter/>都会收到事件然后弹出显示,打你点击emitIdentity按钮时,只有id为1的那个<Counter/>会弹出提示,是不是更直白和优雅?
事实上可能有细心的读者注意到每次组件componentDidMount都会触发$$on,会不会造成内存泄露,需不需要人工off?尽管cc提供了api让你可以使用this.$$off(eventName:String),但是这里cc在这里已经在每次组件卸载时off掉这写监听了,不需要你再去componentWillUnmount里实现了。

  • 计算属性呢?reduxmapStateProps里可以让用户重新计算注入到组件里的值,让我们看看cc怎么样更直白的实现
@cc.r('Counter',{m:'counter', s:'*'})
class Foo extends Component{
    $$computed(){
        return {
            count(count){
                return count*100;
            }
        }
    }
    render(){
        const {count} = this.$$refComputed;
        return <div>scaled count {count}</div>
    }
}

实际上你还可在模块里定义computed,这样计算出来的值是这个module下的所有组件都可以获取到的了,不过在render里是通过this.$$moduleComputed取到。

cc.startup(){
    //...
    computed:{
        counter:{//为counter模块的count定义计算函数
            count(count){
                return count*100;
            }
        }
    }
}

注意,计算函数只有在对应的key值发生变化时才重新触发计算,否则值是一直被缓存住的。

一切从state获取是不是违背原则

读者可能已经注意到了,在cc里,store的数据都是注入在state里了,实际上cc实例的state由cc通过register时标记的modulesharedStateKeysglobalStateKeys的值合成出来的,所有cc组件都天生的能够观察cc内置模块$$global的状态变化,所有cc组件如果不设定module都会默认为属于cc的内置模块$$default,如下图所示,告诉你cc实例state怎么产生的

假设我们的counter模块和$$global模块的state现在如下

cc.startup({
    isModuleModel:true,
    store:{
        counter:{
            count:8,
        }
        $$global:{
            info:'i am global'
        }
    }
})

我们新建一个Bar

//等同于写cc.register('Bar',{m:'counter', sharedStateKeys:'*', globalStateKeys:'*'})
@cc.r('Bar',{m:'counter', s:'*', g:'*'})
class Foo extends Component{
    render(){
       console.log(this.state);// {count:8, info:'i am global'}
    }
}

注意我们没有书写constructorcc为我们合成出了state,让我们稍作修改

@cc.r('Bar',{m:'counter', s:'*', g:'*'})
class Bar extends Component{
    constructor(props, context){
        super(props, context);
        this.state = {myPrivateKey:'666'}
    }
    render(){
       console.log(this.state);
       // {count:8, info:'i am global', myPrivateKey:'666'}
    }
}

如果我们在constructorcount赋值100,打印出来的state里的count还是8,因为这个值被ccstore恢复回来了,你写的值被覆盖了,这一点要注意

 console.log(this.state);
 // {count:8, info:'i am global', myPrivateKey:'666'}

除非你注册时,没有申明任何共享的sharedStateKeys,尽管这个cc class属于counter,但是将不会收counter模块里任何key变化的影响哦

@cc.r('Bar',{m:'counter'})

说到这里,依然还是正面回答标题里提出的疑问:一切从state获取是不是违背原则。因为我们从一开始就被告知,state是自己管理管理的转态,props上派发下来的状态才是需要共享的状态,我们仔细思考一下,在cc里你只要定义的keystorekey不重复,就不发生共享关系,或者你register时刻意设定某些想关心的key,也可以让你的key成为私有的state

counter store: {key:1,key2:2, key3:3}

@cc.r('Bar',{m:'counter',s:['key1','key2']})
class Bar extends Component{
    constructor(props, context){
        this.state = {key3:888888};
    }
    render(){
        console.log(this.state);
        //{key:1,key2:2, key3:888888}
    }
}

打印结果会看到{key:1,key2:2, key3:888888}
尽管counter模块里有key1 key2 key3,但是你注册时只共享了key1,key2,所以key3还是你私有的state ,如果你调用setState({key1:666,key2:888,key3:999})时,
{key1:666,key2:888,key3:999}会赋值给自己,然后cc提取出{key1:666,key2:888}广播出去。

不想用state来承载store的数据可以吗

如果你不喜欢用state来获取store的数据,只想干干净净的用state来做自己组件的状态管理,cc同样提供$$propState来获取store上的数据,上图里用户看到最后一步有一个broadcastPropState,完成此项工作。
我们重写Bar

//@cc.register('Bar',{stateToPropMapping:{'counter/count':'count'}})
@cc.r('Bar',{pm:{'counter/count':'count'}})
class Bar extends Component{
    constructor(props, context){
        this.state = {key3:888888};
    }
    render(){
        console.log(this.$$propState);
        //{count:8}
    }
}

stateToPropMapping复杂完成把模块上的某些key映射到$$propStatekey,大家可能留意到,stateToPropMappingkey是带模块名的,值作为$$propStatekey可以被重命名,是因为这样做cc class可以观察任意多个模块的任意key的变化了

// 假设我们的counter模块里还有其他key如 info:'x',
// 还有另外一个模块chart : {count:19, list:[]} 
const pm = {
    'counter/count':'count',
    'counter/info':'info',  
    'chart/count':'chart_count'
}
@cc.r('Bar',{pm})

 console.log(this.$$propState);
 // {count:8, info:'x', chart_count:19}

当你在别的地方修改chartcount值的为10000时候,Barrender会被触发渲染,你会看到chart_count变为10000

 console.log(this.$$propState);
 // {count:8, info:'x', chart_count:10000}

如果你讨厌会所有key起别名,但是又担心命名冲突,可以写为:

// 假设我们的counter模块里还有其他key如 info:'x',
// 还有另外一个模块chart : {count:19, list:[]} 
const pm = {
    'counter/*':'', 
    'chart/*':''
}
@cc.r('Bar',{pm, isPropStateModuleMode:true})
// 也可以直接写为
@cc.connect('Bar', pm)

 console.log(this.$$propState);
 // {counter:{count:8, info:'x'}, chart:{chart_count:19}}

当然这里要注意,这样写你其实关心这两个模块所有key变化了,根据实际场景来做判断需不需要标记*,实际上register是可以一起写sharedStateKeysstateToPropMapping的,这样的话组件即从this.state拿到store罪行的数据,也能从this.$$propState上拿到store最新的数据

关于无状态组件怎么复用cc里现有的业务逻辑?CcFragment给你答案

19年facebookreact赋能hooks后,都觉得以后慢慢的不需要class组件了,直接使用function组件能搞定一切?各种useStateuseEffectuseContext已经被标准化,看起来function组件能够慢慢替代class组件了,可是我们仔细想想,我们期望状态集中管理,状态变化可以被精确追踪,hooks必然还需要一段很长的路走,我们看看cc给出对无状态组件怎么复用reducer给出的答案

import {CcFragment} from ''
const MyPanel = ()=>{
    return (
        <div>
            <CcFragment ccKey="clickMeChangeCount" connect={{'counter/*':''}}>
                {
                    ({propState, setState})=>(
                        <div onClick={()=>setState('counter', {count:200})}>{propState.counter.count}</div>
                    )
                }
            </CcFragment>
            <CcFragment ccKey="changeFooModuleState" connect={{'foo/*':''}}>
                {
                    ({propState, dispatch})=>(
                        <div onClick={()=>dispatch('foo/changeName', 'newName')}>{propState.foo.name}</div>
                    )
                }
            </CcFragment>
        </div>
    );
}

让我们调戏UI

现在你可以打开console,输入cc回车,你会发现cc已经将api绑定到了window.cc下了,你可以输入cc.setState(moduleName, newPartialState)直接触发渲染,当然前提是有相关的UI已经挂载到界面上,要不然只是改变了store,视图并没有说明变化,除此之外,其他的cc.emitcc.dispatch等使用方法和你在cc class是一样的使用体验,让你可以快速验证一些你的渲染逻辑哦。
输入sss回车,可以查看cc最新的整个状态树。


结语

综上所诉,cc挑战前辈的资本,在于只是提供了最基础的api,却可以让你用更轻松的方式分离你的业务逻辑和视图渲染逻辑,以及更优雅的方式复用你的函数,因为对于对于cc来说,它们更像是一个个newPartialStateCreator,厌倦了redux的你,能不能在cc里找到你想要的答案呢?

3 回复

顶一顶,期待热爱react的你,也能和cc美丽的邂逅

@fantasticsoul 你这种刷屏方式过分了

@justjavac 额,其实我没有刷啊,偶尔点一次而已~~~~(>_<)~~~~

回到顶部