阅读 1087

「划线高亮」和「插入笔记」—— 不止是前端知识点

如今前端领域:serverless,low code,全栈化等概念遍布漫天。开发者们热衷于讨论「如何把前端格局做大」,「如何将高高在上的概念落地」。此时,你有没有感受到「还不知道发展方向到底是什么,就已经被未来抛弃了」。 我想,与其去琢磨「serverless 到底是什么,跟前端有什么关系」,不如先让我们回到需求的起点,从前端开发的护城河特点说起。不忘初心,牢记使命,**前端开发说到底是内容渲染和交互实现。**今天这篇文章,让我们从一个有趣的产品需求说起,换一个角度去思考「前端的边界到底在哪里」。并从这个前端需求出发,看看技术上又能有多深的实践。

理解需求

需求并不算太复杂,简单来说就是在一个文稿页上,实现「划线高亮」和「插入笔记」。通过下面完成图,我们可以总结需求点包括:

需求完成图

公开笔记展示:

需求完成图

  • 这是一个文稿页面,主要实现添加划线和添加笔记两大块功能
  • 用户可以圈选文字内容,在弹出 tooltip 中进行「划线添加」
  • 用户在圈选文字时,或者点击已有划线高亮区块时,唤醒 tooltip 弹出
  • 用户圈选文字时,展示相应 tooltip,提供:「复制」、「添加/删除划线」、「写笔记」、「分享」等按钮
  • 以上按钮功能点容易理解,不再一一展开
  • 只有文稿内容文字支持划线交互,其他页面元素不支持划线操作
  • 划线添加完成后,相应的文字添加高亮背景
  • 删除划线会同时删除该划线对应的所有笔记(如果有对应笔记)
  • tooltip 弹出时,点击「写笔记」按钮,导航到笔记编辑页面,由用户输入内容并添加后,无刷新地返回文稿页,并在相应位置插入该条笔记,笔记内容需要在当前划线的下一行插入展现
  • 页面中一段内如果有其他用户的公开笔记,则在段后展示公开笔记 icon,icon 内展示公开笔记数
  • 点击段后公开笔记 icon,展示公开笔记内容

更细的需求点和交互细节我们会在后文实现环节进行进一步说明。

分析需求

有的读者可能会认为:「划线笔记这类需求我见过,应该也不难吧」,甚至我还看过文章分析其实现,比如:如何用JS实现“划词高亮”的在线笔记功能?。其实不然,不同于以往的「划线高亮」和「插入笔记」需求,我们的场景还真有特殊,包括但不限于(请结合上面完成图理解):

  • 合法划线只能圈定为文稿内文字。也就是说,tooltip 内文案、用户已添加的笔记内容、段后 icon 计数、空行、弹窗文案等一切非原始文稿内容均不支持勾选
  • **用户划线可长可短,**划线范围可能在一段内,也可能跨段落。这个区别会影响划线区高亮的实现方案和持久化数据设计
  • **划线间关系复杂,**因此不同的划线可能会出现:不同划线内容交叉,不同划线内容全覆盖(父子集关系),不同划线内容完全独立三种关系
  • **划线对应的笔记插入位置需要在对应划线的下一行,**如果一条划线添加了多条笔记,那么多条笔记在该划线后一行进行顺序叠加
  • 段后公开笔记计数 icon 的计数,需要随着划线和笔记的动态添加或删除而改变
  • **tooltip 定位:**tooltip 位置需要随着用户勾选文字的内容变化而变化,需要始终保持在勾选区域中心,垂直方向上处于划线第一行上方固定像素距离;如果勾选区域占满一行,tooltip 在水平位置上需要固定出现在屏幕中心

**所有这些细节点都需要在 React 技术栈上实现,**因为我们的文稿内容是通过 React 组件呈现:

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

这将会给我们带来极大的挑战:设想一下,React 一股脑通过 setDangerouslyInnerHTML 渲染整页富文本,我们如何在富文本内容上添加一系列包括划线在内的交互?或者更细节一些,我们如何找到用户划线的下一行进行笔记内容添加?这么看,React 也许是个枷锁,阻碍了我们施展手脚。当然办法总是有的,我们继续分析并实现。

核心问题

需求牵扯到很多细节点,但这篇文章的目的并不想面面俱到,逐一实现。让我们先把精力聚焦在「划线高亮」和「添加笔记」上。思考核心问题主要有三大方向:

  • 添加划线文本的高亮样式
  • 划线后插入笔记
  • 划线高亮和笔记的持久化还原

需要说明的是:我们文稿页面的文稿内容来自后台编辑器以及第三方内容导入。

文稿编辑器

因此我们看到,后台编辑器具备了所有富文本编辑器的常规内容,并含有多项自定义能力,比如:公式添加、代码块添加、引用样式添加、图片/视频添加、书签添加,以及自动格式化(标点挤压、三巨头转换、繁体简体字转换)等。因此,文稿内容可谓千变万化,理论上讲,文稿富文本内容里,任何复杂 DOM 结构都可能出现。

同时,划线高亮和笔记必须支持后续访问时还原,一次性的一锤子买卖是没有任何意义的。

因此就拿划线高亮实现逻辑来说,这个实现将会在两个场景中出现:

  • 第一个场景是用户进入页面,渲染页面时,将之前保存的划线和笔记还原展示;
  • 第二个场景是当前页面生命周期中,用户又动态添加了划线和笔记。

这两个场景都要添加高亮背景等,从代码上来讲,这就需要进行合理的逻辑抽象和复用。

此外,在需求实现过程中,我们发现一个核心问题和风险点是事件兼容性处理以及事件类型的冲突和干扰解决。这些内容后文都将提到,请继续阅读。

业界情况和社区方案

开发前,我们从产品形态上调研了三款业界实现,它们分别是:

  • 网易蜗牛读书
  • 豆瓣阅读
  • Medium

其中,网易蜗牛读书是最接近我们需求的,但是类似需求只出现在网易蜗牛读书 App 内,在 H5 端(wap 端)根本不允许用户勾选文字内容。

豆瓣阅读只有 PC 端实现了划线需求,移动端没有实现划线高亮,且没有实现「划线行后添加笔记」的功能。它用一个单独的页面来展示笔记,比较取巧,但是大大降低了开发难度。这类需求在 PC 端实现的成本远比在移动端实现要低很多。值得一提的是,豆瓣阅读的划线高亮的样式实现方案是采用了一层绝对定位的 mask,如下图:

豆瓣阅读划线高亮

**但我们放弃了绝对定位的高亮 mask 方案。**我们的需求要在划线上实现大量移动端交互,同时要实现划线行后插入笔记(笔记内容不可能采用 absolute 绝对定位,因为无法将文字行内容撑开),再考虑到不同手机屏幕宽度不同,遮罩 mask 位置都要动态计算,当划线高亮量级比较大的时候,算是一个不能忽略的计算成本。同时可以预见:这种方案后期扩展性以及灵活性都不强。因此,这种「加一个遮罩 mask 的高亮样式方案」并不太适用我们的场景。

最后看一下 Medium,喜欢看国外技术文章的同学们应该对这个网站并不陌生。Medium 的小清新风格体验很不错,但是在划线笔记功能上,实现的较为简单。它同样只有划线高亮,没有笔记(或者说笔记是单独的页面呈现)。在技术实现上,如图:

Medium 划线高亮

Medium 采用了拆补标签的方案,它使用 mark 标签将划线文字包裹,通过对 mask 标签的样式设置,达到高亮效果。 **但请注意,该标签中没有标识划线的 id,也就是说它无法区别不同的划线,进一步推测:因为无法对每条划线识别,当不同划线有重叠内容时,Medium 会合并划线,将不同划线合并成为了一条新划线,用户在删除或者其他操作划线的交互时,就操作了合并产生的新划线。**这样的实现当然最简单,但是我们的产品无法满意。

因此行业内的产品跟我们的需求相比,都比较基础,它们:

  • 完全不支持划线交叉/重合
  • 只有 App 内实现,或者只有 PC 实现,移动端 H5 页面没有实现

相关开源库

再来看看社区相关开源库:

  • Rangy,可以实现文本高亮,但其对于划线选区重合的情况是将两个选区直接合并了,当然,这是不合符我们业务需求的
  • Diigo,不仅需要付费,而且能力极弱,它也是直接不允许划线选区的重合
  • Web-highlighter 定制能力比较弱

每一种方案读者都可以找到相应的开源库,这里不再一一剖析。总结下来,社区的轮子更像是一个玩具,即便支持划线区域重合,但更多只是样式上实现了高亮,是一个演示级别的 demo,如果想对接我们后续的交互操作,比如行后插入笔记,点击划线高亮唤醒 tooltip 等,将会是更大挑战。

结合我们特殊的需求,同时考虑灵活性和自主性,我们决定撸起袖子,自己干。

开发思路

需求的复杂度决定了我们的实现方案和社区、业界方案都有所不同,或者说是已有方案的升级和改造版。整体实现思路除了体现前端传统知识点外,更加突出了算法甚至编译原理的应用。

划线高亮样式实现

首先聚焦划线高亮样式的做法。简单来说,我们通过拆解标签来实现。如图:

划线高亮样式实现

第一行表示已有文稿片段内容,橙色字体 3456 表示用户划线区域。我们的预期结果是将 3456 用新标签 span 包裹,span 标签含有该划线的 id 等其他信息。

对于多个划线交叉的情况,我们看下面示意图,已经有划线内容 3456,当用户新勾选划线 5678 时,即 56 分别属于两段划线的交叉区域。理想地,我们应该得到新的标签结构:

image.png

**这样拆解标签的设计比上文分析的豆瓣阅读采用绝对定位的遮罩更加灵活:**豆瓣阅读的方案无疑只是样式的展现,如果考虑上事件交互,那么拆解标签的稳定和强大显而易见。比如,当用户点击 56 文字时,tooltip 出现,如果点击「删除划线」按钮,按照需求,我们应该删除最新的一条划线(即 5678),而不是 3456,这样通过拆解标签生成不同的标签细节,可以很好地结合前端事件处理。

**我们通过模仿天然的事件冒泡:**根据 event.target 向上遍历 DOM 元素,能发现 56 除了归属 id 为 2 的划线之外,也属于 id 为 1 的划线,通过比对划线的创建时间,找到需要操作(删除、分享、添加笔记、复制内容等操作)的最终划线即可。

此逻辑抽象为函数,简单表达为:

// 点击区域中,可能包含多条划线,我们需要找到最近创建的一条划线

getLatestHighlight (e) {
	// 选择文本,检测重复时,设置了 targetHightlightId
	if (this.state.targetHightlightId && !e) {
	  return this.state.targetHightlightId
	}
	
	let target
	
	// triggerTooltipEvent 用于对事件触发兼容性和特殊情况的磨平,读者可暂不考虑
	if (triggerTooltipEvent && !e) {
	  target = triggerTooltipEvent.target
	} else if (e) {
	  target = e.target
	} else {
	  // 无法获取 target 对象时(理论上不可能),安全退出
	  return
	}
	
	const propagationHighlightMap = {}
	const paragraphNode = getFirstBlockAncestor(target)
	
	let latestHighlight = target.getAttribute('createdtime')
	
	// triggerTooltipEvent.target 向上冒泡,将所有点击事件经过的划线(可能有重叠和交叉)信息推入到 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
}
复制代码

根据划线后富文本标签结果,我们直接调用 React setDangerouslyInnerHTML API 即可得到划线页面。

“纸上得来终觉浅,绝知此事要躬行”。方案实现的过程当中,你会发现“说起来容易,做起来难”。比如,一直提到的“拆解标签”,那具体怎么拆,怎么解呢? 有两个拆解标签方案摆在我们面前,我把它归类为:

  • Dom based
  • String based

第一种基于 DOM,第二种基于字符串。在具体展开之前,还是让我们先来熟悉两个基本 BOM/DOM APIs。

Window.getSelection

Window.getSelection() 可以返回一系列关于用户选区的信息,如下使用方式:

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

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

const end = {
  node: range.endContainer,
  offset: range.endOffset
}
复制代码

但是请注意,我们**无法通过它直接获取选区中的所有 DOM 元素,它只能返回选区的首尾节点信息,包括划线起始于哪个 node,起始文本相对于该 node 偏移量是多少;划线结束于哪个 node,结束文本相对于该 node 偏移量是多少。**我们准确找到了首尾节点,下一步就是找出“中间”所有的文本节点。这就需要遍历 DOM 树。

树形 DOM Node 典型如下图,出自这里

ul li DOM 关系

**由于 DOM 不是线性结构而是树形结构,所以“找出中间所有的文本节点”,这个“中间”换成程序语言,就是指深度优先遍历。**我们来看代码:

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

这段文字中,包含了两段划线高亮内容。对应图:

DFS 简单示意

在已有划线对应的 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>
复制代码

因为我们能获得的 startNode(data-id 为 3 的 span)的下一个兄弟节点为 null,为了沿途遍历包裹标签并找到 endNode,我们需要先“回溯”,再深度向下。典型的 DFS,用循环和递归均可实现,这里不再赘述,仅给出一个简单示意:

递归版:

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()

由于用户勾选区域只包含一个文本节点的一部分,所以我们拆解标签时,也是在开始和结束节点的一部分起止。对此,大部分读者可能会想到 Text.splitText() 拆分文本节点。通过 Text.splitText(),对于开始节点,收集它的后半部分;而对于结束节点,则是收集前半部分。

如图,

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 方案

有了以上基础,我们可以轻松实现 DOM based 方案来拆解标签,实现划线高亮的渲染。

我们可以按照如何用JS实现“划词高亮”的在线笔记功能?一文提供的方案,先计算出划线标签起止的偏移量:

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;
}
复制代码

还原高亮选区时,需要一个对应的逆过程:

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,如果你的内容不渲染到浏览器上(或者借助某些宿主 API)的话,这些方法都无法直接实施
  • 如果渲染到浏览器上的话,渲染的每一步动作都要依赖上一步渲染到浏览器之后的更新 DOM,反复读写 DOM,意味着反复 repaint 甚至 reflow。每一个划线的渲染也同样频繁操作 DOM,我们的程序是基于 React 的,反复 setState 触发 setDangerouslyInnerHTML 内容的更新,体验令人崩溃
  • 依赖 DOM,也就意味着有很多奇怪的问题,涉及到兼容性,也就涉及到“不可预知的神秘力量”

基于 DOM 的拆解标签方案还有个最大的劣势在于:我们完全依赖 DOM 树,不管是初于稳定性还是灵活性,一个基于字符串的拆解标签方案似乎更加合适。

划线高亮的持久化和还原

按照文章顺序,我们应该介绍基于字符串的拆解标签 string based 方案了。可是为了更加清楚地剖析该方案原理,请允许我先把这种方案搁置,让我们先了解一下划线高亮的持久化和还原做法。

持久化划线高亮选区的核心是找到一种合适的 DOM 节点序列化方法,以便再次进入页面时候能够定位 DOM 节点,渲染出来划线和高亮内容。

一般方案有以下四种:

  • xPath:记录划线 DOM 的 xPath
  • Css selector:记录划线 DOM 的标签选择器顺序
  • Dom tag node offset + text offset:记录划线 DOM 的标签偏移量以及划线文字在此 DOM 内的的文字偏移量
  • Paragraph offset + text offset:记录划线 DOM 所属段落的段落偏移量以及划线文字相对于该段落的文字偏移量

我们的第一反应就是记录相关 DOM 的偏移,即「是哪些相关 DOM 上发生了划线操作」,遂将此 DOM 的相对或绝对偏移量记录下来,这是前三种方案的思路。事实上,最初我也选择了使用第三种方式来快速实现,但是存在一些“致命问题”。第三种记录 DOM 标签的偏移量,也就是记录相对于所有富文本内容的第 K 个标签发生了划线操作,以及这个标签内的文字相对于该 DOM 文本偏移量(从这个标签的第 K' 个字开始划线或者终止划线)。

还是如何用JS实现“划词高亮”的在线笔记功能?一文的例子,我们来看一下这种持久化方案的问题。

如下内容:

<p>非常高兴今天能够在这里和大家分享一下文本高亮(在线笔记)的实现方式。</p>
复制代码

image.png

用户先划线了「高兴」两个字:

<p>
	非常
	<span class="highlight">高兴</span>
	今天能够在这里和大家分享一下文本高亮(在线笔记)的实现方式。
</p>
复制代码

image.png

我们来生成相关划线数据:

// “高兴”两个字被高亮时获取的序列化信息
{
    start: {
        tagName: 'p',
        index: 0,
        childIndex: 0,
        offset: 2
    },
    end: {
        tagName: 'p',
        index: 0,
        childIndex: 0,
        offset: 4
    }
}
复制代码

这并不难理解,「高兴」两个字出现在第一个 P 标签,该 P 标签内只有一个文本节点,因此 childIndex 为 0,在这个文本节点的第二个字开始进行了划线,到第四个字终止。

这时候,用户又划线了「文本高亮」四个字:

<p>
	非常
	<span class="highlight">高兴</span>
	今天能够在这里和大家分享一下
	<span class="highlight">文本高亮</span>
	(在线笔记)的实现方式。
</p>
复制代码

image.png

此时持久化数据的计算是基于前一刻的 DOM 快照生成的,即「文本高亮」这四个字的划线是相对前一刻的 DOM 结构进行计算:首尾节点的 childIndex 都被记为 2(此时 P 标签有三个 children),「文本高亮」这四个字的偏移量是相对于「今天能够在这里和大家分享一下文本高亮(在线笔记)的实现方式」计算的。

得到新的数据结构:

// “文本高亮”四个字被高亮时获取的序列化信息。
// 这时候由于 p 下面已经存在了一个高亮信息(即“高兴”)。
// 所以其内部 HTML 结构已被修改,直观来说就是 childNodes 改变了。
// 进而,childIndex属性由于前一个 span 元素的加入,变为了 2。
{
    start: {
        tagName: 'p',
        index: 0,
        childIndex: 2,
        offset: 14
    },
    end: {
        tagName: 'p',
        index: 0,
        childIndex: 2,
        offset: 18
    }
}
复制代码

请设想,如果用户又删除了「高兴」选区的划线,那么所有出现在「高兴」选区划线之后的划线数据将会出现错误。**本质上,伴随着划线添加,我们动态改变了 DOM 结构,使得持久化数据发生错乱,这便是问题所在。**需求中还会随时动态添加笔记段落,无疑会让问题更加复杂严重。

因此,合理的划线高亮的持久化和还原方案应该记录文稿文本相对于文字的偏移量,而不是 DOM 标签的编译量。

我们来看一下最终方案:

对应的数据为:

image.png

notes 字段表示笔记信息,这里暂时不涉及加入的笔记需求,我们可以先忽略。

字段解释:paragraph_startparagraph_end 表示当前划线的起始和结尾段落,如果对应数值不相等,说明该条划线是跨段落的划线。mark_startmark_end 分别表示对应 paragraph_startparagraph_end 段落中,开始和结束划线文字相对于该段纯文稿文本的偏移量。

String based 方案

了解了持久化划线和高亮方案,我们趁热打铁,看看 string based 方案是如何结合持久化数据实现划线高亮渲染(拆解标签)的。

首先需要注意的是:我们不能粗暴地在开始和结尾划线直接加入 span 开始和闭合标签,因为这种过于理想的情况会导致标签混乱不匹配。

比如:

<p>今天<span>我非常高兴</span>给大家介绍</p>
复制代码

已有高亮内容:我非常高兴。这时候,用户又划线“非常高兴给大家”这 7 个文字时,如果直接添加标签,会得到:

<p>
	今天
	<span>我
	<span>非常高兴</span>给大家</span>
	介绍
</p>
复制代码

明显 span 层级错乱,包裹标签失效。

示意图

我们在做划线高亮时,得到的基本信息富文本就是字符串,比如这样的内容:

<p>123456<span>789012345</span>678901234567</p>
复制代码

假设实际需要高亮的内容如下,划线高亮起始于第一个 9,终止于第二个 9 处:

image.png

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

结合划线数据:mark_start,mark_end,我们先要找到划线开始的那个字符。方法是:设置指针,开始逐个扫描字符串:

image.png

扫描直到指针偏移到 mark_start 处,则表示找到了划线起始。我们插入 span 字符串,最终遍历划线得到全部修饰过的富文本字符串,一次性交给 React setDangerouslyInnerHTML 渲染即可。

但是这里需要注意:因为 mark_start,mark_end 是相对于文本的偏移量,因此在扫描标签时,如果遇到了 DOM tag,进入了 DOM 标签内的文字,那么我们需要停止计数,并继续移动指针,直到移动出当前 Dom tag,才可以恢复计数。也就是说在上图中,扫描到 直到移出的 6 个位移中,我们不进行计数。

如果你想问,那我们记录相对于富文本内容的偏移量不就不用这么麻烦了吗?恭喜你,如果这么想,那我们就又回到了上文提到的动态改变 DOM 标签即富文本内容的问题了。

实际上,DOM tag 的开标标签字符 < 以及结束标签 > 都会被转义。但是这里我想延伸,即便不被转义,真正的问题是:我们如何判断指针移动过程中遇到了 DOM tag,进而需要停止计数?因为文稿内容可能就有一个 <,我们怎么知道这是文稿的真实内容而不是进入 DOM tag 的标记呢?(实际上 < 会被转义,这里问了简化问题,先不考虑转义的情况)。

**其实上述过程,已经是一个现代编译器的雏形了,我们可以看看编译器是如何处理这种问题:**当扫描到 < 时,我们设置第二根指针,这个第二根指针继续向下嗅探,进行扫描,如果一路匹配出 <span 我们就可以断言第一根指针遇见的当前 < 是一个 DOM tag 开始标签。

事实上,熟悉 Vue 源码的同学可能会想到 Vue compiler 模块:在 Vue 实现模版引擎,并进行模版变量双向绑定时,也处理了同样的问题。——因为这是一个经典的编译器基本原理。

**再比如 Babel 进行编译代码时,**例如 optional chaining 这个编译插件,也是通过扫描源码字符串,发现一个 ?,则通过新的嗅探指针进行向下扫描,如果发现 ? 后面跟着一个 .,即 foo?.bar 这种表达形式,那么可判断这是一个 optional chaining 用法,我们可以进行相应的 ES5 编译;如果嗅探指针发现 ?后出现了 :,那么就应该把它当作三目运算符理解。真实情况更加复杂,且有所出入,这里只是对原理进行说明,不再展开。

这个嗅探指针的实现过程,有一个专业术语也许大家听说过,叫做 tokenizer,即分词。它往往会结合 AST(抽象语法树)出现,在前端工程化等领域中出现。

划线笔记的标签拆解,就是一个朴素的编译器原理,涉及到 tokenizer 等一系列过程。有了这样的能力,一切就会变得“不那么复杂”。当然,在具体实现划线高亮业务中,我根据需求特点,创建了很多 fastpath,简化了编译分词过程,这里读者只需要理解底层思想即可。

行后插入笔记

聊完划线高亮,我们再看一下划线行后插入笔记效果的实现。之前提到,我们的页面所有文稿内容都是 React 一股脑渲染富文本得来的,划线完毕后,如何找到适当的位置(划线后下一行)插入笔记呢?

**方案非常巧妙:**我们借助 document.createRange() 和 Text.splitNode() 两个 APIs,在划线后的第一个字符后,创建一个 range,长度为 1,换句话说,提取划线截止的最后一个字后的每一个字拆并计算这个字距离屏幕最左侧的长度,进行记录。按照常理,划线后的每一个字距离屏幕左侧的长度应该依次递增,直到换行后的第一个字符。这样我们就找到了划线后一行的起始。

如下图所示:

image.png

接下来就是在找到换行的位置插入笔记节点的逻辑了。说起来简单,实施起来除了递归算法的运用之外,还用进行多种 cases 的容错处理,实现代码也有几百行了。

代码:

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

  if (lastRightOffset > currentRightOffset) {
    // 找到了插入点
    doRenderCommentBlock(range, currentAnnotationId, lastAnnotationByIdNode)
  } else {
    // 继续向下一个字符寻找
    if (range.endOffset < range.endContainer.textContent.length - 1) {
      // 如果当前 range 还没找到头,那就继续下一个
      const currentRange = document.createRange()
      currentRange.setStart(range.endContainer, range.endOffset)

      // 如果结束节点类型是 Text, Comment, or CDATASection 之一, 那么 endOffset 指的是从结束节点算起字符的偏移量
      // 对于其他 Node 类型节点, endOffset 是指从结束结点开始算起子节点的偏移量。

      try {
        // 存在 range.endOffset + 1 不存在的情况 (比如空标签),这时候就用下一个节点
        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 {
      // 如果当前 range 到头了,还没有找到,则找下一个 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 {
        // 找到了当前段的最后面,在段后加
        doRenderCommentBlock(null, currentAnnotationId, lastAnnotationByIdNode)
      }
    }
  }
}
复制代码

整体流程梳理

我们来看一下全套流程的时序图:

image.png

项目采用了基于 Rect 的 SSR 架构,在服务端预获取两类数据:

  • fetchManuscript
  • fetchAnnotationsData

第一类数据是原始文稿内容;第二类是文稿对应的划线笔记持久化数据。在交给浏览器渲染之后,React 进行初次绘制,这一次渲染呈现原始文稿内容。因为我们只有把原始文稿实际渲染完成后,再结合手机屏幕宽度和位置信息,才能应用划线笔记的逻辑。在 componentDidMount 逻辑中,我们先进行 disableSelection,该方法设置所有非文稿内容不可选,之后 transformData 逻辑将加工后端数据为:

  • annotationsById
  • notesById

划线高亮逻辑核心函数:renderHighlight 对 annotationsById 进行遍历,生成全量的已加入划线标签的富文本字符串,此时再次触发渲染。这次渲染完后,执行 renderNotes 函数和 renderNotesIcon 函数,他对 notesById 进行遍历,生成生成全量的已加入笔记区块的富文本字符串,并触发渲染。

整个流程通过 Promise 串联,依次执行。请注意这里不能并行执行,因为笔记的插入依赖渲染划线高亮样式后的布局。

因为我们的事件处理和绑定采用了事件代理的方式,因此在 componentDidMount 之后即可和其他渲染流程并行处理。我们在开篇就提到过,事件的冲突和干扰在此项目中尤为突出和棘手。由于篇幅限制,我们不再深入分析,而是拆出来几个条目供大家参考。

touch 事件处理

可能你好奇,为什么需要对 touch 事件进行监听?是因为 click 在移动端的 300 ms 延迟?

其实没有那么简单,是因为用户在勾选文字后,触发 click 时,由于系统原因,勾选区域将会置空,这时候获取 window.getSelection() 只会得到 null,而 tooltip 上的点击事件处理大都需要 window.getSelection() 的内容。因此,我们要么对最近一次的 window.getSelection() 返回值持久化存在内存中,要么对于 tooltip 的点击事件换成对 touch 事件的监听,而后者明显是更合理的方案。

接下来,我们看看对 touch 事件(准确来说 touchend 事件)绑定了哪些交互。

复制

复制按钮的点击又有两种场景:

  • 一种是在用户勾选文字的过程中,点击 tooltip 上「复制」,那么复制的内容为勾选的合法文字;
  • 一种是点击已经存在的高亮划线,唤醒 tooltip,再点击 tooltip 上「复制」按钮,这时候复制的内容为划线高亮文字。

说起来简单,做起来有点复杂。对于第一种情况,我们需要找到合法的勾选文字,需求要求勾选内容如果包含笔记内容,那么复制文案要排除笔记内容,只复制文稿内容;如果包含段后公开笔记计数 icon,也不能复制进去计数值。因此我们还需要进行圈选区域的遍历,并判断非法标签(非文稿内容标签)。如下如,我们勾选得到两个 text 分别问勾选 startNode 和 endNode,一个经典的 DFS 又出来了:

image.png

对于第二种情况,点击「复制」按钮后,我们要先判断点击区域是否属于多条划线高亮的交叉区域,如果是,那么就要模拟向上冒泡过程,找到最近的归属划线,复制相应内容。

  • 划线

点击「划线」按钮,我们就要先判断选区是否可划线,然后计算划线偏移量得到划线持久化数据,进行标签拆解,渲染高亮区域,接着向后端发送请求并更新内存中 annotaionsById 数据。别忘了还需要更新段后计数 icon 的值。

  • 删除划线

与「划线」按钮类似,同样需要判断是否点击区域是否为多条划线的交叉区域,并和后端通信,以及修改内存数据和 DOM 内容。

  • 分享

分享需要调客户端端能力,同样先要确定是点击划线后唤醒 tooltip 并点击「分享」按钮,还是用户勾选文字后唤醒 tooltip 并点击「分享」按钮。基本逻辑类似「复制」按钮的点击。

  • 写笔记

这时候可能是用户勾选了新的内容:因此要先加高亮划线,再去写笔记;也可能是点击已经存在的划线,去增加笔记。

由此可见,各种按钮逻辑都有多种触发场景, 需要我们做很多细致的判断和处理。这只是“冰山一角”,更多的逻辑和场景不再一一列举。

click, touch, selectionchange 的三国演义

touch 事件的引入,细化了我们事件处理粒度,使得需求能够完成。但它带来了事件的交织和冲突。结合碎片化的手机终端,矛盾冲突重重。

比如:对于点击事件 click,如果点击时当前 tooltip 不存在,且点击的是已有划线高亮内容,那么应该唤醒 tooltip,且 tooltip 含有「删除划线」按钮;如果当前 tooltip 已经存在,则认为触碰了空白区域,tooltip 就应该消失。有极个别浏览器,点击事件 click 会触发 selectionchange 事件,但是 selectionchange 事件的触发,会使我们认为用户勾选了新的内容,引发一系列的连锁反应。 再说回来,当 tooltip 存在时,点击空白区域,tooltip 消失。但是需求要求滚动时候 tooltip 不能消失,可是滚动事件触发,很多浏览器也会触发点击事件,这时候我们认为 tooltip 又应该消失。类似所有这些内容都交织一起,开发者都需要考虑到。

有认真的读者可能会想:「为什么不考虑监听 selectionchangeend 事件」,在开发过程中,我们发现 selectionchangeend 虽然在规范中有提及,但该事件在任何手机在都不会触发。如果使用 touchend 模拟 selectionchangeend,又发现有的手机在勾选结束后不触发 touchend/touchmove。

当然,这不是本篇文章的重点,我们点到为止。

安全性和性能保障

整体下来,划线笔记项目的安全性尤为重要。这里的安全主要是指用户交互的非阻塞性,文稿内容和划线笔记的呈现准确性。但文稿页面内容千变万化,标签结构理论上能达到最复杂。如何在线上出现问题时,不阻塞页面,且保障其他交互的顺畅进行呢? 其实方法也很简单,主要依赖 try...catch 区块,在 catch 中注意进行错误采集和还原,方便后续记录并追查。同时合理的 fallback 机制也非常重要,这需要和产品讨论制定更加完善的方案。值得一提的是,我们前端组如今正在着力打造完善的端到端测试流程,已经接入了最基本的划线高亮测试,未来在端到端的测试上,我们将持续深耕,届时也会分享更多经验和心得。

**划线笔记涉及到的性能话题其实较为常见,**保障策略也较为常规,但是性能手段每一点的背后都是一个极大的话题,这里我们简单总结使用到的性能优化方法,并不再往下延伸:

  • 服务端渲染,预获取数据
  • Dom 节点选择器的优化
  • 递归性能优化(优先使用 for 循环,借助蹦床函数等尾递归调用优化实施)
  • debouch 和 throttle 的合理使用
  • Dom 操作减少 repaint 和 reflow
  • 独立合成层,GPU 渲染加速相关:比如 transform,opacity 等 CSS3 属性的使用
  • addEventListenner 第三个参数 passive 的使用

总结

从「划线高亮」并「插入笔记」这个需求,我们提炼出了一连串前端知识点,同时分析了实施过程当中的困难和解决方案。

这些内容涉及到 DOM、BOM 等基本知识,也涉及到编程领域中不可或缺的 AST、编译原理的皮毛,并延伸出现代前端开发所依赖的 Babel 以及框架 Vue 的实现原理。

**前端开发的护城河之一就是精细化交互实现,前端开发的开疆扩土也依赖于更低层的编程普适原理,**希望这篇长文能对大家有所启发,也欢迎大家一起讨论。