您當前的位置:首頁 > 舞蹈

slate 架構設計分析

作者:由 Wendell 發表于 舞蹈時間:2020-10-06

slate 是一款流行的富文字編輯器——不,與其說它是一款編輯器,不如說它是一個編輯器框架,在這個框架上,開發者可以透過外掛的形式提供豐富的富文字編輯功能。slate 比較知名的使用者(包括前使用者)有 GitBook 和語雀,具體可以檢視官網的 products 頁面。

所謂“工欲善其事,必先利其器”,想要在專案中用好 slate,掌握其原理是一種事半功倍的做法。對於開發編輯器的同學來說,slate 的架構和技術選型也有不少值得學習的地方。這篇文章將會從以下幾個方面探討 slate:

文章包括以下主題:

slate 資料模型(model)的設計

model 變更機制

model 校驗

外掛系統

undo/redo 機制

渲染機制

鍵盤事件處理

選區和游標處理

slate 架構簡介

slate 作為一個編輯器框架,分層設計非常明顯。slate 倉庫下包含四個 package:

slate:這一部分是編輯器的核心,定義了資料模型(model),操作模型的方法和編輯器例項本身

slate-history:以外掛的形式提供 undo/redo 能力,本文後面將會介紹 slate 的外掛系統設計

slate-react:以外掛的形式提供 DOM 渲染和使用者互動能力,包括游標、快捷鍵等等

slate-hyperscript:讓使用者能夠使用 JSX 語法來建立 slate 的資料,本文不會介紹這一部分

slate (model)

先來看 slate package,這一部分是 slate 的核心,定義了編輯器的資料模型、操作這些模型的基本操作、以及建立編輯器例項物件的方法。

model 結構

slate 以樹形結構來表示和儲存文件內容,樹的節點型別為

Node

,分為三種子型別:

export type Node = Editor | Element | Text

export interface Element {

children: Node[]

[key: string]: unknown

}

export interface Text {

text: string

[key: string]: unknown

}

Element

型別含有

children

屬性,可以作為其他

Node

的父節點

Editor

可以看作是一種特殊的

Element

,它既是編輯器例項型別,也是文件樹的根節點

Text

型別是樹的葉子結點,包含文字資訊

使用者可以自行拓展

Node

的屬性,例如透過新增

type

欄位標識

Node

的型別(paragraph, ordered list, heading 等等),或者是文字的屬性(italic, bold 等等),來描述富文字中的文字和段落。

我們可以透過官方的 richtext demo 來直觀地感受一下 slate model 的結構。

在本地執行 slate,透過 React Dev Tool 找到

Slate

標籤,引數中的

editor

就是編輯器例項,右鍵選擇它,然後點選 store as global variable,就可以在 console 中 inspect 這個物件了。

slate 架構設計分析

可以看到它的

children

屬性中有四個

Element

並透過

type

屬性標明瞭型別,對應編輯器中的四個段落。第一個 paragraph 的

children

中有 7 個

Text

Text

bold

italic

這些屬性描述它們的文字式樣,對應普通、粗體、斜體和行內程式碼樣式的文字。

slate 架構設計分析

那麼為什麼 slate 要採用樹結構來描述文件內容呢?採用樹形結構描述 model 有這樣一些好處:

富文字文件本身就包含層次資訊,比如 page,section, paragraph, text 等等,用樹進行描述符合開發者的直覺

文字和屬性資訊存在一處,方便同時獲取文字和屬性資訊

model tree

Node

和 DOM tree Element 存在對映關係,這樣在處理使用者操作的時候,能夠很快地從 element 對映到

Node

方便用元件以遞迴的方式渲染 model

用樹形結構當然也有一些問題:

對於協同編輯的衝突處理,樹的解決方案比線性 model 複雜

持久化 model / 建立編輯器的時候需要進行序列化 / 反序列化

游標和選區

有了 model,還需要在 model 中定位的方法,即選區(selection),slate 的選區採用的是

Path

加 offset 的設計。

Path

是一個數字型別的陣列

number[]

,它代表的是一個

Node

和它的祖先節點,在各自的上一級祖先節點的

children

陣列中的 index。

export

type

Path

=

number

[]

offset 則是對於 Text 型別的節點而言,代表游標在文字串中的 index 位置。

Path

加上 offet 即構成了

Point

型別,即可表示 model 中的一個位置。

export

interface

Point

{

path

Path

offset

number

}

兩個

Point

型別即可組合為一個

Range

,表示選區。

export

interface

Range

{

anchor

Point

// 選區開始的位置

focus

Point

// 選區結束的位置

}

比如我這樣選中一段文字(我這裡是從後向前選擇的):

slate 架構設計分析

透過訪問

editor

selection

屬性來檢視當前的選區位置:

slate 架構設計分析

可見,選擇的起始位置

focus

在第一段的最後一個文字處,且由於第一段中 “bold” 被加粗,所以實際上有 3 個

Text

的節點,因此

anchor

path

即為

[1, 2]

offset

為游標位置在第三個

Text

節點中的偏移量 82。

如何對 model 進行變更

有了 model 和對 model 中位置的描述,接下來的問題就是如何對 model 進行變更(mutation)了。編輯器例項提供了一系列方法(由

Editor

interface 所宣告),如

insertNode

insertText

等,直接供外部模組變更 model,那麼 slate 內部是如何實現這些方法的呢?

在閱讀原始碼的過程中,瞭解到這一點可能會對你有幫助:slate 在最近的一次重構中完全去除了類(class),所有資料結構和工具方法都是由同名的介面和物件來實現的,比如

Editor

export

interface

Editor

{

children

Node

[]

// 。。。其他一些屬性

}

export

const

Editor

=

{

/**

* Get the ancestor above a location in the document。

*/

above

<

T

extends

Ancestor

>(

editor

Editor

options

{

at?

Location

match?

NodeMatch

<

T

>

mode

?:

‘highest’

|

‘lowest’

voids?

boolean

}

=

{}

NodeEntry

<

T

>

|

undefined

{

// 。。。

}

},

}

interface

Editor

為編輯器例項所需要實現的介面,而物件

Editor

則封裝了操作 interface

Editor

的一些方法。所以,在檢視

Editor

的例項

editor

的方法時,要注意方法實際上定義在 create-editor。ts 檔案中。這可能是第一次閱讀 slate 程式碼時最容易感到混淆的地方。

通常來說,對 model 進行的變更應當是

原子化

(atomic)的,這就是說,應當存在一個獨立的資料結構去描述對 model 發生的變更,這些描述通常包括變更的型別(type)、路徑(path)和內容(payload),例如新增的文字、修改的屬性等等。原子化的變更方便做 undo/redo,也方便做協同編輯(當然需要對沖突的變更做轉換,其中一種方法就是有名的 operation transform, OT)。

slate 也是這麼處理的,它對 model 進行變更的過程主要分為以下兩步,第二步又分為四個子步驟:

透過

Transforms

提供的一系列方法生成

Operation

Operation

進入 apply 流程

記錄變更髒區

Operation

進行 transform

對 model 正確性進行校驗

觸發變更回撥

slate 架構設計分析

首先,透過

Transforms

所提供的一系列方法生成

Operation

,這些方法大致分成四種類型:

export

const

Transforms

=

{

。。。

GeneralTransforms

。。。

NodeTransforms

。。。

SelectionTransforms

。。。

TextTransforms

}

NodeTransforms

:對

Node

的操作方法

SelectionTransforms

:對選區的操作方法

TextTransforms

:對文字操作方法

特殊的是

GeneralTransforms

,它並不生成

Operation

而是對

Operation

進行處理,只有它能直接修改 model,其他 transforms 最終都會轉換成

GeneralTransforms

中的一種。

這些最基本的方法,也即是

Operation

型別僅有 9 個:

insert_node

:插入一個 Node

insert_text

:插入一段文字

merge_node

:將兩個 Node 組合成一個

move_node

:移動 Node

remove_node

:移除 Node

remove_text

:移除文字

set_node

:設定 Node 屬性

set_selection

:設定選區位置

split_node

:拆分 Node

我們以

Transforms。insertText

為例(略過一些對游標位置的處理):

export

const

TextTransforms

=

