iT邦幫忙

2021 iThome 鐵人賽

DAY 27
0
Modern Web

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

Day 27. slate × Normalizing × normalize

https://ithelp.ithome.com.tw/upload/images/20211012/201393594zCF74ayKU.png

前一篇我們介紹了 Slate Normalizing 裡負責實作節點正規化以及讓使用者自定義 constraints 的主要函式: create-editor 編輯器的 normalizeNode action 。還沒閱讀過的讀者 傳送門 在此~

今天我們緊接著要介紹 Normalizing 章節的另一個主要函式:interfaces/editor.ts 的 normalize method 。以及輔助函式: withoutNormalizingisNormalizing

它們除了負責呼叫 normalizeNode 執行節點的正規化之外,還有處理整個 Normalizing 功能的效能優化以及實作我們在 Day24 裡提到過的特性。

如果説 normalizeNode 是肉眼可見的表皮的話,那麼今天要介紹的 normalize method 以及它的小夥伴們就是支撐著整個 Normalizing 系統能夠順利運作的骨幹啊!

那麼事不宜遲,讓我們直接深入它的 code 一探究竟

normalize Method Content


我們可以把整個 method 拆成兩個部分:

  • 函式宣告與主要段落的預處理( Preprocess )
  • 搭配 withoutNormalizing 實作的主要段落( Main-Process )

Preprocess 的部分除了幾個判斷式負責輔助主要段落之外,還有一些額外的功能:

  • 取得 Dirty-Path value

    這部分很基本,就是另外宣告一個 getDirtyPaths function 提供給後續的功能取得 DIRTY_PATHS 的 value 而已:

    normalize(
      editor: Editor,
      options: {
        force?: boolean
      } = {}
    ): void {
    	const getDirtyPaths = (editor: Editor) => {
    	  return DIRTY_PATHS.get(editor) || []
    	}
    
    	// ...
    }
    
  • 處理 forth option

    forth 參數代表的含意就是忽略計算出來的 Dirty-Path 結果,強行正規化編輯器內的所有節點。

    這裡的做法也很簡單:透過 Node.nodes method 取出編輯器當前存在的全部節點,並設定進 DIRTY_PATHS 裡:

    normalize(
      editor: Editor,
      options: {
        force?: boolean
      } = {}
    ): void {
    	// ...
    
    	if (force) {
        const allPaths = Array.from(Node.nodes(editor), ([, p]) => p)
        DIRTY_PATHS.set(editor, allPaths)
      }
    
    	// ...
    }
    

接著就輪到了主要段落的部分了,我們會依序介紹下方它所實現的功能們:

  • Normalization Deferring (正規化延遲)
  • Multi-pass Normalizing (正規化的 Infinite-loop )
  • Empty Children Early Constraint Execution (第 1. constraint 的初始正規化)

Normalization Deferring


還記得在 前一篇 文章中提到過, Slate 正規化的運作方式是『一組完整的 FLUSHING 搭配一次 Normalize 』, withoutNormalize 就是實現這項功能最核心的 method ,所有會需要 Normalizing 功能的 Transform methods 裡都一定會包進這個 method 裡來實作,隨便舉個 insertNodes 當作範例:

insertNodes<T extends Node>(
    editor: Editor,
    // ... args
  ): void {
    Editor.withoutNormalizing(editor, () => {
      // ... Implementation
    })
  },

先一起來看一下它的 code 長啥樣子:

/**
 * Call a function, deferring normalization until after it completes.
 */

withoutNormalizing(editor: Editor, fn: () => void): void {
  const value = Editor.isNormalizing(editor)
  NORMALIZING.set(editor, false)
  try {
    fn()
  } finally {
    NORMALIZING.set(editor, value)
  }
  Editor.normalize(editor)
},

它首先儲存了 isNormalizing method 回傳的資料作為 NORMALIZING WeakMap 的初始值。

isNormalizing 其實單純就是回傳當前 editor 的 NORMALIZING value ,代表編輯器當前是否為『完成正規化』的狀態,只是多加了一層三元判斷:如果 value === undefined 則回傳 true (因為 undefined 為初始值,編輯器的初始狀態就是已經完成正規化的狀態了)。

/**
 * Check if the editor is currently normalizing after each operation.
 */

isNormalizing(editor: Editor): boolean {
  const isNormalizing = NORMALIZING.get(editor)
  return isNormalizing === undefined ? true : isNormalizing
},

