0%

快速上手油猴插件开发(实战篇)

这篇文章你能够学习到:

  1. 快速使用油猴插件进行浏览器插件开发
  2. 用 wokoo 脚手架开发 2 个有意思的插件: 划词搜索MoveSearch 和 知乎目录zhihu-helper
  3. 有精力的话可以继续学习wokoo 脚手架的搭建

为什么要学习浏览器插件开发

浏览器插件开发有的同学可能接触的不多,或者有疑问,学习开发浏览器插件有什么用呢?这里我列举一些工作中的场景:

场景 1:

告警平台收到某个前端项目的告警日志,为了复现定位问题,需要登录某个账号内进行查看。此时 rd 需要联系产品同学,让产品同学联系对应的用户,让用户提供账号密码给 rd。此流程很长,而且给用户造成不好的印象。而如果公司内部支持免密登录,只是需要拼接一个特定的 url,url 规则比较复杂,需要有账号 id 等信息。此时如果将拼接规则写到插件里,插件提供一个按钮就能够跳转到对应的用户账号中,是不是降低了 rd 的工作量?

场景 2:

当测试的同学发现问题时,需要截图,打开测试平台,填写具体的问题,提交测试报告。如果能有插件在当前页面中弹出要填写的测试表单,是不是就减少了测试同学的工作流程?

学会了浏览器插件开发后,可以做一些工作场景相关的插件,来帮助自己以及同事提高效率。这不管是述职晋升还是跳槽换工作都会是一个加分的亮点。

正式开始插件开发

一、划词搜索 MoveSearch

成品展示

安装地址:

  1. 安装油猴插件
  2. 安装MoveSearch

代码地址:wokoo/example/MoveSearch

开发步骤

1.1 项目安装 & 初始化配置

1
2
npm i wokoo -g
wokoo MoveSearch

选择模板

  • vue
  • react

这里选择 react

安装完成后,出现安装成功的界面

根据提示执行下命令

1
2
cd MoveSearch
npm start
  • 打开油猴脚本编辑器,把 tampermonkey.js 的内容复制进去。(注意:这里是指把 tampermonkey.js 里的所有内容都复制进去,包括注释。因为此文件中被注释掉的//@xxx 都有含义,可以对应着 tampermonkey 开发文档 理解。比如@match https://代表在 https 页面下才会启动插件)


  • 打开网页开发者搜索,右上角出现猴子的 logo,说明环境跑通

1.2 实现基本功能

先整理一下思路,要实现划词搜索功能,主要实现以下步骤:

  1. 监听 mouseup 事件,触发时检查是否有文字被选中,如果有则弹窗展示
  2. 弹窗展示前发请求到 baidu 的开发者搜索的接口,获取搜索内容
  3. 如果搜索内容不为空,将内容展示到弹窗中
  4. 边界检测,点击弹窗区域外的内容,弹窗关闭

此处有个难点,就是 baidu 的开发者搜索接口不允许跨域,比如在掘金的网页中去请求开发者搜索的接口会被拦住。为解决这个问题,我使用vercel提供的 serverless 服务做了一次代理,在代理中给响应头增加了Access-Control-Allow-Origin等字段,开启跨域。具体方法在1.3给出,此时我们先在开发者搜索页面里面进行开发,先保证是同域下。

步骤 1. 监听 mouseup 事件,触发时检查是否有文字被选中

编辑/src/app.js文件,增加对 mouseup 的监听:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import React from 'react'
import axios from 'axios'
import './app.less'

export default class extends React.Component {
constructor(props) {
super(props)
this.state = { show: false }
}
componentDidMount() {
document.addEventListener('mouseup', (e) => {
var selectionObj = window.getSelection()
var selectedText = selectionObj.toString()
console.log('selectedText', selectedText)
})
}

render() {
let { show } = this.state
return <>{show ? <div className="Wokoo"></div> : null}</>
}
}

步骤 2. 弹窗展示前发请求到开发者搜索的接口,获取搜索内容

  • 安装 axios,引入 axios
  • 对 componentDidMount 内的代码进行改造,请求开发者搜索 获取搜索结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
componentDidMount() {
document.addEventListener('mouseup', (e) => {
var selectionObj = window.getSelection()
var selectedText = selectionObj.toString()
console.log('selectionObj::', selectedText)
if (selectedText.length === 0) {
} else {
axios
.get(
`https://kaifa.baidu.com/rest/v1/search?query=${selectedText}&pageNum=1&pageSize=10`
)
.then((res) => {
let { data } = res.data.data.documents
console.log(data)
if (data.length) {
this.setState({
data: data,
show: true,
})
}
})
}
})
}
  • 修改 render,展示搜索结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
render() {
let { show, data } = this.state
return (
<>
{show ? (
<div className="Wokoo">
<ul>
{data.map((i) => (
<li>{i.title}</li>
))}
</ul>
</div>
) : null}
</>
)
}

此时,网页中能够看到搜索的结果了,基本功能搞定了。

步骤 3. 计算弹窗出现的位置,让弹窗出现在选中文字下方


我们可以得出计算公式为:

left = x + width/2 - MODAL_WIDTH/2

top = y + height

此时,app.js的代码被改造成这样:

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import React from 'react'
import axios from 'axios'
import './app.less'
const MODAL_WIDTH = 350
export default class extends React.Component {
constructor(props) {
super(props)
this.state = { show: false, data: [] }
}
componentDidMount() {
document.addEventListener('mouseup', (e) => {
var selectionObj = window.getSelection()
var selectedText = selectionObj.toString()
if (selectedText.length === 0) {
} else {
var selectionObjRect = selectionObj
.getRangeAt(0)
.getBoundingClientRect()
let { x, y, height, width } = selectionObjRect // 获取选中文字的位置,x y是横纵坐标,height width是选中文字的高度和宽度
// 计算弹窗位置,算出left和top
var left = x - MODAL_WIDTH / 2 + width / 2
left = left > 10 ? left : 10
var top = y + height
var scrollLeft =
document.documentElement.scrollLeft || document.body.scrollLeft
var scrollTop =
document.documentElement.scrollTop || document.body.scrollTop
axios
.get(
`https://kaifa.baidu.com/rest/v1/search?query=${selectedText}&pageNum=1&pageSize=10`
)
.then((res) => {
let { data } = res.data.data.documents
if (data.length) {
this.setState({
data,
show: true,
selectedText: selectedText,
modalPosition: {
left: left + scrollLeft,
top: top + scrollTop,
},
})
}
})
}
})
}

render() {
let { show, selectedText, modalPosition, data } = this.state
return (
<>
{show && data && data.length ? (
<div
className="move-search"
id="MoveSearchApp"
style={{
...modalPosition,
}}
>
<div className="move-search-content">
<ul className="move-search-ul">
{data.map((l) => (
<li className="move-search-li" key={l.id}>
<a href={l.url} target="_blank">
{l.title}
</a>
<span>{l.summary}</span>
</li>
))}
</ul>
</div>
<div className="move-search-bottom-fade"></div>
<footer className="move-search-footer">
<a
href={`https://kaifa.baidu.com/searchPage?wd=${selectedText}`}
target="_blank"
>
Read More
</a>
</footer>
</div>
) : null}
</>
)
}
}

别忘了增加样式,修改 app.less

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.move-search {
position: absolute;
text-align: center;
width: 350px;
max-height: 300px;
top: 0;
right: 0;
border-radius: 5px;
z-index: 2147483647;
box-shadow: rgba(0, 0, 0, 0.2) 0px 16px 100px 0px;
transition: all 0.1s ease-out 0s;
border: 1px solide #282a33;
background: #fff;
font-size: 12px;
overflow: scroll;
text-align: left;
}

步骤 4. 边界检测,点击弹窗外部,弹窗隐藏

边界检测的判断条件如下:

left+width > x > left && top+height > y > top

1
2
3
4
5
6
7
8
9
10
11
12
function boundaryDetection(x, y, modalPosition = { left: 0, top: 0 }) {
let { left, top } = modalPosition
if (
x > left &&
x < left + MODAL_WIDTH &&
y > top &&
y < top + MoveSearchApp.offsetHeight
) {
return true
}
return false
}

修改app.js增加弹窗消失的逻辑,在 selectedText 为空时判断鼠标位置是否在弹窗内。

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
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import React from 'react'
import axios from 'axios'
import './app.less'
// 弹窗宽度
const MODAL_WIDTH = 350
/**
* 边界检测,鼠标点击modal之外,modal隐藏
* @param {*} x 鼠标的x轴位置
* @param {*} y 鼠标的y轴位置
* @param {*} modalPosition 弹窗的left和top
*/
function boundaryDetection(x, y, modalPosition = { left: 0, top: 0 }) {
let { left, top } = modalPosition
if (
x > left &&
x < left + MODAL_WIDTH &&
y > top &&
y < top + MoveSearchApp.offsetHeight
) {
return true
}
return false
}
export default class extends React.Component {
constructor(props) {
super(props)
this.state = { show: false, data: [] }
}
componentDidMount() {
document.addEventListener('mouseup', (e) => {
var selectionObj = window.getSelection()
var selectedText = selectionObj.toString()
if (selectedText.length === 0) {
if (this.state.show) {
// 重新计算是否关闭弹窗
// 检测鼠标位置是否在弹窗内,不是则关闭弹窗
var inModal = boundaryDetection(
e.clientX,
e.clientY,
this.state.modalPosition
)
if (!inModal) {
this.setState({
show: false,
data: [],
})
}
}
} else {
var selectionObjRect = selectionObj
.getRangeAt(0)
.getBoundingClientRect()
let { x, y, height, width } = selectionObjRect // 获取选中文字的位置,x y是横纵坐标,height width是选中文字的高度和宽度
// 计算弹窗位置,算出left和top
var left = x - MODAL_WIDTH / 2 + width / 2
left = left > 10 ? left : 10
var top = y + height
var scrollLeft =
document.documentElement.scrollLeft || document.body.scrollLeft
var scrollTop =
document.documentElement.scrollTop || document.body.scrollTop
axios
.get(
`https://kaifa.baidu.com/rest/v1/search?query=${selectedText}&pageNum=1&pageSize=10`
)
.then((res) => {
let { data } = res.data.data.documents
if (data.length) {
this.setState({
data,
show: true,
selectedText: selectedText,
modalPosition: {
left: left + scrollLeft,
top: top + scrollTop,
},
})
}
})
}
})
}

render() {
let { show, selectedText, modalPosition, data } = this.state
return (
<>
{show && data && data.length ? (
<div
className="move-search"
id="MoveSearchApp"
style={{
...modalPosition,
}}
>
<div className="move-search-content">
<ul className="move-search-ul">
{data.map((l) => (
<li className="move-search-li" key={l.id}>
<a href={l.url} target="_blank">
{l.title}
</a>
<span>{l.summary}</span>
</li>
))}
</ul>
</div>
<div className="move-search-bottom-fade"></div>
<footer className="move-search-footer">
<a
href={`https://kaifa.baidu.com/searchPage?wd=${selectedText}`}
target="_blank"
>
Read More
</a>
</footer>
</div>
) : null}
</>
)
}
}

此时,项目的基本功能已经开发完成 🎉,一些样式问题可以自行调整。

具体的代码逻辑可看 github 地址:MoveSearch

1.3 解决跨域问题

百度的接口不允许跨域访问。也就是说如果我们在一个第三方页面,比如 cdn,掘金等的网页里,要访问百度的接口就存在跨域限制,没法访问。我采用增加 node 中间层,在 node 中给响应增加Access-Control-Allow-Origin请求头的方式开启跨域。

目前有现成的提供 serverless 服务的第三方厂家,提供服务器,node 环境等服务,我们只要关心 node 的代码逻辑即可,部署服务器配置环境等问题交给第三方厂家解决。我采用vercel提供的服务。

下面的内容和插件开发关系不大,如果不感兴趣的同学可以直接使用我配置好的域名:https:/movesearch.vercel.app/api/baidu
也就是将代码中 axios 请求的 url 由

1
`https://kaifa.baidu.com/rest/v1/search?query=${selectedText}&pageNum=1&pageSize=10`

替换成

1
`https://movesearch.vercel.app/api/baidu?query=${selectedText}&pageNum=1&pageSize=10`

如果对于如何实现跨域配置感兴趣的话,可以继续往下看 👇。

  1. 登录vercel官网,根据提示绑定 github 账号;
  2. 点击新建 next 项目。vercel 会自动给你的 github 上创建新的仓库,并有一个初始化项目;(注意:此处会让你填写仓库名,此仓库名和域名是相关联的。)
  3. git clone 此项目到本地,根据 readme 的提示,执行npm run dev,启动项目
  4. 增加src/api/baidu.js文件,内容如下:

稍微解释一下,src/api/baidu.js下的文件对应的就是/api/baidu接口

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
const { createProxyMiddleware } = require('http-proxy-middleware')

// restream parsed body before proxying
var restream = function (proxyRes, req, res, options) {
proxyRes.headers['Access-Control-Allow-Origin'] = '*'
proxyRes.headers['Access-Control-Allow-Headers'] = '*'
proxyRes.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, OPTIONS'
}

const apiProxy = createProxyMiddleware({
target: 'https://kaifa.baidu.com/',
changeOrigin: true,
pathRewrite: { '^/api/baidu': '/rest/v1/search' },
secure: false,
onProxyRes: restream,
})

module.exports = function (req, res) {
apiProxy(req, res, (result) => {
console.log('result:', result)
if (result instanceof Error) {
throw result
}
throw new Error(
`Request '${req.url}' is not proxied! We should never reach here!`
)
})
}

修改完成后直接 git push 就行。代码推仓库后会,vercel 会自动拉取最新代码更新到它的服务器,我们只要调用一下接口看是否通就行。
具体的代码逻辑可看 github 地址:nextjs

二、知乎专栏目录 zhihu-helper

成品展示


安装地址:

  1. 安装油猴插件 (如果安装过则不用再安)
  2. 安装 zhihu-helper

代码地址:wokoo/zhihu-helper

开发步骤

1.1 项目安装 & 初始化配置

1
2
npm i wokoo -g
wokoo zhihu-help

选择模板

  • vue
  • react

这里选择 react,等待项目安装。项目安装完成后,根据提示执行下命令:

1
2
cd zhihu-helper
npm start
  • 打开油猴脚本编辑器,把 tampermonkey.js 的内容复制进去。

    tampermonkey

    copy-tampermonkey

  • 打开网页知乎专栏,右上角出现一只猴子图标,说明项目已跑通。

补充说明

此处有的浏览器不会出现猴子图标。打开控制台可见报错:

再看请求的 html 资源的响应头,可以发现多了一条content-security-policy规则。也就是说知乎使用了 csp 内容安全策略,通过content-security-policy中的script-src字段可知,知乎只允许加载指定域名的 js。具体情况可阅读 👉 内容安全策略( CSP )

如何绕过此安全策略,此处给两个方法:

  1. 安装插件Disable Content-Security-Policy, 在调试知乎页面时开启插件,自动把 html 页面的content-security-policy给设置为空。
    注意,开启页面后,要点一下插件,按钮变成彩色了才算开启成功

  1. 会配置 charles 的同学,可以设置一条转发规则

这两种方法选一种就行。

遇到这中 csp 内容安全策略的网页,在上线到油猴商店的时候不能用托管 cdn 的方式,要将代码复制到编辑框中。

1.2 开发基本功能

整理思路

  1. 绘制左侧抽屉弹窗
  2. 弹窗弹出时请求知乎列表接口,拿到列表数据
  3. 下拉时实现加载更多的功能

下面我们来逐步实现吧~

步骤 1. 绘制左侧抽屉弹窗

主要通过 this.state.show 控制弹窗的显示隐藏。此步骤挺简单的,可以直接看代码:step1,可以将 index.js 入口里的 app 替换成 step1 查看效果。

步骤 2. 请求知乎列表接口,获取列表数据

  • 安装 axios,并引入
1
npm install axios
  • 计算请求参数

分析请求的 url 可知,请求接口为:

1
;`https://www.zhihu.com/api/v4/columns${this.queryName}/items?limit=20&offset=${offset}`

其中 this.queryName 是专栏名称,当页面为专栏列表页时就是 pathname; 当页面为专栏详情页时,需要通过类名为ColumnPageHeader-TitleColumn的 a 标签的 href 来获取。

zhihu

zhihu1

通过这两张图应该更好理解 getQueryName 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
getQueryName = () => {
let pathname = location.pathname
let detailRegExp = /^\/p\/\d+/
let queryName = ''
// 专栏详情页
if (detailRegExp.test(pathname)) {
let aTage = document.getElementsByClassName('ColumnPageHeader-TitleColumn')
let url = aTage[0].href
queryName = url.slice(url.lastIndexOf('/'))
} else {
// 专栏列表页
// http://zhuanlan.zhihu和http://zhihu/column两种情况都是专栏
if (pathname.indexOf('/column') === 0) {
pathname = pathname.slice('/column'.length)
}
queryName = pathname
}
this.queryName = queryName
}

而列表页又存在两种域名:https://www.zhihu.com/column/mandyhttps://zhuanlan.zhihu.com/mandy 所以在else逻辑里针对https://www.zhihu.com/column/mandy 做了处理,只保留/mandy

通过 getQueryName 方法,我们获取到了请求参数

  • 发送请求,拉取目录列表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
getList = async () => {
if (!this.state.hasMore) return
let { offset } = this.state
let { data } = await axios.get(
`https://www.zhihu.com/api/v4/columns${this.queryName}/items?limit=20&offset=${offset}`
)
let list = data.data.map((i) => ({
title: i.title,
url: i.url,
id: i.id,
commentCount: i.comment_count,
voteupCount: i.voteup_count,
}))
if (data.paging.is_end) {
this.setState({ hasMore: false })
}
offset += limit

this.setState({
list: [...this.state.list, ...list],
offset,
})
}

第二步的过程实现完了,代码在这里 👉step2 ,可以将 index.js 入口里的 app 替换成 step2 查看效果。

这时插件的效果是这样的,除了没有下拉加载功能,其他基本完工了。

image-20210206004837608

步骤 3. 下拉时实现加载更多的功能

无限滚动的组件引了第三方库react-infinite-scroll-component

1
npm install react-infinite-scroll-component

主要是在 render 函数里增加 InfiniteScroll 组件,注意 InfiniteScroll 的 height 需要通过计算给一个固定的值,否则无法触发滚动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<ul className="list-ul" onMouseLeave={this.handleMouseLeave}>
<InfiniteScroll
dataLength={list.length}
next={this.handleInfiniteOnLoad}
hasMore={hasMore}
loader={<h4>Loading...</h4>}
height={document.documentElement.clientHeight - 53}
endMessage={
<p style={{ textAlign: 'center' }}>
<b>到底了,没内容啦~</b>
</p>
}
>
{list.map((i) => (
<li className="list-li" key={i.id}>
...
</li>
))}
</InfiniteScroll>
</ul>

此时功能已经开发完成,具体代码查看 👉app.js

部署插件到油猴商店

3.1 构建

执行命令

1
npm run build

3.2 确认油猴脚本文件 tampermonkey.js

此文件中被注释掉的//@xxx 都有含义,可以对应着 tampermonkey 开发文档 理解。

  • @description 插件描述

  • @match 指定某些域名下开启此插件,默认配了两条,// @match https://*/*// @match https://*/*表示在所有域名下都开启。但是此处希望只在 zhihu 专栏里使用此插件,所以要修改@math 字段。

    1
    2
    // @match        https://zhuanlan.zhihu.com/*
    // @match https://www.zhihu.com/column/*
  • @require 油猴脚本内部帮忙引入第三方资源,比如 jquery,react 等。

    1
    2
    // @require https://unpkg.com/react@17/umd/react.production.min.js
    // @require https://unpkg.com/react-dom@17/umd/react-dom.production.min.js

3.3 发布插件到油猴市场

发布油猴市场的优点是不用审核,即发即用,非常方便。

  1. 将/dist/app.bundle.js 文件部署到 cdn 上,获取到对应 url。

注意:

  • js 文件可放到 github 上,如果托管到 github 上最好做 cdn 加速(我使用 cdn.jsdelivr.net 进行 cdn 加速)。

  • 如果没有 cdn 服务器可跳过此步骤,在步骤 4 直接将 app.bundle.js 复制到油猴脚本编辑器中

  1. 登录油猴市场,谷歌账号或 github 账号都可使用。

  2. 点击账号名称,再点击「发布你编写的脚本」

    wokoo-tamp3

    wokoo-tamp4

  3. 进入编辑页,将 tampermonkey.js 里的内容复制到编辑框中

    注意:

    • 步骤 1 中如果托管了 cdn,需要将代码中的localhost:8080网址替换成静态资源 url

    • 步骤 1 中没有托管 cdn,不能直接将/dist/app.bundle.js 文件里的内容复制编辑框。因为编辑框内代码有最大限制,我们构建的 app.bundle.js 把 react 等三方库构建进去超过最大限制了。

      需要对构建结果进行拆包

      4.1 修改 tampermonkey.js ,通过@require 方式引入 react 和 react-dom

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      // ==UserScript==
      // @name zhihu-helper
      // @namespace http://tampermonkey.net/
      // @version 0.0.1
      // @description 知乎目录
      // @author xx
      // @match https://zhuanlan.zhihu.com/*
      // @match https://www.zhihu.com/column/*
      // @require https://unpkg.com/react@17/umd/react.production.min.js
      // @require https://unpkg.com/react-dom@17/umd/react-dom.production.min.js

      // ==/UserScript==

      // app.bundle.js构建好的代码

      4.2 修改 webpack.config.base.js 的 entry 字段

      1
      2
      3
      4
      5
      6
      7
      8
      entry: {
      app: '/src/index.js',
      vendor: [
      // 将react和react-dom这些单独打包出来,减小打包文件体积
      'react',
      'react-dom',
      ],
      },

      4.3 重新执行npm run build 构建出新的 app.bundle.js,复制到油猴市场的编辑框内。

      zhihu-tampermonkey

  4. 点击 「发布脚本」即可

wokoo 脚手架的搭建

感兴趣的同学可以阅读 wokoo 脚手架(搭建篇)