聊一聊状态管理和concent设计理念-深蓝源码网


时间: 2020-09-03 00:08:26 人气: 2275 评论: 0

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

为何需要状态管理

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

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

已有状态管理方案现状

redux

遵循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</
						 技术沙龙 教程文章 热点综合					

评论