1. 什麼是“劃詞高亮”?
有些同學可能不太清楚“劃詞高亮”是指什麼,下面就是一個典型的“劃詞高亮”:
上圖的示例網站可以點選這裡訪問。使用者選擇一段文字(即劃詞),即會自動將這段選取的文字新增高亮背景,使用者可以很方便地為網頁新增線上筆記。
筆者前段時間為線上業務實現了一個與內容結構非耦合的文字高亮筆記功能。非耦合是指不需要為高亮功能建立特殊的頁面 DOM 結構,而高亮功能對業務近乎透明。該功能核心部分具有較強的通用性與移植性,故拿出來和大家分享交流一下。
本文具體的核心程式碼已封裝成獨立庫 web-highlighter,閱讀中如有疑問可參考其中程式碼↓↓。
2. 實現“劃詞高亮”需要解決哪些問題?
實現一個“劃詞高亮”的線上筆記功能需要解決的核心問題有兩個:
- 加高亮背景。即如何根據使用者在網頁上的選取,為相應的文字新增高亮背景;
- 高亮區域的持久化與還原。即如何儲存使用者高亮資訊,並在下次瀏覽時準確還原,否則下次開啟頁面使用者高亮的資訊就丟失了。
一般來說,劃詞高亮的業務需求方主要是針對自己產出的內容,你可以比較容易對內容在網頁上的排版、HTML 標籤等方面進行控制。這種情況下,處理高亮需求會更方便一些,畢竟自己可以根據高亮需求調整現有內容的 HTML。
而筆者面對的情況是,頁面 HTML 排版結構複雜,且無法根據高亮需求來推動業務改動 HTML。這也催生出了對解決方案更通用化的要求,目標就是:針對任意內容均可“劃詞高亮”並支援後續訪問時還原高亮狀態,而不用去關心內容的組織結構。
下面就來具體說說,如何解決上面的兩個核心問題。
3. 如何“加高亮背景”?
根據動圖演示我們可以知道,使用者選擇某一段文字(下文稱為“使用者選區”)後,我們會給這段文字加一個高亮背景。
例如使用者選擇了上圖中的文字(即藍色部分)。為其加高亮的基本思路如下:
- 獲取選中的文字節點:通過使用者選擇的區域資訊,獲取所有被選中的所有文字節點;
- 為文字節點新增背景色:給這些文字節點包裹一層新的元素,該元素具有指定的背景顏色。
3.1. 如何獲取選中的文字節點?
1)Selection API
需要基於瀏覽器為我們提供的 Selection API 。它的相容性還不錯。如果要支援更低版本的瀏覽器則需要用 polyfill。
Selection API 可以返回一系列關於使用者選區的資訊。那麼是不是可以通過它直接獲取選取中的所有 DOM 元素呢?
很遺憾並不能。但好在它可以返回選區的首尾節點資訊:
const range = window.getSelection().getRangeAt(0);
const start = {
node: range.startContainer,
offset: range.startOffset
};
const end = {
node: range.endContainer,
offset: range.endOffset
};
複製程式碼
Range
物件包含了選區的開始與結束資訊,其中包括節點(node)與文字偏移量(offset)。節點資訊不用多說,這裡解釋一下 offset 是指什麼:例如,標籤<p>這是一段文字的示例</p>
,使用者選取的部分是“一段文字”這四個字,這時首尾的 node 均為 p 元素內的文字節點(Text Node),而 startOffset 和 endOffset 分別為 2 和 6。
2)首尾文字節點拆分
理解了 offset 的概念後,自然就發現有個問題需要解決。由於使用者選區(selection)可能只包含一個文字節點的一部分(即 offset 不為 0),所以我們最後得到的使用者選區所包含的節點裡,也只希望有首尾文字節點的這“一部分”。對此,我們可以使用 .splitText()
拆分文字節點:
// 首節點
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);
}
}
複製程式碼
以上程式碼會依據 offset 對文字節點進行拆分。對於開始節點,只需要收集它的後半部分;而對於結束節點則是前半部分。
3)遍歷 DOM 樹
到目前為止,我們準確找到了首尾節點,所以下一步就是找出“中間”所有的文字節點。這就需要遍歷 DOM 樹。
“中間”加上引號是因為,在視覺上這些節點是位於首尾之間的,但由於 DOM 不是線性結構而是樹形結構,所以這個“中間”換成程式語言,就是指深度優先遍歷時,位於首尾兩節點之間的所有文字節點。DFS 的方法有很多,可以遞迴,也可以用棧+迴圈,這裡就不贅述了。
需要提一下的是,由於我們是要為文字節點新增高亮背景,因此在遍歷時只會收集文字節點。
if (curNode.nodeType === 3) {
selectedNodes.push(curNode);
}
複製程式碼
3.2. 如何為文字節點新增背景色?
這一步本身並不困難。在上一步的基礎上,我們已經選出了所有被使用者選中的 文字節點(包括拆分後的首尾節點)。對此,一個最直接的方法就是為其“包裹上”一個帶背景樣式的元素。
具體的,我們可以給每個文字節點外加上一個 class 為 highlight
的 <span>
元素;而背景樣式則通過 CSS .highlight
選擇器設定。
// 使用上一步中封裝的方法獲取選區內的文字節點
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. 選區的重合問題
然而,文字高亮裡還有一個比較棘手的需求 —— 高亮區域的重合。舉個例子,最開始的演示圖(下圖)裡,第一個高亮區域和第二個高亮區域之間存在重疊部分,即“本區域高”四個字。
這個問題目前來看似乎還不是問題,但在結合下面要提到的一些功能與需求時,就會變成非常麻煩,甚至無法正常執行(一些開源庫這塊處理也不盡如人意,這也是沒有選擇它們的一個原因)。這裡簡單提一下,具體的情況我會放到後續對應的地方再詳細說。
4. 如何實現高亮選區的持久化與還原?
到目前我們已經可以給選中的文字新增高亮背景了。但還有一個大問題:
想象一下,使用者辛辛苦苦劃了很多重點(高亮),開心地退出頁面後,下次訪問時發現這些都不能儲存時,該有多麼得沮喪。因此,如果只是在頁面上做“一次性”的文字高亮,那它的使用價值會大大降低。這也就促使我們的“劃詞高亮”功能要能夠儲存(持久化)這些高亮選區並正確還原。
持久化高亮選區的核心是找到一種合適的 DOM 節點序列化方法。
通過第三部分可以知道,當確定了首尾節點與文字偏移(offset)資訊後,即可為其間文字節點新增背景色。其中,offset 是數值型別,要在伺服器儲存它自然沒有問題;但是 DOM 節點不同,在瀏覽器中儲存它只需要賦值給一個變數,但想在後端儲存所謂的 DOM 則不那麼直接了。
4.1 序列化 DOM 節點標識
所以這裡的核心點就是找到一種方法,能夠定位 DOM 節點,同時可以被儲存成普通的 JSON Object,用以傳給後端儲存,這個過程在本文中被稱為 DOM 標識 的“序列化”。而下次使用者訪問時,又可以從後端取回,然後“反序列化”為對應的 DOM 節點。
有幾種常見的方式來標識 DOM 節點:
- 使用 xPath
- 使用 CSS Selector 語法
- 使用 tagName + index
這裡選擇了使用第三種方式來快速實現。需要注意一點,我們通過 Selection API 取到的首尾節點一般是文字節點,而這裡要記錄的 tagName 和 index 都是該文字節點的父元素節點(Element Node)的,而 childIndex 表示該文字節點是其父親的第幾個兒子:
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 資訊,即定位選取的起始位置,同時也完全可傳送給後端進行儲存了。
4.2 反序列化 DOM 節點
基於上一節的序列化方法,從後端獲取到資料後,可以很容易反序列化為 DOM 節點:
function deSerialize(meta, root = document) {
const {tagName, index, childIndex} = meta;
const parent = root.getElementsByTagName(tagName)[index];
return parent.childNodes[childIndex];
}
複製程式碼
至此,我們大體已經解決了兩個核心問題,這似乎已經是一個可用版本了。但其實不然,根據實踐經驗,如果僅僅是上面這些處理,往往是無法應對實際需求的,存在一些“致命問題”。
但不用灰心,下面會具體來說說所謂的“致命問題”是什麼,而又是如何解決並實現一個線上業務可用的通用“劃詞高亮”功能的。
5. 如何實現一個生產環境可用的“劃詞高亮”?
1)上面的方案有什麼問題?
首先來看看上面的方案會有什麼問題。
當我們需要高亮文字時,會為文字節點包裹span
元素,這就改動了頁面的 DOM 結構。它可能會導致後續高亮的首尾節點與其 offset 資訊其實是基於被改動後的 DOM 結構的。帶來的結果有兩個:
- 下次訪問時,程式必須按上次使用者高亮的順序還原。
- 使用者不能隨意取消(刪除)高亮區域,只能按新增順序從後往前刪。
否則,就會有部分的高亮選區在還原時無法定位到正確的元素。
文字可能不好理解,下面我舉個例子來直觀解釋下這個問題。
<p>
非常高興今天能夠在這裡和大家分享一下文字高亮的實現方式。
</p>
複製程式碼
對於上面這段 HTML,使用者分別按順序高亮了兩個部分:“高興”和“文字高亮”。那麼按照上面的實現方式,這段 HTML 變成了下面這樣:
<p>
非常
<span class="highlight">高興</span>
今天能夠在這裡和大家分享一下
<span class="highlight">文字高亮</span>
的實現方式。
</p>
複製程式碼
對應的兩個序列化資料分別為:
// “高興”兩個字被高亮時獲取的序列化資訊
{
start: {
tagName: 'p',
index: 0,
childIndex: 0,
offset: 2
},
end: {
tagName: 'p',
index: 0,
childIndex: 0,
offset: 4
}
}
複製程式碼
// “文字高亮”四個字被高亮時獲取的序列化資訊。
// 這時候由於p下面已經存在了一個高亮資訊(即“高興”)。
// 所以其內部 HTML 結構已被修改,直觀來說就是 childNodes 改變了。
// 進而,childIndex屬性由於前一個 span 元素的加入,變為了 2。
{
start: {
tagName: 'p',
index: 0,
childIndex: 2,
offset: 14
},
end: {
tagName: 'p',
index: 0,
childIndex: 2,
offset: 18
}
}
複製程式碼
可以看到,“文字高亮”這四個字的首尾節點的 childIndex
都被記為 2,這是由於前一個高亮區域改變了<p>
元素下的DOM結構。如果此時“高興”選區的高亮被使用者取消,那麼下次再訪問頁面就無法還原高亮了 —— “高興”選區的高亮被取消了,<p>
下自然就不會出現第三個 childNode,那麼 childIndex 為 2 就找不到對應的節點了。這就導致儲存的資料在還原高亮選區時出現問題。
此外,還記得在第三部分末尾提到的高亮選取重合問題麼?支援選取重合很容易出現如下的包裹元素巢狀情況:
<p>
非常
<span class="highlight">高興</span>
今天能夠在這裡和大家分享一下
<span class="highlight">
文字
<span class="highlight">高涼</span>
</span>
的實現方式。
</p>
複製程式碼
這也使得某個文字區域經過多次高亮、取消高亮後,會出現與原 HTML 頁面不同的複雜巢狀結構。可以預見,當我們使用 xpath 或 CSS selector 作為 DOM 標識時,上面提到的問題也會出現,同時也使其他需求的實現更加複雜。
到這裡可以提一下其他開源庫或產品是如何處理選區重合問題的:
- 開源庫 Rangy 有一個 Highlighter 模組可以實現文字高亮,但其對於選區重合的情況是將兩個選區直接合並了,這是不合符我們業務需求的。
- 付費產品 Diigo 直接不允許選區的重合。
- Medium.com 是支援選區重合的,體驗非常不錯,這也是我們產品的目標。但它頁面的內容區結構相較我面對的情況會更簡單與更可控。
所以如何解決這些問題呢?
2)另一種序列化 / 反序列化方式
我會對第四部分提到的序列化方式進行改進。仍然記錄文字節點的父節點 tagName 與 index,但不再記錄文字節點在 childNodes 中的 index 與 offset,而是記錄開始(結束)位置在整個父元素節點中的文字偏移量。
例如下面這段 HTML:
<p>
非常
<span class="highlight">高興</span>
今天能夠在這裡和大家分享一下
<span class="highlight">文字高亮</span>
的實現方式。
</p>
複製程式碼
對於“文字高亮”這個高亮選區,之前用於標識文字起始位置的資訊為childIndex = 2, offset = 14
。而現在變為offset = 18
(從<p>
元素下第一個文字“非”開始計算,經過18個字元後是“文”)。可以看出,這樣表示的優點是,不管<p>
內部原有的文字節點被<span>
(包裹)節點如何分割,都不會影響高亮選區還原時的節點定位。
據此,在序列化時,我們需要一個方法來將文字節點內偏移量“翻譯”為其對應的父節點內部的總體文字偏移量:
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;
}
複製程式碼
而還原高亮選區時,需要一個對應的逆過程:
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)支援高亮選區的重合
重合的高亮選區帶來的一個問題就是高亮包裹元素的巢狀,從而使得 DOM 結構會有較複雜的變動,增加了其他功能(互動)實現與問題排查的複雜度。因此,我在 3.2. 節提到的包裹高亮元素時,會再進行一些稍複雜的處理(尤其是重合選區),以保證儘量複用已有的包裹元素,避免元素的巢狀。
在處理時,將需要包裹的各個文字片段(Text Node)分為三類情況:
- 完全未被包裹,則直接包裹該部分。
- 屬於被包裹過的文字節點的一部分,則使用
.splitText()
將其拆分。 - 是一段完全被包裹的文字段,不需要對節點進行處理。
於此同時,為每個選區生成唯一 ID,將該段文字幾點多對應的 ID、以及其由於選區重合所涉及到的其他 ID,都附加包裹元素上。因此像上面的第三種情況,不需要變更 DOM 結構,只用更新包裹元素兩類 ID 所對應的 dataset 屬性即可。
6. 其他問題
解決以上的一些問題後,“文字劃詞高亮”就基本可用了。還剩下一些“小修補”,簡單提一下。
6.1. 高亮選區的互動事件,例如 click、hover
首先,可以為每個高亮選區生成一個唯一 ID,然後在該選區內所有的包裹元素上記錄該 ID 資訊,例如用data-highlight-id
屬性。而對於選取重合的部分可以在data-highlight-extra-id
屬性中記錄重合的其他選區的 ID。
而監聽到包裹元素的 click、hover 後,則觸發 highlighter 的相應事件,並帶上高亮 ID。
6.2. 取消高亮(高亮背景的刪除)
由於在包裹時支援選區重合(對應會有上面提到的三種情況需要處理),因此,在刪除選取高亮時,也會有三種情況需要分別處理:
- 直接刪除包裹元素。即不存在選區重合。
- 更新
data-highlight-id
屬性和data-highlight-extra-id
屬性。即刪除的高亮 ID 與data-highlight-id
相同。 - 只更新
data-highlight-extra-id
屬性。即刪除的高亮 ID 只在data-highlight-extra-id
中。
6.3. 對於前端生成的動態頁面怎麼辦?
不難發現,這種非耦合的文字高亮功能很依賴於頁面的 DOM 結構,需要保證做高亮時的 DOM 結構和還原時的一致,否則無法正確還原出選區的起始節點位置。據此,對“劃詞”高亮最友好的應該是純後端渲染的頁面,在onload
監聽中觸發高亮選區還原的方法即可。但目前越來越多的頁面(或頁面的一部分)是前端動態生成的,針對這個問題該怎麼處理呢?
我在實際工作中也遇到了類似問題 —— 頁面的很多區域是 ajax 請求後前端渲染的。我的處理方式包括如下:
- 隔離變化範圍。將上述程式碼中的“根節點”從
documentElement
換為另一個更具體的容器元素。例如我面對的業務會在 id 為article-container
的<div>
內載入動態內容,那麼我就會指定這個article-container
為“根節點”。這樣可以最大程度防止外部的 DOM 變動影響到高亮位置的定位,尤其是頁面改版。 - 確定高亮選區的還原時機。由於內容可能是動態生成,所以需要等到該部分的 DOM 渲染完成後再呼叫還原方法。如果有暴露的監聽事件可以在監聽內處理;或者通過 MutationObserver 監聽標誌性元素來判斷該部分是否載入完成。
- 記錄業務內容資訊,應對內容區改版。內容區的 DOM 結構更改算是“毀滅性打擊”。如何確實有該類情況,可以嘗試讓業務內容展示方將段落資訊等具體的內容資訊繫結在 DOM 元素上,而我在高亮時取出這些資訊來冗餘儲存,改版後可以通過這些內容資訊“刷”一遍儲存的資料。
6.4. 其他
篇幅問題,還有其他細節的問題就不在這篇文章裡分享了。詳細內容可以參考 web-highlighter 這個倉庫裡的實現。
7. 總結
本文先從“劃詞高亮”功能的兩個核心問題(如何高亮使用者選區的文字、如何將高亮選區還原)切入,基於 Selection API、深度優先遍歷和 DOM 節點標識的序列化這些手段實現了“劃詞高亮”的核心功能。然而,該方案仍然存在一些實際問題,因此在第 5 部分進一步給出了相應的解決方案。
基於實際開發的經驗,我發現解決上述幾個“劃詞高亮”核心問題的程式碼具有一定通用性,因此把核心部分的原始碼封裝成了獨立的庫 web-highlighter,託管在 github,也可以通過 npm 安裝。
其已服務於線上產品業務,基本的高亮功能一行程式碼即可開啟:
(new Highlighter()).run();
複製程式碼
相容IE 10/11、Edge、Firefox 52+、Chrome 15+、Safari 5.1+、Opera 15+。
感興趣的小夥伴可以 star 一下。感謝支援,歡迎交流 ?