实现编辑功能有哪几个action_Web 应用的撤销重做实现

899a1b5d1d2799bc9d6e71f5559b790c.png

背景

前不久,我参与开发了团队中的一个 web 应用,其中的一个页面操作如下图所示:

GIF

这个制作间页面有着类似 PPT 的交互:从左侧的工具栏中选择元素放入中间的画布、在画布中可以删除、操作(拖动、缩放、旋转等)这些元素。

在这个编辑过程中,让用户能够进行操作的撤销、重做会提高编辑效率,大大提高用户体验,而本文要讲的正是在这个功能实现中的探索与总结。

功能分析

用户的一系列操作会改变页面的状态:

1f484deca15dabdf118c12e766f884b8.png

在进行了某个操作后,用户有能力回到之前的某个状态,即撤销

1d00ef3445cb939eb7d2325343d40a6d.png

在撤销某个操作后,用户有能力再次恢复这个操作,即重做

088399dda87b0145da5389c88ce9db63.png

当页面处于某个历史状态时,这时用户进行了某个操作后,这个状态后面的状态会被抛弃,此时产生一个新的状态分支:

372aa5be0850acb7b22c25d0104472cc.png

下面,开始实现这些逻辑。

功能初实现

基于以上的分析,实现撤销重做功能需要实现:

  • 保存用户的每个操作;
  • 针对每个操作设计与之对应的一个撤销逻辑;
  • 实现撤销重做的逻辑;

第一步:数据化每一个操作

操作造成的状态改变可以用语言来描述,如下图,页面上有一个绝对定位的 div 和 一个 button,每次点击 button 会让 div 向右移动 10px。这个点击操作可以被描述为:div 的样式属性 left 增加 10px

41dac272bd5b34c1fa81b3c8b4f9c6e7.png

显然,JavaScript 并不认识这样的描述,需要将这份描述翻译成 JavaScript 认识的语言:

const action = {name: 'changePosition',params: {target: 'left',value: 10,},
};

上面代码中使用变量 name 表示操作具体的名称,params 存储了该操作的具体数据。不过 JavaScript 目前仍然不知道如何使用这个它,还需要一个执行函数来指定如何使用上面的数据:

function changePosition(data, params) {const { property, distance } = params;data = { ...data };data[property] += distance;return data;
}

其中,data 为应用的状态数据,paramsaction.params

第二步:编写操作对应的撤销逻辑

撤销函数中结构与执行函数类似,也应该能获取到 dataaction

function changePositionUndo(data, params) {const { property, distance } = params;data = { ...data };data[property] -= distance;return data;
}

所以,action 的设计应当同时满足执行函数和撤销函数的逻辑。

第三步:撤销、重做处理

上述的 action、执行函数、撤销函数三者作为一个整体共同描述了一个操作,所以存储时三者都要保存下来。

这里基于约定进行绑定:执行函数名等于操作的 name ,撤销函数名等于 name + 'Undo',这样就只需要存储 action,隐式地也存储了执行函数和撤销函数。

编写一个全局模块存放函数、状态等:src/manager.js

const functions = {changePosition(state, params) {...},changePositionUndo(state, params) {...}
};export default {data: {},actions: [],undoActions: [],getFunction(name) {return functions[name];}
};

那么,点击按钮会产生一个新的操作,我们需要做的事情有三个:

  • 存储操作的 action
  • 执行该操作;
  • 如果处于历史节点,需要产生新的操作分支;
import manager from 'src/manager.js';buttonElem.addEventListener('click', () => {manager.actions.push({name: 'changePosition',params: { target: 'left', value: 10 }});const execFn = manager.getFunction(action.name);manager.data = execFn(manager.data, action.params);if (manager.undoActions.length) {manager.undoActions = [];}
});

其中,undoActions 存放的是撤销的操作的 action,这里清空表示抛弃当前节点以后的操作。将 action 存进 manager.actions ,这样需要撤销操作的时候,直接取出 manager.actions 中最后一个 action,找到对应撤销函数并执行即可。

