关于react.js:细聊Concent-Recoil-探索react数据流的新开发模式

10次阅读

共计 12098 个字符,预计需要花费 31 分钟才能阅读完成。

开源不易,感激你的反对,❤ star me if you like concent ^_^

序言

之前发表了一篇文章 redux、mobx、concent 个性大比拼, 看后生如何对局前辈,吸引了不少感兴趣的小伙伴入群开始理解和应用 concent,并取得了很多正向的反馈,实实在在的帮忙他们进步了开发体验,群里人数尽管还很少,但大家热情高涨,技术探讨气氛浓重,对很多陈腐技术都有放弃肯定的敏感度,如上个月开始逐步被提及得越来越多的出自 facebook 的最新状态治理计划 recoil,尽管还处于试验状态,然而相必大家曾经私底下开始欲欲跃试了,毕竟出世名门,有 fb 背书,肯定会大放异彩。

不过当我体验完 recoil 后,我对其中标榜的 准确更新 放弃了狐疑态度,有一些误导的嫌疑,这一点下文会独自剖析,是否属于误导读者在读完本文后天然能够得出结论,总之本文次要是剖析 ConcentRecoil的代码格调差异性,并探讨它们对咱们未来的开发模式有何新的影响,以及思维上须要做什么样的转变。

数据流计划之 3 大流派

目前支流的数据流计划按状态都能够划分以下这三类

  • redux 流派

redux、和基于 redux 衍生的其余作品,以及相似 redux 思路的作品,代表作有 dva、rematch 等等。

  • mobx 流派

借助 definePerperty 和 Proxy 实现数据劫持,从而达到响应式编程目标的代表,类 mobx 的作品也有不少,如 dob 等。

  • Context 流派

这里的 Context 指的是 react 自带的 Context api,基于 Context api 打造的数据流计划通常主打轻量、易用、概览少,代表作品有 unstated、constate 等,大多数作品的外围代码可能不超过 500 行。

到此咱们看看 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,定义状态
  • selector, 定义派生数据
  • useRecoilState,生产状态
  • useRecoilValue,生产派生数据

定义状态

内部应用 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,定义模块状态(必须)、模块计算(可选)、模块察看(可选)

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

  • setState,批改状态

定义状态 & 批改状态

以下示例咱们先脱离 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 模块数据的能力

  • register, 注册类组件为 concent 组件
  • useConcent, 注册函数组件为 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…

查看在线示例

准确更新

开篇我说对 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 请查看此文

current mode

对于 concent 是否反对 current mode 这个疑难呢,这里先说答案,concent是 100% 齐全反对的,或者进一步说,所有状态管理工具,最终触发的都是 setStateforceUpdate,咱们只有在渲染过程中不要写具备任何副作用的代码,让雷同的状态输出失去的渲染后果幂,即是在 current mode 下运行平安的代码。

current mode只是对咱们的代码提出了更刻薄的要求。

// bad
function Test(){track.upload('renderTrigger');// 上报渲染触发事件
   return <h1>bad case</h1>
}

// good
function Test(){useEffect(()=>{
      // 就算仅执行了一次 setState, current mode 下该组件可能会反复渲染,// 但 react 外部会保障该副作用只触发一次
      track.upload('renderTrigger');
   })
   return <h1>bad case</h1>
}

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

所以同样的,对于 concent

const setup = (ctx)=>{ctx.effect(()=>{
     // effect 是对 useEffect 的封装,// 同样在 current mode 下该副作用也只触发一次(由 react 保障)
      track.upload('renderTrigger');
  });
}

// good
function Test2(){useConcent({setup})
   return <h1>good case</h1>
}

同样的,依赖收集在 current mode 模式下,反复渲染仅仅是导致触发了屡次收集,只有状态输出一样,渲染后果幂等,收集到的依赖后果也是幂等的。

