Vue3与Immer双剑合璧: 打造高性能可视化拖拽编辑器历史记录解决方案

5,066 阅读11分钟

在图形编辑器、低代码等等交互式项目中,历史记录是一项非常的重要的功能,它允许用户查看并回溯到之前的任一操作,同时也可以前进到任意操作,从而提供更好的用户体验。在实现历史记录功能时,我们通常会遇到一个关键问题:如何高效、便捷地管理和保存编辑器的状态变化?

在过去的实现中,我们常常使用全量深拷贝或按需深拷贝的方式来保存编辑器的历史状态。全量深拷贝是指将整个编辑器状态对象完全复制一份,而按需深拷贝则是只复制发生变化的部分。

下面我们先来看看这两种方式的实现及优缺点。

全量深拷贝

全量深拷贝的优点在于简单直观,每次保存历史记录时只需将整个状态对象复制一份即可。然而,这种方式会形成数据冗余,消耗大量的内存空间,尤其是在编辑器状态较为复杂的情况下,会导致程序性能下降。

以下是全量深拷贝常见的实现方式:

snapshotData: []
snapshotIndex: -1
        
undo(state) {
    if (state.snapshotIndex >= 0) {
        state.snapshotIndex--
        store.commit('setComponentData', cloneDeep(state.snapshotData[state.snapshotIndex]))
    }
},

redo(state) {
    if (state.snapshotIndex < state.snapshotData.length - 1) {
        state.snapshotIndex++
        store.commit('setComponentData', cloneDeep(state.snapshotData[state.snapshotIndex]))
    }
},

setComponentData(state, componentData = []) {
    Vue.set(state, 'componentData', componentData)
},

recordSnapshot(state) {
    // 添加新的快照
    state.snapshotData[++state.snapshotIndex] = cloneDeep(state.componentData)
    // 如若之前撤销过,当新增快照时,清空它之后的快照
    if (state.snapshotIndex < state.snapshotData.length - 1) {
        state.snapshotData = state.snapshotData.slice(0, state.snapshotIndex + 1)
    }
}

按需深拷贝

相比之下,按需深拷贝可以更加高效地管理编辑器的状态变化。它只复制发生变化的部分,从而减少了内存消耗。然而,按需深拷贝的实现相对复杂,需要额外的逻辑来跟踪状态的变化,并确定哪些部分需要进行深拷贝。业界常用的实现方式就是双栈记录法,核心在于6字:

  • 注册,即注册我们需要记录的操作,比如添加组件,删除组件等等,同时提供undo与redo。
  • 记录,记录操作前后相关数据(按需深拷贝就发生在这里),供注册的undo与redo消费。
  • 双栈,即使用undoStack与redoStack分别记录撤销记录与重做记录。在用户撤销时,从undoStack栈顶取出一条记录,根据记录的操作类型找到注册的undo并执行。重做同理。

双栈记录法

class HistoryManager {
    constructor() { 
        this.undoStack = [];
        this.redoStack = [];
        this.types = {};
    }
    // 注册
    registerType(type, undo, redo) {
        this.types[type] = { undo, redo };
    }
    // 记录
    record(type, props) {
        this.undoStack.push({
            type,
            props,
        });
        // 如若之前撤销过,当新增记录时,清空redoStack
        this.redoStack = [];
    }
    // 撤销
    undo() {
        const undoAction = this.undoStack.pop();
        if (!undoAction) {
            return;
        }
        
        if(this.types[undoAction.type]) {
            this.types[undoAction.type].undo(undoAction.props);
            this.redoStack.push(undoAction);
        }
    }
    // 重做
    redo() {
        const redoAction = this.redoStack.pop();
        if (!redoAction) {
            return;
        }

        if(this.types[redoAction.type]) {
            this.types[redoAction.type].redo(redoAction.props);
            this.undoStack.push(redoAction);
        }
    }
}

以下仅是演示如何为“添加组件”这一操作添加历史记录:

用法

注册

// 注册
const undoAdd = ({prevInfos}) => {
    editorStore.components = cloneDeep(prevInfos.components);
};
const redoAdd = ({nextInfos}) => {
    editorStore.components = cloneDeep(nextInfos.components);
};
onMounted(() => {
    historyManager.registerType("add", undoAdd, redoAdd);
})

记录

