博客 / 詳情

返回

esbuild 構建油猴腳本

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

在使用油猴之前,使用過一個叫“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
user avatar lanlanjintianhenhappy 頭像 ziyeliufeng 頭像 shaochuancs 頭像 zhangxishuo 頭像 79px 頭像 qianduanlangzi_5881b7a7d77f0 頭像
6 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.