javascript-Chrome扩展实例(二)

Chrome扩展实例(二)

在Chrome扩展实例(一)中我们用一个例子走通了扩展开发的大体流程,实现了简单的换背景颜色功能。其中执行的js函数将上下文设置为浏览的网页页面,用的是chrome.scripting.executeScript API来改变上下文环境。这其实是Chrome扩展content script的一种,这即是本篇文章介绍的重点。

Content Script

Content script是扩展中运行在网页上下文中的typescript/javascript/css文件,它直接作用于网页的DOM,能够直接访问、修改网页元素。但是由于content script的上下文与扩展不同,因此扩展本身交互时需要消息传递机制。同时,content script并不能像service worker或popup那样可以使用几乎所有的Chrome API,它能够使用的Chrome API很有限。

Content Script可用接口

Content Script能够直接使用的Chrome API如下:

  • i18n (语言国际化接口)
  • storage (存储接口)
  • runtime (运行时接口)
    • connect
    • getManifest
    • getURL
    • id
    • onConnect
    • onMessage
    • sendMessage

虽然content script无法直接调用其他Chrome API,但是可以利用消息传递机制,通过service worker或popup等间接地调用其他Chrome API。

Content Script上下文

上下文可以说是Content Script最核心的内容。默认情况下,content script运行的上下文是一个独立的环境。这个独立环境是所在扩展所独享的,因此content script默认情况下只能操作所在扩展中的内容。扩展独立的空间保证content script的内容不会与网页页面内容、其他扩展的内容不会产生冲突。

但是,如果content script只能在扩展独立的空间中发挥作用,那么它就没法访问、修改网页元素,从而实现目标功能了。因此,Chrome提供一种叫做“Inject scripts”的技术,来修改content script执行时的上下文

Inject scripts改变上下问的方式有三种:

  1. 静态声明(declared statically)注入
  2. 动态声明(declared dynamically)注入
  3. 以编程方式注入(programmatically injected)

Inject Scripts

直观地说,Inject Scripts就是将扩展中typescript/javascript/css文件注入到特定的运行环境中,这样就能够用目标环境的上下文覆盖原来文件的上下文。而inject script的三种模式,可以根据开发需求,酌情选择。

静态声明(declared statically)注入

静态声明注入是inject script最常用的模式。这种模式需要在manifest.json文件中提前写入。优点是方便简洁,缺点是缺乏灵活性,需要提前对manifest.json内容进行规划。静态声明注入使用的manifest.json中的content_scripts字段,基本模式如下:

 1{
 2 "name": "扩展名称",
 3 //...
 4 "content_scripts": [
 5   {
 6     "matches": ["https://*.github/*"],
 7     "css": ["my-styles.css"],
 8     "js": ["content-script.js"],
 9     "run_at": "document_idle",
10     "match_about_blank": false,
11     "match_origin_as_fallback":true
12   }
13 ],
14 //...
15}

简单来说,content_script在匹配成功matches字段的网页中,注入js指定的javascript文件和css指定的css文件,其中jscss都可以指定一组文件。machesjscss此三个字段是content_script的核心字段,后面三个字段都是功能配置字段。matches字段的使用详情可参见文章https://developer.chrome.com/docs/extensions/mv3/match_patterns/以及补充内容https://developer.chrome.com/docs/extensions/mv3/content_scripts/#matchAndGlob。三种功能字段介绍如下:

  • run_at:在什么时候注入内容文件,有三个选项document_startdocument_enddocument_idle,默认选项是document_idle
    • document_start:DOM开始载入。
    • document_end:DOM主体部分载入完毕,资源文件(如图像、脚本)可能尚在载入中。
    • document_idle:DOM和资源文件全部载入完毕。
  • match_about_blank:如果matches字段能够匹配空页面about:blank,注入是否生效,默认false。常见于通配符匹配场景。
  • match_origin_as_fallback:当页面中包含框架(frame),如果框架的URL不匹配matches字段,但是框架所在的母网页匹配matches字段,内容注入是否生效,默认为true。这个属性适用于manifest V3及以上版本的扩展,同时由于HTML5中框架(frame)字段遭到删除,这条可能主要用于兼容老版本网页或<iframe>标签。

下面我们就用一个例子解释静态声明注入的用法。

实例:阅读时间统计

有了《chrome扩展入门》《chrome扩展实例(一)》两篇文章,我们对扩展的基本开发流程已有了基本的了解。现在我们就省略已知的步骤,快速实现一个新的扩展。首先,依旧是manifest.json文件:

 1{
 2  "manifest_version": 3,
 3  "name": "Reading time",
 4  "version": "1.0",
 5  "description": "估计阅读文章所需要的时间",
 6
 7  "icons": {
 8    "16": "images/icon-16.png",
 9    "32": "images/icon-32.png",
10    "48": "images/icon-48.png",
11    "128": "images/icon-128.png"
12  },
13  "content_scripts": [
14    {
15      "js": [
16        "scripts/content.js"
17      ],
18      "matches": [
19        "https://surprisedcat.github.io/studynotes/*",
20        "https://surprisedcat.github.io/projectnotes/*"
21      ]
22    }
23  ]
24}

manifest.json文件前面几项都没什么再需要解释的了,icons的素材来自URLhttps://github.com/GoogleChrome/chrome-extensions-samples/tree/main/functional-samples/tutorial.reading-time/images(如果链接失效,可以随自己喜好找合适的图片)。新增的内容content_scripts字段即为静态声明注入content_scripts,注入的js脚本来自扩展根目录下的scripts/content.js,扩展可用的网页是匹配matches字段的本人博客网页^_^。该扩展运行时,Chrome浏览器会给扩展提供网页URL,当matches字段匹配成功时,注入js脚本的功能生效(默认在网页完全载入的document_idle阶段启动注入)。

接下来,新建目录scripts并在其下新建js文件content.js,并添加如下代码:

 1//content_script.js
 2
 3//获取网页中的文章<article>标签(并不是所有网页都有,我的博客中有这个标签)
 4const article = document.querySelector("article");
 5//判断是否成功,不成功则返回null,也可能返回多个,为了方便我们先不考虑
 6if (article) {
 7    const text = article.textContent;//获取标签中文本
 8    //分中英文统计字符
 9    //中文,/\p{Unified_Ideograph}/ug 匹配所有中文
10    const chineseChar = text.matchAll(/\p{Unified_Ideograph}/ug);
11    const chineseNum = [...chineseChar].length;//计算文本长度
12    //英文,去除中文后再匹配
13    const englishChar = text.replace(/[^\w-]/g, ' ').matchAll(/[^\s]+/g);
14    const englishNum = [...englishChar].length;//计算文本长度
15    //假设我们每分钟阅读中文400个字,英文300单词,计算阅读时长
16    const readingTime = Math.round(chineseNum / 400+ englishNum / 300);
17    //创建<p>元素存放结果
18    const badge = document.createElement("p");
19    badge.textContent = `⏱️ ${readingTime} min read`;
20
21    //在<h1>标题后面添加阅读时间的<p>元素
22    const heading = article.querySelector("h1");
23    heading.insertAdjacentElement("afterend", badge);
24}

需要指出,这个content.js文件中的DOM操作针对的是本人博客网页的操作,并不能无缝移植到其他网页上。接下来,我们载入这个扩展。当我们打开一般网页时,这个扩展由于匹配字段并不成功,不会生效,只有当我们访问

1https://surprisedcat.github.io/studynotes/*
2https://surprisedcat.github.io/projectnotes/*

这两组网页时,扩展才会生效。那么,我们就访问上一篇文章《javascript-chrome扩展实例(一)》的URLhttps://surprisedcat.github.io/projectnotes/javascript-chrome%E6%89%A9%E5%B1%95%E5%AE%9E%E4%BE%8B%E4%B8%80/,显然时能够匹配的,注意看大标题“javascript-Chrome扩展实例(一)”下面确实多出了一行阅读时间。

chrome_extension_readingtime.png

静态声明注入实验成功!附:reading_time文件结构。

1reading_time
2   ├─manifest.json
3   ├─images
4   │    ├─icon-128.png
5   │    ├─icon-16.png
6   │    ├─icon-32.png
7   │    └─icon-48.png
8   └─scripts
9        └─content.js

动态声明(declared dynamically)注入

如果某些网站不是那么知名,扩展无法在manifest.json设计时就预见到或者对于某个匹配到的网站,并不是要总是注入内容脚本,需要在运行时再决定。对于这两种情况,静态声明注入就无法胜任,我们需要一种更灵活的内容注入方式,这就需要动态声明(declared dynamically)注入

从Chrome 96开始,我们可以调用chrome.scripting API进行动态声明注入,其主要方法包括:

  1. 注册content script:chrome.scripting.registerContentScriptschrome.scripting.insertCSS
  2. 查看当前所有动态注册的content script:chrome.scripting.getRegisteredContentScripts
  3. 更新content script:chrome.scripting.updateContentScripts
  4. 删除已注册的content script:chrome.scripting.unregisterContentScriptschrome.scripting.removeCSS

动态声明注入通常至少需要两个权限:activeTabscripting,其他权限根据所需的功能额外再提供。

