记一次划线需求的实现方式
1 背景
1.1 需求背景
前半年接了一个划线需求,一直没空总结下。微信公众号和微信读书等读书类应用都有此功能,但前者只对部分用户开放了,并且没有长期运营。
这次只谈下划线技术实现本身。
1.2 功能详叙
- 用户可以对文章句子进行长按选区,过程中弹出面板,且面板位置动态变化,点击点赞按钮后生成划线;
- 点击划线句子默认选中,并弹出面板,显示所点句子点赞量;
- 划线句子可以合并,规则是选取句子和已赞过的句子有交叉时合并为一条新的首尾更长的句子,选取的句子被包含在已赞过的句子中时显示点赞量,选取句子包含了已赞句子则删掉已赞句子并对新句子点赞量加一;
- 点赞量超过3的句子才外显;
- 他人的划线句子用虚线展示,自己的划线用实线展示;
- 小流量,用户量由小至大,过程中可以对外显策略微调;
1.3 竞品
可以看到,微信公众号划线过程会弹出一个灰色面板,面板上有划线(这次需求改为了点赞)按钮:
2 关键逻辑
这个需求乍看可能会觉得没那么复杂,但细细分析后会发现有较长的交互流程和逻辑链:
其中有几个会影响整体逻辑的关键点需要关注:
- 渲染划线的方式:插入 dom 标签还是绝对定位或其他方式;
- 监听划线选取的交互事件选择 selectionchange 还是 touchend;
- 整个交互过程分为哪些部分;
- 怎么判断新划线和其他划线的位置关系,怎么合并或删除;
- 数据结构怎么设计;
- 怎么将划线序列化;
- 怎么将数据反序列化成划线;
3 详细设计
3.1获取划线
window 上提供了 Selection 对象,它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。调用 Selection.toString()
方法会返回被选中区域中的纯文本。
var selObj = window.getSelection();
var range = selObj.getRangeAt(0);
Selection 对象所对应的是用户所选择的 ranges
(区域)
var selObj = window.getSelection();
var range = selObj.getRangeAt(0);
3.2 划线渲染方式
渲染划线有两种方式:
1 在划线range对象的首尾dom的位置,插入线段的dom标签;
优点:划线的点击不需要计算点击位置,直接在插入dom上绑定事件即可;
缺点:对原页面结构有入侵,改变了dom结构,可能引发其他问题;
2 绝对定位,相对于整篇文章;
优点:完全增量,对原页面没有入侵;
缺点:需要计算点击位置;
我选择的第二种,原因是为了不影响原有页面逻辑,这样项目风险也是最小的。那么具体怎么实现呢?
range对象提供了一个 getClientRects
方法,表示 range 在屏幕上所占的区域。这个列表相当于汇集了范围中所有元素调用 Element.getClientRects()
方法所得的结果。用拿到的位置信息进行绝对定位即可。
rectList = range.getClientRects()
我们把用户所有划线range对象和其产生的位置信息都存入到一个list中。
pageRangeList.push({
range,
rectInfo
})
3.3 交互过程
我们分析下整个交互过程:
有两个主要的交互事件,一是点击划线,二是滑动选区。
3.3.1 点击事件
处理点击事件,我们拿到点击事件的位置,和存放的 pageRangeList 进行位置比较,得出用户点击的是哪个range对象。
// 点击事件
const {pageX, pageY} = event;
const lineHeight = 23;
const {range} = rectInfo.some(rect => {
const {left, right, realY} = rect;
return pageX < left && pageX > left && pageY > realY
})
this.selection.removeAllRanges();
this.selection.addRange();
3.3.2 选区事件
选区事件我选择的是 selectionchange,需要加防抖和节流处理。
如果你选的是 touchend 安卓系统会点问题。
3.3.3 比较位置关系
如第2点核心逻辑中所说,在滑词过程中,需要比较位置关系,我们直接使用Range.compareBoundaryPoints
方法即可。返回值 0 、-1 、1 分别代表不同的位置关系。
const compare = range.compareBoundaryPoints(Range.START_TO_END, sourceRange);
3.4 序列化与反序列化
序列化是整个需求的重点,序列化是指将交互产生的划线转化成某种数据结构能存储在服务器上,反序列化是指如何将server下发的序列化数据转化成非序列化的划线。
两者是两个相反的过程,当我们确定了序列化方案,其实也就知道了反序列化了。
3.4.1 序列化
方案一,识别段落
刚开始我观察文章都会拆分段落,如按P标签或某一个class类名来划分段落,于是计划用段落信息,告诉 server 划线在第几段的第几个字。
interface data {
startParagraph: 1,
startIndex: 22,
endParagraph: 2,
endIndex: 15
}
但后来发现有一些抓取的文章根本内容很混乱,且没有特定的段落,强行识别复杂度极高。(如下图)所以此方案不可行。
方案二,全文第几个字
前面的方案不可能的原因是,识别段落信息复杂度不可控,那么我们可以绕过段落信息,去识别全文第几个字。
interface data {
startCharacters: 122,
endCharacters: 166
}
具体方式是用Range,圈选文章开头到当前dom,形成一个新Range,再调用range.toString查看字数即可。
const range = new Range();
range.setStart(pageContainer, 0);
range.setEnd(curEndContainer, endOffset);
const str = range.toString();
3.4.2 反序列化
这里注意,由于 Javascript 在大多宿主环境下没有递归的尾调用优化,所以我采用了手动创建栈来进行 dfs:
dfs({
node = this.content,
}) {
const stack = [];
if (!node) {
return;
}
stack.push(node);
while (stack.length) {
const item = stack.pop();
const children = item.childNodes;
for (let i = children.length - 1; i >= 0; i--) {
stack.push( [i]);
}
}
}
来源:juejin.cn/post/7344993022075813938