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

160 lines
8.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```ts
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 自己做深度追踪:
```ts
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` 返回的覆盖项:
```ts
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。
```ts
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 里 `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 |
---
## 修复后建议的验证方法
1. Chrome DevTools → Performance → 录制一次"打开页面 → 编辑若干字段 → 关闭页面"循环 10 次。
2. Memory → Allocation timeline,对比修复前后:
- cloneDeep 调用堆栈应明显减少(#2#3 的效果)
- 多次循环后 detached DOM / 闭包数应稳定不增长(#1 的效果)
3. Heap snapshot 多次循环间对比,搜索 `SandBox``FormApi` 实例数应等于活跃页面数,不应累积。