区别与静态声明注入使用matches字段来决定哪些URL执行内容注入,动态声明注入使用注入目标(Injection targets)来决定内容注入对象。注入目标使用tabID来唯一决定。

tabID是Chrome标签页面window对象的ID,当我们打开多个tab页面时,每个tab页面都是一个独立的window对象,它们通过不同tabId区分,默认内容注入只在页面的主框架中有效。

实例:专注模式

现在网页上面有很多杂七杂八的元素,当我们阅读时很容易被这些元素分心,因此我们想做一个扩展,能够暂时性地让这些杂七杂八的元素消失,是我们能够更专心地阅读文章。本例使用的素材修改自官方教程https://developer.chrome.com/docs/extensions/mv3/getstarted/tut-focus-mode/

我们整体思路如下:先选CSDN网站为例,上面有很多妨碍阅读的元素,我们点击该扩展后,能将这些元素的CSS的display属性修改为none。假设我们已经提前写好了一个CSS文件,只要注入此文件就能实现(而非一个个地设置哪些元素应该不可见)。此外,我们还需要设置一个键盘快捷键,能够方便地在一般模式和专注模式中切换。

我们首先设计manifest.json文件,因为不需要静态声明注入,所以不需要content_scripts字段,取而代之的是动态声明注入所需要的activeTabscripting两个权限。

 1{
 2  "manifest_version": 3,
 3  "name": "Focus Mode",
 4  "description": "Enable reading mode on Chrome's official Extensions and Chrome Web Store documentation.",
 5  "version": "1.0",
 6  "icons": {
 7    "16": "images/icon-16.png",
 8    "32": "images/icon-32.png",
 9    "48": "images/icon-48.png",
10    "128": "images/icon-128.png"
11  },
12  "background": {
13    "service_worker": "background.js"
14  },
15  "action": {
16    "default_icon": {
17      "16": "images/icon-16.png",
18      "32": "images/icon-32.png",
19      "48": "images/icon-48.png",
20      "128": "images/icon-128.png"
21    }
22  },
23  "permissions": ["scripting", "activeTab"]
24}

manifest其他部分之前都应该讲解过了,现阶段唯一需要指出的是我们会在service worker的background.js中动态声明注入内容。

为了区分当前网页是一般模式还是专注模式,我们给扩展的图标添加一个小徽章(badge),当开启专注模式时显示“ON”,否则显示“OFF”。所谓“badge”就是在扩展图标上显示一些文本,可以用来更新一些小的扩展状态提示信息。因为“badge”空间有限,所以只支持4个以下的字符(英文4个,中文2个)。“badge”无法通过配置文件来指定,必须通过代码实现,设置badge文字和颜色可以分别使用chrome.action.setBadgeText({text: 'WORD'})chrome.action.setBadgeBackgroundColor({color:[255, 0, 0, 255]})

每次点击扩展图标,就换切换网页状态,同时badge状态也会跟着改变。

 1//background.js
 2
 3//初始状态下,状态为OFF
 4chrome.runtime.onInstalled.addListener(() => {
 5  chrome.action.setBadgeText({
 6    text: "OFF",
 7  });
 8});
 9
10//添加监听事件,点击扩展action图标
11//tab默认指当前的tab页面
12chrome.action.onClicked.addListener(async(tab) => {
13  //获取当前badge状态
14  const prevState = await chrome.action.getBadgeText({ tabId: tab.id });
15  //点击后的状态总是和之前相反
16  const nextState = prevState === 'ON' ? 'OFF' : 'ON';
17  //更改badge状态的文字
18  await chrome.action.setBadgeText({ tabId: tab.id, text: nextState });
19})

chrome_extension_badge.png

以上代码实现了Badge状态文字的切换。我们希望在CSDN的网页上实现开启专注模式时,杂乱元素不可见,因此我们要添加CSDN的URL作为判别条件,同时根据Badge状态文字,决定注入还是取消注入CSS文件。因此,完善后的background.js代码如下:

 1//background.js
 2
 3//初始状态下,状态为OFF
 4chrome.runtime.onInstalled.addListener(() => {
 5    chrome.action.setBadgeText({
 6        text: "OFF",
 7    });
 8    });
 9
10//目标URL
11const CSDN_url = 'https://blog.csdn.net/';
12    //添加监听事件,点击扩展action图标
13    //tab默认指当前的tab页面
14chrome.action.onClicked.addListener(async(tab) => {
15//如果以CSDN_URL开头则执行内容脚本
16    if(tab.url.startsWith(CSDN_url)){
17        //获取当前badge状态
18        const prevState = await chrome.action.getBadgeText({ tabId: tab.id });
19        //点击后的状态总是和之前相反
20        const nextState = prevState === 'ON' ? 'OFF' : 'ON';
21        //更改badge状态的文字
22        await chrome.action.setBadgeText({ tabId: tab.id, text: nextState });
23        //根据Badge状态文字执行注入CSS和取消注入CSS
24        if(nextState === "ON"){
25            await chrome.scripting.insertCSS({//注入CSS
26                files: ["css/csdn.css"],
27                target: { tabId: tab.id }
28            });
29        } else if (nextState === "OFF") {
30            await chrome.scripting.removeCSS({//取消注入CSS
31                files: ["css/csdn.css"],
32                target: { tabId: tab.id }
33            });
34        }
35    }
36})

这样就以动态声明注入实现了两种模式CSS的切换。csdn.css的内容如下:

 1/*CSDN 专注模式样式表*/
 2/*URL:  *blog.csdn.net/*   */
 3#csdn-toolbar{display: none}
 4#mainBox > aside{display: none}
 5#recommend-right{display: none}
 6#toolBarBox{display: none}
 7#mainBox > main > div.first-recommend-box.recommend-box{display: none}
 8#mainBox > main > div.second-recommend-box.recommend-box{display: none}
 9.csdn-side-toolbar {display: none}
10#pcCommentBox{display: none}
11#mainBox > main > div.recommend-box.insert-baidu-box.recommend-box-style{display: none}
12#mainBox > main > div.template-box{display: none}
13#mainBox > main > div.blog-footer-bottom{display: none}
14#blogHuaweiyunAdvert > div{display: none}
15#blogColumnPayAdvert{display: none}
16body > div.main_father.clearfix.d-flex.justify-content-center{width: 100%;background-color: aliceblue}
17#mainBox{width: 100%;background-color: aliceblue}
18#mainBox > main{width: 100%;background-color: aliceblue}
19#blogExtensionBox{display: none}
20#js_content > pre{display: none}
21/*使用内联样式表以及!important的顽固分子*/
22/*一般情况不要再扩展中使用!important*/
23#treeSkill{display: none!important}
24#pcCommentBox{display: none!important}
25#recommendNps{display: none!important}
26body.nodata{background: none!important;background-color:aliceblue!important;background-image: none!important}

现在我们可以重新载入扩展,选择CSDN的博客来测试下效果。示例URL:https://blog.csdn.net/wuyxinu/article/details/115839575

一般模式:

chrome_extension_focus_mode_before.png

专注模式:

chrome_extension_focus_mode_after.png

动态声明注入试验成功!附:focus_mode文件结构。

 1focus_mode
 2├─background.js
 3├─manifest.json
 4├─css
 5│   └─ csdn.css
 6└─images
 7    ├─icon-128.png
 8    ├─icon-16.png
 9    ├─icon-32.png
10    └─icon-48.png

为什么不太适合注入javascript

上面的例子我们动态注入的是CSS样式表,那么是否可以注入javascript文件呢?可以的。事实上,最开始我对官方示例改造时就用的是动态注入javascript。但是动态注入javascript时需要指定runAt,而之前我们说明runAt只有三个时刻,这三个时刻都需要重新载入网页,对于扩展来说实在不方便,因此我们采用了不需要重载网页的CSS样式表来做示例。

那么,有没有一种不需要重载网页就能运行javascript的内容注入模式呢?当然有的,就是下一节介绍的以编程方式注入

(可选)为扩展添加键盘快捷方式

为了使用方便,我们还可以给扩展添加键盘快捷方式。比如可以通过快捷键启用/关闭专注模式。我们只需要在manifest.json最后添加如下代码:

 1//...
 2{
 3  "commands": {
 4    "_execute_action": {
 5      "suggested_key": {
 6        "default": "Ctrl+B",
 7        "mac": "Command+B"
 8      }
 9    }
10  }
11}

command字段代表监听的键盘事件。_execute_action等同于点击扩展图标事件action.onClicked(),因此我们不需要额外添加任何代码。suggested_key则是指定的快捷键,对于不同的操作系统(win、mac)快捷键有所区别。

更多关于键盘快捷键的内容可参考https://developer.chrome.com/docs/extensions/reference/commands/

以编程方式注入(programmatically injected)

我们前来介绍的两种内容注入方式都或多或少有些缺点。静态声明注入需要开发者极富远见,在manifest.json设计阶段就能够决定未来各种情况,否则就得频繁地更新插件;动态声明注入后又得重载网页让javascript生效,十分麻烦。能否可以让注入的js内容实时生效呢?此时就可以使用以编程方式注入(programmatically injected)。它允许扩展使用chrome.scripting.executeScript API在特定事件或特殊场景执行内容注入。

