從零實現的瀏覽器Web腳本

從零實現的瀏覽器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 [email protected]
          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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章