前段時間思否十週年,搞了個問答打卡活動。參與打卡活動的人需要在回答問題的結尾加一個“小尾巴”。加小尾巴本身並不難,但是由於官方沒有提供快捷方式,每次都需要自己從某個地方拷貝過去,稍嫌繁瑣。正好前不久剛裝了油猴插件,就想:自己給編輯器注入一個按鈕用來添加小尾巴如何?
在使用油猴之前,使用過一個叫“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