slate原始碼解析(三)- 定位

愛喝可樂的咖啡發表於2023-10-02

介面定義

能夠對於文字、段落乃至任何元素的精準定位 並做出增刪改查,都是在開發一款富文字編輯器時一項最基本也是最重要的功能之一。讓我們先來看看Slate中對於如何在文件樹中定位元素是怎麼定義的[原始碼]

/**
 * The `Location` interface is a union of the ways to refer to a specific
 * location in a Slate document: paths, points or ranges.
 *
 * Methods will often accept a `Location` instead of requiring only a `Path`,
 * `Point` or `Range`. This eliminates the need for developers to manage
 * converting between the different interfaces in their own code base.
 */
export type Location = Path | Point | Range

Location是一個包含了PathPointRange的聯合型別,代指了Slate中所有關於“定位”的概念,同時也方便了開發。例如在幾乎所有的Transforms方法中,都可以透過傳遞Location引數來決定Transforms方法需要應用到文件中的哪些位置上[連結]

All transforms support a parameter options. This includes options specific to the transform, and general NodeOptions to specify which Nodes in the document that the transform is applied to.

interface NodeOptions {
  at?: Location
  match?: (node: Node, path: Location) => boolean
  mode?: 'highest' | 'lowest'
  voids?: boolean
}

Path

Path是三個中最基本的概念,也是唯一一個不可擴充的型別。

/**
 * `Path` arrays are a list of indexes that describe a node's exact position in
 * a Slate node tree. Although they are usually relative to the root `Editor`
 * object, they can be relative to any `Node` object.
 */
export type Path = number[]

Path型別就是一個陣列,用來表示Slate文件樹中自根節點root到指定node的絕對路徑。我們以下邊的示例來演示下各個node所代表的路徑:

const initialValue: Descendant[] = [
    // path: [0]
    {
        type: 'paragraph',
        children: [
            { text: 'This is editable ' },		// path: [0, 0]
            { text: 'rich text!', bold: true }	// path: [0, 1]
        ]
    },
    // path: [1]
    {
        type: 'paragraph',
        children: [
            { text: 'It\' so cool.' }	// path: [1, 0]
        ]
    }
]

雖然Path所代表的路徑通常是以頂層Editor作為root節點的,但也會有其他情況,比如由Node提供的get方法中傳入的Path引數則代表的是相對路徑[原始碼]

/**
 * Get the descendant node referred to by a specific path. If the path is an
 * empty array, it refers to the root node itself.
 */
get(root: Node, path: Path): Node {
    let node = root

    for (let i = 0; i < path.length; i++) {
        const p = path[i]

        if (Text.isText(node) || !node.children[p]) {
            throw new Error(
                `Cannot find a descendant at path [${path}] in node: ${Scrubber.stringify(
                    root
                )}`
            )
        }

        node = node.children[p]
    }

    return node
}

Point

Point是在Path的基礎上封裝而來的概念:

/**
 * `Point` objects refer to a specific location in a text node in a Slate
 * document. Its path refers to the location of the node in the tree, and its
 * offset refers to the distance into the node's string of text. Points can
 * only refer to `Text` nodes.
 */
export interface BasePoint {
  path: Path
  offset: number
}

export type Point = ExtendedType<'Point', BasePoint>

用於定位單個字元在文件中的位置;先用Path定位到字元所屬的Node,再根據offset欄位資訊精確到字元是在該Nodetext文字中的偏移量。

我們仍然以前面的示例做說明,如果想要將游標位置定位到第一句話"This is editable rich text!"的感嘆號之後,其Point值為:

const initialValue: Descendant[] = [
    // path: [0]
    {
        type: 'paragraph',
        children: [
            { text: 'This is editable ' },		// path: [0, 0]
            { text: 'rich text!', bold: true }	// { path: [0, 1], offset: 10 }
        ]
    },
    // path: [1]
    {
        type: 'paragraph',
        children: [
            { text: 'It\' so cool.' }	// path: [1, 0]
        ]
    }
]

Range

最後一個Range則是再在Point基礎上延伸封裝而來的概念:

/**
 * `Range` objects are a set of points that refer to a specific span of a Slate
 * document. They can define a span inside a single node or a can span across
 * multiple nodes.
 */
export interface BaseRange {
  anchor: Point
  focus: Point
}

export type Range = ExtendedType<'Range', BaseRange>

它代表的是一段文字的集合;包含有兩個Point型別的欄位anchorfocus。看到這,應該能發現Slate中Range的概念其實與DOM中的Selection物件是一樣的,anchorfocus分別對應原生Selection中的錨點anchorNode和焦點focusNode;這正是Slate對於原生Selection做的抽象,使之在自身API中更方便地透過游標選區來獲取文件樹中的內容。

我們在上一篇文章中有提到過的Editor.selection是一個Selections型別,其本身就是一個Range型別,專門用來指定編輯區域中的游標位置[原始碼]

export type BaseSelection = Range | null

export type Selection = ExtendedType<'Selection', BaseSelection>

Refs

