web文本划線的極簡實現


開篇

文本划線是目前逐漸流行的一個功能,不管你是小說閱讀網站,還是賣教程的的網站,一般都會有記筆記或者評論的功能,傳統的做法都是在文章底部加一個評論區,優點是簡單,統一,缺點是不方便對文章的某一段或一句話進行針對性的評論,所以出現了划線及評論的需求,目前我見到的產品有划線功能的有:微信閱讀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屬性返回包含startContainerendContainer的公共父節點;

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

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

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

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

所以目標是要遍歷startContainerendContainer兩個節點之間的所有節點來收集文本節點,受限於筆者匱乏的算法和數據結構知識,只能選擇一個投機取巧的方法,遍歷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
    }
}

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

反序列化顯示

顯示就是根據上面存儲的數據把線畫上,遍歷上面的數據,先根據tagNameindex獲取到指定元素,然后遍歷該元素下的所有文本節點,根據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,然后點擊后把該划線元素刪除即可。

// 顯示取消划線的tooltip
showCancelTip (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.length
if (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.如何用JS實現“划詞高亮”的在線筆記功能?

2.「划線高亮」和「插入筆記」—— 不止是前端知識點


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM