前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >基于 Vuex 的时移操作(撤回/恢复)实现

基于 Vuex 的时移操作(撤回/恢复)实现

作者头像
寒月十八
发布2021-09-29 15:51:42
1.2K0
发布2021-09-29 15:51:42
举报

最近做了一个 BI 平台的可视化看板编辑器,项目刚做完一期,各方面的功能都还能粗糙,但该有的也都有了,比如编辑器场景下最基本的两类时移操作-撤回(undo) 和恢复 (redo)。

用 vuex 实现的原理其实很简单,一句话就可以概括:维护一个 state快照 的历史记录数组和当前索引值, undo 和 redo 分别对应索引的回退(backward)的前移(forward)。

原理虽然简单,但代码实现还是要注意一些细节。

搭配源码@bugonly/vuex-undo-redo阅读口味更佳。

时间线不可逆

假设A为空白状态,依序进行以下操作:

  1. 新增一个组件1,进入状态B;
  2. 再次新增一个组件2,进入状态C;
  3. 执行undo操作,回退到状态B,组件2被清除,仅剩组件1;
  4. 新增一个组件3,进入状态D;
  5. 再次执行undo操作,回退到状态B,组件3被清除,仅剩一个组件1;
  6. 再次执行undo操作,组件1被清除,看板为空白状态,即状态A;
  7. 再次执行undo操作,提示无历史记录。

以上操作流程如下视频:

上述步骤中有争议的是步骤6,在测试过程中测试同事提出步骤6的表现应该是恢复到状态C,即组件2被恢复到看板中。如果是这样的话会发生以下问题:

  • 状态B的 undo 操作结果会有两种:状态 A 和状态 C;
  • 如何判断该什么时候回退到 A?什么时候回退到 C?
  • 从状态B undo 回退到 C,再次 undo 应该回退到哪个状态?按时间线的话应该是回退到 B,那么再次 undo 呢?死循环?

之所以对步骤6的结果有争议,根本原因是混淆了编辑行为和时移行为。时移行为 undo/redo 恢复的是上一步/下一步的编辑行为,而时移行为本身是不被记录在操作历史栈中的,也就是说, undo 行为本身不能被 undo ,redo 行为本身不能被 redo。否则就会造成时间线混乱,难以管理。

时间线不可逆这条规则在所有类型的可视化编辑器中都是统一的,比如在线文档、IDE等等,大家有兴趣可以亲自去验证一下。

行为分类

并不是所有行为都是可以撤回的,理论上应该只有编辑行为可撤回,其他的比如页签之间的切换等简单交互的行为虽然也是状态机驱动(此处留个扣子,下文细聊),但并没有支持撤回的必要性,如果所有状态都能撤回反而令编辑器不好用。

所以在设计技术方案时,需要对用户行为进行归类,最基本要有三类:

  • 支持撤回的行为;
  • 不支持撤回的行为;
  • 不支持撤回但是需要覆盖当前状态机快照的行为。

最后一种非常有必要,有些行为虽然本身不能撤回,但是在它之后的一些行为需要支持撤回,为了保持状态机的完整性,这类行为也必须记录下来,但是并不会作为一个独立的快照,而是覆盖当前快照。

举个例子。

  1. 页签1新增一个组件;
  2. 新增页签2;
  3. 页签2新增一个组件;
  4. 切换到页签1;
  5. 执行 undo,此时的表现是自动切换至页签2并且清除了页签2中的组件。

上述步骤中页签之间的切换行为就属于「不支持撤回但是需要覆盖当前状态机快照的行为」之一。在绝大多数交互场景中,页签之间的切换是没有必要使用 store 驱动的,往往是组件内部的状态机,上面示例之所以将它加入 store 就是为了实现视频中展示的 undo 自动切换页签效果。

这种方案比较简单有效,当然也有其他解决方案实现。

时移操作的作用域

这一点就很简单了,编辑器是应用的一个模块,在 vuex 中是 store 的一个 module,所以时移操作的插件函数在订阅 mutations 时需要判断 mutation-type,过滤非编辑器模块的 mutation。

const moduleFilterReg = new RegExp(`^${module}\/([a-zA-Z0-9\_]+)$`);
store.subscribe((mutation, state: Record<string, any>) => {
  let mutationType = mutation.type;

  if (moduleFilterReg){
    const match = moduleFilterReg.exec(mutation.type);
    // 过滤非指定模块的mutation
    if (!match) {
      return;
    }
    mutationType = match[1];
  }
  // ...其他逻辑
});

插件函数完整源码链接

总结以上内容,时移操作插件的完整配置项如下:

interface IUndoRedoConfig {
  /**
   * 模块名称
   * 如果指定模块则过滤此模块之外的所有 mutation
   */
  module?: string;
  /**
   * 不跟踪的 mutation-type 清单
   */
  noTraceMutationTypes?: string[];
  /**
   * 此列表中的 mutation-type 行为不跟踪,但是会覆盖当前历史记录
   */
  needReplaceMutationTypes?: string[];
  /**
   * 过滤器,返回 false 时不执行后续逻辑
   * 使用 filter 可以编写更复杂的过滤逻辑
   * @param mutation
   * @param state
   */
  filter?: (mutation: MutationPayload, state:Record<string, any>) => boolean;
  /**
   * 历史记录容量,最小值1
   */
  historyCapcity?: number;
}
页签域的时移操作如何实现?

最后留一个问题,这个问题我也暂时没想通最优解。目前市面上几乎所有的可视化编辑器都是这样的逻辑:时移操作的作用域的编辑器全局

如何理解这句话呢?比如上文提到的报告编辑器,undo/redo 操作是针对报告 scope的,而不是页签 scope。报告编辑器可能有些人比较陌生,类比一种更普遍的编辑器:Excel。

Excel 的每个工作表(sheet)相当于报告中的页签,你试着在excel中执行以下步骤:

  1. 在 sheet 1 中任意编辑一次;
  2. 新建一个 sheet 2;
  3. 在 sheet 2 中任意编辑一次;
  4. 执行一次 undo,表现为 sheet 2中的编辑被还原;
  5. 再执行一次 undo,表现为 sheet 2 被整体清除;
  6. 再执行一次 undo,表现为 sheet 1中的编辑被还原。

以上步骤可以看出,excel 的 undo 行为是针对 excel 文档 scope 的,而不是每个 sheet 的 scope。

那么假如我想实现每个 sheet 域的时移操作呢?具体表现为:

  • 每个 sheet 有单独的操作历史,互不影响;
  • sheet 不能被时移操作删除,只能手动删除。

其实有很多种解决方案,最简单的就是每个 sheet 在 vuex store 对应一个 module,然后为每个 module 单独维护一个操作历史栈,这属于暴力解法,简单有效但很挫。也有更复杂的,比如基于图(Graph)数据结构做状态机发散,这属于自己牛逼同事看不懂的非工程解法,而且这个逻辑放在客户端会很重。所以这俩都不是最优解,更好的方案暂时不写了,因为我也没想出来...

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2021-09-27 ,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 时间线不可逆
  • 行为分类
  • 时移操作的作用域
  • 页签域的时移操作如何实现?
相关产品与服务
腾讯云 BI
腾讯云 BI(Business Intelligence,BI)提供从数据源接入、数据建模到数据可视化分析全流程的BI能力,帮助经营者快速获取决策数据依据。系统采用敏捷自助式设计,使用者仅需通过简单拖拽即可完成原本复杂的报表开发过程,并支持报表的分享、推送等企业协作场景。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档