詳解實(shí)現(xiàn)一個(gè)通用的“劃詞高亮”在線筆記功能
1. 什么是“劃詞高亮”?
有些同學(xué)可能不太清楚“劃詞高亮”是指什么,下面就是一個(gè)典型的“劃詞高亮”:
上圖的示例網(wǎng)站可以點(diǎn)擊這里訪問(wèn)。用戶選擇一段文本(即劃詞),即會(huì)自動(dòng)將這段選取的文本添加高亮背景,用戶可以很方便地為網(wǎng)頁(yè)添加在線筆記。
筆者前段時(shí)間為線上業(yè)務(wù)實(shí)現(xiàn)了一個(gè)與內(nèi)容結(jié)構(gòu)非耦合的文本高亮筆記功能。非耦合是指不需要為高亮功能建立特殊的頁(yè)面 DOM 結(jié)構(gòu),而高亮功能對(duì)業(yè)務(wù)近乎透明。該功能核心部分具有較強(qiáng)的通用性與移植性,故拿出來(lái)和大家分享交流一下。
本文具體的核心代碼已封裝成獨(dú)立庫(kù) web-highlighter,閱讀中如有疑問(wèn)可參考其中代碼↓↓。
2. 實(shí)現(xiàn)“劃詞高亮”需要解決哪些問(wèn)題?
實(shí)現(xiàn)一個(gè)“劃詞高亮”的在線筆記功能需要解決的核心問(wèn)題有兩個(gè):
- 加高亮背景。即如何根據(jù)用戶在網(wǎng)頁(yè)上的選取,為相應(yīng)的文本添加高亮背景;
- 高亮區(qū)域的持久化與還原。即如何保存用戶高亮信息,并在下次瀏覽時(shí)準(zhǔn)確還原,否則下次打開(kāi)頁(yè)面用戶高亮的信息就丟失了。
一般來(lái)說(shuō),劃詞高亮的業(yè)務(wù)需求方主要是針對(duì)自己產(chǎn)出的內(nèi)容,你可以比較容易對(duì)內(nèi)容在網(wǎng)頁(yè)上的排版、HTML 標(biāo)簽等方面進(jìn)行控制。這種情況下,處理高亮需求會(huì)更方便一些,畢竟自己可以根據(jù)高亮需求調(diào)整現(xiàn)有內(nèi)容的 HTML。
而筆者面對(duì)的情況是,頁(yè)面 HTML 排版結(jié)構(gòu)復(fù)雜,且無(wú)法根據(jù)高亮需求來(lái)推動(dòng)業(yè)務(wù)改動(dòng) HTML。這也催生出了對(duì)解決方案更通用化的要求,目標(biāo)就是:針對(duì)任意內(nèi)容均可“劃詞高亮”并支持后續(xù)訪問(wèn)時(shí)還原高亮狀態(tài),而不用去關(guān)心內(nèi)容的組織結(jié)構(gòu)。
下面就來(lái)具體說(shuō)說(shuō),如何解決上面的兩個(gè)核心問(wèn)題。
3. 如何“加高亮背景”?
根據(jù)動(dòng)圖演示我們可以知道,用戶選擇某一段文本(下文稱為“用戶選區(qū)”)后,我們會(huì)給這段文本加一個(gè)高亮背景。
例如用戶選擇了上圖中的文本(即藍(lán)色部分)。為其加高亮的基本思路如下:
- 獲取選中的文本節(jié)點(diǎn):通過(guò)用戶選擇的區(qū)域信息,獲取所有被選中的所有文本節(jié)點(diǎn);
- 為文本節(jié)點(diǎn)添加背景色:給這些文本節(jié)點(diǎn)包裹一層新的元素,該元素具有指定的背景顏色。
3.1. 如何獲取選中的文本節(jié)點(diǎn)?
1)Selection API
需要基于瀏覽器為我們提供的 Selection API 。它的兼容性還不錯(cuò)。如果要支持更低版本的瀏覽器則需要用 polyfill。
Selection API 可以返回一系列關(guān)于用戶選區(qū)的信息。那么是不是可以通過(guò)它直接獲取選取中的所有 DOM 元素呢?
很遺憾并不能。但好在它可以返回選區(qū)的首尾節(jié)點(diǎn)信息:
const range = window.getSelection().getRangeAt(0); const start = { node: range.startContainer, offset: range.startOffset }; const end = { node: range.endContainer, offset: range.endOffset };
Range 對(duì)象包含了選區(qū)的開(kāi)始與結(jié)束信息,其中包括節(jié)點(diǎn)(node)與文本偏移量(offset)。節(jié)點(diǎn)信息不用多說(shuō),這里解釋一下 offset 是指什么:例如,標(biāo)簽<p>這是一段文本的示例</p>,用戶選取的部分是“一段文本”這四個(gè)字,這時(shí)首尾的 node 均為 p 元素內(nèi)的文本節(jié)點(diǎn)(Text Node),而 startOffset 和 endOffset 分別為 2 和 6。
2)首尾文本節(jié)點(diǎn)拆分
理解了 offset 的概念后,自然就發(fā)現(xiàn)有個(gè)問(wèn)題需要解決。由于用戶選區(qū)(selection)可能只包含一個(gè)文本節(jié)點(diǎn)的一部分(即 offset 不為 0),所以我們最后得到的用戶選區(qū)所包含的節(jié)點(diǎn)里,也只希望有首尾文本節(jié)點(diǎn)的這“一部分”。對(duì)此,我們可以使用 .splitText() 拆分文本節(jié)點(diǎn):
// 首節(jié)點(diǎn) if (curNode === $startNode) { if (curNode.nodeType === 3) { curNode.splitText(startOffset); const node = curNode.nextSibling; selectedNodes.push(node); } } // 尾節(jié)點(diǎn) if (curNode === $endNode) { if (curNode.nodeType === 3) { const node = curNode; node.splitText(endOffset); selectedNodes.push(node); } }
以上代碼會(huì)依據(jù) offset 對(duì)文本節(jié)點(diǎn)進(jìn)行拆分。對(duì)于開(kāi)始節(jié)點(diǎn),只需要收集它的后半部分;而對(duì)于結(jié)束節(jié)點(diǎn)則是前半部分。
3)遍歷 DOM 樹(shù)
到目前為止,我們準(zhǔn)確找到了首尾節(jié)點(diǎn),所以下一步就是找出“中間”所有的文本節(jié)點(diǎn)。這就需要遍歷 DOM 樹(shù)。
“中間”加上引號(hào)是因?yàn)椋谝曈X(jué)上這些節(jié)點(diǎn)是位于首尾之間的,但由于 DOM 不是線性結(jié)構(gòu)而是樹(shù)形結(jié)構(gòu),所以這個(gè)“中間”換成程序語(yǔ)言,就是指深度優(yōu)先遍歷時(shí),位于首尾兩節(jié)點(diǎn)之間的所有文本節(jié)點(diǎn)。DFS 的方法有很多,可以遞歸,也可以用棧+循環(huán),這里就不贅述了。
需要提一下的是,由于我們是要為文本節(jié)點(diǎn)添加高亮背景,因此在遍歷時(shí)只會(huì)收集文本節(jié)點(diǎn)。
if (curNode.nodeType === 3) { selectedNodes.push(curNode); }
3.2. 如何為文本節(jié)點(diǎn)添加背景色?
這一步本身并不困難。在上一步的基礎(chǔ)上,我們已經(jīng)選出了所有被用戶選中的 文本節(jié)點(diǎn)(包括拆分后的首尾節(jié)點(diǎn))。對(duì)此,一個(gè)最直接的方法就是為其“包裹上”一個(gè)帶背景樣式的元素。
具體的,我們可以給每個(gè)文本節(jié)點(diǎn)外加上一個(gè) class 為 highlight 的 <span> 元素;而背景樣式則通過(guò) CSS .highlight 選擇器設(shè)置。
// 使用上一步中封裝的方法獲取選區(qū)內(nèi)的文本節(jié)點(diǎn) const nodes = getSelectedNodes(start, end); nodes.forEach(node => { const wrap = document.createElement('span'); wrap.setAttribute('class', 'highlight'); wrap.appendChild(node.cloneNode(false)); node.parentNode.replaceChild(wrap); });
.highlight { background: #ff9; }
這樣就可以給被選中的文字添加一個(gè)“永久”的高亮背景了。
p.s. 選區(qū)的重合問(wèn)題
然而,文本高亮里還有一個(gè)比較棘手的需求 —— 高亮區(qū)域的重合。舉個(gè)例子,最開(kāi)始的演示圖(下圖)里,第一個(gè)高亮區(qū)域和第二個(gè)高亮區(qū)域之間存在重疊部分,即“本區(qū)域高”四個(gè)字。
這個(gè)問(wèn)題目前來(lái)看似乎還不是問(wèn)題,但在結(jié)合下面要提到的一些功能與需求時(shí),就會(huì)變成非常麻煩,甚至無(wú)法正常運(yùn)行(一些開(kāi)源庫(kù)這塊處理也不盡如人意,這也是沒(méi)有選擇它們的一個(gè)原因)。這里簡(jiǎn)單提一下,具體的情況我會(huì)放到后續(xù)對(duì)應(yīng)的地方再詳細(xì)說(shuō)。
4. 如何實(shí)現(xiàn)高亮選區(qū)的持久化與還原?
到目前我們已經(jīng)可以給選中的文本添加高亮背景了。但還有一個(gè)大問(wèn)題:
想象一下,用戶辛辛苦苦劃了很多重點(diǎn)(高亮),開(kāi)心地退出頁(yè)面后,下次訪問(wèn)時(shí)發(fā)現(xiàn)這些都不能保存時(shí),該有多么得沮喪。因此,如果只是在頁(yè)面上做“一次性”的文本高亮,那它的使用價(jià)值會(huì)大大降低。這也就促使我們的“劃詞高亮”功能要能夠保存(持久化)這些高亮選區(qū)并正確還原。
持久化高亮選區(qū)的核心是找到一種合適的 DOM 節(jié)點(diǎn)序列化方法。
通過(guò)第三部分可以知道,當(dāng)確定了首尾節(jié)點(diǎn)與文本偏移(offset)信息后,即可為其間文本節(jié)點(diǎn)添加背景色。其中,offset 是數(shù)值類(lèi)型,要在服務(wù)器保存它自然沒(méi)有問(wèn)題;但是 DOM 節(jié)點(diǎn)不同,在瀏覽器中保存它只需要賦值給一個(gè)變量,但想在后端保存所謂的 DOM 則不那么直接了。
4.1 序列化 DOM 節(jié)點(diǎn)標(biāo)識(shí)
所以這里的核心點(diǎn)就是找到一種方法,能夠定位 DOM 節(jié)點(diǎn),同時(shí)可以被保存成普通的 JSON Object,用以傳給后端保存,這個(gè)過(guò)程在本文中被稱為 DOM 標(biāo)識(shí) 的“序列化”。而下次用戶訪問(wèn)時(shí),又可以從后端取回,然后“反序列化”為對(duì)應(yīng)的 DOM 節(jié)點(diǎn)。
有幾種常見(jiàn)的方式來(lái)標(biāo)識(shí) DOM 節(jié)點(diǎn):
- 使用 xPath
- 使用 CSS Selector 語(yǔ)法
- 使用 tagName + index
這里選擇了使用第三種方式來(lái)快速實(shí)現(xiàn)。需要注意一點(diǎn),我們通過(guò) Selection API 取到的首尾節(jié)點(diǎn)一般是文本節(jié)點(diǎn),而這里要記錄的 tagName 和 index 都是該文本節(jié)點(diǎn)的父元素節(jié)點(diǎn)(Element Node)的,而 childIndex 表示該文本節(jié)點(diǎn)是其父親的第幾個(gè)兒子:
function serialize(textNode, root = document) { const node = textNode.parentElement; let childIndex = -1; for (let i = 0; i < node.childNodes.length; i++) { if (textNode === node.childNodes[i]) { childIndex = i; break; } } const tagName = node.tagName; const list = root.getElementsByTagName(tagName); for (let index = 0; index < list.length; index++) { if (node === list[index]) { return {tagName, index, childIndex}; } } return {tagName, index: -1, childIndex}; }
通過(guò)該方法返回的信息,再加上 offset 信息,即定位選取的起始位置,同時(shí)也完全可發(fā)送給后端進(jìn)行保存了。
4.2 反序列化 DOM 節(jié)點(diǎn)
基于上一節(jié)的序列化方法,從后端獲取到數(shù)據(jù)后,可以很容易反序列化為 DOM 節(jié)點(diǎn):
function deSerialize(meta, root = document) { const {tagName, index, childIndex} = meta; const parent = root.getElementsByTagName(tagName)[index]; return parent.childNodes[childIndex]; }
至此,我們大體已經(jīng)解決了兩個(gè)核心問(wèn)題,這似乎已經(jīng)是一個(gè)可用版本了。但其實(shí)不然,根據(jù)實(shí)踐經(jīng)驗(yàn),如果僅僅是上面這些處理,往往是無(wú)法應(yīng)對(duì)實(shí)際需求的,存在一些“致命問(wèn)題”。
但不用灰心,下面會(huì)具體來(lái)說(shuō)說(shuō)所謂的“致命問(wèn)題”是什么,而又是如何解決并實(shí)現(xiàn)一個(gè)線上業(yè)務(wù)可用的通用“劃詞高亮”功能的。
5. 如何實(shí)現(xiàn)一個(gè)生產(chǎn)環(huán)境可用的“劃詞高亮”?
1)上面的方案有什么問(wèn)題?
首先來(lái)看看上面的方案會(huì)有什么問(wèn)題。
當(dāng)我們需要高亮文本時(shí),會(huì)為文本節(jié)點(diǎn)包裹span元素,這就改動(dòng)了頁(yè)面的 DOM 結(jié)構(gòu)。它可能會(huì)導(dǎo)致后續(xù)高亮的首尾節(jié)點(diǎn)與其 offset 信息其實(shí)是基于被改動(dòng)后的 DOM 結(jié)構(gòu)的。帶來(lái)的結(jié)果有兩個(gè):
- 下次訪問(wèn)時(shí),程序必須按上次用戶高亮的順序還原。
- 用戶不能隨意取消(刪除)高亮區(qū)域,只能按添加順序從后往前刪。
否則,就會(huì)有部分的高亮選區(qū)在還原時(shí)無(wú)法定位到正確的元素。
文字可能不好理解,下面我舉個(gè)例子來(lái)直觀解釋下這個(gè)問(wèn)題。
<p> 非常高興今天能夠在這里和大家分享一下文本高亮的實(shí)現(xiàn)方式。 </p>
對(duì)于上面這段 HTML,用戶分別按順序高亮了兩個(gè)部分:“高興”和“文本高亮”。那么按照上面的實(shí)現(xiàn)方式,這段 HTML 變成了下面這樣:
<p> 非常 <span class="highlight">高興</span> 今天能夠在這里和大家分享一下 <span class="highlight">文本高亮</span> 的實(shí)現(xiàn)方式。 </p>
對(duì)應(yīng)的兩個(gè)序列化數(shù)據(jù)分別為:
// “高興”兩個(gè)字被高亮?xí)r獲取的序列化信息 { start: { tagName: 'p', index: 0, childIndex: 0, offset: 2 }, end: { tagName: 'p', index: 0, childIndex: 0, offset: 4 } }
// “文本高亮”四個(gè)字被高亮?xí)r獲取的序列化信息。 // 這時(shí)候由于p下面已經(jīng)存在了一個(gè)高亮信息(即“高興”)。 // 所以其內(nèi)部 HTML 結(jié)構(gòu)已被修改,直觀來(lái)說(shuō)就是 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 } }
可以看到,“文本高亮”這四個(gè)字的首尾節(jié)點(diǎn)的 childIndex 都被記為 2,這是由于前一個(gè)高亮區(qū)域改變了<p>元素下的DOM結(jié)構(gòu)。如果此時(shí)“高興”選區(qū)的高亮被用戶取消,那么下次再訪問(wèn)頁(yè)面就無(wú)法還原高亮了 —— “高興”選區(qū)的高亮被取消了,<p>下自然就不會(huì)出現(xiàn)第三個(gè) childNode,那么 childIndex 為 2 就找不到對(duì)應(yīng)的節(jié)點(diǎn)了。這就導(dǎo)致存儲(chǔ)的數(shù)據(jù)在還原高亮選區(qū)時(shí)出現(xiàn)問(wèn)題。
此外,還記得在第三部分末尾提到的高亮選取重合問(wèn)題么?支持選取重合很容易出現(xiàn)如下的包裹元素嵌套情況:
<p> 非常 <span class="highlight">高興</span> 今天能夠在這里和大家分享一下 <span class="highlight"> 文本 <span class="highlight">高涼</span> </span> 的實(shí)現(xiàn)方式。 </p>
這也使得某個(gè)文本區(qū)域經(jīng)過(guò)多次高亮、取消高亮后,會(huì)出現(xiàn)與原 HTML 頁(yè)面不同的復(fù)雜嵌套結(jié)構(gòu)??梢灶A(yù)見(jiàn),當(dāng)我們使用 xpath 或 CSS selector 作為 DOM 標(biāo)識(shí)時(shí),上面提到的問(wèn)題也會(huì)出現(xiàn),同時(shí)也使其他需求的實(shí)現(xiàn)更加復(fù)雜。
到這里可以提一下其他開(kāi)源庫(kù)或產(chǎn)品是如何處理選區(qū)重合問(wèn)題的:
開(kāi)源庫(kù) Rangy 有一個(gè) Highlighter 模塊可以實(shí)現(xiàn)文本高亮,但其對(duì)于選區(qū)重合的情況是將兩個(gè)選區(qū)直接合并了,這是不合符我們業(yè)務(wù)需求的。
付費(fèi)產(chǎn)品 Diigo 直接不允許選區(qū)的重合。
Medium.com 是支持選區(qū)重合的,體驗(yàn)非常不錯(cuò),這也是我們產(chǎn)品的目標(biāo)。但它頁(yè)面的內(nèi)容區(qū)結(jié)構(gòu)相較我面對(duì)的情況會(huì)更簡(jiǎn)單與更可控。
所以如何解決這些問(wèn)題呢?
2)另一種序列化 / 反序列化方式
我會(huì)對(duì)第四部分提到的序列化方式進(jìn)行改進(jìn)。仍然記錄文本節(jié)點(diǎn)的父節(jié)點(diǎn) tagName 與 index,但不再記錄文本節(jié)點(diǎn)在 childNodes 中的 index 與 offset,而是記錄開(kāi)始(結(jié)束)位置在整個(gè)父元素節(jié)點(diǎn)中的文本偏移量。
例如下面這段 HTML:
<p> 非常 <span class="highlight">高興</span> 今天能夠在這里和大家分享一下 <span class="highlight">文本高亮</span> 的實(shí)現(xiàn)方式。 </p>
對(duì)于“文本高亮”這個(gè)高亮選區(qū),之前用于標(biāo)識(shí)文本起始位置的信息為childIndex = 2, offset = 14。而現(xiàn)在變?yōu)閛ffset = 18(從<p>元素下第一個(gè)文本“非”開(kāi)始計(jì)算,經(jīng)過(guò)18個(gè)字符后是“文”)??梢钥闯觯@樣表示的優(yōu)點(diǎn)是,不管<p>內(nèi)部原有的文本節(jié)點(diǎn)被<span>(包裹)節(jié)點(diǎn)如何分割,都不會(huì)影響高亮選區(qū)還原時(shí)的節(jié)點(diǎn)定位。
據(jù)此,在序列化時(shí),我們需要一個(gè)方法來(lái)將文本節(jié)點(diǎn)內(nèi)偏移量“翻譯”為其對(duì)應(yīng)的父節(jié)點(diǎn)內(nèi)部的總體文本偏移量:
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)的逆過(guò)程:
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}; }
3)支持高亮選區(qū)的重合
重合的高亮選區(qū)帶來(lái)的一個(gè)問(wèn)題就是高亮包裹元素的嵌套,從而使得 DOM 結(jié)構(gòu)會(huì)有較復(fù)雜的變動(dòng),增加了其他功能(交互)實(shí)現(xiàn)與問(wèn)題排查的復(fù)雜度。因此,我在 3.2. 節(jié)提到的包裹高亮元素時(shí),會(huì)再進(jìn)行一些稍復(fù)雜的處理(尤其是重合選區(qū)),以保證盡量復(fù)用已有的包裹元素,避免元素的嵌套。
在處理時(shí),將需要包裹的各個(gè)文本片段(Text Node)分為三類(lèi)情況:
- 完全未被包裹,則直接包裹該部分。
- 屬于被包裹過(guò)的文本節(jié)點(diǎn)的一部分,則使用.splitText()將其拆分。
- 是一段完全被包裹的文本段,不需要對(duì)節(jié)點(diǎn)進(jìn)行處理。
于此同時(shí),為每個(gè)選區(qū)生成唯一 ID,將該段文本幾點(diǎn)多對(duì)應(yīng)的 ID、以及其由于選區(qū)重合所涉及到的其他 ID,都附加包裹元素上。因此像上面的第三種情況,不需要變更 DOM 結(jié)構(gòu),只用更新包裹元素兩類(lèi) ID 所對(duì)應(yīng)的 dataset 屬性即可。
6. 其他問(wèn)題
解決以上的一些問(wèn)題后,“文本劃詞高亮”就基本可用了。還剩下一些“小修補(bǔ)”,簡(jiǎn)單提一下。
6.1. 高亮選區(qū)的交互事件,例如 click、hover
首先,可以為每個(gè)高亮選區(qū)生成一個(gè)唯一 ID,然后在該選區(qū)內(nèi)所有的包裹元素上記錄該 ID 信息,例如用data-highlight-id屬性。而對(duì)于選取重合的部分可以在data-highlight-extra-id屬性中記錄重合的其他選區(qū)的 ID。
而監(jiān)聽(tīng)到包裹元素的 click、hover 后,則觸發(fā) highlighter 的相應(yīng)事件,并帶上高亮 ID。
6.2. 取消高亮(高亮背景的刪除)
由于在包裹時(shí)支持選區(qū)重合(對(duì)應(yīng)會(huì)有上面提到的三種情況需要處理),因此,在刪除選取高亮?xí)r,也會(huì)有三種情況需要分別處理:
直接刪除包裹元素。即不存在選區(qū)重合。
更新data-highlight-id屬性和data-highlight-extra-id屬性。即刪除的高亮 ID 與 data-highlight-id 相同。
只更新data-highlight-extra-id屬性。即刪除的高亮 ID 只在 data-highlight-extra-id 中。
6.3. 對(duì)于前端生成的動(dòng)態(tài)頁(yè)面怎么辦?
不難發(fā)現(xiàn),這種非耦合的文本高亮功能很依賴于頁(yè)面的 DOM 結(jié)構(gòu),需要保證做高亮?xí)r的 DOM 結(jié)構(gòu)和還原時(shí)的一致,否則無(wú)法正確還原出選區(qū)的起始節(jié)點(diǎn)位置。據(jù)此,對(duì)“劃詞”高亮最友好的應(yīng)該是純后端渲染的頁(yè)面,在onload監(jiān)聽(tīng)中觸發(fā)高亮選區(qū)還原的方法即可。但目前越來(lái)越多的頁(yè)面(或頁(yè)面的一部分)是前端動(dòng)態(tài)生成的,針對(duì)這個(gè)問(wèn)題該怎么處理呢?
我在實(shí)際工作中也遇到了類(lèi)似問(wèn)題 —— 頁(yè)面的很多區(qū)域是 ajax 請(qǐng)求后前端渲染的。我的處理方式包括如下:
隔離變化范圍。將上述代碼中的“根節(jié)點(diǎn)”從documentElement換為另一個(gè)更具體的容器元素。例如我面對(duì)的業(yè)務(wù)會(huì)在 id 為 article-container 的<div>內(nèi)加載動(dòng)態(tài)內(nèi)容,那么我就會(huì)指定這個(gè) article-container 為“根節(jié)點(diǎn)”。這樣可以最大程度防止外部的 DOM 變動(dòng)影響到高亮位置的定位,尤其是頁(yè)面改版。
確定高亮選區(qū)的還原時(shí)機(jī)。由于內(nèi)容可能是動(dòng)態(tài)生成,所以需要等到該部分的 DOM 渲染完成后再調(diào)用還原方法。如果有暴露的監(jiān)聽(tīng)事件可以在監(jiān)聽(tīng)內(nèi)處理;或者通過(guò) MutationObserver 監(jiān)聽(tīng)標(biāo)志性元素來(lái)判斷該部分是否加載完成。
記錄業(yè)務(wù)內(nèi)容信息,應(yīng)對(duì)內(nèi)容區(qū)改版。內(nèi)容區(qū)的 DOM 結(jié)構(gòu)更改算是“毀滅性打擊”。如何確實(shí)有該類(lèi)情況,可以嘗試讓業(yè)務(wù)內(nèi)容展示方將段落信息等具體的內(nèi)容信息綁定在 DOM 元素上,而我在高亮?xí)r取出這些信息來(lái)冗余存儲(chǔ),改版后可以通過(guò)這些內(nèi)容信息“刷”一遍存儲(chǔ)的數(shù)據(jù)。
6.4. 其他
篇幅問(wèn)題,還有其他細(xì)節(jié)的問(wèn)題就不在這篇文章里分享了。詳細(xì)內(nèi)容可以參考 web-highlighter 這個(gè)倉(cāng)庫(kù)里的實(shí)現(xiàn)。
7. 總結(jié)
本文先從“劃詞高亮”功能的兩個(gè)核心問(wèn)題(如何高亮用戶選區(qū)的文本、如何將高亮選區(qū)還原)切入,基于 Selection API、深度優(yōu)先遍歷和 DOM 節(jié)點(diǎn)標(biāo)識(shí)的序列化這些手段實(shí)現(xiàn)了“劃詞高亮”的核心功能。然而,該方案仍然存在一些實(shí)際問(wèn)題,因此在第 5 部分進(jìn)一步給出了相應(yīng)的解決方案。
基于實(shí)際開(kāi)發(fā)的經(jīng)驗(yàn),我發(fā)現(xiàn)解決上述幾個(gè)“劃詞高亮”核心問(wèn)題的代碼具有一定通用性,因此把核心部分的源碼封裝成了獨(dú)立的庫(kù) web-highlighter,托管在 github,也可以通過(guò) npm 安裝。
其已服務(wù)于線上產(chǎn)品業(yè)務(wù),基本的高亮功能一行代碼即可開(kāi)啟:
(new Highlighter()).run();
兼容IE 10/11、Edge、Firefox 52+、Chrome 15+、Safari 5.1+、Opera 15+。
以上所述是小編給大家介紹的如何實(shí)現(xiàn)一個(gè)通用的“劃詞高亮”在線筆記功能?詳解整合,希望對(duì)大家有所幫助,如果大家有任何疑問(wèn)請(qǐng)給我留言,小編會(huì)及時(shí)回復(fù)大家的。在此也非常感謝大家對(duì)腳本之家網(wǎng)站的支持!
- js兼容IE6,IE7菜單高亮顯示效果代碼
- 用js查找法實(shí)現(xiàn)當(dāng)前欄目的高亮顯示的代碼
- firefox下javascript實(shí)現(xiàn)高亮關(guān)鍵詞的方法
- JavaScript版代碼高亮
- 用JS將搜索的關(guān)鍵字高亮顯示實(shí)現(xiàn)代碼
- 兩種簡(jiǎn)單實(shí)現(xiàn)菜單高亮顯示的JS類(lèi)代碼
- js 全兼容可高亮二級(jí)緩沖折疊菜單
- tinyMCE插件開(kāi)發(fā)之插入html,php,sql,js代碼 并代碼高亮顯示
- Javascript實(shí)現(xiàn)的CSS代碼高亮顯示
- 9個(gè)javascript語(yǔ)法高亮插件 推薦
相關(guān)文章
js改變style樣式和css樣式的簡(jiǎn)單實(shí)例
下面小編就為大家?guī)?lái)一篇js改變style樣式和css樣式的簡(jiǎn)單實(shí)例。小編覺(jué)得挺不錯(cuò)的,現(xiàn)在就分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2016-06-06JavaScript ECMA-262-3 深入解析(一):執(zhí)行上下文實(shí)例分析
這篇文章主要介紹了JavaScript ECMA-262-3 執(zhí)行上下文,結(jié)合實(shí)例形式詳細(xì)分析JavaScript ECMA執(zhí)行上下文相關(guān)概念、原理與操作注意事項(xiàng),需要的朋友可以參考下2020-04-04