天天看點

實作編輯功能有哪幾個action_基于 Immutable.js 實作撤銷重做功能

實作編輯功能有哪幾個action_基于 Immutable.js 實作撤銷重做功能

浏覽器的功能越來越強大,許多原來由其他用戶端提供的功能漸漸轉移到了前端,前端應用也越來越複雜。許多前端應用,尤其是一些線上編輯軟體,運作時需要不斷處理使用者的互動,提供了撤消重做功能來保證互動的流暢性。不過為一個應用實作撤銷重做功能并不是一件容易的事情。Redux官方文檔中 介紹了如何在 redux 應用中實作撤銷重做功能。基于 redux 的撤銷功能是一個自頂向下的方案:引入 redux-undo 之後所有的操作都變為了「可撤銷的」,然後我們不斷修改其配置使得撤銷功能變得越來越好用(這也是 redux-undo 有那麼多配置項 的原因)。

本文将采用自底向上的思路,以一個簡易的線上畫圖工具為例子,使用 TypeScript、Immutable.js 實作一個實用的「撤消重做」功能。大緻效果如下圖所示:

實作編輯功能有哪幾個action_基于 Immutable.js 實作撤銷重做功能

撤銷重做功能預覽

上圖看不清的話,可以看這裡。

第一步:确定哪些狀态需要曆史記錄,建立自定義的 State 類

并非所有的狀态都需要曆史記錄。許多狀态是非常瑣碎的,尤其是一些與滑鼠或者鍵盤互動相關的狀态,例如在畫圖工具中拖拽一個圖形時我們需要設定一個「正在進行拖拽」的标記,頁面會根據該标記顯示對應的拖拽提示,顯然該拖拽标記不應該出現在曆史記錄中;而另一些狀态無法被撤銷或是不需要被撤銷,例如網頁視窗大小,向背景發送過的請求清單等。

排除那些不需要曆史記錄的狀态,我們将剩下的狀态用 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 類中包含了三個字段,items 用來記錄已經繪制的圖形,transform 用來記錄畫闆的平移和縮放狀态,selection 則表示目前選中的圖形的 ID。而畫圖工具中的其他狀态,例如圖形繪制預覽,自動對齊配置,操作提示文本等,則沒有放在 State 類中。

第二步:定義 Action 基類,并為每種不同的操作建立對應的 Action 子類

與 redux-undo 不同的是,我們仍然采用指令模式:定義基類 Action,所有對 State 的操作都被封裝為一個 Action 的執行個體;定義若幹 Action 的子類,對應于不同類型的操作。

在 TypeScript 中,Action 基類用 Abstract Class 來定義比較友善。

// 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 }
}
           

Action 對象的 next 方法用來計算「下一個狀态」,prev 方法用來計算「上一個狀态」。getMessage 方法用來擷取 Action 對象的簡短描述。通過 getMessage 方法,我們可以将使用者的操作記錄顯示在頁面上,讓使用者更友善地了解最近發生了什麼。prepare 方法用來在 Action 第一次被應用之前,使其「準備好」,AppHistory 的定義在本文後面會給出。

Action 子類舉例

下面的 AddItemAction 是一個典型的 Action 子類,用于表達「添加一個新的圖形」。

// actions/AddItemAction.ts
export default class AddItemAction extends Action {
  newItem: Item
  prevSelection: number

  constructor(newItem: Item) {
    super()
    this.newItem = newItem
  }

  prepare(history: AppHistory) {
    // 建立新的圖形後會自動選中該圖形,為了使得撤銷該操作時 state.selection 變為原來的值
    // prepare 方法中讀取了「添加圖形之前 selection 的值」并儲存到 this.prevSelection
    this.prevSelection = history.state.selection
    return history
  }

  next(state: State) {
    return state
      .setIn(['items', this.newItem.id], this.newItem)
      .set('selection', this.newItemId)
  }

  prev(state: State) {
    return state
      .deleteIn(['items', this.newItem.id])
      .set('selection', this.prevSelection)
  }

  getMessage() { return `Add item ${this.newItem.id}` }
}
           
運作時行為

應用運作時,使用者互動産生一個 Action 流,每次産生 Action 對象時,我們調用該對象的 next 方法來計算後一個狀态,然後将該 action 儲存到一個清單中以備後用;使用者進行撤銷操作時,我們從 action 清單中取出最近一個 Action 并調用其 prev 方法。應用運作時,next/prev 方法被調用的情況大緻如下:

// initState 是一開始就給定的應用初始狀态
// 某一時刻,使用者互動産生了 action1 ...
state1 = action1.next(initState)
// 又一個時刻,使用者互動産生了 action2 ...
state2 = action2.next(state1)
// 同樣的,action3也出現了 ...
state3 = action3.next(state2)

// 使用者進行撤銷,此時我們需要調用最近一個action的prev方法
state4 = action3.prev(state3)
// 如果再次進行撤銷,我們從action清單中取出對應的action,調用其prev方法
state5 = action2.prev(state4)

// 重做的時候,取出最近一個被撤銷的action,調用其next方法
state6 = action2.next(state5)
           
Applied-Action

為了友善後面的說明,我們對 Applied-Action 進行一個簡單的定義:Applied-Action 是指那些操作結果已經反映在目前應用狀态中的 action;當 action 的 next 方法執行時,該 action 變為 applied;當 prev 方法被執行時,該 action 變為 unapplied。

第三步:建立曆史記錄容器 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 一旦進入記錄清單就是不可變的了。

總結

以上就是實作一個實用的撤銷重做功能的所有步驟了。不同的前端項目有不同的需求和技術方案,有可能上面的代碼在你的項目中一行也用不上;不過撤銷重做的思路應該是相同的,希望本文能夠給你帶來一些啟發。