今回は、Immutable.js を使用して元に戻すおよびやり直しの効果を実現する方法と、Immutable.js で元に戻すおよびやり直しの効果を実現するための notes について説明します。以下は実際的なケースです。見てみましょう。
ブラウザはますます強力になり、もともと他のクライアントによって提供されていた多くの機能が徐々にフロントエンドに移され、フロントエンド アプリケーションはますます複雑になってきています。多くのフロントエンド アプリケーション、特に一部のオンライン編集ソフトウェアは、操作中にユーザー インタラクションを継続的に処理し、スムーズなインタラクションを保証するために元に戻す機能とやり直し機能を提供する必要があります。ただし、アプリケーションに元に戻す機能とやり直し機能を実装するのは簡単な作業ではありません。
Redux の公式ドキュメントでは、Redux アプリケーションに元に戻す機能とやり直し機能を実装する方法が紹介されています。 redux に基づく元に戻す機能はトップダウンのソリューションです。redux-undo
の導入後、すべての操作が「元に戻すことが可能」になり、その後も元に戻す機能をより使いやすくするために設定の変更を続けます (これも同様です)
redux-undo
設定項目が多い理由)。
この記事では、ボトムアップ アプローチを採用し、単純なオンライン描画ツールを例として、TypeScript と Immutable.js を使用して実用的な「元に戻す」と「やり直し」機能を実装します。おおよその効果は下の写真の通りです:
ステップ 1: どの州に履歴レコードが必要かを判断し、カスタム State クラスを作成します
すべての州に歴史が必要なわけではありません。多くの状態、特にマウスやキーボードの操作に関連する状態は非常に簡単です。たとえば、描画ツールでグラフィックをドラッグする場合、「ドラッグ中」マークを設定する必要があり、ページには対応するドラッグ プロンプトが表示されます。当然、ドラッグ マークは履歴に表示されるべきではありません。また、Web ページのウィンドウ サイズ、バックグラウンドに送信されたリクエストのリストなど、他の状態は取り消すことができない、または取り消す必要がありません。
履歴を必要としない状態を除いて、残りの状態を Immutable Record でカプセル化し、State クラスを定義します:
// State.ts import { Record, List, Set } from 'immutable' const StateRecord = Record({ items: List<Item> transform: d3.ZoomTransform selection: number }) // 用类封装,便于书写 TypeScript,注意这里最好使用Immutable 4.0 以上的版本 export default class State extends StateRecord {}
ここでの例は単純なオンライン描画ツールであるため、上記の State クラスには 3 つのフィールドが含まれており、項目は描画されたグラフィックの記録、変換に使用されます。 アートボードのパンとズームのステータスを記録するために使用され、選択は現在選択されているグラフィックを表します。 ID。グラフィック描画プレビュー、自動配置設定、操作プロンプト テキストなど、描画ツールの他の状態は State クラスには配置されません。
ステップ 2: アクションの基本クラスを定義し、異なる操作ごとに対応するアクションのサブクラスを作成します
redux-undo とは異なり、引き続き コマンド モード を使用します。基本クラス Action を定義し、State に対するすべての操作は Action のインスタンスとしてカプセル化されます。さまざまなタイプの操作に対応する、Action のいくつかのサブクラスを定義します。
TypeScript では、Action 基本クラスを Abstract Class で定義する方が便利です。
りーAction オブジェクトの next メソッドは「次の状態」を計算するために使用され、prev メソッドは「前の状態」を計算するために使用されます。 getMessage メソッドは取得するために使用されます。 Action オブジェクトの簡単な説明。 getMessage経由 方法としては、ユーザーの操作記録をページ上に表示することで、ユーザーが最近何が起こったのかを理解しやすくすることができます。 prepare メソッドはアクションで使用されます 初めて使用する前に「準備完了」にしてください。AppHistory の定義については、この記事の後半で説明します。
アクションサブクラスの例
次の AddItemAction は典型的な Action サブクラスで、「新しいグラフィックの追加」を表現するために使用されます。
りー実行時の動作
アプリケーションの実行中、ユーザーの操作により Action ストリームが生成され、Action オブジェクトが生成されるたびに、オブジェクトの next メソッドを呼び出して次の状態を計算し、 アクションは後で使用できるようにリストに保存され、ユーザーが元に戻す操作を実行するときに、アクション リストから最新のアクションを取得し、その prev を呼び出します。 方法。アプリケーションの実行中に、next/prev メソッドが次のように呼び出されます:
// actions/index.ts export default abstract class Action { abstract next(state: State): State abstract prev(state: State): State prepare(appHistory: AppHistory): AppHistory { return appHistory } getMessage() { return this.constructor.name } }
以下の説明を容易にするために、応用アクションを簡単に定義します。 操作結果が現在のアプリケーションの状態に反映されているアクションを指します。アクションの次のメソッドが実行されると、アクションが適用されます。 prev メソッドが実行されると、アクションは適用されなくなります。
第三步:创建历史记录容器 AppHistory
前面的 State 类用于表示某个时刻应用的状态,接下来我们定义 AppHistory 类用来表示应用的历史记录。同样的,我们仍然使用 Immutable Record 来定义历史记录。其中 state 字段用来表达当前的应用状态,list 字段用来存放所有的 action,而 index 字段用来记录最近的 applied-action 的下标。应用的历史状态可以通过 undo/redo 方法计算得到。apply 方法用来向 AppHistory 中添加并执行具体的 Action。具体代码如下:
// AppHistory.ts const emptyAction = Symbol('empty-action') export const undo = Symbol('undo') export type undo = typeof undo // TypeScript2.7之后对symbol的支持大大增强 export const redo = Symbol('redo') export type redo = typeof redo const AppHistoryRecord = Record({ // 当前应用状态 state: new State(), // action 列表 list: List<Action>(), // index 表示最后一个applied-action在list中的下标。-1 表示没有任何applied-action index: -1, }) export default class AppHistory extends AppHistoryRecord { pop() { // 移除最后一项操作记录 return this .update('list', list => list.splice(this.index, 1)) .update('index', x => x - 1) } getLastAction() { return this.index === -1 ? emptyAction : this.list.get(this.index) } getNextAction() { return this.list.get(this.index + 1, emptyAction) } apply(action: Action) { if (action === emptyAction) return this return this.merge({ list: this.list.setSize(this.index + 1).push(action), index: this.index + 1, state: action.next(this.state), }) } redo() { const action = this.getNextAction() if (action === emptyAction) return this return this.merge({ list: this.list, index: this.index + 1, state: action.next(this.state), }) } undo() { const action = this.getLastAction() if (action === emptyAction) return this return this.merge({ list: this.list, index: this.index - 1, state: action.prev(this.state), }) } }
第四步:添加「撤销重做」功能
假设应用中的其他代码已经将网页上的交互转换为了一系列的 Action 对象,那么给应用添上「撤销重做」功能的大致代码如下:
type HybridAction = undo | redo | Action // 如果用Redux来管理状态,那么使用下面的reudcer来管理那些「需要历史记录的状态」 // 然后将该reducer放在应用状态树中合适的位置 function reducer(history: AppHistory, action: HybridAction): AppHistory { if (action === undo) { return history.undo() } else if (action === redo) { return history.redo() } else { // 常规的 Action // 注意这里需要调用prepare方法,好让该action「准备好」 return action.prepare(history).apply(action) } } // 如果是在 Stream/Observable 的环境下,那么像下面这样使用 reducer const action$: Stream<HybridAction> = generatedFromUserInteraction const appHistory$: Stream<AppHistory> = action$.fold(reducer, new AppHistory()) const state$ = appHistory$.map(h => h.state) // 如果是用回调函数的话,大概像这样使用reducer onActionHappen = function (action: HybridAction) { const nextHistory = reducer(getLastHistory(), action) updateAppHistory(nextHistory) updateState(nextHistory.state) }
第五步:合并 Action,完善用户交互体验
通过上面这四个步骤,画图工具拥有了撤消重做功能,但是该功能用户体验并不好。在画图工具中拖动一个图形时,MoveItemAction 的产生频率和 mousemove 事件的发生频率相同,如果我们不对该情况进行处理,MoveItemAction 马上会污染整个历史记录。我们需要合并那些频率过高的 action,使得每个被记录下来的 action 有合理的撤销粒度。
每个 Action 在被应用之前,其 prepare 方法都会被调用,我们可以在 prepare 方法中对历史记录进行修改。例如,对于 MoveItemAction,我们判断上一个 action 是否和当前 action 属于同一次移动操作,然后来决定在应用当前 action 之前是否移除上一个 action。代码如下:
// actions/MoveItemAction.ts export default class MoveItemAction extends Action { prevItem: Item // 一次图形拖动操作可以由以下三个变量来进行描述: // 拖动开始时鼠标的位置(startPos),拖动过程中鼠标的位置(movingPos),以及拖动的图形的 ID constructor(readonly startPos: Point, readonly movingPos: Point, readonly itemId: number) { // 上一行中 readonly startPos: Point 相当于下面两步: // 1. 在MoveItemAction中定义startPos只读字段 // 2. 在构造函数中执行 this.startPos = startPos super() } prepare(history: AppHistory) { const lastAction = history.getLastAction() if (lastAction instanceof MoveItemAction && lastAction.startPos == this.startPos) { // 如果上一个action也是MoveItemAction,且拖动操作的鼠标起点和当前action相同 // 则我们认为这两个action在同一次移动操作中 this.prevItem = lastAction.prevItem return history.pop() // 调用pop方法来移除最近一个action } else { // 记录图形被移动之前的状态,用于撤销 this.prevItem = history.state.items.get(this.itemId) return history } } next(state: State): State { const dx = this.movingPos.x - this.startPos.x const dy = this.movingPos.y - this.startPos.y const moved = this.prevItem.move(dx, dy) return state.setIn(['items', this.itemId], moved) } prev(state: State) { // 撤销的时候我们直接使用已经保存的prevItem即可 return state.setIn(['items', this.itemId], this.prevItem) } getMessage() { /* ... */ } }
从上面的代码中可以看到,prepare 方法除了使 action 自身准备好之外,它还可以让历史记录准备好。不同的 Action 类型有不同的合并规则,为每种 Action 实现合理的 prepare 函数之后,撤消重做功能的用户体验能够大大提升。
一些其他需要注意的地方
撤销重做功能是非常依赖于不可变性的,一个 Action 对象在放入 AppHistory.list 之后,其所引用的对象都应该是不可变的。如果 action 所引用的对象发生了变化,那么在后续撤销时可能发生错误。本方案中,为了方便记录操作发生时的一些必要信息,Action 对象的 prepare 方法中允许出现原地修改操作,但是 prepare 方法只会在 action 被放入历史记录之前调用一次,action 一旦进入纪录列表就是不可变的了。
总结
以上就是实现一个实用的撤销重做功能的所有步骤了。不同的前端项目有不同的需求和技术方案,有可能上面的代码在你的项目中一行也用不上;不过撤销重做的思路应该是相同的,希望本文能够给你带来一些启发。
相信看了本文案例你已经掌握了方法,更多精彩请关注php中文网其它相关文章!
推荐阅读:
以上がImmutable.js で元に戻すおよびやり直しの効果を実現する方法の詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。