油猴脚本入坑指南
当前页面是本站的「Google AMP」版。查看和发表评论请点击:完整版 »
希望可以让开始尝试自己写脚本的同学们少走一些弯路吧
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 形式
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
不仅是动态的场景下可以这么做,当你需要针对一个很多元素的静态列表监听每个元素的事件时也可以这么做,这种方法最大的优点是你只需要添加一个事件监听,如果你对列表中的每个元素都添加事件监听,会增大内存开销,影响页面性能
有种比较特殊的情况:
<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 的语法来说明页面元素逻辑:
<!-- 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
写成代码大概像这样:
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 | 方便的显示页内通知 |