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

WindrunnerMax發表於2023-11-03

從零實現的瀏覽器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://xxx/xxx.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": "???文字選中複製(通用)???",
        "en": "Text Copy Universal",
        "zh-CN": "???文字選中複製(通用)???"
      },
    "namespace": "https://github.com/WindrunnerMax/TKScript",
    "version": "1.1.2",
    "description": {
        "default": "文字選中複製通用版本,適用於大多數網站",
        "en": "Text copy general version, suitable for most websites.",
        "zh-CN": "文字選中複製通用版本,適用於大多數網站"
      },
    "author": "Czy",
    "match": [
        "http://*/*",
        "https://*/*"
    ],
    "supportURL": "https://github.com/WindrunnerMax/TKScript/issues",
    "license": "GPL License",
    "installURL": "https://github.com/WindrunnerMax/TKScript",
    "run-at": "document-end",
    "grant": [
        "GM_registerMenuCommand",
        "GM_unregisterMenuCommand",
        "GM_notification"
    ]
}

例項

那麼在這部分我們實現使用者指令碼的例項,雖然我們平時可能Ctrl C+V程式碼比較多,但是Ctrl C+V也不是僅僅用來搞程式碼的,平時抄作業抄報告也是很需要用到的,尤其是當時我還是學生黨的時候,要是不能複製貼上純自己寫報告那簡直要了命。那麼問題來了,總有一些網站不想讓我們愉快地進行復制貼上,所以在這裡我們來實現解除瀏覽器複製限制的通用方案,具體程式碼可以參考https://github.com/WindrunnerMax/TKScript文字選中複製-通用這部分。

CSS

某些網站會會透過CSS來禁用複製貼上,具體表現為文字無法直接選中,特別是很多文庫類的網站,例如隨便在百度上搜尋一下實習報告,那麼很多搜出來的網站都是無法複製的,當然我們可以直接使用F12看到這部分文字,但是當他是這種巢狀層次很深並且分開展示的資料使用F12複製起來還是比較麻煩的,當然可以直接使用$0.innerText來獲取文字,但是畢竟過於麻煩,不如讓我們來看看CSS是如何禁用的文字選中能力。

那麼平時如果我們寫過一些文字類操作的能力,比如富文字Void塊元素的時候,很容易就可以瞭解到use-select這個CSS屬性,user-select屬性用於控制使用者是否可以選擇文字,這不會對作為瀏覽器使用者介面的一部分的內容載入產生任何影響,除非是在文字框中。

user-select: none; /* 元素及其子元素的文字不可選中 */
user-select: auto; /* 具體取值取決於一系列條件 */
user-select: text; /* 元素及其子元素的文字內容可選中 */
user-select: contain; /* 元素的子元素的文字可選中 但元素本身的文字不可選中 */
user-select: all; /* 元素及其子元素的文字內容可選中 */

那麼我們在這些網站中檢索一下,就可以很明顯的看到user-select: none;,那麼如果想解除這個限制,我們可以很輕鬆地想到CSS的優先順序,利用優先順序來強行覆蓋所有屬性的值即可,這也是比較通用的實現方案,可以輕鬆適配絕大多數利用這種方式禁止複製的頁面。

const style = document.createElement("style"); 
style.type = "text/css";
style.innerText = "*{user-select: auto !important; -webkit-user-select: auto !important;}"; 
document.head.appendChild(style);

Event

在大部分時候網站都不僅僅是使用CSS來禁止使用者複製行為的,特別是使用Canvas繪製的內容,當然這種方式不在本文討論的範圍,在這裡我們要討論利用事件來限制使用者複製的方式,那麼能夠影響到使用者複製行為的事件主要有onCopyonSelectStart事件。onCopy事件很明顯,我們在觸發複製例如使用Ctrl + C或者右鍵複製的時候就會觸發,在這裡我們只要將其截獲就可以做到阻止複製了,同樣的onSelectStart事件也是,只要阻止其預設行為就可以阻止使用者的文字選中,自然也就無法複製了。在這裡為了簡單直接使用DOM0事件,如果在控制輸入這段程式碼就可以發現無法正常複製了。

document.oncopy = event => event.preventDefault();
document.onselectstart = event => event.preventDefault();

在研究如何處理這些事件的行為之前,我們先來看一下getEventListeners方法,Chrome瀏覽器提供的getEventListeners方法來獲取所有的事件監聽,但是這畢竟是在控制檯中才能使用的函式,不具有通用性,只是方便我們除錯用。

console.log(getEventListeners(document));
// {
//     click: Array(4), 
//     DOMContentLoaded: Array(3),
//     // ...
// }

那麼既然不具有通用性,我們為什麼要聊這個方法呢,這其中涉及一個問題,對於這些事件監聽,如果我們想解除這些事件處理函式,對於DOM0級的事件而言,我們只需要將屬性設定為null即可,但是對於DOM2級的事件而言,我們需要使用removeEventListener來移除事件處理函式,那麼問題來了,使用removeEventListener函式我們必須要獲取當時addEventListener時的函式引用,但是我們並沒有儲存這個引用,那麼我們如何獲取這個引用呢,這就是我們討論的getEventListeners方法的作用了,我們可以透過這個方法獲取到所有的事件監聽,之後再透過removeEventListener來移除事件處理函式即可,當然在這裡我們只能進行事件判定的除錯用,並不能實現一個通用的方案。

const listeners = getEventListeners(document);
Object.keys(listeners).forEach(key => {
    console.log(key);
    listeners[key].forEach(item => {
        document.removeEventListener(item.type, item.listener);
    });
});

