如今前端領(lǐng)域:serverless,low code,全?;雀拍畋椴悸臁i_發(fā)者們熱衷于討論「如何把前端格局做大」,「如何將高高在上的概念落地」。此時(shí),你有沒有感受到「還不知道發(fā)展方向到底是什么,就已經(jīng)被未來拋棄了」。
我想,與其去琢磨「serverless 到底是什么,跟前端有什么關(guān)系」,不如先讓我們回到需求的起點(diǎn),從前端開發(fā)的護(hù)城河特點(diǎn)說起。不忘初心,牢記使命,前端開發(fā)說到底是內(nèi)容渲染和交互實(shí)現(xiàn)。今天這篇文章,讓我們從一個(gè)有趣的產(chǎn)品需求說起,換一個(gè)角度去思考「前端的邊界到底在哪里」。并從這個(gè)前端需求出發(fā),看看技術(shù)上又能有多深的實(shí)踐。
理解需求
需求并不算太復(fù)雜,簡(jiǎn)單來說就是在一個(gè)文稿頁上,實(shí)現(xiàn)「劃線高亮」和「插入筆記」。通過下面完成圖,我們可以總結(jié)需求點(diǎn)包括:

公開筆記展示:

- 這是一個(gè)文稿頁面,主要實(shí)現(xiàn)添加劃線和添加筆記兩大塊功能
- 用戶可以圈選文字內(nèi)容,在彈出 tooltip 中進(jìn)行「劃線添加」
- 用戶在圈選文字時(shí),或者點(diǎn)擊已有劃線高亮區(qū)塊時(shí),喚醒 tooltip 彈出
- 用戶圈選文字時(shí),展示相應(yīng) tooltip,提供:「復(fù)制」、「添加/刪除劃線」、「寫筆記」、「分享」等按鈕
- 以上按鈕功能點(diǎn)容易理解,不再一一展開
- 只有文稿內(nèi)容文字支持劃線交互,其他頁面元素不支持劃線操作
- 劃線添加完成后,相應(yīng)的文字添加高亮背景
- 刪除劃線會(huì)同時(shí)刪除該劃線對(duì)應(yīng)的所有筆記(如果有對(duì)應(yīng)筆記)
- tooltip 彈出時(shí),點(diǎn)擊「寫筆記」按鈕,導(dǎo)航到筆記編輯頁面,由用戶輸入內(nèi)容并添加后,無刷新地返回文稿頁,并在相應(yīng)位置插入該條筆記,筆記內(nèi)容需要在當(dāng)前劃線的下一行插入展現(xiàn)
- 頁面中一段內(nèi)如果有其他用戶的公開筆記,則在段后展示公開筆記 icon,icon 內(nèi)展示公開筆記數(shù)
- 點(diǎn)擊段后公開筆記 icon,展示公開筆記內(nèi)容
更細(xì)的需求點(diǎn)和交互細(xì)節(jié)我們會(huì)在后文實(shí)現(xiàn)環(huán)節(jié)進(jìn)行進(jìn)一步說明。
分析需求
有的讀者可能會(huì)認(rèn)為:「劃線筆記這類需求我見過,應(yīng)該也不難吧」,甚至我還看過文章分析其實(shí)現(xiàn),比如:如何用JS實(shí)現(xiàn)“劃詞高亮”的在線筆記功能?。其實(shí)不然,不同于以往的「劃線高亮」和「插入筆記」需求,我們的場(chǎng)景還真有特殊,包括但不限于(請(qǐng)結(jié)合上面完成圖理解):
- 合法劃線只能圈定為文稿內(nèi)文字。也就是說,tooltip 內(nèi)文案、用戶已添加的筆記內(nèi)容、段后 icon 計(jì)數(shù)、空行、彈窗文案等一切非原始文稿內(nèi)容均不支持勾選
- 用戶劃線可長(zhǎng)可短,劃線范圍可能在一段內(nèi),也可能跨段落。這個(gè)區(qū)別會(huì)影響劃線區(qū)高亮的實(shí)現(xiàn)方案和持久化數(shù)據(jù)設(shè)計(jì)
- 劃線間關(guān)系復(fù)雜,因此不同的劃線可能會(huì)出現(xiàn):不同劃線內(nèi)容交叉,不同劃線內(nèi)容全覆蓋(父子集關(guān)系),不同劃線內(nèi)容完全獨(dú)立三種關(guān)系
- 劃線對(duì)應(yīng)的筆記插入位置需要在對(duì)應(yīng)劃線的下一行,如果一條劃線添加了多條筆記,那么多條筆記在該劃線后一行進(jìn)行順序疊加
- 段后公開筆記計(jì)數(shù) icon 的計(jì)數(shù),需要隨著劃線和筆記的動(dòng)態(tài)添加或刪除而改變
- tooltip 定位:tooltip 位置需要隨著用戶勾選文字的內(nèi)容變化而變化,需要始終保持在勾選區(qū)域中心,垂直方向上處于劃線第一行上方固定像素距離;如果勾選區(qū)域占滿一行,tooltip 在水平位置上需要固定出現(xiàn)在屏幕中心
所有這些細(xì)節(jié)點(diǎn)都需要在 React 技術(shù)棧上實(shí)現(xiàn),因?yàn)槲覀兊奈母鍍?nèi)容是通過 React 組件呈現(xiàn):
return (
<div ...props>
<Component1 />
<Component2 />
<RichText
prop1={prop1}
text={manuscript}
prop3={prop3}
/>
<Component3 />
</div>
)
這將會(huì)給我們帶來極大的挑戰(zhàn):設(shè)想一下,React 一股腦通過 setDangerouslyInnerHTML 渲染整頁富文本,我們?nèi)绾卧诟晃谋緝?nèi)容上添加一系列包括劃線在內(nèi)的交互?或者更細(xì)節(jié)一些,我們?nèi)绾握业接脩魟澗€的下一行進(jìn)行筆記內(nèi)容添加?這么看,React 也許是個(gè)枷鎖,阻礙了我們施展手腳。當(dāng)然辦法總是有的,我們繼續(xù)分析并實(shí)現(xiàn)。
核心問題
需求牽扯到很多細(xì)節(jié)點(diǎn),但這篇文章的目的并不想面面俱到,逐一實(shí)現(xiàn)。讓我們先把精力聚焦在「劃線高亮」和「添加筆記」上。思考核心問題主要有三大方向:
- 添加劃線文本的高亮樣式
- 劃線后插入筆記
- 劃線高亮和筆記的持久化還原
需要說明的是:我們文稿頁面的文稿內(nèi)容來自后臺(tái)編輯器以及第三方內(nèi)容導(dǎo)入。

