8.3 KiB
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.vue、DesignerFormRenderMobile.vue、PreviewFormRenderMobile.vue、apps/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 里 cloneDeep(GC 抖动)
文件: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 无意义 cloneDeep(GC 抖动,最大头)
文件: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;
});
但 transformSchemaProperties(packages/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。
正式提测:
- 在
packages/lowcode-create/package.jsonbump 版本(当前1.0.8)。 pnpm --filter lowcode-create run release或既有发包流程发到内部 nexus。- 更新
pnpm-workspace.yamlcatalog 中@lingshu/lowcode-create版本号。 pnpm install。
已发现但未修的问题(备查)
排查过程中识别出的另外几处问题,本次未动,后续按优先级处理:
| 位置 | 问题 | 性质 |
|---|---|---|
packages/lowcode-create/src/utils/CreateFormState.ts:78-91 |
getBindValue 在 computed getter 里 setFieldValue;const v = stateValue ?? stateValue 是 typo(应为 ?? schemaValue)。已两次提交修过响应式循环(commit 49558e2b9、3d16e41a8),根因仍在。 |
反应式正确性 |
packages/lowcode-create/src/apis/FormApi.ts:120 |
setFieldValue 触发 FORM_FIELD_DATA_CHANGE_EVENT 时 oldValue: get(this.formState.value, field) 在 set 之后读取,得到的是新值。 |
事件 payload 错误 |
packages/lowcode-create/src/logics/SandBox.ts:140-153 |
setFieldStyle 的 3000ms setTimeout 没在 stopEffect 时 clear,组件卸载后仍会跑 document.querySelectorAll 并 ElMessage.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) |
修复后建议的验证方法
- Chrome DevTools → Performance → 录制一次"打开页面 → 编辑若干字段 → 关闭页面"循环 10 次。
- Memory → Allocation timeline,对比修复前后:
- cloneDeep 调用堆栈应明显减少(#2、#3 的效果)
- 多次循环后 detached DOM / 闭包数应稳定不增长(#1 的效果)
- Heap snapshot 多次循环间对比,搜索
SandBox、FormApi实例数应等于活跃页面数,不应累积。