// 记录:在添加组件时记录前后数据
const addComponent = () => {
    const newComponent = {...};

    // add history
    historyManager.record("add", {
        prevInfos: {
            components: cloneDeep(editorStore.components),
        },
        nextInfos: {
            components: cloneDeep([
                ...editorStore.components,
                newComponent,
            ])
        }
    });

    editorStore.components.push(newComponent);
};

可以看到,这种方式虽然较为通用,但需要自行提供undo与redo方法追踪状态变化。 下面我将介绍一种高效、优雅的解决方案,它不仅提供了更好的性能和响应速度,还能够简化代码逻辑,减少资源消耗。

不可变数据

不可变数据是指一旦创建就无法修改的数据。任何对数据的修改都会返回一个新的对象或值,而不会改变原始数据。常见的实现不可变数据的方法有对象的Object.assign()、扩展运算符(...)等,数组的concat()、slice()、扩展运算符(...)等。

Immer及其核心用法简介

Immer 是一个专注于不可变数据的库,它可以帮助我们高效便捷地实现不可变数据,同时提供了新老数据的补丁以及如何应用这些补丁回溯状态,因此,我们不再需要深拷贝,也不再需要自行实现undo与redo了。

我们来看下Immer的核心用法

import { produce } from "immer"

const baseState = [  
    {  
        title: "Learn TypeScript",  
        done: true  
    },  
    {  
        title: "Try Immer",  
        done: false  
    }  
]

const nextState = produce(baseState, (draft) => {
    // 不对draft进行任何操作
})
console.log(nextState === baseState) // true

const nextState = produce(baseState, (draft) => {
    draft[1].done = true
})
console.log(nextState === baseState) // false
console.log(nextState[0] === baseState[0]) // true

produce是immer的核心方法,它接收一个原始状态baseState以及一个可用于对传入的 draft 进行所有所需更改的recipe回调函数。其中,draft是原始状态的代理(底层使用Proxy实现),用户对其进行的修改,都会被setter拦截到,从而将修改状态反映到nextState中,同时保留了baseState中未发生改变的部分,此处即baseState[0]。

immer-4002b3fd2cfd3aa66c62ecc525663c0d.png

以上图片描述了Immer的工作原理,底层实现原理非常巧妙,精简的说就是从被修改位置层层往上进行浅拷贝,我会在下文将其简单还原一下。

为了获取更新补丁,我们需要借助另一个方法produceWithPatches

import { enablePatches, produceWithPatches } from "immer";

enablePatches();

const [nextState, patches, inversePatches] = produceWithPatches(
    {
        age: 33
    },
    draft => {
        draft.age++
    }
)

Immer v6版本之后需要在程序启动时调用enablePatches开启对Patches的支持。

produceWithPatches除了产出nextState之外,还包括重做补丁patches以及撤销补丁inversePatches,它们3者看起来就像下面这样:

;[
    {
        age: 34
    },
    [
        {
            op: "replace",
            path: ["age"],
            value: 34
        }
    ],
    [
        {
            op: "replace",
            path: ["age"],
            value: 33
        }
    ]
]

此外,Immer提供了applyPatches 帮助回溯补丁:

// 撤销
nextState = applyPatches(nextState, inverseChanges)

// 重做
nextState = applyPatches(nextState, patches)

可以看到,applyPatches就是类似于上文提及的注册时实现的undo与redo,inverseChanges与patches就是undo与redo需要的props:prevInfo与nextnfo。

将每次更新得到的inverseChanges与patches视为一个快照数据存入快照数据列表之中,同时提供一个当前快照指针,用于取出当前快照,并交给applyPatches回溯状态,这就是Immer实现历史记录的雏形。

Vue3中使用Immer实现历史记录

理解了基本用法之后,我们再来看看如何在Vue3中优雅的使用Immer实现历史记录。通常来说,一个完善的历史记录功能应该包括以下子功能:

  1. 提供按钮,单击撤销重做;
  2. 提供快捷键撤销重做(通常是ctrl+z与ctrl+shift+z);
  3. 提供记录面板,方便查看、跳跃式撤销重做;
  4. 支持记录最大数目及如何修剪;
  5. 其它和业务相关的奇异行为。

子功能1:提供按钮,单击撤销重做

以下是使用Immer实现历史记录功能中最基础、最核心的部分,其中有几点需要注意:

  1. shallowRef。这是浅响应的ref,因为我们不会去直接修改baseState内部状态。
  2. undoStack.value.length = pointer,指如若之前撤销过,当新增记录时,清空之后的记录。
  3. 有时我们并不需要立即启用历史记录,比如baseState是异步获取的,因此我加了一个开关undoable
