Files
wukuang/lowcode-create-内存修复-26-04.md
2026-05-23 14:05:22 +08:00

8.3 KiB
Raw Permalink Blame History

lowcode-create 内存问题修复记录(2026-04)

背景

反馈:@lingshu/lowcode-create 在长会话中存在"内存泄漏"现象。系统排查后判定真泄漏 1 处 + 高频 GC 抖动 2 处,本次三处都已修。apps/lcdp 通过 catalog 版本 @lingshu/lowcode-create@1.0.8 从内部 nexus 装包,三处修复的源码与本地 dist/ 已同步落地,正式生效需要 bump 版本并发包


修复清单

#1 codeProtocol 不销毁导致的 Sandbox watch 泄漏(真泄漏)

文件packages/lowcode-create/src/composables/FeFormCreate.tsx

症状 useLowCodeFormCreate 在内部构造 SandBox(含 effectScope),通过 codeProtocol.destroy() 释放。但销毁责任在 caller,实际 4 个调用点中只有 PreviewForm.vue 显式 destroy,其余 3 个(DesignerForm.vueDesignerFormRenderMobile.vuePreviewFormRenderMobile.vueapps/lcdp/.../vant/index.vue)都不销毁。每次 host 卸载,sandbox 内通过 onFormDataChange/onFieldChange 注册的 watch 闭包(持有旧 formApi、formState)都会留存;切页 / 弹窗反复打开时叠加。

改动codeProtocol 创建后,自动挂到 caller 的 effect scope

import { getCurrentScope, onScopeDispose, unref } from "vue";

// ...创建 codeProtocol 之后
if (getCurrentScope()) {
  onScopeDispose(() => codeProtocol.destroy());
}

为什么安全

  • effectScope.stop() 是幂等的,PreviewForm.vue 现有的显式 destroy 不会出错。
  • getCurrentScope() 只有在 Vue setup 上下文里才返回 scope,非 Vue 调用方不受影响。
  • useLowCodeFormCreate 几乎都是在 <script setup> 中调用,自动挂上就够了。

影响面:所有调用 useLowCodeFormCreate 的入口现在都自动回收 sandbox watch,无需 host 自觉。


#2 SandBox.onFormDataChange 在 watch source 里 cloneDeepGC 抖动)

文件packages/lowcode-create/src/logics/SandBox.ts

症状 原 source getter 是 () => this.formApi.getFormValues(),而 getFormValues = () => cloneDeep(formState.value)。每次表单字段变更,watcher 重跑 source → 整张表 cloneDeep。批量写入(如 setFormValuesForce 一次写 N 个字段)会 N 次连续 clone。

改动 直接以 Ref 为 source,让 Vue 自己做深度追踪:

public onFormDataChange(fn: UserChangeFnArgs, options: WatchOptions = { deep: true }) {
  this.effectScope.run(() => {
    watch(this.formApi.formState, (newVal, oldVal) => {
      fn(newVal, oldVal);
    }, options);
  });
}

行为变更(已写在源码注释里):

  • newVal === oldVal,都是同一个 reactive proxy 引用(Vue 在 reactive 源上的标准行为)。
  • 用户脚本若需要"对比新旧",需自行 cloneDeep(newVal) 缓存。
  • 验证过 form-designer/src/components/user-script/Constants.tsx:58 中提供的 demo 模板:onFormDataChange(() => { console.log(...) }) 不消费 newVal/oldVal,公开契约未破。

影响面:注册了 onFormDataChange 的页面,每次字段变更省一次全表 cloneDeep;批量写入是 N 倍降本。


#3 LowCodeFormItem.vue schemaRaw 无意义 cloneDeepGC 抖动,最大头)

文件packages/lowcode-create/src/components/form/LowCodeFormItem.vue

症状 原代码每次 schemaRaw computed 重算都先 cloneDeep(schema),再叠加 transformSchemaProperties 返回的覆盖项:

const schemaRaw = computed(() => {
  const transformSchemaProperties = apis?.runtimeApi?.transformSchemaProperties(
    configs,
    schema,
    apis,
  );
  const raw = cloneDeep(schema);
  if (transformSchemaProperties) {
    Object.entries(transformSchemaProperties).forEach(([key, value]) =>
      set(raw, key, value),
    );
  }
  return raw;
});

transformSchemaPropertiespackages/form-designer/src/config/WidgetHelper.ts:322 + widget-helper/utils/CommonUtils.ts:325)只在 widget helper 注册了对应实现时才返回非空对象,多数 widget 直接返回 undefined。也就是说,绝大部分表单项每次 reactive 触发都跑了一次"clone 完啥也不改再返回"的废操作。100 字段表单 × 任一 reactive 变更 = 100 次大 cloneDeep。

改动:早出,无 overrides 时直接返回原 schema。

const schemaRaw = computed(() => {
  const overrides = apis?.runtimeApi?.transformSchemaProperties(
    configs,
    schema,
    apis,
  );
  if (!overrides || Object.keys(overrides).length === 0) {
    return schema;
  }
  const raw = cloneDeep(schema);
  Object.entries(overrides).forEach(([key, value]) => {
    set(raw, key, value);
  });
  return raw;
});

为什么安全

  • set(raw, key, value) 在 overrides 为空时本来就是空操作,原代码逻辑等价于"克隆完原样返回"。
  • computed 自带缓存,schema 引用稳定时 schemaRaw 引用也稳定,下游模板 :schema="schemaRaw" 是只读消费,无 mutation 风险。

影响面:本次 3 处修复中收益最大。100 字段表单中假如只有 5 个字段需要 transform,从 100 次 cloneDeep 降到 5 次。


部署路径

apps/lcdp 走的是从 nexus 装包,本地 dist/ 重 build 后不会被自动消费。两条路:

临时验证:把 packages/lowcode-create/dist/ 覆盖到 node_modules/@lingshu/lowcode-create/dist/,重启 dev server。

正式提测

  1. packages/lowcode-create/package.json bump 版本(当前 1.0.8)。
  2. pnpm --filter lowcode-create run release 或既有发包流程发到内部 nexus。
  3. 更新 pnpm-workspace.yaml catalog 中 @lingshu/lowcode-create 版本号。
  4. pnpm install

已发现但未修的问题(备查)

排查过程中识别出的另外几处问题,本次未动,后续按优先级处理:

位置 问题 性质
packages/lowcode-create/src/utils/CreateFormState.ts:78-91 getBindValue 在 computed getter 里 setFieldValueconst v = stateValue ?? stateValue 是 typo(应为 ?? schemaValue)。已两次提交修过响应式循环(commit 49558e2b93d16e41a8),根因仍在。 反应式正确性
packages/lowcode-create/src/apis/FormApi.ts:120 setFieldValue 触发 FORM_FIELD_DATA_CHANGE_EVENToldValue: get(this.formState.value, field) 在 set 之后读取,得到的是新值。 事件 payload 错误
packages/lowcode-create/src/logics/SandBox.ts:140-153 setFieldStyle 的 3000ms setTimeout 没在 stopEffect 时 clear,组件卸载后仍会跑 document.querySelectorAllElMessage.error 杂项
packages/form-designer/src/widget-mobile/config/widget/SerialNumber.vue:124 eventBus.on(FORM_FIELD_DATA_CHANGE_EVENT, ...) 没有 off——每次挂载新增一个不释放的订阅者(PC 同名文件已注释掉)。 真泄漏(不在 lowcode-create,但用同一 event bus

修复后建议的验证方法

  1. Chrome DevTools → Performance → 录制一次"打开页面 → 编辑若干字段 → 关闭页面"循环 10 次。
  2. Memory → Allocation timeline,对比修复前后:
    • cloneDeep 调用堆栈应明显减少(#2、#3 的效果)
    • 多次循环后 detached DOM / 闭包数应稳定不增长(#1 的效果)
  3. Heap snapshot 多次循环间对比,搜索 SandBoxFormApi 实例数应等于活跃页面数,不应累积。