因此我們看到,后臺(tái)編輯器具備了所有富文本編輯器的常規(guī)內(nèi)容,并含有多項(xiàng)自定義能力,比如:公式添加、代碼塊添加、引用樣式添加、圖片/視頻添加、書簽添加,以及自動(dòng)格式化(標(biāo)點(diǎn)擠壓、三巨頭轉(zhuǎn)換、繁體簡(jiǎn)體字轉(zhuǎn)換)等。因此,文稿內(nèi)容可謂千變?nèi)f化,理論上講,文稿富文本內(nèi)容里,任何復(fù)雜 DOM 結(jié)構(gòu)都可能出現(xiàn)。
同時(shí),劃線高亮和筆記必須支持后續(xù)訪問時(shí)還原,一次性的一錘子買賣是沒有任何意義的。
因此就拿劃線高亮實(shí)現(xiàn)邏輯來說,這個(gè)實(shí)現(xiàn)將會(huì)在兩個(gè)場(chǎng)景中出現(xiàn):
- 第一個(gè)場(chǎng)景是用戶進(jìn)入頁面,渲染頁面時(shí),將之前保存的劃線和筆記還原展示;
- 第二個(gè)場(chǎng)景是當(dāng)前頁面生命周期中,用戶又動(dòng)態(tài)添加了劃線和筆記。
這兩個(gè)場(chǎng)景都要添加高亮背景等,從代碼上來講,這就需要進(jìn)行合理的邏輯抽象和復(fù)用。
此外,在需求實(shí)現(xiàn)過程中,我們發(fā)現(xiàn)一個(gè)核心問題和風(fēng)險(xiǎn)點(diǎn)是事件兼容性處理以及事件類型的沖突和干擾解決。這些內(nèi)容后文都將提到,請(qǐng)繼續(xù)閱讀。
業(yè)界情況和社區(qū)方案
開發(fā)前,我們從產(chǎn)品形態(tài)上調(diào)研了三款業(yè)界實(shí)現(xiàn),它們分別是:
- 網(wǎng)易蝸牛讀書
- 豆瓣閱讀
- Medium
其中,網(wǎng)易蝸牛讀書是最接近我們需求的,但是類似需求只出現(xiàn)在網(wǎng)易蝸牛讀書 App 內(nèi),在 H5 端(wap 端)根本不允許用戶勾選文字內(nèi)容。
豆瓣閱讀只有 PC 端實(shí)現(xiàn)了劃線需求,移動(dòng)端沒有實(shí)現(xiàn)劃線高亮,且沒有實(shí)現(xiàn)「劃線行后添加筆記」的功能。它用一個(gè)單獨(dú)的頁面來展示筆記,比較取巧,但是大大降低了開發(fā)難度。這類需求在 PC 端實(shí)現(xiàn)的成本遠(yuǎn)比在移動(dòng)端實(shí)現(xiàn)要低很多。值得一提的是,豆瓣閱讀的劃線高亮的樣式實(shí)現(xiàn)方案是采用了一層絕對(duì)定位的 mask,如下圖:

但我們放棄了絕對(duì)定位的高亮 mask 方案。我們的需求要在劃線上實(shí)現(xiàn)大量移動(dòng)端交互,同時(shí)要實(shí)現(xiàn)劃線行后插入筆記(筆記內(nèi)容不可能采用 absolute 絕對(duì)定位,因?yàn)闊o法將文字行內(nèi)容撐開),再考慮到不同手機(jī)屏幕寬度不同,遮罩 mask 位置都要?jiǎng)討B(tài)計(jì)算,當(dāng)劃線高亮量級(jí)比較大的時(shí)候,算是一個(gè)不能忽略的計(jì)算成本。同時(shí)可以預(yù)見:這種方案后期擴(kuò)展性以及靈活性都不強(qiáng)。因此,這種「加一個(gè)遮罩 mask 的高亮樣式方案」并不太適用我們的場(chǎng)景。
最后看一下 Medium,喜歡看國(guó)外技術(shù)文章的同學(xué)們應(yīng)該對(duì)這個(gè)網(wǎng)站并不陌生。Medium 的小清新風(fēng)格體驗(yàn)很不錯(cuò),但是在劃線筆記功能上,實(shí)現(xiàn)的較為簡(jiǎn)單。它同樣只有劃線高亮,沒有筆記(或者說筆記是單獨(dú)的頁面呈現(xiàn))。在技術(shù)實(shí)現(xiàn)上,如圖:

