前言:本篇文章来分析一下PPTist中实现历史记录功能的代码,包括快照的数据结构、主要方法 回退、重做 以及实现的思路
一、快照的定义
定义在 src/store/snapshot.ts 中,使用的是 pinia 状态管理库,和Vuex挺像的,但是要更简洁一下
- State
存储原始数据
需要响应式的数据
全局共享的数据 - Getters
需要根据 state 计算的值
多处使用的计算逻辑
需要缓存的计算结果 - Actions
业务逻辑封装
异步操作
多个状态修改的组合
分别看一下它的几个属性
1、state
① snapshotCursor
历史快照指针,表示当前快照的位置,初始值为 -1,使用示例:
bash
初始状态:
snapshotCursor = -1
snapshots = []
添加快照A:
snapshotCursor = 0
snapshots = [A]
添加快照B:
snapshotCursor = 1
snapshots = [A, B]
添加快照C:
snapshotCursor = 2
snapshots = [A, B, C]
执行撤销:
snapshotCursor = 1
snapshots = [A, B, C] // 回到B的状态
执行重做:
snapshotCursor = 2
snapshots = [A, B, C] // 回到C的状态
在B的位置添加新快照D:
snapshotCursor = 2
snapshots = [A, B, D] // C被丢弃
② snapshotLength
历史快照的长度
重做的时候,需要判断当前指针的位置和snapshotLength
的关系,如果当前指针在最后一个操作了,即等于 snapshotLength-1
,就不能重做
2、getters
类似于计算属性,包含两个属性
① canUndo
能否执行撤销操作
ts
canUndo(state) {
// 当指针大于0时,表示可以撤销
return state.snapshotCursor > 0
},
② canRedo
能否执行重做操作
typescript
canRedo(state) {
// 当指针小于最后一个位置时,表示可以重做
return state.snapshotCursor < state.snapshotLength - 1
},
3、actions
① initSnapshotDatabase
初始化快照
typescript
async initSnapshotDatabase() {
// 初始化快照数据库
const slidesStore = useSlidesStore()
// 创建第一个快照
const newFirstSnapshot = {
index: slidesStore.slideIndex,
slides: slidesStore.slides,
}
// 将第一个快照添加到数据库中
await db.snapshots.add(newFirstSnapshot)
// 设置快照指针为0,表示当前快照在历史快照中的位置
this.setSnapshotCursor(0)
// 设置历史快照长度为1,表示当前快照在历史快照中的位置
this.setSnapshotLength(1)
},
② addSnapshot
添加快照
这里有一个类似于浏览器实现回退和前进的逻辑
想象一下,假设当前的浏览过的页面的快照列表是 [A、B、C],也就是说我们依次访问了A、B、C这三个页面,并且当前位置在C页面。此时我们回退两次,到A页面,然后再跳往D页面,那么在历史记录中,B、C的痕迹就会被抹杀,点击回退会到A页面,再点击前进会到D页面,B、C是无法通过回退和前进到达的彼岸。
快照的实现逻辑也是类似的,就是说我们在添加快照的时候,要关注当前的指针的位置,指针后面的快照都要舍弃,在当前的指针的位置的后面添加一个新快照。
另外快照保存的数量限制在20条
typescript
async addSnapshot() {
const slidesStore = useSlidesStore()
// 获取当前indexeddb中全部快照的ID
const allKeys = await db.snapshots.orderBy('id').keys()
let needDeleteKeys: IndexableTypeArray = []
// 记录需要删除的快照ID
// 若当前快照指针不处在最后一位,那么再添加快照时,应该将当前指针位置后面的快照全部删除,对应的实际情况是:
// 用户撤回多次后,再进行操作(添加快照),此时原先被撤销的快照都应该被删除
if (this.snapshotCursor >= 0 && this.snapshotCursor < allKeys.length - 1) {
needDeleteKeys = allKeys.slice(this.snapshotCursor + 1)
}
// 添加新快照
const snapshot = {
index: slidesStore.slideIndex,
slides: slidesStore.slides,
}
await db.snapshots.add(snapshot)
// 计算当前快照长度,用于设置快照指针的位置(此时指针应该处在最后一位,即:快照长度 - 1)
let snapshotLength = allKeys.length - needDeleteKeys.length + 1
// 快照数量超过长度限制时,应该将头部多余的快照删除
const snapshotLengthLimit = 20
if (snapshotLength > snapshotLengthLimit) {
needDeleteKeys.push(allKeys[0])
snapshotLength--
}
// 批量删除
await db.snapshots.bulkDelete(needDeleteKeys)
// 更新指针
this.setSnapshotCursor(snapshotLength - 1)
this.setSnapshotLength(snapshotLength)
},
③ unDo
撤销操作,需要先判断指针的位置,如果指针小于等于0,说明操作已经被撤销完了,木有退路,退不可退,直接返回
然后如果可以撤销的话,指针需要向前挪动一位,然后获取此时的快照数据,赋值给幻灯片
typescript
async unDo() {
// 如果快照指针小于0,表示没有快照可以撤销
if (this.snapshotCursor <= 0) return
const slidesStore = useSlidesStore()
const mainStore = useMainStore()
const snapshotCursor = this.snapshotCursor - 1
const snapshots: Snapshot[] = await db.snapshots.orderBy('id').toArray()
const snapshot = snapshots[snapshotCursor]
const { index, slides } = snapshot
const slideIndex = index > slides.length - 1 ? slides.length - 1 : index
slidesStore.setSlides(slides)
slidesStore.updateSlideIndex(slideIndex)
this.setSnapshotCursor(snapshotCursor)
mainStore.setActiveElementIdList([])
},
④ redo
和撤销差不多的思路
typescript
async reDo() {
if (this.snapshotCursor >= this.snapshotLength - 1) return
const slidesStore = useSlidesStore()
const mainStore = useMainStore()
const snapshotCursor = this.snapshotCursor + 1
const snapshots: Snapshot[] = await db.snapshots.orderBy('id').toArray()
const snapshot = snapshots[snapshotCursor]
const { index, slides } = snapshot
const slideIndex = index > slides.length - 1 ? slides.length - 1 : index
slidesStore.setSlides(slides)
slidesStore.updateSlideIndex(slideIndex)
this.setSnapshotCursor(snapshotCursor)
mainStore.setActiveElementIdList([])
},
其中 db.snapshots
的 id
是数据库自己实现的自增id
二、通过防抖节流包装历史记录操作
src/hooks/useHistorySnapshot.ts 文件使用防抖和节流包装历史记录的操作,节约性能,另外提供统一的接口实现历史记录操作。可以看到,其中的防抖和节流使用的是lodash
库提供的方法,这也是我们在实际编程中可以借鉴的一个技巧
typescript
import { debounce, throttle} from 'lodash'
import { useSnapshotStore } from '@/store'
export default () => {
const snapshotStore = useSnapshotStore()
// 防抖 (debounce):
// 当持续触发事件时,一定时间段内没有再触发事件,事件才会执行一次
// 如果设定的时间到来之前,又一次触发了事件,就重新开始延时
const addHistorySnapshot = debounce(function() {
snapshotStore.addSnapshot()
}, 300, { trailing: true }) // 300ms 内的连续调用,只执行最后一次
// 节流 (throttle):
// 当持续触发事件时,保证一定时间段内只调用一次事件处理函数
const redo = throttle(function() {
snapshotStore.reDo()
}, 100, { leading: true, trailing: false }) // 100ms 内最多执行一次
const undo = throttle(function() {
snapshotStore.unDo()
}, 100, { leading: true, trailing: false }) // 100ms 内最多执行一次
return {
addHistorySnapshot,
redo,
undo,
}
}
三、给操作添加快照
所有的要记录到历史记录的操作,在执行操作的时候都必须手动调用 addHistorySnapshot
方法
typescript
import useHistorySnapshot from '@/hooks/useHistorySnapshot'
const { addHistorySnapshot } = useHistorySnapshot()
// 在执行操作的地方添加
addHistorySnapshot()
hooks
文件夹中的操作都有执行这个方法,确保所有操作都是可以撤销或重做的。所以在添加新功能的时候,也不要忘记使用这个方法记录操作快照哦🐸