// 假如这是一个渲染很耗时的组件,在 current mode 模式下可能会被中断渲染
function HeavyComp(){const { state} = useConcent({module:'counter'});// 属于 counter 模块

 // 这里读取了 num 和 numBig 两个值,收集到了依赖
 // 即当仅当 counter 模块的 num、numBig 的发生变化时,才触发其重渲染(最终还是调用 setState)
 // 而 counter 模块的其余值发生变化时,不会触发该实例的 setState
  return (<div>num: {state.num} numBig: {state.numBig}</div>
  );
}

最初咱们能够梳理一下,hook自身是反对把逻辑剥离到用的自定义 hook(无 ui 返回的函数),而其余状态治理也只是多做了一层工作,疏导用户把逻辑剥离到它们的规定之下,最终还是把业务解决数据交回给 react 组件调用其 setStateforceUpdate触发重渲染,current mode的引入并不会对现有的状态治理或者新生的状态治理计划有任何影响,仅仅是对用户的 ui 代码提出了更高的要求,免得因为 current mode 引发难以排除的 bug

为此 react 还特地提供了 React.Strict 组件来成心触发双调用机制, https://reactjs.org/docs/stri… 以疏导用户书写更符合规范的 react 代码,以便适配未来提供的 current mode。

react 所有新个性其实都是被 fiber 激活了,有了 fiber 架构,衍生出了 hooktime slicingsuspense 以及未来的 Concurrent Mode,class 组件和 function 组件都能够在Concurrent Mode 下平安工作,只有遵循标准即可。

摘取自:https://reactjs.org/docs/stri…

Strict mode can’t automatically detect side effects for you, but it can help you spot them by making them a little more deterministic. This is done by intentionally double-invoking the following functions:

  • Class component constructor, render, and shouldComponentUpdate methods
  • Class component static getDerivedStateFromProps method
  • Function component bodies
  • State updater functions (the first argument to setState)
  • Functions passed to useState, useMemo, or useReducer

所以呢,React.Strict其实为了疏导用户写可能在 Concurrent Mode 里运行的代码而提供的辅助 api,先让用户缓缓习惯这些限度,循序渐进一步一步来,最初再推出Concurrent Mode

结语

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

// 定义状态
const numState = atom({key:'num', default:0});
const numBigState = atom({key:'numBig', default:100});
// 定义衍生数据
const numx2Val = selector({
  key: "numx2",
  get: ({get}) => get(numState) * 2,
});
const numBigx2Val = selector({
  key: "numBigx2",
  get: ({get}) => get(numBigState) * 2,
});
const numSumBigVal = selector({
  key: "numSumBig",
  get: ({get}) => get(numState) + get(numBigState),
});

// ---> ui 处生产状态或衍生数据
const [num] = useRecoilState(numState);
const [numBig] = useRecoilState(numBigState);
const numx2 = useRecoilValue(numx2Val);
const numBigx2 = useRecoilValue(numBigx2Val);
const numSumBig = useRecoilValue(numSumBigVal);

Concent遵循 redux 繁多状态树的实质,推崇模块化治理数据以及派生数据,同时依附 Proxy 能力实现了 运行时依赖收集 谋求不可变 的完满整合。

run({
  counter: {// 申明一个 counter 模块
    state: {num: 1, numBig: 100}, // 定义状态
    computed:{// 定义计算,参数列表里解构具体的状态时确定了依赖
       numx2: ({num})=> num * 2,
       numBigx2: ({numBig})=> numBig * 2,
       numSumBig: ({num, numBig})=> num + numBig,
     }
  },
});

// ---> ui 处生产状态或衍生数据, 在 ui 处构造了才产生依赖
const {state, moduleComputed, setState} = useConcent('counter') 
const {numx2, numBigx2, numSumBig} = moduleComputed;
const {num, numBig} = state;

所以你将取得:

  • 运行时的依赖收集,同时也遵循 react 不可变的准则
  • 所有皆函数(state, reducer, computed, watch, event…),能取得更敌对的 ts 反对
  • 反对中间件和插件机制,很容易兼容 redux 生态
  • 同时反对集中与分形模块配置,同步与异步模块加载,对大型工程的弹性重构过程更加敌对

❤ star me if you like concent ^_^

Edit on CodeSandbox

Edit on StackBlitz

正文完
 0