油猴(Tampermonkey)是一款可以在浏览器页面中注入 JavaScript 代码的浏览器插件,允许用户自定义脚本或者从商店中下载其他人发布的脚本,在一个插件中又开放了一个插件系统。

一般来说添加一个新的脚本的方式有两种,使用内置的编辑器编写原生 JavaScript 代码、从远程商店搜索下载

image-20230327214525105

如果打开新建脚本,会进入到如下页面,一般来说,简单的脚本使用这种方式是可以的

image-20230327214611661

但是如果是比较复杂的脚本,使用这个编辑器来编写是很麻烦的,而且不符合如今前端工程化的大趋势,所以这不是我们今天的重点。

前置知识

在编写脚本之前我们需要知道油猴脚本的一些声明语法及 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

初始化过程如下,可以根据自己的喜好选择技术栈

image-20230327215633458

初始化完成根据指引启动开发服务器,打开服务地址即可体验安装脚本。

image-20230327215756757

返回的文件内容就是一个油猴脚本,油猴插件识别到这个文件之后就会询问用户是否安装。

配置

使用 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 切换路由之后注入内容丢失

最终的效果如下

image-20230329174325023

最终如果需要发布的话,执行pnpm run build后将构建产物发布到脚本托管平台即可。


前端小白