首页 > 其他分享 >从零实现的浏览器Web脚本

从零实现的浏览器Web脚本

时间:2023-11-03 20:48:21浏览次数:41  
标签:脚本 Web 浏览器 window 管理器 document 我们

从零实现的浏览器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>, onl oad?: (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": "

标签:脚本,Web,浏览器,window,管理器,document,我们
From: https://www.cnblogs.com/WindrunnerMax/p/17808404.html

相关文章

  • 定时统计vm_stat的一个脚本
    main.py:#!/usr/bin/python3importsubprocessimporttimeimportreimportthreadingfromvm_statimportVmStatfromstart_web_tabimportWebif__name__=='__main__':vm=VmStat()vm_stat_th=threading.Thread(target=vm.CollectVmStat......
  • 从零开始构建报警中心:part01 使用python脚本接收zabbix报警信息
    在part00中提到过,zabbix在整个结构中,只起到发起报警,并将信息推送给Python脚本的作用。所以此文的主要目的就是描述如何配置zabbix的报警媒介。zabbix可以通过配置报警媒介的方式,来自定义的接收报警信息。查看AlertScriptsPath配置报警脚本可以是shell、py或者其他各种格式类型的可......
  • ast 扣取webpack
    学了ast都会自己写扣webpack脚本是吧!!所以我也写了一份letfs=require("fs");lettypes=require("@babel/types");lettraverse=require("@babel/traverse").default;letparser=require("@babel/parser");letgenerator=require(&qu......
  • 信宇宙 TrustVerse:Web3.0数字资产管理
    在数字时代,个人和机构越来越依赖数字资产,这包括加密货币、数字证券、艺术品、不动产等各种价值存储形式。随着数字资产的重要性不断增加,安全性和管理变得至关重要。元信宇宙(TrustVerse)应运而生,旨在成为数字资产管理的未来,充分整合了Web3.0技术,智能生态网络(IEN)作为一种创新性的网络......
  • sh脚本扫描k8s一批微服务过滤错误日志生成文件
    sh脚本扫描k8s一批微服务过滤错误日志生成文件,并用vim一次性打开所有日志文件进行错误排查#!/bin/bashecho"请输入param参数(多个参数用逗号分隔,默认为admin-center,space,permission,user,project,project-center):"readparamparam=${param:-"admin-center,space,permission,......
  • Python_web开发基础内容
    网络长连接的HTTP协议,会在响应头有加入这个头部字段:Connection:keep-aliveWebSocket:需要通过网络传输的任何实时更新或连续数据流,则可以使用WebSocketHTTP:不需要很频繁或仅获取一次的数据可以通过简单的HTTP请求查询都是用来跟踪浏览器用户身份的会话方式.cookie......
  • Web组态是大趋势 国内工控组态软件现状
    工控组态软件发展历史组态软件是随着计算机在工业领域的广泛应用而兴起的,最早的组态软件诞生于上世纪80年代,它起源于DCS,壮大于PLC。在组态软件进入工业领域之前,企业往往需要聘请编程人员或向软件公司定做工业控制软件来应对日常生产。这类软件是专为了某套自动控制系统编写的,如果该......
  • Nuxt-监听浏览器返回
    区分普通页面/keep-alive缓存页面1、普通页面mounted:{this.setAddListener()},destroyed(){window.removeEventListener('popstate',this.setBack,false)},methods:{//监听浏览器返回操作setAddListener(){consthasRefresh......
  • 基于WebGL+HTML5的智慧粮仓3D可视化系统
    仓廪实、天下安。民之所需,行之所至。建设背景古往今来,粮食问题历来是安邦定国的头等大事。“粮食”作为人类生活的生命之源,在人们的日常生活中起着决定性的作用。收获的粮食归仓,仓储工作是稳定“大国粮仓”的重要环节,当粮食收购后,如何让丰收成果颗粒归仓,减少损失,并确保粮食储藏质量......
  • [Python] 基于 flask 构建 Web API 实现参数注入和校验
    在python中,flask包是一个轻量级的WEB框架,常用于快速构建HTTP服务。但它并没有提供参数校验和注入的功能。习惯了java等高级编程语言开发webapi的同学,应该都不想每定义一个api都要写很多代码去做校验和获取请求参数吧,至少我是这样。幸运的是,已经有人提供了参数校验相......