当前位置: 首页 > 工具软件 > concent > 使用案例 >

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

吕淮晨
2023-12-01

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

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

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

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

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

当然因为mbox操作数据很方便,不满足大型应用里对状态流转路径清晰可追溯的诉求,为了约束用户的更新行为,配套出了一个mobx-state-tree,总而言之,mobx成为了响应式的代表。
其他
剩下的状态管理方案,主要有3类。
一类是不满足redux代码冗余啰嗦,接口不够友好等缺点,进而在redux之上做2次封装,典型的代表国外的有如rematch,国内有如dva、mirror等,我将它们称为redux衍生的家族作品,或者是解读了redux源码,整合自己的思路重新设计一个库,如final-state、retalk、hydux等,我将它们称为类redux作品。
一类是走响应式道路的方案,和mobx一样,劫持普通状态对象转变为可观察对象,如dob,我将它们称为类mobx作品。
剩下的就是利用react context api或者最新的hook特性,主打轻量,上手简单,概念少的方案,如unstated-next,reactn、smox、react-model等。
我心中的理想方案
上述相关的各种方案,都各自在一定程度上能满足我们的需求,但是对于追求完美的水瓶座程序猿,我觉得它们终究都不是我理想的方案,它们或小而美、或大而全,但还是不够强,不够友好,所以决定开始自研状态管理方案。
我知道小和 美、全、强本身是相冲突的,我能接受一定量的大,gzip后10kb到20kb都是我接受的范围,在此基础上,去逐步地实现美、全、强,以便达到以下目的,从而体现出和现有状态管理框架的差异性、优越性。

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

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

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

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

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

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

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

) => (Pick<S, K> | S | null)) | (Pick<S, K> | S | null),
callback?: () => void
): void;
复制代码通过签名描述,我们可以看出传递给setState的是一个部分状态(片段状态),实际上我们在调用setState也是经常这么做的,修改了谁就传递对应的stateKey和值。

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

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

显而易见我们需要劫持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去完成这些额外的注入逻辑,所以设计了两个关键的接口run和register,run负责载入模块配置,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
}
}
复制代码在线示例代码见此处
现在我们来看看上面这段代码,除了没有显示的在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里的f1、f2、f3三个字段对应的值,而属于模块foo的组件CompB可能只消费模块foo里另外的f4、f5、f6三个字段对应的值,我们当然不期望CompA的实例只修改了f2、f3时却触发了的CompB实例渲染。
大多数时候我们期望组件和模块保持的是一对一的关系,即一个组件只消费某一个模块提供的数据,但是现实情况的确存在一个组件消费多个模块的数据。
所以针对register接口,我们需要传入更多的信息来满足更细粒度的数据消费需求。

通过module标记组件属于哪个具体的模块

这是一个可选项,不指定的话就让其属于内置的$$default模块(一个空模块),有了module,就能够让concent在其组件实例化之后将模块的状态注入到实例的state上了。

通过watchedKeys标记组件观察所属模块的stateKey范围

这是一个可选项,不传入的话,默认就是观察所属模块所有stateKey的变化,通过watchedKeys来定义一个stateKey列表,控制同模块的其他组件提交新状态时,自己需不需要被渲染更新。

通过connect标记连接的其他模块

这是一个可选项,让用户使用connect参数去标记连接的其他模块,设定在其他模块里的观察stateKey范围。

通过ccClassKey设定当前组件类名

这是一个可选项,设定后方便在react dom tree上查看具名的concent组件节点,如果不设定的话,concent会自动更根据其module和connect参数的值算出一个,此时注册了同一个模块标记了相同connect参数的不同react组件在react dom tree上看到的就是相同的标签名字。

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

实例的state作为数据容器已经盛放了所属模块的状态,那么当使用connect让组件连接到其他多个模块时,这些数据又该怎么注入呢?跟着这个问题我们回想一下上面提到过的,某个实例调用setState时提交的状态会被concent提取出其所属模块状态,将它作为sharedState精确的分发到其他实例。
能够做到精确分发,是因为当这些注册过的组件在实例化的时候,concent就会为其构建了一个实例上下文ctx,一个实例对应着一个唯一的ctx,然后concent这些ctx引用精心保管在全局上下文ccContext里(一个单例对象,在run的时候创建),所以说组件的实例化过程完成了concent核心工作原理里很重要的一环:引用收集,当然了,实例销毁后,对应的ctx也会被删除。
有了ctx对象,concent就可以很自然将各种功能在上面实现了,上面提到的连接了多个模块的组件,其模块数据将注入到ctx.connectedState下,通过具体的模块名去获取对应的数据。

我们可以在代码里很方便的构建跨多个模块消费数据的组件,并按照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 (


count: {this.state.count}
<button onClick={() => this.ctx.dispatch(‘inc’)}>inc
<button onClick={() => this.ctx.dispatch(‘dec’)}>dec

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

所以不管是全局消费的business model、还是组件或者页面自己维护的component model和page 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一定能给你足够的信心去从容应对,我们来看看对比mbox,concent做了哪些更多场景的探索。
renderKey,更精确的渲染范围控制
每一个组件的实例上下文ctx都有一个唯一索引与之对应,称之为ccUniqueKey,每一个组件在其实例化的时候如果不显示的传入renderKey来重写的话,其renderKey默认值就是ccUniqueKey,当我们遇到模块的某个stateKey是一个列表或者map时,遍历它生产的视图里各个子项调用了同样的reducer,通过id来达到只修改自己数据的目的,但是他们共享的是一个stateKey,所以必然观察这个stateKey的其他子项也会被触发冗余渲染,而我们期望的结果是:谁修改了自己的数据,就只触发渲染谁。
如store的list是一个长列表,每一个item都会渲染成一个ItemView,每一个ItemView都走同一个reducer函数修改自己的数据,但是我们期望修改完后只能渲染自己,从而做到更精确的渲染范围控制。

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

无论是setState、dispatch,还是invoke,都支持传入renderKey。

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 (


{/** 遍历生成ItemView */}
{books.map((v, idx) => )}

)
}
}
复制代码因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)}>复杂的更新
复制代码更新流程如下所示

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

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

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

 类似资料: