开发一款无副作用的Chrome高亮插件

审核业务的同事需要一款chrome高亮插件来加快审核,由于涉及到敏感的数据,因此不放心第三方的插件,并且调研发现很多chrome商店的高亮插件并不能兼容使用mvvm框架编写的网页,都存在一定的副作,用当被高亮的内容被修改时会造成信息显示错误,于是决定自己开发一个。

GitHub

Chrome插件

总的来说,Chrome插件的开发完全是前端的技术,跟写一个网页、js类库并没有区别,一个前端技能娴熟的程序员可以在简单阅读文档后迅速进行Chrome插件的开发。

content_script

Chrome插件提供在网页中注入js代码(xss既视感)的能力,这些js代码就是content_script。注入的js代码和网页本身的js代码可以理解为不在同一个作用域,不能直接交互,但是可以访问DOM。高亮就是由content_script操作DOM实现。

pop_up就是点击插件图标时出现的页面,在高亮插件里,这个页面用来输入高亮关键字、控制插件开关。

storage与通信

chrome提供了用于存储和通信的api。

storage分为local, sync, managed 3种类型。local类似于localStorage,sync在登录了google账号的情况下会把数据存放在云端,若未登录则使用local。需要注意2点:1. chrome stroage的操作是异步的。2. 存放的数据自动进行了parse,不像localSrorage存储的是String。

通信的api用于content_script、popup、background之间的通信。

以上就是开发一款chrome高亮插件需要了解的知识点。仅仅使用了chrome插件可以使用的一小部分能力。

高亮算法

在页面展示文本一般有两种途径:

  • TextNode
  • 利用input的value或者placeholder(百度首页的「百度一下」按钮就是使用input的value做的)

通常来说input标签的属性我们是不能乱动的,所以只需要关注TextNode。

给定一个关键字列表

1
keyWrods = ['我', '爱', '她']

给定一个文本节点及其父节点

1
<p>其实,我真的爱着她</p>

比较自然的思路是使用字符串的replace方法进行正则匹配就可以完成高亮了。
所以这里有两个问题:

  • 生成正则
  • 完成高亮

生成正则

只有一点需要注意,特殊字符需要进行转义,比如”/“、”.”等等

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const getRegFromStrList = (function () {
const metaStrInReg = [
'*', '^', '$', '*', '+', '?', '{', '}',
'?', '.', '(', ')', '!', '|', '\\',
]
const reg = new RegExp(metaStrInReg.map(s => `\\${s}`).join('|'), 'g')

return arr => {
if (!Array.isArray(arr)) return false
if (arr.length === 0) return false
const regStr = arr.map(str => str.replace(reg, match => `\\${match}`)).join('|')
return new RegExp(regStr, 'g')
}
})()

高亮单个文本节点

把匹配到的字符外面包裹一个wrap元素添加高亮的样式,然后整体替换到这个文本节点就可以了,由于做的是一个chrome插件,所以使用了一个只有chrome支持的实验性api: node.replaceWith,可以非常方便完成节点的替换操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
let reg = getRegFromStrList(keyWords)
// 高亮单个text-node
const highlightTextNode = node => {
if (!reg) return
if (node.nodeType !== 3) return
text = node.data
if (text.search(reg) === -1) return

const styleTypeCount = 6 // 样式种类数量
const gethighLightTagHead = i => {
const index = i % styleTypeCount + 1
return `<span\
class="_higtlight_chrome_extension_mor _higtlight_chrome_extension_mor__style__${index}"\
data-highlighted="1">`
}
const highLightTagTail = '</span>'

const newElement = document.createElement('morun')
// 生成有高亮样式的dom元素
newElement.innerHTML = text.replace(reg, match => {
const index = keywords.indexOf(match)
if (index > -1) {
return `${gethighLightTagHead(index)}${match}${highLightTagTail}`
}
})

// 保留原始文本节点,但是清除它的内容,不让原来的文本内容显示
node.textContent = ''
// 这次的mutation不用追踪,不能触发下一轮的replacedNodeObserver,否则会清除高亮
node.needTrack = false
// 利用micro-queue的优先调度
setTimeout(() => node.needTrack = true)
// 在已经是空的文本节点之前插入高亮节点,完成高亮
node.before(newElement)
originTextNodes.push(node)
node.responsedNode = newElement
replacedNodeObserver.observe(node, {
'childList': true,
'characterData': true,
'subtree': true,
'attributes': true,
})
observerRemove(node)
}

遍历DOM

遍历DOM,对每一个文本节点使用刚才的高亮算法,就完成了高亮。

有两种思路对DOM进行遍历。

  • 遍历document.all
  • 遍历DOM树

我采用的是层序遍历DOM的方法。有一点需要注意的是需要忽略掉一些特殊的标签,比如script, style,meta等等。

观察DOM变动

Mutation Observer

Mutation Observer 就是一个观察DOM变动的API,可以观察所有的DOM变动,包括属性,子孙节点,文本等等。

需要注意在每一个event-loop中,所有的DOM变动会进行合并,并不会多次触发,而且这个回调是异步的,并且是在micro-task中。

这一点非常重要,因为每高亮一个节点,就会产生一次DOM变动,如果每一次文本变动都会触发Mutation Observer,就会造成性能问题,而且,由于回调在micro-task中,使用throttle也不能很好的解决,因为通常的throttle是使用setTimeout来实现的。

标记已被高亮的node

在遍历DOM的过程,需要跳过已经被高亮过的节点,因为在遍历DOM进行高亮之后会造成DOM变动,所以在这一个tick执行完之后,会进入micro-task再执行高亮函数,由于匹配到关键字的文本一直存在,又会继续进行高亮,一直无限循环下去。

所以对需要更新高亮算法,把已被高亮的节点做一个标记,遍历DOM的时候遇到标记就跳过。

如何取消高亮

高亮之后,肯定需要能够取消,比如要提供一个开关,控制高亮插件的关闭,比如更新关键字的时候需要把DOM回退。

在进行高亮的时候,使用一个数组把这个textNode存起来,并且在textNode上添加一个responsedNode属性,指向的就是替换掉这个textNode的元素。当需要取消高亮的时候,遍历这个数组,替换回去就可以了。

在MVVM下工作

现在很多的网站是使用MVVM框架写的,比如Vue, React等等,框架会把很多DOM对象和一个javascript对象(virtual dom、数据)对应起来,当数据变化时更新(渲染)页面,比如使用textNode.textNode = newText。那么问题来了。

建立对应关系是在模板解析阶段完成的,这个时候并没有渲染DOM,高亮插件也没有对DOM进行过改变。当网页渲染好,高亮函数进行高亮的时候,我们「替換」掉了textNode,textNode是什么,就是一个对象,而框架保留的是对被我们替换掉的textNode的引用,所以当数据更新后,页面并不会发生变化,这就很尴尬了。不过既然发现了造成问题的原因,那么就好解决了。

观察被替换掉的节点

思路很简单,当被替换掉的文本节点有变动的时候,我们把它换回去就OK了。

我首先尝试的是使用Object.defineProperty定义setter来解决,不过显然是我天真,textNode的textContent属性是non-configurable的,无法重新定义。

so,还是拿起了之前用过的Mutation Observer武器,问题解决。当对一个文本节点进行高亮的时候,使用Mutation Observer对它进行观察,当发生变动时把原文本节点替换回去,这一次的替换又会造成一次DOM变动触发Mutation Observer,对新的文本进行高亮。

观察被替换掉的节点父节点

现在还有一个问题,一个节点本身被移除这个操作是没法通过观察它自己来监测到的,只能通过观察它的父节点来完成,所以还需要观察被替换掉的节点父节点,当观察到这个节点被移除是需要同步移除高亮节点。

总结

通过这个项目还是学到了不少东西,知识水平得到了提高。

  • Chrome插件的开发
  • Mutation Observer
  • mvvm的渲染流程
  • 正则处理
  • 熟悉了更多的DOM API