搭建一个快速开发油猴脚本的前端工程
一、需求起因
最近遇到一个问题:公司自用的 bug 管理工具太老了,网页风格还是上世纪的文字页面。虽然看习惯了还好,但是某些功能确实很不方便。比如,联系人都是邮箱或者英文名,没有中文名称,在流转 bug 时还得复制粘贴英文名去企业微信里搜索对应的人名。第二是人员比较多,在一堆邮箱里很难找到对应的人......
总之,诸如此类的问题让我有了对该网页进行改造的想法。
但是这种网页都是公司创业时期拿的开源产品私有化部署,网页源码能不能找到都不好说。再者,公司也不会允许此类的“小聪明”,这并不是我的主职工作,所以修改源码是非常不现实的。
那目前的思路,就是在原网页基础上进行脚本注入,修改网页内容和样式。方案无非就是浏览器插件或者脚本注入两种。
脚本的话就是利用油猴插件
的能力,写一个油猴脚本,在网页加载完成后注入我们书写的脚本,达到修改原网页的效果。
插件也是类似的原理,但是写插件要麻烦得多。
出于效率考虑,我选择了脚本的方案。这里其实也是想巩固下 js
的 DOM API
,框架写多了,很多原生的 API
反而忘得一干二净。
二、关于油猴脚本
先看一份 demo
:
// ==UserScript==
// @name script
// @namespace
// @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
)。
也就是说,我需要手动实现 less
到 css
到 js
这个过程。
转变的步骤就是用 less
本身的编译能力,将其产物转变为一个 js
模块。
具体实现放到后面再聊。
3.实现类似热更新的效果
我们启动一个传统的 vite
工程时,我们更新了某个 js
文件或者相关文件后,工程会监听我们的文件被修改了,从而触发热更新,服务也会自动刷新,从而达到实时预览的效果。
这是因为工程会在本地启动一个开发服务器,最终产物也会实时构建,那网页每次去获取这个服务器上的资源,就会获取到最新的代码。根据这点,我们同样需要启动一个本地服务器,而这在 vite
中直接一个 vite
命令即可。
在油猴脚本中,我们新建一个 script
标签,将其 src
指向我们本地服务器的构建产物的地址,即可实现实时的脚本更新,而不用复制产物代码再粘贴到油猴。
代码如下:
// ==UserScript==
// @name script
// @namespace
// @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',
port: 6419,
},
build: {
minify: false,
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
字符串模块,然后才能执行 build
,build
完还需要拼接油猴注释,这样最终产物才具备可用的能力。
开发完成后,就将打包产物替换掉之前粘贴进油猴的内容。
八、额外的补充
vite
命令会直接启动本地开发服务器,而我们的 script
命令中,使用 &&
时,下一个命令会等待上一个命令执行完成后再执行,所以 vite
需要放在最后执行,这是串行逻辑。当然,借助一些库我们可以实现并行 script
命令。但是我们这里需要的是串行,只是不完美的是,每次文件变更,都需要重新执行 pnpm dev:build && vite
,这样会重复新启一个服务器,但是不重启的话,始终使用最初的那个服务,最新编译的资源无法被油猴感知,资源没有得到更新。
所以,聪明的你有办法解决吗?
来源:juejin.cn/post/7437887483259584522