{

insertText

editor

Editor

text

string

options

{

at?

Location

voids?

boolean

}

=

{}

{

Editor

withoutNormalizing

editor

()

=>

{

// 對選區和 voids 型別的處理

const

{

path

offset

}

=

at

editor

apply

({

type

‘insert_text’

path

offset

text

})

})

},

}

可見

Transforms

的最後生成了一個

type

insert_text

Operation

並呼叫

Editor

例項的

apply

方法。

apply

內容如下:

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

}

// 執行變更

Transforms

transform

editor

op

// 記錄 operation

editor

operations

push

op

// 進行校驗

Editor

normalize

editor

// Clear any formats applied to the cursor if the selection changes。

if

op

type

===

‘set_selection’

{

editor

marks

=

null

}

if

FLUSHING

get

editor

))

{

// 標示需要清空 operations

FLUSHING

set

editor

true

Promise

resolve

()。

then

(()

=>

{

// 清空完畢

FLUSHING

set

editor

false

// 通知變更

editor

onChange

()

// 移除 operations

editor

operations

=

[]

})

}

},

其中

Transforms。transform(editor, op)

就是在呼叫

GeneralTransforms

處理

Operation

transform

方法的主體是一個 case 語句,根據

Operatoin

type

分別應用不同的處理,例如對於

insertText

,其邏輯為:

const

{

path

offset

text

}

=

op

const

node

=

Node

leaf

editor

path

const

before

=

node

text

slice

0

offset

const

after

=

node

text

slice

offset

node

text

=

before

+

text

+

after

if

selection

{

for

const

point

key

of

Range

points

selection

))

{

selection

key

=

Point

transform

point

op

}

}

break

可以看到,這裡的程式碼會直接操作 model,即修改

editor。children

editor。selection

屬性。

slate 使用了 immer 來應用 immutable data,即

createDraft

finishDrag

成對的呼叫。使用 immer 可以將建立資料的開銷減少到最低,同時又能使用 JavaScript 原生的 API 和賦值語法。

model 校驗

對 model 進行變更之後還需要對 model 的合法性進行校驗,避免內容出錯。校驗的機制有兩個重點,一是對髒區域的管理,一個是

withoutNormalizing

機制。

許多 transform 在執行前都需要先呼叫

withoutNormalizing

方法判斷是否需要進行合法性校驗:

export

const

Editor

=

{

// 。。。

withoutNormalizing

editor

Editor

fn

()

=>

void

void

{

const

value

=

Editor

isNormalizing

editor

NORMALIZING

set

editor

false

fn

()

NORMALIZING

set

editor

value

Editor

normalize

editor

}

}

可以看到這段程式碼透過棧幀(stack frame)儲存了是否需要合法性校驗的狀態,保證 transform 執行前後是否需要合法性校驗的狀態是一致的。transform 可能呼叫別的 transform,不做這樣的處理很容易導致冗餘的合法性校驗。

合法性校驗的入口是

normalize

方法,它建立一個迴圈,從 model 樹的葉節點自底向上地不斷獲取髒路徑並呼叫

nomalizeNode

檢驗路徑所對應的節點是否合法。

while

getDirtyPaths

editor

)。

length

!==

0

{

// 對校驗次數做限制的 hack

const

path

=

getDirtyPaths

editor

)。

pop

()

const

entry

=

Editor

node

editor

path

editor

normalizeNode

entry

m

++

}

讓我們先來看看髒路徑是如何生成的(省略了不相關的部分),這一步發生在

Transforms。transform(editor, op)

之前:

apply

op

Operation

=>

{

// 髒區記錄

const

set

=

new

Set

()

const

dirtyPaths

Path

[]

=

[]

const

add

=

path

Path

|

null

=>

{

if

path

{

const

key

=

path

join

‘,’

if

set

has

key

))

{

set

add

key

dirtyPaths

push

path

}

}

}

const

oldDirtyPaths

=

DIRTY_PATHS

get

editor

||

[]

const

newDirtyPaths

=

getDirtyPaths

op

for

const

path

of

oldDirtyPaths

{

const

newPath

=

Path

transform

path

op

add

newPath

}

for

const

path

of

newDirtyPaths

{

add

path

}

DIRTY_PATHS

set

editor

dirtyPaths

},

dirtyPaths

一共有以下兩種生成機制:

