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

发布时间 2023-12-11 16:47:50作者: lzhdim

浏览器架构演进

单进程浏览器时代

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

单进程浏览器的架构

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

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

多进程浏览器时代

早期架构

2008 年 Chrome 发布时的进程架构

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

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

近期架构

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

近期 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 请求(网络请求);
  • 网络进程解析响应流程;
    • 检查状态码,非 200 执行状态码对应的处理逻辑;
    • 200 响应处理:检查响应类型 Content-Type,如果是字节流类型,则将该请求提交给下载管理器,不再进行后续的渲染,如果是 html 则通知浏览器进程准备渲染进程进行渲染;
  • 准备渲染进程
    • 浏览器进程检查当前 url 是否和之前打开的渲染进程根域名是否相同,如果相同,则复用原来的进程,如果不同,则开启新的渲染进程;
  • 传输数据、更新状态
    • 渲染进程准备好后,浏览器向渲染进程发起 “提交文档” 的消息,渲染进程接收到消息和网络进程建立传输数据的 “管道”;
    • 渲染进程接收完数据后,向浏览器发送确认消息;
    • 浏览器进程接收到确认消息后更新浏览器界面状态:安全、地址栏 url、前进后退的历史状态、更新 web 页面;

打开插件发生了什么

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

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

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

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

  • 扩展进程中运行 Extension Page,主要包括 backgrount.html 和 popup.html;
    • backgrount.html 中没有任何内容,是通过 background.js 创建生成,当浏览器打开时,会自动加载插件的 background.js 文件,它独立于网页并且一直运行在后台,它主要通过调用浏览器提供的 API 和浏览器进行交互;
    • 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 的代码逻辑进行一些改造,以往我们会习惯将一些数据直接存储到全局变量,比如像下面这样:
// 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 中,需要用到的地方随用随取:
// 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

山海关插件 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 进入搜索。

JSON Viewer 插件

桌面通知

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

核心介绍

manifest.json

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

Manifest V2

{
// 清单文件的版本,这里先使用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 版本的不同点)

{
"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

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。

权限对比

JS 种类 可访问的 API DOM 访问情况 JS 访问情况 直接跨域
injected 和普通 JS 无任何差别,不能访问任何扩展 API 可以访问 可以访问 不可以
content 只能访问 extension、runtime 等部分 API 可以访问 不可以 不可以
popup 可访问绝大部分 API,除了 devtools 系列 不可直接访问 不可以 可以
background 可访问绝大部分 API,除了 devtools 系列 不可直接访问 不可以 可以
devtools 只能访问 devtools、extension、runtime 等部分 API 可以 可以 不可以

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

通信概览

  injected content popup background
injected - window.postMessage - -
content window.postMessage - chrome.runtime.sendMessage chrome.runtime.connect chrome.runtime.sendMessage chrome.runtime.connect
popup - chrome.tabs.sendMessage chrome.tabs.connect - chrome.extension. getBackgroundPage
background - chrome.tabs.sendMessage chrome.tabs.connect chrome.extension.getViews -
devtools chrome.devtools. inspectedWindow.eval - chrome.runtime.sendMessage chrome.runtime.sendMessage

一些常见插件的实现思路

埋点日志检测

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

页面注入小工具

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

总结

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