slate 架構設計分析
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 這個物件了。
可以看到它的
children
屬性中有四個
Element
並透過
type
屬性標明瞭型別,對應編輯器中的四個段落。第一個 paragraph 的
children
中有 7 個
Text
,
Text
用
bold
italic
這些屬性描述它們的文字式樣,對應普通、粗體、斜體和行內程式碼樣式的文字。
那麼為什麼 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
// 選區結束的位置
}
比如我這樣選中一段文字(我這裡是從後向前選擇的):
透過訪問
editor
的
selection
屬性來檢視當前的選區位置:
可見,選擇的起始位置
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 正確性進行校驗
觸發變更回撥
首先,透過
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-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-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 case ‘bulleted-list’: return {children}
{children}
case ‘heading-one’:
return
{children}
case ‘heading-two’:
return
{children}
case ‘list-item’:
return
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 並沒有被移除。
但是在有輸入法的情況下,contenteditable 會將游標所選位置的 DOM element 先行清除,此時 React 中卻還有對應的 Fiber Node,這樣更新之後,React 就會發現需要解除安裝的 Fiber 所對應的 DOM element 已經不屬於其父 element,從而報錯。並且這一事件不能被 prevent default,所以單向資料流一定會被打破。
React 相關的 issue 從 2015 年起就掛在那裡了,slate 官方對 IME 相關的問題的積極性也不高。
對於協同編輯的支援僅停留在理論可行性上
。slate 使用了
Operation
,這使得協同編輯存在理論上的可能,但是對於協同編輯至關重要的 operation transform 方案(即如何處理兩個有衝突的編輯操作),則沒有提供實現。
總的來說,slate 是一個擁有良好擴充套件性的輕量富文字編輯器(框架?),很適合 CMS、社交媒體這種不需要複雜排版和實時協作的簡單富文字編輯場景。
希望這篇文章能夠幫助大家對 slate 形成一個整體的認知,並從其技術方案中瞭解它的優點和侷限性,從而更加得心應手地使用 slate。