【W(wǎng)eb技術(shù)】1048- 手把手教你實(shí)現(xiàn)web文本劃線的功能

來源 | https://www.cnblogs.com/wanglinmantan/p/15106871.html
開篇

InfoQ寫作平臺(tái):

等等,這個(gè)功能看似簡單,實(shí)際上難點(diǎn)還是很多的,比如如何高性能的對(duì)各種復(fù)雜的文本結(jié)構(gòu)劃線、如何盡可能少的存儲(chǔ)數(shù)據(jù)、如何精準(zhǔn)的回顯劃線、如何處理重復(fù)劃線、如何應(yīng)對(duì)文本后續(xù)編輯的情況等等。
作為一個(gè)前端搬磚工,每當(dāng)看到一個(gè)有意思的小功能時(shí)我都想自己去把它做出來,但是看了僅有的幾篇相關(guān)文章之后,發(fā)現(xiàn),不會(huì)??,這些文章介紹的都只是一個(gè)大概思路,看完讓人感覺好像會(huì)了,但是細(xì)想就會(huì)發(fā)現(xiàn)很多問題,只能去看源碼,看源碼總是費(fèi)時(shí)的,還不一定能看懂。
想要實(shí)現(xiàn)一個(gè)生產(chǎn)可用的難度還是很大的,所以本文退而求其次,單純的寫一個(gè)demo開心開心。
demo效果請(qǐng)點(diǎn)擊:http://lxqnsys.com/#/demo/textUnderline。
總體思路
總體思路很簡單,遍歷選區(qū)內(nèi)的所有文本,切割成單個(gè)字符,給每個(gè)字符都包裹上劃線元素,重復(fù)劃線的話就在最深層繼續(xù)包裹,事件處理的話從最深的元素開始。
存儲(chǔ)的方式是記錄該劃線文本外層第一個(gè)非劃線元素的標(biāo)簽名和索引,以及字符在其內(nèi)所有字符里總的偏移量。
回顯的方式是獲取到上述存儲(chǔ)數(shù)據(jù)對(duì)應(yīng)的元素,然后遍歷該元素的字符添加劃線元素。
實(shí)現(xiàn)
HTML結(jié)構(gòu)
<div class="article" ref="article"></div>
文本內(nèi)容就放在上述的div里,我從掘金小冊(cè)里隨便挑選了一篇文章,把它的html結(jié)構(gòu)原封不動(dòng)的復(fù)制粘貼進(jìn)去:

