iT邦幫忙

2021 iThome 鐵人賽

DAY 14
0
Modern Web

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

Day 14. slate × Interfaces × Ref

https://ithelp.ithome.com.tw/upload/images/20210929/20139359N6DVCQNRv8.png

相信有 React 開發經驗的讀者們對 Ref 這個詞一定不陌生。

其實 slate 裡頭的 Ref concept 與 React Ref 非常相似,同樣都是用來指向 Document ( HTML Document / Slate Document ) 中的某一個 Node ( DOM Node / Slate Node ),並持續追蹤這個節點的資料更新。

在開始介紹之前為了 讓文章篇幅看起來長一點 能更全面地認識它,我們先來追朔一下整個概念的歷史由來。

Concept History


slate 裡頭的 refs concept 是由作者之一的 ianstormtaylor 在 github issue 上發問索取想法的。

舊版的 slate 中,tree 裡頭所有的 Node 都帶有一個 auto-incrementing 的 key 屬性,而 Point 除了我們前文提到的 pathoffset 屬性之外還有一個 key 屬性對應到指定 Node 的 key 值。

Point({
  key: String,
  path: List,
  offset: Number,
})
使用上跟新版 slate 比較不一樣,但因為不是本篇的重點就先略過解釋了,概念基本上是一樣的。

pathkey 這兩個屬性提供給無論開發者或是 slate 本身得以 reference 到 slate tree 的指定節點,但後者因為就只是個 unique string 而已,要想透過它來查找對應的節點就必須要完整遍歷整個 slate tree ,而不像 path 只要尋著陣列裡頭的數字,很快就能抵達指定節點。

但我們卻無法透過 path 的 value 來持續追蹤指定節點,因為經過 insert 、 remove 等操作時,同個節點的 path 是有可能會更動,甚至被作廢的。

也因此作者提出了 refs concept ,起初是為了讓 Point 透過『非』 Document 查找的方式,而是有個 mutable object 能持續追蹤它的資料變化,同時能夠透過 unref method 讓它能夠被 Garbage collect 掉,經過了一連串的討論演化以後才成為了我們現在看到的 ref interface 的形式。

一樣附上 Github issue 的討論串,有興趣的讀者可以上前去觀賞它的演化史。


Slate Refs


在 weak-maps.ts 裡面我們會看到三組 *_REFS 的 WeakMap 變數:

// weak-map.ts

export const PATH_REFS: WeakMap<Editor, Set<PathRef>> = new WeakMap()
export const POINT_REFS: WeakMap<Editor, Set<PointRef>> = new WeakMap()
export const RANGE_REFS: WeakMap<Editor, Set<RangeRef>> = new WeakMap()

這些 WeakMaps 裡紀錄著 editor 裡頭所有的 Ref 集合。

關於 WeakMaps 我們之後會特別拉出一篇文章來介紹它

接著來看一下各個 ref 的 interfaces :

export interface PathRef {
  current: Path | null
  affinity: 'forward' | 'backward' | null
  unref(): Path | null
}

export interface PointRef {
  current: Point | null
  affinity: 'forward' | 'backward' | null
  unref(): Point | null
}

export interface RangeRef {
  current: Range | null
  affinity: 'forward' | 'backward' | 'outward' | 'inward' | null
  unref(): Range | null
}
  • current

    就跟 React Ref 的 current property 一樣,儲存著 Slate Node 的資料。

  • affinity

    這項資料會在每次執行 Operation 時提供給各個對應的 transform method 的 affinity option 。

  • unref

    將這項 ref 徹底刪除到可以被 GC 的程度。

所有與 Ref 相關的實作都被寫在 editor.ts 的 Editor method apis 裡面,例如:

  • 取得 WeakMap 裡頭的 editor ref set :

    export interface EditorInterface {
    	pathRefs: (editor: Editor) => Set<PathRef>
    	pointRefs: (editor: Editor) => Set<PointRef>
    	rangeRefs: (editor: Editor) => Set<RangeRef>
    }
    
  • 製作 ref 並綁定到對應的 WeakMap set

    export interface EditorInterface {
    	pathRef: (
        editor: Editor,
        path: Path,
        options?: {
          affinity?: 'backward' | 'forward' | null
        }
      ) => PathRef
    	pointRef: (
        editor: Editor,
        point: Point,
        options?: {
          affinity?: 'backward' | 'forward' | null
        }
      ) => PointRef
    	rangeRef: (
        editor: Editor,
        range: Range,
        options?: {
          affinity?: 'backward' | 'forward' | 'outward' | 'inward' | null
        }
      ) => RangeRef
    }
    