接著將 NORMALIZING value 設為 false ,等執行完傳入的 fn 以後再設 NORMALIZING 回先前存下來的初始值,並重新執行 normalize method 。

這麼做的用途是推延執行 normalize ,在 normalize 裡有一行 statement 會去呼叫 isNormalizing 回傳的 value ,如果回傳 false 就直接跳過這次的 normalize

normalize(
  editor: Editor,
  options: {
    force?: boolean
  } = {}
): void {
	// ...

	if (!Editor.isNormalizing(editor)) {
    return
  }

	// ... Implementation
}

這使得一組 Transform 裡頭除了最初呼叫的那次包進 withoutNormalizingfn method 會推遲執行 normalize method 之外,其餘在過程中呼叫的 normalize 都會因為 isNormalizing 回傳值為 false 因而直接跳過。

Multi-pass Normalizing


基本上整個 normalize method 是否結束完全取決於 DIRTY_PATHS 裡頭是否仍然有值,前面也有提到過 normalize method 的主要段落是搭配 withoutNormalizing ,放在傳入的 fn 參數裡去執行,所以就算完整地執行完 fn 的內容以後仍然會再執行一次 normalize method :

normalize(
  editor: Editor,
  options: {
    force?: boolean
  } = {}
): void {
	// ...

	// Main-Process
	Editor.withoutNormalizing(editor, () => {
		// ...
	});
},

withoutNormalizing(editor: Editor, fn: () => void): void {
  const value = Editor.isNormalizing(editor)
  NORMALIZING.set(editor, false)
  try {
    fn()
  } finally {
    NORMALIZING.set(editor, value)
  }
  Editor.normalize(editor)
},

它會不斷地重複執行 normalize method ,直到從 getDirtyPath 取得的 length 為 0 時才真正結束。

所以我們不會在函式裡看到將 Dirty-Paths 設為空陣列之類的初始化動作,它只會不斷地重複執行 Normalizing 直到隨著每次新的 Operation 呼叫,透過 apply method 重新計算出要設定進 DIRTY_PATHS 的 value 為空陣列(詳請請回顧 上一篇 的內容)以後再透過預處理段落的輔助判斷式來跳出 method :

normalize(
  editor: Editor,
  options: {
    force?: boolean
  } = {}
): void {
	// ...

	if (getDirtyPaths(editor).length === 0) {
	  return
	}

	// Main-Process
	Editor.withoutNormalizing(editor, () => {
		// ...
	});
},

在主要段落裡也是透過一組 while loop 重複 pop 出 Dirty-Path 的 value 來重複呼叫 normalizeNode 。當然作者還是有設一個門檻不讓整個正規化的次數無限上綱,也會先確定 pop 出來的路徑所指向的節點確實存在於 document 中再呼叫 normalizeNode

// Main-Process
Editor.withoutNormalizing(editor, () => {

	// ...

	const max = getDirtyPaths(editor).length * 42 // HACK: better way?
  let m = 0

  while (getDirtyPaths(editor).length !== 0) {
    if (m > max) {
      throw new Error(`
        Could not completely normalize the editor after ${max} iterations! This is usually due to incorrect normalization logic that leaves a node in an invalid state.
      `)
    }

    const dirtyPath = getDirtyPaths(editor).pop()!

    // If the node doesn't exist in the tree, it does not need to be normalized.
    if (Node.has(editor, dirtyPath)) {
      const entry = Editor.node(editor, dirtyPath)
      editor.normalizeNode(entry)
    }
    m++
  }
});

原來如此!再綜合上一小節的內容就能夠確保『正規化會被推延到最後才執行那麼一次』了!
但我想問個問題:如果在呼叫 normalizeNode 執行正規化的過程中為了更新 document 而又另外呼叫了 Transform method 又會發生什麼事呢?這些 method 所推延的 normalize method 會在最後成功執行嗎?


答案是:不會!

還記得 normalize 將主要段落包進 withoutNormalizing 這件事嗎?

這麼做除了會讓 normalize method 被重複執行之外,也會同時將 NORMALIZING 設為 false ,因此在新的 Transform method 執行的過程中所呼叫的 normalize method ,包含 Transform 開頭包進 withoutNormalizing 受到推延執行的那一組 normalize method 都會因為 NORMALIZING 被設為 false 而跳出:

// New "Transform" call during "normalizeNode"
withoutNormalizing(editor: Editor, fn: () => void): void {
  const value = Editor.isNormalizing(editor) // set "value" to false
  NORMALIZING.set(editor, false)
  try {
    fn()
  } finally {
    NORMALIZING.set(editor, value) // set "NORMALIZING" the value of "value" variable, which is still false
  }
  Editor.normalize(editor) // "normalize" won't execute successfully since "NORMALIZING: false"
}

因此在過程中執行新的 Transform 只會更新 document 以及 Dirty-Paths 的 value 而已,並不會再次出發執行另一組 Normalization ,直到完整執行完 Transform 的內容以後才會回到原本尚未執行完的 normalize method 裡,以新的 DIRTY_PATHS value 繼續執行正規化。

綜合前兩篇介紹的內容,我們可以將整個 Normalizing 與 Transform / Operation 的互動流程以下圖呈現:

https://ithelp.ithome.com.tw/upload/images/20211012/20139359quDZtgm6eg.png

Empty Children Early Constraint Execution


這一段的 code 基本上與 normalizeNode 實作第 1. constraint 的內容大同小異。

就是取出所有的 Dirty-Paths ,確保這些路徑指向的節點都確實存在於 document 裡之後,將不存在子節點的 Element node 插入空的 Text void 節點:

Editor.withoutNormalizing(editor, () => {
	/*
    Fix dirty elements with no children.
    editor.normalizeNode() does fix this, but some normalization fixes also require it to work.
    Running an initial pass avoids the catch-22 race condition.
  */
  for (const dirtyPath of getDirtyPaths(editor)) {
    if (Node.has(editor, dirtyPath)) {
      const [node, _] = Editor.node(editor, dirtyPath)

      // Add a text child to elements with no children.
      // This is safe to do in any order, by definition it can't cause other paths to change.
      if (Element.isElement(node) && node.children.length === 0) {
        const child = { text: '' }
        Transforms.insertNodes(editor, child, {
          at: dirtyPath.concat(0),
          voids: true,
        })
      }
    }
  }
}
*裡頭的 comment 是說因為其他的正規化有些需要這項正規化事先完成後才能繼續執行,為了防止 catch-22 race condition 的發生所以進行一次 initial 的 Normalizing ,但原諒筆者沒有時間去找出實際會發生 race condition 的情境 ? 等未來有時間再補充這邊的內容! *

最後我們放上一張延續 Day10 , Slate 完整的運作流程圖:

https://ithelp.ithome.com.tw/upload/images/20211012/201393593GX3OuA8NF.png

  1. 我們透過 Transform method 觸發編輯器的更新,並複數次地呼叫 Operations
  2. 首次呼叫的 Operation 除了會執行 transformnormalize 之外,也會將 FLUSHING 設為 true ,並將 onChange 的執行以 Promise 的 Micro-Task 包裝起來
  3. Operation 透過 getDirtyPath 取得並更新 DIRTY_PATHS WeakMap variable
  4. Operation 再透過 GeneralTransforms.transform 搭配 Immer Draft State 呼叫 applyToDraft 更新 childrenselection
  5. 執行 Transform 所推延的 normalize method 搭配 normalizeNode 執行 Dirty-Paths 的節點正規化,再次呼叫 Transform 來更新節點以滿足 constraints 的規範並重跑一次相同的 Transform 流程,除了被推延的 normalize 不會正確地被執行以外
  6. 完成所有 synchronous 的編輯器更新後,執行 Micro-Task 的內容,將 FLUSHING 設為 false 並觸發 onChange function ,結束一輪完整的編輯器更新!

到此為止我們終於完整介紹完了一整輪 Slate 編輯器的運作與更新流程了!整個正規化的章節也在今天告一個段落~

相信具備了完整知識的讀者們未來在開發上或是處理類似的情境問題時都會更有解題的方向。

接下來我們準備要進入到整個系列文章的最後一個章節:『 Transform 』。

前面這麼常提到它,相信讀者對它肯定是不陌生的,一切相關的介紹我們就留到明天吧~


上一篇
Day 26. slate × Normalizing × normalizeNode
下一篇
Day 28. slate × Transforms × Node
系列文
深入 slate.js x 一起打造專屬的富文字編輯器吧!30

1 則留言

1
juck30808
iT邦新手 3 級 ‧ 2021-10-12 18:31:09

第27天了! 恭喜即將完賽 (拍手!!!

感謝你!!前幾天還做夢夢到忘記發文結果斷賽QQ

我要留言

立即登入留言