iT邦幫忙

2021 iThome 鐵人賽

DAY 24
0
Modern Web

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

Day 24. slate × Normalizing

https://ithelp.ithome.com.tw/upload/images/20211009/20139359rfjAp0aNNg.png

在開始繼續深入源碼之前,我們先花點篇幅討論 Normalizing 這回事。

Normalizing - 正規化,對資料庫稍有著墨的話對這個名詞一定不陌生。

在關聯式資料庫的世界裡,執行資料正規化的『目的』是為了去除冗餘的資料,我們事先針對各種正規化形式定義出了不同的『 constraints 』,而資料庫的欄位結構則須符合這些『 constraints 』,例如:在第一正規化( 1NF )中,一個欄位只能存入一筆資料,而不能是複數筆,這就是其中一條 constraint 。

而 slate 為了應對過於彈性的 document model ,它也規範了一套內建的 constraints ,每當觸發了編輯器的資料更新時便會自動執行 Normalization ,確保資料符合 constraints 的規範。這使得開發者在進行開發時,能比使用 contenteditable 來得更容易預測編輯器的資料內容。

一起來看一下 slate 定義了哪些 built-in constraints 吧!

Built-in & Addable Constraints


  1. 所有的 Element 節點內必須含有至少一個 Text 子節點。在進行正規化時如果遭遇到不符合此規範的 Element 節點,會加入一個空的 Text 節點進入它的子層。

    原因:這是為了確保編輯器的 selectionanchorfocus point 能指向編輯器內的任意一個節點,這條限制也確保了 Void 元素無法被反白的特性。

  2. 將兩個相鄰且擁有完全相同的 properties 的 Text nodes 合併成同一個節點(不會刪減文字內容,只是單純做節點合併而已)。

    原因:這是為了防止編輯器內的 Text 節點在——『新增文字屬性』與『移除文字屬性』都會造成節點的拆分。這樣的情形下被無意義地無限擴張。

  3. Block type 節點只能在 Block type 或者 Inline-Block type & Text 節點之中,選擇一種作為它的子層節點。舉例: paragraph block 不能同時將另外的 paragraph block element 以及 link inline-block element 作為它的子層節點。 Slate 會以子層的第一個順位的節點作為判斷可接受類別的依據,不符合規範的子層節點將直接被移除。

    原因:這是為了讓『拆分 Block type 節點』相關的功能能夠維持固定的結果。

  4. 保持所有的 Inline-Block 節點都被環繞在 Text 節點之間, Slate 會透過『插入空的 Text 節點』來修正違反此 constraint 的情形。

    原因:這項 constraint 的目的更偏向於優化設計編輯器的資料模型,我們可以在 v0.16.0 的 Breaking changes 裡看到作者對這項 constraint 的相關解釋。

  5. 最頂層的 Editor 節點只能將 Block type 節點作為其子層節點,任何子層的 Inline type 與 Text 節點都會直接被移除。

    原因:這是為了確保編輯器裡存在 Block type 節點,使得『拆分 Block type 節點』相關的功能能正常運作。

雖然上述的這些 constraints 是 Slate 所提出,原始就存在於編輯器的限制,但它們當然是越少越精簡越好。作者也很大方地在 官方的 document 裡徵求任何刪減或修正這些 constraints 的想法

『 ? Although these constraints are the best we've come up with now, we're always looking for ways to have Slate's built-in constraints be less constraining if possible—as long as it keeps standard behaviors easy to reason about. If you come up with a way to reduce or remove a built-in constraint with a different approach, we're all ears! 』

除了上方提到的 Built-in constraints 之外, slate 同樣也允許開發者自行擴充 constraints , 它靠著 editor 裡的 normalizeNode 來執行 Normalizing ,同樣也是透過我們熟悉的 override 的方式來擴充他,來看一下官網的範例:

import { Transforms, Element, Node } from 'slate'

const withParagraphs = editor => {
  const { normalizeNode } = editor

  editor.normalizeNode = entry => {
    const [node, path] = entry

    // If the element is a paragraph, ensure its children are valid.
    if (Element.isElement(node) && node.type === 'paragraph') {
      for (const [child, childPath] of Node.children(editor, path)) {
        if (Element.isElement(child) && !editor.isInline(child)) {
          Transforms.unwrapNodes(editor, { at: childPath })
          return
        }
      }
    }

    // Fall back to the original `normalizeNode` to enforce other constraints.
    normalizeNode(entry)
  }

  return editor
}

