從零實現富文本編輯器#8-瀏覽器輸入模式的非受控DOM行為
先前我們在選區模塊的基礎上,通過瀏覽器的組合事件來實現半受控的輸入模式,這是狀態同步的重要實現之一。在這里我們要關注于處理瀏覽器復雜DOM結構默認行為,以及兼容IME輸入法的各種輸入場景,相當于我們來Case By Case地處理輸入法和瀏覽器兼容的行為。
- 開源地址: https://github.com/WindRunnerMax/BlockKit
- 在線編輯: https://windrunnermax.github.io/BlockKit/
- 項目筆記: https://github.com/WindRunnerMax/BlockKit/blob/master/NOTE.md
從零實現富文本編輯器系列文章
概述
在整個編輯器系列最開始的時候,我們就提到了ContentEditable的可控性以及瀏覽器兼容性問題,特別是結合了React作為視圖層的模式下,狀態管理以及DOM的行為將變得更不可控,這里回顧一下常見的瀏覽器的兼容性問題:
- 在空
contenteditable編輯器的情況下,直接按下回車鍵,在Chrome中的表現是會插入<div><br></div>,而在FireFox(<60)中的表現是會插入<br>,IE中的表現是會插入<p><br></p>。 - 在有文本的編輯器中,如果在文本中間插入回車例如
123|123,在Chrome中的表現內容是123<div>123</div>,而在FireFox中的表現則是會將內容格式化為<div>123</div><div>123</div>。 - 同樣在有文本的編輯器中,如果在文本中間插入回車后再刪除回車,例如
123|123->123123,在Chrome中的表現內容會恢復原本的123123,而在FireFox中的表現則是會變為<div>123123</div>。 - 在同時存在兩行文本的時候,如果同時選中兩行內容再執行
("formatBlock", false, "P")命令,在Chrome中的表現是會將兩行內容包裹在同個<p>中,而在FireFox中的表現則是會將兩行內容分別包裹<p>標簽。 - ...
由于我們的編輯器輸入是依靠瀏覽器提供的組合事件,自然無法規避相關問題。編輯器設計的視圖結構是需要嚴格控制的,這樣我們才能根據一定的規則實現視圖與選區模式的同步。依照整體MVC架構的設計,當前編輯器的視圖結構設計如下:
<div data-block="true" >
<div data-node="true">
<span data-leaf="true"><span data-string="true">inline</span></span>
<span data-leaf="true"><span data-string="true">inline2</span></span>
</div>
</div>
那么如果在ContentEdiable輸入時導致上述的結構被破壞,我們設計的編輯器同步模式便會出現問題。因此為了解決類似的問題,我們就需要實現臟DOM檢查,若是出現破壞性的節點結構,就需要嘗試修復DOM結構,甚至需要調度React來重新渲染嚴格的視圖結構。
然而,如果每次輸入或者選區變化等時機都進行DOM檢查和修復,勢必會影響編輯器整體性能或者輸入流暢性,并且DOM檢查和修復的范圍也需要進行限制,否則同樣影響性能。因此在這里我們需要對瀏覽器的輸入模式進行歸類,針對不同的類型進行不同的DOM檢查和修復模式。
行內節點
DOM結構與Model結構的同步在非受控的React組件中變得復雜,這其實也就是部分編輯器選擇自繪選區的原因之一,可以以此避免非受控問題。那么非受控的行為造成的主要問題可以比較容易地復現出來,假設此時存在兩個節點,分別是inline類型和text類型的文本節點:
inline|text
此時我們的光標在inline后,假設schema中定義的inline規則是不會繼承前個節點的格式,那么接下來如果我們輸入內容例如1,此時文本就變成了inline|1text。這個操作是符合直覺的,然而當我們在上述的位置喚醒IME輸入中文內容時,這里的文本就變成了錯誤的內容。
inline中文|中文text
這里的差異可以比較容易地看出來,如果是輸入的英文或者數字,即不需要喚醒IME的受控輸入模式,1這個字符是會添加到text文本節點前。而喚醒IME輸入法的非受控輸入模式,則會導致輸入的內容不僅出現在text前,而且還會出現在inline節點的后面,這部分顯然是有問題的。
這里究其原因還是在于非受控的IME問題,在輸入英文時我們的輸入在beforeinput事件中被阻止了默認行為,因此不會觸發瀏覽器默認行為的DOM變更。然而當前在喚醒IME的情況下,DOM的變更行為是無法被阻止的,因此此時屬于非受控的輸入,這樣就導致了問題。
此時由于瀏覽器的默認行為,inline節點的內容會被輸入法插入“中文”的文本,這部分是瀏覽器對于輸入法的默認處理。而當我們輸入完成后,數據結構Model層的內容是會將文本放置于text前,這部分則是編輯器來控制的行為,這跟我們輸入非中文的表現是一致的,也是符合預期表現的。
那么由于我們的immutable設計,再加上性能優化策略的memo以及useMemo的執行,即使在最終的文本節點渲染加入了臟DOM檢測也是不夠的,因為此時完全不會執行rerender。這就導致React原地復用了當前的DOM節點,因此造成了IME輸入的DOM變更和Model層的不一致。
const onRef = (dom: HTMLSpanElement | null) => {
if (props.children === dom.textContent) return void 0;
const children = dom.childNodes;
// If the text content is inconsistent due to the modification of the input
// it needs to be corrected
for (let i = 1; i < children.length; ++i) {
const node = children[i];
node && node.remove();
}
// Guaranteed to have only one text child
if (isDOMText(dom.firstChild)) {
dom.firstChild.nodeValue = props.children;
}
};
而如果我們直接將leaf的React.memo以及useMemo移除,這個問題自然是會消失,然而這樣就會導致編輯器的性能下降。因此我們就需要考慮盡可能檢查到臟DOM的情況,實際上如果是在input事件或者MutationObserver中處理輸入的純非受控情況,也需要處理臟DOM的問題。
那么我們可以明顯的想到,當行狀態發生變更時,我們就直接檢查當前行的所有leaf節點,然后對比文本內容,如果存在不一致的情況則直接進行修正。如果直接使用querySelector的話顯然不夠優雅,我們可以借助WeakMap來映射葉子狀態到DOM結構,以此來快速定位到需要的節點。
然后在行節點的狀態變更后,在處理副作用的時候檢查臟DOM節點,并且由于我們的行狀態也是immutable的,因此也不需要擔心性能問題。此時檢查的執行是O(N)的算法,而且檢查的范圍也會限制在發生rerender的行中,具體檢查節點的方法自然也跟上述onRef一致。
const leaves = lineState.getLeaves();
for (const leaf of leaves) {
const dom = LEAF_TO_TEXT.get(leaf);
if (!dom) continue;
const text = leaf.getText();
// 避免 React 非受控與 IME 造成的 DOM 內容問題
if (text === dom.textContent) continue;
editor.logger.debug("Correct Text Node", dom);
const nodes = dom.childNodes;
for (let i = 1; i < nodes.length; ++i) {
const node = nodes[i];
node && node.remove();
}
if (isDOMText(dom.firstChild)) {
dom.firstChild.nodeValue = text;
}
}
這里需要注意的是,臟節點的狀態檢查是需要在useLayoutEffect時機執行的,因為我們需要保證執行的順序是先校正DOM再更新選區。如果反過來的話就會導致一個問題,先更新的選區依然停留在臟節點上,此時再校正會由于DOM節點變化導致選區的丟失,表現是選區會在inline的最前方。
leaf rerender -> line rerender -> line layout effect -> block layout effect
此外,這里的實現在首次渲染并不需要檢查,此時不會存在臟節點的情況,因此初始化渲染的時候我們可以直接跳過檢查。以這種策略來處理臟DOM的問題,還可以避免部分其他可能存在的問題,零寬字符文本的內容暫時先不處理,如果再碰到類似的情況是需要額外的檢查的。
其實換個角度想,這里的問題也可能是我們的選區策略是盡可能偏左側的查找,如果在這種情況將其校正到右側節點可能也可以解決問題。不過因為在空行的情況下我們的末尾\n節點并不會渲染,因此這樣的策略目前并不能徹底解決問題,而且這個處理方式也會使得編輯器的選區策略變得更加復雜。
[inline|][text] => [inline][|text]
這里還需要關注下React的Hooks調用時機,在下面的例子中,從真實DOM中得到onRef執行順序是最前的,因此在此時進行首次DOM檢查是合理的。而后續的Child LayoutEffect就類似于行DOM檢查,在修正過后在Parent LayoutEffect中更新選區是符合調度時機方案。
Child onRef
Child useLayoutEffect
Parent useLayoutEffect
Child useEffect
Parent useEffect
// https://playcode.io/react
import React from 'react';
const Child = () => {
const [,forceUpdate] = React.useState({});
const onRef = () => console.log("Child onRef");
React.useEffect(() => console.log("Child useEffect"));
React.useLayoutEffect(() => console.log("Child useLayoutEffect"));
return <button ref={onRef} onClick={() => forceUpdate({})}>Update</button>
}
export function App(props) {
React.useEffect(() => console.log("Parent useEffect"));
React.useLayoutEffect(() => console.log("Parent useLayoutEffect"));
return <Child></Child>;
}
包裝節點
關于包裝節點的問題需要我們先聊一下這個模式的設計,現在實現的富文本編輯器是沒有塊結構的,因此實現任何具有嵌套的結構都是個復雜的問題。在這里我們原本就不會處理諸如表格類的嵌套結構,但是例如blockquote這種wrapper級結構我們是需要處理的。
類似的結構還有list,但是list我們可以完全自己繪制,但是blockquote這種結構是需要具體組合才可以的。然而如果僅僅是blockquote還好,在inline節點上使用wrapper是更常見的實現,例如a標簽的包裝在編輯器的實現模式中就是很常規的行為。
具體來說,在我們將文本分割為bold、italic等inline節點時,會導致DOM節點被實際切割,此時如果嵌套<a>節點的話,就會導致hover后下劃線等效果出現切割。因此如果能夠將其wrapper在同一個<a>標簽的話,就不會出現這種問題。
但是新的問題又來了,如果僅僅是單個key來實現渲染時嵌套并不是什么復雜問題,而同時存在多個需要wrapper的key則變成了令人費解的問題。如下面的例子中,如果將34單獨合并b,外層再包裹a似乎是合理的,但是將34先包裹a后再合并5的b也是合理的,甚至有沒有辦法將67一并合并,因為其都存在b標簽。
1 2 3 4 5 6 7 8 9 0
a a ab ab b bc b c c c
思來想去,我最終想到了個簡單的實現,對于需要wrapper的元素,如果其合并list的key和value全部相同的話,那么就作為同一個值來合并。那么這種情況下就變的簡單了很多,我們將其認為是一個組合值,而不是單獨的值,在大部分場景下是足夠的。
1 2 3 4 5 6 7 8 9 0
a a ab ab b bc b c c c
12 34 5 6 7 890
不過話又說回來,這種wrapper結構是比較特殊的場景下才會需要的,在某些操作例如縮進這個行為中,是無法判斷究竟是要縮進引用塊還是縮進其中的文字。這個問題在很多開源編輯器中都存在,特別是扁平化的數據結構設計例如Quill編輯器。
其實也就是在沒有塊結構的情況下,對于類似的行為不好控制,而整體縮進這件事配合list在大型文檔中也是很合理的行為,因此這部分實現還是要等我們的塊結構編輯器實現才可以。當然,如果數據結構本身支持嵌套模式,例如Slate就可以實現。
后續在wrap node實現的a標簽來實現輸入時,又出現了上述類似inline-code的臟DOM問題。以下面的DOM結構來看,看似并不會有什么問題,然而當光標放置于超鏈接這三個字后喚醒IME輸入中文時,會發現輸入“測試輸入”這幾個字會被放置于直屬div下,與a標簽平級。
<div contenteditable>
<a ><span>超鏈接</span></a>
<span>文本</span>
</div>
<div contenteditable>
<a ><span>超鏈接</span></a>
測試輸入
<span>文本</span>
</div>
在這種情況下我們先前實現的臟DOM檢測就失效了,因為檢查臟DOM的實現是基于data-leaf實現的。此時瀏覽器的輸入表現會導致我們無法正確檢查到這部分內容,除非直接拿data-node行節點來直接判斷,這樣的實現自然不夠好。
說到這里,先前我發現飛書文檔的實現是a標簽渲染的leaf,而wrap的包裝實現是使用的span直接處理的,并且額外增加了樣式來實現hover效果。直接使用span包裹就不會出現上述問題,而內部的a標簽雖然會導致同樣的問題,但是在leaf下可以觸發臟DOM檢查。
<div contenteditable>
<span>
<a ><span>超鏈接</span></a>
測試輸入
</span>
<span>文本</span>
</div>
因此就可以在先前的臟DOM檢查基礎上解決了問題,而本質上類似的行為就是瀏覽器默認處理的結果,不同的瀏覽器處理結果可能都不一樣。目前看起來是瀏覽器認為a標簽的結構應該是屬于inline的實現,也就是類似我們的inline-code實現,理論上倒卻是并沒有什么問題,由此我們需要自己來處理這些非受控的問題。
實際上Quill本身也會出現這個問題,同樣也是臟DOM的處理。而slate并不會出現這個問題,這里處理方案則是通過DOM規避了問題,在a標簽兩端放置額外的 節點,以此來避免這個問題。當然還引入了額外的問題,引入了新的節點,目前看起來轉移光標需要受控處理。
<!-- https://github.com/ianstormtaylor/slate/blob/main/site/examples/ts/inlines.tsx -->
<div contenteditable>
<a
><span contenteditable="false" style="font-size: 0"> </span
><span>超鏈接測試輸入</span
><span contenteditable="false" style="font-size: 0"> </span></a
><span>文本</span>
</div>
瀏覽器兼容性
在后續瀏覽器的測試中,重新出現了上述提到的a標簽問題,此時并不是由于包裝節點引起的,因此問題變得復雜了很多,主要是各個瀏覽器的兼容性的問題。類似于行內代碼塊,本質上還是瀏覽器IME非受控導致的DOM變更問題,但是在瀏覽器表現差異很大,下面是最小的DEMO結構。
<div contenteditable>
<span data-leaf><a href="#"><span data-string>在[:]后輸入:</span></a></span><span data-leaf>非鏈接文本</span>
</div>
在上述示例的a標簽位置的最后的位置上輸入內容,主流的瀏覽器的表現是有差異的,甚至在不同版本的瀏覽器上表現還不一致:
- 在
Chrome中會在a標簽的同級位置插入文本類型的節點,效果類似于<a></a>"text"內容。 - 在
Firefox中會在a標簽內插入span類型的節點,效果類似于<a></a><span data-string>text</span>內容。 - 在
Safari中會將a標簽和span標簽交換位置,然后在a標簽上同級位置加入文本內容,類似<span><a></a>"text"</span>。
<!-- Chrome -->
<span data-leaf="true">
<a ><span data-string="true">超鏈接</span></a>
"文本"
</span>
<!-- Firefox -->
<span data-leaf="true">
<a ><span data-string="true">超鏈接</span></a>
<span data-string="true">文本</span>
</span>
<!-- Safari -->
<span data-leaf="true">
<span data-string="true">
<a >超鏈接</a>
"文本"
""
</span>
</span>
因此我們的臟DOM檢查需要更細粒度地處理,僅僅對比文本內容顯然是不足以處理的,我們還需要檢查文本的內容節點結構是否準確。其實最開始我們是僅處理了Chrome下的情況,最簡單的辦法就是在leaf節點下僅允許存在單個節點,存在多個節點則說明是臟DOM。
for (let i = 1; i < nodes.length; ++i) {
const node = nodes[i];
node && node.remove();
}
但是后來發現在編輯時會把Embed節點移除,這里也就是因為我們錯誤地把組合的div節點當成了臟DOM,因此這里就需要更細粒度地處理了。然后考慮檢查節點的類型,如果是文本的節點類型再移除,那么就可以避免Embed節點被誤刪的問題。
for (let i = 1; i < nodes.length; ++i) {
const node = nodes[i];
isDOMText(node) && node.remove();
}
雖然看起來是解決了問題,然而在后續就發現了Firefox和Safari下的問題。先來看Firefox的情況,這個節點并非文本類型的節點,在臟DOM檢查的時候就無法被移除掉,這依然無法處理Firefox下的臟DOM問題,因此我們需要進一步處理不同類型的節點。
// data-leaf 節點內部僅應該存在非文本節點, 文本類型單節點, 嵌入類型雙節點
for (let i = 1; i < nodes.length; ++i) {
const node = nodes[i];
// 雙節點情況下, 即 Void/Embed 節點類型時需要忽略該節點
if (isHTMLElement(node) && node.hasAttribute(VOID_KEY)) {
continue;
}
node.remove();
}
在Safari的情況下就更加復雜,因為其會將a標簽和span標簽交換位置,這樣就導致了DOM結構性造成了破壞。這種情況下我們就必須要重新刷新DOM結構,這種情況下就需要更加復雜地處理,在這里我們加入forceUpdate以及TextNode節點的檢查。
其實在飛書文檔中也是采用了類似的做法,飛書文檔的a標簽在喚醒IME輸入后,同樣會觸發臟DOM的檢查,然后飛書文檔會直接以行為基礎ReMount當前行的所有leaf節點,這樣就可以避免復雜的臟DOM檢查。我們這里實現更精細的leaf處理,主要是避免不必要的掛載。
const LeafView: FC = () => {
const { forceUpdate, index: renderKey } = useForceUpdate();
LEAF_TO_REMOUNT.set(leafState, forceUpdate);
return (<span key={renderKey}></span>);
}
if (isDOMText(dom.firstChild)) {
// ...
} else {
const func = LEAF_TO_REMOUNT.get(leaf);
func && func();
}
這里需要注意的是,我們還需要處理零寬字符類型的情況。當Embed節點前沒有任何節點,即位于行首時,輸入中文后同樣會導致IME的輸入內容被滯留在Embed節點的零寬字符上,這點與上述的inline節點是類似的,因此這部分也需要處理。
const zeroNode = LEAF_TO_ZERO_TEXT.get(leaf);
const isZeroNode = !!zeroNode;
const textNode = isZeroNode ? zeroNode : LEAF_TO_TEXT.get(leaf);
const text = isZeroNode ? ZERO_SYMBOL : leaf.getText();
const nodes = textNode.childNodes;
到這里,我們的臟DOM檢查已經能夠處理大部分情況了,整體的模式都是React在行DOM結構計算完成后,瀏覽器渲染前進行處理。針對于文本節點以及a標簽的檢查,需要檢查文本與狀態的關系,以及嚴格的DOM結構破壞后的需要直接Remount組件。
// 文本節點內部僅應該存在一個文本節點, 需要移除額外節點
for (let i = 1; i < nodes.length; ++i) {
const node = nodes[i];
node && node.remove();
}
// 如果文本內容不合法, 通常是由于輸入的臟 DOM, 需要糾正內容
if (isDOMText(textNode.firstChild)) {
// Case1: [inline-code][caret][text] IME 會導致模型/文本差異
// Case3: 在單行僅存在 Embed 節點時, 在節點最前輸入會導致內容重復
if (textNode.firstChild.nodeValue === text) return false;
textNode.firstChild.nodeValue = text;
} else {
// Case2: Safari 下在 a 節點末尾輸入時, 會導致節點內外層交換
const func = LEAF_TO_REMOUNT.get(leaf);
func && func();
if (process.env.NODE_ENV === "development") {
console.log("Force Render Text Node", textNode);
}
}
而針對于額外的文本節點,即本章節中重點提到的瀏覽器兼容性問題,我們需要嚴格地控制leaf節點下的DOM結構。如果僅存在單個文本節點的情況下,是符合設計的結構,而如果是存在多個節點,除了Void/Embed節點的情況外,則說明DOM結構被破壞了,這里我們就需要移除掉多余的節點。
// data-leaf 節點內部僅應該存在非文本節點, 文本類型單節點, 嵌入類型雙節點
for (let i = 1; i < nodes.length; ++i) {
const node = nodes[i];
// 雙節點情況下, 即 Void/Embed 節點類型時需要忽略該節點
if (isHTMLElement(node) && node.hasAttribute(VOID_KEY)) {
continue;
}
// Case1: Chrome a 標簽內的 IME 輸入會導致同級的額外文本節點類型插入
// Case2: Firefox a 標簽內的 IME 輸入會導致同級的額外 data-string 節點類型插入
node.remove();
}
樣式組合渲染
由于我們的編輯器是以immutable提高渲染性能,因此在文本節點變更時若是需要存在連續的格式處理,例如inline-code的樣式實現,就會出現組件不重新渲染問題。具體表現是若是存在多個連續的code節點,最后一個節點長度為1,刪除最后這個節點時會導致前一個節點無法刷新樣式。
[inline][c]|
這個問題的原因是我們的className是在渲染leaf節點時動態計算的,具體的邏輯如下所示。如果前一個節點不存在或者前一個節點不是inline-code,則添加inline-code-start類屬性,類似的需要在最后一個節點加入inline-code-end類屬性。
if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
context.classList.push(INLINE_CODE_START_CLASS);
}
context.classList.push("block-kit-inline-code");
if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
context.classList.push(INLINE_CODE_END_CLASS);
}
這個情況同樣類似于Dirty DOM的問題,由于刪除的節點長度為1,因此前一個節點的LeafState并沒有變更,因此不會觸發React的重新渲染。這里我們就需要在行節點渲染時進行糾正,這里的執行倒是不需要像上述檢查那樣同步執行,以異步的effect執行即可。
/**
* 編輯器行結構布局計算后異步調用
*/
public didPaintLineState(lineState: LineState): void {
for (let i = 0; i < leaves.length; i++) {
if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
node && node.classList.add(INLINE_CODE_START_CLASS);
}
if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
node && node.classList.add(INLINE_CODE_END_CLASS);
}
}
}
雖然看起來已經解決了問題,然而在React中還是存在一些問題,主要的原因此時的DOM處理是非受控的。類似于下面的例子,由于React在處理style屬性時,只會更新發生變化的樣式屬性,即使整體是新對象,但具體值與上次渲染時相同,因此React不會重新設置這個樣式屬性。
// https://playcode.io/react
import React from "react";
export function App() {
const el = React.useRef();
const [, setState] = React.useState(1);
const onClick = () => {
el.current && (el.current.style.color = "blue");
}
console.log("Render App")
return (
<div>
<div style={{ color:"red" }} ref={el}>Hello React.</div>
<button onClick={onClick}>Color Button</button>
<button onClick={() => setState(c => ++c)}>Rerender Button</button>
</div>
);
}
因此,在上述的didPaintLineState中我們主要是classList添加類屬性值,即使是LeafState發生了變更,React也不會重新設置類屬性值,因此這里我們還需要在didPaintLineState變更時刪除非必要的類屬性值。
public didPaintLineState(lineState: LineState): void {
for (let i = 0; i < leaves.length; i++) {
if (!prev || !prev.op.attributes || !prev.op.attributes[INLINE_CODE_KEY]) {
node && node.classList.add(INLINE_CODE_START_CLASS);
} else {
node && node.classList.remove(INLINE_CODE_START_CLASS);
}
if (!next || !next.op.attributes || !next.op.attributes[INLINE_CODE_KEY]) {
node && node.classList.add(INLINE_CODE_END_CLASS);
} else {
node && node.classList.remove(INLINE_CODE_END_CLASS);
}
}
}
總結
在先前我們實現了半受控的輸入模式,這個輸入模式同樣是目前大多數富文本編輯器的主流實現方式。在這里我們關注于瀏覽器ContentEdiable模式輸入的默認行為造成的DOM結構問題,并且通過臟DOM檢查的方式來修正這些問題,以此來保持編輯器的嚴格DOM結構。
當前我們主要關注的是編輯器文本的輸入問題,即如何將鍵盤輸入的內容寫入到編輯器數據模型中。而接下來我們需要關注于輸入模式結構化變更的受控處理,即回車、刪除、拖拽等操作的處理,這些操作同樣也是基于輸入相關事件實現的,而且通常會涉及到文本的結構變更,屬于輸入模式的補充。

浙公網安備 33010602011771號