從零實現的瀏覽器Web指令碼

2023-11-03 21:02:42

從零實現的瀏覽器Web指令碼

在之前我們介紹了從零實現Chrome擴充套件,而實際上瀏覽器級別的擴充套件整體架構非常複雜,儘管當前有統一規範但不同瀏覽器的具體實現不盡相同,並且成為開發者並上架Chrome應用商店需要支付5$的註冊費,如果我們只是希望在Web頁面中進行一些輕量級的指令碼編寫,使用瀏覽器擴充套件級別的能力會顯得成本略高,所以在本文我們主要探討瀏覽器Web級別的輕量級指令碼實現。

描述

在前邊的從零實現Chrome擴充套件中,我們使用了TS完成了整個擴充套件的實現,並且使用Rspack作為打包工具來構建應用,那麼雖然我們實現輕量級指令碼是完全可以直接使用JS實現的,但是畢竟隨著指令碼的能力擴充套件會變得越來越難以維護,所以同樣的在這裡我們依舊使用TS來構建指令碼,並且在構建工具上我們可以選擇使用Rollup來打包指令碼,本文涉及的相關的實現可以參考個人實現的指令碼集合https://github.com/WindrunnerMax/TKScript

當然瀏覽器是不支援我們直接編寫Web級別指令碼的,所以我們需要一個執行指令碼的基準環境,當前有很多開源的指令碼管理器:

  • GreaseMonkey: 俗稱油猴,最早的使用者指令碼管理器,為Firefox提供擴充套件能力,採用MIT license協定。
  • TamperMonkey: 俗稱篡改猴,最受歡迎的使用者指令碼管理器,能夠為當前主流瀏覽器提供擴充套件能力,開源版本採用GPL-3.0 license協定。
  • ViolentMonkey: 俗稱暴力猴,完全開源的使用者指令碼管理器,同樣能夠為當前主流瀏覽器提供擴充套件能力,採用MIT license協定。
  • ScriptCat: 俗稱指令碼貓,完全開源的使用者指令碼管理器,同樣能夠為當前主流瀏覽器提供擴充套件能力,採用 GPL-3.0 license協定。

此外還有很多指令碼集合網站,可以用來分享指令碼,例如GreasyFork。在之前我們提到過,在研究瀏覽器擴充套件能力之後,可以發現擴充套件的許可權實在是太高了,那麼同樣的指令碼管理器實際上也是通過瀏覽器擴充套件來實現的,選擇可信的瀏覽器擴充套件也是很重要的,例如在上邊提到的TamperMonkey在早期的版本是開源的,但是在18年之後倉庫就不再繼續更新了,也就是說當前的TamperMonkey實際上是一個閉源的擴充套件,雖然上架谷歌擴充套件是會有一定的稽核,但是畢竟是閉源的,開源對於類似使用者指令碼管理器這類高階使用者工具來說是一個建立信任的訊號,所以在選擇管理器時也是需要參考的。

指令碼管理器實際上依然是基於瀏覽器擴充套件來實現的,通過封裝瀏覽器擴充套件的能力,將部分能力以API的形式暴露出來,並且提供給使用者指令碼許可權來應用這些API能力,實際上這其中涉及到很多非常有意思的實現,例如指令碼中可以存取的windowunsafeWindow,那麼如何實現一個完全隔離的window沙箱環境就值的探索,再比如在Web頁面中是無法跨域存取資源的,如何實現在Inject Script中跨域存取資源的CustomEvent通訊機制也可以研究一下,以及如何使用createElementNSHTML級別實現Runtime以及Script注入、指令碼程式碼組裝後//# sourceURL的作用等等,所以如果有興趣的同學可以研究下ScriptCat,這是國內的同學開發的指令碼管理器,註釋都是中文會比較容易閱讀。那麼本文還是主要關注於應用,我們從最基本的UserScript指令碼相關能力,到使用Rollup來構建指令碼,再通過範例來探索指令碼的實現來展開本文的討論。

UserScript

在最初GreaseMonkey油猴實現指令碼管理器時,是以UserScript作為指令碼的MetaData也就是後設資料塊描述,並且還以GM.開頭提供了諸多高階的API使用,例如可跨域的GM.xmlHttpRequest,實際上相當於實現了一整套規範,而後期開發的指令碼管理器大都會遵循或者相容這套規範,以便複用相關的生態。其實對於開發者來說這也是個麻煩事,因為我們沒有辦法控制使用者安裝的瀏覽器擴充套件,而我們的指令碼如果用到了某一個擴充套件單獨實現的API,那麼就會導致指令碼在其他擴充套件中無法使用,特別是將指令碼放在指令碼平臺上之後,沒有辦法構建渠道包去分發,所以平時還是儘量使用各大擴充套件都支援的MetaAPI來開發,避免不必要的麻煩。

此外在很久之前我一直好奇在GreasyFork上是如何實現使用者指令碼的安裝的,因為實際上我並沒有在那個安裝指令碼的按鈕之後發現什麼特殊的事件處理,以及如何檢測到當前已經安裝指令碼管理器並且實現通訊的,之後簡單研究了下發現實際上只要使用者指令碼是以.user.js結尾的檔案,就會自動觸發指令碼管理器的指令碼安裝功能,並且能夠自動記錄指令碼安裝來源,以便在開啟瀏覽器時檢查指令碼更新,同樣的後期這些指令碼管理器依然會遵循這套規範,既然我們瞭解到了指令碼的安裝原理,在後邊範例一節中我會介紹下我個人進行指令碼分發的最佳實踐。那麼在本節,我們主要介紹常見的Meta以及API的使用,一個指令碼的整體概覽可以參考https://github.com/WindrunnerMax/TKScript/blob/gh-pages/copy-currency.user.js

Meta

後設資料是以固定的格式存在的,主要目的是便於指令碼管理器能夠解析相關屬性比如名字和匹配的站點等,每一條屬性必須使用雙斜槓//開頭,不得使用塊註釋/* */,與此同時,所有的指令碼後設資料必須放置於// ==UserScript==// ==/UserScript==之間才會被認定為有效的後設資料,即必須按照以下格式填寫:

// ==UserScript==
// @屬性名 屬性值
// ==/UserScript==

常用的屬性如下所示:

  • @name: 指令碼的名字,在@namespace級別的指令碼的唯一識別符號,可以設定語言,例如// @name:zh-CN 文字選中複製(通用)
  • @author: 指令碼的作者,例如// @author Czy
  • @license: 指令碼的許可證,例如// @license MIT License
  • @description: 指令碼功能的描述,在安裝指令碼時會在管理對話方塊中呈現給使用者,同樣可以設定語言,例如// @description:zh-CN 通用版本的網站複製能力支援
  • @namespace: 指令碼的名稱空間,用於區分指令碼的唯一識別符號,例如// @namespace https://github.com/WindrunnerMax/TKScript
  • @version: 指令碼的版本號,指令碼管理器啟動時通常會對比改欄位決定是否下載更新,例如// @version 1.1.2
  • @updateURL: 檢查更新地址,在檢查更新時會首先存取該地址,來對比@version欄位來決定是否更新,該地址應只包含後設資料而不包含指令碼內容。
  • @downloadURL: 指令碼更新地址(https協定),在檢查@updateURL後需要更新時,則會請求改地址獲取最新的指令碼,若未指定該欄位則使用安裝指令碼地址。
  • @include: 可以使用*表示任意字元,支援標準正規表示式物件,指令碼中可以有任意數量的@include規則,例如// @include http://www.example.org/*.bar
  • @exclude: 可以使用*表示任意字元,支援標準正規表示式物件,同樣支援任意數量的規則且@exclude的匹配許可權比@include要高,例如// @exclude /^https?://www\.example\.com/.*$/
  • @match: 更加嚴格的匹配模式,根據ChromeMatch Patterns規則來匹配,例如// @match *://*.google.com/foo*bar
  • @icon: 指令碼管理介面顯示的圖示,幾乎任何影象都可以使用,但32x32畫素大小是最合適的資源大小。
  • @resource: 在安裝指令碼時,每個@resource都會下載一次,並與指令碼一起儲存在使用者的硬碟上,這些資源可以分別通過GM_getResourceTextGM_getResourceURL存取,例如// @resource name https://s3.ap-northeast-1.wasabisys.com/img.tw511.com/202311/xxx5xjktnszlcj.png
  • @require: 指令碼所依賴的其他指令碼,通常為可以提供全域性物件的庫,例如參照jQuery則使用// @require https://cdn.staticfile.org/jquery/3.7.1/jquery.min.js
  • @run-at: 用於指定指令碼執行的時機,可用的引數只能為document-start頁面載入前、document-end頁面載入後資源載入前、document-idle頁面與資源載入後,預設值為document-end
  • @noframes: 當存在時,該命令會限制指令碼的執行。該指令碼將僅在頂級檔案中執行,而不會在巢狀框架中執行,不需要任何引數,預設情況下此功能處於關閉狀態即允許指令碼在iframe中執行。
  • @grant: 指令碼所需要的許可權,例如unsafeWindowGM.setValueGM.xmlHttpRequest等,如果沒有指定@grant則預設為none,即不需要任何許可權。

API

API是指令碼管理器提供用來增強指令碼功能的物件,通過這些指令碼我們可以實現針對於Web頁面更加高階的能力,例如跨域請求、修改頁面佈局、資料儲存、通知能力、剪貼簿等等,甚至於在Beta版的TamperMonkey中,還有著允許使用者指令碼讀寫HTTP OnlyCookie的能力。同樣的,使用API也有著固定的格式,在使用之前必須要在Meta中宣告相關的許可權,以便指令碼將相關函數動態注入,否則會導致指令碼無法正常執行,此外還需要注意的是相關函數的命名可能不同,在使用時還需要參考相關檔案。

// ==UserScript==
// @grant unsafeWindow
// ==/UserScript==
  • GM.info: 獲取當前指令碼的後設資料以及指令碼管理器的相關資訊。
  • GM.setValue(name: string, value: string | number | boolean): Promise<void>: 用於寫入資料並儲存,資料通常會儲存在指令碼管理器本體維護的IndexDB中。
  • GM.getValue(name: string, default?: T): : Promise<string | number | boolean | T | undefined>: 用於獲取指令碼之前使用GM.setValue賦值儲存的資料。
  • GM.deleteValue(name: string): Promise<void>: 用於刪除之前使用GM.setValue賦值儲存的資料。
  • GM.getResourceUrl(name: string): Promise<string>: 用於獲取之前使用@resource宣告的資源地址。
  • GM.notification(text: string, title?: string, image?: string, onclick?: () => void): Promise<void>: 用於呼叫系統級能力的視窗通知。
  • GM.openInTab(url: string, open_in_background?: boolean ): 用於在新索引標籤中開啟指定的URL
  • GM.registerMenuCommand(name: string, onclick: () => void, accessKey?: string): void: 用於在指令碼管理器的選單中新增一個選單項。
  • GM.setClipboard(text: string): void: 用於將指定的文字資料寫入剪貼簿。
  • GM.xmlHttpRequest(options: { method?: string, url: string, headers?: Record<string, string>, onload?: (response: { status: number; responseText: string , ... }) => void , ... }): 用於與標準XMLHttpRequest物件類似的發起請求的功能,但允許這些請求跨越同源策略。
  • unsafeWindow: 用於存取頁面原始的window物件,在指令碼中直接存取的window物件是經過指令碼管理器封裝過的沙箱環境。

單看這些常用的API其實並不好玩,特別是其中很多能力我們也可以直接換種思路藉助指令碼來實現,當然有一些例如unsafeWindowGM.xmlHttpRequest我們必須要藉助指令碼管理器的API來完成。那麼在這裡我們還可以聊一下指令碼管理器中非常有意思的實現方案,首先是unsafeWindow這個非常特殊的API,試想一下如果我們完全信任使用者當前頁面的window,那麼我們可能會直接將API掛載到window物件上,聽起來似乎沒有什麼問題,但是設想這麼一個場景,假如使用者存取了一個惡意頁面,然後這個網頁又恰好被類似https://*/*規則匹配到了,那麼這個頁面就可以獲得存取我們的指令碼管理器的相關API,這相當於是瀏覽器擴充套件級別的許可權,例如直接獲取使用者磁碟中的檔案內容,並且可以直接將內容跨域傳送到惡意伺服器,這樣的話我們的指令碼管理器就會成為一個安全隱患,再比如當前頁面已經被XSS攻擊了,攻擊者便可以藉助指令碼管理器GM.cookie.get來獲取HTTP OnlyCookie,並且即使不開啟CORS也可以輕鬆將請求傳送到伺服器端。那麼顯然我們本身是準備使用指令碼管理器來Hook瀏覽器的Web頁面,此時反而卻被越權存取了更高階的函數,這顯然是不合理的,所以GreaseMonkey實現了XPCNativeWrappers機制,也可以理解為針對於window物件的沙箱環境。

那麼我們在隔離的環境中,可以得到window物件是一個隔離的安全window環境,而unsafeWindow就是使用者頁面中的window物件。曾經我很長一段時間都認為這些外掛中可以存取的window物件實際上是瀏覽器拓展的Content Scripts提供的window物件,而unsafeWindow是使用者頁面中的window,以至於我用了比較長的時間在探尋如何直接在瀏覽器拓展中的Content Scripts直接獲取使用者頁面的window物件,當然最終還是以失敗告終,這其中比較有意思的是一個逃逸瀏覽器拓展的實現,因為在Content ScriptsInject Scripts是共用DOM的,所以可以通過DOM來實現逃逸,當然這個方案早已失效。

var unsafeWindow;
(function() {
    var div = document.createElement("div");
    div.setAttribute("onclick", "return window");
    unsafeWindow = div.onclick();
})();

此外在FireFox中還提供了一個wrappedJSObject來幫助我們從Content Scripts中存取頁面的的window物件,但是這個特性也有可能因為不安全在未來的版本中被移除。那麼為什麼現在我們可以知道其實際上是同一個瀏覽器環境呢,除了看原始碼之外我們也可以通過以下的程式碼來驗證指令碼在瀏覽器的效果,可以看出我們對於window的修改實際上是會同步到unsafeWindow上,證明實際上是同一個參照。

unsafeWindow.name = "111111";
console.log(window === unsafeWindow); // false
console.log(window); // Proxy {Symbol(Symbol.toStringTag): 'Window'}
console.log(window.onblur); // null
unsafeWindow.onblur = () => 111;
console.log(unsafeWindow); // Window { ... }
console.log(unsafeWindow.name, window.name); // 111111 111111
console.log(window.onblur === unsafeWindow.onblur); // true
const win = new Function("return this")();
console.log(win === unsafeWindow); // true

實際上在@grant none的情況下,指令碼管理器會認為當前的環境是安全的,同樣也不存在越權存取的問題了,所以此時存取的window就是頁面原本的window物件。此外,如果觀察仔細的話,我們可以看到上邊的驗證程式碼最後兩行我們突破了這些擴充套件的沙盒限制,從而可以在未@grant unsafeWindow情況下能夠直接存取unsafeWindow,當然這並不是什麼大問題,因為指令碼管理器本身也是提供unsafeWindow存取的,而且如果在頁面未啟用unsafe-evalCSP情況下這個例子就失效了。只不過我們也可以想一下其他的方案,是不是直接禁用Function函數以及eval的執行就可以了,但是很明顯即使我們直接禁用了Function物件的存取,也同樣可以通過建構函式的方式即(function(){}).constructor來存取Function物件,所以針對於window沙箱環境也是需要不斷進行攻防的,例如小程式不允許使用FunctionevalsetTimeoutsetInterval來動態執行程式碼,那麼社群就開始有了手寫直譯器的實現,對於我們這個場景來說,我們甚至可以直接使用iframe建立一個about:blankwindow物件作為隔離環境。

那麼我們緊接著可以簡單地討論下如何實現沙箱環境隔離,其實在上邊的例子中也可以看到直接列印window輸出的是一個Proxy物件,那麼在這裡我們同樣使用Proxy來實現簡單的沙箱環境,我們需要實現的是對於window物件的代理,在這裡我們簡單一些,我們希望的是所有的操作都在新的物件上,不會操作原本的物件,在取值的時候可以做到首先從我們新的物件取,取不到再去window物件上取,寫值的時候只會在我們新的物件上操作,在這裡我們還用到了with操作符,主要是為了將程式碼的作用域設定到一個特定的物件中,在這裡就是我們建立的的context,在最終結果中我們可以看到我們對於window物件的讀操作是正確的,並且寫操作都只作用在沙箱環境中。

const context = Object.create(null);
const global = window;
const proxy = new Proxy(context, {
    // `Proxy`使用`in`操作符號判斷是否存在屬性
    has: () => true,
    // 寫入屬性作用到`context`上
    set: (target, prop, value) => {
        target[prop] = value;
        return true;
    },
    // 特判特殊屬性與方法 讀取屬性依次讀`context`、`window`
    get: (target, prop) => {
        switch (prop) {
            // 重寫特殊屬性指向
            case "globalThis":
            case "window":
            case "parent":
            case "self":
                return proxy;
            default:
                if (prop in target) {
                    return target[prop];
                }
                const value = global[prop];
                // `alert`、`setTimeout`等方法作用域必須在`window`下
                if (typeof value === "function" && !value.prototype) {
                    return value.bind(global);
                }
                return value;
        }
    },
});

window.name = "111";
with (proxy) {
    console.log(window.name); // 111
    window.name = "222";
    console.log(name); // 222
    console.log(window.name); // 222
}
console.log(window.name); // 111
console.log(context); // { name: '222' }

那麼現在到目前為止我們使用Proxy實現了window物件隔離的沙箱環境,總結起來我們的目標是實現一個乾淨的window沙箱環境,也就是說我們希望網站本身執行的任何不會影響到我們的window物件,比如網站本體在window上掛載了$$物件,我們本身不希望其能直接在開發者的指令碼中存取到這個物件,我們的沙箱環境是完全隔離的,而使用者指令碼管理器的目標則是不同的,比如使用者需要在window上掛載事件,那麼我們就應該將這個事件處理常式掛載到原本的window物件上,那麼我們就需要區分讀或者寫的屬性是原本window上的還是Web頁面新寫入的屬性,顯然如果想解決這個問題就要在使用者指令碼執行之前將原本window物件上的key記錄副本,相當於以白名單的形式操作沙箱。由此引出了我們要討論的下一個問題,如何在document-start即頁面載入之前執行指令碼。

實際上document-start是使用者指令碼管理器中非常重要的實現,如果能夠保證指令碼是最先執行的,那麼我們幾乎可以做到在語言層面上的任何事情,例如修改window物件、Hook函數定義、修改原型鏈、阻止事件等等等等。當然其本身的能力也是源自於瀏覽器拓展,而如何將瀏覽器擴充套件的這個能力暴露給Web頁面就是需要考量的問題了。首先我們大概率會寫過動態/非同步載入JS指令碼的實現,類似於下面這種方式:

const loadScriptAsync = (url: string) => {
    return new Promise<Event>((resolve, reject) => {
        const script = document.createElement("script");
        script.src = url;
        script.async = true;
        script.onload = e => {
            script.remove();
            resolve(e);
        };
        script.onerror = e => {
            script.remove();
            reject(e);
        };
        document.body.appendChild(script);
    });
};