顯示tooltip
首先要做的是在選區(qū)上顯示一個(gè)劃線按鈕,這個(gè)很簡單,我們監(jiān)聽一下mouseup事件,然后獲取一下選區(qū)對(duì)象,調(diào)用它的getBoundingClientRect方法獲取位置信息,然后設(shè)置到我們的tooltip元素上:
document.addEventListener('mouseup', this.onMouseup)onMouseup () {// 獲取Selection對(duì)象,里面可能包含多個(gè)`ranges`(區(qū)域)let selObj = window.getSelection()// 一般就只有一個(gè)Range對(duì)象let range = selObj.getRangeAt(0)// 如果選區(qū)起始位置和結(jié)束位置相同,那代表沒有選到任何東西if (range.collapsed) {return}this.range = range.cloneRange()this.tipText = '劃線'this.setTip(range)}setTip (range) {let { left, top, width } = range.getBoundingClientRect()this.tipLeft = left + (width - 80) / 2this.tipTop = top - 40this.showTip = true}

劃線
給tooltip綁定一下點(diǎn)擊事件,點(diǎn)擊后需要獲取到選區(qū)內(nèi)的所有文本節(jié)點(diǎn),先看一下Range對(duì)象的結(jié)構(gòu):

簡單介紹一下:
collapsed屬性表示開始和結(jié)束的位置是否相同;
commonAncestorContainer屬性返回包含startContainer和endContainer的公共父節(jié)點(diǎn);
endContainer屬性返回包含range終點(diǎn)的節(jié)點(diǎn),通常是文本節(jié)點(diǎn);
endOffset返回range終點(diǎn)在endContainer內(nèi)的位置的數(shù)字;
startContainer屬性返回包含range起點(diǎn)的節(jié)點(diǎn),通常是文本節(jié)點(diǎn);
startContainer返回range起點(diǎn)在startContainer內(nèi)的位置的數(shù)字;
所以目標(biāo)是要遍歷startContainer和endContainer兩個(gè)節(jié)點(diǎn)之間的所有節(jié)點(diǎn)來收集文本節(jié)點(diǎn),受限于筆者匱乏的算法和數(shù)據(jù)結(jié)構(gòu)知識(shí),只能選擇一個(gè)投機(jī)取巧的方法,遍歷commonAncestorContainer節(jié)點(diǎn)。
然后使用range對(duì)象的isPointInRange()方法來檢測(cè)當(dāng)前遍歷的節(jié)點(diǎn)是否在選區(qū)范圍內(nèi),這個(gè)方法需要注意的兩個(gè)點(diǎn)地方,一個(gè)是isPointInRange()方法目前不支持IE,二是首尾節(jié)點(diǎn)需要單獨(dú)處理,因?yàn)槭孜补?jié)點(diǎn)可能部分在選區(qū)內(nèi),這樣這個(gè)方法是返回false的。
mark ()this.textNodes = []let { commonAncestorContainer, startContainer, endContainer } = this.rangethis.walk(commonAncestorContainer, (node) => {if (node === startContainer ||node === endContainer ||this.range.isPointInRange(node, 0)) {// 起始和結(jié)束節(jié)點(diǎn),或者在范圍內(nèi)的節(jié)點(diǎn),如果是文本節(jié)點(diǎn)則收集起來if (node.nodeType === 3) {this.textNodes.push(node)}}})this.handleTextNodes()this.showTip = falsethis.tipText = ''}
walk是一個(gè)深度優(yōu)先遍歷的函數(shù):
walk (node, callback = () => {}) {callback(node)if (node && node.childNodes) {for (let i = 0; i < node.childNodes.length; i++) {this.walk(node.childNodes[i], callback)}}}
獲取到選區(qū)范圍內(nèi)的所有文本節(jié)點(diǎn)后就可以切割字符進(jìn)行元素替換:
handleTextNodes () {// 生成本次的唯一idlet id = ++this.idx// 遍歷文本節(jié)點(diǎn)this.textNodes.forEach((node) => {// 范圍的首尾元素需要判斷一下偏移量,用來截取字符let startOffset = 0let endOffset = node.nodeValue.lengthif (node === this.range.startContainer &&this.range.startOffset !== 0) {startOffset = this.range.startOffset}if (node === this.range.endContainer && this.range.endOffset !== 0) {endOffset = this.range.endOffset}// 替換該文本節(jié)點(diǎn)this.replaceTextNode(node, id, startOffset, endOffset)})// 序列化進(jìn)行存儲(chǔ),獲取剛剛生成的所有該id的劃線元素this.serialize(this.$refs.article.querySelectorAll('.mark_id_' + id))}
如果是首節(jié)點(diǎn),且startOffset不為0,那么startOffset之前的字符不需要添加劃線包裹元素,如果是尾節(jié)點(diǎn),且endOffset不為0,那么endOffset之后的字符不需要?jiǎng)澗€,中間的其他所有文本都需要進(jìn)行切割及劃線:
replaceTextNode (node, id, startOffset, endOffset) {// 創(chuàng)建一個(gè)文檔片段用來替換文本節(jié)點(diǎn)let fragment = document.createDocumentFragment()let startNode = nulllet endNode = null// 截取前一段不需要?jiǎng)澗€的文本if (startOffset !== 0) {startNode = document.createTextNode(node.nodeValue.slice(0, startOffset))}// 截取后一段不需要?jiǎng)澗€的文本if (endOffset !== 0) {endNode = document.createTextNode(node.nodeValue.slice(endOffset))}startNode && fragment.appendChild(startNode)// 切割中間的所有文本node.nodeValue.slice(startOffset, endOffset).split('').forEach((text) => {// 創(chuàng)建一個(gè)span標(biāo)簽用來作為劃線包裹元素let textNode = document.createElement('span')textNode.className = 'markLine mark_id_' + idtextNode.setAttribute('data-id', id)textNode.textContent = textfragment.appendChild(textNode)})endNode && fragment.appendChild(endNode)// 替換文本節(jié)點(diǎn)node.parentNode.replaceChild(fragment, node)}
效果如下:

此時(shí)html結(jié)構(gòu):

序列化存儲(chǔ)
一次性的劃線是沒啥用的,那還不如在文章上面蓋一個(gè)canvas元素,給用戶一個(gè)自由畫布,所以還需要進(jìn)行保存,下次打開還能重新顯示之前畫的線。
存儲(chǔ)的關(guān)鍵是要能讓下次還能定位回去,參考其他文章介紹的方法,本文選擇的是存儲(chǔ)劃線元素外層的第一個(gè)非劃線元素的標(biāo)簽名,以及在指定節(jié)點(diǎn)范圍內(nèi)的同類型元素里的索引,以及該字符在該非劃線元素里的總的字符偏移量。
描述起來可能有點(diǎn)繞,看代碼:
serialize (markNodes) {// 選擇article元素作為根元素,這樣的好處是頁面的其他結(jié)構(gòu)如果改變了不影響劃線元素的定位let root = this.$refs.article// 遍歷剛剛生成的本次劃線的所有span節(jié)點(diǎn)markNodes.forEach((markNode) => {// 計(jì)算該字符離外層第一個(gè)非劃線元素的總的文本偏移量let offset = this.getTextOffset(markNode)// 找到外層第一個(gè)非劃線元素let { tagName, index } = this.getWrapNode(markNode, root)// 保存相關(guān)數(shù)據(jù)this.serializeData.push({tagName,index,offset,id: markNode.getAttribute('data-id')})})}
計(jì)算字符離外層第一個(gè)非劃線元素的總的文本偏移量的思路是先算獲取同級(jí)下之前的兄弟元素的總字符數(shù),再依次向上遍歷父元素及其之前的兄弟節(jié)點(diǎn)的總字符數(shù),直到外層元素:

getTextOffset (node) {let offset = 0let parNode = node// 遍歷直到外層第一個(gè)非劃線元素while (parNode && parNode.classList.contains('markLine')) {// 獲取前面的兄弟元素的總字符數(shù)offset += this.getPrevSiblingOffset(parNode)parNode = parNode.parentNode}return offset}
獲取前面的兄弟元素的總字符數(shù):
getPrevSiblingOffset (node) {let offset = 0let prevNode = node.previousSiblingwhile (prevNode) {offset +=prevNode.nodeType === 3? prevNode.nodeValue.length: prevNode.textContent.lengthprevNode = prevNode.previousSibling}return offset}
獲取外層第一個(gè)非劃線元素在上面獲取字符數(shù)的方法里其實(shí)已經(jīng)有了:
getWrapNode (node, root) {// 找到外層第一個(gè)非劃線元素let wrapNode = node.parentNodewhile (wrapNode.classList.contains('markLine')) {wrapNode = wrapNode.parentNode}let wrapNodeTagName = wrapNode.tagName// 計(jì)算索引let wrapNodeIndex = -1// 使用標(biāo)簽選擇器獲取所有該標(biāo)簽元素let els = root.getElementsByTagName(wrapNodeTagName)els = [...els].filter((item) => {// 過濾掉劃線元素return !item.classList.contains('markLine');}).forEach((item, index) => {// 計(jì)算當(dāng)前元素在其中的索引if (wrapNode === item) {wrapNodeIndex = index}})return {tagName: wrapNodeTagName,index: wrapNodeIndex}}
最后存儲(chǔ)的數(shù)據(jù)示例如下:

反序列化顯示
顯示就是根據(jù)上面存儲(chǔ)的數(shù)據(jù)把線畫上,遍歷上面的數(shù)據(jù),先根據(jù)tagName和index獲取到指定元素,然后遍歷該元素下的所有文本節(jié)點(diǎn),根據(jù)offset找到需要?jiǎng)澗€的字符:
deserialization () {let root = this.$refs.article// 遍歷序列化的數(shù)據(jù)markData.forEach((item) => {// 獲取到指定元素let els = root.getElementsByTagName(item.tagName)els = [...els].filter((item) => {// 過濾掉劃線元素return !item.classList.contains('markLine');})let wrapNode = els[item.index]let len = 0let end = false// 遍歷該元素所有節(jié)點(diǎn)this.walk(wrapNode, (node) => {if (end) {return}// 如果是文本節(jié)點(diǎn)if (node.nodeType === 3) {// 如果當(dāng)前文本節(jié)點(diǎn)的字符數(shù)+之前的總數(shù)大于offset,說明要找的字符就在該文本內(nèi)if (len + node.nodeValue.length > item.offset) {// 計(jì)算在該文本里的偏移量let startOffset = item.offset - len// 因?yàn)槲覀兪乔懈畹絾蝹€(gè)字符,所以總長度也就是1let endOffset = startOffset + 1this.replaceTextNode(node, item.id, startOffset, endOffset)end = true}// 累加字符數(shù)len += node.nodeValue.length}})})}
結(jié)果如下:

刪除劃線
刪除劃線很簡單,我們監(jiān)聽一下點(diǎn)擊事件,如果目標(biāo)元素是劃線元素,那么獲取一下所有該id的劃線元素,創(chuàng)建一個(gè)range,顯示一下tooltip,然后點(diǎn)擊后把該劃線元素刪除即可。
// 顯示取消劃線的tooltipshowCancelTip (e) {let tar = e.targetif (tar.classList.contains('markLine')) {e.stopPropagation()e.preventDefault()// 獲取劃線idthis.clickId = tar.getAttribute('data-id')// 獲取該id的所有劃線元素let markNodes = document.querySelectorAll('.mark_id_' + this.clickId)// 選擇第一個(gè)和最后一個(gè)文本節(jié)點(diǎn)來作為range邊界let startContainer = markNodes[0].firstChildlet endContainer = markNodes[markNodes.length - 1].lastChildthis.range = document.createRange()this.range.setStart(startContainer, 0)this.range.setEnd(endContainer,endContainer.nodeValue.length)this.tipText = '取消劃線'this.setTip(this.range)}}
點(diǎn)擊了取消按鈕后遍歷該id的所有劃線節(jié)點(diǎn),進(jìn)行元素替換:
cancelMark () {this.showTip = falsethis.tipText = ''let markNodes = document.querySelectorAll('.mark_id_' + this.clickId)// 遍歷所有劃線街道for (let i = 0; i < markNodes.length; i++) {let item = markNodes[i]// 如果還有子節(jié)點(diǎn),也就是其他id的劃線元素if (item.children[0]) {let node = item.children[0].cloneNode(true)// 子節(jié)點(diǎn)替換當(dāng)前節(jié)點(diǎn)item.parentNode.replaceChild(node, item)} else {// 否則只有文本的話直接創(chuàng)建一個(gè)文本節(jié)點(diǎn)來替換let textNode = document.createTextNode(item.textContent)item.parentNode.replaceChild(textNode, item)}}// 從序列化數(shù)據(jù)里刪除該id的數(shù)據(jù)this.serializeData = this.serializeData.filter((item) => {return item.id !== this.clickId})}

缺點(diǎn)
到這里這個(gè)極簡劃線就結(jié)束了,現(xiàn)在來看一下這個(gè)極簡的方法有什么缺點(diǎn).
首先毋庸置疑的就是如果劃線字符很多,重復(fù)劃線很多次,那么會(huì)生成非常多的span標(biāo)簽及嵌套層次,節(jié)點(diǎn)數(shù)量是影響頁面性能的一個(gè)大問題。
第二個(gè)問題是需要存儲(chǔ)的數(shù)據(jù)也會(huì)很大,增加存儲(chǔ)成本和網(wǎng)絡(luò)傳輸時(shí)間:

這可以通過把字段名字壓縮一下,改成一個(gè)字母,另外可以把連續(xù)的字符合并一下來稍微優(yōu)化一下,但是然并卵。
第三個(gè)問題是如其名,文本劃線,真的是只能給文本進(jìn)行劃線,其他的圖片上面的就不行了:

第四個(gè)問題是無法應(yīng)對(duì)如果劃線后文章被修改了,html結(jié)構(gòu)變化了的問題。
這幾個(gè)問題個(gè)個(gè)扎心,導(dǎo)致它只能是個(gè)demo。
稍微優(yōu)化一下
很容易想到的一個(gè)優(yōu)化方法是不要把字符單個(gè)切割,整塊包裹不就好了嗎,道理是這個(gè)道理:
replaceTextNode (node, id, startOffset, endOffset) {// ...startNode && fragment.appendChild(startNode)// 改成直接包裹整塊文本let textNode = document.createElement('span')textNode.className = 'markLine mark_id_' + idtextNode.setAttribute('data-id', id)textNode.textContent = node.nodeValue.slice(startOffset, endOffset)fragment.appendChild(textNode)endNode && fragment.appendChild(endNode)// ...}
這樣序列化時(shí)需要增加一個(gè)長度的字段:
let textLength = markNode.textContent.lengthif (textLength > 0) {// 過濾掉長度為0的空字符,否則會(huì)有不可預(yù)知的問題this.serializeData.push({tagName,index,offset,length: textLength,// ++id: markNode.getAttribute('data-id')})}
這樣序列化后的數(shù)據(jù)量會(huì)大大減少:

接下來反序列化也需要修改,字符長度不定的話就可能跨文本節(jié)點(diǎn)了:
deserialization () {let root = this.$refs.articlemarkData.forEach((item) => {let wrapNode = root.getElementsByTagName(item.tagName)[item.index]let len = 0let end = falselet first = truelet _length = item.lengththis.walk(wrapNode, (node) => {if (end) {return}if (node.nodeType === 3) {let nodeTextLength = node.nodeValue.lengthif (len + nodeTextLength > _offset) {// startOffset之前的文本不需要?jiǎng)澗€let startOffset = (first ? item.offset - len : 0)first = false// 如果該文本節(jié)點(diǎn)剩余的字符數(shù)量小于劃線文本的字符長度的話代表該文本節(jié)點(diǎn)還只是劃線文本的一部分,還需要到下一個(gè)文本節(jié)點(diǎn)里去處理let endOffset = startOffset + (nodeTextLength - startOffset >= _length ? _length : nodeTextLength - startOffset)this.replaceTextNode(node, item.id, startOffset, endOffset)// 長度需要減去之前節(jié)點(diǎn)已經(jīng)處理掉的長度_length = _length - (nodeTextLength - startOffset)// 如果剩余要處理的劃線文本的字符數(shù)量為0代表已經(jīng)處理完了,可以結(jié)束了if (_length <= 0) {end = true}}len += nodeTextLength}})})}
最后取消劃線也需要修改,因?yàn)樽庸?jié)點(diǎn)可能就不是只有單純的一個(gè)劃線節(jié)點(diǎn)或文本節(jié)點(diǎn)了,需要遍歷全部子節(jié)點(diǎn):
cancelMark () {this.showTip = falsethis.tipText = ''let markNodes = document.querySelectorAll('.mark_id_' + this.clickId)for (let i = 0; i < markNodes.length; i++) {let item = markNodes[i]let fregment = document.createDocumentFragment()for (let j = 0; j < item.childNodes.length; j++) {fregment.appendChild(item.childNodes[j].cloneNode(true))}item.parentNode.replaceChild(fregment, item)}this.serializeData = this.serializeData.filter((item) => {return item.id !== this.clickId})}
現(xiàn)在再來看一下效果:

html結(jié)構(gòu):

可以看到無論是序列化的數(shù)據(jù)還是DOM結(jié)構(gòu)都已經(jīng)簡潔了很多。
但是,如果文檔結(jié)構(gòu)很復(fù)雜或者多次重復(fù)劃線最終產(chǎn)生的節(jié)點(diǎn)和數(shù)據(jù)還是比較大的。
總結(jié)
本文介紹了一個(gè)實(shí)現(xiàn)web文本劃線功能的極簡實(shí)現(xiàn),最初的想法是通過切割成單個(gè)字符來進(jìn)行包裹,這樣的優(yōu)點(diǎn)是十分簡單,缺點(diǎn)也很明顯,產(chǎn)生的序列號(hào)數(shù)據(jù)很大、修改的DOM結(jié)構(gòu)很復(fù)雜,在文章及demo的寫作過程中經(jīng)過實(shí)踐,發(fā)現(xiàn)直接包裹整塊文字也并不會(huì)帶來太多問題,但是卻能減少和優(yōu)化很多要存儲(chǔ)的數(shù)據(jù)和DOM結(jié)構(gòu),所以很多時(shí)候,想當(dāng)然是不對(duì)的,最后想說,數(shù)據(jù)結(jié)構(gòu)和算法真的很重要??。
示例代碼在:https://github.com/wanglin2/textUnderline。

回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~
點(diǎn)擊“閱讀原文”查看 120+ 篇原創(chuàng)文章
