【转】从浏览器原理出发聊聊Chrome插件

浏览器架构演进

单进程浏览器时代

单进程浏览器是指浏览器的所有功能模块都是运行在同一个进程里,这些模块包含了网络、插件、JavaScript 运行环境、渲染引擎和页面等。在 2007 年之前,市面上浏览器都是单进程的。

很多功能模块运行在一个进程里,是导致单进程浏览器不稳定、不流畅和不安全的一个主要因素。

  • 不稳定:早期浏览器需要借助于插件来实现诸如 Web 视频、Web 游戏等各种强大的功能,但是插件是最容易出问题的模块,并且还运行在浏览器进程之中,所以一个插件的意外崩溃会引起整个浏览器的崩溃。除了插件之外,渲染引擎模块也是不稳定的,通常一些复杂的 JavaScript 代码就有可能引起渲染引擎模块的崩溃。和插件一样,渲染引擎的崩溃也会导致整个浏览器的崩溃。
  • 不流畅:所有页面的渲染模块、JavaScript 执行环境以及插件都是运行在同一个线程中的,这就意味着同一时刻只能有一个模块可以执行。如果一个脚本非常耗时,它就会独占整个线程,这样导致其他运行在该线程中的页面没有机会去执行任务,导致整个浏览器失去响应,变卡顿。
  • 不安全:当你在页面运行一个插件时,插件可以操作系统资源,如果是个恶意插件,那么它就可以释放病毒、窃取你的账号密码,引发安全性问题。

多进程浏览器时代

早期架构

从图中可以看出,早期的架构已经对浏览器的能力进行了拆分,主要拆分为三类:浏览器进程、插件进程和渲染进程。每个页面是运行在单独的渲染进程中的,同时页面里的插件也是运行在单独的插件进程之中,进程之间是通过 IPC 机制进行通信。这就解决了单进程时代浏览器的各种问题:

  • 解决不稳定:由于进程是相互隔离的,所以当一个页面或者插件崩溃时,影响到的仅仅是当前的页面进程或者插件进程,并不会影响到浏览器和其他页面。
  • 解决不流畅:JavaScript运行在渲染进程中,所以即使JavaScript阻塞了渲染进程,也只会影响当前的渲染页面,并不会影响浏览器和其他页面,因为其他页面的脚本运行在它们自己的渲染进程中。
  • 解决不安全:Chrome把插件进程和渲染进程锁在沙箱里面,沙箱里面的程序可以运行,但是不能在硬盘上写入任何数据,也不能在敏感位置读取任何数据,这样即使在渲染进程或者插件进程里面执行了恶意程序,恶意程序也无法突破沙箱去获取系统权限。

近期架构

相较之前,近期的架构又有了很多新的变化。

从图中可以看出,最新的 Chrome 浏览器包括:1 个浏览器主进程、1 个 GPU 进程、1 个网络进程、多个渲染进程和多个插件进程。

  • 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时提供存储等功能。可以理解浏览器进程是一个统一的”调度大师”去调度其他进程,比如我们在地址栏输入url时,浏览器进程首先会调用网络进程。
  • 渲染进程:核心任务是将HTML、CSS和JavaScript转换为用户可以交互的网页,排版引擎Blink和JavaScript引擎V8都是运行在该进程中,默认情况下,Chrome会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  • GPU进程:其实,Chrome 刚开始发布的时候是没有 GPU 进程的。而 GPU 的使用初衷是为了实现 3D CSS 的效果,只是随后网页、Chrome 的 UI 界面都选择采用 GPU 来绘制,这使得 GPU 成为浏览器普遍的需求。最后,Chrome 在其多进程架构上也引入了 GPU 进程。
  • 网络进程:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面的,直至最近才独立出来,成为一个单独的进程。
  • 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

当前架构

目前Chrome浏览器的架构正在发生一些改变,称为面向服务的架构(SOA),目的是将和浏览器本身(Chrome)相关的部分拆分为一个个不同的服务,服务化之后,这些功能既可以放在不同的进程里面运行也可以合并为一个单独的进程运行。这样做的主要原因是让Chrome在不同性能的硬件上有不同的表现。当Chrome运行在一些性能比较好的硬件时,浏览器进程相关的服务会被放在不同的进程运行以提高系统的稳定性。相反如果硬件性能不好,这些服务就会被放在同一个进程里面执行来减少内存的占用。

插件运行机制

在运行机制前,我们先来回顾一下打开页面会发生什么:

打开页面发生了什么

  • 用户新增一个tab,此时系统浏览器进程、渲染进程、GPU 进程、网络进程会被创建好;
  • 用户输入url,浏览器进程检查url,组装协议,构成完整的url;
  • 浏览器进程通过进程间通信(IPC)把url请求发送给网络进程;
  • 网络进程接收到url请求后检查本地缓存是否缓存了该请求资源,如果有则将该资源返回给浏览器进程;
  • 如果没有,网络进程向web服务器发起http请求(网络请求);
  • 网络进程解析响应流程;

1.检查状态码,非200执行状态码对应的处理逻辑;
2.200响应处理:检查响应类型Content-Type,如果是字节流类型,则将该请求提交给下载管理器,不再进行后续的渲染,如果是html则通知浏览器进程准备渲染进程进行渲染;

  • 准备渲染进程

1.浏览器进程检查当前url是否和之前打开的渲染进程根域名是否相同,如果相同,则复用原来的进程,如果不同,则开启新的渲染进程;

  • 传输数据、更新状态

1.渲染进程准备好后,浏览器向渲染进程发起“提交文档”的消息,渲染进程接收到消息和网络进程建立传输数据的“管道”;
2.渲染进程接收完数据后,向浏览器发送确认消息;
3.浏览器进程接收到确认消息后更新浏览器界面状态:安全、地址栏url、前进后退的历史状态、更新web页面;

打开插件发生了什么

插件的运行相较于页面会有简化

1.我们打开浏览器,新增一个空白tab页

2.tab栏空白处右键,选择任务管理器,打开任务管理器面板

3.可以看到运行了6个进程,分别是浏览器进程、GPU进程、网络进程、存储进程、渲染进程和扩展进程。

  • 扩展进程中运行Extension Page,主要包括backgrount.html和popup.html;

1.backgrount.html中没有任何内容,是通过background.js创建生成,当浏览器打开时,会自动加载插件的background.js文件,它独立于网页并且一直运行在后台,它主要通过调用浏览器提供的API和浏览器进行交互;
2.popup.html有内容的,跟我们普通的web页面一样,由html、css、Javascript组成,它是按需加载的,需要用户去点击地址栏的按钮去触发,才能弹出页面;

  • 渲染进程主要运行Web Page,当打开页面时,会将content_script.js加载并注入到该网页的环境中,它和网页中引入的Javascript一样,可以操作该网页的DOM Tree,改变页面的展示效果;
  • GPU进程主要为插件界面的渲染提供硬件能力支持;
  • 网络进程主要处理插件中的外部资源请求,比如nexydy插件依赖到一些外部js;
  • 存储进程为插件提供本地存储能力,比如使用chrome.storage.local进行持久化存储;
  • 浏览器进程在这里更多起到桥梁作用,作为中转可以实现Extension Page和content_script.js之间的消息通信。

插件基本介绍

版本发展

chrome插件存在三个版本,分别是Manifest V1、Manifest V2和Manifest V3。其中MV1版本已经被废弃了,目前市面上存在MV2和MV3版本,以MV2为主流,在被MV3慢慢取代。时间线:

Manifest V2新特性

https://developer.chrome.com/docs/extensions/mv2/manifestVersion/#manifest-v1-changes

  • 设置了默认的内容安全策略script-src 'self'; object-src 'self';。有关内容安全策略的详细配置,可以参考MDN文档;
  • 默认情况下,插件包内的资源不再可供外部网站使用。需要通过清单web_accessible_resources属性将其显式列入白名单;
  • browser action API更改;
  • page action API更改;
  • chrome.extension 代替 chrome.self 来指向插件本身;
  • chrome.extension.getTabContentses和chrome.extension.getExtensionTabs废弃,使用extension.getViews替代;
  • Port.tab废弃,使用runtime.Port替代;

Manifest V3新特性

  • Service worker替换Background Page;
  • 网络请求修改废弃webRequest API使用新的 declarativentrequest API 来处理;
  • 不再允许执行远程托管的代码,只能执行扩展包内包含的JS;
  • Promises 已经被添加到许多方法中,但仍支持回调作为替代方法;
  • Browser Action API 和 Page Action API被统一为单独的Action API;
  • Web可访问的资源,可以只对指定的站点和扩展可用;
  • 内容安全策略(CSP),现在可以为单个对象中的不同执行上下文指定单独的CSP;
  • executeScript的变化,不能再执行任意字符串,只能执行脚本文件和函数;

切换MV3会带来的问题

  • 由于background不再支持page页面配置background.html,因此也无法调用window对象上的XMLHttpRequest来构建ajax请求,也就是说我们不能像V2版本一样,在background.html中使用XMLHttpRequest来发送请求了,而是需要使用fetch来获取接口数据;
  • 由于service workers是短暂的,在不使用时会终止,这意味着它们在整个插件运行期间会不断的启动、运行和终止,也就是不稳定的;因此我们可能需要对V2中background.js的代码逻辑进行一些改造,以往我们会习惯将一些数据直接存储到全局变量,比如像下面这样:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// V2 background.js
let saveUserName = "";

// 其他页面,比如content-script或者popup中存储数据
chrome.runtime.onMessage.addListener(({ type, name }) => {
if (type === "set-name") {
saveUserName = name;
}
});

// 点击popup时展示数据
chrome.action.onClicked.addListener((tab) => {
// 这里saveUserName可能为空字符串
console.log(saveUserName, "saveUserName");
});
  • 因此在V3中,需要对这种全局变量数据进行改造,改造的方式也很简单,就是将数据持久化保存到storage中,需要用到的地方随用随取:
1
2
3
4
5
6
7
8
9
10
11
// V3 service worker
chrome.runtime.onMessage.addListener(({ type, name }) => {
if (type === "set-name") {
chrome.storage.local.set({ name });
}
});

chrome.action.onClicked.addListener(async (tab) => {
const { name } = await chrome.storage.local.get(["name"]);
chrome.tabs.sendMessage(tab.id, { name });
});
  • 由webRequest API切换至declarativentrequest API,很多代码逻辑需要重构;

为什么切换MV3?

从Manifest V1到Manifest V2,可以看到Chrome想提高插件的隐私和安全,同时也优化了不少API。而Manifest V3除了安全性更完善外,还在性能上下了功夫。Manifest V3 的核心非常明确,就是限制扩展对系统资源的使用。一直以来高资源占用都是 Chrome 为人诟病的痛点,而且扩展由于在后台运行,如果出现问题,更是难以定位和管理。虽然增加了诸多限制,但Manifest V3还是有优点的:

  • Service Worker 使扩展不再能常驻后台,让扩展所占用的资源可以被回收,降低了浏览器整体的开销;
  • 限制规则的数量,相当于控制了单一扩展在规则计算方面的资源使用上限;

这些变化可以让 Chrome 变得更加流畅,对于用户来说是好事。

展示形式

Chrome插件有以下常见的8中展现形式:

browserAction(浏览器右上角)

在浏览器右上角扩展程序一栏显示,包含一个图标、名称和popup

pageAction(地址栏右侧)

pageAction指的是在当某些特定页面打开才显示的图标。在早些版本的Chrome是将pageAction放在地址栏的最右边,左键单击弹出popup,右键单击则弹出相关默认的选项菜单。而新版的Chrome更改了这一策略,pageAction和普通的browserAction一样也是放在浏览器右上角,只不过没有点亮时是灰色的,点亮了才是彩色的,灰色时无论左键还是右键单击都是弹出选项。

右键菜单

通过开发Chrome插件可以自定义浏览器的右键菜单,主要是通过chrome.contextMenus API实现,右键菜单可以出现在不同的上下文,比如普通页面、选中的文字、图片、链接,等等。

override(覆盖特定页面)

使用override可以将Chrome默认的一些特定页面替换掉,改为使用扩展提供的页面。扩展可以替代如下页面:

  • 历史记录:从工具菜单上点击历史记录时访问的页面,或者从地址栏直接输入 chrome://history
  • 新标签页:当创建新标签的时候访问的页面,或者从地址栏直接输入 chrome://newtab
  • 书签:浏览器的书签,或者直接输入 chrome://bookmarks

devtools(开发者工具)

Chrome允许插件在开发者工具(devtools)上开发,主要表现在:

  • 自定义一个和多个和Elements、Console、Sources等同级别的面板;
  • 自定义侧边栏(sidebar),目前只能自定义Elements面板的侧边栏;

React Developer Tools

option(选项页)

插件的设置页面,可以在右上角入口右键,有一个选项标签

omnibox

omnibox是向用户提供搜索建议的一种方式,可以在搜索栏输入特定的标识然后按Tab进入搜索。

桌面通知

Chrome提供了一个chrome.notificationsAPI以便插件推送桌面通知,暂未找到chrome.notifications和HTML5自带的Notification的显著区别及优势。在后台JS中,无论是使用chrome.notifications还是Notification都不需要申请权限(HTML5方式需要申请权限),直接使用即可。

核心介绍

manifest.json

这是一个Chrome插件最重要也是必不可少的文件,用来配置所有和插件相关的配置,必须放在根目录。其中,manifest_version、name、version3个是必不可少的。

Manifest V2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
{
// 清单文件的版本,这里先使用2演示
"manifest_version": 2,
// 插件的名称
"name": "...",
// 插件的版本
"version": "1.0.0",
// 插件描述
"description": "...",
// 图标,一般偷懒全部用一个尺寸的也没问题
"icons": {
"16": "img/icon.png",
"48": "img/icon.png",
"128": "img/icon.png"
},
// 会一直常驻的后台JS或后台页面
"background": {
"scripts": ["js/background.js"]
},
// 浏览器右上角图标设置,browser_action、page_action、app必须三选一
"browser_action": {
"default_icon": "img/icon.png",
"default_title": "...",
"default_popup": "popup.html"
},
// 当某些特定页面打开才显示的图标
"page_action": {
"default_icon": "img/icon.png",
"default_title": "...",
"default_popup": "popup.html"
},
// 需要直接注入页面的JS
"content_scripts": [{
"matches": ["<all_urls>"],
"js": ["js/content-script.js"],
"css": ["css/custom.css"],
// 代码注入的时机,document_start, document_end, document_idle,默认document_idle
"run_at": "document_start"
},
],
// 权限申请
"permissions": [
"contextMenus", // 右键菜单
"tabs", // 标签
"notifications", // 通知
"webRequest", // web请求
"webRequestBlocking",
"storage", // 插件本地存储
"https://*/*" // 可以通过executeScript或者insertCSS访问的网站
],
// 普通页面能够直接访问的插件资源列表,如果不设置是无法直接访问的
"web_accessible_resources": ["js/inject.js"],
"homepage_url": "...", // 插件主页
"chrome_url_overrides": { // 覆盖浏览器默认页面
"newtab": "newtab.html"
},
"options_ui": { // 插件选项页
"page": "options.html",
"chrome_style": true
},
"omnibox": { "keyword" : "..." }, // 向地址栏注册一个关键字以提供搜索建议,只能设置一个关键字
"default_locale": "zh_CN", // 默认语言
"devtools_page": "devtools.html", // devtools页面入口,注意只能指向一个HTML文件,不能是JS文件
"content_security_policy": "...", // 安全策略
"web_accessible_resources": [ // 可以加载的资源
RESOURCE_PATHS
]
}