import { ref, shallowRef } from 'vue'
import { applyPatches, produceWithPatches, enablePatches, Patch } from "immer"
// 启用补丁
enablePatches()

export interface UndoStackItem {
    patches: Patch[]
    inversePatches: Patch[]
}

export function useHistory<T>(baseState: T) {
    // 历史记录
    const undoStack = ref<UndoStackItem[]>([])
    // 当前索引
    const undoStackPointer = ref(-1)
    // 是否开启记录
    const undoable = ref(false)

    const state = shallowRef(baseState)
    const update = (updater: (draft: T) => any) => {
        const [nextState, patches, inversePatches] = produceWithPatches(
            state.value,
            updater
        )
        state.value = nextState

        if (undoable.value && (patches.length && inversePatches.length)) {
            const pointer = ++undoStackPointer.value
            undoStack.value.length = pointer
            undoStack.value[pointer] = {
                patches,
                inversePatches
            }
        }
    }

    function enable(value = true) {
        undoable.value = value
    }
    
    return {
        state, 
        update,
        enable
    }
}

下面为其添加undo与redo,核心就是根据指针undoStackPointer取出补丁集,如果是撤销则应用inversePatches;如果是重做则应用patches

同时提供2个计算属性isUndoDisableisRedoDisable用于控制按钮的可用性,

此外,还提供了undoCountredoCount表示可用的撤销/重做步数。

image.png

代码实现如下:

function undo() {
    if (undoStackPointer.value < 0) return;
    const patches = undoStack.value[undoStackPointer.value].inversePatches;
    state.value = applyPatches(state.value, patches);
    undoStackPointer.value--;
}

function redo() {
    if (undoStackPointer.value === undoStack.value.length - 1) return;
    undoStackPointer.value++;
    const patches = undoStack.value[undoStackPointer.value].patches;
    state.value = applyPatches(state.value, patches);
}

const isUndoDisable = computed(
    () => undoStackPointer.value < 0
);

const isRedoDisable = computed(
    () => undoStackPointer.value === undoStack.value.length - 1
);

const undoCount = computed(() => undoStackPointer.value + 1)

const redoCount = computed(
    () => undoStack.value.length - undoStackPointer.value - 1
)

现在,我们只需要为useHistory提供一个编辑器初始状态即可实现历史记录。 以下演示在pinia中使用useHistory:

// stores/componentsStore.ts

export const useComponents = defineStore('components', () => {
    let {
        state: components,
        update: updateComponents,
        undo,
        redo,
        undoStack,
        undoStackPointer,
        enable
    } = useHistory([]);
    
    // 同步启用
    // enable();
    
    // 异步启用
    async init() {
        const componentList = await fetchData();
        set(componentList);
        enable();
    }}
    
    function set(value) {
        components.value = value
    }

    return {
        components,
        updateComponents,
        undo,
        redo,
        set,
    }
})

子功能2:提供快捷键撤销重做

借助第三方库hotkeys-js注册相应快捷键,并调用undo与redo即可,非常简单,直接上代码:

import hotkeys from "hotkeys-js";

export const HOT_UNDO = "ctrl+z, command+z"
export const HOT_REDO = "ctrl+shift+z, command+shift+z"

export default function useHotkey() {
    const { undo, redo } = useComponents();
    
    function bindHotkeys() {
        hotkeys(HOT_UNDO, function (event) {
            event.preventDefault();
            undo()
        });

        hotkeys(HOT_REDO, function (event) {
            event.preventDefault();
            redo();
        });
    }
    
    function unbindHotkeys() {
        hotkeys.unbind(HOT_UNDO);
        hotkeys.unbind(HOT_REDO);
    }
    
    onMounted(bindHotkeys);
    onBeforeMount(unbindHotkeys);
}

子功能3:提供记录面板,方便查看、跳跃式撤销重做

实现历史记录面板

实现历史记录面板也很简单,首先,遍历undoStack展示每条记录,其次手动编写一个“打开”的记录,用于还原至最初状态。

<div @click="jumpTo(-1)">打开</div>
<div
    v-for="(item, index) in componentsStore.undoStack"
    :key="index"
    @click="jumpTo(index)"
>
    <span class="history-item-index">{{ index + 1 }}.</span>
    <span>{{ item.action }}</span>
</div>