import manager from 'src/manager.js';function undo() {const action = manager.actions.pop();const undoFn = manager.getFunction(`${action.name}Undo`);manager.data = undoFn(manager.data, action.params);manager.undoActions.push(action);
}

需要重做的时候,取出 manager.undoActions 中最后的 action,找到对应执行函数并执行。

import manager from 'src/manager.js';function redo() {const action = manager.undoActions.pop();const execFn = manager.getFunction(action.name);manager.data = execFn(manager.data, action.params);
}

模式优化:命令模式

以上代码可以说已经基本满足了功能需求,但是在我看来仍然存在一些问题:

  • 管理分散:某个操作的 action、执行函数、撤销函数分开管理。当项目越来越大时将会维护困难;
  • 职责不清:并没有明确规定执行函数、撤销函数、状态的改变该交给业务组件执行还是给全局管理者执行,这不利于组件和操作的复用;

想有效地解决以上问题,需要找到一个合适的新模式来组织代码,我选择了命令模式。

命令模式简介

简单来说,命令模式将方法、数据都封装到单一的对象中,对调用方与执行方进行解耦,达到职责分离的目的。

以顾客在餐厅吃饭为例子:

  • 顾客点餐时,选择想吃的菜,提交一份点餐单
  • 厨师收到这份点餐单后根据内容做菜

期间,顾客和厨师之间并没有见面交谈,而是通过一份点餐单来形成联系,这份点餐单就是一个命令对象,这样的交互模式就是命令模式。

action + 执行函数 + 撤销函数 = 操作命令对象

为了解决管理分散的问题,可以把一个操作的 action、执行函数、撤销函数作为一个整体封装成一个命令对象:

class ChangePositionCommand {constructor(property, distance) {this.property = property; // 如:'left'this.distance = distance; // 如: 10}execute(state) {const newState = { ...state }newState[this.property] += this.distance;return newState;}undo(state) {const newState = { ...state }newState[this.property] -= this.distance;return newState;}
}

业务组件只关心命令对象的生成和发送

在状态数据处理过程中往往伴随着一些副作用,这些与数据耦合的逻辑会大大降低组件的复用性。因此,业务组件不用关心数据的修改过程,而是专注自己的职责:生成操作命令对象并发送给状态管理者。

import manager from 'src/manager';
import { ChangePositionCommand } from 'src/commands';buttonElem.addEventListener('click', () => {const command = new ChangePositionCommand('left', 10);manager.addCommand(command);
});

状态管理者只关心数据变更和操作命令对象治理

class Manager {constructor(initialState) {this.state = initialState;this.commands = [];this.undoCommands = [];}addCommand(command) {this.state = command.execute(this.state);this.commands.push(command);this.undoCommands = []; // 产生新分支}undo() {const command = this.commands.pop();this.state = command.undo(this.state);this.undoCommands.push(command);}redo() {const command = this.undoCommands.pop();this.state = command.execute(this.state);this.commands.push(command);}
}export default new Manger({});

这样的模式已经可以让项目的代码变得健壮,看起来已经很不错了,但是能不能更好呢?

模式进阶:数据快照式

命令模式要求开发者针对每一个操作都要额外开发一个撤销函数,这无疑是麻烦的。接下来要介绍的数据快照式就是要改进这个缺点。

数据快照式通过保存每次操作后的数据快照,然后在撤销重做的时候通过历史快照恢复页面,模式模型如下:

2791cd0d2501f838c0f56bb94c9f75fa.png

要使用这种模式是有要求的:

  • 应用的状态数据需要集中管理,不应该分散在各个组件;
  • 数据更改流程中有统一的地方可以做数据快照存储;

这些要求不难理解,既然要产生数据快照,集中管理才会更加便利。基于这些要求,我选择了市面上较为流行的 Redux 来作为状态管理器。

状态数据结构设计

按照上面的模型图,Redux 的 state 可以设计成:

const state = {timeline: [],current: -1,limit: 1000,
};

代码中,各个属性的含义为:

  • timeline:存储数据快照的数组;
  • current:当前数据快照的指针,为 timeline 的索引;
  • limit:规定了 timeline 的最大长度,防止存储的数据量过大;

数据快照生成的方式

假设应用初始的状态数据为:

const data = { left: 100 };
const state = {timeline: [data],current: 0,limit: 1000,
};

进行了某个操作后,left 加 100,有些新手可能会直接这么做:

cont newData = data;
newData.left += 100;
state.timeline.push(newData);
state.current += 1;

这显然是错误的,因为 JavaScript 的对象是引用类型,变量名只是保存了它们的引用,真正的数据存放在堆内存中,所以 datanewData 共享一份数据,所以历史数据和当前数据都会发生变化。

方式一:使用深拷贝

深拷贝的实现最简单的方法就是使用 JSON 对象的原生方法:

const newData = JSON.parse(JSON.stringify(data));

或者,借助一些工具比如 lodash:

const newData = lodash.cloneDeep(data);

不过,深拷贝可能出现循环引用而引起的死循环问题,而且,深拷贝会拷贝每一个节点,这样的方式带来了无谓的性能损耗。

方式二:构建不可变数据

假设有个对象如下,需要修改第一个 componentwidth200

const state = {components: [{ type: 'rect', width: 100,  height: 100 },{ type: 'triangle': width: 100, height: 50}]
}

目标属性的在对象树中的路径为:['components', 0, 'width'],这个路径上有些数据是引用类型,为了不造成共享数据的变化,这个引用类型要先变成一个新的引用类型,如下:

const newState = { ...state };
newState.components = [...state.components];
newState.components[0] = { ...state.components[0] };

这时你就可以放心修改目标值了:

newState.components[0].width = 200;
console.log(newState.components[0].width, state.components[0].width); // 200, 100

这样的方式只修改了目标属性节点的路径上的引用类型值,其他分支上的值是不变的,这样节省了不少内存。为了避免每次都一层一层去修改,可以将这个处理封装成一个工具函数:

const newState = setIn(state, ['components', 0, 'width'], 200)

setIn 源码:https://github.com/cwajs/cwa-immutable/blob/master/src/setIn.js

数据快照处理逻辑

进行某个操作,reducer 代码为:

function operationReducer(state, action) {state = { ...state };const { current, limit } = state;const newData = ...; // 省略过程state.timeline = state.timeline.slice(0, current + 1);state.timeline.push(newData);state.timeline = state.timeline.slice(-limit);state.current = state.timeline.length - 1;return state;
}

有两个地方需要解释:

  • timline.slice(0, current + 1):这个操作是前文提到的,进行新操作时,应该抛弃当前节点后的操作,产生一个新的操作分支;
  • timline.slice(-limit):表示只保留最近的 limit 个数据快照;

使用高阶 reducer

在实际项目中,通常会使用 combineReducers 来模块化 reducer,这种情况下,在每个 reducer 中都要重复处理以上的逻辑。这时候就可以使用高阶 reducer 函数来抽取公用逻辑:

const highOrderReducer = (reducer) => {return (state, action) => {state = { ...state };const { timeline, current, limit } = state;// 执行真实的业务reducerconst newState = reducer(timeline[current], action);// timeline处理state.timeline = timeline.slice(0, current + 1);state.timeline.push(newState);state.timeline = state.timeline.slice(-limit);state.current = state.timeline.length - 1;return state;};
}// 真实的业务reducer
function reducer(state, action) {switch (action.type) {case 'xxx':newState = ...;return newState;}
}const store = createStore(highOrderReducer(reducer), initialState);

这个高阶 reducer 使用 const newState = reducer(timeline[current], action) 来对业务 reducer 隐藏数据快照队列的数据结构,使得业务 reducer 对撤销重做逻辑无感知,实现功能可拔插。

增强高阶 reducer,加入撤销重做逻辑

撤销重做时也应该遵循 Redux 的数据修改方式使用 store.dispatch,为:

  • store.dispatch({ type: 'undo' }) ;
  • store.dispatch({ type: 'redo' });

这两种 action 不应该进入到业务 reducer,需要进行拦截:

const highOrderReducer = (reducer) => {return (state, action) => {// 进行 undo、redo 的拦截if (action.type === 'undo') {return {...state,current: Math.max(0, state.current - 1),};}// 进行 undo、redo 的拦截if (action.type === 'redo') {return {...state,current: Math.min(state.timeline.length - 1, state.current + 1),};}state = { ...state };const { timeline, current, limit } = state;const newState = reducer(timeline[current], action);state.timeline = timeline.slice(0, current + 1);state.timeline.push(newState);state.timeline = state.timeline.slice(-limit);state.current = state.timeline.length - 1;return state;};
}

使用 react-redux 在组件中获取状态

我在项目中使用的是 React 和 react-redux,由于 state 的数据结构发生了变化,所以在组件中获取状态的写法也要相应作出调整:

import React from 'react';
import { connect } from 'react-redux';function mapStateToProps(state) {const currentState = state.timeline[state.current];return {};
}class SomeComponent extends React.Component {}export default connect(mapStateToProps)(SomeComponent);

然而,这样的写法让组件感知到了撤销重做的数据结构,与上面所说的功能可拔插明显相悖,我通过重写 store.getState 方法来解决:

const store = createStore(reducer, initialState);const originGetState = store.getState.bind(store);store.getState = (...args) => {const state = originGetState(...args);return state.timeline[state.current];
}

总结

本文围绕撤销重做功能实现的讲解到此结束,在实现该功能后引入了命令模式来使得代码结构更加健壮,最后改进成数据快照式,从而让整个应用架构更加优雅。

参考资料

  • 《JavaScript设计模式》Addy Osmani著
  • Redux Documentation
本文发布自 网易云音乐前端团队,文章未经授权禁止任何形式的转载。我们对人才饥渴难耐,快来 加入我们!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/370131.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

为什么我们要做三份 Webpack 配置文件

时至今日,Webpack 已经成为前端工程必备的基础工具之一,不仅被广泛用于前端工程发布前的打包,还在开发中担当本地前端资源服务器(assets server)、模块热更新(hot module replacement)、API Pro…

使用maven插件构建docker镜像

为什么要用插件 主要还是自动化的考虑,如果额外使用Dockerfile进行镜像生成,可能会需要自己手动指定jar/war位置,并且打包和生成镜像间不同步,带来很多琐碎的工作。 插件选择 使用比较多的是spotify的插件:https://github.com/spo…

windows下如何安装pip以及如何查看pip是否已经安装成功?

最近刚学习python,发现很多关于安装以及查看pip是否安装成的例子都比较老,不太适合于现在(python 3.6 )因此,下一个入门级别的教程。 0:首先如何安装python我就不做介绍了。 1:如果安装的是pyth…

检查用户显示器的分辨率

检查用户显示器的分辨率 转载于:https://www.cnblogs.com/Renyi-Fan/p/8088012.html

android 字体 dpi,详解Android开发中常用的 DPI / DP / SP

Android的碎片化已经被喷了好多年,随着国内手机厂商的崛起,碎片化也越来越严重,根据OpenSignal的最新调查,2014年市面上有18796种不同的Android设备,作为开发者,一个无法回避的难题就是需要适配各种各样奇奇…

android studio闪退代码不报错_代码不报错,不代表真的没错

今天是生信星球陪你的第695天大神一句话,菜鸟跑半年。我不是大神,但我可以缩短你走弯路的半年~就像歌儿唱的那样,如果你不知道该往哪儿走,就留在这学点生信好不好~这里有豆豆和花花的学习历程,从新手到进阶&#xff0c…

Centos7操作系统部署指南

一、硬件环境: Dell R620 二、软件环境: Centos6.4 X86_64 KVM Windows7vnc 三、安装说明 操作系统更新之迅速,让作为新手的系统运维人员有点措手不及,相对于老手就胸有成竹。怎么讲?由于老手对技术方向把握的非常好&…

Eclipse插件中的SLF4J登录

一直都在使用Maven和纯Java库进行开发,我从没想过在开发Eclipse插件时发出一些日志语句可能会成为问题。 但是,在Eclipse开发人员的想象中,一切似乎总是在Eclipse环境中,而Eclipse宇宙之外则什么都没有。 如果您使用Google搜索上…

CSS(四)

css元素溢出 当子元素的尺寸超过父元素的尺寸时,需要设置父元素显示溢出的子元素的方式,设置的方法是通过overflow属性来设置。 overflow的设置项: 1、visible 默认值。内容不会被修剪,会呈现在元素框之外。2、hidden 内容会被修…

mysql排名

转载自思心思危http://www.cnblogs.com/zengguowang/p/5541431.html 一、sql1{不管数据相同与否,排名依次排序(1,2,3,4,5,6,7.....)} SELECTobj.user_id,   obj.score,  rownum : rownum 1 AS rownum FROM(SELECT…

python中变量名后的逗号_深入浅析python变量加逗号,的含义

逗号,用于生成一个长度为1的元组>>> (1)1>>> (1,)(1,)>>> 1,(1,)因此需要将长度为1的元组中元素提取出来可以用,简化赋值操作>>> a(1,)>>> ba>>> b(1,)>>> b,a>>> b1最后print打印变量加,实现连续打印…

广告的显示和关闭

app或游戏的主页显示广告页面,实现方式: public class MainActivity extends Activity implements View.OnClickListener{private Button btnShowAd;private RelativeLayout layoutAd;Overrideprotected void onCreate(Bundle savedInstanceState) {supe…

android签到功能模块,基于android的课堂签到系统.doc

基于android的课堂签到系统本科毕业论文(设计)题 目 基于Android的课堂签到系统学生姓名 XXX指导教师 XX学 院 信息科学与工程学院专业班级 计算机科学与技术0908班完成时间 2013年5月 摘 要在大学课堂中,签到问题一直困扰着老师和同学们。传统课堂签到的手段大多是…

Java EE 7社区调查结果!

在JSR 342下可以继续进行Java EE 7的工作。一切进展顺利,Java EE 7现在处于“初稿审查”阶段。 在11月初, Oracle发布了一个有关即将推出的Java EE 7功能的小型社区调查 。 昨天结果公布了。 超过1,100名开发人员参加了调查,并且几乎对每个问…

CSS(三)

CSS盒子模型 盒子模型解释 元素在页面中显示成一个方块,类似一个盒子,CSS盒子模型就是使用现实中盒子来做比喻,帮助我们设置元素对应的样式。盒子模型示意图如下: 把元素叫做盒子,设置对应的样式分别为:盒…

一道关于运行顺序题

function foo(){   getName function(){console.log(1)}   return this } foo.getName function(){console.log(2)} foo.prototype.getName function(){console.log(3)} var getName function(){console.log(4)} function getName(){console.log(5)} foo.getName()//2 …

android+小米文件管理器源码,小米开源文件管理器MiCodeFileExplorer-源码研究(2)-2个单实例工具类...

从本篇开始,讲解net.micode.fileexplorer.util工具包中的类。这个包下的类,功能也比较单一和独立。很多代码的思想和实现,可以用于JavaWeb和Android等多种环境中。一、单实例活动管理器ActivitiesManager一个单实例的活动管理器,从…

移动优先的响应式布局

前面的话 随着移动互联网的兴起,不同设备的分辨率相差较大,如果在不同的设置上显示同一个页面,则用户体验差。响应式网页设计是一种方法,使得一个网站能够兼容多个终端,而不用为每个终端制作特定的版本。它使得一个网站…

python中英文字符和中文字符存储长度不同_Django如何正确截取中英混合字符串及表单中限制中文字符中长度...

中文字符和英文字符所占的字节长度是不一样,一个是2个字节,一个是1个字节,这给我们用英文的web框架开发中文app带来了麻烦。比如Django自带过滤器truncatewords并不支持截取中文,另外模型中CharField中的max_length选项用于限制中…

使用RESTful客户端API进行GET / POST

互联网上有很多如何使用RESTful Client API的东西。 这些是基础。 但是,尽管该主题看起来微不足道,但仍然存在一些障碍,尤其是对于初学者而言。 在这篇文章中,我将尝试总结我的专业知识,以及我如何在实际项目中做到这…