在這個範例中我們為編輯器新增了一條 constraint :如果節點類型為 'paragraph'Element node type ,則它的子層節點不能出現 Block type 的 Element 節點,否則將會展開該子層節點的內容,將它的之下的子層節點內容向上提升。

Characteristics of Constraints


接著我們要依序介紹關於 Slate Normalizing 在官方文件上提到的重要特性,它們也都是我們在後續的篇章會深入探討裡頭的實作方式的內容,它們分別是:

  • Multi-pass Normalizing : Slate 正規化的 infinite-loop 與遞迴特性
  • Empty Children Early Constraint Execution : 針對上方的 Built-in constraints 的 1. 的初始正規化
  • Incorrect Fixes : 編輯器的更新結果無法滿足自定義的 constraint ,造成永無止盡的 Normalizing loop

Multi-pass Normalizing


slate 的 Normalizing 是會複數次執行的,因為每次執行 Normalize 並確實修改了資料內容時,又會再次觸發執行下一個 Normalization ,這樣的特性也導致了 slate Normalization 會以 recursive 的方式進行。

我們拿上方 override 過後的範例來說明:

<editor>
  <paragraph a>
    <paragraph b>
      <paragraph c>word</paragraph>
    </paragraph>
  </paragraph>
</editor>

此時編輯器會從 <paragraph c> 開始執行 normalizeNode ,因為它符合我們訂下的規範(子層節點只包含一個 Text 節點)因此不進行任何操作。

接著它向上看到 <paragraph b> 並執行 normalizeNode ,此處的節點因為子層包含了 Block type 節點( <paragraph c> )因此並不合法, Slate 會展開 <paragraph c> 使 document 成為下方的樣子:

<editor>
  <paragraph a>
    <paragraph b>word</paragraph>
  </paragraph>
</editor>

再來又經過了一樣的流程, Slate 會展開 <paragraph b> 使 document 成為下方的樣子。

<editor>
  <paragraph a>word</paragraph>
</editor>

最後我們會再跑一次 normalizeNode ,此時沒有出現任何更新,標記的骯髒路徑清空,整個 document 為合法的以後才跳出這個 Normalizing loop 。

Empty Children Early Constraint Execution


所有的正規化工作都被執行在 normalizeNode 這個 editor method 裡,除了第 1. 的 Built-in constraint 之外。

Slate 會在呼叫 normalizeNode ,對 document 進行正式的正規化之前先完整遍歷過 document 裡的 Element 節點並執行第 1. 的正規化,確保它們都擁有至少一個子層節點後才會呼叫 normalizeNode

因此在開發時要避免出現對無子層節點的 Element 節點做正規化,相關的自定義 constraint 。

例如:建立一個完整移除一個內容被全數刪除的 table 的 constraint 。它永遠不會被執行到,因為在 table 資料被完整刪除,裡頭不存在任何子層節點時, Slate 會先自動為它加上一組空的 Text node 。

Incorrect Fixes


最後是當編輯器的更新結果無法滿足自定義的 constraint ,將會造成永無止盡的 Normalizing loop 。因為節點將會持續被標記為不合法的,卻永遠無法被正確地修正。

我們一樣拿官方範例作為例子:

// WARNING: this is an example of incorrect behavior!
const withLinks = editor => {
  const { normalizeNode } = editor

  editor.normalizeNode = entry => {
    const [node, path] = entry

    if (
      Element.isElement(node) &&
      node.type === 'link' &&
      typeof node.url !== 'string'
    ) {
      // ERROR: null is not a valid value for a url
      Transforms.setNodes(editor, { url: null }, { at: path })
      return
    }

    normalizeNode(entry)
  }

  return editor
}

我們添加了一條 constraint : 'link' 的 element 節點類別的 url property 必須為 string type 。

但是位於判斷式底下的修改卻不是正確的,因為修改後的 url 仍然為 null ,導致下一次的 Normalizing loop 仍然會進入這個判斷式裡,並再次進行無意義的錯誤修改。


今天只是端出官方文件裡的內容當作前菜而已,介紹完基本的 Normalizing 概念後我們接下來就要一步步深入 slate 的內部又是如何實作的。

一樣明天見嘍~!


上一篇
Day 23. slate × Operation × L-transform
下一篇
Day 25. slate × Normalizing × Dirty-Path
系列文
深入 slate.js x 一起打造專屬的富文字編輯器吧!30

尚未有邦友留言

立即登入留言