因為實作的內容大同小異,頂多帶的參數不太一樣而已,我們就拿 pathRef 當作範例來介紹吧。

pathRef(
  editor: Editor,
  path: Path,
  options: {
    affinity?: 'backward' | 'forward' | null
  } = {}
): PathRef {
  const { affinity = 'forward' } = options
  const ref: PathRef = {
    current: path,
    affinity,
    unref() {
      const { current } = ref
      const pathRefs = Editor.pathRefs(editor)
      pathRefs.delete(ref)
      ref.current = null
      return current
    },
  }

  const refs = Editor.pathRefs(editor)
  refs.add(ref)
  return ref
},

code 並不複雜,就是把傳入的參數擺進 ref 變數,同時實作 unref method :執行 pathRefs method 回傳的 Set 的 delete method 並把 current 設為 null 。再把定義好的 ref 加進 Set 裡面。

pathRefs method 裡頭做的事也就是單純回傳 Set 以及初始化而已。

pathRefs(editor: Editor): Set<PathRef> {
  let refs = PATH_REFS.get(editor)

  if (!refs) {
    refs = new Set()
    PATH_REFS.set(editor, refs)
  }

  return refs
},

介紹完它的 interface 以及實作以後我們來看一下 Ref 更新 current 資料的流程。

Ref Transform


為什麼會有『更新 current 資料』的必要存在啊? Ref 不是就直接指向 Node 的位置了嗎?這樣當 Node 被更新了以後 Ref 就應該跟著被更新了吧?


這部分就會牽涉到之後 Immutable 的章節內容了,這邊先簡單介紹原因:

這是因為所有的 Location type 的更新操作都會經過 Immer 的包裝,更新後的變數是指向新的位置而非舊的,也因此 Ref current 的資料指向的位置與 Slate 真實的 Node 指向的位置並不一樣。

所以為了保持它們之間資料的一致性所以我們需要在每次的 Operation 都對所有的 refs 執行一次相同的 Operation 。

Ref 的資料更新相關的內容是放在 create-editor.ts 裡 editor 的 apply method ,關於 apply 的詳細介紹我們留到之後的 Operation 篇章,這邊讀者先知道這個 method 是所有 Operations 的入口,也就是執行任意 Operation 的起點就可以了。

在 method 的最一開始我們就能看到三組 for loop 去執行 *Ref.transform method :

for (const ref of Editor.pathRefs(editor)) {
  PathRef.transform(ref, op)
}

for (const ref of Editor.pointRefs(editor)) {
  PointRef.transform(ref, op)
}

for (const ref of Editor.rangeRefs(editor)) {
  RangeRef.transform(ref, op)
}

*Ref.transform method 裡做的事也不多,就是將傳入的 ref 資料傳給對應 concept 的 transform method api 並重新綁定或 unref 回傳的資料。

我們一樣拿 PathRef 的 transform method 當作範例:

transform(ref: PathRef, op: Operation): void {
  const { current, affinity } = ref

  if (current == null) {
    return
  }

  const path = Path.transform(current, op, { affinity })
  ref.current = path

  if (path == null) {
    ref.unref()
  }
},

Ref 對於 slate 而言是一個 lower level interface ,我們可以在許多 Transform methods 裡面看到使用 Ref 的蹤跡,能更全面地了解這項概念對我們之後要深入探討 Transforms 也會有不小的幫助。

那麼今天對於 Ref 的概念我們就介紹到這邊,下一篇就要來收回在之前的文章裡挖的坑,完整地介紹一輪 JS iterate 以及 slate 的 EntryType 是如何與它做搭配的。

我們一樣明天見囉~


上一篇
Day 13. slate × Interfaces × Positioning
下一篇
Day 15. slate × Interfaces × Iteration
系列文
深入 slate.js x 一起打造專屬的富文字編輯器吧!30

尚未有邦友留言

立即登入留言