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