细聊 Concent & Recoil , 探索 react 数据流的新开发模式

2020-06-15 14:40:54 +08:00
 fantasticsoul

开源不易,感谢你的支持,❤ star me if you like concent ^_^

序言

之前发表了一篇文章 redux 、mobx 、concent 特性大比拼, 看后生如何对局前辈,吸引了不少感兴趣的小伙伴入群开始了解和使用 concent,并获得了很多正向的反馈,实实在在的帮助他们提高了开发体验,群里人数虽然还很少,但大家热情高涨,技术讨论氛围浓厚,对很多新鲜技术都有保持一定的敏感度,如上个月开始逐渐被提及得越来越多的出自 facebook 的最新状态管理方案 recoil,虽然还处于实验状态,但是相必大家已经私底下开始欲欲跃试了,毕竟出生名门,有 fb 背书,一定会大放异彩。

不过当我体验完 recoil 后,我对其中标榜的精确更新保持了怀疑态度,有一些误导的嫌疑,这一点下文会单独分析,是否属于误导读者在读完本文后自然可以得出结论,总之本文主要是分析ConcentRecoil的代码风格差异性,并探讨它们对我们将来的开发模式有何新的影响,以及思维上需要做什么样的转变。

数据流方案之 3 大流派

目前主流的数据流方案按形态都可以划分以下这三类

到此我们看看Recoil应该属于哪一类?很显然按其特征属于 Context 流派,那么我们上面说的主打轻量对 Recoil并不适用了,打开其源码库发现代码并不是几百行完事的,所以基于Context api做得好用且强大就未必轻量,由此看出facebookRecoil是有野心并给予厚望的。

我们同时也看看Concent属于哪一类呢?Concentv2版本之后,重构数据追踪机制,启用了 defineProperty 和 Proxy 特性,得以让 react 应用既保留了不可变的追求,又享受到了运行时依赖收集和 ui 精确更新的性能提升福利,既然启用了 defineProperty 和 Proxy,那么看起来Concent应该属于 mobx 流派?

事实上Concent属于一种全新的流派,不依赖 react 的 Context api,不破坏 react 组件本身的形态,保持追求不可变的哲学,仅在 react 自身的渲染调度机制之上建立一层逻辑层状态分发调度机制,defineProperty 和 Proxy 只是用于辅助收集实例和衍生数据对模块数据的依赖,而修改数据入口还是 setState(或基于 setState 封装的 dispatch, invoke, sync),让Concent可以 0 入侵的接入 react 应用,真正的即插即用和无感知接入。

即插即用的核心原理是,Concent自建了一个平行于 react 运行时的全局上下文,精心维护这模块与实例之间的归属关系,同时接管了组件实例的更新入口 setState,保留原始的 setState 为 reactSetState,所有当用户调用 setState 时,concent 除了调用 reactSetState 更新当前实例 ui,同时智能判断提交的状态是否也还有别的实例关心其变化,然后一并拿出来依次执行这些实例的 reactSetState,进而达到了状态全部同步的目的。

Recoil 初体验

我们以常用的 counter 来举例,熟悉一下Recoil暴露的四个高频使用的 api

定义状态

外部使用atom接口,定义一个 key 为num,初始值为0的状态

const numState = atom({
  key: "num",
  default: 0
});

定义派生数据

外部使用selector接口,定义一个 key 为numx10,初始值是依赖numState再次计算而得到

const numx10Val = selector({
  key: "numx10",
  get: ({ get }) => {
    const num = get(numState);
    return num * 10;
  }
});

定义异步的派生数据

selectorget支持定义异步函数

需要注意的点是,如果有依赖,必需先书写好依赖在开始执行异步逻辑

const delay = () => new Promise(r => setTimeout(r, 1000));

const asyncNumx10Val = selector({
  key: "asyncNumx10",
  get: async ({ get }) => {
    // !!!这句话不能放在 delay 之下, selector 需要同步的确定依赖
    const num = get(numState);
    await delay();
    return num * 10;
  }
});

消费状态

组件里使用useRecoilState接口,传入想要获去的状态(由atom创建而得)

