新人前端,有不足之处请多多指教。
仓库地址:[editor-navigation](gitee.com/akahsi/web-…
目前编辑器的导航功能(前进、后退、跳转)主流有两种实现方式:
深拷贝:每次导航时,将当前编辑器的状态深拷贝一份,然后在新的状态上进行操作,这样就可以实现前进、后退、跳转等功能。
命令模式:将每次操作抽象成一个命令对象,然后将命令对象存储在一个栈中,每次导航时,从栈中取出命令对象执行。
深拷贝实现相对简单,但内存消耗较大,性能较差(重复渲染、重计算);而命令模式实现则相对复杂,但内存消耗较小,性能较好。本文将介绍如何使用命令模式实现编辑器导航功能。
JSON Patch
在介绍命令模式之前,我们先来了解一下JSON Patch。JSON Patch是一种用于描述JSON文档之间差异的格式,它是RFC 6902规范中定义的。JSON Patch的格式如下:
[
{ "op": "add", "path": "/a/b/c", "value": 1 },
{ "op": "remove", "path": "/a/b/c" },
{ "op": "replace", "path": "/a/b/c", "value": 2 },
{ "op": "move", "from": "/a/b/c", "path": "/a/b/d" },
{ "op": "copy", "from": "/a/b/c", "path": "/a/b/e" }
]
JSON Patch由一系列操作组成,每个操作包含以下字段:
op:操作类型,包括add、remove、replace、move、copy等。path:操作路径,用于指定操作的目标位置。from:源路径,用于指定move、copy操作的源位置。value:操作值,用于指定add、replace操作的值。
op的实际含义大部分是显而易见的,这里主要重点关照在数组的新增与删除操作上:
- 在数组当中执行新增操作时,path表示新增的位置,数组后面的元素会依次向后移动。
- 在数组当中执行删除操作时,path表示删除的位置,数组后面的元素会依次向前移动。
在本次实现中,我们使用的命令与JSON Patch规范有一定区别,主要在于:
- path采用JSON Path,而不是JSON Pointer。这样做有利于使用lodash get/set等工具函数。
- 命令新增字段
description,用于描述命令的作用。
在本次实现中,导航中途新建操作采用的是清空后面的操作。
实现思路
测试页面搭建
使用pnpm create vite快速创建一个Vite项目,使用ant design vue作为UI组件库。编辑器部分使用单层动态表单实现。页面结构如下:
页面使用方法:
- 左侧上方选择输入框类型,点击新增一行新增该类型的输入框。
- 左侧下方为动态表单部分,点击每一行右侧的删除、上移、下移可以执行对应操作。
- 右侧为编辑器历史部分。右侧上方为前进、后退按钮,右侧下方为历史记录,通过点击每一行右侧跳转按钮可跳转到对应的编辑器状态。
导航功能使用设计
const [schema, navigator, queue, current] = createNavigation<{
id: string
component: string
props: any
}>([])
通过createNavigation函数创建导航功能,返回一个包含schema、navigator、queue、current四个变量的数组。其中:
schema:动态表单的数据结构。navigator:导航对象,提供对schema的操作方法,以及导航方法(前进、后退、跳转)。queue:命令队列,存储每次操作的命令。该数据用来显示历史记录。current:当前命令的索引。该数据用来实现当前在历史记录中的位置。
在修改动态表单结构中,采取不直接修改原数据的方式,而是通过navigator对象提供的方法进行操作。这样可以保证每次操作都会生成一个命令对象,存储在queue中,方便后续的导航操作。
function addField() {
navigator.add(String(schema.value.length), {
id: randomId(),
component: selectComponent.value,
props: data
}, `新增组件${selectComponent.name}`)
}
进行历史记录跳转时,使用navigator.jump等方法进行跳转。
<aside>
<div class="action">
<Button @click="navigator.undo()">撤销</Button>
<Button @click="navigator.redo()">重做</Button>
</div>
<template v-for="(task, index) in queue" :key="task.id">
<div
class="task"
:style="{
fontWeight: index === current ? 'bold' : 'normal'
}"
>
<div>{{ task.description }}</div>
<Button @click="navigator.jump(index)">跳转</Button>
</div>
</template>
</aside>
导航功能实现
首先定义JSON Patch类型
type OP = 'add' | 'remove' | 'replace' | 'copy' | 'move'
interface JsonPatch {
id: string
op: OP
path: string
from?: string
value?: any
description: string
}
导航功能的实现主要包括两部分:对schema的操作方法和导航方法。
对schema的操作方法
对schema的操作方法主要包括add、remove、replace、copy、move五种操作,但实际上原子操作只有add和remove,其他操作都可以通过add和remove组合实现。
export const createNavigation = <T>(initialValue: any) => {
const source = ref<T[]>(initialValue)
function addOp(obj: any, path: string, value: any) {
const { parent, key } = parsePath(path)
const parentObj = parent.length ? lodashGet(obj, parent) : obj
if (Array.isArray(parentObj)) {
parentObj.splice(Number(key), 0, value)
} else {
lodashSet(obj, path, value)
}
}
function removeOp(obj: any, path: string) {
const { parent, key } = parsePath(path)
const parentObj = parent.length ? lodashGet(obj, parent) : obj
if (Array.isArray(parentObj)) {
parentObj.splice(Number(key), 1)
} else {
delete lodashGet(obj, parent)[key]
}
}
function applyOp(obj: any, task: JsonPatch) {
if (task.op === 'add' || task.op === 'replace') {
addOp(obj, task.path, task.value)
}
if (task.op === 'remove') {
removeOp(obj, task.path)
}
if (task.op === 'move') {
const fromValue = lodashGet(obj, task.from!)
removeOp(obj, task.from!)
addOp(obj, task.path, fromValue)
}
if (task.op === 'copy') {
const fromValue = lodashGet(obj, task.from!)
addOp(obj, task.path, cloneDeep(fromValue))
}
}
const batch = (tasks: JsonPatch[]) => {
tasks.forEach(task => {
applyOp(source.value, task)
})
}
return [
source,
{ }
] as const
}
这里面逻辑需要注意的是要判断parentObj是否为数组,数组对象要通过splice方法进行操作。batch用于对多个操作进行批量处理,是用于实现后续导航功能的核心方法。
导航方法
export const createNavigation = <T>(initialValue: any) => {
// ...
const redoQueue = shallowRef<JsonPatch[]>([])
const undoQueue = shallowRef<JsonPatch[]>([])
const currentIndex = ref<number>()
const add = (path: string, value: any, description: string) => {
checkIndex()
const task: JsonPatch = { id: randomId(), op: 'add', path, value, description }
redoQueue.value.push(task)
undoQueue.value.push({ ...task, op: 'remove' })
incIndex()
applyOp(source.value, task)
}
// remove、replace、copy、move等方法类似
const jump = (jumpIndex: number) => {
if (jumpIndex === currentIndex.value) {
return
}
const tasks = jumpIndex > currentIndex.value!
? redoQueue.value.slice(currentIndex.value! + 1, jumpIndex + 1)
: undoQueue.value.slice(jumpIndex + 1, currentIndex.value! + 1).reverse()
batch(tasks)
currentIndex.value = jumpIndex
}
// undo、redo等方法类似
return [
source,
{
add,
remove,
replace,
move,
copy,
jump,
undo,
redo,
},
redoQueue,
currentIndex,
] as const
}
在进行导航功能实现中,添加了两个新的栈redoQueue和undoQueue用以实现命令记录。
在每次schema操作中,都会生成一条命令和其对应的撤销命令,分别存储在这两个栈中。在对栈和索引更新完成后,调用applyOp方法对schema进行操作。
在跳转方法中,根据当前索引和目标索引的大小关系,从对应的栈中取出命令,然后调用batch方法对schema进行操作。
checkIndex用于检查是否实在导航途中,如果是,则清空后面的操作。incIndexcurrentIndex索引自增。
这样次一个最基础的导航功能就实现了,可以实现基本的前进、后退、跳转功能。
可以改进的地方
支持更复杂的操作
例如交换两个元素的位置,可以通过两次move操作实现。因此在后续优化中,可以将每条历史记录支持多个操作,然后统一调用batch方法进行处理。
命令结构优化
JSON Patch 中的 path可以从string改为string[],免于每次解析与拼接
总结
在实际编辑器项目中,页面数据结构通常是更为复杂的树形结构,在面对这种更复杂的数据结构时,可能需要考虑的点是:
- 路径系统:确定每个组件所在的路径。
- 调用方式:可以采取配置化方式,或者直接调用
navigator对象的方法。