heluxjs/helux

[RFC] 为响应式对象更新引入严格模式

zhangfisher opened this issue · 4 comments

helux3.5.0以后支持直接引入reactive,可以支持直接进行响应式更新。

const [sharedState, setState, {reactive}] = share({ a: 1, b: { b1: { b2: 200 } } });
reactive.a=1000  // 响应式更新,会引起依赖收集和突变更新等

如此要对一个share对象进行更新有三种方式:

  • setState
  • action
  • reactive

setState是对标ReactuseState的方法,action是引入Flux 设计范式时导入的,Flux 是 React 框架的好伴侣。它优秀的单向数据流设计,使得数据的流向更加清晰,能帮助开发者更好的管理和调试组件的内部状态,因此在React甚至Vue的状态管理中被广泛使用。

Flux 设计范式简单地说,就是不允许直接更改state,而应该通过分发Action来更新State,这样可以使用State的更新可预测,对大型对象状态很有好处。

可以看到,对一个share对象的setStatereactive都是直接更新State,明显不符合Flux 设计范式。

  • 显然,setState比较适合小型对象更新,此时无所谓设计模式。
  • reactive带来了无以伦比的灵活性和更新颗粒度,但是也破坏了Flux 设计范式,当我们一旦用了reactive后,就会发现action变得没有任何价值了,因为它打破了Flux 设计范式带来的强制约束。

在其他React状态库中,如果你直接更新State则是不允许的,并且会给出警告。

本提案就是想为响应式对象引入严格模式,在严格模式下,对reactive的更新也会受到约束,让其在大型复杂对象下符合Flux 设计范式。

本提案的思路如下:

  1. 创建share时,通过strict=true来指定严格模式
const [sharedState, setState, {reactive}] = share({ a: 1, b: { b1: { b2: 200 } } },{
     strict:true
});

reactive.a=1000    // 不允许

  1. 通过action来更新响应式对象
import { action } from "helux"

// 将a更新为100
reactive.a=action(100)    
//描述可以被显示在开发工具的Action列表中
reactive.a=action(100,"描述")    
// 异步更新
reactive.a=action(async ()=>100)   

在严格模式下,不再允许直接使用reactive.a=1000,而是只能通过action来更新响应式对象。

这个提案挺好的,本意是希望严格约束对象更新流程,方便变更记录可追溯,但是对于helux内部来说 reactive 和 setState 底层都是一套逻辑,是可以完美共存的,但提案的实现不太优雅,可能加的参数应该为 strictLevel 更合适,


  • strictLevel=0

最严格模式,只能使用 action提交变更(action自带描述)

  • strictLevel=1

表示使用响应式更新必须追加描述值,方便响应式变更也是可追踪到具体位置,如果没有没有desc,则变更操作不会提交(相当于改的草稿无效),开发模式给出报错。

reactive.a.b.c = 1; 
reactiveDesc('xxxChange')

使用setState必须追加描述值,方便响应式变更也是可追踪到具体位置

setState((draft)=>reactive.a.b.c = 1,  {desc:'xxxChange'});
  • strictLevel=2

松散模式,不限制任何变更操作

查看变更记录可参照这个示例,引入插件
然后写一个带有响应式描述的的变更操作
image

将会看到devtool有详细的变更记录可追溯

注:不添加描述的话,type为 xxxxModule@Reactive/setState

image

strictLevel还行

而reactive.a.b.c = 1; reactiveDesc('xxxChange')
分两句来写比较不可接受,
还是reactive.a.b.c=action()方式比较好,语义上也符合通过action来更新的含义

action 本身就是修改数据的逻辑,返回格式[nextSnap, err],强行和reative结合在一起不符合设计, 目前defineActions使用方式如下

// 【可选】约束各个函数入参 payload 类型
type Payloads = {
  changeA: [number, number];
  foo: boolean | undefined;
};

// 为方便提供各函数 payload 类型约束,这里采用柯里化方式
const { actions, useLoading, getLoading } = ctxp.defineActions<Payloads>()({
  // 同步 action,直接修改草稿
  changeA1({ draft, payload }) {
    draft.a.b.c = 200;
  },
  // 同步 action,返回部分状态
  changeA2({ draft, payload }) {
    return { c: 'new desc' };
  },
  // 同步 action,直接修改草稿和返回部分状态同时使用
  changeA3({ draft, payload }) {
    draft.a.b.c = 200;
    return { c: 'new desc' };
  },
 // 异步 action,直接修改草稿
  async foo1({ draft, payload }) {
    await delay(3000);
    draft.a.b.c += 1000;
  },
 // 异步 action,多次直接修改草稿,返回部分状态
  async foo2({ draft, payload }) {
    draft.a.b.c += 1000;
    await delay(3000); // 进入下一次事件循环触发草稿提交
    draft.a.b.c += 1000;
    await delay(3000); // 再次进入下一次事件循环触发草稿提交
    const list = await fetchList();
    return { list }; // 等价于 draft.list = list
  },
});

// action 方法的异常默认被拦截掉不再继续抛出,只是并发送给插件和伴生loading状态
// 调用方法,错误会传递到 err 位置
const [ snap, err ] = actions.changeA1();
// 调用方法并抛出错误,此时错误既发给插件和伴生loading状态,也向上抛出,用户需自己 catch
const [ snap ] = actions.changeA1(1, true);

reactive 只表示数据修改,提交时机默认是下一次事件循环,

reactive.a.b.c =1;
reactive.a.b.c2 =2;
reactive.info = 'xxx';
await someMethod(); // 触发提交

可以主动提交

reactive.a.b.c =1;
flush(reactive, 'change_a.b.c'); // 主动提交,触发更新
reactive.a.b.c2 =2;
flush(reactive, 'change_a.b.c2'); // 主动提交,触发更新
reactive.info = 'xxx';
await someMethod(); // 由事件循环系统触发提交

理解完这个设计,你就明白 reacitveaction是不符合内部运行机制的,也不符合用户习惯