那麼現在就有一個明顯的問題,我們如果在body標籤構建完成也就是大概在DOMContentLoaded時機再載入指令碼肯定是達不到document-start的目標的,甚至於在head標籤完成之後處理也不行,很多網站都會在head內編寫部分JS資源,在這裡載入同樣時機已經不合適了。那麼對於整個頁面來說,最先載入的必定是html這個標籤,那麼很明顯我們只要將指令碼在html標籤級別插入就好了,配合瀏覽器擴充套件中backgroundchrome.tabs.executeScript動態執行程式碼以及content.js"run_at": "document_start"建立訊息通訊確認注入的tab,這個方法是不是看起來很簡單,但就是這麼簡單的問題讓我思索了很久是如何做到的。此外這個方案目前在擴充套件V2中是可以行的,在V3中移除了chrome.tabs.executeScript,替換為了chrome.scripting.executeScript,當前的話使用這個API可以完成框架的注入,但是做不到使用者指令碼的注入,因為無法動態執行程式碼。

(function () {
    const script = document.createElementNS("http://www.w3.org/1999/xhtml", "script");
    script.setAttribute("type", "text/javascript");
    script.innerText = "console.log(111);";
    script.className = "injected-js";
    document.documentElement.appendChild(script);
    script.remove();
})();

此外我們可能納悶,為什麼指令碼管理器框架和使用者指令碼都是採用這種方式注入的,而在瀏覽器控制檯的Sources控制面板下只能看到一個userscript.html?name=xxxxxx.user.js卻看不到指令碼管理器的程式碼注入,實際上這是因為指令碼管理器會在使用者指令碼的最後部分注入一個類似於//# sourceURL=chrome.runtime.getURL(xxx.user.js)的註釋,其中這個sourceURL會將註釋中指定的URL作為指令碼的源URL,並在Sources面板中以該URL標識和顯示該指令碼,這對於在偵錯和追蹤程式碼時非常有用,特別是在載入動態生成的或內聯指令碼時。

window["xxxxxxxxxxxxx"] = function (context, GM_info) {
  with (context)
    return (() => {
      // ==UserScript==
      // @name       TEST
      // @description       TEST
      // @version    1.0.0
      // @match      http://*/*
      // @match      https://*/*
      // ==/UserScript==

      console.log(window);

      //# sourceURL=chrome-extension://xxxxxx/DEBUG.user.js
    })();
};

還記得我們最初的問題嗎,即使我們完成了沙箱環境的構建,但是如何將這個物件傳遞給使用者指令碼,我們不能將這些變數暴露給網站本身,但是又需要將相關的變數傳遞給指令碼,而指令碼本身就是執行在使用者頁面上的,否則我們沒有辦法存取使用者頁面的window物件,所以接下來我們就來討論如何保證我們的高階方法安全地傳遞到使用者指令碼的問題。實際上在上邊的source-map我們也可以明顯地看出來,我們可以直接藉助閉包以及with存取變數即可,並且在這裡還需要注意this的問題,所以在呼叫該函數的時候通過如下方式呼叫即可將當前作用域的變數作為傳遞給指令碼執行。

script.apply(proxyContent, [ proxyContent, GM_info ]);

我們都知道瀏覽器會有跨域的限制,但是為什麼我們的指令碼可以通過GM.xmlHttpRequest來實現跨域介面的存取,而且我們之前也提到了指令碼是執行在使用者頁面也就是作為Inject Script執行的,所以是會受到跨域存取的限制的。那麼解決這個問題的方式也比較簡單,很明顯在這裡發起的通訊並不是直接從頁面的window發起的,而是從瀏覽器擴充套件發出去的,所以在這裡我們就需要討論如何做到在使用者頁面與瀏覽器擴充套件之間進行通訊的問題。在Content Script中的DOM和事件流是與Inject Script共用的,那麼實際上我們就可以有兩種方式實現通訊,首先我們常用的方法是window.addEventListener + window.postMessage,只不過這種方式很明顯的一個問題是在Web頁面中也可以收到我們的訊息,即使我們可以生成一些隨機的token來驗證訊息的來源,但是這個方式畢竟能夠非常簡單地被頁面本身截獲不夠安全,所以在這裡通常是用的另一種方式,即document.addEventListener + document.dispatchEvent + CustomEvent自定義事件的方式,在這裡我們需要注意的是事件名要隨機,通過在注入框架時於background生成唯一的隨機事件名,之後在Content ScriptInject Script都使用該事件名通訊,就可以防止使用者截獲方法呼叫時產生的訊息了。

