在 Vue3 中处理自动保存

Sam*_* B. 5 autosave typescript vue.js vuejs3 pinia

我有一个 Vue3 应用程序,我想传达一种特定类型的 UX,其中对资源所做的更改会自动保存(即没有保存按钮)。该应用程序有几种不同类型的可编辑资源和几个不同的编辑器组件,我想创建一个可以处理自动保存并且可以简单地插入我的编辑器组件内部的抽象。作为附加要求,某些字段必须去抖(例如文本字段),而其他字段则要立即保存到服务器。

我有兴趣了解使用我在这里提供的解决方案可能存在哪些缺点以及是否有更好的方法。

想法:

  • 创建一个多态类AutoSaveManager<T>来处理类型对象的自动保存T
  • 传递一个更新内存中本地对象的函数,该函数在该对象发生更改时调用(例如,绑定到该对象字段的输入元素发出更新)
  • 传递更新远程对象(即数据库中的记录)的函数。例如,进行 API 调用的 Pinia 操作
  • 将远程函数包装在需要去抖的字段的去抖器中,或者如果更新了无去抖字段,则立即刷新它。

类的代码:

/* eslint-disable @typescript-eslint/no-explicit-any */

import { debounce, DebouncedFunc } from "lodash";

type RemotePatchFunction<T> = (changes: Partial<T>) => Promise<void>;
type PatchFunction<T> = (changes: Partial<T>, reverting?: boolean) => void;
export type FieldList<T> = (keyof T)[];

enum AutoSaveManagerState {
    UP_TO_DATE,
    PENDING,
    ERROR,
}
export class AutoSaveManager<T> {
    instance: T;
    unsavedChanges: Partial<T>;
    beforeChanges: Partial<T>;
    remotePatchFunction: DebouncedFunc<RemotePatchFunction<T>>;
    localPatchFunction: PatchFunction<T>;
    errorFunction?: (e: any) => void;
    successFunction?: () => void;
    cleanupFunction?: () => void;
    debouncedFields: FieldList<T>;
    revertOnFailure: boolean;
    alwaysPatchLocal: boolean;
    state: AutoSaveManagerState;

    constructor(
        instance: T,
        remotePatchFunction: RemotePatchFunction<T>,
        localPatchFunction: PatchFunction<T>,
        debouncedFields: FieldList<T>,
        debounceTime: number,
        successFunction?: () => void,
        errorFunction?: (e: any) => void,
        cleanupFunction?: () => void,
        revertOnFailure = false,
        alwaysPatchLocal = true,
    ) {
        this.instance = instance;
        this.localPatchFunction = localPatchFunction;
        this.remotePatchFunction = debounce(
            this.wrapRemotePatchFunction(remotePatchFunction),
            debounceTime,
        );
        this.debouncedFields = debouncedFields;
        this.unsavedChanges = {};
        this.beforeChanges = {};
        this.successFunction = successFunction;
        this.errorFunction = errorFunction;
        this.cleanupFunction = cleanupFunction;
        this.revertOnFailure = revertOnFailure;
        this.alwaysPatchLocal = alwaysPatchLocal;
        this.state = AutoSaveManagerState.UP_TO_DATE;
    }

    async onChange(changes: Partial<T>): Promise<void> {
        this.state = AutoSaveManagerState.PENDING;

        // record new change to field
        this.unsavedChanges = { ...this.unsavedChanges, ...changes };

        // make deep copy of fields about to change in case rollback becomes necessary
        // (only for non-debounced fields as it would be disconcerting to roll back
        // debounced changes like in text fields)
        Object.keys(changes)
            .filter(k => !this.debouncedFields.includes(k as keyof T))
            .forEach(
                k => (this.beforeChanges[k] = JSON.parse(JSON.stringify(this.instance[k]))),
            );

        if (this.alwaysPatchLocal) {
            // instantly update in-memory instance
            this.localPatchFunction(changes);
        }

        // dispatch update to backend
        await this.remotePatchFunction(this.unsavedChanges);

        if (Object.keys(changes).some(k => !this.debouncedFields.includes(k as keyof T))) {
            // at least one field isn't to be debounced; call remote update immediately
            await this.remotePatchFunction.flush();
        }
    }

    private wrapRemotePatchFunction(
        callback: RemotePatchFunction<T>,
    ): RemotePatchFunction<T> {
        /**
         * Wraps the callback into a function that awaits the callback first, and
         * if it is successful, then empties the unsaved changes object
         */
        return async (changes: Partial<T>) => {
            try {
                await callback(changes);
                if (!this.alwaysPatchLocal) {
                    // update in-memory instance
                    this.localPatchFunction(changes);
                }
                // reset bookkeeping about recent changes
                this.unsavedChanges = {};
                this.beforeChanges = {};
                this.state = AutoSaveManagerState.UP_TO_DATE;

                // call user-supplied success callback
                this.successFunction?.();
            } catch (e) {
                // call user-supplied error callback
                this.errorFunction?.(e);

                if (this.revertOnFailure) {
                    // roll back unsaved changes
                    this.localPatchFunction(this.beforeChanges, true);
                }
                this.state = AutoSaveManagerState.ERROR;
            } finally {
                this.cleanupFunction?.();
            }
        };
    }
}
Run Code Online (Sandbox Code Playgroud)

此类将在编辑器组件内部实例化,如下所示:

            this.autoSaveManager = new AutoSaveManager<MyEditableObject>(
                this.modelValue,
                async changes => {
                    await this.store.someAction({ // makes an API call to the server to actually update the object
                        id: this.modelValue.id
                        changes,
                    });
                    this.saving = false;
                },
                changes => {
                    this.saving = true;
                    this.savingError = false;
                    this.store.someAction({ // only updates the local object in the store
                        id: this.modelValue.id
                        changes;
                    })
                },
                ["body", "title"], // these are text fields and need to be debounced
                2500, // debounce time
                undefined, // no cleanup function
                () => { // function to call in case of error
                    this.saving = false;
                    this.savingError = true;
                },
            );
Run Code Online (Sandbox Code Playgroud)

然后,在模板内部,您可以像这样使用它:

<TextInput
    :modelValue="modelValue.title"
    @update:modelValue="autoSaveManager.onChange({ title: $event })"
/>
Run Code Online (Sandbox Code Playgroud)

我可以做些什么来改进这个想法吗?我应该使用完全不同的方法来在 Vue 中自动保存吗?