const NumView = () => {
  const [num, setNum] = useRecoilState(numState);

  const add = ()=>setNum(num+1);

  return (
    <div>
      {num}<br/>
      <button onClick={add}>add</button>
    </div>
  );
}

消费派生数据

组件里使用useRecoilValue接口,传入想要获去的派生数据(由selector创建而得),同步派生数据和异步派生数据,皆可通过此接口获得

const NumValView = () => {
  const numx10 = useRecoilValue(numx10Val);
  const asyncNumx10 = useRecoilValue(asyncNumx10Val);

  return (
    <div>
      numx10 :{numx10}<br/>
    </div>
  );
};

渲染它们查看结果

暴露定义好的这两个组件, 查看在线示例

export default ()=>{
  return (
    <>
      <NumView />
      <NumValView />
    </>
  );
};

顶层节点包裹React.SuspenseRecoilRoot,前者用于配合异步计算函数需要,后者用于注入Recoil上下文

const rootElement = document.getElementById("root");
ReactDOM.render(
  <React.StrictMode>
    <React.Suspense fallback={<div>Loading...</div>}>
      <RecoilRoot>
        <Demo />
      </RecoilRoot>
    </React.Suspense>
  </React.StrictMode>,
  rootElement
);

Concent 初体验

如果读过 concent 文档(还在持续建设中...),可能部分人会认为 api 太多,难于记住,其实大部分都是可选的语法糖,我们以 counter 为例,只需要使用到以下两个 api 即可

运行 run 接口后,会生成一份 concent 全局上下文

定义状态&修改状态

以下示例我们先脱离 ui,直接完成定义状态&修改状态的目的

import { run, setState, getState } from "concent";

run({
  counter: {// 声明一个 counter 模块
    state: { num: 1 }, // 定义状态
  }
});

console.log(getState('counter').num);// log: 1
setState('counter', {num:10});// 修改 counter 模块的 num 值为 10
console.log(getState('counter').num);// log: 10

我们可以看到,此处和redux很类似,需要定义一个单一的状态树,同时第一层 key 就引导用户将数据模块化管理起来.

引入 reducer

上述示例中我们直接掉一个呢setState修改数据,但是真实的情况是数据落地前有很多同步的或者异步的业务逻辑操作,所以我们对模块填在reducer定义,用来声明修改数据的方法集合。

import { run, dispatch, getState } from "concent";

const delay = () => new Promise(r => setTimeout(r, 1000));

const state = () => ({ num: 1 });// 状态声明
const reducer = {// reducer 声明
  inc(payload, moduleState) {
    return { num: moduleState.num + 1 };
  },
  async asyncInc(payload, moduleState) {
    await delay();
    return { num: moduleState.num + 1 };
  }
};

run({
  counter: { state, reducer }
});

然后我们用dispatch来触发修改状态的方法

因 dispatch 会返回一个 Promise,所以我们需要用一个 async 包裹起来执行代码

import { dispatch } from "concent";

(async ()=>{
  console.log(getState("counter").num);// log 1
  await dispatch("counter/inc");// 同步修改
  console.log(getState("counter").num);// log 2
  await dispatch("counter/asyncInc");// 异步修改
  console.log(getState("counter").num);// log 3
})()

注意 dispatch 调用时基于字符串匹配方式,之所以保留这样的调用方式是为了照顾需要动态调用的场景,其实更推荐的写法是

import { dispatch } from "concent";

(async ()=>{
  console.log(getState("counter").num);// log 1
  await dispatch(reducer.inc);// 同步修改
  console.log(getState("counter").num);// log 2
  await dispatch(reducer.asyncInc);// 异步修改
  console.log(getState("counter").num);// log 3
})()

接入 react

上述示例主要演示了如何定义状态和修改状态,那么接下来我们需要用到以下两个 api 来帮助 react 组件生成实例上下文(等同于与 vue 3 setup 里提到的渲染上下文),以及获得消费 concent 模块数据的能力

import { register, useConcent } from "concent";

@register("counter")
class ClsComp extends React.Component {
  changeNum = () => this.setState({ num: 10 })
  render() {
    return (
      <div>
        <h1>class comp: {this.state.num}</h1>
        <button onClick={this.changeNum}>changeNum</button>
      </div>
    );
  }
}

function FnComp() {
  const { state, setState } = useConcent("counter");
  const changeNum = () => setState({ num: 20 });
  
  return (
    <div>
      <h1>fn comp: {state.num}</h1>
      <button onClick={changeNum}>changeNum</button>
    </div>
  );
}

注意到两种写法区别很小,除了组件的定义方式不一样,其实渲染逻辑和数据来源都一模一样。

渲染它们查看结果

在线示例

const rootElement = document.getElementById("root");
ReactDOM.render(
  <React.StrictMode>
    <div>
      <ClsComp />
      <FnComp />
    </div>
  </React.StrictMode>,
  rootElement
);

对比Recoil,我们发现没有顶层并没有Provider或者Root类似的组件包裹,react 组件就已接入 concent,做到真正的即插即用和无感知接入,同时api保留为与react一致的写法。

组件调用 reducer

concent 为每一个组件实例都生成了实例上下文,方便用户直接通过ctx.mr调用 reducer 方法

mr 为 moduleReducer 的简写,直接书写为 ctx.moduleReducer 也是合法的

//  --------- 对于类组件 -----------
changeNum = () => this.setState({ num: 10 })
// ===> 修改为
changeNum = () => this.ctx.mr.inc(10);// or this.ctx.mr.asynCtx()

//  --------- 对于函数组件 -----------
const { state, mr } = useConcent("counter");// useConcent 返回的就是 ctx
const changeNum = () => mr.inc(20);// or ctx.mr.asynCtx()

异步计算函数

run接口里支持扩展computed属性,即让用户定义一堆衍生数据的计算函数集合,它们可以是同步的也可以是异步的,同时支持一个函数用另一个函数的输出作为输入来做二次计算,计算的输入依赖是自动收集到的。

 const computed = {// 定义计算函数集合
  numx10({ num }) {
    return num * 10;
  },
  // n:newState, o:oldState, f:fnCtx
  // 结构出 num,表示当前计算依赖是 num,仅当 num 发生变化时触发此函数重计算
  async numx10_2({ num }, o, f) {
    // 必需调用 setInitialVal 给 numx10_2 一个初始值,
    // 该函数仅在初次 computed 触发时执行一次
    f.setInitialVal(num * 55);
    await delay();
    return num * 100;
  },
  async numx10_3({ num }, o, f) {
    f.setInitialVal(num * 1);
    await delay();
    // 使用 numx10_2 再次计算
    const ret = num * f.cuVal.numx10_2;
    if (ret % 40000 === 0) throw new Error("-->mock error");
    return ret;
  }
}

// 配置到 counter 模块
run({
  counter: { state, reducer, computed }
});

上述计算函数里,我们刻意让numx10_3在某个时候报错,对于此错误,我们可以在run接口的第二位options配置里定义errorHandler来捕捉。

run({/**storeConfig*/}, {
    errorHandler: (err)=>{
        alert(err.message);
    }
})

当然更好的做法,利用concent-plugin-async-computed-status插件来完成对所有模块计算函数执行状态的统一管理。

import cuStatusPlugin from "concent-plugin-async-computed-status";

run(
  {/**storeConfig*/},
  {
    errorHandler: err => {
      console.error('errorHandler ', err);
      // alert(err.message);
    },
    plugins: [cuStatusPlugin], // 配置异步计算函数执行状态管理插件
  }
);

该插件会自动向 concent 配置一个cuStatus模块,方便组件连接到它,消费相关计算函数的执行状态数据

