• 作者:老汪软件技巧
  • 发表时间:2024-09-02 11:02
  • 浏览量:

状态引擎

一个Proxy Bug,引发不可变数据的再思考,让我们进一步了解proxy与不可变数据的紧密关系,并解决immer的设计缺陷,让limu拥有更强大的特性,扩宽不可变数据的使用场景。

此文章会涉及到一个自带依赖收集的状态引擎helux相关知识(helux基于limu构建),在此我们先简单介绍一下helux的使用方式,方便读者后续能够理解后续提及的bug产生根因。

快速开始

定义atom共享状态

import { atom, share } from 'helux';
// 原始类型 atom
const [numAtom] = atom(1);
// 字典对象类型 atom,可使用 atom 和 share 创建,atom 始终会装箱,故推荐使用 share 对字典创建 atom
const [objAtom] = atom({ a: 1, b: { b1: 1 } }); // objAtom 装箱为 { val: T }
const [dictAtom] = share({ a: 1, b: { b1: 1 } }); // 无装箱行为,dictAtom 为 T

组件里使用共享状态,需通过useAtom完成,修改方式可在组件内定义,也可以在组件外部定义

import { share, useAtom } from 'helux';
const [sharedState, setState] = share({
  info: { born: '2023-12-31', age: 2 },
});
// change也可以定义在组件外部
// const change = () => setState((draft) => {
//   draft.info.born = `${Date.now()}`;
// });
export default function Demo() {
  const [state, setState] = useAtom(sharedState);
  const change = () => setState((draft) => {
    draft.info.born = `${Date.now()}`;
  });
  return (
    <div>
      <h1>hello helux {state.info.born} h1>
      <button onClick={change} > changeBornInner button>
    div>
  );
}

修改共享状态方式除了setState,也提供reactive机制让用户直接修改共享状态(每次修改完毕,内部都会不停的生成新的数据副本透传给react,依然不违反react提倡的不可变原则)

// sharex 返回共享上下文,可取到 state, setState, reactive, useReactive 等属性
const { reactive, useReactive } = sharex({...}); 
const change = ()=> reactive.info.born = `${Date.now()}`;
export default function Demo() {
  const [reactive] = useReactive();
  // 组件里定义修改
  // const change = ()=> reactive.info.born = `${Date.now()}`;
  return <h1 onClick={change}>{reactive.info.born}h1>;
}

当然了reactive和setState由于太过于灵活,不利于做状态变迁回溯查询问题,故推荐的方式使用action去修改

const { defineActions, state, useState } = sharex({...}); 
// 导出 actions 函数集合给用户使用,使用 useLoading 可获取函数运行进度
const { actions, useLoading } = ctx.defineActions()({
  // 同步 action,直接修改草稿
  changeA1({ draft, payload }) {
    draft.a.b.c += payload;
  },
  // 异步 action,直接修改草稿
  async foo1({ draft, payload }) {
    draft.a.b.c += 1000;
    await delay(3000); // 进入下一次事件循环触发草稿提交
    draft.a.b.c += 1000;
  },
});

基于action修改的话我们就可以接入helux-plugin-devtool插件查看状态变迁过程了

import { addPlugin } from 'helux';
import { HeluxPluginDevtool } from '@helux/plugin-devtool';
addPlugin(HeluxPluginDevtool);

除了useAtom和useReactive导出共享状态提供给组件使用之外(让组件获得对数据的精确依赖),提供$完成无钩子函数的数据依赖绑定与视图更新,实现dom粒度更新

import { $ } from 'helux';
export default function Demo() {
  // 其他任何地方修改了 state.info.born,会触发此处的文案重渲染,但不会触发 Demo 组件渲染
  return <h1>state.info.born: {$(state.info.born)}h1>
}

bug 复盘

此bug来自helux issue 166,用户还提供了最小可复现示例,如下图所示,用户用到了reactive特性,点击item时调用change更新属性。

点击第二次时报错如下

原因也较为简单,因为重赋值了state.current,生成了新的数据副本,那么旧数据对应的代理对象就全部被回收了,所以对应这种使用reactive来修改的使用场景,我最初只是简单回复了不要再闭包里读取对象,因为很可能被回收了,最好透传克隆对象,或者传id等标识符,用的时候现从根对象去读取。

由于我们自己使用时都是使用action模式去修改状态,总是使用action回调里提供的状态来读取最新值,到没有遇到类似问题,但透传proxy对象作为参数时一个很常见的场景,故这个问题引发我更深层的思考,根因是什么,有没有更好的方式去解决?

重用逃逸的代理对象

为什么第二次点击时item不是最新值呢,核心原理还是helux依赖收集特性导致的结果,Demo组件渲染时只依赖了list,而list的代理对象生成了视图,同时子元素也交给了onClick回调形成了一个闭包,第一次点击时没问题,但是修改仅仅是state.current,而Demo组件依赖图谱仅关心list变化,故它不会被刷新,但是这些代理对象在新的数据副本生成后就全部被回收了,再次点击就出现上图错误。

或许部分不熟悉immer或者limu这类不可变数据的同学,对上述提到的代理对象在新的数据副本生成后就全部被回收这句很是不理解,这里将用更简单的底层代码来演示这个问题,代码里会用到im代号,表示不可变数据操作js包,具体比较模块包含immer和limu。

如果我们能解决如何重用逃逸的代理对象问题,将更优雅的解决上述bug,而不是强制用户使用克隆对象或用的时候现从根对象去读取这样的心智负担较重的方式来组织代码。

代理对象被回收

下面的代码里无论是immer还是limu,调用b.test都将触发错误Cannot perform 'get' on a proxy that has been revoked,b节点在createDraft和finishDraft可以任意修改,最终修改行为都将落到新的副本上,一旦finishDraft被调用,finishDraft内部会对这些代理节点做了统一的回收处理,避免用户再次去操作代理节点做修改产生意外行为,故在finishDraft之后操作b的任何读写行为都将触发错误。

访问此链接复现下面代码演示的错误

import * as immer from "immer";
import * as limu from "limu";
function changeTest(lib, options) {
  const base = { a: 1, b: { test: 2 }, c: { c1: 1 } };
  const draft = lib.createDraft(base, options);
  const b = draft.b;
  delete draft.b;
  draft.c.c2 = b;
  draft.c.c2.test = 1000;
  const final = lib.finishDraft(draft);
  // trigger error
  b.test = 8888;
}
changeTest(immer);
changeTest(limu);

代理对象读写分离

这里类似在finishDraft之后再次使用的b节点,我们都统称为逃逸的代理对象,其实他们都是很危险的,我们无法控制用户是否会使用他们,故内部回收代理是一劳永逸的办法,强制用户对代理对象的使用要按规范来,甚至immer的10.x版本后直接删除了createDraft和finishDraft接口,强制用户使用produce来同步完成对象的读写操作。

// immer >= 10.x
produce((draft)=>{
    draft.c.c.test = 1000;
});

但这样做真的有效么?如果用户想造就这种逃逸节点,依然可以轻松办到。

let node;
produce((draft)=>{
    node = draft.b;
});
node.test = 888; // 引发 Cannot perform 'get' on a proxy that has been revoked

所以如果我们建立一种机制,在草稿结束前,代理节点是可读写状态,结束后代理节点是只可读状态,那么此问题将完美解决。

沿此思路,我重新发布了limu的3.13.0版本,允许创建操作时设置autoRevoke=false,这样逃逸的代理节点不会被回收,处于只可读状态,修复了上诉bug。

代理不回收会造成内存泄露么

加完autoRevoke特性,又想到一个问题,为什么代理要设计revocable接口呢,如果不调用回收是否会造成内存泄露?

const {proxy, revoke} = Proxy.revocable(target, handler);

stackoverflow上也找到了相关讨论

msdn如下:

红字部分提示了一旦调用了revoke,代理对象会打开和原始对象的链接,原始对象可以尽快被垃圾回收器GC回收,如果没调用revoke,那代理自身会不会被回收呢,原始对象不会被回收了么。

顺着疑问继续往下看,发现以下示例代码:

推荐使用WeakMap管理revoke句柄,可以主动调用revoke断掉与原始对象的关系,也可能因为作为WeakMap键的proxy对象不可达后(即没有任何其他引用指向它),proxy将被清理,proxy一单被清理,如果只有proxy指向原始对象,那么原始对象也将被清理,所以这里得到关键结论是:Proxy能否被gc清理和是否调用revoke没有关系,revoke的更大的作用是提供一种显式的方式让开发展彻底隔离使用者和原始对象之间的关系。

询问智能问答得到的结果也和我所想一致:

验证是否真的回收了

当然了,古人说得好:纸上得来终觉浅,绝知此事要躬行,为了验证limu关闭自动revoke功能前后内存暂用是否有差异,我们使用process.memoryUsage()来获得相关内存占用情况。

测试代码还是复用了limu自带的benchmark,步骤如下:

git clone git@github.com:tnfe/limu.git
cd benchmark
npm i
npm run s1 // 执行大数据大数据读写测试,其中limu库保持代理对象自动撤销模式
npm run s1d // 执行大数据大数据读写测试,其中limu库切换为代理对象不撤销模式

循环次数100次时,无论代理对象自动撤销模式还是代理对象不撤销模式,均可以第二次打印看到rss内存占用已降下来。

循环次数1000次时,十几次打印后发现代理对象不撤销模式的rss内存始终未降低

猜测gc有延迟,于是命令函数里加入--expose-gc让node主动注入gc

然后再第五次打印时执行global.gc

可以看到主动gc后rss降低到了和循环100次的日志一样,说明一个问题:主动revoke的确可以更早的让内存得到释放,但是不调用revoke依然不会导致内存泄露(前提是代理对象的引用计数为0),只是释放得稍微慢一些。

经过严格的性能测试和单元测试,limu 3.13.0得以发布,基于limu构建的helux 4.4.0也顺利发布,修复了issue 16,同时也提供了修复后的演示链接,链接里点击【limu ok】按钮将不会报错,同时修改也是无效的(只能读取)。

小小的proxybug竟牵连出这么多知识点,感觉就像入了一次盘丝洞,经历了各种惊心动魄的故事-_-||,谨以此文分享给大家,让我们进一步了解proxy与不可变数据的紧密关系。

友链:

❤️ 你的小星星是我们开源最大的精神动力,欢迎关注以下项目:

limu 最快的不可变数据js操作库.

hel-micro 工具链无关的运行时模块联邦sdk.

helux 集atom、signal、依赖追踪为一体,支持细粒度响应更新的状态引擎