使用命令模式实现编辑器导航(前进、后退、跳转)

179 阅读6分钟

新人前端,有不足之处请多多指教。

仓库地址:[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:操作类型,包括addremovereplacemovecopy等。
  • path:操作路径,用于指定操作的目标位置。
  • from:源路径,用于指定movecopy操作的源位置。
  • value:操作值,用于指定addreplace操作的值。

op的实际含义大部分是显而易见的,这里主要重点关照在数组的新增与删除操作上:

  1. 在数组当中执行新增操作时,path表示新增的位置,数组后面的元素会依次向后移动。
  2. 在数组当中执行删除操作时,path表示删除的位置,数组后面的元素会依次向前移动。

在本次实现中,我们使用的命令与JSON Patch规范有一定区别,主要在于:

  1. path采用JSON Path,而不是JSON Pointer。这样做有利于使用lodash get/set等工具函数。
  2. 命令新增字段description,用于描述命令的作用。

在本次实现中,导航中途新建操作采用的是清空后面的操作。

实现思路

测试页面搭建

使用pnpm create vite快速创建一个Vite项目,使用ant design vue作为UI组件库。编辑器部分使用单层动态表单实现。页面结构如下:

img1.png

页面使用方法:

  • 左侧上方选择输入框类型,点击新增一行新增该类型的输入框。
  • 左侧下方为动态表单部分,点击每一行右侧的删除、上移、下移可以执行对应操作。
  • 右侧为编辑器历史部分。右侧上方为前进、后退按钮,右侧下方为历史记录,通过点击每一行右侧跳转按钮可跳转到对应的编辑器状态。

导航功能使用设计

const [schema, navigator, queue, current] = createNavigation<{
  id: string
  component: string
  props: any
}>([])

通过createNavigation函数创建导航功能,返回一个包含schemanavigatorqueuecurrent四个变量的数组。其中:

  • 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的操作方法主要包括addremovereplacecopymove五种操作,但实际上原子操作只有addremove,其他操作都可以通过addremove组合实现。

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
}

在进行导航功能实现中,添加了两个新的栈redoQueueundoQueue用以实现命令记录。 在每次schema操作中,都会生成一条命令和其对应的撤销命令,分别存储在这两个栈中。在对栈和索引更新完成后,调用applyOp方法对schema进行操作。 在跳转方法中,根据当前索引和目标索引的大小关系,从对应的栈中取出命令,然后调用batch方法对schema进行操作。

checkIndex用于检查是否实在导航途中,如果是,则清空后面的操作。 incIndexcurrentIndex索引自增。

这样次一个最基础的导航功能就实现了,可以实现基本的前进、后退、跳转功能。

可以改进的地方

支持更复杂的操作

例如交换两个元素的位置,可以通过两次move操作实现。因此在后续优化中,可以将每条历史记录支持多个操作,然后统一调用batch方法进行处理。

命令结构优化

JSON Patch 中的 path可以从string改为string[],免于每次解析与拼接

总结

在实际编辑器项目中,页面数据结构通常是更为复杂的树形结构,在面对这种更复杂的数据结构时,可能需要考虑的点是:

  • 路径系统:确定每个组件所在的路径。
  • 调用方式:可以采取配置化方式,或者直接调用navigator对象的方法。

参考资料