function Test() {
  const { moduleComputed, connectedState, setState, state, ccUniqueKey } = useConcent({
    module: "counter",// 属于 counter 模块,状态直接从 state 获得
    connect: ["cuStatus"],// 连接到 cuStatus 模块,状态从 connectedState.{$moduleName}获得
  });
  const changeNum = () => setState({ num: state.num + 1 });
  
  // 获得 counter 模块的计算函数执行状态
  const counterCuStatus = connectedState.cuStatus.counter;
  // 当然,可以更细粒度的获得指定结算函数的执行状态
  // const {['counter/numx10_2']:num1Status, ['counter/numx10_3']: num2Status} = connectedState.cuStatus;

  return (
    <div>
      {state.num}
      <br />
      {counterCuStatus.done ? moduleComputed.numx10 : 'computing'}
      {/** 此处拿到错误可以用于渲染,当然也抛出去 */}
      {/** 让 ErrorBoundary 之类的组件捕捉并渲染降级页面 */}
      {counterCuStatus.err ? counterCuStatus.err.message : ''}
      <br />
      {moduleComputed.numx10_2}
      <br />
      {moduleComputed.numx10_3}
      <br />
      <button onClick={changeNum}>changeNum</button>
    </div>
  );
}

![]https://raw.githubusercontent.com/fantasticsoul/assets/master/article-img/recoil-vs-concent/r4.gif)

查看在线示例

精确更新

开篇我说对Recoli提到的精确更新保持了怀疑态度,有一些误导的嫌疑,此处我们将揭开疑团

大家知道hook使用规则是不能写在条件控制语句里的,这意味着下面语句是不允许的

const NumView = () => {
  const [show, setShow] = useState(true);
  if(show){// error
    const [num, setNum] = useRecoilState(numState);
  }
}

所以用户如果 ui 渲染里如果某个状态用不到此数据时,某处改变了num值依然会触发NumView重渲染,但是concent的实例上下文里取出来的statemoduleComputed是一个Proxy对象,是在实时的收集每一轮渲染所需要的依赖,这才是真正意义上的按需渲染和精确更新。

const NumView = () => {
  const [show, setShow] = useState(true);
  const {state} = useConcent('counter');
  // show 为 true 时,当前实例的渲染对 state.num 的渲染有依赖
  return {show ? <h1>{state.num}</h1> : 'nothing'}
}

点我查看代码示例

当然如果用户对 num 值有 ui 渲染完毕后,有发生改变时需要做其他事的需求,类似useEffect的效果,concent 也支持用户将其抽到setup里,定义effect来完成此场景诉求,相比useEffect,setup 里的ctx.effect只需定义一次,同时只需传递 key 名称,concent 会自动对比前一刻和当前刻的值来决定是否要触发副作用函数。

conset setup = (ctx)=>{
  ctx.effect(()=>{
    console.log('do something when num changed');
    return ()=>console.log('clear up');
  }, ['num'])
}

function Test1(){
  useConcent({module:'cunter', setup});
  return <h1>for setup<h1/>
}

更多关于 effect 与 useEffect 请查看此文

结语

Recoil推崇状态和派生数据更细粒度控制,写法上 demo 看起来简单,实际上代码规模大之后依然很繁琐。

Concent遵循redux单一状态树的本质,推崇模块化管理数据以及派生数据,同时依靠Proxy能力完成了运行时依赖收集追求不可变的完美整合。

所以你将获得:

最后解答一下关于 concent 是否支持current mode的疑惑,先上结论,100%支持。

我们首先要理解current mode原理是因为 fiber 架构模拟出了和整个渲染堆栈(即 fiber node 上存储的信息),得以有机会让 react 自己以组件为单位调度组件的渲染过程,可以悬停并再次进入渲染,安排优先级高的先渲染,重度渲染的组件会切片为多个时间段反复渲染,而 concent 的上下文本身是独立于 react 存在的(接入 concent 不需要再顶层包裹任何 Provider ), 只负责处理业务生成新的数据,然后按需派发给对应的实例,之后就是 react 自己的调度流程,修改状态的函数并不会因为组件反复重入而多次执行(这点需要我们遵循不该在渲染过程中书写包含有副作用的代码原则),react 仅仅是调度组件的渲染时机。

❤ star me if you like concent ^_^

Edit on CodeSandbox

Edit on StackBlitz

如果有关于 concent 的疑问,可以扫码加群咨询,会尽力答疑解惑,帮助你了解更多,里面的不少小伙伴都变成老司机了,用过之后都表示非常 happy,客官上船试试便知😀。

1809 次点击
所在节点    React
0 条回复

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/681684

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX