從零實現的瀏覽器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
能力,實際上這其中涉及到很多非常有意思的實現,例如腳本中可以訪問的window
與unsafeWindow
,那麼如何實現一個完全隔離的window
沙箱環境就值的探索,再比如在Web
頁面中是無法跨域訪問資源的,如何實現在Inject Script
中跨域訪問資源的CustomEvent
通信機制也可以研究一下,以及如何使用createElementNS
在HTML
級別實現Runtime
以及Script
注入、腳本代碼組裝後//# sourceURL
的作用等等,所以如果有興趣的同學可以研究下ScriptCat
,這是國內的同學開發的腳本管理器,註釋都是中文會比較容易閱讀。那麼本文還是主要關注於應用,我們從最基本的UserScript
腳本相關能力,到使用Rollup
來構建腳本,再通過實例來探索腳本的實現來展開本文的討論。
UserScript
在最初GreaseMonkey
油猴實現腳本管理器時,是以UserScript
作爲腳本的MetaData
也就是元數據塊描述,並且還以GM.
開頭提供了諸多高級的API
使用,例如可跨域的GM.xmlHttpRequest
,實際上相當於實現了一整套規範,而後期開發的腳本管理器大都會遵循或者兼容這套規範,以便複用相關的生態。其實對於開發者來說這也是個麻煩事,因爲我們沒有辦法控制用戶安裝的瀏覽器擴展,而我們的腳本如果用到了某一個擴展單獨實現的API
,那麼就會導致腳本在其他擴展中無法使用,特別是將腳本放在腳本平臺上之後,沒有辦法構建渠道包去分發,所以平時還是儘量使用各大擴展都支持的Meta
與API
來開發,避免不必要的麻煩。
此外在很久之前我一直好奇在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
: 更加嚴格的匹配模式,根據Chrome
的Match Patterns規則來匹配,例如// @match *://*.google.com/foo*bar
。@icon
: 腳本管理界面顯示的圖標,幾乎任何圖像都可以使用,但32x32
像素大小是最合適的資源大小。@resource
: 在安裝腳本時,每個@resource
都會下載一次,並與腳本一起存儲在用戶的硬盤上,這些資源可以分別通過GM_getResourceText
和GM_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
: 腳本所需要的權限,例如unsafeWindow
,GM.setValue
,GM.xmlHttpRequest
等,如果沒有指定@grant
則默認爲none
,即不需要任何權限。
API
API
是腳本管理器提供用來增強腳本功能的對象,通過這些腳本我們可以實現針對於Web
頁面更加高級的能力,例如跨域請求、修改頁面佈局、數據存儲、通知能力、剪貼板等等,甚至於在Beta
版的TamperMonkey
中,還有着允許用戶腳本讀寫HTTP Only
的Cookie
的能力。同樣的,使用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
其實並不好玩,特別是其中很多能力我們也可以直接換種思路藉助腳本來實現,當然有一些例如unsafeWindow
和GM.xmlHttpRequest
我們必須要藉助腳本管理器的API
來完成。那麼在這裏我們還可以聊一下腳本管理器中非常有意思的實現方案,首先是unsafeWindow
這個非常特殊的API
,試想一下如果我們完全信任用戶當前頁面的window
,那麼我們可能會直接將API
掛載到window
對象上,聽起來似乎沒有什麼問題,但是設想這麼一個場景,假如用戶訪問了一個惡意頁面,然後這個網頁又恰好被類似https://*/*
規則匹配到了,那麼這個頁面就可以獲得訪問我們的腳本管理器的相關API
,這相當於是瀏覽器擴展級別的權限,例如直接獲取用戶磁盤中的文件內容,並且可以直接將內容跨域發送到惡意服務器,這樣的話我們的腳本管理器就會成爲一個安全隱患,再比如當前頁面已經被XSS
攻擊了,攻擊者便可以藉助腳本管理器GM.cookie.get
來獲取HTTP Only
的Cookie
,並且即使不開啓CORS
也可以輕鬆將請求發送到服務端。那麼顯然我們本身是準備使用腳本管理器來Hook
瀏覽器的Web
頁面,此時反而卻被越權訪問了更高級的函數,這顯然是不合理的,所以GreaseMonkey
實現了XPCNativeWrappers
機制,也可以理解爲針對於window
對象的沙箱環境。
那麼我們在隔離的環境中,可以得到window
對象是一個隔離的安全window
環境,而unsafeWindow
就是用戶頁面中的window
對象。曾經我很長一段時間都認爲這些插件中可以訪問的window
對象實際上是瀏覽器拓展的Content Scripts
提供的window
對象,而unsafeWindow
是用戶頁面中的window
,以至於我用了比較長的時間在探尋如何直接在瀏覽器拓展中的Content Scripts
直接獲取用戶頁面的window
對象,當然最終還是以失敗告終,這其中比較有意思的是一個逃逸瀏覽器拓展的實現,因爲在Content Scripts
與Inject 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-eval
的CSP
情況下這個例子就失效了。只不過我們也可以想一下其他的方案,是不是直接禁用Function
函數以及eval
的執行就可以了,但是很明顯即使我們直接禁用了Function
對象的訪問,也同樣可以通過構造函數的方式即(function(){}).constructor
來訪問Function
對象,所以針對於window
沙箱環境也是需要不斷進行攻防的,例如小程序不允許使用Function
、eval
、setTimeout
、setInterval
來動態執行代碼,那麼社區就開始有了手寫解釋器的實現,對於我們這個場景來說,我們甚至可以直接使用iframe
創建一個about:blank
的window
對象作爲隔離環境。
那麼我們緊接着可以簡單地討論下如何實現沙箱環境隔離,其實在上邊的例子中也可以看到直接打印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
標籤級別插入就好了,配合瀏覽器擴展中background
的chrome.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 Script
與Inject 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-replace
將process.env.CHANNEL
字符串替換成執行命令的時候設置的環境變量;之後在代碼中我們需要定義環境變量的使用,在這裏特別要注意的是要寫成直接表達式而不是函數的形式,因爲如果寫成了函數我們就無法觸發TreeShaking
,TreeShaking
是靜態檢測的方式,我們需要在代碼中明確指明這個表達式的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
繪製的內容,當然這種方式不在本文討論的範圍,在這裏我們要討論利用事件來限制用戶複製的方式,那麼能夠影響到用戶複製行爲的事件主要有onCopy
、onSelectStart
事件。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
的冒泡,就可能導致編輯器複製採用了默認行爲,而通常編輯器會對於複製文本的格式進行一些處理,所以在有編輯功能的時候還是要慎重,完全作爲展覽端倒是就問題不大了,整體來說是收益更大。
前一段時間我發現了另一種非常有意思的事情,onFocus
、onBlur
事件也可以做到限制用戶文本選中,隨便找個頁面然後將下邊的代碼在控制檯執行,我們可以驚奇地發現,我們無法正常選中文本了。
const button = document.createElement("button");
button.onblur = () => button.focus();
button.textContent = "BUTTON";
document.body.appendChild(button);
button.focus();
那麼實際上這裏的原理也很簡單,通常在HTMLInputElement
、HTMLSelectElement
、HTMLTextAreaElement
、HTMLAnchorElement
、HTMLButtonElement
等元素會有焦點這個概念,而文本的選中也有焦點這個行爲,那麼既然焦點不能夠同時聚焦在一起,我們就直接強行將焦點聚焦在其他的地方,比如上邊的例子就是將焦點強行聚焦在了按鈕上,這樣因爲文本內容無法獲取焦點,就無法正常選中了。
那麼我們同樣可以使用捕獲階段阻止事件執行的方式解決這個問題,分別將onFocus
、onBlur
事件處理掉即可,只不過這種方式可能會導致頁面的焦點控制出現一些問題,所以在這裏我們還有另一種方式,通過在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
等腳本網站通常會有源代碼同步的能力,我們可以直接填入一個腳本鏈接就可以自動同步腳本更新,就不需要我們到處填寫了,那麼這裏還有一個問題,這個腳本鏈接應該從哪裏來呢,那麼同樣我們可以藉助GitHub
的 GitPages
來生成腳本鏈接,並且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