一部分是在 operation apply 之前的

oldDirtypath

,這一部分根據 operation 的型別做路徑轉換處理

另一部分是 operation 自己建立的,由

getDirthPaths

方法獲取

normalizeNode

方法會對

Node

進行合法性校驗,slate 預設有以下校驗規則:

文字節點不校驗,直接返回,預設是正確的

空的

Elmenet

節點,需要給它插入一個

voids

型別節點

接下來對非空的

Element

節點進行校驗

首先判斷當前節點是否允許包含行內節點,比如圖片就是一種行內節點

接下來對子節點進行處理

如果當前允許行內節點而子節點非文字或行內節點(或當前不允許行內節點而子節點是文字或行內節點),則刪除該子節點

確保行內節點的左右都有文字節點,沒有則插入一個空文字節點

確保相鄰且有相同屬性的文位元組點合併

確保有相鄰文位元組點的空文位元組點被合併

合法性變更之後,就是呼叫

onChange

方法。這個方法 slate package 中定義的是一個空函式,實際上是為外掛準備的一個“model 已經變更”的回撥。

到這裡,對 slate model 的介紹就告一段落了。

slate 外掛機制

在進一步學習其他 package 之前,我們先要學習一下 slate 的外掛機制以瞭解各個 package 和如何與核心 package 合作的。

上一節提到的判斷一個節點是否為行內節點的

isInline

方法,以及

normalizeNode

方法本身都是可以被擴充套件,不僅如此,另外三個 package 包括 undo/redo 功能和渲染層均是以外掛的形式工作的。看起來 slate 的外掛機制非常強大,但它有一個非常簡單的實現:

覆寫編輯器例項 editor 上的方法

slate-react 提供的

withReact

方法給我們做了一個很好的示範:

export

const

withReact

=

<

T

extends

Editor

>(

editor

T

=>

{

const

e

=

editor

as

T

&

ReactEditor

const

{

apply

onChange

}

=

e

e

apply

=

op

Operation

=>

{

// 。。。

apply

op

}

e

onChange

=

()

=>

{

// 。。。

onChange

()

}

}

withReact

修飾編輯器例項,直接覆蓋例項上原本的

apply

change

方法。~~換句話說,slate 的外掛機制就是沒有外掛機制!~~這難道就是傳說中的無招勝有招?

slate-history

學習了外掛機制,我們再來看 undo/redo 的功能,它由 slate-history package 所實現。

實現 undo/redo 的機制一般來說有兩種。第一種是儲存各個時刻(例如發生變更前後)model 的快照(snapshot),在撤銷操作的時候恢復到之前的快照,這種機制看起來簡單,但是較為消耗記憶體(有 n 步操作我們就需要儲存 n+1 份資料!),而且會使得協同編輯實現起來非常困難(比較兩個樹之間的差別的時間複雜度是 O(n^3),更不要提還有網路傳輸的開銷)。第二種是記錄變更的應用記錄,在撤銷操作的時候取要撤銷操作的反操作,這種機制複雜一些——主要是要進行各種選區計算——但是方便做協同,且不會佔用較多的記憶體空間。slate 即基於第二種方法進行實現。

withHistory

方法中,slate-history 在 editor 上建立了兩個陣列用來儲存歷史操作:

e

history

=

{

undos

[],

redos

[]

}

它們的型別都是

Operation[][]

,即

Operation

的二維陣列,其中的每一項代表了一批操作(在程式碼上稱作 batch), batch 可含有多個

Operation

我們可以透過 console 看到這一結構:

slate 架構設計分析

slate-history 透過覆寫

apply

方法來在

Operation

的 apply 流程之前插入 undo/redo 的相關邏輯,這些邏輯主要包括:

判斷是否需要儲存該

Operation

,諸如改變選區位置等操作是不需要 undo 的

判斷該

Operation

是否需要和前一個 batch 合併,或覆蓋前一個 batch

建立一個 batch 插入

undos

佇列,或者插入到上一個 batch 的尾部,同時計算是否超過最大撤銷步數,超過則去除首部的 batch

呼叫原來的

apply

方法

e

apply

=

op

Operation

=>

{

const

{

operations

history

}

=

e

const

{

undos

}

=

history

const

lastBatch

=

undos

undos

length

-

1

const

lastOp

=

lastBatch

&&

lastBatch

lastBatch

length

-

1

const

overwrite

=

shouldOverwrite

op

lastOp

let

save

=

HistoryEditor

isSaving

e

let

merge

=

HistoryEditor

isMerging

e

// 判斷是否需要儲存該 operation

if

save

==

null

{

save

=

shouldSave

op

lastOp

}

if

save

{

// 判斷是否需要和上一個 batch 合併

// 。。。

if

lastBatch

&&

merge

{

if

overwrite

{

lastBatch

pop

()

}

lastBatch

push

op

}

else

{

const

batch

=

op

undos

push

batch

}

// 最大撤銷 100 步

while

undos

length

>

100

{

undos

shift

()

}

if

shouldClear

op

))

{

history

redos

=

[]

}

}

