1. 什么是“劃詞高亮”?
有些同學(xué)可能不太清楚“劃詞高亮”是指什么,下面就是一個典型的“劃詞高亮”:
上圖的示例網(wǎng)站可以點擊這里訪問。用戶選擇一段文本(即劃詞),即會自動將這段選取的文本添加高亮背景,用戶可以很方便地為網(wǎng)頁添加在線筆記。
筆者前段時間為線上業(yè)務(wù)實現(xiàn)了一個與內(nèi)容結(jié)構(gòu)非耦合的文本高亮在線筆記功能。非耦合是指不需要為高亮功能建立特殊的頁面 DOM 結(jié)構(gòu),而高亮功能對業(yè)務(wù)近乎透明。該功能核心部分具有較強的通用性與移植性,故拿出來和大家分享交流一下。
本文具體的核心代碼已封裝成獨立庫 web-highlighter,閱讀中如有疑問可參考其中代碼↓↓。2. 實現(xiàn)“劃詞高亮”需要解決哪些問題?
實現(xiàn)一個“劃詞高亮”的在線筆記功能需要解決的核心問題有兩個:
加高亮背景。即如何根據(jù)用戶在網(wǎng)頁上的選取,為相應(yīng)的文本添加高亮背景;
高亮區(qū)域的持久化與還原。即如何保存用戶高亮信息,并在下次瀏覽時準確還原,否則下次打開頁面用戶高亮的信息就丟失了。
一般來說,劃詞高亮的業(yè)務(wù)需求方主要是針對自己產(chǎn)出的內(nèi)容,你可以比較容易對內(nèi)容在網(wǎng)頁上的排版、HTML 標簽等方面進行控制。這種情況下,處理高亮需求會更方便一些,畢竟自己可以根據(jù)高亮需求調(diào)整現(xiàn)有內(nèi)容的 HTML。
而筆者面對的情況是,頁面 HTML 排版結(jié)構(gòu)復(fù)雜,且無法根據(jù)高亮需求來推動業(yè)務(wù)改動 HTML。這也催生出了對解決方案更通用化的要求,目標就是:針對任意內(nèi)容均可“劃詞高亮”并支持后續(xù)訪問時還原高亮狀態(tài),而不用去關(guān)心內(nèi)容的組織結(jié)構(gòu)。
下面就來具體說說,如何解決上面的兩個核心問題。
3. 如何“加高亮背景”?根據(jù)動圖演示我們可以知道,用戶選擇某一段文本(下文稱為“用戶選區(qū)”)后,我們會給這段文本加一個高亮背景。
例如用戶選擇了上圖中的文本(即藍色部分)。為其加高亮的基本思路如下:
獲取選中的文本節(jié)點:通過用戶選擇的區(qū)域信息,獲取所有被選中的所有文本節(jié)點;
為文本節(jié)點添加背景色:給這些文本節(jié)點包裹一層新的元素,該元素具有指定的背景顏色。
3.1. 如何獲取選中的文本節(jié)點? 1)Selection API需要基于瀏覽器為我們提供的 Selection API 。它的兼容性還不錯。如果要支持更低版本的瀏覽器則需要用 polyfill。
Selection API 可以返回一系列關(guān)于用戶選區(qū)的信息。那么是不是可以通過它直接獲取選取中的所有 DOM 元素呢?
很遺憾并不能。但好在它可以返回選區(qū)的首尾節(jié)點信息:
const range = window.getSelection().getRangeAt(0); const start = { node: range.startContainer, offset: range.startOffset }; const end = { node: range.endContainer, offset: range.endOffset };
Range 對象包含了選區(qū)的開始與結(jié)束信息,其中包括節(jié)點(node)與文本偏移量(offset)。節(jié)點信息不用多說,這里解釋一下 offset 是指什么:例如,標簽
這是一段文本的示例
,用戶選取的部分是“一段文本”這四個字,這時首尾的 node 均為 p 元素內(nèi)的文本節(jié)點(Text Node),而 startOffset 和 endOffset 分別為 2 和 6。 2)首尾文本節(jié)點拆分理解了 offset 的概念后,自然就發(fā)現(xiàn)有個問題需要解決。由于用戶選區(qū)(selection)可能只包含一個文本節(jié)點的一部分(即 offset 不為 0),所以我們最后得到的用戶選區(qū)所包含的節(jié)點里,也只希望有首尾文本節(jié)點的這“一部分”。對此,我們可以使用 .splitText() 拆分文本節(jié)點:
// 首節(jié)點 if (curNode === $startNode) { if (curNode.nodeType === 3) { curNode.splitText(startOffset); const node = curNode.nextSibling; selectedNodes.push(node); } } // 尾節(jié)點 if (curNode === $endNode) { if (curNode.nodeType === 3) { const node = curNode; node.splitText(endOffset); selectedNodes.push(node); } }
以上代碼會依據(jù) offset 對文本節(jié)點進行拆分。對于開始節(jié)點,只需要收集它的后半部分;而對于結(jié)束節(jié)點則是前半部分。
3)遍歷 DOM 樹到目前為止,我們準確找到了首尾節(jié)點,所以下一步就是找出“中間”所有的文本節(jié)點。這就需要遍歷 DOM 樹。
“中間”加上引號是因為,在視覺上這些節(jié)點是位于首尾之間的,但由于 DOM 不是線性結(jié)構(gòu)而是樹形結(jié)構(gòu),所以這個“中間”換成程序語言,就是指深度優(yōu)先遍歷時,位于首尾兩節(jié)點之間的所有文本節(jié)點。DFS 的方法有很多,可以遞歸,也可以用棧+循環(huán),這里就不贅述了。
需要提一下的是,由于我們是要為文本節(jié)點添加高亮背景,因此在遍歷時只會收集文本節(jié)點。
if (curNode.nodeType === 3) { selectedNodes.push(curNode); }3.2. 如何為文本節(jié)點添加背景色?
這一步本身并不困難。在上一步的基礎(chǔ)上,我們已經(jīng)選出了所有被用戶選中的 文本節(jié)點(包括拆分后的首尾節(jié)點)。對此,一個最直接的方法就是為其“包裹上”一個帶背景樣式的元素。
具體的,我們可以給每個文本節(jié)點外加上一個 class 為 highlight 的 元素;而背景樣式則通過 CSS .highlight 選擇器設(shè)置。
// 使用上一步中封裝的方法獲取選區(qū)內(nèi)的文本節(jié)點 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; }
這樣就可以給被選中的文字添加一個“永久”的高亮背景了。
p.s. 選區(qū)的重合問題然而,文本高亮里還有一個比較棘手的需求 —— 高亮區(qū)域的重合。舉個例子,最開始的演示圖(下圖)里,第一個高亮區(qū)域和第二個高亮區(qū)域之間存在重疊部分,即“本區(qū)域高”四個字。
這個問題目前來看似乎還不是問題,但在結(jié)合下面要提到的一些功能與需求時,就會變成非常麻煩,甚至無法正常運行(一些開源庫這塊處理也不盡如人意,這也是沒有選擇它們的一個原因)。這里簡單提一下,具體的情況我會放到后續(xù)對應(yīng)的地方再詳細說。
4. 如何實現(xiàn)高亮選區(qū)的持久化與還原?到目前我們已經(jīng)可以給選中的文本添加高亮背景了。但還有一個大問題:
想象一下,用戶辛辛苦苦劃了很多重點(高亮),開心地退出頁面后,下次訪問時發(fā)現(xiàn)這些都不能保存時,該有多么得沮喪。因此,如果只是在頁面上做“一次性”的文本高亮,那它的使用價值會大大降低。這也就促使我們的“劃詞高亮”功能要能夠保存(持久化)這些高亮選區(qū)并正確還原。
持久化高亮選區(qū)的核心是找到一種合適的 DOM 節(jié)點序列化方法。
通過第三部分可以知道,當(dāng)確定了首尾節(jié)點與文本偏移(offset)信息后,即可為其間文本節(jié)點添加背景色。其中,offset 是數(shù)值類型,要在服務(wù)器保存它自然沒有問題;但是 DOM 節(jié)點不同,在瀏覽器中保存它只需要賦值給一個變量,但想在后端保存所謂的 DOM 則不那么直接了。
4.1 序列化 DOM 節(jié)點標識所以這里的核心點就是找到一種方法,能夠定位 DOM 節(jié)點,同時可以被保存成普通的 JSON Object,用以傳給后端保存,這個過程在本文中被稱為 DOM 標識 的“序列化”。而下次用戶訪問時,又可以從后端取回,然后“反序列化”為對應(yīng)的 DOM 節(jié)點。
有幾種常見的方式來標識 DOM 節(jié)點:
使用 xPath
使用 CSS Selector 語法
使用 tagName + index
這里選擇了使用第三種方式來快速實現(xiàn)。需要注意一點,我們通過 Selection API 取到的首尾節(jié)點一般是文本節(jié)點,而這里要記錄的 tagName 和 index 都是該文本節(jié)點的父元素節(jié)點(Element Node)的,而 childIndex 表示該文本節(jié)點是其父親的第幾個兒子:
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}; }
通過該方法返回的信息,再加上 offset 信息,即定位選取的起始位置,同時也完全可發(fā)送給后端進行保存了。
4.2 反序列化 DOM 節(jié)點基于上一節(jié)的序列化方法,從后端獲取到數(shù)據(jù)后,可以很容易反序列化為 DOM 節(jié)點:
function deSerialize(meta, root = document) { const {tagName, index, childIndex} = meta; const parent = root.getElementsByTagName(tagName)[index]; return parent.childNodes[childIndex]; }
至此,我們大體已經(jīng)解決了兩個核心問題,這似乎已經(jīng)是一個可用版本了。但其實不然,根據(jù)實踐經(jīng)驗,如果僅僅是上面這些處理,往往是無法應(yīng)對實際需求的,存在一些“致命問題”。
但不用灰心,下面會具體來說說所謂的“致命問題”是什么,而又是如何解決并實現(xiàn)一個線上業(yè)務(wù)可用的通用“劃詞高亮”功能的。
5. 如何實現(xiàn)一個生產(chǎn)環(huán)境可用的“劃詞高亮”? 1)上面的方案有什么問題?首先來看看上面的方案會有什么問題。
當(dāng)我們需要高亮文本時,會為文本節(jié)點包裹span元素,這就改動了頁面的 DOM 結(jié)構(gòu)。它可能會導(dǎo)致后續(xù)高亮的首尾節(jié)點與其 offset 信息其實是基于被改動后的 DOM 結(jié)構(gòu)的。帶來的結(jié)果有兩個:
下次訪問時,程序必須按上次用戶高亮的順序還原。
用戶不能隨意取消(刪除)高亮區(qū)域,只能按添加順序從后往前刪。
否則,就會有部分的高亮選區(qū)在還原時無法定位到正確的元素。
文字可能不好理解,下面我舉個例子來直觀解釋下這個問題。
非常高興今天能夠在這里和大家分享一下文本高亮(在線筆記)的實現(xiàn)方式。
對于上面這段 HTML,用戶分別按順序高亮了兩個部分:“高興”和“文本高亮”。那么按照上面的實現(xiàn)方式,這段 HTML 變成了下面這樣:
非常 高興 今天能夠在這里和大家分享一下 文本高亮 (在線筆記)的實現(xiàn)方式。
對應(yīng)的兩個序列化數(shù)據(jù)分別為:
// “高興”兩個字被高亮?xí)r獲取的序列化信息 { start: { tagName: "p", index: 0, childIndex: 0, offset: 2 }, end: { tagName: "p", index: 0, childIndex: 0, offset: 4 } }
// “文本高亮”四個字被高亮?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 } }
可以看到,“文本高亮”這四個字的首尾節(jié)點的 childIndex 都被記為 2,這是由于前一個高亮區(qū)域改變了
元素下的DOM結(jié)構(gòu)。如果此時“高興”選區(qū)的高亮被用戶取消,那么下次再訪問頁面就無法還原高亮了 —— “高興”選區(qū)的高亮被取消了,
下自然就不會出現(xiàn)第三個 childNode,那么 childIndex 為 2 就找不到對應(yīng)的節(jié)點了。這就導(dǎo)致存儲的數(shù)據(jù)在還原高亮選區(qū)時出現(xiàn)問題。
此外,還記得在第三部分末尾提到的高亮選取重合問題么?支持選取重合很容易出現(xiàn)如下的包裹元素嵌套情況:
非常 高興 今天能夠在這里和大家分享一下 文本 高亮 (在線筆記)的實現(xiàn)方式。
這也使得某個文本區(qū)域經(jīng)過多次高亮、取消高亮后,會出現(xiàn)與原 HTML 頁面不同的復(fù)雜嵌套結(jié)構(gòu)。可以預(yù)見,當(dāng)我們使用 xpath 或 CSS selector 作為 DOM 標識時,上面提到的問題也會出現(xiàn),同時也使其他需求的實現(xiàn)更加復(fù)雜。
到這里可以提一下其他開源庫或產(chǎn)品是如何處理選區(qū)重合問題的:
開源庫 Rangy 有一個 Highlighter 模塊可以實現(xiàn)文本高亮,但其對于選區(qū)重合的情況是將兩個選區(qū)直接合并了,這是不合符我們業(yè)務(wù)需求的。
付費產(chǎn)品 Diigo 直接不允許選區(qū)的重合。
Medium.com 是支持選區(qū)重合的,體驗非常不錯,這也是我們產(chǎn)品的目標。但它頁面的內(nèi)容區(qū)結(jié)構(gòu)相較我面對的情況會更簡單與更可控。
所以如何解決這些問題呢?
2)另一種序列化 / 反序列化方式我會對第四部分提到的序列化方式進行改進。仍然記錄文本節(jié)點的父節(jié)點 tagName 與 index,但不再記錄文本節(jié)點在 childNodes 中的 index 與 offset,而是記錄開始(結(jié)束)位置在整個父元素節(jié)點中的文本偏移量。
例如下面這段 HTML:
非常 高興 今天能夠在這里和大家分享一下 文本高亮 (在線筆記)的實現(xiàn)方式。
對于“文本高亮”這個高亮選區(qū),之前用于標識文本起始位置的信息為childIndex = 2, offset = 14。而現(xiàn)在變?yōu)?b>offset = 18(從
元素下第一個文本“非”開始計算,經(jīng)過18個字符后是“文”)。可以看出,這樣表示的優(yōu)點是,不管
內(nèi)部原有的文本節(jié)點被(包裹)節(jié)點如何分割,都不會影響高亮選區(qū)還原時的節(jié)點定位。
據(jù)此,在序列化時,我們需要一個方法來將文本節(jié)點內(nèi)偏移量“翻譯”為其對應(yīng)的父節(jié)點內(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ū)時,需要一個對應(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}; }3)支持高亮選區(qū)的重合
重合的高亮選區(qū)帶來的一個問題就是高亮包裹元素的嵌套,從而使得 DOM 結(jié)構(gòu)會有較復(fù)雜的變動,增加了其他功能(交互)實現(xiàn)與問題排查的復(fù)雜度。因此,我在 3.2. 節(jié)提到的包裹高亮元素時,會再進行一些稍復(fù)雜的處理(尤其是重合選區(qū)),以保證盡量復(fù)用已有的包裹元素,避免元素的嵌套。
在處理時,將需要包裹的各個文本片段(Text Node)分為三類情況:
完全未被包裹,則直接包裹該部分。
屬于被包裹過的文本節(jié)點的一部分,則使用.splitText()將其拆分。
是一段完全被包裹的文本段,不需要對節(jié)點進行處理。
于此同時,為每個選區(qū)生成唯一 ID,將該段文本幾點多對應(yīng)的 ID、以及其由于選區(qū)重合所涉及到的其他 ID,都附加包裹元素上。因此像上面的第三種情況,不需要變更 DOM 結(jié)構(gòu),只用更新包裹元素兩類 ID 所對應(yīng)的 dataset 屬性即可。
6. 其他問題解決以上的一些問題后,“文本劃詞高亮”就基本可用了。還剩下一些“小修補”,簡單提一下。
6.1. 高亮選區(qū)的交互事件,例如 click、hover首先,可以為每個高亮選區(qū)生成一個唯一 ID,然后在該選區(qū)內(nèi)所有的包裹元素上記錄該 ID 信息,例如用data-highlight-id屬性。而對于選取重合的部分可以在data-highlight-extra-id屬性中記錄重合的其他選區(qū)的 ID。
而監(jiān)聽到包裹元素的 click、hover 后,則觸發(fā) highlighter 的相應(yīng)事件,并帶上高亮 ID。
6.2. 取消高亮(高亮背景的刪除)由于在包裹時支持選區(qū)重合(對應(yīng)會有上面提到的三種情況需要處理),因此,在刪除選取高亮?xí)r,也會有三種情況需要分別處理:
直接刪除包裹元素。即不存在選區(qū)重合。
更新data-highlight-id屬性和data-highlight-extra-id屬性。即刪除的高亮 ID 與 data-highlight-id 相同。
只更新data-highlight-extra-id屬性。即刪除的高亮 ID 只在 data-highlight-extra-id 中。
6.3. 對于前端生成的動態(tài)頁面怎么辦?不難發(fā)現(xiàn),這種非耦合的文本高亮功能很依賴于頁面的 DOM 結(jié)構(gòu),需要保證做高亮?xí)r的 DOM 結(jié)構(gòu)和還原時的一致,否則無法正確還原出選區(qū)的起始節(jié)點位置。據(jù)此,對“劃詞”高亮最友好的應(yīng)該是純后端渲染的頁面,在onload監(jiān)聽中觸發(fā)高亮選區(qū)還原的方法即可。但目前越來越多的頁面(或頁面的一部分)是前端動態(tài)生成的,針對這個問題該怎么處理呢?
我在實際工作中也遇到了類似問題 —— 頁面的很多區(qū)域是 ajax 請求后前端渲染的。我的處理方式包括如下:
隔離變化范圍。將上述代碼中的“根節(jié)點”從documentElement換為另一個更具體的容器元素。例如我面對的業(yè)務(wù)會在 id 為 article-container 的 確定高亮選區(qū)的還原時機。由于內(nèi)容可能是動態(tài)生成,所以需要等到該部分的 DOM 渲染完成后再調(diào)用還原方法。如果有暴露的監(jiān)聽事件可以在監(jiān)聽內(nèi)處理;或者通過 MutationObserver 監(jiān)聽標志性元素來判斷該部分是否加載完成。 記錄業(yè)務(wù)內(nèi)容信息,應(yīng)對內(nèi)容區(qū)改版。內(nèi)容區(qū)的 DOM 結(jié)構(gòu)更改算是“毀滅性打擊”。如何確實有該類情況,可以嘗試讓業(yè)務(wù)內(nèi)容展示方將段落信息等具體的內(nèi)容信息綁定在 DOM 元素上,而我在高亮?xí)r取出這些信息來冗余存儲,改版后可以通過這些內(nèi)容信息“刷”一遍存儲的數(shù)據(jù)。 篇幅問題,還有其他細節(jié)的問題就不在這篇文章里分享了。詳細內(nèi)容可以參考 web-highlighter 這個倉庫里的實現(xiàn)。 本文先從“劃詞高亮”功能的兩個核心問題(如何高亮用戶選區(qū)的文本、如何將高亮選區(qū)還原)切入,基于 Selection API、深度優(yōu)先遍歷和 DOM 節(jié)點標識的序列化這些手段實現(xiàn)了“劃詞高亮”的核心功能。然而,該方案仍然存在一些實際問題,因此在第 5 部分進一步給出了相應(yīng)的解決方案。 基于實際開發(fā)的經(jīng)驗,我發(fā)現(xiàn)解決上述幾個“劃詞高亮”核心問題的代碼具有一定通用性,因此把核心部分的源碼封裝成了獨立的庫 web-highlighter,托管在 github,也可以通過 npm 安裝。 其已服務(wù)于線上產(chǎn)品業(yè)務(wù),基本的高亮功能一行代碼即可開啟: 兼容IE 10/11、Edge、Firefox 52+、Chrome 15+、Safari 5.1+、Opera 15+。 感興趣的小伙伴可以 star 一下。感謝支持,歡迎交流
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。 轉(zhuǎn)載請注明本文地址:http://systransis.cn/yun/103845.html(new Highlighter()).run();
摘要:官網(wǎng)地址聊天機器人插件開發(fā)實例教程一創(chuàng)建插件在系統(tǒng)技巧使你的更加專業(yè)前端掘金一個幫你提升技巧的收藏集。我會簡單基于的簡潔視頻播放器組件前端掘金使用和實現(xiàn)購物車場景前端掘金本文是上篇文章的序章,一直想有機會再次實踐下。 2道面試題:輸入URL按回車&HTTP2 - 掘金通過幾輪面試,我發(fā)現(xiàn)真正那種問答的技術(shù)面,寫一堆項目真不如去刷技術(shù)文章作用大,因此刷了一段時間的博客和掘金,整理下曾經(jīng)被...
摘要:秉持著是騾子是馬拉出來溜溜的心態(tài),我注冊賬號試用了一下他給我的第一印象是簡單和,這些團隊協(xié)作工具一樣,也是一款基于的在線管理工具,沒有在一開始就讓我的安裝包恐懼癥發(fā)作。如何做版本管理第一個要說的,就是目標功能了。 對于一個團隊來說,工作效率的高低很大程度上取決于團隊的管理。 而作為一名剛接觸測試職位的新人來說,如何把一堆堆雜亂不堪的bug管理得井井有條,無疑是最重要的。 我之前一直覺得...
摘要:定義在線狀態(tài)主題類似地,定義離線狀態(tài)主題創(chuàng)建一個函數(shù),用于根據(jù)在線離線狀態(tài)顯示不同的主題現(xiàn)在,關(guān)掉連接,然后刷新頁面,頁面會采用紅色主題再打開連接,然后刷新頁面,頁面會采用綠色主題。 showImg(https://segmentfault.com/img/bVbfQmV?w=400&h=305); 效果預(yù)覽 按下右側(cè)的點擊預(yù)覽按鈕可以在當(dāng)前頁面預(yù)覽,點擊鏈接可以全屏預(yù)覽。 https...
閱讀 1046·2021-11-22 13:52
閱讀 1470·2021-11-19 09:40
閱讀 3258·2021-11-16 11:44
閱讀 1304·2021-11-15 11:39
閱讀 3995·2021-10-08 10:04
閱讀 5433·2021-09-22 14:57
閱讀 3135·2021-09-10 10:50
閱讀 3219·2021-08-17 10:13