// Content Script
document.addEventListener("xxxxxxxxxxxxx" + "content", e => {
    console.log("From Inject Script", e.detail);
});

// Inject Script
document.addEventListener("xxxxxxxxxxxxx" + "inject", e => {
    console.log("From Content Script", e.detail);
});

// Inject Script
document.dispatchEvent(
    new CustomEvent("xxxxxxxxxxxxx" + "content", {
        detail: { message: "call api" },
    }),
);

// Content Script
document.dispatchEvent(
    new CustomEvent("xxxxxxxxxxxxx" + "inject", {
        detail: { message: "return value" },
    }),
);

指令碼構建

在構建Chrome擴充套件的時候我們是使用Rspack來完成的,這次我們換個構建工具使用Rollup來打包,主要還是Rspack更適合打包整體的Web應用,而Rollup更適合打包工具類庫,我們的Web指令碼是單檔案的指令碼,相對來說更適合使用Rollup來打包,當然如果想使用Rspack來體驗Rust構建工具的打包速度也是沒問題的,甚至也可以直接使用SWC來完成打包,實際上在這裡我並沒有使用Babel而是使用ESBuild來構建的指令碼,速度也是非常不錯的。

此外,之前我們也提到過指令碼管理器的API雖然都對GreaseMonkey相容,但實際上各個指令碼管理器會出現特有的API,這也是比較正常的現象畢竟是不同的指令碼管理器,完全實現相同的功能是意義不大的,至於不同瀏覽器的差異還不太一樣,瀏覽器之間的API差異是需要執行時判斷的。那麼如果我們需要全平臺支援的話就需要實現渠道包,這個概念在Android開發中是非常常見的,那麼每個包都由開發者手寫顯然是不現實的,使用現代化的構建工具除了方便維護之外,對於渠道包的支援也更加方便,利用環境變數與TreeShaking可以輕鬆地實現渠道包的構建,再配合指令碼管理器以及指令碼網站的同步功能,就可以實現分發不同渠道的能力。

Rollup

這一部分比較類似於各種SDK的打包,假設在這裡我們有多個指令碼需要打包,而我們的目標是將每個工程目錄打包成單獨的包,Rollup提供了這種同時打包多個輸入輸出能力,我們可以直接通過rollup.config.js設定一個陣列,通過input來指定入口檔案,通過output來指定輸出檔案,通過plugins來指定外掛即可,我們輸出的包一般需要使用iife立執行函數也就是能夠自動執行的指令碼,適合作為script標籤這樣的輸出格式。

[
  {
    input: "./packages/copy/src/index.ts",
    output: {
      file: "./dist/copy.user.js",
      format: "iife",
      name: "CopyModule",
    },
    plugins: [ /* ... */ ],
  },
  // ...
];

如果需要使用@updateURL來檢查更新的話,我們還需要單獨打包一個meta檔案,打包meta檔案與上邊同理,只需要提供一個空白的blank.js作為input,之後將meta資料注入就可以了,這裡需要注意的一點是這裡的format要設定成es,因為我們要輸出的指令碼不能帶有自執行函數的(function () {})();包裹。

[
  {
    input: "./meta/blank.js",
    output: {
      file: "./dist/meta/copy.meta.js",
      format: "es",
      name: "CopyMeta",
    },
    plugins: [{ /* ... */}],
  },
  // ...
];

前邊我們也提到了渠道包的問題,那麼如果想打包渠道包的話主要有以下幾個需要注意的地方:首先是在命令執行的時候,我們要設定好環境變數,例如在這裡我設定的環境變數是process.env.CHANNEL;其次在打包工具中,我們需要在打包的時候將定義的整個環境變數替換掉,實際上這裡也是個非常有意思的事情,雖然我們認為process是個變數,但是在打包的時候我們是當字串處理的,利用@rollup/plugin-replaceprocess.env.CHANNEL字串替換成執行命令的時候設定的環境變數;之後在程式碼中我們需要定義環境變數的使用,在這裡特別要注意的是要寫成直接表示式而不是函數的形式,因為如果寫成了函數我們就無法觸發TreeShakingTreeShaking是靜態檢測的方式,我們需要在程式碼中明確指明這個表示式的Boolean值;最後再通過環境變數來設定檔案的輸出,最終將所有的檔案打包出來即可。

