esbuild 構建油猴指令碼

邊城發表於2022-06-27

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

在使用油猴之前,使用過一個叫“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 引數,但有兩個問題:

  1. 指令碼頭太長,還是多行,用 --banner 引數也不好加;
  2. 如果需要同時轉譯多個指令碼,沒辦法動態地為每個指令碼修改 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

相關文章