从零实现的浏览器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能力,實際上這其中涉及到很多非常有意思的實現,例如腳本中可以訪問的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 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
總結
以上是生活随笔為你收集整理的从零实现的浏览器Web脚本的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: JUC并发编程学习笔记(六)Callab
- 下一篇: Redis 技术整理