希望可以让开始尝试自己写脚本的同学们少走一些弯路吧

Head Pic: #アズールレーン 恶毒 – 小小男爵不要坑的插画 – pixiv

 

基础

这部分主要是开始写油猴脚本前应当有所了解的知识

 

常见的用户脚本管理器

Tampermonkey
应该是各位见得最多的也是最知名的,好用又稳定,多浏览器支持,我很喜欢
Greasemonkey
用户脚本始祖,我们现在一直习惯说的油猴脚本的“油猴”实际上就是 Greasemonkey,只支持 Firefox
由于与 Tampermonkey 等其它脚本管理器在 API 的使用上会有些区别,导致某些情况下你很难保持你的脚本同时对 Greasemonkey 兼容,我一般直接放弃兼容
Violentmonkey
由国人开发的一款脚本管理器,界面好看,我很喜欢

 

元数据

即每个油猴脚本都有的,脚本开头很多行注释的内容,这是油猴脚本关键的基础部分,刚开始接触可能会一头雾水,但你绝不能忽视这部分内容

建议:

多参考别人的脚本,能对各个字段的意义了解个大概
阅读官方 wiki,有每个字段详细的介绍
如果你觉得读鸟语实在是很头疼,你也可以阅读由他人维护的中文 GreaseMonkey 用户脚本开发手册
不同的用户脚本管理器可能会加入自己独有的 meta,开发时建议以你的脚本打算主要支持的脚本管理器为主,例如这是 Tampermonkey 的文档

 

GM API

油猴提供了很多强大的 API,它们可以使很操作变得相当简单

注意每个 API 在使用前需要在元数据中用 @grant 进行声明,若你不打算使用这些 API,应当声明 @grant none

以下是一个简单的表格,帮助你了解油猴的 API 大概能做哪些事情

旧 API 新 API 说明
GM_info GM.info 返回当前脚本的元数据
GM_addStyle   为网页添加 CSS
GM_setValue GM.setValue 在本地储存值(只能是字符串),你可以将这个储存看作是 localStorage 一样的东西
GM_getValue GM.getValue 获取使用储存的值
GM_deleteValue GM.deleteValue 删除储存的值
GM_listValues GM.listValues 返回一个由所有储存值的键名组成的数组
GM_getResourceText   获取元数据中定义的 @resource 的资源内容
GM_getResourceURL GM.getResourceUrl 获取元数据中定义的 @resource 资源的 URL(base64 编码后的data:协议地址)
GM_openInTab GM.openInTab 新标签页打开指定地址(用来绕过 Chrome 会阻止所有非用户触发的window.open的限制)
GM_registerMenuCommand   向油猴插件菜单中添加脚本指令(通常用于打开自己写的设置界面或者执行代码之类的)
GM_setClipboard GM.setClipboard 复制指定内容到剪贴板
GM_xmlhttpRequest GM.xmlHttpRequest 发送网络请求,且允许跨域
  GM.notification 浏览器通知

 

新旧 API 的区别

Greasemonkey 从版本 4 开始向性能更高的异步模型发展,旧的 API GM_* 通常是同步的,而新的 API GM.* 是异步的(采用 Promise),在使用时请参考官方 wiki 并多加留意

并且,有些 API 的名称拼写也发生了变化,在上面的表格中已经用粗体标识

想了解更多信息可以阅读官方说明文章 Greasemonkey 4 For Script Authors

 

unsafeWindow

如果你在写脚本的时候有尝试直接通过 window 添加或访问网页全局变量,你会发现这是没有效果的

这是因为油猴的沙箱机制,任何人都无法从 window 直接访问到油猴的 API 或脚本内的变量,保证了安全

如果你确实需要访问 window,可以使用 unsafeWindow,但在正式发布的脚本中你不应该将任何油猴 API 或者脚本中的变量通过它暴露到 window 中

unsafeWindow 在不同脚本管理器中的表现可能会有所不同,特别是 Violentmonkey,如需考虑兼容性还需要多加测试

 

跨域请求

在油猴脚本中你可以引用网络脚本来使用 axios 之类的网络请求模块,这很方便,但同样也产生了局限性,例如由于浏览器机制的限制,你无法直接在网页上进行没有被事先允许的跨域请求

这时建议使用 GM.xmlHttpRequest,同时你应当在元数据用// @connect <value>声明允许被 GM.xmlHttpRequest 访问的域名

<value>可以是:

域名,例如example.com,这也将允许所有子域
子域,例如abc.example.com
self,即脚本运行的网址
localhost
IP 地址
*

如果你习惯用 axios 之类的用 Promise 封装的请求模块,你同样可以将 GM.xmlHttpRequest 封装成 Promise 形式

1
2
3
4
5
6
7
const xhr = option => new Promise((resolve, reject) => {
GM.xmlHttpRequest({
...option,
onerror: reject,
onload: resolve,
});
});

 

使用自己的 IDE 编写油猴脚本

一般脚本管理器自带的编辑器功能十分单一,全程在里面写代码肯定十分不爽,那么如何使用自己的 IDE 编写脚本并随时保存随时生效呢

答案是利用元数据的 @require,它不仅能引用网络脚本,还可以引用本地脚本,所以我们只要 require 用 IDE 编辑的本地脚本就行了

在这之前我们需要允许油猴插件访问本地文件,以 Chrome 为例,在扩展程序列表chrome://extensions/进入插件的详细信息,开启“允许访问文件网址”即可,接着就可以// @require file://<本地路径>的文件网址方式引用本地脚本了

 

引用 CSS

引用 JS 可以采用@require,但 CSS 不行

可行的方法有两种

老办法:用 JS 往<head>插入 CSS 的<link>
API 方法:在元数据中声明// @resource mycss <地址>,然后GM_addStyle(GM_getResourceText('mycss'));
别忘了用到的这两个 API 也要@grant声明

 

进阶

这部分主要是写脚本的过程中有可能遇到的一些难点的较优解决方法

 

避免将 setInterval 用作动态监听的解决方案

初学 JS 的新手在遇到监听动态元素的问题的时候,由于缺乏经验,通常只能想到用 setInterval 去“每隔一段时间就检测一下”,当然这也包括我自己,但不管从性能上还是从实现复杂度来说,这都不是一个好选择,不够优雅

大部分类似的问题都可以在事件监听层面运用点技巧来解决

此处会列举几个常见的场景来说明一下解决思路

 

1. 监听动态生成的页面元素的事件

在有些时候我们可能要去监听动态生成的页面元素的事件,例如自动翻页加载的评论这类

不好的思路
setInterval 每隔一段时间检测一下有没有新生成的页面元素,然后对这些页面元素添加事件监听
好的思路
由于事件冒泡机制,我们可以监听其父级元素的点击事件,然后通过事件信息来确定被点击的元素currentTarget或其父级元素currentTarget.parentNode

不仅是动态的场景下可以这么做,当你需要针对一个很多元素的静态列表监听每个元素的事件时也可以这么做,这种方法最大的优点是你只需要添加一个事件监听,如果你对列表中的每个元素都添加事件监听,会增大内存开销,影响页面性能

有种比较特殊的情况:

1
2
3
4
5
6
7
<ul class="list">
<li class="item">
<img class="image" />
...
<li>
...
</ul>

假设在该场景下,点击 .image 时它自身会被移除,而你需要得到被点击的 .image 所在的 .item,由于该 .image 已经被移出页面的 DOM 树,因此你无法通过点击事件的currentTarget.parentNode来得到 .item

最简单的解决方案是在事件发生时获取鼠标所在的 .item,例如使用 jQuery:$('.item:hover')

 

2. 对动态生成的页面元素进行修改

假设一个场景,此处借用一下 vue 的语法来说明页面元素逻辑:

1
2
3
4
5
6
7
8
9
<!-- Init: showA = true; showB = false; -->
<ul class="list">
<li class="item">
<div v-if="showA" class="item-a" @click="showA = false; doSth().then(() => { showB = true });">...</div>
<div v-if="showB" class="item-b">...</div>
...
<li>
...
</ul>

大致就是,当你点击 .item-a 的时候,.item-a 会被移除,并在一个异步函数doSth()完成后显示 .item-b

你当前的目标是要在 .item-b 出现的时候修改其内容

不好的思路
监听 .item-a 的点击事件,setInterval 每隔一段时间检测一下当前 .item 内有没有 .item-b,有的话就进行修改然后终止该 interval
好的思路
监听 .item-a 的点击事件,当其被点击后监视 .item 的 DOM 变化,若新增了 .item-b 就对其进行修改

是时候祭出 MutationObserver 了,利用它我们可以监视 DOM 树的改动,同时它也是过去的 Mutation Events 的替代品

上面所说的场景可以按这个思路来解决

监听 .list 的点击
当触发点击事件时,找到 :hover 状态的 .item,对其添加 MutationObserver
当 MutationObserver 监视到 .item-b 被添加时,修改 .item-b,并disconnect()该 MutationObserver

写成代码大概像这样:

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
const findItemB = $item => new Promise((resolve, reject) => {
if ($item.length === 0) reject();
// 有可能此时 .item-b 已经出现,所以先检查下
const $itemB = $item.find('.item-b');
if ($itemB.length > 0) {
resolve($itemB);
return;
}
// 监视 .item 的 DOM 树 childList 变化
new MutationObserver((mutations, self) => {
mutations.forEach(({ addedNodes }) => {
addedNodes.forEach(node => {
if (node.className !== '.item-b') return;
self.disconnect();
resolve($(node));
});
});
}).observe($item[0], { childList: true });
});

$('.list').click(async ({ target }) => {
if (target.className !== 'item-a') return;
const $itemB = await findItemB($('.item:hover'));
// do something with $itemB
});

 

补充

 

推荐的一些可能会常用的模块

Github BootCDN 用途
jquery-pjax Link 为页面添加 pjax 支持
jquery-mousewheel Link 为 jQuery 添加鼠标滚轮事件的支持
FileSaver.js Link 另存为任意 blob 为文件
jszip Link 读写创建压缩文件
gif.js Link 制作 gif,支持 worker 方式
clipboard.js Link 虽然油猴提供剪贴板 API,但该模块可以提供一些扩展功能,例如 tooltips 反馈等
dragula Link 提供页面元素的拖拽调序功能
toastr Link 方便的显示页内通知