iT邦幫忙

2021 iThome 鐵人賽

DAY 18
0
Modern Web

深入 slate.js x 一起打造專屬的富文字編輯器吧!系列 第 18

Day 18. slate × Immutable × Immer & slate

https://ithelp.ithome.com.tw/upload/images/20211003/20139359BwID3WAMQG.png

ImmutableJS v.s. ImmerJS


舊版的 Slate 選擇 Immutable.js 這個套件去建立 Immutable object ,但這卻造成了一些嚴重的缺點:

  • Immutable.js 的學習成本並不低,這造成了開發者需要事先具備使用 Immutable.js 的知識才能開始開發 Slate.js 。甚至舊版的 document 就直接跟開發者說『學習 Immutable.js 是一項非常值得的投資』呢!
  • Immutable.js 整個套件約為 15kb ,高過僅有 4kb 左右的 Immer.js 11kb 左右。
  • Immutable.js 引進了新的 Immutable 資料結構,而非純粹的 JS Object ,這迫使開發者需要額外花費心力去處理 ImmutableJS 資料結構與 plain JS Object 之間的 transformation ,更遑論這件事所需要耗費的效能問題了。

這裏再提供當時討論 Immutable 套件轉換的 Github issue 討論串,因為顧及了舊版 Slate 的實作方式因此我們也能在裡面看到各路 contributors 丟出當時所面臨的 issues 以及各種 sample codes ,有興趣的讀者們可以上前觀看歷史的走向。


那麼在進入程式碼與它提供的 api 前,我們首先來介紹一下 Immer.js 的運作概念。

Concept of Immer.js


https://ithelp.ithome.com.tw/upload/images/20211003/20139359lkXjik2DdW.png

如上圖所示, Immer 的使用上都圍繞在一個位於 Current state 與 Next state 之間,名叫 Draft state 的灰色地帶。為了讓開發者在操作時與操作一般 Mutable Object 時一樣直觀,它在 Draft state 裡提供一個 Current state 的 proxy object 讓開發者直接對這個 proxy object 做資料修改。

等到修改結束後, Immer 會再比對 Draft state 裡的修改內容,並將結果輸出成 Next state 。

不像 Immutable.js ,所有 state 資料的 CRUD 都是透過它提供給你的 api 進行,需要花相對多的時間去學習各個 api 的使用情境。 Immer 提供的更像是一張印有原始資料的稿紙,待開發者確定並將稿紙上的修改送出後才執行真正的 Immutable state change 。

來看一下 Immer 的官網是如何形容自己的:

Using Immer is like having a personal assistant. The assistant takes a letter (the current state) and gives you a copy (draft) to jot changes onto. Once you are done, the assistant will take your draft and produce the real immutable, final letter for you (the next state).


再來我們就要進到 code 與 Immer.js 提供的 api 的部分了,因為篇幅問題所以我們只會把重點圍繞在 slate 的使用範圍之下去延伸,包含了最基本的 produce 以及 createDraft / finishDraft

Immer - produce function


produce 是 Immer.js 提供,達成上半部所描述的運作理念,最基本最核心的 api 了。我們先一起來看一下它的 formula :

produce(currentState, recipe: (draftState) => void): nextState

在第一個參數放入要修改的 state ,它會提供 draft state 給第二個 callback function 參數供開發者使用,最後回傳修改完成的 next state 。

我們能以一般函式的使用方式使用它:

import produce from "immer"

const baseState = [
    {
        title: "Learn TypeScript",
        done: true
    },
    {
        title: "Try Immer",
        done: false
    }
]

const nextState = produce(baseState, draftState => {
    draftState.push({title: "Tweet about it"})
    draftState[1].done = true
})

也能以 Curry function 的方式來使用它:

import produce from "immer"

function toggleTodo(state, id) {
    return produce(state, draft => {
        const todo = draft.find(todo => todo.id === id)
        todo.done = !todo.done
    })
}

const baseState = [
    {
        id: "JavaScript",
        title: "Learn TypeScript",
        done: true
    },
    {
        id: "Immer",
        title: "Try Immer",
        done: false
    }
]

const nextState = toggleTodo(baseState, "Immer")

上面都是官網提供的範例,從丟進 produce 的 callback function 裡可以看到我們修改 draft state 的方式就跟一般我們在使用 JS 的 Object type data 一模一樣,它讓我們能在保有 Immutable 特性的優點下用自己最習慣的方式與 data 互動。

而在 Slate 裡,主要使用 produce method 的地方就在所有 Location types 提供的 transform method :