當我們需要長期追蹤某些Node時,可以透過獲取對應NodePath/Point/Range值並儲存下來以達到目的。但這種方式存在的問題是,在Slate文件樹經過insertremove等操作後,原先的Path/Point/Range可能會產生變動或者直接作廢掉。

Refs的出現就是為了解決上述問題。

三者的ref的定義分別在slate/src/interfaces/下的path-ref.tspoint-ref.tsrange-ref.ts檔案中:

/**
 * `PathRef` objects keep a specific path in a document synced over time as new
 * operations are applied to the editor. You can access their `current` property
 * at any time for the up-to-date path value.
 */
export interface PathRef {
  current: Path | null
  affinity: 'forward' | 'backward' | null
  unref(): Path | null
}

/**
 * `PointRef` objects keep a specific point in a document synced over time as new
 * operations are applied to the editor. You can access their `current` property
 * at any time for the up-to-date point value.
 */
export interface PointRef {
  current: Point | null
  affinity: TextDirection | null
  unref(): Point | null
}

/**
 * `RangeRef` objects keep a specific range in a document synced over time as new
 * operations are applied to the editor. You can access their `current` property
 * at any time for the up-to-date range value.
 */
export interface RangeRef {
  current: Range | null
  affinity: 'forward' | 'backward' | 'outward' | 'inward' | null
  unref(): Range | null
}

都包含以下三個欄位:

  • current:同React ref用法一樣,用current欄位儲存最新值
  • affinity:當前定位所代表的節點 在文件樹變動時如果受到影響的話,所採取的調整策略
  • unref:解除安裝方法;徹底刪除當前的ref確保能夠被引擎GC掉

另外我們先來看下各種RefsSlate儲存的方式,跳到slate/src/utils/weak-maps.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()

可以看到Refs的儲存區在一個Set資料結構中的;而對於不同EditorSet<xxxRef>的儲存則是放在雜湊表WeakMap中的,使其不會影響到GC(WeakMap同Map原理一樣,都是ES6之後新出的雜湊資料結構,與Map不同點在於其持有的引用算作弱引用)。

生成三種Ref以及獲取相應Refs的方法定義在EditorInterface介面上[原始碼]

export interface EditorInterface {
    // ...
    pathRef: (
    	editor: Editor,
    	path: Path,
    	options?: EditorPathRefOptions
  	) => PathRef
    
    pointRef: (
    	editor: Editor,
    	point: Point,
    	options?: EditorPointRefOptions
  	) => PointRef
    
    rangeRef: (
    	editor: Editor,
    	range: Range,
    	options?: EditorRangeRefOptions
  	) => RangeRef
    
    pathRefs: (editor: Editor) => Set<PathRef>
    
    pointRefs: (editor: Editor) => Set<PointRef>
    
    rangeRefs: (editor: Editor) => Set<RangeRef>
}

PathPointRange三者的實現邏輯都差不多,下面就以Path為例作介紹。

pathRef

  /**
   * Create a mutable ref for a `Point` object, which will stay in sync as new
   * operations are applied to the editor.
   */
  pointRef(
    editor: Editor,
    point: Point,
    options: EditorPointRefOptions = {}
  ): PointRef {
    const { affinity = 'forward' } = options
    const ref: PointRef = {
      current: point,
      affinity,
      unref() {
        const { current } = ref
        const pointRefs = Editor.pointRefs(editor)
        pointRefs.delete(ref)
        ref.current = null
        return current
      },
    }

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

實現邏輯非常簡單,就是根據傳入的引數放入ref物件中,並新增解除安裝方法unref。然後透過pathRefs拿到對應的Set,講當前的ref物件新增進去。unref方法中實現的則是相反的操作:透過pathRefs拿到對應的Set後,將當前ref物件移除掉,然後再把ref.current的值置空。

pathRefs

  /**
   * Get the set of currently tracked path refs of the editor.
   */
  pathRefs(editor: Editor): Set<PathRef> {
    let refs = PATH_REFS.get(editor)

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

    return refs
  }

程式碼非常簡短,類似懶載入的方式做Set的初始化,然後呼叫get方法獲取集合後返回。

Ref同步

前一篇文章我們提到過,用於修改內容的單個Transform方法會包含有多個OperationOperation則是Slate中的原子化操作。而在Slate文件樹更新之後,解決Ref同步更新的方式就是:在執行了任意Operation之後,對所有的Ref根據執行的Operation型別做相應的調整。

看到create-editor.ts中的apply方法,該方法是所有Operation執行的入口[原始碼]

apply: (op: Operation) => {
    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)
    }
    
    // ...
}

apply方法的最開頭就是三組for of迴圈,對所有的Ref執行對應的Ref.transform方法並傳入當前執行的Operation

同樣以Path為例,看下path-ref.ts中的PathRef.transform方法[原始碼]

export const PathRef: PathRefInterface = {
  /**
   * Transform the path ref's current value by an operation.
   */
  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中的資料和Operation作為引數,傳遞給對應的Path.transform,返回更新後的path值並賦值給ref.current。如果path為空,則說明當前ref指代的位置已經失效,呼叫解除安裝方法unref

至於Path.transform的細節在本篇就不展開了,我們留到後續的Transform篇再統一講解: )