// package.json scripts
// "build:special": "cross-env CHANNEL=SPECIAL rollup -c"

// index.ts
const isSpecialEnv = process.env.CHANNEL === "SPECIAL";
if (isSpecialEnv) {
    console.log("IS IN SPECIAL ENV");
}

// @rollup/plugin-replace
replace({
    "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
    "process.env.CHANNEL": JSON.stringify(process.env.CHANNEL),
    "preventAssignment": true,
})

// rollup.config.js
if(process.env.CHANNEL === "SPECIAL"){
    config.output.file = "./dist/copy.special.user.js";
}

此外,我們不能使用rollup-plugin-terser等模組去壓縮打包的產物,特別是要分發到GreasyFork等平臺中,因為本身指令碼的許可權也可以說是非常高的,所以配合程式碼審查是非常有必要的。同樣的也因為類似的原因,類似於jQuery這種包我們是不能夠直接打包到專案中的,一般是需要作為external配合@require外部引入的,類似於GreasyFork也會採取白名單機制審查外部引入的包。大部分情況下我們需要使用document-start去前置執行程式碼,但是在此時head標籤是沒有完成的,所以在這裡還需要特別關注下CSS注入的時機,如果指令碼是在document-start執行的話通常就需要自行注入CSS而不能直接使用rollup-plugin-postcss的預設注入能力。那麼到這裡實際上Rollup打包這部分並沒有特別多需要注意的能力,基本就是我們普通的前端工程化專案,完整的設定可以參考https://github.com/WindrunnerMax/TKScript/blob/master/rollup.config.js

// `Plugins Config` 
const buildConfig = {
    postcss: {
        minimize: true,
        extensions: [".css"],
    },
    esbuild: {
        exclude: [/node_modules/],
        sourceMap: false,
        target: "es2015",
        minify: false,
        charset: "utf8",
        tsconfig: path.resolve(__dirname, "tsconfig.json"),
    },
};

// `Script Config` 
const scriptConfig = [
    {
        name: "Copy",
        meta: {
            input: "./meta/blank.js",
            output: "./dist/meta/copy.meta.js",
            metaFile: "./packages/copy/meta.json",
        },
        script: {
            input: "./packages/copy/src/index.ts",
            output: "./dist/copy.user.js",
            injectCss: false,
        },
    },
    // ...
];


export default [
    // `Meta`
    ...scriptConfig.map(item => ({
        input: item.meta.input,
        output: {
            file: item.meta.output,
            format: "es",
            name: item.name + "Meta",
        },
        plugins: [metablock({ file: item.meta.metaFile })],
    })),
    // `Script`
    ...scriptConfig.map(item => ({
        input: item.script.input,
        output: {
            file: item.script.output,
            format: "iife",
            name: item.name + "Module",
        },
        plugins: [
            postcss({ ...buildConfig.postcss, inject: item.script.injectCss }),
            esbuild(buildConfig.esbuild),
            // terser({ format: { comments: true } }),
            metablock({ file: item.meta.metaFile }),
        ],
    })),
];

Meta

在上邊雖然我們完成了主體包的構建,但是似乎我們遺漏了一個大問題,也就是指令碼管理器指令碼描述Meta的生成,幸運的是在這裡有Rollup的外掛可以讓我們直接呼叫,當然實現類似於這種外掛的能力本身並不複雜,首先是需要準備一個meta.json的檔案,在其中使用json的形式將各種設定描述出來,之後便可以通過遍歷的方式生成字串,在Rollup的勾點函數中講字串注入到輸出的檔案中即可。當然這個包還做了很多事情,例如對於欄位格式的檢查、輸出內容的美化等等。

{
    "name": {
        "default": "