<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

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

          共 13123字,需瀏覽 27分鐘

           ·

          2021-08-15 23:55

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


          開篇

          文本劃線是目前逐漸流行的一個(gè)功能,不管你是小說閱讀網(wǎng)站,還是賣教程的的網(wǎng)站,一般都會(huì)有記筆記或者評(píng)論的功能,傳統(tǒng)的做法都是在文章底部加一個(gè)評(píng)論區(qū),優(yōu)點(diǎn)是簡單,統(tǒng)一。
          缺點(diǎn)是不方便對(duì)文章的某一段或一句話進(jìn)行針對(duì)性的評(píng)論,所以出現(xiàn)了劃線及評(píng)論的需求,目前我見到的產(chǎn)品有劃線功能的有:微信閱讀APP、極客時(shí)間:

          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) / 2 this.tipTop = top - 40 this.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.range  this.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 = false  this.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 () {    // 生成本次的唯一id    let id = ++this.idx    // 遍歷文本節(jié)點(diǎn)    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        }        // 替換該文本節(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 = null    let 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_' + id        textNode.setAttribute('data-id', id)        textNode.textContent = text        fragment.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 = 0    let parNode = node    // 遍歷直到外層第一個(gè)非劃線元素    while (parNode && parNode.classList.contains('markLine')) {        // 獲取前面的兄弟元素的總字符數(shù)        offset += this.getPrevSiblingOffset(parNode)        parNode = parNode.parentNode    }    return offset}

          獲取前面的兄弟元素的總字符數(shù):

          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}

          獲取外層第一個(gè)非劃線元素在上面獲取字符數(shù)的方法里其實(shí)已經(jīng)有了:

          getWrapNode (node, root) {    // 找到外層第一個(gè)非劃線元素    let wrapNode = node.parentNode    while (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 = 0        let 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è)字符,所以總長度也就是1                    let endOffset = startOffset + 1                    this.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.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)        // 選擇第一個(gè)和最后一個(gè)文本節(jié)點(diǎn)來作為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)    }}

          點(diǎn)擊了取消按鈕后遍歷該id的所有劃線節(jié)點(diǎn),進(jìn)行元素替換:

          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]        // 如果還有子節(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_' + id textNode.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.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之前的文本不需要?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 = 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    })}

          現(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。


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

          回復(fù)“加群”與大佬們一起交流學(xué)習(xí)~

          點(diǎn)擊“閱讀原文”查看 120+ 篇原創(chuàng)文章

          瀏覽 51
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  真人一级黄色片 | 亚洲精品中文无码视频 | 91艹逼视频 | 中文无码视频在线 | 亚洲色图综合 |