refactor(editor): 优化历史记录 IndexedDB 持久化,仅序列化 step diff

避免 serialize-javascript 序列化整份快照,改为结构化克隆写入 IndexedDB;初始历史行展示已保存标记;root-change 事件携带 options。
This commit is contained in:
roymondchen 2026-06-12 17:46:55 +08:00
parent 6960bd50e1
commit 921188d2da
6 changed files with 64 additions and 26 deletions

View File

@ -7,6 +7,7 @@
<span class="m-editor-history-list-item-index" title="历史步骤编号 #0未修改的初始状态">#0</span>
<span class="m-editor-history-list-item-op op-initial">初始</span>
<span class="m-editor-history-list-item-desc">{{ desc }}</span>
<span v-if="saved" class="m-editor-history-list-item-saved" title="该记录为最近一次保存的状态">已保存</span>
<span v-if="gotoEnabled && !isCurrent" class="m-editor-history-list-item-actions">
<span class="m-editor-history-list-item-goto" title="回到该记录" @click.stop="onClick">回到</span>
</span>
@ -49,6 +50,8 @@ const props = withDefaults(
);
const desc = computed(() => props.marker?.historyDescription || '未修改的初始状态');
/** 基线(初始状态)是否为最近一次保存点:仅页面栈的 `initial` 基线 step 会被 markSaved 标记。 */
const saved = computed(() => Boolean(props.marker?.saved));
const time = computed(() => formatHistoryTime(props.marker?.timestamp));
const timeTitle = computed(() => formatHistoryFullTime(props.marker?.timestamp));
const rowTitle = computed(() => {

View File

@ -179,7 +179,7 @@ class Editor extends BaseService {
this.state.stageLoading = false;
}
this.emit('root-change', value as StoreState['root'], preValue as StoreState['root']);
this.emit('root-change', value as StoreState['root'], preValue as StoreState['root'], options);
}
}

View File

@ -17,7 +17,6 @@
*/
import { reactive } from 'vue';
import serialize from 'serialize-javascript';
import type { CodeBlockContent, DataSourceSchema, Id, MPage, MPageFragment } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
@ -57,7 +56,8 @@ import editorService from './editor';
const DEFAULT_DB_NAME = 'tmagic-editor';
const DEFAULT_STORE_NAME = 'history';
const DEFAULT_KEY: IDBValidKey = 'default';
const PERSIST_VERSION = 1;
// v2仅把每条 step 的 diff 序列化成字符串,其余字段交给 IndexedDB 结构化克隆原生存储(见 saveToIndexedDB
const PERSIST_VERSION = 2;
class History extends BaseService {
public state = reactive<HistoryState>({
@ -442,6 +442,8 @@ class History extends BaseService {
public async saveToIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState> {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
// serializeStacks 会在序列化各栈时只把每条 step 的 diff可能含函数序列化成字符串其余字段原样保留
// 因此整份快照可直接按对象写入 IndexedDB结构化克隆避免序列化整份快照的开销读取时再用 parseDSL 还原 diff。
const snapshot: PersistedHistoryState = {
version: PERSIST_VERSION,
pageId: this.state.pageId,
@ -451,9 +453,7 @@ class History extends BaseService {
savedAt: Date.now(),
};
// 历史记录里可能包含函数(如代码块内容 / 节点事件 / 数据源方法IndexedDB 的结构化克隆无法写入函数,
// 因此用 serialize-javascript 序列化成字符串后再写入(支持函数 / Map 等),读取时用 parseDSL 还原。
await idbSet(this.resolveDbName(dbName, appId), storeName, key, serialize(snapshot));
await idbSet(this.resolveDbName(dbName, appId), storeName, key, snapshot);
this.emit('save-to-indexed-db', snapshot);
return snapshot;
}
@ -469,16 +469,14 @@ class History extends BaseService {
public async restoreFromIndexedDB(options: HistoryPersistOptions = {}): Promise<PersistedHistoryState | null> {
const { dbName = DEFAULT_DB_NAME, storeName = DEFAULT_STORE_NAME, key = DEFAULT_KEY, appId } = options;
const raw = await idbGet<string | PersistedHistoryState>(this.resolveDbName(dbName, appId), storeName, key);
if (!raw) return null;
// 新版以序列化字符串存储(含函数),用 parseDSL 还原;兼容历史上以对象形式存入的旧数据。
const snapshot = (typeof raw === 'string' ? getEditorConfig('parseDSL')(`(${raw})`) : raw) as PersistedHistoryState;
const snapshot = await idbGet<PersistedHistoryState>(this.resolveDbName(dbName, appId), storeName, key);
if (!snapshot) return null;
this.state.pageSteps = deserializeStacks(snapshot.pageSteps);
this.state.codeBlockState = deserializeStacks(snapshot.codeBlockState);
this.state.dataSourceState = deserializeStacks(snapshot.dataSourceState);
// 各 step 的 diff 以序列化字符串存储(含函数),由 deserializeStacks 逐条用 parseDSL 还原。
const parseDSL = getEditorConfig('parseDSL');
this.state.pageSteps = deserializeStacks(snapshot.pageSteps, parseDSL);
this.state.codeBlockState = deserializeStacks(snapshot.codeBlockState, parseDSL);
this.state.dataSourceState = deserializeStacks(snapshot.dataSourceState, parseDSL);
// initial 基线作为页面栈 index 0 的 step 随 pageSteps 一并还原,无需单独恢复。
this.state.pageId = snapshot.pageId;

View File

@ -1189,7 +1189,11 @@ export type CustomContentMenuFunction = (
) => (MenuButton | MenuComponent)[];
export interface EditorEvents {
'root-change': [value: StoreState['root'], preValue?: StoreState['root']];
'root-change': [
value: StoreState['root'],
preValue?: StoreState['root'],
options?: { historySource?: HistoryOpSource },
];
select: [node: MNode | null];
add: [nodes: MNode[]];
remove: [nodes: MNode[]];

View File

@ -17,6 +17,7 @@
*/
import { cloneDeep } from 'lodash-es';
import serialize from 'serialize-javascript';
import type { Id } from '@tmagic/core';
import type { ChangeRecord } from '@tmagic/form';
@ -234,11 +235,25 @@ export const detectPageTargetName = (step: StepValue): string | undefined => {
return undefined;
};
/** 把 `Record<Id, UndoRedo>` 整体序列化为 `Record<Id, SerializedUndoRedo>`。 */
export const serializeStacks = <T>(stacks: Record<Id, UndoRedo<T>>) => {
/**
* `Record<Id, UndoRedo>` `Record<Id, SerializedUndoRedo>`
*
* step `diff` serialize-javascript
* uuid / opType / timestamp / `modifiedNodeIds` Map IndexedDB
* {@link parseStacksStepDiff} diff
* `diff`
*/
export const serializeStacks = <T extends { diff?: unknown }>(stacks: Record<Id, UndoRedo<T>>) => {
const result: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {};
Object.entries(stacks).forEach(([id, undoRedo]) => {
if (undoRedo) result[id] = undoRedo.serialize();
if (!undoRedo) return;
const serialized = undoRedo.serialize();
result[id] = {
...serialized,
elementList: serialized.elementList.map((step) =>
step.diff === undefined ? step : Object.assign({}, step, { diff: serialize(step.diff) }),
),
};
});
return result;
};
@ -246,15 +261,27 @@ export const serializeStacks = <T>(stacks: Record<Id, UndoRedo<T>>) => {
/**
* `Record<Id, SerializedUndoRedo>` `Record<Id, UndoRedo>`
* `saved === true`
*
* {@link serializeStacks} `parse`parseDSL step `diff`
* `diff`
*/
export const deserializeStacks = <T extends { saved?: boolean }>(
stacks: Record<Id, ReturnType<UndoRedo<T>['serialize']>> = {},
parse?: (serialized: string) => unknown,
): Record<Id, UndoRedo<T>> => {
const result: Record<Id, UndoRedo<T>> = {};
Object.entries(stacks).forEach(([id, serialized]) => {
if (serialized) {
result[id] = UndoRedo.fromSerialized<T>(serialized, { isSavedStep: (element) => element.saved === true });
}
if (!serialized) return;
const elementList = parse
? serialized.elementList.map((step) => {
const { diff } = step as { diff?: unknown };
return typeof diff === 'string' ? Object.assign({}, step, { diff: parse(`(${diff})`) }) : step;
})
: serialized.elementList;
result[id] = UndoRedo.fromSerialized<T>(
{ ...serialized, elementList },
{ isSavedStep: (element) => element.saved === true },
);
});
return result;
};

View File

@ -123,17 +123,23 @@ describe('history service - clear', () => {
});
describe('history service - IndexedDB 持久化', () => {
test('saveToIndexedDB 以序列化字符串写入并返回快照对象', async () => {
test('saveToIndexedDB 以对象写入(仅 step.diff 序列化成字符串)并返回快照对象', async () => {
history.changePage({ id: 'p1' } as any);
history.push(pageStep());
history.push({ ...pageStep(), diff: [{ newSchema: { id: 'n1', name: '节点' } }] } as any);
const snapshot = await history.saveToIndexedDB();
expect(snapshot.version).toBe(1);
expect(snapshot.version).toBe(2);
expect(snapshot.pageId).toBe('p1');
// 实际写入 IndexedDB 的是字符串serialize-javascript 结果)
// 实际写入 IndexedDB 的是对象(交给结构化克隆),仅每条 step 的 diff 被序列化成字符串
expect(indexedDb.idbSet).toHaveBeenCalled();
const written = (indexedDb.idbSet as any).mock.calls[0][3];
expect(typeof written).toBe('string');
expect(typeof written).toBe('object');
expect(typeof written.pageSteps.p1.elementList[0].diff).toBe('string');
// diff 之外的字段(如 modifiedNodeIds Map原样交给结构化克隆不被字符串化
expect(written.pageSteps.p1.elementList[0].modifiedNodeIds instanceof Map).toBe(true);
// 返回的快照即写入 IndexedDB 的持久化形态diff 已是序列化字符串
expect(written).toBe(snapshot);
expect(typeof snapshot.pageSteps.p1.elementList[0].diff).toBe('string');
});
test('restoreFromIndexedDB 还原页面 / 代码块 / 数据源全部栈与游标', async () => {