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

❤ star me if you like concent ^_^前端

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

为什么须要状态管理

为什么须要在前端引用里引入状态管理,基本上你们都达成了共识,在此我总结为3点:node

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

已有状态管理方案现状

redux

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

image.png

mbox

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

image.png

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

其余

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

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

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

剩下的就是利用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
  }
}
复制代码

考虑到这种合成的状态在对接ts时会有必定模糊性,concent容许你显示的声明完整的状态,当你的状态里含有和所属模拟同名的stateKey时,在首次渲染以前这些stateKey的值会被模块状态里对应的值覆盖掉。

@register('foo')
class Foo extends Component {
  // name对应的值在首次渲染前被替换为模块状态里name对应的值
  state = { name:'', privName: 'i am private, not from store' };
  render(){
    // name: 'concent', privName: 'i am private, not from store'
    const { name, privName } = this.state;
  }
}
复制代码

在这样的模式下,你能够在任何地方实例化多个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的理念必定能走得更远。

相关文章
相关标签/搜索