【Web技術】1048- 手把手教你實現web文本劃線的功能

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


開篇

文本劃線是目前逐漸流行的一個功能,不管你是小說閱讀網站,還是賣教程的的網站,一般都會有記筆記或者評論的功能,傳統的做法都是在文章底部加一個評論區,優點是簡單,統一。
缺點是不方便對文章的某一段或一句話進行鍼對性的評論,所以出現了劃線及評論的需求,目前我見到的產品有劃線功能的有:微信閱讀APP、極客時間:

InfoQ寫作平臺:

等等,這個功能看似簡單,實際上難點還是很多的,比如如何高性能的對各種複雜的文本結構劃線、如何儘可能少的存儲數據、如何精準的回顯劃線、如何處理重複劃線、如何應對文本後續編輯的情況等等。

作爲一個前端搬磚工,每當看到一個有意思的小功能時我都想自己去把它做出來,但是看了僅有的幾篇相關文章之後,發現,不會😓,這些文章介紹的都只是一個大概思路,看完讓人感覺好像會了,但是細想就會發現很多問題,只能去看源碼,看源碼總是費時的,還不一定能看懂。

想要實現一個生產可用的難度還是很大的,所以本文退而求其次,單純的寫一個demo開心開心。

demo效果請點擊:http://lxqnsys.com/#/demo/textUnderline。

總體思路

總體思路很簡單,遍歷選區內的所有文本,切割成單個字符,給每個字符都包裹上劃線元素,重複劃線的話就在最深層繼續包裹,事件處理的話從最深的元素開始。

存儲的方式是記錄該劃線文本外層第一個非劃線元素的標籤名和索引,以及字符在其內所有字符裏總的偏移量。

回顯的方式是獲取到上述存儲數據對應的元素,然後遍歷該元素的字符添加劃線元素。

實現

HTML結構

  
  
  
<div class="article" ref="article"></div>

文本內容就放在上述的div裏,我從掘金小冊裏隨便挑選了一篇文章,把它的html結構原封不動的複製粘貼進去:

顯示tooltip

