写在前面
Chrome插件是一种用于增强Google Chrome浏览器功能的小型程序。它们允许开发者使用HTML、CSS、JavaScript等Web技术来创建能够修改浏览器行为、添加新功能或与网页交互的扩展。我将以自己花了两天写的一个小项目为例,在接下来的几篇博客中介绍一个插件的开发过程和踩坑经历。
这篇博客主要参考自Google官方教程和2023金秋版:基于Vite4+Vue3的Chrome插件开发教程,在此表示感谢。
项目开源仓库地址在这里,欢迎品鉴
IE,你的太阳落山了
Chrome插件的基本构成
chrome插件由以下几部分构成:
- manifest.json - 配置文件
- popup - 点击插件按钮弹出页面
- content script - 插入目标页面执行脚本
- service worker script - 在Chrome后台一直运行的脚本
manifest.json
manifest.json必须放在插件项目根目录,里面包含了插件的各种配置信息,其中也包括了popup、content script、service worker script等文件的存放路径。
说到这里,本次介绍的插件版本为Manifest V3,是最新版扩展程序平台,提供了支持Promise,Service Worker等更加便利的方法。
作为一个独立的弹出页面,有自己的html、css、js,可以按照常规项目来开发。
content script
content script是注入到目标页面中执行的js脚本,可以获取目标页面的Dom并进行修改。但是,content script的JavaScript与目标页面是互相隔离的。也就是说,content script与目标页面的JavaScript不会出现互相污染的问题,同时,也不能调用对方的方法。
注意,以上只是js作用域的隔离,通过content script向目标页面加入的DOM可以应用目标页面的css,从而造成css互相污染。
service worker script
service worker script 常驻在浏览器后台Service Workers运行,没有实际页面。一般把全局的、需要一直运行的代码放在这里。重要的是,service worker script的权限非常高,除了可以调用几乎所有Chrome Extension API外,还可以发起跨域请求。
在Manifest V2中,这个部分被称为background script,似乎更能从字面含义上理解。本文之后对这部分的介绍也使用background script的称谓。
与service worker(V3)最大的区别是,V2的background会一直在后台运行,这无疑会占用部分资源。V3的service worker仅在需要的时候运行。
项目构建与基本结构
通过以上介绍,可以认识到一个Chrome插件需要manifest.json 、popup、content script、service worker script四个部分组成。实际上,这与普通网页的开发差别不大。
这次,我们使用Vite4
+Vue3
来进行开发,前置要求是安装nodeJS
。同时,使用ant-design-vue
作为UI库,使用WebStorm
作为开发工具。
Vite项目创建
先进入想要创建项目的目录,在这个目录下执行安装命令。
如果使用npm
,执行:
执行后,会要求填写项目名称
1
| Project name: glados-daily-checkin
|
然后,会要求选择框架,选择Vue
:
1 2 3 4 5 6 7 8 9 10
| ? Select a framework: » - Use arrow-keys. Return to submit. Vanilla > Vue React Preact Lit Svelte Solid Qwik Others
|
最后,选择开发语言,本教程选择TypeScript:
1 2 3 4 5
| ? Select a variant: » - Use arrow-keys. Return to submit. > TypeScript JavaScript Customize with create-vue ↗ Nuxt ↗
|
如果一切正常,此时我们便完成了一个vite
项目的创建。
查看和运行创建好的项目:
1 2 3
| cd glados-daily-checkin npm install npm run dev
|
打开以下网址就可以看到我们创建好的项目:
ant-design-vue 引入
本项目使用ant-design-vue
作为UI库,因此需要引入:
1
| npm install ant-design-vue@4.x --save
|
自动按需引入组件
unplugin-vue-components
如果你使用的是 Vite
,推荐使用 unplugin-vue-components
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| $ npm install unplugin-vue-components -D // vite.config.js import { defineConfig } from 'vite'; import Components from 'unplugin-vue-components/vite'; import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers'; export default defineConfig({ plugins: [ // ... Components({ resolvers: [ AntDesignVueResolver({ importStyle: false, // css in js }), ], }), ], });
|
然后你可以在代码中直接引入 ant-design-vue
的组件,插件会自动将代码转化为 import { Button } from 'ant-design-vue'
的形式。
1
| import { Button } from 'ant-design-vue';
|
Chrome插件基本结构配置
上述步骤创建得到的文件结构应该是这样的
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| glados-daily-checkin ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public │ └── vite.svg ├── src │ ├── App.vue │ ├── assets │ │ └── vue.svg │ ├── components │ │ └── HelloWorld.vue │ ├── main.ts │ ├── style.css │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts
|
为了适应插件的开发,可以将这个结构先简化为这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| glados-daily-checkin ├── README.md ├── index.html ├── package-lock.json ├── package.json ├── public │ └── favicon.ico ├── src │ ├── App.vue │ ├── main.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts
|
vite-env.d.ts是vite的环境变量配置,可根据实际项目需求选择是否删除
当然,此时项目会因为缺少被删除的文件而报错,需要进行以下修改:
App.vue
1 2 3 4 5 6 7 8 9 10 11
| <template> Anyway the wind blows, doesn't really matter to me </template>
<script setup lang="ts">
</script>
<style scoped>
</style>
|
index.html
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Glados Daily Checkin</title> </head> <body> <div id="app"></div> <script type="module" src="/src/main.ts"></script> </body> </html>
|
注意link中的/favicon.ico,他应该放在public文件夹下,是一个自己添加的图标或矢量文件
main.ts
1 2 3 4
| import { createApp } from 'vue' import App from './App.vue'
createApp(App).mount('#app')
|
重新运行项目,可以看到页面发生了变更
现在让我们对结构进行魔改,以适应插件开发的需要:
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
| glados-daily-checkin ├── README.md ├── components.d.ts ├── globalConfig.js ├── index.html ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── images │ │ └── app.png │ └── manifest.json ├── src │ ├── background │ │ └── index.js │ ├── content │ │ ├── index.css │ │ └── index.js │ └── popup │ ├── main.ts │ └── popup.vue ├── tsconfig.json ├── tsconfig.node.json ├── vite.background.config.ts ├── vite.content.config.ts └── vite.popup.config.ts
|
变化最大的是src文件夹。在其中创建了三个文件夹background、content、popup,与插件的三个部分对应。注意每个模块都有对应的index.js,而popup文件夹下是以main.ts+popup.vue的文件形式出现的,这意味着我们实际上在popup文件夹中创建了一个vue项目。事实上,在三个文件夹中都可以分别独立地创建对应的vue文件以模块化开发。
那么决定插件各模块入口的文件,也就是manifest.json,被我放置在了public中。manifest.json文件内容如下,实际开发时需要去掉注释:
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
| { "name": "Glados自动签到插件", "version": "1.0", "description": "支持浏览器启动时自动签到,查看用户当前点数与剩余天数,若签到失败请重新登录一次", "manifest_version": 3, "background": { "service_worker": "background.js" }, "content_scripts": [ ], "permissions": ["storage","declarativeContent","cookies","notifications"], "host_permissions":["<all_urls>"], "web_accessible_resources": [ { "resources": [ "/images/app.png" ], "matches": ["<all_urls>"] } ], "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" }, "action": { "default_popup": "index.html", "default_icon": { "16": "/images/app.png", "32": "/images/app.png", "48": "/images/app.png", "128": "/images/app.png" }, "default_title": "Glados-Daily-Checkin" }, "icons": { "16": "/images/app.png", "32": "/images/app.png", "48": "/images/app.png", "128": "/images/app.png" } }
|
可以发现,配置文件中三个模块的入口文件与我们现在的文件名称和位置均有不同。这主要是因为最后build出来的文件包并没有模块文件夹的划分,而是以独立的js文件存在。同样的,vue文件也会在最后变成不同的文件,与我们开发时的文件结构有所不同。build文件结构如下:
1 2 3 4 5 6 7 8 9 10 11 12
| build ├── assets │ ├── index-siNZXYyQ.css │ └── index-ty4cvEqm.js ├── background.js ├── content.css ├── content.js ├── favicon.ico ├── images │ └── app.png ├── index.html └── manifest.json
|
对比可以得出,文件的转换逻辑如下:
1 2 3 4 5 6 7 8 9 10 11
| [popup] src/popup/main.ts + popup.vue => assets/index-siNZXYyQ.css + index-ty4cvEqm.js
[content] src/content/index.js + index.css => content.js + content.css
[background] src/background/index.js => background.js
[public] 文件夹中文件均移动至根目录下
|
因此,我们需要对vite的输出配置进行更改,这样在最后build时才能将各个模块文件夹中的文件更名整合到一起:
globalConfig.ts
这个ts文件主要用于定义目录名
1 2 3 4 5 6 7
| export const CRX_OUTDIR = 'build'
export const CRX_CONTENT_OUTDIR = '_build_content'
export const CRX_BACKGROUND_OUTDIR = '_build_background'
|
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
| import {defineConfig} from 'vite' import vue from '@vitejs/plugin-vue' import path from "path"
import {CRX_OUTDIR} from "./globalConfig.js"; import Components from 'unplugin-vue-components/vite'; import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
export default defineConfig({ build: { outDir: CRX_OUTDIR }, server: { port: 10086, }, resolve: { alias: { '@': path.resolve(__dirname, 'src'), } }, plugins: [ vue(), Components({ resolvers: [ AntDesignVueResolver({ importStyle: false, }), ], }), ], })
|
vite.content.config.ts
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
| import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue'
import path from 'path' import { CRX_CONTENT_OUTDIR } from './globalConfig.js'
export default defineConfig({ build: { outDir: CRX_CONTENT_OUTDIR, lib: { entry: [path.resolve(__dirname, 'src/content/index.js')], formats: ['cjs'], fileName: () => { return 'content.js' }, }, rollupOptions: { output: { assetFileNames: (assetInfo) => { return 'content.css' }, }, }, }, resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, }, define: { 'process.env.NODE_ENV': null, }, plugins: [vue()], })
|
vite.background.config.ts
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
| import {defineConfig} from 'vite' import vue from '@vitejs/plugin-vue'
import path from "path" import {CRX_BACKGROUND_OUTDIR} from './globalConfig.js'
export default defineConfig({ build: { outDir: CRX_BACKGROUND_OUTDIR, lib: { entry: [path.resolve(__dirname, 'src/background/index.js')], formats: ['cjs'], fileName: () => { return 'background.js' } }, }, resolve: { alias: { '@': path.resolve(__dirname, 'src'), }, },
plugins: [vue()], })
|
build.js
这个脚本文件用于将临时生成的文件进行最终合并
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
| import fs from 'fs'
import path from 'path' import { CRX_OUTDIR, CRX_CONTENT_OUTDIR, CRX_BACKGROUND_OUTDIR } from './globalConfig.js'
const copyDirectory = (srcDir, destDir) => { if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir) }
fs.readdirSync(srcDir).forEach((file) => { const srcPath = path.join(srcDir, file) const destPath = path.join(destDir, file)
if (fs.lstatSync(srcPath).isDirectory()) { copyDirectory(srcPath, destPath) } else { fs.copyFileSync(srcPath, destPath) } }) }
const deleteDirectory = (dir) => { if(fs.existsSync(dir)) { fs.readdirSync(dir).forEach((file) => { const curPath = path.join(dir, file) if (fs.lstatSync(curPath).isDirectory()) { deleteDirectory(curPath) } else { fs.unlinkSync(curPath) } }) fs.rmdirSync(dir) } }
const contentOutDir = path.resolve(process.cwd(), CRX_CONTENT_OUTDIR)
const backgroundOutDir = path.resolve(process.cwd(), CRX_BACKGROUND_OUTDIR)
const outDir = path.resolve(process.cwd(), CRX_OUTDIR)
copyDirectory(contentOutDir, outDir) copyDirectory(backgroundOutDir, outDir)
deleteDirectory(contentOutDir) deleteDirectory(backgroundOutDir)
|
popup/main.ts
以上配置完成后,对popup文件夹内main.ts文件进行调整,让它能够使用popup.vue文件:
1 2 3 4 5 6
| import { createApp } from 'vue'
import Popup from "@/popup/popup.vue";
const app = createApp(Popup) app.mount('#app')
|
index.html
然后,需要修改根目录下的index.html文件,让他指向popup文件夹的main.ts文件,也就是将popup模块作为插件总入口:
1 2 3 4 5 6 7 8 9 10 11 12 13
| <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <link rel="icon" type="image/svg+xml" href="/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Glados Daily Checkin</title> </head> <body style="margin: 0"> <div id="app"></div> <script type="module" src="/src/popup/main.ts"></script> </body> </html>
|
最后,修改package.json文件中的script设置,让各个文件联动打包:
package.json
1 2 3 4 5 6 7 8
| { ... "scripts": { "dev": "vite -c vite.popup.config.ts", "build": "vite build -c vite.popup.config.ts && vite build -c vite.content.config.ts && vite build -c vite.background.config.ts && node build.js", }, ... }
|
现在,项目基本配置完成。但你可能会疑惑dev为什么只对popup模块生效。这是因为manifest V3版本从安全性考虑禁止了插件包的页面热更新。也就是说,每次都需要build才能更新一次插件。这对于popup这种需要调试css、html的实际页面来说无疑是非常拖后腿的。因此,可以单独开启对popup的dev构建选项,此时可以直接在浏览器页面预览和调试popup页面。
那么更大的疑惑来了:content作为插入页面的模块,也需要时刻调试,他怎么实现热更新呢?
考虑到content作为脚本,可以通过以下的方式在popup.vue中调用:
1 2 3 4
| <script setup> // 在popup页面调试content script,仅用于开发环境,build前记得要注释掉。 import '@/content' </script>
|
如果一切顺利,你可以使用npm run dev
运行项目,使用以下的命令构建最终程序包:
开发辅助工具
这里介绍可以提高效率和代码质量的几个工具
chrome-types
如果你使用 VSCode 或 WebStorm 等代码编辑器进行开发,可以通过npm
软件包 chrome-types 来利用完成自动填充与类型检查功能。当 Chromium 源代码发生更改时,此 npm 软件包会自动更新。
如果你使用WebStorm进行开发,可以直接在WebStorm - Settings - Language & Frameworks - JavaScript - Libraries 下安装chrome库即可
Vue 官方发布的调试浏览器插件,可以安装在 Chrome 和 Firefox 等浏览器上,直接内嵌在开发者工具中,使用体验流畅。
可以在官网下载安装
不多说了,自己试试看就知道了
总结
这篇博客详细介绍了一个Chrome扩展程序开发的项目配置与构建细节,这只是踏出的第一步。下一篇博客将具体阐述popup模块的开发过程,以及目标网站的请求处理,敬请期待。