那麼我們是不是可以換個思路,非得移除事件監聽是比較鑽牛角尖了,俗話說得好,最高階的食物往往只需要最簡單的烹飪方式,既然移除不了,我們就不讓他執行就完事了,既然不想讓他執行,那就很自然的聯想到了JS的事件流模型,那就給他阻止冒泡唄。

document.body.addEventListener("copy", e => e.stopPropagation()); 
document.body.addEventListener("selectstart", e => e.stopPropagation());

看似這個方式是沒有問題的,那麼假如此時Web頁面本身監聽的事件是在body上的話,那麼很明顯在document上去阻止冒泡就已經太晚了,並不能達到效果,所以這就很尷尬,那說明這個方案不夠通用。那既然冒泡不行,我們直接在捕獲階段給他幹掉就ok了,並且配合上指令碼管理器的document-start來保證我們的事件捕獲是最先執行的,這樣不光能夠解決這類DOM0事件的問題,對於DOM2級的事件也同樣有效果。

document.body.addEventListener("copy", e => e.stopPropagation(), true); 
document.body.addEventListener("selectstart", e => e.stopPropagation(), true);

這個方案已經是一個比較通用的複製方案了,我們可以解決大多數網站的限制,但透過直接在捕獲階段攔截事件也是可能有一定的副作用的,例如我們在捕獲階段就阻止了鍵盤的事件,然後在編輯語雀的檔案的時候就會出現問題,因為語雀的檔案也跟飛書類似,都是按行處理文字,然後猜測他是阻止了contenteditable的預設行為,然後編輯器完全接管了鍵盤的事件,所以會導致其無法換行和處理快捷啟動選單。同理,如果直接阻止了onCopy的冒泡,就可能導致編輯器複製採用了預設行為,而通常編輯器會對於複製文字的格式進行一些處理,所以在有編輯功能的時候還是要慎重,完全作為展覽端倒是就問題不大了,整體來說是收益更大。

前一段時間我發現了另一種非常有意思的事情,onFocusonBlur事件也可以做到限制使用者文字選中,隨便找個頁面然後將下邊的程式碼在控制檯執行,我們可以驚奇地發現,我們無法正常選中文字了。

const button = document.createElement("button");
button.onblur = () => button.focus();
button.textContent = "BUTTON";
document.body.appendChild(button);
button.focus();

那麼實際上這裡的原理也很簡單,通常在HTMLInputElementHTMLSelectElementHTMLTextAreaElementHTMLAnchorElementHTMLButtonElement等元素會有焦點這個概念,而文字的選中也有焦點這個行為,那麼既然焦點不能夠同時聚焦在一起,我們就直接強行將焦點聚焦在其他的地方,比如上邊的例子就是將焦點強行聚焦在了按鈕上,這樣因為文字內容無法獲取焦點,就無法正常選中了。

那麼我們同樣可以使用捕獲階段阻止事件執行的方式解決這個問題,分別將onFocusonBlur事件處理掉即可,只不過這種方式可能會導致頁面的焦點控制出現一些問題,所以在這裡我們還有另一種方式,透過在document-start執行MutationObserver,在發現類似的DOM節點的時候直接將其移出,讓其無法插入到DOM樹中自然也就不會有相關問題了,只不過這就不是一個通用的解決方案,通常需要case by case地處理才可以。

const handler = mutationsList => {
    for (const mutation of mutationsList) {
        const addedNodes = mutation.addedNodes;
        for (let i = 0; i < addedNodes.length; i++) {
            const target = addedNodes[i];
            if (target.nodeType != 1) return void 0;
            if (
                target instanceof HTMLButtonElement &&
                target.textContent === "BUTTON"
            ) {
                target.remove();
            }
        }
    }
};
const observer = new MutationObserver(handler);
observer.observe(document, { childList: true, subtree: true });

指令碼分發

那麼基於上述方式我們完成了指令碼的編寫與打包,在這裡也分享一個指令碼分發的最佳實踐,GreasyFork等指令碼網站通常會有原始碼同步的能力,我們可以直接填入一個指令碼連結就可以自動同步指令碼更新,就不需要我們到處填寫了,那麼這裡還有一個問題,這個指令碼連結應該從哪裡來呢,那麼同樣我們可以藉助GitHubGitPages來生成指令碼連結,並且GitHub還有GitAction可以幫助我們自動構建指令碼。

那麼整個流程就是這樣的,我們首先在GitHub配置好GitAction,當我們推送程式碼的時候就可以觸發自動構建流程,在構建完成後我們可以將程式碼自動地推送到GitPages,之後我們就可以手動獲取GitPages的指令碼連結並且填入到各個指令碼網站了,並且如果打了渠道包也可以分別分發不同的指令碼連結,這樣就完成了整個流程的自動化,並且藉助GitHub還可以將jsDelivr作為CDN使用,下面就是完整的GitAction的配置。

name: publish gh-pages

on:
  push:
    branches:
      - master

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v2
        with:
          persist-credentials: false

      - name: install and build
        run: |
          npm install -g pnpm@6.24.3
          pnpm install
          pnpm run build
      - name: deploy
        uses: JamesIves/github-pages-deploy-action@releases/v3
        with:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          BRANCH: gh-pages
          FOLDER: dist

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://wiki.greasespot.net/Security
https://docs.scriptcat.org/docs/dev/api/
https://en.wikipedia.org/wiki/Greasemonkey
https://wiki.greasespot.net/Metadata_Block
https://juejin.cn/post/6844903977759293448  
https://www.tampermonkey.net/documentation.php
https://wiki.greasespot.net/Greasemonkey_Manual:API
https://learn.scriptcat.org/docs/%E7%AE%80%E4%BB%8B/
http://jixunmoe.github.io/gmDevBook/#/doc/intro/gmScript

相關文章