Manifest V3(仅展示与V2版本的不同点)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"manifest_version": 3,
"background": {
"service_worker": js/background.js"
},
"action": { //browser_action 和 page_action,统一为 Action
"default_icon": "img/icon.png",
"default_title": "这是一个示例Chrome插件",
"default_popup": "popup.html"
}
"content_security_policy": {
"extension_pages": "...",
"sandbox": "..."
},
"web_accessible_resources": [{
"resources": [RESOURCE_PATHS]
}]
}

content-scripts

是Chrome插件中向页面注入脚本的一种形式(虽然名为script,其实还可以包括css的),借助content-scripts我们可以实现通过配置的方式轻松向指定页面注入JS和CSS。content-scripts和原始页面共享DOM,但不共享JS。如要访问页面JS(例如某个JS变量),只能通过injected js来实现。content-scripts不能访问绝大部分chrome API,除了下面这4种:

  • chrome.extension
  • chrome.i18n
  • chrome.runtime
  • chrome.storage

这些API绝大部分时候都够用了,有需要调用其它API的话,可以通过通信让background或service worker来帮忙调用

background

后台是一个常驻的页面,它的生命周期是插件中所有类型页面中最长的,它随着浏览器的打开而打开,随着浏览器的关闭而关闭,所以通常把需要一直运行的、启动就运行的、全局的代码放在background里面。background的权限非常高,几乎可以调用所有的Chrome扩展API(除了devtools),而且它可以无限制跨域,可以跨域访问任何网站而无需要求对方设置CORS。background的概念在MV3版本中变为了service worker,区别在于生命周期变短了,service worker是短暂的基于事件的脚本,所以不适合用来保存全局变量。

popup是点击右上角图标时打开的一个小窗口网页,焦点离开网页就立即关闭,一般用来做一些临时性的交互。权限级别和background差不多,就是生命周期比较短。

injected-script

chrome插件中其实没有injected-script这一概念,这是开发者们在开发过程中衍生出来的一种概念,指的是通过DOM操作的方式向页面注入的一种JS。因为content-script无法访问页面中的JS,虽然可以操作DOM,但是DOM却不能调用它,也就是无法在DOM中通过绑定事件的方式调用content-script中的代码。但是在网页中增加一个按钮来调用插件的能力是一个比较常见的需求,所以诞生了injected-script。

插件通信机制

讲通信机制之前,先回顾一下插件中存在的脚本类型。Chrome插件的JS主要可以分为这5类:injected script、content-script、popup js、background js和devtools js。

权限对比

通过权限对比可以看到,每一种脚本在权限上都不相同,所以各种脚本间的相互通信就非常重要,这也是插件能够实现众多功能的基础。

通信概览

一些常见插件的实现思路

埋点日志检测

一般业务中都会进行一些埋点上报,埋点的本质就是发送一些带特定参数的请求,前端本地调试的时候想实时查看埋点信息通常需要去查看上报接口的入参,或者去对应的埋点平台查看,这样非常不方便。基于这个,我们可以使用插件来帮助我们快速的可视化查看埋点信息:

页面注入小工具

插件的另一个常见用法就是往页面注入一些工具代码,比如去除页面广告工具。

总结

  • 随着浏览器不断的发展,Chrome逐渐把一些基础服务独立出来,类似于一个跨平台的线上操作系统。
  • Chrome插件提供的能力很丰富,比如代码注入、跨域请求、持久化方案、各种通信机制等,开发者可以发挥想象,组装不同能力以适应不同场景的需求,基本可以实现现代web所能支持的所有功能。
  • Chrome插件MV2版本将在24年1月全面废弃,需要尽快迁移至MV3版本。

参考资料:
《浏览器工作原理与实践》:https://time.geekbang.org/column/intro/100033601?tab=catalog
《Inside look at modern web browser》:https://developer.chrome.com/blog/inside-browser-part1/
《图解浏览器的基本工作原理》:https://zhuanlan.zhihu.com/p/47407398
《Welcome to Manifest V3》:https://developer.chrome.com/docs/extensions/mv3/intro/
MDN文档:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CSP
web_accessible_resources:https://developer.chrome.com/docs/extensions/mv2/manifest/web_accessible_resources/

作者|闵子

原文链接