以编程方式注入是最灵活多变的注入方法,它不仅可以像前两种方式那样选择Javascript或CSS文件进行注入,还可以选择可用的Javascript函数进行注入,例如在《javascript-chrome扩展实例(一)》中的修改背景颜色的实例,就是采用了Javascript函数注入的方式。但是如果该模式运用的不合理就会使得代码变得杂乱而无条理。其一般形式代码框架如下:

 1//使用js/css文件注入方式
 2chrome.action.onClicked.addListener((tab) => {
 3  chrome.scripting.executeScript({
 4    target: { tabId: tab.id },//注入的目标tab页面
 5    files: ["content-script.js"]//注入的文件
 6  });
 7});
 8
 9//使用js函数注入方式
10chrome.action.onClicked.addListener((tab) => {
11  chrome.scripting.executeScript({
12    target : {tabId : tab.id},//注入的目标tab页面
13    func : injectedFunction,//注入的函数,这个函数必须当前脚本可调用的
14    args : [ "arg1","arg2" ]//函数的参数
15  });
16});
17
18function injectedFunction() {
19  document.body.style.backgroundColor = "orange";
20}

请注意,在使用函数注入的方式时,注入的函数是chrome.scripting.executeScript调用中引用的函数的副本,而不是原始函数本身。因此,函数的主体必须是自包含的(self-contained),即不能使用函数以外的上下文内容;对函数外部变量的引用将导致内容脚本引发引用错误(ReferenceError)。

以编程方式注入通常至少需要两个权限:activeTabscripting,其他权限根据所需的功能额外再提供。

实例:无图模式

这个实例将使用以编程方式注入删除当前激活tab页面的所以图像元素<img>,这个简单的扩展只包含三种文件:manifest.jsonbackground.jsimages文件夹中的图标。

manifest.json文件如下:

 1{
 2    "manifest_version": 3,
 3    "name": "No Images",
 4    "version": "1.0",
 5    "description": "Remove all the images in the web pages",
 6    "background": {
 7        "service_worker": "background.js"
 8    },
 9    "action": {
10        "default_icon": {
11            "16": "images/icon-16.png",
12            "32": "images/icon-32.png",
13            "48": "images/icon-48.png",
14            "128": "images/icon-128.png"
15        }
16    },
17    "icons": {
18        "16": "images/icon-16.png",
19        "32": "images/icon-32.png",
20        "48": "images/icon-48.png",
21        "128": "images/icon-128.png"
22    },
23    "permissions": ["activeTab", "scripting"]
24}

这里没有什么新的知识。只需要注意以编程方式注入需要两种权限activeTabscripting。我们在background.js中实现删除图片元素的js功能代码:

 1//background.js
 2
 3chrome.action.onClicked.addListener(async(tab) => {
 4    if(!tab.url.includes("chrome://")) {//chrome设置页面不生效
 5    //以编程方式注入
 6    chrome.scripting.executeScript({
 7        target: { tabId: tab.id },//tabId默认是当前激活tab页面的ID
 8        function: removeImages//调用函数
 9    });
10    }
11});
12
13function removeImages(){
14    //选出所有<img>元素
15    const elememts = document.querySelectorAll("img");
16    if(elememts){
17        for(const item of elememts){
18            //元素删除的模式,并非直接删除,而是通过父元素删除
19            item.parentNode.removeChild(item);
20        }
21    }
22}

我们载入扩展,用百度主页来做测试:

之前,下图中百度图标是存在的:

chrome_extension_no_images_before.png

点击扩展后,百度主页的图标被删除了:

chrome_extension_no_images_after.png

以编程方式注入内容实验成功!

总结

本文以三种方式实现了CSS/Javascript内容的注入,分别是静态声明注入、动态声明注入和以编程方式注入。首先需要注意注入的内容所在上下文与扩展所在上下文的区别。注入内容的上下文需要与目标tab网页一致。其次,我们需要根据不同的场景选择合理的注入方式,还需要给予注入操作权限。内容注入是Chrome扩展实现其功能多样性的灵活。

参考文档

  1. Google官方文档https://developer.chrome.com/docs/extensions/mv3/
  2. Content Script https://developer.chrome.com/docs/extensions/mv3/content_scripts/
  3. Focus Mode https://developer.chrome.com/docs/extensions/mv3/getstarted/tut-focus-mode/
  4. Page redder https://github.com/GoogleChrome/chrome-extensions-samples/tree/main/functional-samples/sample.page-redder