Medium 采用了拆補(bǔ)標(biāo)簽的方案,它使用 mark 標(biāo)簽將劃線文字包裹,通過對(duì) mask 標(biāo)簽的樣式設(shè)置,達(dá)到高亮效果。 但請(qǐng)注意,該標(biāo)簽中沒有標(biāo)識(shí)劃線的 id,也就是說它無法區(qū)別不同的劃線,進(jìn)一步推測(cè):因?yàn)闊o法對(duì)每條劃線識(shí)別,當(dāng)不同劃線有重疊內(nèi)容時(shí),Medium 會(huì)合并劃線,將不同劃線合并成為了一條新劃線,用戶在刪除或者其他操作劃線的交互時(shí),就操作了合并產(chǎn)生的新劃線。這樣的實(shí)現(xiàn)當(dāng)然最簡(jiǎn)單,但是我們的產(chǎn)品無法滿意。
因此行業(yè)內(nèi)的產(chǎn)品跟我們的需求相比,都比較基礎(chǔ),它們:
- 完全不支持劃線交叉/重合
- 只有 App 內(nèi)實(shí)現(xiàn),或者只有 PC 實(shí)現(xiàn),移動(dòng)端 H5 頁面沒有實(shí)現(xiàn)
相關(guān)開源庫(kù)
再來看看社區(qū)相關(guān)開源庫(kù):
- Rangy,可以實(shí)現(xiàn)文本高亮,但其對(duì)于劃線選區(qū)重合的情況是將兩個(gè)選區(qū)直接合并了,當(dāng)然,這是不合符我們業(yè)務(wù)需求的
- Diigo,不僅需要付費(fèi),而且能力極弱,它也是直接不允許劃線選區(qū)的重合
- Web-highlighter 定制能力比較弱
每一種方案讀者都可以找到相應(yīng)的開源庫(kù),這里不再一一剖析??偨Y(jié)下來,社區(qū)的輪子更像是一個(gè)玩具,即便支持劃線區(qū)域重合,但更多只是樣式上實(shí)現(xiàn)了高亮,是一個(gè)演示級(jí)別的 demo,如果想對(duì)接我們后續(xù)的交互操作,比如行后插入筆記,點(diǎn)擊劃線高亮喚醒 tooltip 等,將會(huì)是更大挑戰(zhàn)。
結(jié)合我們特殊的需求,同時(shí)考慮靈活性和自主性,我們決定擼起袖子,自己干。
開發(fā)思路
需求的復(fù)雜度決定了我們的實(shí)現(xiàn)方案和社區(qū)、業(yè)界方案都有所不同,或者說是已有方案的升級(jí)和改造版。整體實(shí)現(xiàn)思路除了體現(xiàn)前端傳統(tǒng)知識(shí)點(diǎn)外,更加突出了算法甚至編譯原理的應(yīng)用。
劃線高亮樣式實(shí)現(xiàn)
首先聚焦劃線高亮樣式的做法。簡(jiǎn)單來說,我們通過拆解標(biāo)簽來實(shí)現(xiàn)。如圖:

第一行表示已有文稿片段內(nèi)容,橙色字體 3456 表示用戶劃線區(qū)域。我們的預(yù)期結(jié)果是將 3456 用新標(biāo)簽 span 包裹,span 標(biāo)簽含有該劃線的 id 等其他信息。
對(duì)于多個(gè)劃線交叉的情況,我們看下面示意圖,已經(jīng)有劃線內(nèi)容 3456,當(dāng)用戶新勾選劃線 5678 時(shí),即 56 分別屬于兩段劃線的交叉區(qū)域。理想地,我們應(yīng)該得到新的標(biāo)簽結(jié)構(gòu):

這樣拆解標(biāo)簽的設(shè)計(jì)比上文分析的豆瓣閱讀采用絕對(duì)定位的遮罩更加靈活:豆瓣閱讀的方案無疑只是樣式的展現(xiàn),如果考慮上事件交互,那么拆解標(biāo)簽的穩(wěn)定和強(qiáng)大顯而易見。比如,當(dāng)用戶點(diǎn)擊 56 文字時(shí),tooltip 出現(xiàn),如果點(diǎn)擊「刪除劃線」按鈕,按照需求,我們應(yīng)該刪除最新的一條劃線(即 5678),而不是 3456,這樣通過拆解標(biāo)簽生成不同的標(biāo)簽細(xì)節(jié),可以很好地結(jié)合前端事件處理。
我們通過模仿天然的事件冒泡:根據(jù) event.target 向上遍歷 DOM 元素,能發(fā)現(xiàn) 56 除了歸屬 id 為 2 的劃線之外,也屬于 id 為 1 的劃線,通過比對(duì)劃線的創(chuàng)建時(shí)間,找到需要操作(刪除、分享、添加筆記、復(fù)制內(nèi)容等操作)的最終劃線即可。
此邏輯抽象為函數(shù),簡(jiǎn)單表達(dá)為:
// 點(diǎn)擊區(qū)域中,可能包含多條劃線,我們需要找到最近創(chuàng)建的一條劃線
getLatestHighlight (e) {
// 選擇文本,檢測(cè)重復(fù)時(shí),設(shè)置了 targetHightlightId
if (this.state.targetHightlightId && !e) {
return this.state.targetHightlightId
}
let target
// triggerTooltipEvent 用于對(duì)事件觸發(fā)兼容性和特殊情況的磨平,讀者可暫不考慮
if (triggerTooltipEvent && !e) {
target = triggerTooltipEvent.target
} else if (e) {
target = e.target
} else {
// 無法獲取 target 對(duì)象時(shí)(理論上不可能),安全退出
return
}
const propagationHighlightMap = {}
const paragraphNode = getFirstBlockAncestor(target)
let latestHighlight = target.getAttribute('createdtime')
// triggerTooltipEvent.target 向上冒泡,將所有點(diǎn)擊事件經(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ù)劃線后富文本標(biāo)簽結(jié)果,我們直接調(diào)用 React setDangerouslyInnerHTML API 即可得到劃線頁面。
“紙上得來終覺淺,絕知此事要躬行”。方案實(shí)現(xiàn)的過程當(dāng)中,你會(huì)發(fā)現(xiàn)“說起來容易,做起來難”。比如,一直提到的“拆解標(biāo)簽”,那具體怎么拆,怎么解呢?
有兩個(gè)拆解標(biāo)簽方案擺在我們面前,我把它歸類為:
- Dom based
- String based
第一種基于 DOM,第二種基于字符串。在具體展開之前,還是讓我們先來熟悉兩個(gè)基本 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ǐng)注意,我們無法通過它直接獲取選區(qū)中的所有 DOM 元素,它只能返回選區(qū)的首尾節(jié)點(diǎn)信息,包括劃線起始于哪個(gè) node,起始文本相對(duì)于該 node 偏移量是多少;劃線結(jié)束于哪個(gè) node,結(jié)束文本相對(duì)于該 node 偏移量是多少。我們準(zhǔn)確找到了首尾節(jié)點(diǎn),下一步就是找出“中間”所有的文本節(jié)點(diǎn)。這就需要遍歷 DOM 樹。
樹形 DOM Node 典型如下圖,出自這里:

由于 DOM 不是線性結(jié)構(gòu)而是樹形結(jié)構(gòu),所以“找出中間所有的文本節(jié)點(diǎn)”,這個(gè)“中間”換成程序語言,就是指深度優(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)容。對(duì)應(yīng)圖:

在已有劃線對(duì)應(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>
因?yàn)槲覀兡塬@得的 startNode(data-id 為 3 的 span)的下一個(gè)兄弟節(jié)點(diǎn)為 null,為了沿途遍歷包裹標(biāo)簽并找到 endNode,我們需要先“回溯”,再深度向下。典型的 DFS,用循環(huán)和遞歸均可實(shí)現(xiàn),這里不再贅述,僅給出一個(gè)簡(jiǎ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ū)域只包含一個(gè)文本節(jié)點(diǎn)的一部分,所以我們拆解標(biāo)簽時(shí),也是在開始和結(jié)束節(jié)點(diǎn)的一部分起止。對(duì)此,大部分讀者可能會(huì)想到 Text.splitText() 拆分文本節(jié)點(diǎn)。通過 Text.splitText(),對(duì)于開始節(jié)點(diǎn),收集它的后半部分;而對(duì)于結(jié)束節(jié)點(diǎn),則是收集前半部分。
如圖,

代碼
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ǔ),我們可以輕松實(shí)現(xiàn) DOM based 方案來拆解標(biāo)簽,實(shí)現(xiàn)劃線高亮的渲染。
我們可以按照如何用JS實(shí)現(xiàn)“劃詞高亮”的在線筆記功能?一文提供的方案,先計(jì)算出劃線標(biāo)簽起止的偏移量:
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ū)時(shí),需要一個(gè)對(duì)應(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)的話,這些方法都無法直接實(shí)施
- 如果渲染到瀏覽器上的話,渲染的每一步動(dòng)作都要依賴上一步渲染到瀏覽器之后的更新 DOM,反復(fù)讀寫 DOM,意味著反復(fù) repaint 甚至 reflow。每一個(gè)劃線的渲染也同樣頻繁操作 DOM,我們的程序是基于 React 的,反復(fù) setState 觸發(fā) setDangerouslyInnerHTML 內(nèi)容的更新,體驗(yàn)令人崩潰
- 依賴 DOM,也就意味著有很多奇怪的問題,涉及到兼容性,也就涉及到“不可預(yù)知的神秘力量”
基于 DOM 的拆解標(biāo)簽方案還有個(gè)最大的劣勢(shì)在于:我們完全依賴 DOM 樹,不管是初于穩(wěn)定性還是靈活性,一個(gè)基于字符串的拆解標(biāo)簽方案似乎更加合適。
劃線高亮的持久化和還原
按照文章順序,我們應(yīng)該介紹基于字符串的拆解標(biāo)簽 string based 方案了??墒菫榱烁忧宄仄饰鲈摲桨冈恚?qǐng)?jiān)试S我先把這種方案擱置,讓我們先了解一下劃線高亮的持久化和還原做法。
持久化劃線高亮選區(qū)的核心是找到一種合適的 DOM 節(jié)點(diǎn)序列化方法,以便再次進(jìn)入頁面時(shí)候能夠定位 DOM 節(jié)點(diǎn),渲染出來劃線和高亮內(nèi)容。
一般方案有以下四種:
- xPath:記錄劃線 DOM 的 xPath
- Css selector:記錄劃線 DOM 的標(biāo)簽選擇器順序
- Dom tag node offset + text offset:記錄劃線 DOM 的標(biāo)簽偏移量以及劃線文字在此 DOM 內(nèi)的的文字偏移量
- Paragraph offset + text offset:記錄劃線 DOM 所屬段落的段落偏移量以及劃線文字相對(duì)于該段落的文字偏移量
我們的第一反應(yīng)就是記錄相關(guān) DOM 的偏移,即「是哪些相關(guān) DOM 上發(fā)生了劃線操作」,遂將此 DOM 的相對(duì)或絕對(duì)偏移量記錄下來,這是前三種方案的思路。事實(shí)上,最初我也選擇了使用第三種方式來快速實(shí)現(xiàn),但是存在一些“致命問題”。第三種記錄 DOM 標(biāo)簽的偏移量,也就是記錄相對(duì)于所有富文本內(nèi)容的第 K 個(gè)標(biāo)簽發(fā)生了劃線操作,以及這個(gè)標(biāo)簽內(nèi)的文字相對(duì)于該 DOM 文本偏移量(從這個(gè)標(biāo)簽的第 K' 個(gè)字開始劃線或者終止劃線)。
還是如何用JS實(shí)現(xiàn)“劃詞高亮”的在線筆記功能?一文的例子,我們來看一下這種持久化方案的問題。
如下內(nèi)容:
<p>非常高興今天能夠在這里和大家分享一下文本高亮(在線筆記)的實(shí)現(xiàn)方式。</p>

用戶先劃線了「高興」兩個(gè)字:
<p>
非常
<span class="highlight">高興</span>
今天能夠在這里和大家分享一下文本高亮(在線筆記)的實(shí)現(xiàn)方式。
</p>

我們來生成相關(guān)劃線數(shù)據(jù):
// “高興”兩個(gè)字被高亮?xí)r獲取的序列化信息
{
start: {
tagName: 'p',
index: 0,
childIndex: 0,
offset: 2
},
end: {
tagName: 'p',
index: 0,
childIndex: 0,
offset: 4
}
}
這并不難理解,「高興」兩個(gè)字出現(xiàn)在第一個(gè) P 標(biāo)簽,該 P 標(biāo)簽內(nèi)只有一個(gè)文本節(jié)點(diǎn),因此 childIndex 為 0,在這個(gè)文本節(jié)點(diǎn)的第二個(gè)字開始進(jìn)行了劃線,到第四個(gè)字終止。
這時(shí)候,用戶又劃線了「文本高亮」四個(gè)字:
<p>
非常
<span class="highlight">高興</span>
今天能夠在這里和大家分享一下
<span class="highlight">文本高亮</span>
(在線筆記)的實(shí)現(xiàn)方式。
</p>

此時(shí)持久化數(shù)據(jù)的計(jì)算是基于前一刻的 DOM 快照生成的,即「文本高亮」這四個(gè)字的劃線是相對(duì)前一刻的 DOM 結(jié)構(gòu)進(jìn)行計(jì)算:首尾節(jié)點(diǎn)的 childIndex 都被記為 2(此時(shí) P 標(biāo)簽有三個(gè) children),「文本高亮」這四個(gè)字的偏移量是相對(duì)于「今天能夠在這里和大家分享一下文本高亮(在線筆記)的實(shí)現(xiàn)方式」計(jì)算的。
得到新的數(shù)據(jù)結(jié)構(gòu):
// “文本高亮”四個(gè)字被高亮?xí)r獲取的序列化信息。
// 這時(shí)候由于 p 下面已經(jīng)存在了一個(gè)高亮信息(即“高興”)。
// 所以其內(nèi)部 HTML 結(jié)構(gòu)已被修改,直觀來說就是 childNodes 改變了。
// 進(jìn)而,childIndex屬性由于前一個(gè) span 元素的加入,變?yōu)榱?2。
{
start: {
tagName: 'p',
index: 0,
childIndex: 2,
offset: 14
},
end: {
tagName: 'p',
index: 0,
childIndex: 2,
offset: 18
}
}
請(qǐng)?jiān)O(shè)想,如果用戶又刪除了「高興」選區(qū)的劃線,那么所有出現(xiàn)在「高興」選區(qū)劃線之后的劃線數(shù)據(jù)將會(huì)出現(xiàn)錯(cuò)誤。本質(zhì)上,伴隨著劃線添加,我們動(dòng)態(tài)改變了 DOM 結(jié)構(gòu),使得持久化數(shù)據(jù)發(fā)生錯(cuò)亂,這便是問題所在。需求中還會(huì)隨時(shí)動(dòng)態(tài)添加筆記段落,無疑會(huì)讓問題更加復(fù)雜嚴(yán)重。
因此,合理的劃線高亮的持久化和還原方案應(yīng)該記錄文稿文本相對(duì)于文字的偏移量,而不是 DOM 標(biāo)簽的編譯量。
我們來看一下最終方案:
對(duì)應(yīng)的數(shù)據(jù)為:

notes 字段表示筆記信息,這里暫時(shí)不涉及加入的筆記需求,我們可以先忽略。
字段解釋:paragraph_start 和 paragraph_end 表示當(dāng)前劃線的起始和結(jié)尾段落,如果對(duì)應(yīng)數(shù)值不相等,說明該條劃線是跨段落的劃線。mark_start 和 mark_end 分別表示對(duì)應(yīng) paragraph_start 和 paragraph_end 段落中,開始和結(jié)束劃線文字相對(duì)于該段純文稿文本的偏移量。
String based 方案
了解了持久化劃線和高亮方案,我們趁熱打鐵,看看 string based 方案是如何結(jié)合持久化數(shù)據(jù)實(shí)現(xiàn)劃線高亮渲染(拆解標(biāo)簽)的。
首先需要注意的是:我們不能粗暴地在開始和結(jié)尾劃線直接加入 span 開始和閉合標(biāo)簽,因?yàn)檫@種過于理想的情況會(huì)導(dǎo)致標(biāo)簽混亂不匹配。
比如:
<p>今天<span>我非常高興</span>給大家介紹</p>
已有高亮內(nèi)容:我非常高興。這時(shí)候,用戶又劃線“非常高興給大家”這 7 個(gè)文字時(shí),如果直接添加標(biāo)簽,會(huì)得到:
<p>
今天
<span>我
<span>非常高興</span>給大家</span>
介紹
</p>
明顯 span 層級(jí)錯(cuò)亂,包裹標(biāo)簽失效。

我們?cè)谧鰟澗€高亮?xí)r,得到的基本信息富文本就是字符串,比如這樣的內(nèi)容:
<p>123456<span>789012345</span>678901234567</p>
假設(shè)實(shí)際需要高亮的內(nèi)容如下,劃線高亮起始于第一個(gè) 9,終止于第二個(gè) 9 處:

也就是說我想要得到上面的效果。
結(jié)合劃線數(shù)據(jù):mark_start,mark_end,我們先要找到劃線開始的那個(gè)字符。方法是:設(shè)置指針,開始逐個(gè)掃描字符串:

掃描直到指針偏移到 mark_start 處,則表示找到了劃線起始。我們插入 span 字符串,最終遍歷劃線得到全部修飾過的富文本字符串,一次性交給 React setDangerouslyInnerHTML 渲染即可。
但是這里需要注意:因?yàn)?mark_start,mark_end 是相對(duì)于文本的偏移量,因此在掃描標(biāo)簽時(shí),如果遇到了 DOM tag,進(jìn)入了 DOM 標(biāo)簽內(nèi)的文字,那么我們需要停止計(jì)數(shù),并繼續(xù)移動(dòng)指針,直到移動(dòng)出當(dāng)前 Dom tag,才可以恢復(fù)計(jì)數(shù)。也就是說在上圖中,掃描到 <span> 直到移出的 6 個(gè)位移中,我們不進(jìn)行計(jì)數(shù)。
如果你想問,那我們記錄相對(duì)于富文本內(nèi)容的偏移量不就不用這么麻煩了嗎?恭喜你,如果這么想,那我們就又回到了上文提到的動(dòng)態(tài)改變 DOM 標(biāo)簽即富文本內(nèi)容的問題了。
實(shí)際上,DOM tag 的開標(biāo)標(biāo)簽字符 < 以及結(jié)束標(biāo)簽 > 都會(huì)被轉(zhuǎn)義。但是這里我想延伸,即便不被轉(zhuǎn)義,真正的問題是:我們?nèi)绾闻袛嘀羔樢苿?dòng)過程中遇到了 DOM tag,進(jìn)而需要停止計(jì)數(shù)?因?yàn)槲母鍍?nèi)容可能就有一個(gè) <,我們?cè)趺粗肋@是文稿的真實(shí)內(nèi)容而不是進(jìn)入 DOM tag 的標(biāo)記呢?(實(shí)際上 < 會(huì)被轉(zhuǎn)義,這里問了簡(jiǎn)化問題,先不考慮轉(zhuǎn)義的情況)。
其實(shí)上述過程,已經(jīng)是一個(gè)現(xiàn)代編譯器的雛形了,我們可以看看編譯器是如何處理這種問題:當(dāng)掃描到 < 時(shí),我們?cè)O(shè)置第二根指針,這個(gè)第二根指針繼續(xù)向下嗅探,進(jìn)行掃描,如果一路匹配出 <span 我們就可以斷言第一根指針遇見的當(dāng)前 < 是一個(gè) DOM tag 開始標(biāo)簽。
事實(shí)上,熟悉 Vue 源碼的同學(xué)可能會(huì)想到 Vue compiler 模塊:在 Vue 實(shí)現(xiàn)模版引擎,并進(jìn)行模版變量雙向綁定時(shí),也處理了同樣的問題?!?yàn)檫@是一個(gè)經(jīng)典的編譯器基本原理。
再比如 Babel 進(jìn)行編譯代碼時(shí),例如 optional chaining 這個(gè)編譯插件,也是通過掃描源碼字符串,發(fā)現(xiàn)一個(gè) ?,則通過新的嗅探指針進(jìn)行向下掃描,如果發(fā)現(xiàn) ? 后面跟著一個(gè) .,即 foo?.bar 這種表達(dá)形式,那么可判斷這是一個(gè) optional chaining 用法,我們可以進(jìn)行相應(yīng)的 ES5 編譯;如果嗅探指針發(fā)現(xiàn) ?后出現(xiàn)了 :,那么就應(yīng)該把它當(dāng)作三木運(yùn)算符理解。真實(shí)情況更加復(fù)雜,且有所出入,這里只是對(duì)原理進(jìn)行說明,不再展開。
這個(gè)嗅探指針的實(shí)現(xiàn)過程,有一個(gè)專業(yè)術(shù)語也許大家聽說過,叫做 tokenizer,即分詞。它往往會(huì)結(jié)合 AST(抽象語法樹)出現(xiàn),在前端工程化等領(lǐng)域中出現(xiàn)。
劃線筆記的標(biāo)簽拆解,就是一個(gè)樸素的編譯器原理,涉及到 tokenizer 等一系列過程。有了這樣的能力,一切就會(huì)變得“不那么復(fù)雜”。當(dāng)然,在具體實(shí)現(xiàn)劃線高亮業(yè)務(wù)中,我根據(jù)需求特點(diǎn),創(chuàng)建了很多 fastpath,簡(jiǎn)化了編譯分詞過程,這里讀者只需要理解底層思想即可。
行后插入筆記
聊完劃線高亮,我們?cè)倏匆幌聞澗€行后插入筆記效果的實(shí)現(xiàn)。之前提到,我們的頁面所有文稿內(nèi)容都是 React 一股腦渲染富文本得來的,劃線完畢后,如何找到適當(dāng)?shù)奈恢茫▌澗€后下一行)插入筆記呢?
方案非常巧妙:我們借助 document.createRange() 和 Text.splitNode() 兩個(gè) APIs,在劃線后的第一個(gè)字符后,創(chuàng)建一個(gè) range,長(zhǎng)度為 1,換句話說,提取劃線截止的最后一個(gè)字后的每一個(gè)字拆并計(jì)算這個(gè)字距離屏幕最左側(cè)的長(zhǎng)度,進(jìn)行記錄。按照常理,劃線后的每一個(gè)字距離屏幕左側(cè)的長(zhǎng)度應(yīng)該依次遞增,直到換行后的第一個(gè)字符。這樣我們就找到了劃線后一行的起始。
如下圖所示:

接下來就是在找到換行的位置插入筆記節(jié)點(diǎn)的邏輯了。說起來簡(jiǎn)單,實(shí)施起來除了遞歸算法的運(yùn)用之外,還用進(jìn)行多種 cases 的容錯(cuò)處理,實(shí)現(xiàn)代碼也有幾百行了。
代碼:
const walkToRenderCommentBlock = (range, lastRightOffset, currentAnnotationId, lastAnnotationByIdNode) => {
const currentRightOffset = range.getBoundingClientRect().right
if (lastRightOffset > currentRightOffset) {
// 找到了插入點(diǎn)
doRenderCommentBlock(range, currentAnnotationId, lastAnnotationByIdNode)
} else {
// 繼續(xù)向下一個(gè)字符尋找
if (range.endOffset < range.endContainer.textContent.length - 1) {
// 如果當(dāng)前 range 還沒找到頭,那就繼續(xù)下一個(gè)
const currentRange = document.createRange()
currentRange.setStart(range.endContainer, range.endOffset)
// 如果結(jié)束節(jié)點(diǎn)類型是 Text, Comment, or CDATASection 之一, 那么 endOffset 指的是從結(jié)束節(jié)點(diǎn)算起字符的偏移量
// 對(duì)于其他 Node 類型節(jié)點(diǎn), endOffset 是指從結(jié)束結(jié)點(diǎn)開始算起子節(jié)點(diǎn)的偏移量。
try {
// 存在 range.endOffset + 1 不存在的情況 (比如空標(biāo)簽),這時(shí)候就用下一個(gè)節(jié)點(diǎn)
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 到頭了,還沒有找到,則找下一個(gè) 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)
}
}
}
}
整體流程梳理
我們來看一下全套流程的時(shí)序圖:

項(xiàng)目采用了基于 Rect 的 SSR 架構(gòu),在服務(wù)端預(yù)獲取兩類數(shù)據(jù):
- fetchManuscript
- fetchAnnotationsData
第一類數(shù)據(jù)是原始文稿內(nèi)容;第二類是文稿對(duì)應(yīng)的劃線筆記持久化數(shù)據(jù)。在交給瀏覽器渲染之后,React 進(jìn)行初次繪制,這一次渲染呈現(xiàn)原始文稿內(nèi)容。因?yàn)槲覀冎挥邪言嘉母鍖?shí)際渲染完成后,再結(jié)合手機(jī)屏幕寬度和位置信息,才能應(yīng)用劃線筆記的邏輯。在 componentDidMount 邏輯中,我們先進(jìn)行 disableSelection,該方法設(shè)置所有非文稿內(nèi)容不可選,之后 transformData 邏輯將加工后端數(shù)據(jù)為:
- annotationsById
- notesById
劃線高亮邏輯核心函數(shù):renderHighlight 對(duì) annotationsById 進(jìn)行遍歷,生成全量的已加入劃線標(biāo)簽的富文本字符串,此時(shí)再次觸發(fā)渲染。這次渲染完后,執(zhí)行 renderNotes 函數(shù)和 renderNotesIcon 函數(shù),他對(duì) notesById 進(jìn)行遍歷,生成生成全量的已加入筆記區(qū)塊的富文本字符串,并觸發(fā)渲染。
整個(gè)流程通過 Promise 串聯(lián),依次執(zhí)行。請(qǐng)注意這里不能并行執(zhí)行,因?yàn)楣P記的插入依賴渲染劃線高亮樣式后的布局。
因?yàn)槲覀兊氖录幚砗徒壎ú捎昧耸录淼姆绞?,因此?componentDidMount 之后即可和其他渲染流程并行處理。我們?cè)陂_篇就提到過,事件的沖突和干擾在此項(xiàng)目中尤為突出和棘手。由于篇幅限制,我們不再深入分析,而是拆出來幾個(gè)條目供大家參考。
touch 事件處理
可能你好奇,為什么需要對(duì) touch 事件進(jìn)行監(jiān)聽?是因?yàn)?click 在移動(dòng)端的 300 ms 延遲?
其實(shí)沒有那么簡(jiǎn)單,是因?yàn)橛脩粼诠催x文字后,觸發(fā) click 時(shí),由于系統(tǒng)原因,勾選區(qū)域?qū)?huì)置空,這時(shí)候獲取 window.getSelection() 只會(huì)得到 null,而 tooltip 上的點(diǎn)擊事件處理大都需要 window.getSelection() 的內(nèi)容。因此,我們要么對(duì)最近一次的 window.getSelection() 返回值持久化存在內(nèi)存中,要么對(duì)于 tooltip 的點(diǎn)擊事件換成對(duì) touch 事件的監(jiān)聽,而后者明顯是更合理的方案。
接下來,我們看看對(duì) touch 事件(準(zhǔn)確來說 touchend 事件)綁定了哪些交互。
復(fù)制
復(fù)制按鈕的點(diǎn)擊又有兩種場(chǎng)景:
- 一種是在用戶勾選文字的過程中,點(diǎn)擊 tooltip 上「復(fù)制」,那么復(fù)制的內(nèi)容為勾選的合法文字;
- 一種是點(diǎn)擊已經(jīng)存在的高亮劃線,喚醒 tooltip,再點(diǎn)擊 tooltip 上「復(fù)制」按鈕,這時(shí)候復(fù)制的內(nèi)容為劃線高亮文字。
說起來簡(jiǎn)單,做起來有點(diǎn)復(fù)雜。對(duì)于第一種情況,我們需要找到合法的勾選文字,需求要求勾選內(nèi)容如果包含筆記內(nèi)容,那么復(fù)制文案要排除筆記內(nèi)容,只復(fù)制文稿內(nèi)容;如果包含段后公開筆記計(jì)數(shù) icon,也不能復(fù)制進(jìn)去計(jì)數(shù)值。因此我們還需要進(jìn)行圈選區(qū)域的遍歷,并判斷非法標(biāo)簽(非文稿內(nèi)容標(biāo)簽)。如下如,我們勾選得到兩個(gè) text 分別問勾選 startNode 和 endNode,一個(gè)經(jīng)典的 DFS 又出來了:

對(duì)于第二種情況,點(diǎn)擊「復(fù)制」按鈕后,我們要先判斷點(diǎn)擊區(qū)域是否屬于多條劃線高亮的交叉區(qū)域,如果是,那么就要模擬向上冒泡過程,找到最近的歸屬劃線,復(fù)制相應(yīng)內(nèi)容。
- 劃線
點(diǎn)擊「劃線」按鈕,我們就要先判斷選區(qū)是否可劃線,然后計(jì)算劃線偏移量得到劃線持久化數(shù)據(jù),進(jìn)行標(biāo)簽拆解,渲染高亮區(qū)域,接著向后端發(fā)送請(qǐng)求并更新內(nèi)存中 annotaionsById 數(shù)據(jù)。別忘了還需要更新段后計(jì)數(shù) icon 的值。
- 刪除劃線
與「劃線」按鈕類似,同樣需要判斷是否點(diǎn)擊區(qū)域是否為多條劃線的交叉區(qū)域,并和后端通信,以及修改內(nèi)存數(shù)據(jù)和 DOM 內(nèi)容。
- 分享
分享需要調(diào)客戶端端能力,同樣先要確定是點(diǎn)擊劃線后喚醒 tooltip 并點(diǎn)擊「分享」按鈕,還是用戶勾選文字后喚醒 tooltip 并點(diǎn)擊「分享」按鈕?;具壿嬵愃啤笍?fù)制」按鈕的點(diǎn)擊。
- 寫筆記
這時(shí)候可能是用戶勾選了新的內(nèi)容:因此要先加高亮劃線,再去寫筆記;也可能是點(diǎn)擊已經(jīng)存在的劃線,去增加筆記。
由此可見,各種按鈕邏輯都有多種觸發(fā)場(chǎng)景, 需要我們做很多細(xì)致的判斷和處理。這只是“冰山一角”,更多的邏輯和場(chǎng)景不再一一列舉。
click, touch, selectionchange 的三國(guó)演義
touch 事件的引入,細(xì)化了我們事件處理粒度,使得需求能夠完成。但它帶來了事件的交織和沖突。結(jié)合碎片化的手機(jī)終端,矛盾沖突重重。
比如:對(duì)于點(diǎn)擊事件 click,如果點(diǎn)擊時(shí)當(dāng)前 tooltip 不存在,且點(diǎn)擊的是已有劃線高亮內(nèi)容,那么應(yīng)該喚醒 tooltip,且 tooltip 含有「刪除劃線」按鈕;如果當(dāng)前 tooltip 已經(jīng)存在,則認(rèn)為觸碰了空白區(qū)域,tooltip 就應(yīng)該消失。有極個(gè)別瀏覽器,點(diǎn)擊事件 click 會(huì)觸發(fā) selectionchange 事件,但是 selectionchange 事件的觸發(fā),會(huì)使我們認(rèn)為用戶勾選了新的內(nèi)容,引發(fā)一系列的連鎖反應(yīng)。
再說回來,當(dāng) tooltip 存在時(shí),點(diǎn)擊空白區(qū)域,tooltip 消失。但是需求要求滾動(dòng)時(shí)候 tooltip 不能消失,可是滾動(dòng)事件觸發(fā),很多瀏覽器也會(huì)觸發(fā)點(diǎn)擊事件,這時(shí)候我們認(rèn)為 tooltip 又應(yīng)該消失。類似所有這些內(nèi)容都交織一起,開發(fā)者都需要考慮到。
有認(rèn)真的讀者可能會(huì)想:「為什么不考慮監(jiān)聽 selectionchangeend 事件」,在開發(fā)過程中,我們發(fā)現(xiàn) selectionchangeend 雖然在規(guī)范中有提及,但該事件在任何手機(jī)在都不會(huì)觸發(fā)。如果使用 touchend 模擬 selectionchangeend,又發(fā)現(xiàn)有的手機(jī)在勾選結(jié)束后不觸發(fā) touchend/touchmove。
當(dāng)然,這不是本篇文章的重點(diǎn),我們點(diǎn)到為止。
安全性和性能保障
整體下來,劃線筆記項(xiàng)目的安全性尤為重要。這里的安全主要是指用戶交互的非阻塞性,文稿內(nèi)容和劃線筆記的呈現(xiàn)準(zhǔn)確性。但文稿頁面內(nèi)容千變?nèi)f化,標(biāo)簽結(jié)構(gòu)理論上能達(dá)到最復(fù)雜。如何在線上出現(xiàn)問題時(shí),不阻塞頁面,且保障其他交互的順暢進(jìn)行呢?
其實(shí)方法也很簡(jiǎn)單,主要依賴 try...catch 區(qū)塊,在 catch 中注意進(jìn)行錯(cuò)誤采集和還原,方便后續(xù)記錄并追查。同時(shí)合理的 fallback 機(jī)制也非常重要,這需要和產(chǎn)品討論制定更加完善的方案。值得一提的是,我們前端組如今正在著力打造完善的端到端測(cè)試流程,已經(jīng)接入了最基本的劃線高亮測(cè)試,未來在端到端的測(cè)試上,我們將持續(xù)深耕,屆時(shí)也會(huì)分享更多經(jīng)驗(yàn)和心得。
劃線筆記涉及到的性能話題其實(shí)較為常見,保障策略也較為常規(guī),但是性能手段每一點(diǎn)的背后都是一個(gè)極大的話題,這里我們簡(jiǎn)單總結(jié)使用到的性能優(yōu)化方法,并不再往下延伸:
- 服務(wù)端渲染,預(yù)獲取數(shù)據(jù)
- Dom 節(jié)點(diǎn)選擇器的優(yōu)化
- 遞歸性能優(yōu)化(優(yōu)先使用 for 循環(huán),借助蹦床函數(shù)等尾遞歸調(diào)用優(yōu)化實(shí)施)
- debouch 和 throttle 的合理使用
- Dom 操作減少 repaint 和 reflow
- 獨(dú)立合成層,GPU 渲染加速相關(guān):比如 transform,opacity 等 CSS3 屬性的使用
- addEventListenner 第三個(gè)參數(shù) passive 的使用
總結(jié)
從「劃線高亮」并「插入筆記」這個(gè)需求,我們提煉出了一連串前端知識(shí)點(diǎn),同時(shí)分析了實(shí)施過程當(dāng)中的困難和解決方案。
這些內(nèi)容涉及到 DOM、BOM 等基本知識(shí),也涉及到編程領(lǐng)域中不可或缺的 AST、編譯原理的皮毛,并延伸出現(xiàn)代前端開發(fā)所依賴的 Babel 以及框架 Vue 的實(shí)現(xiàn)原理。
前端開發(fā)的護(hù)城河之一就是精細(xì)化交互實(shí)現(xiàn),前端開發(fā)的開疆?dāng)U土也依賴于更低層的編程普適原理,希望這篇長(zhǎng)文能對(duì)大家有所啟發(fā),也歡迎大家一起討論。