apply

op

}

slate-history 還在 editor 例項上賦值了

undo

方法,用於撤銷上一組操作:

e

undo

=

()

=>

{

const

{

history

}

=

e

const

{

undos

}

=

history

if

undos

length

>

0

{

const

batch

=

undos

undos

length

-

1

HistoryEditor

withoutSaving

e

()

=>

{

Editor

withoutNormalizing

e

()

=>

{

const

inverseOps

=

batch

map

Operation

inverse

)。

reverse

()

for

const

op

of

inverseOps

{

// If the final operation is deselecting the editor, skip it。 This is

if

op

===

inverseOps

inverseOps

length

-

1

&&

op

type

===

‘set_selection’

&&

op

newProperties

==

null

{

continue

}

else

{

e

apply

op

}

}

})

})

history

redos

push

batch

history

undos

pop

()

}

}

這個演算法的主要部分就是對最後一個 batch 中所有的

Operation

取反操作然後一一 apply,再將這個 batch push 到

redos

陣列中。

redo 方法就更簡單了,這裡不再贅述。

slate-react

最後我們來探究渲染和互動層,即 slate-react package。

渲染機制

我們最關注的問題當然是 model 是如何轉換成檢視層(view)的。經過之前的學習我們已經瞭解到 slate 的 model 本身就是樹形結構,因此只需要遞迴地去遍歷這棵樹,同時渲染就可以了。基於 React,這樣的遞迴渲染用幾個元件就能夠很容易地做到,這幾個元件分別是

Editable

Children

Element

Leaf

String

Text

。在這裡舉幾個例子:

Children

元件用來渲染 model 中類行為

Editor

Element

Node

children

,比如最頂層的

Editable

元件就會渲染

Editor

children

注意下面的

node

引數即為編輯器例項

Editor

export const Editable = (props: EditableProps) => {

return

decorate={decorate}

decorations={decorations}

node={editor}

renderElement={renderElement}

renderLeaf={renderLeaf}

selection={editor。selection}

/>

}

Children

元件會根據

children

中各個

Node

的型別,生成對應的

ElementComponent

或者

TextComponent

const

Children

=

props

=>

{

const

{

node

renderElement

renderLeaf

}

=

props

for

let

i

=

0

i

<

node

children

length

i

++

{

const

p

=

path

concat

i

const

n

=

node

children

i

as

Descendant

if

Element

isElement

n

))

{

children

push

<

ElementComponent

element

=

{

n

}

renderElement

=

{

renderElement

}

renderLeaf

=

{

renderLeaf

}

/>

}

else

{

children

push

<

TextComponent

renderLeaf

=

{

renderLeaf

}

text

=

{

n

}

/>

}

}

return

<

React。Fragment

>{

children

}

React。Fragment

>

}

ElementComponent

渲染一個

Element

元素,並用

Children

元件渲染其

children

