撤销回退历史记录功能的思考

如果要实现一个历史记录功能,支持撤销和重做操作,例如正确地打印下面的代码,要如何实现呢

typescript
// test
const stack = createStack(1);
const print = () => console.log('current: ', stack.current(), ' canRedo: ', stack.canRedo(), ' canUndo: ', stack.canUndo());
console.log('stack created with 1:');
print(); // current 1, canRedo: false, canUndo: false
console.log('stack save 2:');
stack.save(2);
print(); // current 2, canRedo: false, canUndo: true
console.log('stack undo:');
stack.undo();
print(); // current 1, canRedo: true, canUndo: false
console.log('stack save 3:');
stack.save(3);
print(); // current 3, canRedo: false, canUndo: true
console.log('stack undo:');
stack.undo();
print(); // current 3, canRedo: false, canUndo: true

首先想到的是使用两个数组作为记录栈和重做栈,每次undo操作,相当于从记录栈里取出最后一个放入重做栈,而每次redo就相当于从重做栈取出第一个放回到记录栈中,与undo正好相反,最后,调用save时需要清空重做栈,通过两个栈的长度来判断是否可以undo和redo,如下:

typescript
const createStack=<T>(init:T)=>{
    let INITIAL=[init]
    let stack=[...init]
    let redoStack=[]


    const canUndo=()=>stack.length>INITIAL.length

    const canRedo=()=>redoStack.length>0

    const undo=()=>{
        if(!canUndo())return
        const latest=stack.pop();
        if(latest)redoStack.push(latest)
    }

    const redo=()=>{
        if(!canRedo())return
        const latest=redoStack.pop();
        if(latest)stack.push(latest)
    }

    const save=(v:T)=>{
        redoStack=[]
        stack.push(v)
    }
    // 表示取stack最后一个元素
    const current=()=>stack.at(-1)

    return{
        undo,redo,canUndo,canRedo,save
    }
}

这样就是一个简单的历史记录功能的实现了,当然还有很多没有考虑的地方,例如没有使用深拷贝,会导致某些时候外部修改了current的引用时同时修改历史记录元素,不过这里只针对基本类型而非引用类型,基本逻辑还是完善的。如果需要提高泛用性,引入某个deepClone函数把每次操作都深拷贝一次就可以了。

这里使用到了两个数组来存储,还有一种更优的方法是使用一个数组加指针,指针指向最数组的末尾,每次undo或者redo相当于指针-1或者+1,而save则是裁剪数组指针的长度,再把新元素放到数组末尾,然后指针+1,整个逻辑也十分直观:

typescript
const createStack=<T>(init:T)=>{
    let INITIAL=[init]
    let stack=[...init]
    let cursor=stack.length-1

    const canUndo=()=> cursor>0

    const canRedo=()=> cursor<stack.length-1

    const undo=()=>{
        if(!canUndo())return
        cursor -= 1
    }

    const redo=()=>{
         if(!canRedo())return
        cursor += 1
    }


    const save=(v:T)=>{
        stack=stack.slice(0, cursor+1)
        cursor += 1
    }
    // 等同于 stack[cursor]
    const current=()=>stack.at(cursor)

    return{
        undo,redo,canUndo,canRedo,save
    }
}

这样就少用了一个数组,降低了一点空间复杂度,减少了一点push、pop的时间,但是提高了我们的代码优雅度(划掉)

有没有办法再优雅一点呢?这里还是用到了好几个可变变量,我们把它们去掉,换成纯函数的写法

typescript
const createStack=<T>(initialStack:T[])=>{
    const stack=[...initialStack]
    const cursor=stack.length-1
    const canUndo=()=> cursor > 0
    const canRedo=()=> cursor < stack.length-1
    return {
        current:()=> stack.at(cursor),
        undo:()=> canUndo() ? createStack(stack,cursor-1) : undefined,
        redo:()=> canRedo() ? createStack(stack,cursor+1) : undefined,
        save:()=> createStack(stack.slice(0,cursor+1),cursor-1),
        canUndo,
        canRedo
    }
}

是不是更加精致简洁了?不过这样会修改测试用例的调用方法,让我们对它做一个封装,在不修改测试用例的情况下保持兼容:

typescript
const _create=<T>(initialStack:T[])=>{
    const stack=[...initialStack]
    const cursor=stack.length-1
    const canUndo=()=> cursor > 0
    const canRedo=()=> cursor < stack.length-1
    return {
        current:()=> stack.at(cursor),
        undo:()=> canUndo() ? createStack(stack,cursor-1) : undefined,
        redo:()=> canRedo() ? createStack(stack,cursor+1) : undefined,
        save:()=> createStack(stack.slice(0,cursor+1)),
        canUndo,
        canRedo
    }
}

const createStack=<T>(init:T)=>{
    let stack=_create([init])
    return {
        current:()=>stack.current(),
        undo:()=>stack=stack.undo() ?? stack,
        redo:()=>stack=stack.redo() ?? stack,
        save:(v:T)=>stack=stack.save(v)
        canRedo:()=>stack.canRedo()
        canUndo:()=>stack.canUndo()
    }
}

虽然代码分成了两部分,好像更长了,不过我们用上了纯函数,整个代码健壮性又增加了(划掉) 如果考虑实用性,还需要对创建stack和调用current时做好深拷贝,还可以限制最大历史记录长度等等。