搭建一个快速开发油猴脚本的前端工程

2024-11-24 09:00   重庆  

点击关注公众号,“技术干货” 及时达!

版权声明:本人文章仅在掘金平台发布,请勿抄袭搬运,转载请注明作者及原文链接 🦉

阅读提示:网页版带有主题和代码高亮,阅读体验更佳 🍉

一、需求起因

最近遇到一个问题:公司自用的 bug 管理工具太老了,网页风格还是上世纪的文字页面。虽然看习惯了还好,但是某些功能确实很不方便。比如,联系人都是邮箱或者英文名,没有中文名称,在流转 bug 时还得复制粘贴英文名去企业微信里搜索对应的人名。第二是人员比较多,在一堆邮箱里很难找到对应的人......

总之,诸如此类的问题让我有了对该网页进行改造的想法。

但是这种网页都是公司创业时期拿的开源产品私有化部署,网页源码能不能找到都不好说。再者,公司也不会允许此类的“小聪明”,这并不是我的主职工作,所以修改源码是非常不现实的。

那目前的思路,就是在原网页基础上进行脚本注入,修改网页内容和样式。方案无非就是浏览器插件或者脚本注入两种。

脚本的话就是利用油猴插件的能力,写一个油猴脚本,在网页加载完成后注入我们书写的脚本,达到修改原网页的效果。

插件也是类似的原理,但是写插件要麻烦得多。

出于效率考虑,我选择了脚本的方案。这里其实也是想巩固下 jsDOM API,框架写多了,很多原生的 API 反而忘得一干二净。

二、关于油猴脚本

先看一份 demo

// ==UserScript==
// @name         script
// @namespace    http://tampermonkey.net/
// @version      0.0.1
// @description  这是一段油猴脚本
// @author       xxx
// @match        *://baidu.com/*
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function ({
  "use strict";
  const script = document.createElement("script");
  document.body.appendChild(script);
})();

油猴脚本由注释及 js 代码组成。注释需要包裹在

// ==UserScript==

// ==/UserScript==

两个闭合标签内。同时只能书写类似 @name 规定好的注释头,用于标明脚本的一些元信息。其中比较重要的是 @match@run-at

@match 规定了该脚本所运行的域名,例如,只有当我打开了百度的网页时我才运行脚本,这个 @match 可以书写多个。@run-at 则规定了脚本的运行时机,一般是网页加载开始,网页加载结束。@run-at 只声明一次。

@run-at 有以下可选值:

图片看得不清晰也没关系,这种都是用到再查。

更多注释配置请参考:油猴脚本。

而代码部分是一个立即执行函数,所有的内容都需要写在这个立即执行函数内,否则无法生效。

三、问题显现

刚开始,我并没有工程化开发的想法,我想的是就是一个脚本,直接一梭子写到底即可,反正就是那样,就是个普通的 js 文件,一切都是那么原始,朴实无华。

但是当代码来到两千多行后(我是真的很爱加东西),绷不住了,每次写代码都需要在文件上下之间反复横跳,有时候有些变量定义了都不记得,写代码还得滚动半天才能到最底下。

加东西也变得越来越臃肿,越来越丑陋。

忍无可忍,我决定对这个脚本进行工程化改造。但是工程化之前有几个问题需要解决,或者说需要调研清楚。

四、关键点分析

1.构建工具

首先肯定是打包成 iife 的产物,很多工具都支持。既然工程化了,一般大家的选择就是 webpack 或者 vite。这里因为涉及到开发模式,需要及时产出打包产物,且能够搭建 dev 服务器,方便访问本地打包后的资源,因此需要选择具备 dev 服务器的开发构建工具。

我选择 vite。当然,webpack 也是不错的选择。

如果你对实时预览要求不高,能够接受复制粘贴到油猴再刷新页面预览,也可以选择纯粹的打包器,例如 rollup

2.css 预编译器

传统的添加样式的方式,一般就是生成一个 style 标签,然后修改其 innerHTML

export const addStyle = (css: string) => {
  const style = document.createElement('style');
  style.type = 'text/css';
  style.innerHTML = css;
  document.getElementsByTagName('head')[0].appendChild(style);
}

addStyle(`
  body {
    width: 100%;
    height: 100%;
  }
`
);

这样就能实现往网页里添加自定义的样式。但是我现在不满足于书写传统的 css,我既然都工程化了,肯定要把 less 或者 scss 用上。

我的目的,就是可以新建一个例如 style.less 的文件开心地书写 less,打包时候编译一下这个 less 文件,并将其样式注入到目标 HTML 中。

但在传统模块化工程里,构建工具对 less 的支持,是直接在 HTML 中生成一个 style 标签,引入编译后的 less 产物(css)。

也就是说,我需要手动实现 lesscssjs 这个过程。

转变的步骤就是用 less 本身的编译能力,将其产物转变为一个 js 模块。

具体实现放到后面再聊。

3.实现类似热更新的效果

我们启动一个传统的 vite 工程时,我们更新了某个 js 文件或者相关文件后,工程会监听我们的文件被修改了,从而触发热更新,服务也会自动刷新,从而达到实时预览的效果。

这是因为工程会在本地启动一个开发服务器,最终产物也会实时构建,那网页每次去获取这个服务器上的资源,就会获取到最新的代码。根据这点,我们同样需要启动一个本地服务器,而这在 vite 中直接一个 vite 命令即可。

在油猴脚本中,我们新建一个 script 标签,将其 src 指向我们本地服务器的构建产物的地址,即可实现实时的脚本更新,而不用复制产物代码再粘贴到油猴。

代码如下:

// ==UserScript==
// @name         script
// @namespace    http://tampermonkey.net/
// @version      0.0.1
// @description  这是描述
// @author       xxx
// @match        *://baidu.com/*
// @run-at       document-end
// @license      MIT
// ==/UserScript==

(function ({
  "use strict";
  const script = document.createElement("script");
  script.src = "http://localhost:6419/dist/script.iife.js";
  document.body.appendChild(script);
})();

这里的 localhost:6419/dist/script.iife.js 都取决于你 vite.config.js 中的配置。

具体后面再聊。

五、开始搭建工程

「1.使用 yarn create vite 或者 pnpm create vite 初始化一个 vite 模板工程」

其他的你自己看着选就可以。

「2.修改 vite.config.js

/**
 * @type {import('vite').UserConfig}
 */

module.exports = {
  server: {
    host'localhost',
    port6419,
  },
  build: {
    minifyfalse,
    outDir'dist',
    lib: {
      entry'src/main.ts',
      name'script',
      fileName'script',
      formats: ['iife'],
    },
  },
  resolve: {
    alias: {
      '@''/src',
      '@utils''/src/utils',
      '@enum''/src/enum',
      '@const''/src/const',
      '@style''/src/style',
    }
  }
}

这里使用 cjs 是因为我们会实现一些脚本,脚本里可能会用到这里的某些配置,所以使用 cjs 导出也有利于外部的使用。

「3.创建一个 tampermonkey.config 文件,将油猴注释放在这里」

// ==UserScript==
// @name         script
// @namespace    http://tampermonkey.net/
// @version      0.0.1
// @description  这是描述
// @author       xxx
// @match        *://baidu.com/*
// @run-at       document-end
// @license      MIT
// ==/UserScript==

当然,你要觉得这样多余、没必要,也可以看自己喜好,只要最终产物里有这个注释即可。但是拆出来有利于我们维护,后续也会新增脚本,有利于工程化的整体性和可维护性。

「4.使用 nodemon 监听文件修改」

因为我们自己对 less 有特殊处理,加上未来可能会对需要监听的文件进行精细化管理,所以这里引入 nodemon,如果你自己对工程化有自己的理解,也可以按照自己的理解配置。

执行 pnpm i nodemon -D

根目录新增 nodemon.json

{
  "ext""ts,less",
  "watch": ["src"],
  "exec""pnpm dev:build && vite"
}

这里的 pnpm dev:build 还另有玄机,后面再展开。

到这里,我们的工程雏形已经具备了。但是还有一个最关键的点没有解决——那就是 less 的转换。

六、less 的转换以及几个脚本

首先,less 代码需要编译为 css,但是我们需要的是 css 的字符串,这样才能通过 innerHTML 之类的方法注入到网页中。

使用 less.render 方法可以对 less 代码进行编译,其是一个 Promise,我们可以在 then 中接收编译后的产物。

我们可以直接在根目录新建一个 script 文件夹,在 script 文件夹下新建一个 gen-style-string.js 的脚本:

const less = require('less');
const fs = require('fs');
const path = require('path');

const styleContent = fs.readFileSync(path.resolve(__dirname, '../src/style.less'), 'utf-8');

less.render(styleContent).then(output => {
  if(output.css) {
    const code = `export default \`\n${output.css}\``;

    const relativePath = '../style/index.ts';
    const filePath = path.resolve(__dirname, relativePath)

    if(fs.existsSync(filePath)) {
      fs.rm(filePath, () => {
        fs.writeFileSync(path.resolve(__dirname, relativePath), code)
      })
    } else {
      fs.writeFileSync(path.resolve(__dirname, relativePath), code)
    }
  }
})

我们将编译后的 css 代码结合 js 代码导出为一个模块,供外部使用。也就是说,这部分编译必须在打包之前执行,这样才能得到正常的 js 模块,否则就会报错。

这段脚本执行完后会在 style/index.ts 中生成类似代码:

export default `
  body {
    width: 100%;
    height: 100%;
  }
`

这样 less 代码就能够被外部引入并使用了。

这里多说一句,因为 style/index.ts 的内容是根据 less 编译来的,而我们的 nodemon 会监听 src 目录,因此这个 less 编译后的 js 产物,不能放在 src 下,因为假设将它放在 src 目录下,它在写入的过程中也会触发 nodemon,会导致 nodemon 进入死循环。

除此之外,我们之前还将油猴注释拎出来单独放在一个文件里:tampermonkey.config

在最终产物中,我们需要将其合并进去,思路同上:

const fs = require('fs');
const path = require('path');
const prettier = require('prettier');

const codeFilePath = '../dist/script.iife.js';
const configFilePath = '../tampermonkey.config';
const codeContent = fs.readFileSync(path.resolve(__dirname, codeFilePath), 'utf-8');
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, configFilePath), 'utf-8');

if (codeContent) {
  const code = `${tampermonkeyConfig}\n${codeContent}`;
  prettier.format(code, { parser'babel' }).then((formatted) => {
    fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted)
  })
}

最后,因为我们的 tampermonkey.config 以及 vite.config.js 可能会更改配置,所以每次我们在开发模式时生成的临时油猴脚本,也需要变,我们不可能每次都去修改,而是应该跟随上面两个配置文件进行生成,我们再新建一个脚本:

const fs = require('fs');
const path = require('path');
const prettier = require('prettier');
const viteConfig = require('../vite.config');

const codeFilePath = '../tampermonkey.js';
const tampermonkeyConfig = fs.readFileSync(path.resolve(__dirname, '../tampermonkey.config'), 'utf-8');
const hostPort = `${viteConfig.server.host}:${viteConfig.server.port}`;
const codeContent = `
  (function () {
    'use strict'

    const script = document.createElement('script');

    script.src = 'http://${hostPort}/dist/${viteConfig.build.lib.name}.iife.js';

    document.body.appendChild(script);
  })()
`
;

const code = `${tampermonkeyConfig}\n${codeContent}`;

prettier.format(code, { parser'babel' }).then((formatted) => {
  if(fs.existsSync(path.resolve(__dirname, codeFilePath))) {
    fs.rm(path.resolve(__dirname, codeFilePath), () => {
      fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
    });
  }
  else {
    fs.writeFileSync(path.resolve(__dirname, codeFilePath), formatted);
  }
})

稍微用 prettier 美化一下。

七、完善 package.json 中的 script

我们其实只有开发模式,新建一个命令:

"dev": "node script/gen-tampermonkey.js && nodemon"

优先生成 tampermonkey.js,这时候会启动服务器,记得先将 tampermonkey.js 中的内容拷贝到油猴,才能方便热更新,不然又需要复制粘贴。

对于 build 命令:

"dev:build": "node script/gen-style-string.js && tsc && vite build && node script/gen-script-header-comment.js"

需要先将 less 编译为可用的 js 字符串模块,然后才能执行 buildbuild 完还需要拼接油猴注释,这样最终产物才具备可用的能力。

开发完成后,就将打包产物替换掉之前粘贴进油猴的内容。

八、额外的补充

vite 命令会直接启动本地开发服务器,而我们的 script 命令中,使用 && 时,下一个命令会等待上一个命令执行完成后再执行,所以 vite 需要放在最后执行,这是串行逻辑。当然,借助一些库我们可以实现并行 script 命令。但是我们这里需要的是串行,只是不完美的是,每次文件变更,都需要重新执行 pnpm dev:build && vite,这样会重复新启一个服务器,但是不重启的话,始终使用最初的那个服务,最新编译的资源无法被油猴感知,资源没有得到更新。

所以,聪明的你有办法解决吗?

点击关注公众号,“技术干货” 及时达!

稀土掘金技术社区
掘金,一个帮助开发者成长的技术社区
 最新文章