const Element = (props) => {

let children: JSX。Element | null = (

decorate={decorate}

decorations={decorations}

node={element}

renderElement={renderElement}

renderLeaf={renderLeaf}

selection={selection}

/>

return (

{renderElement({ attributes, children, element })}

}

// renderElement 的預設值

export const DefaultElement = (props: RenderElementProps) => {

const { attributes, children, element } = props

const editor = useEditor()

const Tag = editor。isInline(element) ? ‘span’ : ‘div’

return (

{children}

}

Leaf

等元件的渲染也是同理,這裡不再贅述。

下圖表示了從 model tree 到 React element 的對映,可見用樹形結構來組織 model 能夠很方便地渲染,且在

Node

和 HTML element 之間建立對映關係(具體可檢視

toSlateNode

toSlateRange

等方法和

ELEMENT_TO_NODE

NODE_TO_ELEMENT

等資料結構),這在處理游標和選擇事件時將會特別方便。

slate 架構設計分析

slate-react 還用了

React。memo

來最佳化渲染效能,這裡不贅述。

自定義渲染元素

在上面探究 slate-react 的渲染機制的過程中,我們發現有兩個比較特殊的引數

renderElement

renderLeaf

,它們從最頂層的

Editable

元件開始就作為引數,一直傳遞到最底層的

Leaf

元件,並且還會被

Element

等元件在渲染時呼叫,它們是什麼?

實際上,這就是 slate-react 自定義渲染的 API,使用者可以透過提供這兩個引數來自行決定如何渲染 model 中的一個

Node

,例如 richtext demo 中:

const Element = ({ attributes, children, element }) => {

switch (element。type) {

case ‘block-quote’:

return

{children}

case ‘bulleted-list’:

return

    {children}

case ‘heading-one’:

return

{children}

case ‘heading-two’:

return

{children}

case ‘list-item’:

return

  • {children}
  • case ‘numbered-list’:

    return

      {children}

    default:

    return

    {children}

    }

    }

    我們先前提到 slate 允許

    Node

    有自定義屬性,這個 demo 就拓展了

    Element

    節點的

    type

    屬性,讓

    Element

    能夠渲染為不同的標籤。

    游標和選區的處理

    slate 沒有自行實現游標和選區,而使用了瀏覽器

    contenteditable

    的能力(同時也埋下了隱患,我們會在總結部分介紹)。

    Editable

    元件中,可看到對

    Component

    元素增加了 contenteditable attribute:

    export const Editable = (props: EditableProps) => {

    return

    contentEditable={readOnly ? undefined : true}

    suppressContentEditableWarning

    >

    }

    // Component 預設為 ‘div’

    從這裡開始,contenteditable 就負責了游標和選區的渲染和事件。slate-react 會在每次渲染的時候將 model 中的選區同步到 DOM 上:

    export const Editable = (props: EditableProps) => {

    // 。。。

    useIsomorphicLayoutEffect(() => {

    // 。。。

    domSelection。setBaseAndExtent(

    newDomRange。startContainer,

    newDomRange。startOffset,

    newDomRange。endContainer,

    newDomRange。endOffset

    })

    }

    也會在 DOM 發生選區事件的時候同步到 model 當中:

    const onDOMSelectionChange = useCallback(

    throttle(() => {

    if (!readOnly && !state。isComposing && !state。isUpdatingSelection) {

    // 。。。

    if (anchorNodeSelectable && focusNodeSelectable) {

    const range = ReactEditor。toSlateRange(editor, domSelection) // 這裡即發生了一次 DOM element 到 model Node 的轉換

    Transforms。select(editor, range)

    } else {

    Transforms。deselect(editor)

    }

    }

    }, 100),

    [readOnly]

    選區同步的方法這裡就不介紹了,大家可以透過查閱原始碼自行學習。

    鍵盤事件的處理

    Editable

    元件建立了一個

    onDOMBeforeInput

    函式,用以處理

    beforeInput

    事件,根據事件的

    type

    呼叫不同的方法來修改 model。

    // 。。。

    switch

    type

    {

    case

    ‘deleteByComposition’

    case

    ‘deleteByCut’

    case

    ‘deleteByDrag’

    {

    Editor

    deleteFragment

    editor

    break

    }

    case

    ‘deleteContent’

    case

    ‘deleteContentForward’

    {

    Editor

    deleteForward

    editor

    break

    }

    // 。。。

    }

    // 。。。

    beforeInput

    事件和

    input

    事件的區別就是觸發的時機不同。前者在值改變之前觸發,還能透過呼叫

    preventDefault

    來阻止瀏覽器的預設行為。

    slate 對快捷鍵的處理也很簡單,透過在 div 上繫結 keydown 事件的 handler,然後根據不同的組合鍵呼叫不同的方法。slate-react 也提供了自定義這些 handler 的介面,

    Editable

    預設的 handler 會檢測使用者提供的 handler 有沒有將該 keydown 事件標記為

    defaultPrevented

    ,沒有才執行預設的事件處理邏輯:

    if

    readOnly

    &&

    hasEditableTarget

    editor

    event

    target

    &&

    isEventHandled

    event

    attributes

    onKeyDown

    {

    // 根據不同的組合鍵呼叫不同的方法

    }

    渲染觸發

    slate 在渲染的時候會向

    EDITOR_TO_ON_CHANGE

    中新增一個回撥函式,這個函式會讓

    key

    的值加 1,觸發 React 重新渲染。

    export const Slate = (props: {

    editor: ReactEditor

    value: Node[]

    children: React。ReactNode

    onChange: (value: Node[]) => void

    [key: string]: unknown

    }) => {

    const { editor, children, onChange, value, 。。。rest } = props

    const [key, setKey] = useState(0)

    const onContextChange = useCallback(() => {

    onChange(editor。children)

    setKey(key + 1)

    }, [key, onChange])

    EDITOR_TO_ON_CHANGE。set(editor, onContextChange)

    useEffect(() => {

    return () => {

    EDITOR_TO_ON_CHANGE。set(editor, () => {})

    }

    }, [])

    }

    而這個回撥函式由誰來呼叫呢?可以看到

    withReact

    對於

    onChange

    的覆寫:

    e

    onChange

    =

    ()

    =>

    {

    ReactDOM

    unstable_batchedUpdates

    (()

    =>

    {

    const

    onContextChange

    =

    EDITOR_TO_ON_CHANGE

    get

    e

    if

    onContextChange

    {

    onContextChange

    ()

    }

    onChange

    ()

    })

    }

    在 model 變更的結束階段,從

    EDITOR_TO_ON_CHANGE

    裡拿到回撥並呼叫,這樣就實現 model 更新,觸發 React 重渲染了。

    總結

    這篇文章分析了 slate 的架構設計和文章開頭提到的對一些關鍵設計問題的處理。至此,我們可以發現 slate 存在著這樣幾個主要的問題:

    沒有自行實現排版

    。slate 藉助了 DOM 的排版能力,這樣就使得 slate 只能呈現流式佈局的文件,不能實現分欄、頁首頁尾、圖文混排等高階排版功能。

    使用了 contenteditable 導致無法處理部分選區和輸入事件

    。使用 contenteditable 後雖然不需要開發者去處理游標的渲染和選擇事件,但是造成了另外一個問題:破壞了從 model 到 view 的單向資料流,這在使用輸入法(IME)的時候會導致崩潰這樣嚴重的錯誤。

    我們在 React 更新渲染之前打斷點,然後全選文字,輸入任意內容。可以看到,在沒有輸入法的狀態下,更新之前 DOM element 並沒有被移除。

    slate 架構設計分析

    但是在有輸入法的情況下,contenteditable 會將游標所選位置的 DOM element 先行清除,此時 React 中卻還有對應的 Fiber Node,這樣更新之後,React 就會發現需要解除安裝的 Fiber 所對應的 DOM element 已經不屬於其父 element,從而報錯。並且這一事件不能被 prevent default,所以單向資料流一定會被打破。

    slate 架構設計分析

    React 相關的 issue 從 2015 年起就掛在那裡了,slate 官方對 IME 相關的問題的積極性也不高。

    對於協同編輯的支援僅停留在理論可行性上

    。slate 使用了

    Operation

    ,這使得協同編輯存在理論上的可能,但是對於協同編輯至關重要的 operation transform 方案(即如何處理兩個有衝突的編輯操作),則沒有提供實現。

    總的來說,slate 是一個擁有良好擴充套件性的輕量富文字編輯器(框架?),很適合 CMS、社交媒體這種不需要複雜排版和實時協作的簡單富文字編輯場景。

    希望這篇文章能夠幫助大家對 slate 形成一個整體的認知,並從其技術方案中瞭解它的優點和侷限性,從而更加得心應手地使用 slate。

    標簽: Slate  Editor  Model  Children  節點