「劃線高亮」和「插入筆記」—— 不止是前端知識(shí)點(diǎn)

如今前端領(lǐng)域:serverless,low code,全?;雀拍畋椴悸臁i_發(fā)者們熱衷于討論「如何把前端格局做大」,「如何將高高在上的概念落地」。此時(shí),你有沒有感受到「還不知道發(fā)展方向到底是什么,就已經(jīng)被未來拋棄了」。
我想,與其去琢磨「serverless 到底是什么,跟前端有什么關(guān)系」,不如先讓我們回到需求的起點(diǎn),從前端開發(fā)的護(hù)城河特點(diǎn)說起。不忘初心,牢記使命,前端開發(fā)說到底是內(nèi)容渲染和交互實(shí)現(xiàn)。今天這篇文章,讓我們從一個(gè)有趣的產(chǎn)品需求說起,換一個(gè)角度去思考「前端的邊界到底在哪里」。并從這個(gè)前端需求出發(fā),看看技術(shù)上又能有多深的實(shí)踐。

理解需求

需求并不算太復(fù)雜,簡(jiǎn)單來說就是在一個(gè)文稿頁上,實(shí)現(xiàn)「劃線高亮」和「插入筆記」。通過下面完成圖,我們可以總結(jié)需求點(diǎn)包括:

需求完成圖

公開筆記展示:

需求完成圖
  • 這是一個(gè)文稿頁面,主要實(shí)現(xiàn)添加劃線和添加筆記兩大塊功能
  • 用戶可以圈選文字內(nèi)容,在彈出 tooltip 中進(jìn)行「劃線添加」
  • 用戶在圈選文字時(shí),或者點(diǎn)擊已有劃線高亮區(qū)塊時(shí),喚醒 tooltip 彈出
  • 用戶圈選文字時(shí),展示相應(yīng) tooltip,提供:「復(fù)制」、「添加/刪除劃線」、「寫筆記」、「分享」等按鈕
  • 以上按鈕功能點(diǎn)容易理解,不再一一展開
  • 只有文稿內(nèi)容文字支持劃線交互,其他頁面元素不支持劃線操作
  • 劃線添加完成后,相應(yīng)的文字添加高亮背景
  • 刪除劃線會(huì)同時(shí)刪除該劃線對(duì)應(yīng)的所有筆記(如果有對(duì)應(yīng)筆記)
  • tooltip 彈出時(shí),點(diǎn)擊「寫筆記」按鈕,導(dǎo)航到筆記編輯頁面,由用戶輸入內(nèi)容并添加后,無刷新地返回文稿頁,并在相應(yīng)位置插入該條筆記,筆記內(nèi)容需要在當(dāng)前劃線的下一行插入展現(xiàn)
  • 頁面中一段內(nèi)如果有其他用戶的公開筆記,則在段后展示公開筆記 icon,icon 內(nèi)展示公開筆記數(shù)
  • 點(diǎn)擊段后公開筆記 icon,展示公開筆記內(nèi)容

更細(xì)的需求點(diǎn)和交互細(xì)節(jié)我們會(huì)在后文實(shí)現(xiàn)環(huán)節(jié)進(jìn)行進(jìn)一步說明。

分析需求

有的讀者可能會(huì)認(rèn)為:「劃線筆記這類需求我見過,應(yīng)該也不難吧」,甚至我還看過文章分析其實(shí)現(xiàn),比如:如何用JS實(shí)現(xiàn)“劃詞高亮”的在線筆記功能?。其實(shí)不然,不同于以往的「劃線高亮」和「插入筆記」需求,我們的場(chǎng)景還真有特殊,包括但不限于(請(qǐng)結(jié)合上面完成圖理解):

  • 合法劃線只能圈定為文稿內(nèi)文字。也就是說,tooltip 內(nèi)文案、用戶已添加的筆記內(nèi)容、段后 icon 計(jì)數(shù)、空行、彈窗文案等一切非原始文稿內(nèi)容均不支持勾選
  • 用戶劃線可長(zhǎng)可短,劃線范圍可能在一段內(nèi),也可能跨段落。這個(gè)區(qū)別會(huì)影響劃線區(qū)高亮的實(shí)現(xiàn)方案和持久化數(shù)據(jù)設(shè)計(jì)
  • 劃線間關(guān)系復(fù)雜,因此不同的劃線可能會(huì)出現(xiàn):不同劃線內(nèi)容交叉,不同劃線內(nèi)容全覆蓋(父子集關(guān)系),不同劃線內(nèi)容完全獨(dú)立三種關(guān)系
  • 劃線對(duì)應(yīng)的筆記插入位置需要在對(duì)應(yīng)劃線的下一行,如果一條劃線添加了多條筆記,那么多條筆記在該劃線后一行進(jìn)行順序疊加
  • 段后公開筆記計(jì)數(shù) icon 的計(jì)數(shù),需要隨著劃線和筆記的動(dòng)態(tài)添加或刪除而改變
  • tooltip 定位:tooltip 位置需要隨著用戶勾選文字的內(nèi)容變化而變化,需要始終保持在勾選區(qū)域中心,垂直方向上處于劃線第一行上方固定像素距離;如果勾選區(qū)域占滿一行,tooltip 在水平位置上需要固定出現(xiàn)在屏幕中心

所有這些細(xì)節(jié)點(diǎn)都需要在 React 技術(shù)棧上實(shí)現(xiàn),因?yàn)槲覀兊奈母鍍?nèi)容是通過 React 組件呈現(xiàn):

return (
  <div ...props>
    <Component1 />
    <Component2 />
    <RichText
      prop1={prop1}
      text={manuscript}
      prop3={prop3}
    />
    <Component3 />
  </div>
)

這將會(huì)給我們帶來極大的挑戰(zhàn):設(shè)想一下,React 一股腦通過 setDangerouslyInnerHTML 渲染整頁富文本,我們?nèi)绾卧诟晃谋緝?nèi)容上添加一系列包括劃線在內(nèi)的交互?或者更細(xì)節(jié)一些,我們?nèi)绾握业接脩魟澗€的下一行進(jìn)行筆記內(nèi)容添加?這么看,React 也許是個(gè)枷鎖,阻礙了我們施展手腳。當(dāng)然辦法總是有的,我們繼續(xù)分析并實(shí)現(xiàn)。

核心問題

需求牽扯到很多細(xì)節(jié)點(diǎn),但這篇文章的目的并不想面面俱到,逐一實(shí)現(xiàn)。讓我們先把精力聚焦在「劃線高亮」和「添加筆記」上。思考核心問題主要有三大方向:

  • 添加劃線文本的高亮樣式
  • 劃線后插入筆記
  • 劃線高亮和筆記的持久化還原

需要說明的是:我們文稿頁面的文稿內(nèi)容來自后臺(tái)編輯器以及第三方內(nèi)容導(dǎo)入。

文稿編輯器

因此我們看到,后臺(tái)編輯器具備了所有富文本編輯器的常規(guī)內(nèi)容,并含有多項(xiàng)自定義能力,比如:公式添加、代碼塊添加、引用樣式添加、圖片/視頻添加、書簽添加,以及自動(dòng)格式化(標(biāo)點(diǎn)擠壓、三巨頭轉(zhuǎn)換、繁體簡(jiǎn)體字轉(zhuǎn)換)等。因此,文稿內(nèi)容可謂千變?nèi)f化,理論上講,文稿富文本內(nèi)容里,任何復(fù)雜 DOM 結(jié)構(gòu)都可能出現(xiàn)。

同時(shí),劃線高亮和筆記必須支持后續(xù)訪問時(shí)還原,一次性的一錘子買賣是沒有任何意義的。

因此就拿劃線高亮實(shí)現(xiàn)邏輯來說,這個(gè)實(shí)現(xiàn)將會(huì)在兩個(gè)場(chǎng)景中出現(xiàn):

  • 第一個(gè)場(chǎng)景是用戶進(jìn)入頁面,渲染頁面時(shí),將之前保存的劃線和筆記還原展示;
  • 第二個(gè)場(chǎng)景是當(dāng)前頁面生命周期中,用戶又動(dòng)態(tài)添加了劃線和筆記。

這兩個(gè)場(chǎng)景都要添加高亮背景等,從代碼上來講,這就需要進(jìn)行合理的邏輯抽象和復(fù)用。

此外,在需求實(shí)現(xiàn)過程中,我們發(fā)現(xiàn)一個(gè)核心問題和風(fēng)險(xiǎn)點(diǎn)是事件兼容性處理以及事件類型的沖突和干擾解決。這些內(nèi)容后文都將提到,請(qǐng)繼續(xù)閱讀。

業(yè)界情況和社區(qū)方案

開發(fā)前,我們從產(chǎn)品形態(tài)上調(diào)研了三款業(yè)界實(shí)現(xiàn),它們分別是:

  • 網(wǎng)易蝸牛讀書
  • 豆瓣閱讀
  • Medium

其中,網(wǎng)易蝸牛讀書是最接近我們需求的,但是類似需求只出現(xiàn)在網(wǎng)易蝸牛讀書 App 內(nèi),在 H5 端(wap 端)根本不允許用戶勾選文字內(nèi)容。

豆瓣閱讀只有 PC 端實(shí)現(xiàn)了劃線需求,移動(dòng)端沒有實(shí)現(xiàn)劃線高亮,且沒有實(shí)現(xiàn)「劃線行后添加筆記」的功能。它用一個(gè)單獨(dú)的頁面來展示筆記,比較取巧,但是大大降低了開發(fā)難度。這類需求在 PC 端實(shí)現(xiàn)的成本遠(yuǎn)比在移動(dòng)端實(shí)現(xiàn)要低很多。值得一提的是,豆瓣閱讀的劃線高亮的樣式實(shí)現(xiàn)方案是采用了一層絕對(duì)定位的 mask,如下圖:

豆瓣閱讀劃線高亮

但我們放棄了絕對(duì)定位的高亮 mask 方案。我們的需求要在劃線上實(shí)現(xiàn)大量移動(dòng)端交互,同時(shí)要實(shí)現(xiàn)劃線行后插入筆記(筆記內(nèi)容不可能采用 absolute 絕對(duì)定位,因?yàn)闊o法將文字行內(nèi)容撐開),再考慮到不同手機(jī)屏幕寬度不同,遮罩 mask 位置都要?jiǎng)討B(tài)計(jì)算,當(dāng)劃線高亮量級(jí)比較大的時(shí)候,算是一個(gè)不能忽略的計(jì)算成本。同時(shí)可以預(yù)見:這種方案后期擴(kuò)展性以及靈活性都不強(qiáng)。因此,這種「加一個(gè)遮罩 mask 的高亮樣式方案」并不太適用我們的場(chǎng)景。

最后看一下 Medium,喜歡看國(guó)外技術(shù)文章的同學(xué)們應(yīng)該對(duì)這個(gè)網(wǎng)站并不陌生。Medium 的小清新風(fēng)格體驗(yàn)很不錯(cuò),但是在劃線筆記功能上,實(shí)現(xiàn)的較為簡(jiǎn)單。它同樣只有劃線高亮,沒有筆記(或者說筆記是單獨(dú)的頁面呈現(xiàn))。在技術(shù)實(shí)現(xiàn)上,如圖:

Medium 劃線高亮

Medium 采用了拆補(bǔ)標(biāo)簽的方案,它使用 mark 標(biāo)簽將劃線文字包裹,通過對(duì) mask 標(biāo)簽的樣式設(shè)置,達(dá)到高亮效果。 但請(qǐng)注意,該標(biāo)簽中沒有標(biāo)識(shí)劃線的 id,也就是說它無法區(qū)別不同的劃線,進(jìn)一步推測(cè):因?yàn)闊o法對(duì)每條劃線識(shí)別,當(dāng)不同劃線有重疊內(nèi)容時(shí),Medium 會(huì)合并劃線,將不同劃線合并成為了一條新劃線,用戶在刪除或者其他操作劃線的交互時(shí),就操作了合并產(chǎn)生的新劃線。這樣的實(shí)現(xiàn)當(dāng)然最簡(jiǎn)單,但是我們的產(chǎn)品無法滿意。

因此行業(yè)內(nèi)的產(chǎn)品跟我們的需求相比,都比較基礎(chǔ),它們:

  • 完全不支持劃線交叉/重合
  • 只有 App 內(nèi)實(shí)現(xiàn),或者只有 PC 實(shí)現(xiàn),移動(dòng)端 H5 頁面沒有實(shí)現(xiàn)

相關(guān)開源庫(kù)

再來看看社區(qū)相關(guān)開源庫(kù):

  • Rangy,可以實(shí)現(xiàn)文本高亮,但其對(duì)于劃線選區(qū)重合的情況是將兩個(gè)選區(qū)直接合并了,當(dāng)然,這是不合符我們業(yè)務(wù)需求的
  • Diigo,不僅需要付費(fèi),而且能力極弱,它也是直接不允許劃線選區(qū)的重合
  • Web-highlighter 定制能力比較弱

每一種方案讀者都可以找到相應(yīng)的開源庫(kù),這里不再一一剖析??偨Y(jié)下來,社區(qū)的輪子更像是一個(gè)玩具,即便支持劃線區(qū)域重合,但更多只是樣式上實(shí)現(xiàn)了高亮,是一個(gè)演示級(jí)別的 demo,如果想對(duì)接我們后續(xù)的交互操作,比如行后插入筆記,點(diǎn)擊劃線高亮喚醒 tooltip 等,將會(huì)是更大挑戰(zhàn)。

結(jié)合我們特殊的需求,同時(shí)考慮靈活性和自主性,我們決定擼起袖子,自己干。

開發(fā)思路

需求的復(fù)雜度決定了我們的實(shí)現(xiàn)方案和社區(qū)、業(yè)界方案都有所不同,或者說是已有方案的升級(jí)和改造版。整體實(shí)現(xiàn)思路除了體現(xiàn)前端傳統(tǒng)知識(shí)點(diǎn)外,更加突出了算法甚至編譯原理的應(yīng)用。

劃線高亮樣式實(shí)現(xiàn)

首先聚焦劃線高亮樣式的做法。簡(jiǎn)單來說,我們通過拆解標(biāo)簽來實(shí)現(xiàn)。如圖:

劃線高亮樣式實(shí)現(xiàn)

第一行表示已有文稿片段內(nèi)容,橙色字體 3456 表示用戶劃線區(qū)域。我們的預(yù)期結(jié)果是將 3456 用新標(biāo)簽 span 包裹,span 標(biāo)簽含有該劃線的 id 等其他信息。

對(duì)于多個(gè)劃線交叉的情況,我們看下面示意圖,已經(jīng)有劃線內(nèi)容 3456,當(dāng)用戶新勾選劃線 5678 時(shí),即 56 分別屬于兩段劃線的交叉區(qū)域。理想地,我們應(yīng)該得到新的標(biāo)簽結(jié)構(gòu):

image.png

這樣拆解標(biāo)簽的設(shè)計(jì)比上文分析的豆瓣閱讀采用絕對(duì)定位的遮罩更加靈活:豆瓣閱讀的方案無疑只是樣式的展現(xiàn),如果考慮上事件交互,那么拆解標(biāo)簽的穩(wěn)定和強(qiáng)大顯而易見。比如,當(dāng)用戶點(diǎn)擊 56 文字時(shí),tooltip 出現(xiàn),如果點(diǎn)擊「刪除劃線」按鈕,按照需求,我們應(yīng)該刪除最新的一條劃線(即 5678),而不是 3456,這樣通過拆解標(biāo)簽生成不同的標(biāo)簽細(xì)節(jié),可以很好地結(jié)合前端事件處理。

我們通過模仿天然的事件冒泡:根據(jù) event.target 向上遍歷 DOM 元素,能發(fā)現(xiàn) 56 除了歸屬 id 為 2 的劃線之外,也屬于 id 為 1 的劃線,通過比對(duì)劃線的創(chuàng)建時(shí)間,找到需要操作(刪除、分享、添加筆記、復(fù)制內(nèi)容等操作)的最終劃線即可。

此邏輯抽象為函數(shù),簡(jiǎn)單表達(dá)為:

// 點(diǎn)擊區(qū)域中,可能包含多條劃線,我們需要找到最近創(chuàng)建的一條劃線

getLatestHighlight (e) {
    // 選擇文本,檢測(cè)重復(fù)時(shí),設(shè)置了 targetHightlightId
    if (this.state.targetHightlightId && !e) {
      return this.state.targetHightlightId
    }
    
    let target
    
    // triggerTooltipEvent 用于對(duì)事件觸發(fā)兼容性和特殊情況的磨平,讀者可暫不考慮
    if (triggerTooltipEvent && !e) {
      target = triggerTooltipEvent.target
    } else if (e) {
      target = e.target
    } else {
      // 無法獲取 target 對(duì)象時(shí)(理論上不可能),安全退出
      return
    }
    
    const propagationHighlightMap = {}
    const paragraphNode = getFirstBlockAncestor(target)
    
    let latestHighlight = target.getAttribute('createdtime')
    
    // triggerTooltipEvent.target 向上冒泡,將所有點(diǎn)擊事件經(jīng)過的劃線(可能有重疊和交叉)信息推入到 propagationHighlightMap 中
    const walk = node => {
      while (node !== paragraphNode) {
        if (node.getAttribute('commentid')) {
          const currentHighlightCreatedtime = node.getAttribute('createdtime')
    
          if (Number(currentHighlightCreatedtime) > Number(latestHighlight)) {
            latestHighlight = currentHighlightCreatedtime
          }
    
          propagationHighlightMap[currentHighlightCreatedtime] = node.getAttribute('commentid')
        }
        node = node.parentNode
      }
    }
    
    walk(target)
    
    const latestHighlightId = propagationHighlightMap[latestHighlight]
    
    return latestHighlightId
}

根據(jù)劃線后富文本標(biāo)簽結(jié)果,我們直接調(diào)用 React setDangerouslyInnerHTML API 即可得到劃線頁面。

“紙上得來終覺淺,絕知此事要躬行”。方案實(shí)現(xiàn)的過程當(dāng)中,你會(huì)發(fā)現(xiàn)“說起來容易,做起來難”。比如,一直提到的“拆解標(biāo)簽”,那具體怎么拆,怎么解呢?
有兩個(gè)拆解標(biāo)簽方案擺在我們面前,我把它歸類為:

  • Dom based
  • String based

第一種基于 DOM,第二種基于字符串。在具體展開之前,還是讓我們先來熟悉兩個(gè)基本 BOM/DOM APIs。

Window.getSelection

Window.getSelection() 可以返回一系列關(guān)于用戶選區(qū)的信息,如下使用方式:

const range = window.getSelection().getRangeAt(0)

const start = {
  node: range.startContainer,
  offset: range.startOffset
}

const end = {
  node: range.endContainer,
  offset: range.endOffset
}

但是請(qǐng)注意,我們無法通過它直接獲取選區(qū)中的所有 DOM 元素,它只能返回選區(qū)的首尾節(jié)點(diǎn)信息,包括劃線起始于哪個(gè) node,起始文本相對(duì)于該 node 偏移量是多少;劃線結(jié)束于哪個(gè) node,結(jié)束文本相對(duì)于該 node 偏移量是多少。我們準(zhǔn)確找到了首尾節(jié)點(diǎn),下一步就是找出“中間”所有的文本節(jié)點(diǎn)。這就需要遍歷 DOM 樹。

樹形 DOM Node 典型如下圖,出自這里

ul li DOM 關(guān)系

由于 DOM 不是線性結(jié)構(gòu)而是樹形結(jié)構(gòu),所以“找出中間所有的文本節(jié)點(diǎn)”,這個(gè)“中間”換成程序語言,就是指深度優(yōu)先遍歷。我們來看代碼:

<p>12 
    <span data-id=1> 34 
        <span data-id=2> 56 </span> 
    </span> 
    <span data-id=2> 78 </span> 
    90
</p>

這段文字中,包含了兩段劃線高亮內(nèi)容。對(duì)應(yīng)圖:

DFS 簡(jiǎn)單示意

在已有劃線對(duì)應(yīng)的 DOM 的情況下,用戶又勾選了 67,那么我們希望能得到:

<p>12 
    <span data-id=1> 34 
        <span data-id=2> 
         5
         <span data-id=3> 6 </span>
        </span> 
    </span> 
    <span data-id=2> 
        <span data-id=3> 7 </span>
        8 
    </span> 
    90
</p>

因?yàn)槲覀兡塬@得的 startNode(data-id 為 3 的 span)的下一個(gè)兄弟節(jié)點(diǎn)為 null,為了沿途遍歷包裹標(biāo)簽并找到 endNode,我們需要先“回溯”,再深度向下。典型的 DFS,用循環(huán)和遞歸均可實(shí)現(xiàn),這里不再贅述,僅給出一個(gè)簡(jiǎn)單示意:

遞歸版:

const DFSTraverse = (rootNodes, rootLayer) => {
  const roots = Array.from(rootNodes)
  while (roots.length) {
    const root = roots.shift()
    printInfo(root, rootLayer)
    if (root.children.length) {
      DFSTraverse(root.children, rootLayer + 1)
    }
  }
}

堆棧偽代碼:

stack my_stack;
list visited_nodes;
my_stack.push(starting_node);

while my_stack.length > 0
   current_node = my_stack.pop();

   if current_node == null
       continue;
   if current_node in visited_nodes
      continue;
   visited_nodes.add(current_node);

   // visit node, get the class or whatever you need

   foreach child in current_node.children
      my_stack.push(child);

Text.splitText()

由于用戶勾選區(qū)域只包含一個(gè)文本節(jié)點(diǎn)的一部分,所以我們拆解標(biāo)簽時(shí),也是在開始和結(jié)束節(jié)點(diǎn)的一部分起止。對(duì)此,大部分讀者可能會(huì)想到 Text.splitText() 拆分文本節(jié)點(diǎn)。通過 Text.splitText(),對(duì)于開始節(jié)點(diǎn),收集它的后半部分;而對(duì)于結(jié)束節(jié)點(diǎn),則是收集前半部分。

如圖,

Text.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)
  }
}

DOM based 方案

有了以上基礎(chǔ),我們可以輕松實(shí)現(xiàn) DOM based 方案來拆解標(biāo)簽,實(shí)現(xiàn)劃線高亮的渲染。

我們可以按照如何用JS實(shí)現(xiàn)“劃詞高亮”的在線筆記功能?一文提供的方案,先計(jì)算出劃線標(biāo)簽起止的偏移量:

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;
}

還原高亮選區(qū)時(shí),需要一個(gè)對(duì)應(yīng)的逆過程:

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};
}

理想很豐滿,但是我最終放棄了 DOM based 方案。原因如下:

  • DOM based 方案依賴 DOM Node,如果你的內(nèi)容不渲染到瀏覽器上(或者借助某些宿主 API)的話,這些方法都無法直接實(shí)施
  • 如果渲染到瀏覽器上的話,渲染的每一步動(dòng)作都要依賴上一步渲染到瀏覽器之后的更新 DOM,反復(fù)讀寫 DOM,意味著反復(fù) repaint 甚至 reflow。每一個(gè)劃線的渲染也同樣頻繁操作 DOM,我們的程序是基于 React 的,反復(fù) setState 觸發(fā) setDangerouslyInnerHTML 內(nèi)容的更新,體驗(yàn)令人崩潰
  • 依賴 DOM,也就意味著有很多奇怪的問題,涉及到兼容性,也就涉及到“不可預(yù)知的神秘力量”

基于 DOM 的拆解標(biāo)簽方案還有個(gè)最大的劣勢(shì)在于:我們完全依賴 DOM 樹,不管是初于穩(wěn)定性還是靈活性,一個(gè)基于字符串的拆解標(biāo)簽方案似乎更加合適。

劃線高亮的持久化和還原

按照文章順序,我們應(yīng)該介紹基于字符串的拆解標(biāo)簽 string based 方案了??墒菫榱烁忧宄仄饰鲈摲桨冈恚?qǐng)?jiān)试S我先把這種方案擱置,讓我們先了解一下劃線高亮的持久化和還原做法。

持久化劃線高亮選區(qū)的核心是找到一種合適的 DOM 節(jié)點(diǎn)序列化方法,以便再次進(jìn)入頁面時(shí)候能夠定位 DOM 節(jié)點(diǎn),渲染出來劃線和高亮內(nèi)容。

一般方案有以下四種:

  • xPath:記錄劃線 DOM 的 xPath
  • Css selector:記錄劃線 DOM 的標(biāo)簽選擇器順序
  • Dom tag node offset + text offset:記錄劃線 DOM 的標(biāo)簽偏移量以及劃線文字在此 DOM 內(nèi)的的文字偏移量
  • Paragraph offset + text offset:記錄劃線 DOM 所屬段落的段落偏移量以及劃線文字相對(duì)于該段落的文字偏移量

我們的第一反應(yīng)就是記錄相關(guān) DOM 的偏移,即「是哪些相關(guān) DOM 上發(fā)生了劃線操作」,遂將此 DOM 的相對(duì)或絕對(duì)偏移量記錄下來,這是前三種方案的思路。事實(shí)上,最初我也選擇了使用第三種方式來快速實(shí)現(xiàn),但是存在一些“致命問題”。第三種記錄 DOM 標(biāo)簽的偏移量,也就是記錄相對(duì)于所有富文本內(nèi)容的第 K 個(gè)標(biāo)簽發(fā)生了劃線操作,以及這個(gè)標(biāo)簽內(nèi)的文字相對(duì)于該 DOM 文本偏移量(從這個(gè)標(biāo)簽的第 K' 個(gè)字開始劃線或者終止劃線)。

還是如何用JS實(shí)現(xiàn)“劃詞高亮”的在線筆記功能?一文的例子,我們來看一下這種持久化方案的問題。

如下內(nèi)容:

<p>非常高興今天能夠在這里和大家分享一下文本高亮(在線筆記)的實(shí)現(xiàn)方式。</p>
image.png

用戶先劃線了「高興」兩個(gè)字:

<p>
    非常
    <span class="highlight">高興</span>
    今天能夠在這里和大家分享一下文本高亮(在線筆記)的實(shí)現(xiàn)方式。
</p>
image.png

我們來生成相關(guān)劃線數(shù)據(jù):

// “高興”兩個(gè)字被高亮?xí)r獲取的序列化信息
{
    start: {
        tagName: 'p',
        index: 0,
        childIndex: 0,
        offset: 2
    },
    end: {
        tagName: 'p',
        index: 0,
        childIndex: 0,
        offset: 4
    }
}

這并不難理解,「高興」兩個(gè)字出現(xiàn)在第一個(gè) P 標(biāo)簽,該 P 標(biāo)簽內(nèi)只有一個(gè)文本節(jié)點(diǎn),因此 childIndex 為 0,在這個(gè)文本節(jié)點(diǎn)的第二個(gè)字開始進(jìn)行了劃線,到第四個(gè)字終止。

這時(shí)候,用戶又劃線了「文本高亮」四個(gè)字:

<p>
    非常
    <span class="highlight">高興</span>
    今天能夠在這里和大家分享一下
    <span class="highlight">文本高亮</span>
    (在線筆記)的實(shí)現(xiàn)方式。
</p>
image.png

此時(shí)持久化數(shù)據(jù)的計(jì)算是基于前一刻的 DOM 快照生成的,即「文本高亮」這四個(gè)字的劃線是相對(duì)前一刻的 DOM 結(jié)構(gòu)進(jìn)行計(jì)算:首尾節(jié)點(diǎn)的 childIndex 都被記為 2(此時(shí) P 標(biāo)簽有三個(gè) children),「文本高亮」這四個(gè)字的偏移量是相對(duì)于「今天能夠在這里和大家分享一下文本高亮(在線筆記)的實(shí)現(xiàn)方式」計(jì)算的。

得到新的數(shù)據(jù)結(jié)構(gòu):

// “文本高亮”四個(gè)字被高亮?xí)r獲取的序列化信息。
// 這時(shí)候由于 p 下面已經(jīng)存在了一個(gè)高亮信息(即“高興”)。
// 所以其內(nèi)部 HTML 結(jié)構(gòu)已被修改,直觀來說就是 childNodes 改變了。
// 進(jìn)而,childIndex屬性由于前一個(gè) span 元素的加入,變?yōu)榱?2。
{
    start: {
        tagName: 'p',
        index: 0,
        childIndex: 2,
        offset: 14
    },
    end: {
        tagName: 'p',
        index: 0,
        childIndex: 2,
        offset: 18
    }
}

請(qǐng)?jiān)O(shè)想,如果用戶又刪除了「高興」選區(qū)的劃線,那么所有出現(xiàn)在「高興」選區(qū)劃線之后的劃線數(shù)據(jù)將會(huì)出現(xiàn)錯(cuò)誤。本質(zhì)上,伴隨著劃線添加,我們動(dòng)態(tài)改變了 DOM 結(jié)構(gòu),使得持久化數(shù)據(jù)發(fā)生錯(cuò)亂,這便是問題所在。需求中還會(huì)隨時(shí)動(dòng)態(tài)添加筆記段落,無疑會(huì)讓問題更加復(fù)雜嚴(yán)重。

因此,合理的劃線高亮的持久化和還原方案應(yīng)該記錄文稿文本相對(duì)于文字的偏移量,而不是 DOM 標(biāo)簽的編譯量。

我們來看一下最終方案:

對(duì)應(yīng)的數(shù)據(jù)為:

image.png

notes 字段表示筆記信息,這里暫時(shí)不涉及加入的筆記需求,我們可以先忽略。

字段解釋:paragraph_startparagraph_end 表示當(dāng)前劃線的起始和結(jié)尾段落,如果對(duì)應(yīng)數(shù)值不相等,說明該條劃線是跨段落的劃線。mark_startmark_end 分別表示對(duì)應(yīng) paragraph_startparagraph_end 段落中,開始和結(jié)束劃線文字相對(duì)于該段純文稿文本的偏移量。

String based 方案

了解了持久化劃線和高亮方案,我們趁熱打鐵,看看 string based 方案是如何結(jié)合持久化數(shù)據(jù)實(shí)現(xiàn)劃線高亮渲染(拆解標(biāo)簽)的。

首先需要注意的是:我們不能粗暴地在開始和結(jié)尾劃線直接加入 span 開始和閉合標(biāo)簽,因?yàn)檫@種過于理想的情況會(huì)導(dǎo)致標(biāo)簽混亂不匹配。

比如:

<p>今天<span>我非常高興</span>給大家介紹</p>

已有高亮內(nèi)容:我非常高興。這時(shí)候,用戶又劃線“非常高興給大家”這 7 個(gè)文字時(shí),如果直接添加標(biāo)簽,會(huì)得到:

<p>
    今天
    <span>我
    <span>非常高興</span>給大家</span>
    介紹
</p>

明顯 span 層級(jí)錯(cuò)亂,包裹標(biāo)簽失效。

示意圖

我們?cè)谧鰟澗€高亮?xí)r,得到的基本信息富文本就是字符串,比如這樣的內(nèi)容:

<p>123456<span>789012345</span>678901234567</p>

假設(shè)實(shí)際需要高亮的內(nèi)容如下,劃線高亮起始于第一個(gè) 9,終止于第二個(gè) 9 處:

image.png

也就是說我想要得到上面的效果。

結(jié)合劃線數(shù)據(jù):mark_start,mark_end,我們先要找到劃線開始的那個(gè)字符。方法是:設(shè)置指針,開始逐個(gè)掃描字符串:

image.png

掃描直到指針偏移到 mark_start 處,則表示找到了劃線起始。我們插入 span 字符串,最終遍歷劃線得到全部修飾過的富文本字符串,一次性交給 React setDangerouslyInnerHTML 渲染即可。

