油猴(Tampermonkey)是一款可以在浏览器页面中注入 JavaScript 代码的浏览器插件,允许用户自定义脚本或者从商店中下载其他人发布的脚本,在一个插件中又开放了一个插件系统。
一般来说添加一个新的脚本的方式有两种,使用内置的编辑器编写原生 JavaScript 代码、从远程商店搜索下载
如果打开新建脚本,会进入到如下页面,一般来说,简单的脚本使用这种方式是可以的
但是如果是比较复杂的脚本,使用这个编辑器来编写是很麻烦的,而且不符合如今前端工程化的大趋势,所以这不是我们今天的重点。
前置知识
在编写脚本之前我们需要知道油猴脚本的一些声明语法及 API等,不论是哪一种编写脚本的方式,这都将是非常重要的。
声明
声明块的作用包括插件信息描述(作者、描述、匹配网站等)、依赖引入、调用接口声明等
具体的声明项如下
标识 | 功能 |
---|---|
@name | 脚本名称 |
@namespace | 命名空间,一般填写作者名即可 |
@copyright | 版权声明 |
@version | 版本号,用于油猴插件检测脚本更新 |
@description | 脚本描述,简述脚本的功能,可以帮助使用者更好的搜索脚本 |
@icon, @iconURL, @defaulticon | 小分辨率脚本图标 |
@icon64, @icon64URL | 64*64 的图标 |
@grant | 用到的油猴接口声明,只有在此处声明的 API 可以使用 |
@author | 作者 |
@homepage, @homepageURL, @website, @source | 作者主页或者脚本主页(官网) |
@antifeature | 声明获利方式,可选值ads、tracking、miner |
@require | 可以注入脚本供当前脚本使用 |
@resource | 预加载资源 |
@include | 用于匹配脚本生效的网站(包含) |
@match | 用于匹配脚本生效的网站(匹配) |
@exclude | 不会生效的网站,include 和 match 中匹配成功,exclude 中声明了也不会生效 |
@run-at | 脚本执行的时机 document-start、document-body、document-end、document-idle、context-menu |
@sandbox | 声明脚本需要在沙箱环境中运行,声明后油猴会在执行当前脚本之前创建一个沙箱 |
@connect | 声明脚本需要访问的跨域资源,例如GM_xmlhttpRequest 发起的请求 |
@noframes | 不允许在 iframe 上运行 |
@updateURL | 更新地址 |
@downloadURL | 下载地址 |
@supportURL | 支持地址,类似 issues |
@webRequest | 请求拦截 |
@unwrap | 将不带有沙盒和包装器的脚本注入页面 |
接口
看文档吧,比较详细,都有示例
工程化开发油猴脚本
通过前面的知识已经了解到,一个油猴脚本的头部需要有注释块声明,我们需要在构建产物的文件头部添加这段声明。
这里选择了相对成熟的一个vite插件——vite-plugin-monkey
。插件还提供了一个初始化的脚手架,可以快速初始化一个插件开发环境。
$ pnpm create monkey
初始化过程如下,可以根据自己的喜好选择技术栈
初始化完成根据指引启动开发服务器,打开服务地址即可体验安装脚本。
返回的文件内容就是一个油猴脚本,油猴插件识别到这个文件之后就会询问用户是否安装。
配置
使用 monkey 脚手架初始化之后会有一个默认的 vite 配置文件
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import monkey, { cdn } from 'vite-plugin-monkey';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
react(),
monkey({
entry: 'src/main.tsx',
userscript: {
icon: 'https://vitejs.dev/logo.svg',
namespace: 'npm/vite-plugin-monkey',
match: ['https://www.google.com/'],
},
build: {
externalGlobals: {
react: cdn.jsdelivr('React', 'umd/react.production.min.js'),
'react-dom': cdn.jsdelivr(
'ReactDOM',
'umd/react-dom.production.min.js',
),
},
},
}),
],
});
monkey 插件就是负责打通 vite 和油猴脚本的桥梁,必须要放在所有插件的最后。其中最常用的配置有三个部分,插件程序的主入口、userscript声明以及编译配置。
其完整的配置声明如下
export interface MonkeyOption {
/**
* 脚本文件的入口路径
*/
entry: string;
userscript: MonkeyUserScript;
format?: Format;
server?: {
/**
* 当 第一次启动 或 脚本配置注释改变时 自动在默认浏览器打开脚本
* @default true
*/
open?: boolean;
/**
* 开发阶段的脚本名字前缀,用以在脚本安装列表里区分构建好的脚本
* @default 'dev:'
*/
prefix?: string | ((name: string) => string);
};
build?: {
/**
* 打包构建的脚本文件名字 应该以 '.user.js' 结尾
* @default (package.json.name||'monkey')+'.user.js'
*/
fileName?: string;
/**
* @example
* {
* vue:'Vue',
* // 你需要额外设置脚本配置 userscript.require = ['https://unpkg.com/vue@3.0.0/dist/vue.global.js']
* vuex:['Vuex', 'https://unpkg.com/vuex@4.0.0/dist/vuex.global.js'],
* // 插件将会自动注入 cdn 链接到 userscript.require
* vuex:['Vuex', (version)=>`https://unpkg.com/vuex@${version}/dist/vuex.global.js`],
* // 相比之前的,加了版本号,当依赖升级的时候,cdn 链接自动改变
* vuex:['Vuex', (version, name)=>`https://unpkg.com/${name}@${version}/dist/vuex.global.js`],
* // 还可以加依赖名字,不过各个依赖的 cdn basename 都不尽一致, 导致可能没什么用
* }
*
*/
externalGlobals?: Record<
string,
string | [string, string | ((version: string, name: string) => string)]
>;
/**
* 自动识别代码里用到的 浏览器插件api,然后自动配置 GM_* 或 GM.* 函数到脚本配置注释头
*
* 识别依据是判断代码文本里有没有特定的函数名字
* @default true
*/
autoGrant?: boolean;
};
}
其中MonkeyUserScript
是一个联合类型,合并了不同的脚本注入插件的配置,这一部分根据需要进行编写即可
/**
* UserScript, merge metadata from Greasemonkey, Tampermonkey, Violentmonkey, Greasyfork
*/
type MonkeyUserScript = GreasemonkeyUserScript & TampermonkeyUserScript & ViolentmonkeyUserScript & GreasyforkUserScript & MergemonkeyUserScript;
实战
由于掘金的专栏隐藏较深,需要点开个人主页,然后在关注中选择关注的专栏,所以我想在首页加入一个跳转专栏的按钮(用户有需求自己实现)。
这种需求使用油猴来实现是最合适不过的,这点需求直接在油猴编辑器中完全可以完成,但是,谁知道我以后会不会有其他奇奇怪怪的想法呢,干脆直接上最佳实践。
// main.ts
import { monkeyWindow } from '$';
import { insertSpecialColumn } from './plugins/specialColumn';
insertSpecialColumn()
// @ts-ignore
monkeyWindow.onurlchange = insertSpecialColumn
// plugins/specialColumn
import { createSVG } from "../utils/createSVGElement";
export function insertSpecialColumn() {
// 获取 UID
let uid = JSON.parse(localStorage.getItem(`user_first_visit_dispatch_coupon`) || '');
if (uid) {
const warp = document.querySelector('.side-navigator-wrap')
if (warp) {
const tabs = warp?.childNodes;
// 克隆最后一个 tab
const lastNode = tabs[tabs.length - 1];
const cloneNode = lastNode.cloneNode(true);
// 移除选中样式
const sonNode = cloneNode.firstChild as HTMLDivElement;
if (sonNode.classList.length > 1) {
sonNode.classList.remove('active-nav');
}
// 修改跳转地址
const aTag = sonNode?.firstChild as HTMLAnchorElement;
aTag.href = `/user/${uid}/column_followed`;
if (aTag.childElementCount > 1) {
const svg = createSVG({
width: 16,
height: 16,
fill: 'none',
paths: [{
d: 'M 14.2295 13.4775 c 0 0.494 -0.5677 1.0617 -1.128 1.2018 L 9.9901 13.1973 L 7.3728 14.6055 l -2.6173 -1.4156 L 1.6441 14.7456 C 1.0838 14.6055 0.5161 14.0378 0.5161 13.5438 V 1.2018 C 0.5161 0.494 1.0101 0 1.6441 0 h 11.5237 c 0.5677 0 1.0617 0.494 1.0617 1.2018 v 12.2757 z m -3.1187 -6.4143 H 3.6348 c -0.4202 0 -0.7078 0.2802 -0.7078 0.7078 c 0 0.4202 0.2802 0.7078 0.7078 0.7078 h 7.4834 c 0.4202 0 0.7078 -0.2802 0.7078 -0.7078 s -0.2875 -0.7078 -0.7152 -0.7078 z m 0 -4.2394 H 3.6348 c -0.4202 0 -0.7078 0.2802 -0.7078 0.7078 s 0.2802 0.7078 0.7078 0.7078 h 7.4834 c 0.4202 0 0.7078 -0.2802 0.7078 -0.7078 s -0.2875 -0.7078 -0.7152 -0.7078 z',
'p-id': '3170',
fill: 'currentColor',
}]
})
aTag.firstChild?.replaceWith(svg);
(aTag.lastChild as HTMLSpanElement).innerText = ' 关注专栏 ';
}
warp?.appendChild(cloneNode)
} else {
console.log('不在首页');
}
} else {
console.log('未登录');
}
}
// utils/createSVGElement
interface IOptions {
width: number;
height: number;
fill: string;
paths: Record<string, string>[];
}
export function createSVG(options: IOptions) {
const {width, height, fill, paths} = options
const svgElement = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svgElement.setAttribute('version', '1.1');
svgElement.setAttribute('width', `${width}`);
svgElement.setAttribute('height', `${height}`);
svgElement.setAttribute('viewBox', `0 0 ${width} ${height}`);
svgElement.setAttribute('fill', fill);
paths.forEach((path) => {
const pathElement = document.createElementNS("http://www.w3.org/2000/svg", "path");
Object.entries(path).forEach(([key, value]) => {
pathElement.setAttribute(key, value);
})
svgElement.appendChild(pathElement);
})
return svgElement;
}
主要做的事有:
- 获取用户 uid,因为专栏在个人中心,跳转时需要在路径中拼接,这个 uid 保存在 localStorage 中,key 为 user_first_visit_dispatch_coupon
- 在首页左侧 tabs 中 clone 一个,将其内容修改为跳转专栏
- 将新创建的元素添加到 tabs 中
- 通过onurlchange 监听 url 变化来重新执行注入操作,防止 SPA 切换路由之后注入内容丢失
最终的效果如下
最终如果需要发布的话,执行pnpm run build
后将构建产物发布到脚本托管平台即可。