前段时间思否十周年,搞了个问答打卡活动。参与打卡活动的人需要在回答问题的结尾加一个“小尾巴”。加小尾巴本身并不难,但是由于官方没有提供快捷方式,每次都需要自己从某个地方拷贝过去,稍嫌繁琐。正好前不久刚装了油猴插件,就想:自己给编辑器注入一个按钮用来添加小尾巴如何?
在使用油猴之前,使用过一个叫“User JavaScript and CSS”的插件,可以对特定的网页注入脚本和样式。不过这个插件在 Edge 市场中没有,只能从 Chrome 市场安装,安装有点困难。后来又去 Edge 市场中找到一个“Page Manipulator”也能实现类似的功能。之所以一直没用油猴,主要是油猴要注入样式表得自己写代码,懒得写。
注入小尾巴的脚本不难,也不是本文的重点。重点是脚本分享出去之后,收到一些“脚本不可用”的反馈。虽然说浏览几乎都是用的 Chrome/Edge 或者 Chrome 核心的浏览器,但毕竟版本存在差异,有些版本还不支持 ??
、 ?.
和 ??=
等。
说起来,改一下运算符并不难,毕竟没有这些新运算符的时候,JavaScript 程序还不是一样的写。不过有新语法不能用是真的难受。如果仍然想用新语法,又想兼容更多浏览器,那就只有“编译”这个办法了。
Webpack 有点重,为这几行脚本建个工程,引入 Webpack 不太值得。想起之前听说过的轻量快速的 esbuild,决定试试。
果然,一行命令搞定:
npx esbuild src/add-tail.js --outfile=dist/add-tail.js --target=chrome77
?.
和 ??
都被翻译成了跟 null
进行比较,虽然是用的 ==
而不是 ===
,但是这个结果还算满意。毕竟如果用 ===
还需要跟 undefined
进行对比。
甚至,如果加上了 --bundle
参数,还可以对源文件进行拆分,使用 ESM 来分块编写代码,解耦和复用也不耽误了。
正准备完美收工,突然就发现了问题 —— 用注释写的脚本头信息不见了!虽然可以找个地方保存头信息,再手工补到转译结果之前,但是这样做累啊!在网上转悠了半天,确实没找到什么解决方案。
esbuild 虽然提供了 --banner
参数,但有两个问题:
- 脚本头太长,还是多行,用
--banner
参数也不好加; - 如果需要同时转译多个脚本,没办法动态地为每个脚本修改 banner。
思来想去,只有利用 esbuild 的 API 接口,写段程序来转译,并在转译之后用程序把脚本头补进去。程序写在 build.js
中,基本的转译过程无非就是把命令行参数改为函数调用,倒也简单
const result = await build({
logLevel: "info",
outdir: distDir,
entryPoints,
bundle: true,
target: ["chrome77"],
metafile: true,
}).catch(() => process.exit(1));
const analyzeResult = await analyzeMetafile(result.metafile);
console.log(analyzeResult);
其中, distDir
配置为 "dist"
目录。而 entryPoints
则是用 Node 的 fs 接口在 "src"
目录下找出来的第一层脚本文件,有多少算多少,不找子目录(这样就可以把拆分的子模块放在子目录中去):
const srcDir = path.resolve("./src");
const distDir = path.resolve("./dist");
const entryNames = (await fs.readdir(srcDir, { withFileTypes: true }))
.filter(entry => entry.isFile() && /\.js$/.test(entry.name))
.map(({ name }) => name);
const entryPoints = entryNames.map(filename => path.resolve(srcDir, filename));
只有输出分析结果这里费了点脑筋,命令行下是一个参数,这里需要调用另一个接口。
处理脚本头的思路很清晰:在 build()
之前,可以先读取源文件,把脚本头提取出来。在 build()
之后,读取输出文件,把脚本头加进去重新保存一次。
查了一下 esbuild 的文档,发现可以用它的插件机制来实现。在插件 onLoad
事件中需要读一次文件,在这里读了就不需要构建之前多读一次了。而 onEnd
事件中可以先判断构建过程是否出错,在没出错的情况下注入脚本头就好。
const plugin = {
name: "sf-script-plugin",
setup(build) {
build.headers = {};
build.onLoad({ filter: /src[\\/][^/\\]+\.js$/ }, async (args) => {
const contents = await fs.readFile(args.path, "utf8");
build.headers[path.relative(srcDir, args.path)] = extractHeaders(contents);
return { contents };
});
build.onEnd(result => {
if (result.errors.length) { return; }
Object.entries(build.headers)
.forEach(([filename, header]) => insertHeader(filename, header));
});
}
};
function extractHeaders(contents) {
return contents.match(/^.*?\/\/ ==\/UserScript==/s)?.[0];
}
async function insertHeader(filename, header) {
const filePath = path.resolve(distDir, filename);
const content = await fs.readFile(filePath, "utf8");
fs.writeFile(filePath, [header, content].join("\n\n"));
}
当然,build 过程不要忘了加 plugins
参数
await build({
...
plugins: [plugin],
}
在写 onLoad
的时候踩了点坑,主要就是 filter
要把 src
目录下的所有 .js
包含在内,但要排除掉所有子目录下的文件。
代码完成,尝试了一下,完美!
node ./build.js
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。