如今前端領(lǐng)域:serverless详囤,low code,全椄渥鳎化等概念遍布漫天藏姐。開發(fā)者們熱衷于討論「如何把前端格局做大」,「如何將高高在上的概念落地」该贾。此時羔杨,你有沒有感受到「還不知道發(fā)展方向到底是什么,就已經(jīng)被未來拋棄了」靶庙。
我想问畅,與其去琢磨「serverless 到底是什么娃属,跟前端有什么關(guān)系」,不如先讓我們回到需求的起點护姆,從前端開發(fā)的護城河特點說起矾端。不忘初心,牢記使命卵皂,前端開發(fā)說到底是內(nèi)容渲染和交互實現(xiàn)秩铆。今天這篇文章,讓我們從一個有趣的產(chǎn)品需求說起灯变,換一個角度去思考「前端的邊界到底在哪里」殴玛。并從這個前端需求出發(fā),看看技術(shù)上又能有多深的實踐添祸。
理解需求
需求并不算太復(fù)雜滚粟,簡單來說就是在一個文稿頁上,實現(xiàn)「劃線高亮」和「插入筆記」刃泌。通過下面完成圖凡壤,我們可以總結(jié)需求點包括:
公開筆記展示:
- 這是一個文稿頁面,主要實現(xiàn)添加劃線和添加筆記兩大塊功能
- 用戶可以圈選文字內(nèi)容耙替,在彈出 tooltip 中進行「劃線添加」
- 用戶在圈選文字時亚侠,或者點擊已有劃線高亮區(qū)塊時,喚醒 tooltip 彈出
- 用戶圈選文字時俗扇,展示相應(yīng) tooltip硝烂,提供:「復(fù)制」、「添加/刪除劃線」铜幽、「寫筆記」滞谢、「分享」等按鈕
- 以上按鈕功能點容易理解,不再一一展開
- 只有文稿內(nèi)容文字支持劃線交互啥酱,其他頁面元素不支持劃線操作
- 劃線添加完成后爹凹,相應(yīng)的文字添加高亮背景
- 刪除劃線會同時刪除該劃線對應(yīng)的所有筆記(如果有對應(yīng)筆記)
- tooltip 彈出時,點擊「寫筆記」按鈕镶殷,導(dǎo)航到筆記編輯頁面禾酱,由用戶輸入內(nèi)容并添加后,無刷新地返回文稿頁绘趋,并在相應(yīng)位置插入該條筆記颤陶,筆記內(nèi)容需要在當(dāng)前劃線的下一行插入展現(xiàn)
- 頁面中一段內(nèi)如果有其他用戶的公開筆記,則在段后展示公開筆記 icon陷遮,icon 內(nèi)展示公開筆記數(shù)
- 點擊段后公開筆記 icon滓走,展示公開筆記內(nèi)容
更細的需求點和交互細節(jié)我們會在后文實現(xiàn)環(huán)節(jié)進行進一步說明。
分析需求
有的讀者可能會認為:「劃線筆記這類需求我見過帽馋,應(yīng)該也不難吧」搅方,甚至我還看過文章分析其實現(xiàn)比吭,比如:如何用JS實現(xiàn)“劃詞高亮”的在線筆記功能?姨涡。其實不然衩藤,不同于以往的「劃線高亮」和「插入筆記」需求,我們的場景還真有特殊涛漂,包括但不限于(請結(jié)合上面完成圖理解):
- 合法劃線只能圈定為文稿內(nèi)文字赏表。也就是說,tooltip 內(nèi)文案匈仗、用戶已添加的筆記內(nèi)容瓢剿、段后 icon 計數(shù)、空行悠轩、彈窗文案等一切非原始文稿內(nèi)容均不支持勾選
- 用戶劃線可長可短间狂,劃線范圍可能在一段內(nèi),也可能跨段落火架。這個區(qū)別會影響劃線區(qū)高亮的實現(xiàn)方案和持久化數(shù)據(jù)設(shè)計
- 劃線間關(guān)系復(fù)雜前标,因此不同的劃線可能會出現(xiàn):不同劃線內(nèi)容交叉,不同劃線內(nèi)容全覆蓋(父子集關(guān)系)距潘,不同劃線內(nèi)容完全獨立三種關(guān)系
- 劃線對應(yīng)的筆記插入位置需要在對應(yīng)劃線的下一行,如果一條劃線添加了多條筆記只搁,那么多條筆記在該劃線后一行進行順序疊加
- 段后公開筆記計數(shù) icon 的計數(shù)音比,需要隨著劃線和筆記的動態(tài)添加或刪除而改變
- tooltip 定位:tooltip 位置需要隨著用戶勾選文字的內(nèi)容變化而變化,需要始終保持在勾選區(qū)域中心氢惋,垂直方向上處于劃線第一行上方固定像素距離洞翩;如果勾選區(qū)域占滿一行丽已,tooltip 在水平位置上需要固定出現(xiàn)在屏幕中心
所有這些細節(jié)點都需要在 React 技術(shù)棧上實現(xiàn)腌歉,因為我們的文稿內(nèi)容是通過 React 組件呈現(xiàn):
return (
<div ...props>
<Component1 />
<Component2 />
<RichText
prop1={prop1}
text={manuscript}
prop3={prop3}
/>
<Component3 />
</div>
)
這將會給我們帶來極大的挑戰(zhàn):設(shè)想一下,React 一股腦通過 setDangerouslyInnerHTML
渲染整頁富文本采蚀,我們?nèi)绾卧诟晃谋緝?nèi)容上添加一系列包括劃線在內(nèi)的交互熊赖?或者更細節(jié)一些来屠,我們?nèi)绾握业接脩魟澗€的下一行進行筆記內(nèi)容添加?這么看震鹉,React 也許是個枷鎖俱笛,阻礙了我們施展手腳。當(dāng)然辦法總是有的传趾,我們繼續(xù)分析并實現(xiàn)迎膜。
核心問題
需求牽扯到很多細節(jié)點,但這篇文章的目的并不想面面俱到浆兰,逐一實現(xiàn)磕仅。讓我們先把精力聚焦在「劃線高亮」和「添加筆記」上珊豹。思考核心問題主要有三大方向:
- 添加劃線文本的高亮樣式
- 劃線后插入筆記
- 劃線高亮和筆記的持久化還原
需要說明的是:我們文稿頁面的文稿內(nèi)容來自后臺編輯器以及第三方內(nèi)容導(dǎo)入。
因此我們看到榕订,后臺編輯器具備了所有富文本編輯器的常規(guī)內(nèi)容店茶,并含有多項自定義能力,比如:公式添加卸亮、代碼塊添加忽妒、引用樣式添加、圖片/視頻添加兼贸、書簽添加段直,以及自動格式化(標點擠壓、三巨頭轉(zhuǎn)換溶诞、繁體簡體字轉(zhuǎn)換)等鸯檬。因此,文稿內(nèi)容可謂千變?nèi)f化螺垢,理論上講喧务,文稿富文本內(nèi)容里,任何復(fù)雜 DOM 結(jié)構(gòu)都可能出現(xiàn)枉圃。
同時功茴,劃線高亮和筆記必須支持后續(xù)訪問時還原,一次性的一錘子買賣是沒有任何意義的孽亲。
因此就拿劃線高亮實現(xiàn)邏輯來說坎穿,這個實現(xiàn)將會在兩個場景中出現(xiàn):
- 第一個場景是用戶進入頁面,渲染頁面時返劲,將之前保存的劃線和筆記還原展示玲昧;
- 第二個場景是當(dāng)前頁面生命周期中,用戶又動態(tài)添加了劃線和筆記篮绿。
這兩個場景都要添加高亮背景等孵延,從代碼上來講,這就需要進行合理的邏輯抽象和復(fù)用亲配。
此外尘应,在需求實現(xiàn)過程中,我們發(fā)現(xiàn)一個核心問題和風(fēng)險點是事件兼容性處理以及事件類型的沖突和干擾解決弃榨。這些內(nèi)容后文都將提到菩收,請繼續(xù)閱讀。
業(yè)界情況和社區(qū)方案
開發(fā)前鲸睛,我們從產(chǎn)品形態(tài)上調(diào)研了三款業(yè)界實現(xiàn)娜饵,它們分別是:
- 網(wǎng)易蝸牛讀書
- 豆瓣閱讀
- Medium
其中,網(wǎng)易蝸牛讀書是最接近我們需求的官辈,但是類似需求只出現(xiàn)在網(wǎng)易蝸牛讀書 App 內(nèi)箱舞,在 H5 端(wap 端)根本不允許用戶勾選文字內(nèi)容遍坟。
豆瓣閱讀只有 PC 端實現(xiàn)了劃線需求,移動端沒有實現(xiàn)劃線高亮晴股,且沒有實現(xiàn)「劃線行后添加筆記」的功能愿伴。它用一個單獨的頁面來展示筆記,比較取巧电湘,但是大大降低了開發(fā)難度隔节。這類需求在 PC 端實現(xiàn)的成本遠比在移動端實現(xiàn)要低很多。值得一提的是寂呛,豆瓣閱讀的劃線高亮的樣式實現(xiàn)方案是采用了一層絕對定位的 mask怎诫,如下圖:
但我們放棄了絕對定位的高亮 mask 方案。我們的需求要在劃線上實現(xiàn)大量移動端交互贷痪,同時要實現(xiàn)劃線行后插入筆記(筆記內(nèi)容不可能采用 absolute 絕對定位幻妓,因為無法將文字行內(nèi)容撐開),再考慮到不同手機屏幕寬度不同劫拢,遮罩 mask 位置都要動態(tài)計算肉津,當(dāng)劃線高亮量級比較大的時候,算是一個不能忽略的計算成本舱沧。同時可以預(yù)見:這種方案后期擴展性以及靈活性都不強妹沙。因此,這種「加一個遮罩 mask 的高亮樣式方案」并不太適用我們的場景熟吏。
最后看一下 Medium初烘,喜歡看國外技術(shù)文章的同學(xué)們應(yīng)該對這個網(wǎng)站并不陌生。Medium 的小清新風(fēng)格體驗很不錯分俯,但是在劃線筆記功能上,實現(xiàn)的較為簡單哆料。它同樣只有劃線高亮缸剪,沒有筆記(或者說筆記是單獨的頁面呈現(xiàn))。在技術(shù)實現(xiàn)上东亦,如圖:
Medium 采用了拆補標簽的方案杏节,它使用 mark 標簽將劃線文字包裹,通過對 mask 標簽的樣式設(shè)置典阵,達到高亮效果奋渔。 但請注意,該標簽中沒有標識劃線的 id壮啊,也就是說它無法區(qū)別不同的劃線嫉鲸,進一步推測:因為無法對每條劃線識別,當(dāng)不同劃線有重疊內(nèi)容時歹啼,Medium 會合并劃線玄渗,將不同劃線合并成為了一條新劃線座菠,用戶在刪除或者其他操作劃線的交互時,就操作了合并產(chǎn)生的新劃線藤树。這樣的實現(xiàn)當(dāng)然最簡單浴滴,但是我們的產(chǎn)品無法滿意。
因此行業(yè)內(nèi)的產(chǎn)品跟我們的需求相比岁钓,都比較基礎(chǔ)升略,它們:
- 完全不支持劃線交叉/重合
- 只有 App 內(nèi)實現(xiàn),或者只有 PC 實現(xiàn)屡限,移動端 H5 頁面沒有實現(xiàn)
相關(guān)開源庫
再來看看社區(qū)相關(guān)開源庫:
- Rangy品嚣,可以實現(xiàn)文本高亮,但其對于劃線選區(qū)重合的情況是將兩個選區(qū)直接合并了囚霸,當(dāng)然腰根,這是不合符我們業(yè)務(wù)需求的
- Diigo,不僅需要付費拓型,而且能力極弱额嘿,它也是直接不允許劃線選區(qū)的重合
- Web-highlighter 定制能力比較弱
每一種方案讀者都可以找到相應(yīng)的開源庫,這里不再一一剖析劣挫〔嵫總結(jié)下來,社區(qū)的輪子更像是一個玩具压固,即便支持劃線區(qū)域重合球拦,但更多只是樣式上實現(xiàn)了高亮,是一個演示級別的 demo帐我,如果想對接我們后續(xù)的交互操作坎炼,比如行后插入筆記,點擊劃線高亮喚醒 tooltip 等拦键,將會是更大挑戰(zhàn)谣光。
結(jié)合我們特殊的需求,同時考慮靈活性和自主性芬为,我們決定擼起袖子萄金,自己干。
開發(fā)思路
需求的復(fù)雜度決定了我們的實現(xiàn)方案和社區(qū)媚朦、業(yè)界方案都有所不同氧敢,或者說是已有方案的升級和改造版。整體實現(xiàn)思路除了體現(xiàn)前端傳統(tǒng)知識點外询张,更加突出了算法甚至編譯原理的應(yīng)用孙乖。
劃線高亮樣式實現(xiàn)
首先聚焦劃線高亮樣式的做法。簡單來說,我們通過拆解標簽來實現(xiàn)的圆。如圖:
第一行表示已有文稿片段內(nèi)容鼓拧,橙色字體 3456 表示用戶劃線區(qū)域。我們的預(yù)期結(jié)果是將 3456 用新標簽 span 包裹越妈,span 標簽含有該劃線的 id 等其他信息季俩。
對于多個劃線交叉的情況,我們看下面示意圖梅掠,已經(jīng)有劃線內(nèi)容 3456酌住,當(dāng)用戶新勾選劃線 5678 時,即 56 分別屬于兩段劃線的交叉區(qū)域阎抒。理想地酪我,我們應(yīng)該得到新的標簽結(jié)構(gòu):
這樣拆解標簽的設(shè)計比上文分析的豆瓣閱讀采用絕對定位的遮罩更加靈活:豆瓣閱讀的方案無疑只是樣式的展現(xiàn),如果考慮上事件交互且叁,那么拆解標簽的穩(wěn)定和強大顯而易見都哭。比如,當(dāng)用戶點擊 56 文字時逞带,tooltip 出現(xiàn)欺矫,如果點擊「刪除劃線」按鈕,按照需求展氓,我們應(yīng)該刪除最新的一條劃線(即 5678)穆趴,而不是 3456,這樣通過拆解標簽生成不同的標簽細節(jié)遇汞,可以很好地結(jié)合前端事件處理未妹。
我們通過模仿天然的事件冒泡:根據(jù) event.target 向上遍歷 DOM 元素,能發(fā)現(xiàn) 56 除了歸屬 id 為 2 的劃線之外空入,也屬于 id 為 1 的劃線络它,通過比對劃線的創(chuàng)建時間,找到需要操作(刪除歪赢、分享酪耕、添加筆記、復(fù)制內(nèi)容等操作)的最終劃線即可轨淌。
此邏輯抽象為函數(shù),簡單表達為:
// 點擊區(qū)域中看尼,可能包含多條劃線递鹉,我們需要找到最近創(chuàng)建的一條劃線
getLatestHighlight (e) {
// 選擇文本,檢測重復(fù)時藏斩,設(shè)置了 targetHightlightId
if (this.state.targetHightlightId && !e) {
return this.state.targetHightlightId
}
let target
// triggerTooltipEvent 用于對事件觸發(fā)兼容性和特殊情況的磨平躏结,讀者可暫不考慮
if (triggerTooltipEvent && !e) {
target = triggerTooltipEvent.target
} else if (e) {
target = e.target
} else {
// 無法獲取 target 對象時(理論上不可能),安全退出
return
}
const propagationHighlightMap = {}
const paragraphNode = getFirstBlockAncestor(target)
let latestHighlight = target.getAttribute('createdtime')
// triggerTooltipEvent.target 向上冒泡狰域,將所有點擊事件經(jīng)過的劃線(可能有重疊和交叉)信息推入到 propagationHighlightMap 中
const walk = node => {
while (node !== paragraphNode) {
if (node.getAttribute('commentid')) {
const currentHighlightCreatedtime = node.getAttribute('createdtime')
if (Number(currentHighlightCreatedtime) > Number(latestHighlight)) {
latestHighlight = currentHighlightCreatedtime
}
propagationHighlightMap[currentHighlightCreatedtime] = node.getAttribute('commentid')
}
node = node.parentNode
}
}
walk(target)
const latestHighlightId = propagationHighlightMap[latestHighlight]
return latestHighlightId
}
根據(jù)劃線后富文本標簽結(jié)果媳拴,我們直接調(diào)用 React setDangerouslyInnerHTML API 即可得到劃線頁面黄橘。
“紙上得來終覺淺,絕知此事要躬行”屈溉。方案實現(xiàn)的過程當(dāng)中塞关,你會發(fā)現(xiàn)“說起來容易,做起來難”子巾。比如帆赢,一直提到的“拆解標簽”,那具體怎么拆线梗,怎么解呢椰于?
有兩個拆解標簽方案擺在我們面前,我把它歸類為:
- Dom based
- String based
第一種基于 DOM仪搔,第二種基于字符串瘾婿。在具體展開之前,還是讓我們先來熟悉兩個基本 BOM/DOM APIs烤咧。
Window.getSelection
Window.getSelection() 可以返回一系列關(guān)于用戶選區(qū)的信息偏陪,如下使用方式:
const range = window.getSelection().getRangeAt(0)
const start = {
node: range.startContainer,
offset: range.startOffset
}
const end = {
node: range.endContainer,
offset: range.endOffset
}
但是請注意,我們無法通過它直接獲取選區(qū)中的所有 DOM 元素髓削,它只能返回選區(qū)的首尾節(jié)點信息竹挡,包括劃線起始于哪個 node,起始文本相對于該 node 偏移量是多少立膛;劃線結(jié)束于哪個 node揪罕,結(jié)束文本相對于該 node 偏移量是多少。我們準確找到了首尾節(jié)點宝泵,下一步就是找出“中間”所有的文本節(jié)點好啰。這就需要遍歷 DOM 樹。
樹形 DOM Node 典型如下圖儿奶,出自這里:
由于 DOM 不是線性結(jié)構(gòu)而是樹形結(jié)構(gòu)框往,所以“找出中間所有的文本節(jié)點”,這個“中間”換成程序語言闯捎,就是指深度優(yōu)先遍歷椰弊。我們來看代碼:
<p>12
<span data-id=1> 34
<span data-id=2> 56 </span>
</span>
<span data-id=2> 78 </span>
90
</p>
這段文字中,包含了兩段劃線高亮內(nèi)容瓤鼻。對應(yīng)圖:
在已有劃線對應(yīng)的 DOM 的情況下秉版,用戶又勾選了 67,那么我們希望能得到:
<p>12
<span data-id=1> 34
<span data-id=2>
5
<span data-id=3> 6 </span>
</span>
</span>
<span data-id=2>
<span data-id=3> 7 </span>
8
</span>
90
</p>
因為我們能獲得的 startNode(data-id 為 3 的 span)的下一個兄弟節(jié)點為 null茬祷,為了沿途遍歷包裹標簽并找到 endNode清焕,我們需要先“回溯”,再深度向下。典型的 DFS秸妥,用循環(huán)和遞歸均可實現(xiàn)滚停,這里不再贅述,僅給出一個簡單示意:
遞歸版:
const DFSTraverse = (rootNodes, rootLayer) => {
const roots = Array.from(rootNodes)
while (roots.length) {
const root = roots.shift()
printInfo(root, rootLayer)
if (root.children.length) {
DFSTraverse(root.children, rootLayer + 1)
}
}
}
堆棧偽代碼:
stack my_stack;
list visited_nodes;
my_stack.push(starting_node);
while my_stack.length > 0
current_node = my_stack.pop();
if current_node == null
continue;
if current_node in visited_nodes
continue;
visited_nodes.add(current_node);
// visit node, get the class or whatever you need
foreach child in current_node.children
my_stack.push(child);
Text.splitText()
由于用戶勾選區(qū)域只包含一個文本節(jié)點的一部分粥惧,所以我們拆解標簽時键畴,也是在開始和結(jié)束節(jié)點的一部分起止。對此影晓,大部分讀者可能會想到 Text.splitText() 拆分文本節(jié)點镰吵。通過 Text.splitText(),對于開始節(jié)點挂签,收集它的后半部分疤祭;而對于結(jié)束節(jié)點,則是收集前半部分饵婆。
如圖勺馆,
代碼
if (curNode === $startNode) {
if (curNode.nodeType === 3) {
curNode.splitText(startOffset)
const node = curNode.nextSibling
selectedNodes.push(node)
}
}
if (curNode === $endNode) {
if (curNode.nodeType === 3) {
const node = curNode
node.splitText(endOffset)
selectedNodes.push(node)
}
}
DOM based 方案
有了以上基礎(chǔ),我們可以輕松實現(xiàn) DOM based 方案來拆解標簽侨核,實現(xiàn)劃線高亮的渲染草穆。
我們可以按照如何用JS實現(xiàn)“劃詞高亮”的在線筆記功能?一文提供的方案搓译,先計算出劃線標簽起止的偏移量:
function getTextPreOffset(root, text) {
const nodeStack = [root];
let curNode = null;
let offset = 0;
while (curNode = nodeStack.pop()) {
const children = curNode.childNodes;
for (let i = children.length - 1; i >= 0; i--) {
nodeStack.push(children[i]);
}
if (curNode.nodeType === 3 && curNode !== text) {
offset += curNode.textContent.length;
}
else if (curNode.nodeType === 3) {
break;
}
}
return offset;
}
還原高亮選區(qū)時悲柱,需要一個對應(yīng)的逆過程:
function getTextChildByOffset(parent, offset) {
const nodeStack = [parent];
let curNode = null;
let curOffset = 0;
let startOffset = 0;
while (curNode = nodeStack.pop()) {
const children = curNode.childNodes;
for (let i = children.length - 1; i >= 0; i--) {
nodeStack.push(children[i]);
}
if (curNode.nodeType === 3) {
startOffset = offset - curOffset;
curOffset += curNode.textContent.length;
if (curOffset >= offset) {
break;
}
}
}
if (!curNode) {
curNode = parent;
}
return {node: curNode, offset: startOffset};
}
理想很豐滿,但是我最終放棄了 DOM based 方案些己。原因如下:
- DOM based 方案依賴 DOM Node豌鸡,如果你的內(nèi)容不渲染到瀏覽器上(或者借助某些宿主 API)的話,這些方法都無法直接實施
- 如果渲染到瀏覽器上的話段标,渲染的每一步動作都要依賴上一步渲染到瀏覽器之后的更新 DOM涯冠,反復(fù)讀寫 DOM,意味著反復(fù) repaint 甚至 reflow逼庞。每一個劃線的渲染也同樣頻繁操作 DOM蛇更,我們的程序是基于 React 的,反復(fù) setState 觸發(fā) setDangerouslyInnerHTML 內(nèi)容的更新赛糟,體驗令人崩潰
- 依賴 DOM派任,也就意味著有很多奇怪的問題,涉及到兼容性璧南,也就涉及到“不可預(yù)知的神秘力量”
基于 DOM 的拆解標簽方案還有個最大的劣勢在于:我們完全依賴 DOM 樹掌逛,不管是初于穩(wěn)定性還是靈活性,一個基于字符串的拆解標簽方案似乎更加合適穆咐。
劃線高亮的持久化和還原
按照文章順序,我們應(yīng)該介紹基于字符串的拆解標簽 string based 方案了《耘龋可是為了更加清楚地剖析該方案原理崖叫,請允許我先把這種方案擱置,讓我們先了解一下劃線高亮的持久化和還原做法拍柒。
持久化劃線高亮選區(qū)的核心是找到一種合適的 DOM 節(jié)點序列化方法心傀,以便再次進入頁面時候能夠定位 DOM 節(jié)點,渲染出來劃線和高亮內(nèi)容拆讯。
一般方案有以下四種:
- xPath:記錄劃線 DOM 的 xPath
- Css selector:記錄劃線 DOM 的標簽選擇器順序
- Dom tag node offset + text offset:記錄劃線 DOM 的標簽偏移量以及劃線文字在此 DOM 內(nèi)的的文字偏移量
- Paragraph offset + text offset:記錄劃線 DOM 所屬段落的段落偏移量以及劃線文字相對于該段落的文字偏移量
我們的第一反應(yīng)就是記錄相關(guān) DOM 的偏移脂男,即「是哪些相關(guān) DOM 上發(fā)生了劃線操作」,遂將此 DOM 的相對或絕對偏移量記錄下來种呐,這是前三種方案的思路宰翅。事實上,最初我也選擇了使用第三種方式來快速實現(xiàn)爽室,但是存在一些“致命問題”汁讼。第三種記錄 DOM 標簽的偏移量,也就是記錄相對于所有富文本內(nèi)容的第 K 個標簽發(fā)生了劃線操作阔墩,以及這個標簽內(nèi)的文字相對于該 DOM 文本偏移量(從這個標簽的第 K' 個字開始劃線或者終止劃線)嘿架。
還是如何用JS實現(xiàn)“劃詞高亮”的在線筆記功能?一文的例子啸箫,我們來看一下這種持久化方案的問題耸彪。
如下內(nèi)容:
<p>非常高興今天能夠在這里和大家分享一下文本高亮(在線筆記)的實現(xiàn)方式。</p>
用戶先劃線了「高興」兩個字:
<p>
非常
<span class="highlight">高興</span>
今天能夠在這里和大家分享一下文本高亮(在線筆記)的實現(xiàn)方式忘苛。
</p>
我們來生成相關(guān)劃線數(shù)據(jù):
// “高興”兩個字被高亮?xí)r獲取的序列化信息
{
start: {
tagName: 'p',
index: 0,
childIndex: 0,
offset: 2
},
end: {
tagName: 'p',
index: 0,
childIndex: 0,
offset: 4
}
}
這并不難理解蝉娜,「高興」兩個字出現(xiàn)在第一個 P 標簽,該 P 標簽內(nèi)只有一個文本節(jié)點柑土,因此 childIndex 為 0蜀肘,在這個文本節(jié)點的第二個字開始進行了劃線,到第四個字終止稽屏。
這時候扮宠,用戶又劃線了「文本高亮」四個字:
<p>
非常
<span class="highlight">高興</span>
今天能夠在這里和大家分享一下
<span class="highlight">文本高亮</span>
(在線筆記)的實現(xiàn)方式。
</p>
此時持久化數(shù)據(jù)的計算是基于前一刻的 DOM 快照生成的狐榔,即「文本高亮」這四個字的劃線是相對前一刻的 DOM 結(jié)構(gòu)進行計算:首尾節(jié)點的 childIndex 都被記為 2(此時 P 標簽有三個 children)坛增,「文本高亮」這四個字的偏移量是相對于「今天能夠在這里和大家分享一下文本高亮(在線筆記)的實現(xiàn)方式」計算的。
得到新的數(shù)據(jù)結(jié)構(gòu):
// “文本高亮”四個字被高亮?xí)r獲取的序列化信息薄腻。
// 這時候由于 p 下面已經(jīng)存在了一個高亮信息(即“高興”)收捣。
// 所以其內(nèi)部 HTML 結(jié)構(gòu)已被修改,直觀來說就是 childNodes 改變了庵楷。
// 進而罢艾,childIndex屬性由于前一個 span 元素的加入楣颠,變?yōu)榱?2。
{
start: {
tagName: 'p',
index: 0,
childIndex: 2,
offset: 14
},
end: {
tagName: 'p',
index: 0,
childIndex: 2,
offset: 18
}
}
請設(shè)想咐蚯,如果用戶又刪除了「高興」選區(qū)的劃線童漩,那么所有出現(xiàn)在「高興」選區(qū)劃線之后的劃線數(shù)據(jù)將會出現(xiàn)錯誤。本質(zhì)上春锋,伴隨著劃線添加矫膨,我們動態(tài)改變了 DOM 結(jié)構(gòu),使得持久化數(shù)據(jù)發(fā)生錯亂期奔,這便是問題所在侧馅。需求中還會隨時動態(tài)添加筆記段落,無疑會讓問題更加復(fù)雜嚴重呐萌。
因此馁痴,合理的劃線高亮的持久化和還原方案應(yīng)該記錄文稿文本相對于文字的偏移量,而不是 DOM 標簽的編譯量搁胆。
我們來看一下最終方案:
對應(yīng)的數(shù)據(jù)為:
notes 字段表示筆記信息弥搞,這里暫時不涉及加入的筆記需求,我們可以先忽略渠旁。
字段解釋:paragraph_start
和 paragraph_end
表示當(dāng)前劃線的起始和結(jié)尾段落攀例,如果對應(yīng)數(shù)值不相等,說明該條劃線是跨段落的劃線顾腊。mark_start
和 mark_end
分別表示對應(yīng) paragraph_start
和 paragraph_end
段落中粤铭,開始和結(jié)束劃線文字相對于該段純文稿文本的偏移量。
String based 方案
了解了持久化劃線和高亮方案杂靶,我們趁熱打鐵梆惯,看看 string based 方案是如何結(jié)合持久化數(shù)據(jù)實現(xiàn)劃線高亮渲染(拆解標簽)的。
首先需要注意的是:我們不能粗暴地在開始和結(jié)尾劃線直接加入 span 開始和閉合標簽吗垮,因為這種過于理想的情況會導(dǎo)致標簽混亂不匹配垛吗。
比如:
<p>今天<span>我非常高興</span>給大家介紹</p>
已有高亮內(nèi)容:我非常高興。這時候烁登,用戶又劃線“非常高興給大家”這 7 個文字時怯屉,如果直接添加標簽,會得到:
<p>
今天
<span>我
<span>非常高興</span>給大家</span>
介紹
</p>
明顯 span 層級錯亂饵沧,包裹標簽失效锨络。
我們在做劃線高亮?xí)r,得到的基本信息富文本就是字符串狼牺,比如這樣的內(nèi)容:
<p>123456<span>789012345</span>678901234567</p>
假設(shè)實際需要高亮的內(nèi)容如下羡儿,劃線高亮起始于第一個 9,終止于第二個 9 處:
也就是說我想要得到上面的效果是钥。
結(jié)合劃線數(shù)據(jù):mark_start掠归,mark_end缅叠,我們先要找到劃線開始的那個字符。方法是:設(shè)置指針虏冻,開始逐個掃描字符串:
掃描直到指針偏移到 mark_start 處痪署,則表示找到了劃線起始。我們插入 span 字符串兄旬,最終遍歷劃線得到全部修飾過的富文本字符串,一次性交給 React setDangerouslyInnerHTML 渲染即可余寥。
但是這里需要注意:因為 mark_start领铐,mark_end 是相對于文本的偏移量,因此在掃描標簽時宋舷,如果遇到了 DOM tag绪撵,進入了 DOM 標簽內(nèi)的文字,那么我們需要停止計數(shù)祝蝠,并繼續(xù)移動指針音诈,直到移動出當(dāng)前 Dom tag,才可以恢復(fù)計數(shù)绎狭。也就是說在上圖中细溅,掃描到 <span> 直到移出的 6 個位移中,我們不進行計數(shù)儡嘶。
如果你想問喇聊,那我們記錄相對于富文本內(nèi)容的偏移量不就不用這么麻煩了嗎?恭喜你蹦狂,如果這么想誓篱,那我們就又回到了上文提到的動態(tài)改變 DOM 標簽即富文本內(nèi)容的問題了。
實際上凯楔,DOM tag 的開標標簽字符 < 以及結(jié)束標簽 > 都會被轉(zhuǎn)義窜骄。但是這里我想延伸,即便不被轉(zhuǎn)義摆屯,真正的問題是:我們?nèi)绾闻袛嘀羔樢苿舆^程中遇到了 DOM tag邻遏,進而需要停止計數(shù)?因為文稿內(nèi)容可能就有一個 <鸥拧,我們怎么知道這是文稿的真實內(nèi)容而不是進入 DOM tag 的標記呢党远?(實際上 < 會被轉(zhuǎn)義,這里問了簡化問題富弦,先不考慮轉(zhuǎn)義的情況)沟娱。
其實上述過程,已經(jīng)是一個現(xiàn)代編譯器的雛形了腕柜,我們可以看看編譯器是如何處理這種問題:當(dāng)掃描到 < 時济似,我們設(shè)置第二根指針矫废,這個第二根指針繼續(xù)向下嗅探,進行掃描砰蠢,如果一路匹配出 <span 我們就可以斷言第一根指針遇見的當(dāng)前 < 是一個 DOM tag 開始標簽蓖扑。
事實上,熟悉 Vue 源碼的同學(xué)可能會想到 Vue compiler 模塊:在 Vue 實現(xiàn)模版引擎台舱,并進行模版變量雙向綁定時律杠,也處理了同樣的問題【和铮——因為這是一個經(jīng)典的編譯器基本原理柜去。
再比如 Babel 進行編譯代碼時,例如 optional chaining 這個編譯插件拆宛,也是通過掃描源碼字符串绒北,發(fā)現(xiàn)一個 芦鳍?,則通過新的嗅探指針進行向下掃描,如果發(fā)現(xiàn) ? 后面跟著一個 .瘤礁,即 foo?.bar 這種表達形式箭启,那么可判斷這是一個 optional chaining 用法秉剑,我們可以進行相應(yīng)的 ES5 編譯澜汤;如果嗅探指針發(fā)現(xiàn) ?后出現(xiàn)了 :敢艰,那么就應(yīng)該把它當(dāng)作三木運算符理解茬末。真實情況更加復(fù)雜,且有所出入盖矫,這里只是對原理進行說明丽惭,不再展開。
這個嗅探指針的實現(xiàn)過程辈双,有一個專業(yè)術(shù)語也許大家聽說過责掏,叫做 tokenizer,即分詞湃望。它往往會結(jié)合 AST(抽象語法樹)出現(xiàn)换衬,在前端工程化等領(lǐng)域中出現(xiàn)。
劃線筆記的標簽拆解证芭,就是一個樸素的編譯器原理瞳浦,涉及到 tokenizer 等一系列過程。有了這樣的能力废士,一切就會變得“不那么復(fù)雜”叫潦。當(dāng)然,在具體實現(xiàn)劃線高亮業(yè)務(wù)中官硝,我根據(jù)需求特點矗蕊,創(chuàng)建了很多 fastpath短蜕,簡化了編譯分詞過程,這里讀者只需要理解底層思想即可傻咖。
行后插入筆記
聊完劃線高亮朋魔,我們再看一下劃線行后插入筆記效果的實現(xiàn)。之前提到卿操,我們的頁面所有文稿內(nèi)容都是 React 一股腦渲染富文本得來的警检,劃線完畢后,如何找到適當(dāng)?shù)奈恢茫▌澗€后下一行)插入筆記呢害淤?
方案非常巧妙:我們借助 document.createRange() 和 Text.splitNode() 兩個 APIs解滓,在劃線后的第一個字符后,創(chuàng)建一個 range筝家,長度為 1,換句話說邻辉,提取劃線截止的最后一個字后的每一個字拆并計算這個字距離屏幕最左側(cè)的長度溪王,進行記錄。按照常理值骇,劃線后的每一個字距離屏幕左側(cè)的長度應(yīng)該依次遞增莹菱,直到換行后的第一個字符。這樣我們就找到了劃線后一行的起始吱瘩。
如下圖所示:
接下來就是在找到換行的位置插入筆記節(jié)點的邏輯了道伟。說起來簡單,實施起來除了遞歸算法的運用之外使碾,還用進行多種 cases 的容錯處理蜜徽,實現(xiàn)代碼也有幾百行了。
代碼:
const walkToRenderCommentBlock = (range, lastRightOffset, currentAnnotationId, lastAnnotationByIdNode) => {
const currentRightOffset = range.getBoundingClientRect().right
if (lastRightOffset > currentRightOffset) {
// 找到了插入點
doRenderCommentBlock(range, currentAnnotationId, lastAnnotationByIdNode)
} else {
// 繼續(xù)向下一個字符尋找
if (range.endOffset < range.endContainer.textContent.length - 1) {
// 如果當(dāng)前 range 還沒找到頭票摇,那就繼續(xù)下一個
const currentRange = document.createRange()
currentRange.setStart(range.endContainer, range.endOffset)
// 如果結(jié)束節(jié)點類型是 Text, Comment, or CDATASection 之一, 那么 endOffset 指的是從結(jié)束節(jié)點算起字符的偏移量
// 對于其他 Node 類型節(jié)點拘鞋, endOffset 是指從結(jié)束結(jié)點開始算起子節(jié)點的偏移量。
try {
// 存在 range.endOffset + 1 不存在的情況 (比如空標簽)矢门,這時候就用下一個節(jié)點
currentRange.setEnd(range.endContainer, range.endOffset + 1)
} catch (e) {
currentRange.setStart(getNextSiblingNode(range.endContainer), 0)
currentRange.setEnd(getNextSiblingNode(range.endContainer), 1)
}
return walkToRenderCommentBlock(currentRange, currentRightOffset, currentAnnotationId, lastAnnotationByIdNode)
} else {
// 如果當(dāng)前 range 到頭了盆色,還沒有找到,則找下一個 nodeText
const nextNode = getNextTextNode(range.endContainer)
if (nextNode) {
const currentRange = document.createRange()
currentRange.setStart(nextNode, 0)
currentRange.setEnd(nextNode, 1)
return walkToRenderCommentBlock(currentRange, currentRightOffset, currentAnnotationId, lastAnnotationByIdNode)
} else {
// 找到了當(dāng)前段的最后面祟剔,在段后加
doRenderCommentBlock(null, currentAnnotationId, lastAnnotationByIdNode)
}
}
}
}
整體流程梳理
我們來看一下全套流程的時序圖:
項目采用了基于 Rect 的 SSR 架構(gòu)隔躲,在服務(wù)端預(yù)獲取兩類數(shù)據(jù):
- fetchManuscript
- fetchAnnotationsData
第一類數(shù)據(jù)是原始文稿內(nèi)容;第二類是文稿對應(yīng)的劃線筆記持久化數(shù)據(jù)物延。在交給瀏覽器渲染之后宣旱,React 進行初次繪制,這一次渲染呈現(xiàn)原始文稿內(nèi)容叛薯。因為我們只有把原始文稿實際渲染完成后响鹃,再結(jié)合手機屏幕寬度和位置信息驾霜,才能應(yīng)用劃線筆記的邏輯。在 componentDidMount 邏輯中买置,我們先進行 disableSelection粪糙,該方法設(shè)置所有非文稿內(nèi)容不可選,之后 transformData 邏輯將加工后端數(shù)據(jù)為:
- annotationsById
- notesById
劃線高亮邏輯核心函數(shù):renderHighlight 對 annotationsById 進行遍歷忿项,生成全量的已加入劃線標簽的富文本字符串蓉冈,此時再次觸發(fā)渲染。這次渲染完后轩触,執(zhí)行 renderNotes 函數(shù)和 renderNotesIcon 函數(shù)寞酿,他對 notesById 進行遍歷,生成生成全量的已加入筆記區(qū)塊的富文本字符串脱柱,并觸發(fā)渲染伐弹。
整個流程通過 Promise 串聯(lián),依次執(zhí)行榨为。請注意這里不能并行執(zhí)行惨好,因為筆記的插入依賴渲染劃線高亮樣式后的布局。
因為我們的事件處理和綁定采用了事件代理的方式随闺,因此在 componentDidMount 之后即可和其他渲染流程并行處理日川。我們在開篇就提到過,事件的沖突和干擾在此項目中尤為突出和棘手矩乐。由于篇幅限制龄句,我們不再深入分析,而是拆出來幾個條目供大家參考散罕。
touch 事件處理
可能你好奇分歇,為什么需要對 touch 事件進行監(jiān)聽?是因為 click 在移動端的 300 ms 延遲欧漱?
其實沒有那么簡單卿樱,是因為用戶在勾選文字后,觸發(fā) click 時硫椰,由于系統(tǒng)原因繁调,勾選區(qū)域?qū)每眨@時候獲取 window.getSelection() 只會得到 null靶草,而 tooltip 上的點擊事件處理大都需要 window.getSelection() 的內(nèi)容蹄胰。因此,我們要么對最近一次的 window.getSelection() 返回值持久化存在內(nèi)存中奕翔,要么對于 tooltip 的點擊事件換成對 touch 事件的監(jiān)聽裕寨,而后者明顯是更合理的方案。
接下來,我們看看對 touch 事件(準確來說 touchend 事件)綁定了哪些交互宾袜。
復(fù)制
復(fù)制按鈕的點擊又有兩種場景:
- 一種是在用戶勾選文字的過程中捻艳,點擊 tooltip 上「復(fù)制」,那么復(fù)制的內(nèi)容為勾選的合法文字庆猫;
- 一種是點擊已經(jīng)存在的高亮劃線认轨,喚醒 tooltip,再點擊 tooltip 上「復(fù)制」按鈕月培,這時候復(fù)制的內(nèi)容為劃線高亮文字嘁字。
說起來簡單,做起來有點復(fù)雜杉畜。對于第一種情況纪蜒,我們需要找到合法的勾選文字,需求要求勾選內(nèi)容如果包含筆記內(nèi)容此叠,那么復(fù)制文案要排除筆記內(nèi)容纯续,只復(fù)制文稿內(nèi)容;如果包含段后公開筆記計數(shù) icon灭袁,也不能復(fù)制進去計數(shù)值猬错。因此我們還需要進行圈選區(qū)域的遍歷,并判斷非法標簽(非文稿內(nèi)容標簽)简卧。如下如,我們勾選得到兩個 text 分別問勾選 startNode 和 endNode烤芦,一個經(jīng)典的 DFS 又出來了:
對于第二種情況举娩,點擊「復(fù)制」按鈕后,我們要先判斷點擊區(qū)域是否屬于多條劃線高亮的交叉區(qū)域构罗,如果是铜涉,那么就要模擬向上冒泡過程,找到最近的歸屬劃線遂唧,復(fù)制相應(yīng)內(nèi)容芙代。
- 劃線
點擊「劃線」按鈕,我們就要先判斷選區(qū)是否可劃線盖彭,然后計算劃線偏移量得到劃線持久化數(shù)據(jù)纹烹,進行標簽拆解,渲染高亮區(qū)域召边,接著向后端發(fā)送請求并更新內(nèi)存中 annotaionsById 數(shù)據(jù)铺呵。別忘了還需要更新段后計數(shù) icon 的值。
- 刪除劃線
與「劃線」按鈕類似隧熙,同樣需要判斷是否點擊區(qū)域是否為多條劃線的交叉區(qū)域片挂,并和后端通信,以及修改內(nèi)存數(shù)據(jù)和 DOM 內(nèi)容。
- 分享
分享需要調(diào)客戶端端能力音念,同樣先要確定是點擊劃線后喚醒 tooltip 并點擊「分享」按鈕沪饺,還是用戶勾選文字后喚醒 tooltip 并點擊「分享」按鈕∶品撸基本邏輯類似「復(fù)制」按鈕的點擊整葡。
- 寫筆記
這時候可能是用戶勾選了新的內(nèi)容:因此要先加高亮劃線,再去寫筆記肝谭;也可能是點擊已經(jīng)存在的劃線掘宪,去增加筆記。
由此可見攘烛,各種按鈕邏輯都有多種觸發(fā)場景魏滚, 需要我們做很多細致的判斷和處理。這只是“冰山一角”坟漱,更多的邏輯和場景不再一一列舉鼠次。
click, touch, selectionchange 的三國演義
touch 事件的引入,細化了我們事件處理粒度芋齿,使得需求能夠完成腥寇。但它帶來了事件的交織和沖突。結(jié)合碎片化的手機終端觅捆,矛盾沖突重重赦役。
比如:對于點擊事件 click,如果點擊時當(dāng)前 tooltip 不存在栅炒,且點擊的是已有劃線高亮內(nèi)容掂摔,那么應(yīng)該喚醒 tooltip,且 tooltip 含有「刪除劃線」按鈕赢赊;如果當(dāng)前 tooltip 已經(jīng)存在乙漓,則認為觸碰了空白區(qū)域,tooltip 就應(yīng)該消失释移。有極個別瀏覽器叭披,點擊事件 click 會觸發(fā) selectionchange 事件,但是 selectionchange 事件的觸發(fā)玩讳,會使我們認為用戶勾選了新的內(nèi)容涩蜘,引發(fā)一系列的連鎖反應(yīng)。
再說回來熏纯,當(dāng) tooltip 存在時皱坛,點擊空白區(qū)域,tooltip 消失豆巨。但是需求要求滾動時候 tooltip 不能消失剩辟,可是滾動事件觸發(fā),很多瀏覽器也會觸發(fā)點擊事件,這時候我們認為 tooltip 又應(yīng)該消失贩猎。類似所有這些內(nèi)容都交織一起熊户,開發(fā)者都需要考慮到。
有認真的讀者可能會想:「為什么不考慮監(jiān)聽 selectionchangeend 事件」吭服,在開發(fā)過程中嚷堡,我們發(fā)現(xiàn) selectionchangeend 雖然在規(guī)范中有提及,但該事件在任何手機在都不會觸發(fā)艇棕。如果使用 touchend 模擬 selectionchangeend蝌戒,又發(fā)現(xiàn)有的手機在勾選結(jié)束后不觸發(fā) touchend/touchmove。
當(dāng)然沼琉,這不是本篇文章的重點北苟,我們點到為止。
安全性和性能保障
整體下來打瘪,劃線筆記項目的安全性尤為重要友鼻。這里的安全主要是指用戶交互的非阻塞性,文稿內(nèi)容和劃線筆記的呈現(xiàn)準確性闺骚。但文稿頁面內(nèi)容千變?nèi)f化彩扔,標簽結(jié)構(gòu)理論上能達到最復(fù)雜。如何在線上出現(xiàn)問題時僻爽,不阻塞頁面虫碉,且保障其他交互的順暢進行呢?
其實方法也很簡單胸梆,主要依賴 try...catch 區(qū)塊敦捧,在 catch 中注意進行錯誤采集和還原,方便后續(xù)記錄并追查乳绕。同時合理的 fallback 機制也非常重要绞惦,這需要和產(chǎn)品討論制定更加完善的方案逼纸。值得一提的是洋措,我們前端組如今正在著力打造完善的端到端測試流程,已經(jīng)接入了最基本的劃線高亮測試杰刽,未來在端到端的測試上菠发,我們將持續(xù)深耕,屆時也會分享更多經(jīng)驗和心得贺嫂。
劃線筆記涉及到的性能話題其實較為常見滓鸠,保障策略也較為常規(guī),但是性能手段每一點的背后都是一個極大的話題第喳,這里我們簡單總結(jié)使用到的性能優(yōu)化方法糜俗,并不再往下延伸:
- 服務(wù)端渲染,預(yù)獲取數(shù)據(jù)
- Dom 節(jié)點選擇器的優(yōu)化
- 遞歸性能優(yōu)化(優(yōu)先使用 for 循環(huán),借助蹦床函數(shù)等尾遞歸調(diào)用優(yōu)化實施)
- debouch 和 throttle 的合理使用
- Dom 操作減少 repaint 和 reflow
- 獨立合成層悠抹,GPU 渲染加速相關(guān):比如 transform珠月,opacity 等 CSS3 屬性的使用
- addEventListenner 第三個參數(shù) passive 的使用
總結(jié)
從「劃線高亮」并「插入筆記」這個需求,我們提煉出了一連串前端知識點楔敌,同時分析了實施過程當(dāng)中的困難和解決方案啤挎。
這些內(nèi)容涉及到 DOM、BOM 等基本知識卵凑,也涉及到編程領(lǐng)域中不可或缺的 AST庆聘、編譯原理的皮毛,并延伸出現(xiàn)代前端開發(fā)所依賴的 Babel 以及框架 Vue 的實現(xiàn)原理勺卢。
前端開發(fā)的護城河之一就是精細化交互實現(xiàn)伙判,前端開發(fā)的開疆?dāng)U土也依賴于更低層的編程普適原理,希望這篇長文能對大家有所啟發(fā)值漫,也歡迎大家一起討論澳腹。