请注意,此处的action是除了patches与investPatches额外扩展的字段(上文未实现),表示当前具体操作名称,比如新增图片,移动等。 你可以在updateComponents时传入具体的名称,或根据修改的属性及值生成,生成策略完全在开发者。

image.png

实现跳跃式撤销重做

回到useHistory,实现jumpTo的关键在于收集undoStackPointer到index之间的补丁集合,一起applyPatches。如果index大于undoStackPointer,说明是重做操作,需要收集所有patches;如果index小于undoStackPointer,说明是撤销操作,需要收集所有inversePatches;如果相等,则直接return。

export function useHistory<T>(baseState: T) {
    function jumpTo(index: number) {
        const res: Patch[] = []
        
        if (index === undoStackPointer.value) {
            return
        } else if (index < undoStackPointer.value) {
            for (let i = index + 1; i <= undoStackPointer.value; i++) {
                const element = undoStack.value[i];
                res.unshift(...element.inversePatches)
            }
        } else {
            for (let i = undoStackPointer.value + 1; i <= index; i++) {
                const element = undoStack.value[i];
                res.push(...element.patches)
            }
        }

        state.value = applyPatches(state.value, res)
        undoStackPointer.value = index
    }
    
    return {
        jumpTo
    }
}

子功能4:支持最大记录数目

支持最大记录数目,并非当记录数目到达最大值时直接return这么简单。

实现思路:当记录数目大于最大值时,shift数组第一项记为head,并将head的patches与inversePatches嫁接到shift后数组的第一项当中,实现如下:

const update = (updater: (draft: T) => any, action: string) => {
    const [nextState, patches, inversePatches] = produceWithPatches(
        state.value,
        updater
    )
    state.value = nextState

    if (undoable.value && (patches.length && inversePatches.length)) {
        const pointer = ++undoStackPointer.value
        undoStack.value.length = pointer
        undoStack.value[pointer] = {
            action,
            patches,
            inversePatches
        }
    }

    // 关键
    if (undoStack.value.length > max) {
        const head = undoStack.value.shift()
        const currentHead = undoStack.value[0]
        if (head && currentHead) {
            currentHead.inversePatches.push(...head.inversePatches)
            currentHead.patches.unshift(...head.patches)
            undoStackPointer.value = max - 1
        }
    }
}

其它和业务相关的奇异行为

奇异行为并非程序的bug,而是需要额外处理一些特殊场景。大部分情况下,useHistory都可以满足你的业务需求,但为了适配更复杂的业务场景,我们还是需要进一步封装。

举个例子:组件的新增有一个1s的缩放从0到1的过渡动画,如果重做过快,在过渡期间进入下一状态,而下一个状态所需的scale属性不是1,就会导致bug。因为过渡完成之后,组件的scale会变为1。

因此,我们需要在具体的业务代码中再次封装一下:

import { defineStore } from 'pinia'