但是這里需要注意:因?yàn)?mark_start,mark_end 是相對(duì)于文本的偏移量,因此在掃描標(biāo)簽時(shí),如果遇到了 DOM tag,進(jìn)入了 DOM 標(biāo)簽內(nèi)的文字,那么我們需要停止計(jì)數(shù),并繼續(xù)移動(dòng)指針,直到移動(dòng)出當(dāng)前 Dom tag,才可以恢復(fù)計(jì)數(shù)。也就是說在上圖中,掃描到 <span> 直到移出的 6 個(gè)位移中,我們不進(jìn)行計(jì)數(shù)。

如果你想問,那我們記錄相對(duì)于富文本內(nèi)容的偏移量不就不用這么麻煩了嗎?恭喜你,如果這么想,那我們就又回到了上文提到的動(dòng)態(tài)改變 DOM 標(biāo)簽即富文本內(nèi)容的問題了。

實(shí)際上,DOM tag 的開標(biāo)標(biāo)簽字符 < 以及結(jié)束標(biāo)簽 > 都會(huì)被轉(zhuǎn)義。但是這里我想延伸,即便不被轉(zhuǎn)義,真正的問題是:我們?nèi)绾闻袛嘀羔樢苿?dòng)過程中遇到了 DOM tag,進(jìn)而需要停止計(jì)數(shù)?因?yàn)槲母鍍?nèi)容可能就有一個(gè) <,我們?cè)趺粗肋@是文稿的真實(shí)內(nèi)容而不是進(jìn)入 DOM tag 的標(biāo)記呢?(實(shí)際上 < 會(huì)被轉(zhuǎn)義,這里問了簡(jiǎn)化問題,先不考慮轉(zhuǎn)義的情況)。

其實(shí)上述過程,已經(jīng)是一個(gè)現(xiàn)代編譯器的雛形了,我們可以看看編譯器是如何處理這種問題:當(dāng)掃描到 < 時(shí),我們?cè)O(shè)置第二根指針,這個(gè)第二根指針繼續(xù)向下嗅探,進(jìn)行掃描,如果一路匹配出 <span 我們就可以斷言第一根指針遇見的當(dāng)前 < 是一個(gè) DOM tag 開始標(biāo)簽。

事實(shí)上,熟悉 Vue 源碼的同學(xué)可能會(huì)想到 Vue compiler 模塊:在 Vue 實(shí)現(xiàn)模版引擎,并進(jìn)行模版變量雙向綁定時(shí),也處理了同樣的問題?!?yàn)檫@是一個(gè)經(jīng)典的編譯器基本原理。

再比如 Babel 進(jìn)行編譯代碼時(shí),例如 optional chaining 這個(gè)編譯插件,也是通過掃描源碼字符串,發(fā)現(xiàn)一個(gè) ?,則通過新的嗅探指針進(jìn)行向下掃描,如果發(fā)現(xiàn) ? 后面跟著一個(gè) .,即 foo?.bar 這種表達(dá)形式,那么可判斷這是一個(gè) optional chaining 用法,我們可以進(jìn)行相應(yīng)的 ES5 編譯;如果嗅探指針發(fā)現(xiàn) ?后出現(xiàn)了 :,那么就應(yīng)該把它當(dāng)作三木運(yùn)算符理解。真實(shí)情況更加復(fù)雜,且有所出入,這里只是對(duì)原理進(jìn)行說明,不再展開。

這個(gè)嗅探指針的實(shí)現(xiàn)過程,有一個(gè)專業(yè)術(shù)語也許大家聽說過,叫做 tokenizer,即分詞。它往往會(huì)結(jié)合 AST(抽象語法樹)出現(xiàn),在前端工程化等領(lǐng)域中出現(xiàn)。

劃線筆記的標(biāo)簽拆解,就是一個(gè)樸素的編譯器原理,涉及到 tokenizer 等一系列過程。有了這樣的能力,一切就會(huì)變得“不那么復(fù)雜”。當(dāng)然,在具體實(shí)現(xiàn)劃線高亮業(yè)務(wù)中,我根據(jù)需求特點(diǎn),創(chuàng)建了很多 fastpath,簡(jiǎn)化了編譯分詞過程,這里讀者只需要理解底層思想即可。

行后插入筆記

聊完劃線高亮,我們?cè)倏匆幌聞澗€行后插入筆記效果的實(shí)現(xiàn)。之前提到,我們的頁面所有文稿內(nèi)容都是 React 一股腦渲染富文本得來的,劃線完畢后,如何找到適當(dāng)?shù)奈恢茫▌澗€后下一行)插入筆記呢?

方案非常巧妙:我們借助 document.createRange() 和 Text.splitNode() 兩個(gè) APIs,在劃線后的第一個(gè)字符后,創(chuàng)建一個(gè) range,長(zhǎng)度為 1,換句話說,提取劃線截止的最后一個(gè)字后的每一個(gè)字拆并計(jì)算這個(gè)字距離屏幕最左側(cè)的長(zhǎng)度,進(jìn)行記錄。按照常理,劃線后的每一個(gè)字距離屏幕左側(cè)的長(zhǎng)度應(yīng)該依次遞增,直到換行后的第一個(gè)字符。這樣我們就找到了劃線后一行的起始。

如下圖所示:

image.png

接下來就是在找到換行的位置插入筆記節(jié)點(diǎn)的邏輯了。說起來簡(jiǎn)單,實(shí)施起來除了遞歸算法的運(yùn)用之外,還用進(jìn)行多種 cases 的容錯(cuò)處理,實(shí)現(xiàn)代碼也有幾百行了。

代碼:

const walkToRenderCommentBlock = (range, lastRightOffset, currentAnnotationId, lastAnnotationByIdNode) => {
  const currentRightOffset = range.getBoundingClientRect().right

  if (lastRightOffset > currentRightOffset) {
    // 找到了插入點(diǎn)
    doRenderCommentBlock(range, currentAnnotationId, lastAnnotationByIdNode)
  } else {
    // 繼續(xù)向下一個(gè)字符尋找
    if (range.endOffset < range.endContainer.textContent.length - 1) {
      // 如果當(dāng)前 range 還沒找到頭,那就繼續(xù)下一個(gè)
      const currentRange = document.createRange()
      currentRange.setStart(range.endContainer, range.endOffset)

      // 如果結(jié)束節(jié)點(diǎn)類型是 Text, Comment, or CDATASection 之一, 那么 endOffset 指的是從結(jié)束節(jié)點(diǎn)算起字符的偏移量
      // 對(duì)于其他 Node 類型節(jié)點(diǎn), endOffset 是指從結(jié)束結(jié)點(diǎn)開始算起子節(jié)點(diǎn)的偏移量。

      try {
        // 存在 range.endOffset + 1 不存在的情況 (比如空標(biāo)簽),這時(shí)候就用下一個(gè)節(jié)點(diǎn)
        currentRange.setEnd(range.endContainer, range.endOffset + 1)
      } catch (e) {
        currentRange.setStart(getNextSiblingNode(range.endContainer), 0)
        currentRange.setEnd(getNextSiblingNode(range.endContainer), 1)
      }

      return walkToRenderCommentBlock(currentRange, currentRightOffset, currentAnnotationId, lastAnnotationByIdNode)
    } else {
      // 如果當(dāng)前 range 到頭了,還沒有找到,則找下一個(gè) nodeText
      const nextNode = getNextTextNode(range.endContainer)
      if (nextNode) {
        const currentRange = document.createRange()
        currentRange.setStart(nextNode, 0)
        currentRange.setEnd(nextNode, 1)
        return walkToRenderCommentBlock(currentRange, currentRightOffset, currentAnnotationId, lastAnnotationByIdNode)
      } else {
        // 找到了當(dāng)前段的最后面,在段后加
        doRenderCommentBlock(null, currentAnnotationId, lastAnnotationByIdNode)
      }
    }
  }
}

整體流程梳理

我們來看一下全套流程的時(shí)序圖:

image.png

項(xiàng)目采用了基于 Rect 的 SSR 架構(gòu),在服務(wù)端預(yù)獲取兩類數(shù)據(jù):

  • fetchManuscript
  • fetchAnnotationsData

第一類數(shù)據(jù)是原始文稿內(nèi)容;第二類是文稿對(duì)應(yīng)的劃線筆記持久化數(shù)據(jù)。在交給瀏覽器渲染之后,React 進(jìn)行初次繪制,這一次渲染呈現(xiàn)原始文稿內(nèi)容。因?yàn)槲覀冎挥邪言嘉母鍖?shí)際渲染完成后,再結(jié)合手機(jī)屏幕寬度和位置信息,才能應(yīng)用劃線筆記的邏輯。在 componentDidMount 邏輯中,我們先進(jìn)行 disableSelection,該方法設(shè)置所有非文稿內(nèi)容不可選,之后 transformData 邏輯將加工后端數(shù)據(jù)為:

  • annotationsById
  • notesById

劃線高亮邏輯核心函數(shù):renderHighlight 對(duì) annotationsById 進(jìn)行遍歷,生成全量的已加入劃線標(biāo)簽的富文本字符串,此時(shí)再次觸發(fā)渲染。這次渲染完后,執(zhí)行 renderNotes 函數(shù)和 renderNotesIcon 函數(shù),他對(duì) notesById 進(jìn)行遍歷,生成生成全量的已加入筆記區(qū)塊的富文本字符串,并觸發(fā)渲染。

整個(gè)流程通過 Promise 串聯(lián),依次執(zhí)行。請(qǐng)注意這里不能并行執(zhí)行,因?yàn)楣P記的插入依賴渲染劃線高亮樣式后的布局。

因?yàn)槲覀兊氖录幚砗徒壎ú捎昧耸录淼姆绞?,因此?componentDidMount 之后即可和其他渲染流程并行處理。我們?cè)陂_篇就提到過,事件的沖突和干擾在此項(xiàng)目中尤為突出和棘手。由于篇幅限制,我們不再深入分析,而是拆出來幾個(gè)條目供大家參考。

touch 事件處理

可能你好奇,為什么需要對(duì) touch 事件進(jìn)行監(jiān)聽?是因?yàn)?click 在移動(dòng)端的 300 ms 延遲?

其實(shí)沒有那么簡(jiǎn)單,是因?yàn)橛脩粼诠催x文字后,觸發(fā) click 時(shí),由于系統(tǒng)原因,勾選區(qū)域?qū)?huì)置空,這時(shí)候獲取 window.getSelection() 只會(huì)得到 null,而 tooltip 上的點(diǎn)擊事件處理大都需要 window.getSelection() 的內(nèi)容。因此,我們要么對(duì)最近一次的 window.getSelection() 返回值持久化存在內(nèi)存中,要么對(duì)于 tooltip 的點(diǎn)擊事件換成對(duì) touch 事件的監(jiān)聽,而后者明顯是更合理的方案。

接下來,我們看看對(duì) touch 事件(準(zhǔn)確來說 touchend 事件)綁定了哪些交互。

復(fù)制

復(fù)制按鈕的點(diǎn)擊又有兩種場(chǎng)景:

  • 一種是在用戶勾選文字的過程中,點(diǎn)擊 tooltip 上「復(fù)制」,那么復(fù)制的內(nèi)容為勾選的合法文字;
  • 一種是點(diǎn)擊已經(jīng)存在的高亮劃線,喚醒 tooltip,再點(diǎn)擊 tooltip 上「復(fù)制」按鈕,這時(shí)候復(fù)制的內(nèi)容為劃線高亮文字。

說起來簡(jiǎn)單,做起來有點(diǎn)復(fù)雜。對(duì)于第一種情況,我們需要找到合法的勾選文字,需求要求勾選內(nèi)容如果包含筆記內(nèi)容,那么復(fù)制文案要排除筆記內(nèi)容,只復(fù)制文稿內(nèi)容;如果包含段后公開筆記計(jì)數(shù) icon,也不能復(fù)制進(jìn)去計(jì)數(shù)值。因此我們還需要進(jìn)行圈選區(qū)域的遍歷,并判斷非法標(biāo)簽(非文稿內(nèi)容標(biāo)簽)。如下如,我們勾選得到兩個(gè) text 分別問勾選 startNode 和 endNode,一個(gè)經(jīng)典的 DFS 又出來了:

image.png

對(duì)于第二種情況,點(diǎn)擊「復(fù)制」按鈕后,我們要先判斷點(diǎn)擊區(qū)域是否屬于多條劃線高亮的交叉區(qū)域,如果是,那么就要模擬向上冒泡過程,找到最近的歸屬劃線,復(fù)制相應(yīng)內(nèi)容。

  • 劃線

點(diǎn)擊「劃線」按鈕,我們就要先判斷選區(qū)是否可劃線,然后計(jì)算劃線偏移量得到劃線持久化數(shù)據(jù),進(jìn)行標(biāo)簽拆解,渲染高亮區(qū)域,接著向后端發(fā)送請(qǐng)求并更新內(nèi)存中 annotaionsById 數(shù)據(jù)。別忘了還需要更新段后計(jì)數(shù) icon 的值。

  • 刪除劃線

與「劃線」按鈕類似,同樣需要判斷是否點(diǎn)擊區(qū)域是否為多條劃線的交叉區(qū)域,并和后端通信,以及修改內(nèi)存數(shù)據(jù)和 DOM 內(nèi)容。

  • 分享

分享需要調(diào)客戶端端能力,同樣先要確定是點(diǎn)擊劃線后喚醒 tooltip 并點(diǎn)擊「分享」按鈕,還是用戶勾選文字后喚醒 tooltip 并點(diǎn)擊「分享」按鈕?;具壿嬵愃啤笍?fù)制」按鈕的點(diǎn)擊。

  • 寫筆記

這時(shí)候可能是用戶勾選了新的內(nèi)容:因此要先加高亮劃線,再去寫筆記;也可能是點(diǎn)擊已經(jīng)存在的劃線,去增加筆記。

由此可見,各種按鈕邏輯都有多種觸發(fā)場(chǎng)景, 需要我們做很多細(xì)致的判斷和處理。這只是“冰山一角”,更多的邏輯和場(chǎng)景不再一一列舉。

click, touch, selectionchange 的三國(guó)演義

touch 事件的引入,細(xì)化了我們事件處理粒度,使得需求能夠完成。但它帶來了事件的交織和沖突。結(jié)合碎片化的手機(jī)終端,矛盾沖突重重。

比如:對(duì)于點(diǎn)擊事件 click,如果點(diǎn)擊時(shí)當(dāng)前 tooltip 不存在,且點(diǎn)擊的是已有劃線高亮內(nèi)容,那么應(yīng)該喚醒 tooltip,且 tooltip 含有「刪除劃線」按鈕;如果當(dāng)前 tooltip 已經(jīng)存在,則認(rèn)為觸碰了空白區(qū)域,tooltip 就應(yīng)該消失。有極個(gè)別瀏覽器,點(diǎn)擊事件 click 會(huì)觸發(fā) selectionchange 事件,但是 selectionchange 事件的觸發(fā),會(huì)使我們認(rèn)為用戶勾選了新的內(nèi)容,引發(fā)一系列的連鎖反應(yīng)。
再說回來,當(dāng) tooltip 存在時(shí),點(diǎn)擊空白區(qū)域,tooltip 消失。但是需求要求滾動(dòng)時(shí)候 tooltip 不能消失,可是滾動(dòng)事件觸發(fā),很多瀏覽器也會(huì)觸發(fā)點(diǎn)擊事件,這時(shí)候我們認(rèn)為 tooltip 又應(yīng)該消失。類似所有這些內(nèi)容都交織一起,開發(fā)者都需要考慮到。

有認(rèn)真的讀者可能會(huì)想:「為什么不考慮監(jiān)聽 selectionchangeend 事件」,在開發(fā)過程中,我們發(fā)現(xiàn) selectionchangeend 雖然在規(guī)范中有提及,但該事件在任何手機(jī)在都不會(huì)觸發(fā)。如果使用 touchend 模擬 selectionchangeend,又發(fā)現(xiàn)有的手機(jī)在勾選結(jié)束后不觸發(fā) touchend/touchmove。

當(dāng)然,這不是本篇文章的重點(diǎn),我們點(diǎn)到為止。

安全性和性能保障

整體下來,劃線筆記項(xiàng)目的安全性尤為重要。這里的安全主要是指用戶交互的非阻塞性,文稿內(nèi)容和劃線筆記的呈現(xiàn)準(zhǔn)確性。但文稿頁面內(nèi)容千變?nèi)f化,標(biāo)簽結(jié)構(gòu)理論上能達(dá)到最復(fù)雜。如何在線上出現(xiàn)問題時(shí),不阻塞頁面,且保障其他交互的順暢進(jìn)行呢?
其實(shí)方法也很簡(jiǎn)單,主要依賴 try...catch 區(qū)塊,在 catch 中注意進(jìn)行錯(cuò)誤采集和還原,方便后續(xù)記錄并追查。同時(shí)合理的 fallback 機(jī)制也非常重要,這需要和產(chǎn)品討論制定更加完善的方案。值得一提的是,我們前端組如今正在著力打造完善的端到端測(cè)試流程,已經(jīng)接入了最基本的劃線高亮測(cè)試,未來在端到端的測(cè)試上,我們將持續(xù)深耕,屆時(shí)也會(huì)分享更多經(jīng)驗(yàn)和心得。

劃線筆記涉及到的性能話題其實(shí)較為常見,保障策略也較為常規(guī),但是性能手段每一點(diǎn)的背后都是一個(gè)極大的話題,這里我們簡(jiǎn)單總結(jié)使用到的性能優(yōu)化方法,并不再往下延伸:

  • 服務(wù)端渲染,預(yù)獲取數(shù)據(jù)
  • Dom 節(jié)點(diǎn)選擇器的優(yōu)化
  • 遞歸性能優(yōu)化(優(yōu)先使用 for 循環(huán),借助蹦床函數(shù)等尾遞歸調(diào)用優(yōu)化實(shí)施)
  • debouch 和 throttle 的合理使用
  • Dom 操作減少 repaint 和 reflow
  • 獨(dú)立合成層,GPU 渲染加速相關(guān):比如 transform,opacity 等 CSS3 屬性的使用
  • addEventListenner 第三個(gè)參數(shù) passive 的使用

總結(jié)

從「劃線高亮」并「插入筆記」這個(gè)需求,我們提煉出了一連串前端知識(shí)點(diǎn),同時(shí)分析了實(shí)施過程當(dāng)中的困難和解決方案。

這些內(nèi)容涉及到 DOM、BOM 等基本知識(shí),也涉及到編程領(lǐng)域中不可或缺的 AST、編譯原理的皮毛,并延伸出現(xiàn)代前端開發(fā)所依賴的 Babel 以及框架 Vue 的實(shí)現(xiàn)原理。

前端開發(fā)的護(hù)城河之一就是精細(xì)化交互實(shí)現(xiàn),前端開發(fā)的開疆?dāng)U土也依賴于更低層的編程普適原理,希望這篇長(zhǎng)文能對(duì)大家有所啟發(fā),也歡迎大家一起討論。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容