不同脚本管理器对 GM API 的支持差异
关于作者: 工具开发工程师,曾就职于高通与联发科,现独立开发 MonkeyMom。
实测 57 个 GM 接口在四家用户脚本管理器上的支持情况与返回值差异——暴力猴、Via、Stay for Safari、Userscripts,以及 MonkeyMom 自己实现来抹平差异的 API。
每个脚本管理器都按自己的理解实现 GM API。名字都一样,于是「在一个管理器上能跑」被默认为「在别的管理器上也能跑」。实际上经常不能,而且失败得很安静:函数存在但没有返回值,回调永远不触发,监听器拿不到 id 所以永远卸不掉。
MonkeyMom 要运行自己没写过的脚本,而且运行在它控制不了的管理器上。在设计子脚本桥之前,得先知道地面到底长什么样,于是我们对 57 个 API 在四家管理器上做了一轮实测。
实测是怎么做的
每项检查记录什么
| 检查项 | 怎么查 | 失败长什么样 |
|---|---|---|
| 存在性与类型 | 对标识符取 typeof | undefined,或者一个什么都不做的空函数 |
| 调用结果 | 真的调用一次并捕获返回值 | Promise、元素、undefined,或者直接抛错 |
| 异步回调 | 发起调用,带超时等待 | 回调始终不触发,或者超时才触发 |
| 返回值的解析类型 | 检查 resolve 出来的值 | 该给元素却给了 Promise,或者反过来 |
为什么光看文档不够
文档描述的是意图,脚本看到的才是实现。同一个名字,可以四种行为。
| API | 暴力猴 | Via | Stay for Safari |
|---|---|---|---|
GM.openInTab | 带 close() 的句柄 | undefined | Promise,解析出没有 close() 的标签页对象 |
GM.addStyle | Promise → <style> | <style> 元素 | 选择器字符串 |
GM_xmlhttpRequest | 回调式 | 回调式 | 回调式 |
GM_listValues | 本脚本的键 | 所有脚本的键 | 本脚本的键 |
只看文档会得到一个假设和三个意外。
实测数据表
检测覆盖 57 个 API。下面依次给出各管理器的汇总、API 支持矩阵、返回值类型差异和 MonkeyMom 的合成清单,用作后续写脚本时的速查。
汇总
下表汇总各管理器的通过情况,并列出每个通过检测的 API 全名。
| 管理器 | 通过 / 57 | 通过检测的 API |
|---|---|---|
| 暴力猴 | 48 / 57 | GM_setValueGM_getValueGM_deleteValueGM_listValuesGM_setValuesGM_getValuesGM_deleteValuesGM_addValueChangeListenerGM_removeValueChangeListenerGM_addStyleGM_addElementGM_openInTabGM_setClipboardGM_notificationGM_downloadGM_registerMenuCommandGM_unregisterMenuCommandGM_xmlhttpRequestGM_webRequestGM_cookie.setGM_cookie.listGM_cookie.deleteGM_audio.removeStateChangeListenerGM_getResourceTextGM_getResourceURLGM_infoGM_logunsafeWindowGM.infoGM.setValueGM.getValueGM.deleteValueGM.setValuesGM.getValuesGM.deleteValuesGM.listValuesGM.addStyleGM.addElementGM.notificationGM.downloadGM.registerMenuCommandGM.unregisterMenuCommandGM.getResourceUrlGM.getResourceTextGM.openInTabGM.closeTabGM.setClipboardGM.xmlHttpRequest |
| Via | 41 / 57 | GM_setValueGM_getValueGM_deleteValueGM_listValuesGM_setValuesGM_getValuesGM_deleteValuesGM_addStyleGM_addElementGM_openInTabGM_setClipboardGM_notificationGM_downloadGM_registerMenuCommandGM_unregisterMenuCommandGM_xmlhttpRequestGM_webRequestGM_audio.removeStateChangeListenerGM_getResourceTextGM_getResourceURLGM_infoGM_logunsafeWindowGM.infoGM.setValueGM.getValueGM.deleteValueGM.setValuesGM.getValuesGM.deleteValuesGM.listValuesGM.addStyleGM.addElementGM.notificationGM.registerMenuCommandGM.unregisterMenuCommandGM.getResourceUrlGM.openInTabGM.closeTabGM.setClipboardGM.xmlHttpRequest |
| Stay for Safari | 44 / 57 | GM_setValueGM_getValueGM_deleteValueGM_listValuesGM_addValueChangeListenerGM_removeValueChangeListenerGM_saveTabGM_getTabGM_getTabsGM_addStyleGM_addElementGM_openInTabGM_setClipboardGM_notificationGM_downloadGM_registerMenuCommandGM_unregisterMenuCommandGM_xmlhttpRequestGM_webRequestGM_cookie.setGM_cookie.listGM_cookie.deleteGM_audio.removeStateChangeListenerGM_getResourceTextGM_getResourceURLGM_infoGM_logunsafeWindowGM.infoGM.setValueGM.getValueGM.deleteValueGM.listValuesGM.addStyleGM.addElementGM.notificationGM.registerMenuCommandGM.unregisterMenuCommandGM.getResourceUrlGM.openInTabGM.closeTabGM.setClipboardGM.saveTabGM.xmlHttpRequest |
| Userscripts for Safari | 22 / 57 | GM_setValueGM_getValueGM_deleteValueGM_listValuesGM_saveTabGM_addStyleGM_openInTabGM_setClipboardGM_xmlhttpRequestGM_infoGM.infoGM.setValueGM.getValueGM.deleteValueGM.listValuesGM.addStyleGM.openInTabGM.closeTabGM.setClipboardGM.saveTabGM.getTabGM.xmlHttpRequest |
API 支持矩阵
| API | 暴力猴 | Via | Stay for Safari | Userscripts |
|---|---|---|---|---|
| GM_setValue | ✅ | ✅ | ✅ | ✅ |
| GM_getValue | ✅ | ✅ | ✅ | ✅ |
| GM_deleteValue | ✅ | ✅ | ✅ | ✅ |
| GM_listValues | ✅ | ✅ | ✅ | ⚠️ 返回 {}(GM.listValues 正常) |
| GM_setValues | ✅ | ✅ | ❌ | ❌ |
| GM_getValues | ✅ | ✅ | ❌ | ❌ |
| GM_deleteValues | ✅ | ✅ | ❌ | ❌ |
| GM_addValueChangeListener | ✅ | ❌ | ✅ | ❌ |
| GM_removeValueChangeListener | ✅ | ❌ | ✅ | ❌ |
| GM_saveTab | ❌ | ❌ | ✅ | ✅ |
| GM_getTab | ❌ | ❌ | ✅ | ⚠️ 超时 |
| GM_getTabs | ❌ | ❌ | ✅ | ❌ |
| GM_addStyle | ✅ | ✅ | ✅ | ✅ |
| GM_addElement | ✅ | ✅ | ✅ | ❌ |
| GM_openInTab | ✅ | ✅ | ✅ | ✅ |
| GM_setClipboard | ✅ | ✅ | ✅ | ✅ |
| GM_notification | ✅ | ✅ | ✅ | ❌ |
| GM_download | ✅ | ✅ | ✅ | ❌ |
| GM_registerMenuCommand | ✅ | ✅ | ✅ | ❌ |
| GM_unregisterMenuCommand | ✅ | ❌ | ✅ | ❌ |
| GM_xmlhttpRequest | ✅ | ✅ | ✅ | ✅ |
| GM_webRequest | ⚠️ 空函数 | ⚠️ 空函数 | ⚠️ 空函数 | ❌ |
| GM_cookie.set | ✅ | ❌ | ✅ | ❌ |
| GM_cookie.list | ✅ | ❌ | ✅ | ❌ |
| GM_cookie.delete | ✅ | ❌ | ✅ | ❌ |
| GM_audio.setMute | ❌ | ❌ | ❌ | ❌ |
| GM_audio.getState | ❌ | ❌ | ❌ | ❌ |
| GM_audio.addStateChangeListener | ❌ | ❌ | ❌ | ❌ |
| GM_audio.removeStateChangeListener | ⚠️ 联合测试 | ⚠️ 联合测试 | ⚠️ 联合测试 | ❌ |
| GM_getResourceText | ✅ | ✅ | ✅ | ❌ |
| GM_getResourceURL | ✅ | ✅ | ✅ | ❌ |
| GM_info | ✅ | ✅ | ✅ | ✅ |
| GM_log | ✅ | ✅ | ✅ | ❌ |
| unsafeWindow | ✅ | ✅ | ✅ | ❌ |
| GM.info | ✅ | ✅ | ✅ | ✅ |
| GM.setValue | ✅ | ✅ | ✅ | ✅ |
| GM.getValue | ✅ | ✅ | ✅ | ✅ |
| GM.deleteValue | ✅ | ✅ | ✅ | ✅ |
| GM.setValues | ✅ | ✅ | ❌ | ❌ |
| GM.getValues | ✅ | ✅ | ❌ | ❌ |
| GM.deleteValues | ✅ | ✅ | ❌ | ❌ |
| GM.listValues | ✅ | ✅ | ✅ | ✅ |
| GM.addStyle | ✅ | ✅ | ✅ | ✅ |
| GM.addElement | ✅ | ✅ | ✅ | ❌ |
| GM.notification | ✅ | ✅ | ✅ | ❌ |
| GM.download | ✅ | ❌ | ❌ | ❌ |
| GM.cookie | ⚠️ 超时 | ❌ | ⚠️ 超时 | ❌ |
| GM.registerMenuCommand | ✅ | ⚠️ 不能注销 | ✅ | ❌ |
| GM.unregisterMenuCommand | ✅ | ❌ | ✅ | ❌ |
| GM.getResourceUrl | ✅ | ✅ | ✅ | ❌ |
| GM.getResourceText | ✅ | ❌ | ❌ | ❌ |
| GM.openInTab | ✅ | ✅ | ✅ | ✅ |
| GM.closeTab | ✅ | ✅ | ✅ | ✅ |
| GM.setClipboard | ✅ | ✅ | ✅ | ✅ |
| GM.saveTab | ❌ | ❌ | ✅ | ✅ |
| GM.getTab | ❌ | ❌ | ⚠️ 返回异常 | ✅ |
| GM.xmlHttpRequest | ✅ | ✅ | ✅ | ✅ |
统计:暴力猴 48/57,Stay for Safari 44/57,Via 41/57,Userscripts 22/57。
返回值类型差异
同一函数返回什么,各家并不一致。写跨管理器脚本时这条表比支持矩阵更重要。
| API | 暴力猴 | Via | Stay for Safari |
|---|---|---|---|
| GM.addStyle | Promise → <style> | Promise → <style> | Promise → 选择器字符串 |
| GM_addStyle | Promise → <style> | <style> 元素 | <style> 元素 |
| GM.addElement | Promise → <div> | Promise → <div> | 直接返回 <div> |
| GM_addElement | Promise → <div> | <div> 元素 | <div> 元素 |
| GM.xmlHttpRequest | Promise(不要 await) | undefined | {abort} 对象 |
| GM_xmlhttpRequest | {abort} 对象 | undefined | {abort} 对象 |
| GM.openInTab | {onclose, closed, close} | undefined | Promise → 标签页对象 |
| GM_openInTab | {onclose, closed, close} | undefined | undefined |
| GM_download | {abort} 对象 | 调用无回调 | undefined |
GM.* 与 GM_ 的对应关系
新脚本优先用 GM.*,同步版本只在兼容旧脚本时才有意义。
| GM.* 异步形式 | GM_ 同步形式 |
|---|---|
| GM.setValue | GM_setValue |
| GM.getValue | GM_getValue |
| GM.deleteValue | GM_deleteValue |
| GM.listValues | GM_listValues |
| GM.addStyle | GM_addStyle |
| GM.addElement | GM_addElement |
| GM.setClipboard | GM_setClipboard |
| GM.xmlHttpRequest | GM_xmlhttpRequest |
| GM.getResourceUrl | GM_getResourceURL |
| GM.info | GM_info |
| GM.openInTab | GM_openInTab |
| — | GM_download |
| — | GM_getResourceText |
MonkeyMom 子脚本桥合成清单
以下 API 不依赖管理器原生实现,由 MonkeyMom 在子脚本环境中自己提供。
| API | MonkeyMom 的实现 |
|---|---|
| GM_getResourceText | 读注入前预取的 @resource 缓存 |
| GM_getResourceURL | 把缓存文本包成页面侧 blob URL |
| GM.getResourceUrl | 缓存命中即时返回,未命中经桥回源下载 |
| GM_registerMenuCommand | 登记为面板"脚本"页的按钮,点击回调页面侧闭包 |
| GM_unregisterMenuCommand | 按命令 id 移除按钮 |
| GM_notification | 页面内 toast,支持 onclick / ondone |
明确不做:GM_webRequest、unsafeWindow、GM.closeTab、GM_audio.*、GM.cookie。
管理器之间到底差在哪
差异不是随机的,而是按功能族聚在一起,每一族有各自的坏法。
存储
| 行为 | 暴力猴 | Via | Stay for Safari | Userscripts |
|---|---|---|---|---|
基础四项(get / set / delete / list) | ✅ | ✅ | ✅ | ✅ |
批量形(setValues / getValues / deleteValues) | ✅ | ✅ | ❌ | ❌ |
GM.listValues 返回本脚本的键 | ✅ | ❌ 跨脚本共享 | ✅ | ✅ |
GM_listValues(经典同步形式) | ✅ | ✅ | ✅ | ⚠️ 返回 {} |
Via 的存储按管理器全局共享:两个脚本都用 state 这个键,就会互相覆盖,除非自己手动加命名空间。Userscripts 的分裂发生在一个管理器内部:异步的 GM.listValues 返回真实键名,而经典的 GM_listValues 返回 {}——这比没有更糟,看起来像支持。
监听器与标签页
| 行为 | 暴力猴 | Via | Stay for Safari |
|---|---|---|---|
GM_addValueChangeListener 返回 id | ✅ | ❌ undefined | ✅ |
GM_removeValueChangeListener 认这个 id | ✅ | ❌ | ✅ |
GM_saveTab / GM_getTab / GM_getTabs | ❌ | ❌ | ✅ |
拿不到 id 的监听器会永远注册着。仅这一条,就足以让桥不去依赖管理器自己的监听器 API。
样式与元素注入
| 行为 | 暴力猴 | Via | Stay for Safari | Userscripts |
|---|---|---|---|---|
GM_addStyle | ✅ | ✅ | ✅ | ✅ |
GM_addElement | ✅ | ✅ | ✅ | ❌ 缺失 |
GM.addStyle 返回 | Promise → <style> | Promise → <style> | Promise → 选择器字符串 | 未测 |
GM_addStyle 返回 | Promise → <style> | <style> 元素 | <style> 元素 | 未测 |
GM.addElement 返回 | Promise → <div> | Promise → <div> | 直接返回 <div> | 未测 |
Userscripts 干脆没有 GM_addElement。对在严格 CSP 页面上动态加载代码的脚本来说这是致命的:没有管理器帮忙,纯 DOM 路径绕不开 CSP。
网络、下载、剪贴板与标签页
| 行为 | 暴力猴 | Via | Stay for Safari |
|---|---|---|---|
GM_xmlhttpRequest | ✅ 回调式 | ✅ 回调式 | ✅ 回调式 |
GM.download | ✅ 返回 {abort} | ❌ 缺失,回调也不触发 | ✅ 返回 undefined |
GM.setClipboard | ✅ | ✅ | ✅ |
GM.openInTab 后台打开 | ✅ | ✅ | ✅ |
GM.openInTab 返回 | 带 close() 的句柄 | undefined | Promise → 标签页对象 |
菜单、通知与 Cookie
| 行为 | 暴力猴 | Via | Stay for Safari |
|---|---|---|---|
GM_registerMenuCommand | ✅ | ✅ | ✅ |
GM_unregisterMenuCommand | ✅ | ❌ | ✅ |
GM_notification 真的能弹出来 | ✅ | ✅ | ✅(Edge 需要通知权限) |
GM.cookie.set / list / delete | ✅ | ❌ | ✅ |
资源缺失时 GM_getResourceText 返回 | undefined | null | undefined |
MonkeyMom 自己实现了哪些
桥把子脚本作为注入脚本放进页面,并给它一个合成的 GM 对象。管理器做得可靠的原样透传,缺失、损坏或不一致的部分由 MonkeyMom 自己实现。
| API | 管理器那边的情况 | MonkeyMom 怎么做 |
|---|---|---|
GM_getResourceText / GM_getResourceURL | 依赖安装时下载 @resource | 保存时解析元数据,注入前用 GM.xmlHttpRequest 预取(15 秒硬顶),同步读缓存 |
GM.getResourceUrl | 同样的分裂,外加 null 与 undefined 之争 | 命中缓存即时返回,未命中经桥回源 |
GM_registerMenuCommand / GM_unregisterMenuCommand | 手机端没有菜单可点;Via 不能注销 | 渲染成面板脚本行下方的按钮;返回的 id 能真的注销 |
GM_notification | 取决于浏览器通知权限 | 页面内 toast,两种调用形态都收,支持 onclick 和 ondone |
GM_getValue / GM_setValue / GM_deleteValue / GM_listValues | 部分管理器按脚本隔离,Via 全局共享 | 按脚本 id 隔离,同步读缓存、异步落盘 |
WebKit 上的 GM_addElement | 插入的元素仍会被页面 CSP 拦 | 复用页面已有的 nonce,样式退回 GM_addStyle |
在严格 CSP 下也能注入
在 WebKit 上——Safari、Stay、以及 iOS 上的所有浏览器——扩展插入的元素并不能豁免页面 CSP。桥会读页面自己已有的 nonce,用 MutationObserver 盯着后来才出现的 nonce,并实测内联 script 与 style 会不会被拦。样式退回 GM_addStyle,这条路不受页面 CSP 管;脚本在没有 nonce 可复用时打警告。
明确不支持的
| API | 为什么不做 |
|---|---|
GM_webRequest | 子脚本本身就在主世界里运行,不需要拦截层 |
unsafeWindow | 主世界里页面的 window 就是 window |
GM.closeTab | 实测的每家管理器上都缺失或损坏 |
GM_audio.* | 实测的每家管理器上都没有实现 |
GM.cookie | 注入脚本只能碰非 HttpOnly 的 cookie,承诺完整访问等于撒谎 |
怎么写才能在每家管理器上都活下来
走最窄的那条路
优先用 GM.getValue 这类异步形式而不是同步形式;GM.xmlHttpRequest 一律用回调式,绝不读它的返回值;不需要元素句柄时用 GM_addStyle 而不是 GM.addStyle。既要 GM_addElement 的返回值又要跨管理器的话,先包一层 Promise.resolve() 再 await。
存储键保持命名空间
即便在 MonkeyMom 内部隔离是有保证的,给键加前缀也不花成本,还能让脚本移植到那些脚本间共享存储的管理器时不出事。
导出才是可移植性的验收
子脚本可以导出为标准用户脚本:头部按白名单与兼容清单的并集写 @grant,@resource 声明随脚本一起走。如果导出的文件能在篡改猴、暴力猴或 Stay 上跑起来,说明可移植性的功夫没有白费。