export const useComponents = defineStore('components', () => {
    const {
        redo: _redo,
        isRedoDisable: _isRedoDisable,
        // ...
    } = useHistory([]);
    
    const isRedoDisable = computed(
        () => _isRedoDisable.value || siteStore.isTransiting;
    );

    const redo = () => {
        if (isUndoDisable.value) return
        _redo();
    };
    
    return {
        isRedoDisable,
        redo,
        // ...
    }
}

防抖

对于,需要实时预览的场景比如参数调节,可能需要频繁更新,因此可以考虑添加防抖的功能:

QQ20230819-001802.png

 const recordDelay = debounce(record, delay, {
    leading: true,
})

if (immediate) {
    record({ action, patches, inversePatches })
} else {
    recordDelay({ action, patches, inversePatches })
}

Immer的实现原理

回顾一下produce的用法,其中draft是baseState的一层代理。

const nextState = produce(baseState, (draft) => {
    draft[1].done = true
})

produce基本框架

根据用法我们不难写出produce的基本框架。

通过createProxy创建一个baseState的代理proxy,将其传给用户producer函数,用户会在producer函数中对proxy进行修改,这些修改会被proxy的setter拦截器捕获到,进而更新内部的proxyState状态。

最后,查看proxyState是否被修改过,是则返回setter为我们处理好的copy数据;否则返回最初的baseState。这也就是上文提到的,为什么nextState全等于baseState的原因。

const nextState = produce(baseState, (draft) => {
    // 不对draft进行任何操作
})
console.log(nextState === baseState) // true

produce基本框架代码如下:

const PROXY_STATE = Symbol("immer-proxy-state")

export function produce(baseState, producer) {
    const proxy = createProxy(baseState);
    producer(proxy);
    const proxyState = proxy[PROXY_STATE];
    const nextState = proxyState.modified ? proxyState.copy : baseState;
    // Object.freeze(nextState)
    return nextState
}

export function createProxy(baseState) {
    const proxyState = {
        baseState,
        copy: null, // 被修改过,需要返回的nextState
        modified: false, // 是否被修改过
    }
    return new Proxy(baseState, {
        get(target, key) {
            // 拿到内部的代理状态proxyState
            if (key === PROXY_STATE) {
                return proxyState;
            }
        },
        set(target, key, value) {
            // 更新proxyState
        }
    });
}

proxy的getter与setter

在getter中,为嵌套的对象进行懒代理;在setter中,修改proxyState。为了便于理解produce的工作过程,仅考虑普通对象的情况,实现如下:

export const isObject = (target) => Object.prototype.toString.call(target) === '[object Object]';

const proxies = new Map()

get(target, key) {
    // ..
    const value = target[key];
    if (isObject(value)) {
        if (proxies.has(key)) return proxies.get(key);
        
        const p = createProxy(value)
        proxies.set(key, p);
        return p;
    }
},
set(target, key, value) {
    proxyState.modified = true;
    proxyState.copy = { ...proxyState.baseState }
    // 更新
    proxyState.copy[key] = value;
    return true;
}

子节点被修改通知上层节点修改proxyState

这也是最重要的一步,子节点通知上层节点,可以借鉴provide/inject,子组件要修改父组件提供的响应式数据foo,最好的做法是父组件同时提供一个修改函数UpdateFoo。回到getter,我们在递归函数createProxy中传入一个nodify函数,在孩子节点被修改时同步调用通知父级。

export function createProxy(baseState, notify) {
    // ...
    return new Proxy(baseState, {
        get(target, key) {
            // ...
            const value = target[key];
            if (is.isObject(value)) {
                if (proxies.has(key)) return proxies.get(key);

                const p = createProxy(value, () => {
                    // 孩子改变,父级也要改变。
                    proxyState.modified = true; 
                    proxyState.copy = { ...baseState };
                    
                    // 链接孩子
                    const proxyOfChild = proxies.get(key);
                    const { copy } = proxyOfChild[PROXY_STATE];
                    proxyState.copy[key] = copy; 
                    
                    notify && notify();
                })
                proxies.set(key, p);
                return p;
            }
        },
        set(target, key, value) {
            proxyState.modified = true;
            proxyState.copy = { ...proxyState.baseState }
            // 更新
            proxyState.copy[key] = value;
            notify && notify();
            return true;
        }
    });
}

最后我们来看看效果

<script type="module">
    import { produce } from "./tiny-immer.js"

    const baseState = {
        phase1: {
            title: "Learn HTML",
            done: true,
        },
        phase2: {
            "phase2-1": {
                title: "Learn CSS",
                done: false,
            },
            "phase2-2": {
                title: "Learn LESS",
                done: false,
            },
        }
    }

    const nextState = produce(baseState, draftState => {
        draftState.phase2['phase2-2'].done = true
    })
    
    baseState.phase2['phase2-2'].title = "哈哈哈哈" // 不会影响nextState
    
    // baseState.phase1.done = "111" // 会影响nextState

    console.log(baseState.phase1 === nextState.phase1); // true
</script>

我们可以通过下面两张图直观地看到前后状态的变化。

image.png image.png

小结

本文介绍了在实现历史记录功能时,全量深拷贝和按需深拷贝方式存在的一些局限性:全量深拷贝简单但数据冗余、浪费资源,按需深拷贝通用且一定程度减少了内存和资源的消耗但实现过程较为复杂。

进而介绍了如何使用immer库实现的不可变数据,摒弃了深拷贝,不仅提供了更好的性能和响应速度,还能够简化代码逻辑,减少资源消耗。

最后,简单实现了immer的核心函数produce。

其中最重要的还是理解其递归套路,即如何让子节点记住上层节点的路径,这真的太有用咯,你会在大部分组件库的tree组件里看到它。其实以上传递函数只是一种形态而已,感兴趣可以去看看immer源码里使用的另一种形态,地址见附录。

附录

线上 stackblitz 调试/预览地址(安装依赖过程可能会有点慢)

历史记录demo vue3-immer-history

tiny-immer produce实现原理

参考 Immer@v0.6.0