首先要做的是在選區上顯示一個劃線按鈕,這個很簡單,我們監聽一下mouseup事件,然後獲取一下選區對象,調用它的getBoundingClientRect方法獲取位置信息,然後設置到我們的tooltip元素上:

  
  
  
document.addEventListener('mouseup', this.onMouseup)
onMouseup () { // 獲取Selection對象,裏面可能包含多個`ranges`(區域) let selObj = window.getSelection() // 一般就只有一個Range對象 let range = selObj.getRangeAt(0) // 如果選區起始位置和結束位置相同,那代表沒有選到任何東西 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) / 2 this.tipTop = top - 40 this.showTip = true}

劃線

給tooltip綁定一下點擊事件,點擊後需要獲取到選區內的所有文本節點,先看一下Range對象的結構:

簡單介紹一下:

collapsed屬性表示開始和結束的位置是否相同;

commonAncestorContainer屬性返回包含startContainer和endContainer的公共父節點;

endContainer屬性返回包含range終點的節點,通常是文本節點;

endOffset返回range終點在endContainer內的位置的數字;

startContainer屬性返回包含range起點的節點,通常是文本節點;

startContainer返回range起點在startContainer內的位置的數字;

所以目標是要遍歷startContainer和endContainer兩個節點之間的所有節點來收集文本節點,受限於筆者匱乏的算法和數據結構知識,只能選擇一個投機取巧的方法,遍歷commonAncestorContainer節點。

然後使用range對象的isPointInRange()方法來檢測當前遍歷的節點是否在選區範圍內,這個方法需要注意的兩個點地方,一個是isPointInRange()方法目前不支持IE,二是首尾節點需要單獨處理,因爲首尾節點可能部分在選區內,這樣這個方法是返回false的。

  
  
  
mark ()   this.textNodes = []  let { commonAncestorContainer, startContainer, endContainer } = this.range  this.walk(commonAncestorContainer, (node) => {    if (      node === startContainer ||      node === endContainer ||      this.range.isPointInRange(node, 0)    ) {// 起始和結束節點,或者在範圍內的節點,如果是文本節點則收集起來      if (node.nodeType === 3) {        this.textNodes.push(node)      }    }  })  this.handleTextNodes()  this.showTip = false  this.tipText = ''}

walk是一個深度優先遍歷的函數:

  
  
  
walk (node, callback = () => {}) {    callback(node)    if (node && node.childNodes) {        for (let i = 0; i < node.childNodes.length; i++) {            this.walk(node.childNodes[i], callback)        }    }}

獲取到選區範圍內的所有文本節點後就可以切割字符進行元素替換:

  
  
  
handleTextNodes () {    // 生成本次的唯一id    let id = ++this.idx    // 遍歷文本節點    this.textNodes.forEach((node) => {        // 範圍的首尾元素需要判斷一下偏移量,用來截取字符        let startOffset = 0        let endOffset = node.nodeValue.length        if (            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        }        // 替換該文本節點        this.replaceTextNode(node, id, startOffset, endOffset)    })    // 序列化進行存儲,獲取剛剛生成的所有該id的劃線元素    this.serialize(this.$refs.article.querySelectorAll('.mark_id_' + id))}

如果是首節點,且startOffset不爲0,那麼startOffset之前的字符不需要添加劃線包裹元素,如果是尾節點,且endOffset不爲0,那麼endOffset之後的字符不需要劃線,中間的其他所有文本都需要進行切割及劃線:

  
  
  
replaceTextNode (node, id, startOffset, endOffset) {    // 創建一個文檔片段用來替換文本節點    let fragment = document.createDocumentFragment()    let startNode = null    let endNode = null    // 截取前一段不需要劃線的文本    if (startOffset !== 0) {        startNode = document.createTextNode(            node.nodeValue.slice(0, startOffset)        )    }    // 截取後一段不需要劃線的文本    if (endOffset !== 0) {        endNode = document.createTextNode(node.nodeValue.slice(endOffset))    }    startNode && fragment.appendChild(startNode)    // 切割中間的所有文本    node.nodeValue        .slice(startOffset, endOffset)        .split('')        .forEach((text) => {        // 創建一個span標籤用來作爲劃線包裹元素        let textNode = document.createElement('span')        textNode.className = 'markLine mark_id_' + id        textNode.setAttribute('data-id', id)        textNode.textContent = text        fragment.appendChild(textNode)    })    endNode && fragment.appendChild(endNode)    // 替換文本節點    node.parentNode.replaceChild(fragment, node)}

效果如下:

此時html結構:

序列化存儲

一次性的劃線是沒啥用的,那還不如在文章上面蓋一個canvas元素,給用戶一個自由畫布,所以還需要進行保存,下次打開還能重新顯示之前畫的線。

存儲的關鍵是要能讓下次還能定位回去,參考其他文章介紹的方法,本文選擇的是存儲劃線元素外層的第一個非劃線元素的標籤名,以及在指定節點範圍內的同類型元素裏的索引,以及該字符在該非劃線元素裏的總的字符偏移量。

描述起來可能有點繞,看代碼:

  
  
  
serialize (markNodes) {    // 選擇article元素作爲根元素,這樣的好處是頁面的其他結構如果改變了不影響劃線元素的定位    let root = this.$refs.article    // 遍歷剛剛生成的本次劃線的所有span節點    markNodes.forEach((markNode) => {        // 計算該字符離外層第一個非劃線元素的總的文本偏移量        let offset = this.getTextOffset(markNode)        // 找到外層第一個非劃線元素        let { tagName, index } = this.getWrapNode(markNode, root)        // 保存相關數據        this.serializeData.push({          tagName,          index,          offset,          id: markNode.getAttribute('data-id')        })    })}

計算字符離外層第一個非劃線元素的總的文本偏移量的思路是先算獲取同級下之前的兄弟元素的總字符數,再依次向上遍歷父元素及其之前的兄弟節點的總字符數,直到外層元素:

  
  
  
getTextOffset (node) {    let offset = 0    let parNode = node    // 遍歷直到外層第一個非劃線元素    while (parNode && parNode.classList.contains('markLine')) {        // 獲取前面的兄弟元素的總字符數        offset += this.getPrevSiblingOffset(parNode)        parNode = parNode.parentNode    }    return offset}

獲取前面的兄弟元素的總字符數:

  
  
  
getPrevSiblingOffset (node) {    let offset = 0    let prevNode = node.previousSibling    while (prevNode) {        offset +=            prevNode.nodeType === 3            ? prevNode.nodeValue.length        : prevNode.textContent.length        prevNode = prevNode.previousSibling    }    return offset}

獲取外層第一個非劃線元素在上面獲取字符數的方法裏其實已經有了:

  
  
  
getWrapNode (node, root) {    // 找到外層第一個非劃線元素    let wrapNode = node.parentNode    while (wrapNode.classList.contains('markLine')) {        wrapNode = wrapNode.parentNode    }    let wrapNodeTagName = wrapNode.tagName    // 計算索引    let wrapNodeIndex = -1    // 使用標籤選擇器獲取所有該標籤元素    let els = root.getElementsByTagName(wrapNodeTagName)    els = [...els].filter((item) => {// 過濾掉劃線元素      return !item.classList.contains('markLine');    }).forEach((item, index) => {// 計算當前元素在其中的索引      if (wrapNode === item) {        wrapNodeIndex = index      }    })    return {        tagName: wrapNodeTagName,        index: wrapNodeIndex    }}

最後存儲的數據示例如下:

反序列化顯示

顯示就是根據上面存儲的數據把線畫上,遍歷上面的數據,先根據tagName和index獲取到指定元素,然後遍歷該元素下的所有文本節點,根據offset找到需要劃線的字符:

  
  
  
deserialization () {    let root = this.$refs.article    // 遍歷序列化的數據    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 = 0        let end = false        // 遍歷該元素所有節點        this.walk(wrapNode, (node) => {            if (end) {                return            }            // 如果是文本節點            if (node.nodeType === 3) {                // 如果當前文本節點的字符數+之前的總數大於offset,說明要找的字符就在該文本內                if (len + node.nodeValue.length > item.offset) {                    // 計算在該文本里的偏移量                    let startOffset = item.offset - len                    // 因爲我們是切割到單個字符,所以總長度也就是1                    let endOffset = startOffset + 1                    this.replaceTextNode(node, item.id, startOffset, endOffset)                    end = true                }                // 累加字符數                len += node.nodeValue.length            }        })    })}

結果如下:

刪除劃線

刪除劃線很簡單,我們監聽一下點擊事件,如果目標元素是劃線元素,那麼獲取一下所有該id的劃線元素,創建一個range,顯示一下tooltip,然後點擊後把該劃線元素刪除即可。

  
  
  
// 顯示取消劃線的tooltipshowCancelTip (e) {    let tar = e.target    if (tar.classList.contains('markLine')) {        e.stopPropagation()        e.preventDefault()        // 獲取劃線id        this.clickId = tar.getAttribute('data-id')        // 獲取該id的所有劃線元素        let markNodes = document.querySelectorAll('.mark_id_' + this.clickId)        // 選擇第一個和最後一個文本節點來作爲range邊界        let startContainer = markNodes[0].firstChild        let endContainer = markNodes[markNodes.length - 1].lastChild        this.range = document.createRange()        this.range.setStart(startContainer, 0)        this.range.setEnd(          endContainer,          endContainer.nodeValue.length        )        this.tipText = '取消劃線'        this.setTip(this.range)    }}

點擊了取消按鈕後遍歷該id的所有劃線節點,進行元素替換:

  
  
  
cancelMark () {    this.showTip = false    this.tipText = ''    let markNodes = document.querySelectorAll('.mark_id_' + this.clickId)    // 遍歷所有劃線街道    for (let i = 0; i < markNodes.length; i++) {        let item = markNodes[i]        // 如果還有子節點,也就是其他id的劃線元素        if (item.children[0]) {            let node = item.children[0].cloneNode(true)            // 子節點替換當前節點            item.parentNode.replaceChild(node, item)        } else {// 否則只有文本的話直接創建一個文本節點來替換            let textNode = document.createTextNode(item.textContent)            item.parentNode.replaceChild(textNode, item)        }    }    // 從序列化數據裏刪除該id的數據    this.serializeData = this.serializeData.filter((item) => {        return item.id !== this.clickId    })}

缺點

到這裏這個極簡劃線就結束了,現在來看一下這個極簡的方法有什麼缺點.

首先毋庸置疑的就是如果劃線字符很多,重複劃線很多次,那麼會生成非常多的span標籤及嵌套層次,節點數量是影響頁面性能的一個大問題。

第二個問題是需要存儲的數據也會很大,增加存儲成本和網絡傳輸時間:

這可以通過把字段名字壓縮一下,改成一個字母,另外可以把連續的字符合並一下來稍微優化一下,但是然並卵。

第三個問題是如其名,文本劃線,真的是隻能給文本進行劃線,其他的圖片上面的就不行了:

第四個問題是無法應對如果劃線後文章被修改了,html結構變化了的問題。

這幾個問題個個扎心,導致它只能是個demo。

稍微優化一下

很容易想到的一個優化方法是不要把字符單個切割,整塊包裹不就好了嗎,道理是這個道理:

  
  
  
replaceTextNode (node, id, startOffset, endOffset) {    // ...    startNode && fragment.appendChild(startNode)
// 改成直接包裹整塊文本 let textNode = document.createElement('span') textNode.className = 'markLine mark_id_' + id textNode.setAttribute('data-id', id) textNode.textContent = node.nodeValue.slice(startOffset, endOffset) fragment.appendChild(textNode)
endNode && fragment.appendChild(endNode) // ...}

這樣序列化時需要增加一個長度的字段:

  
  
  
let textLength = markNode.textContent.lengthif (textLength > 0) {// 過濾掉長度爲0的空字符,否則會有不可預知的問題  this.serializeData.push({      tagName,      index,      offset,      length: textLength,// ++      id: markNode.getAttribute('data-id')  })}

這樣序列化後的數據量會大大減少:

接下來反序列化也需要修改,字符長度不定的話就可能跨文本節點了:

  
  
  
deserialization () {    let root = this.$refs.article    markData.forEach((item) => {        let wrapNode = root.getElementsByTagName(item.tagName)[item.index]        let len = 0        let end = false        let first = true        let _length = item.length        this.walk(wrapNode, (node) => {            if (end) {                return            }            if (node.nodeType === 3) {                let nodeTextLength = node.nodeValue.length                if (len + nodeTextLength > _offset) {                    // startOffset之前的文本不需要劃線                    let startOffset = (first ? item.offset - len : 0)                    first = false                    // 如果該文本節點剩餘的字符數量小於劃線文本的字符長度的話代表該文本節點還只是劃線文本的一部分,還需要到下一個文本節點裏去處理                    let endOffset = startOffset + (nodeTextLength - startOffset >= _length ? _length : nodeTextLength - startOffset)                    this.replaceTextNode(node, item.id, startOffset, endOffset)                    // 長度需要減去之前節點已經處理掉的長度                    _length = _length - (nodeTextLength - startOffset)                    // 如果剩餘要處理的劃線文本的字符數量爲0代表已經處理完了,可以結束了                    if (_length <= 0) {                      end = true                    }                  }                len += nodeTextLength            }        })    })}

最後取消劃線也需要修改,因爲子節點可能就不是隻有單純的一個劃線節點或文本節點了,需要遍歷全部子節點:

  
  
  
cancelMark () {    this.showTip = false    this.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    })}

現在再來看一下效果:

html結構:

可以看到無論是序列化的數據還是DOM結構都已經簡潔了很多。

但是,如果文檔結構很複雜或者多次重複劃線最終產生的節點和數據還是比較大的。

總結

本文介紹了一個實現web文本劃線功能的極簡實現,最初的想法是通過切割成單個字符來進行包裹,這樣的優點是十分簡單,缺點也很明顯,產生的序列號數據很大、修改的DOM結構很複雜,在文章及demo的寫作過程中經過實踐,發現直接包裹整塊文字也並不會帶來太多問題,但是卻能減少和優化很多要存儲的數據和DOM結構,所以很多時候,想當然是不對的,最後想說,數據結構和算法真的很重要😭。

示例代碼在:https://github.com/wanglin2/textUnderline。


1. JavaScript 重溫系列(22篇全)
2. ECMAScript 重溫系列(10篇全)
3. JavaScript設計模式 重溫系列(9篇全)
4.  正則 / 框架 / 算法等 重溫系列(16篇全)
5.  Webpack4 入門(上) ||  Webpack4 入門(下)
6.  MobX 入門(上)  ||   MobX 入門(下)
7. 120 +篇原創系列彙總

回覆“加羣”與大佬們一起交流學習~

點擊“閱讀原文”查看 120+ 篇原創文章

本文分享自微信公衆號 - 前端自習課(FE-study)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章