// interfaces/path.ts

/**
 * Transform a path by an operation.
 */
transform(
  path: Path,
  operation: Operation,
  options: { affinity?: 'forward' | 'backward' | null } = {}
): Path | null {
	return produce(path, p => {
		// ... Path.transform implementation
	});
}

// interfaces/point.ts

/**
 * Transform a point by an operation.
 */

transform(
  point: Point,
  op: Operation,
  options: { affinity?: 'forward' | 'backward' | null } = {}
): Point | null {
  return produce(point, p => {
		// ... Point.transform implementation
	});
}

// interfaces/range.ts

/**
 * Transform a range by an operation.
 */

transform(
  range: Range,
  op: Operation,
  options: {
    affinity?: 'forward' | 'backward' | 'outward' | 'inward' | null
  } = {}
): Range | null {
	// ... Statement control

	return produce(range, r => {
    // ... Range.transform implementation
  })
}

這些 transform methods 被使用於整個 editor state 更新的最底層,只要與『節點的路徑更新』相關的功能情境出現時,就會呼叫這些 methods 。

它們負責的工作有兩個:

  • 透過 produce 確保每個存在於 Slate editor 裡的 Locations 皆為 unique 且 immutable 的,讓那些沒有被 reference 到的 Locations 被自然地 GC 掉。
  • 在提供原本的 Location value 、 Operation 、 options 後,經過一連串的運算回傳更新過後的 Location value 。

Immer - createDraft / finishDraft function


另一個製造 draft state 的方式是使用 createDraftfinishDraft 這兩個 api ,有別於 produce 是提供一組 object proxy 在傳入的 callback function 中使用,createDraft 則是會回傳丟入的 current state 的 draft state 供開發者 mutate ,一樣記錄在這期間做的所有操作直到呼叫 finishDraft 回傳下一組 immutable state 並註銷原本的 draft state 。讓開發者不用每次需要使用 draft state 時都要重新建立一組新的 function 。

我們直接拿官方提供的範例來輔助說明:

import {createDraft, finishDraft} from "immer"

const user = {
  name: "michel",
  todos: []
}

const draft = createDraft(user)
draft.todos = await (await window.fetch("http://host/" + draft.name)).json()
const loadedUser = finishDraft(draft)

可能讀者會認為這組 methods 是專門拿來處理非同步相關情境的,其實不然,開發者也能提供非同步函式給 produce

const user = {
    name: "michel",
    todos: []
}

const loadedUser = await produce(user, async function(draft) {
    draft.todos = await (await window.fetch("http://host/" + draft.name)).json()
})

最後 Slate 使用這組 methods 的情境是在 Operation 實際處理 editor state 更新,GeneralTransformstransform method 裡,這部分的詳細介紹我們放在 Operation 的章節裡,讀者目前先了解這個 method 是所有的 Operations 實際執行『更新 editor state 』時會呼叫的 method 就好,我們先把重點聚焦在 createDraft / finishDraft method 的使用上:

export const GeneralTransforms: GeneralTransforms = {
  /**
   * Transform the editor by an operation.
   */

  transform(editor: Editor, op: Operation): void {
    editor.children = createDraft(editor.children)
    let selection = editor.selection && createDraft(editor.selection)

    try {
      selection = applyToDraft(editor, selection, op)
    } finally {
      editor.children = finishDraft(editor.children)

      if (selection) {
        editor.selection = isDraft(selection)
          ? (finishDraft(selection) as Range)
          : selection
      } else {
        editor.selection = null
      }
    }
  },
}

首先透過 createDraft 將 Draft-State 綁定到 childrenselection 裡。這裡將主要的更新邏輯都封裝在 applyToDraft function 裡,這也是我們在 Operation 章節會著重介紹的部分。完成以後再透過 finishDraft 註銷 Draft-State ,並回傳更新過後的 childrenselection


針對 Immer.js 的使用就介紹到這邊,當然除了上面所介紹的, Immer.js 還有提供許多針對不同情境所使用的 apis ,有興趣的讀者可以再前往它們的 官方文件 去查看。

Immutable 章節就到此為止,接下來我們就要進入重頭戲之一—— Slate Operation 章節了!

讓我們養精蓄銳,迎接明天新的篇章吧!


上一篇
Day 17. slate × Immutable
下一篇
Day 19. slate × Operation × WeakMap
系列文
深入 slate.js x 一起打造專屬的富文字編輯器吧!30

尚未有邦友留言

立即登入留言