关于谷歌插件的开发基础写法可以先看一下:chrome 插件开发
一个完整的谷歌插件必须要有manifest.json(配置文件)、popup(窗口文件)、logo图标;
可选后台脚本(background scripts)、内容脚本(content scripts)
因为项目是基于 React+Vite 处理的,所以最后生成的文件夹就是我们打包的最终文件夹
- 首先我们在根目录下创建public 文件夹(vite关于public文件夹的说明),里面放manifest.json和logo图片
- 在build的时候将popup.html、background.js、content.js 文件一起打包组成安装包(下面会将打包流程)
创建插件最重要的一份文件就是 manifest.json 文件了
- manifest_version:注意是3
- action:配置在浏览器页面网址右边的插件图标、名称和打开页面(html)
- background:后台js文件
{
"short_name": "标签管理器",
"name": "TabsManager",
"version": "1.0.1",
"manifest_version": 3,
"permissions": ["storage", "contextMenus", "tabs", "background", "bookmarks"],
"action": {
"default_popup": "index.html",
"default_icon": {
"16": "./logo.png",
"32": "./logo.png",
"48": "./logo.png",
"128": "./logo.png"
},
"default_title": "窗口管理器"
},
"background": {
"service_worker": "background.js",
"type": "module"
},
"icons": {
"16": "logo.png",
"32": "logo.png",
"48": "logo.png",
"128": "logo.png"
}
}
在根目录下创建文件夹extentionUtils,里面放项目里所有调用的插件api(在manifest文件的permission里生命的权限),我项目中用到了tabs、window、storage和bookmarks所以创建了bookmarks.js、storage.js和tabUtils.js
但每次如果修改了代码就要重新打包文件再到chrome:://extentions刷新数据那也太不方便了,为了方便我们在本地浏览器开发插件,所以封装一个判断环境的方法:如果为浏览器环境就返回mockdata,否则就返回正常数据,拿tabUtils.js为例
/* eslint-disable no-undef */
import { mockWindowsData, mockTabsData } from '@/api/popup.js'
import { isExtentionEnv } from '@/utils.js'
/**
* 获取tabs列表
*/
export const getTabLists = (queryInfo = {}) => {
return new Promise(resolve => {
if (isExtentionEnv()) {
chrome.tabs.query(queryInfo, tabs => resolve(tabs))
} else {
resolve(mockTabsData)
}
})
}
// 创建新tab
export const createNewTab = (queryInfo = {}) => {
return chrome.tabs.create(queryInfo)
}
// 创建新窗口
export const createNewWindow = (params = {}, callback) => {
chrome.windows.create(params, window => {
callback && callback(window)
})
}
// 切换窗口
export const toggleWindow = windowId => {
return chrome.windows.update(windowId, { focused: true })
}
/**
* 获取当前tab
*/
export const getCurrentTab = () => {
return new Promise(resolve => {
if (isExtentionEnv()) {
chrome.tabs.query(
{
active: true,
currentWindow: true,
},
tabs => resolve(tabs[0])
)
} else {
resolve(1)
}
})
}
/**
* 切换tab
* @param {number} tab
* @param {number} windowId 当前窗口ID
*/
export const toggleTab = (tab, windowId) => {
toggleWindow(windowId)
chrome.tabs.highlight({ tabs: tab.index })
}
/**
* 删除tab
* @param {string}} type
*/
export const deleteTab = ids => {
return new Promise(resolve => {
if (isExtentionEnv()) {
chrome.tabs.remove(ids, () => {
resolve(true)
})
} else {
resolve(true)
}
})
}
// 移动tab
export const moveTabs = (ids, moveProperties) => {
if (isExtentionEnv()) {
return chrome.tabs.move(ids, moveProperties)
}
}
// window
/**
* 获取当前窗口ID
*/
export const getCurrentWindowId = () => {
return new Promise(resolve => {
if (isExtentionEnv()) {
return chrome.windows.getCurrent(({ id }) => {
if (!id) return
resolve(id)
})
} else {
resolve(973095260)
}
})
}
/**
* 获取所有窗口
*/
export const getAllWindow = () => {
return new Promise(resolve => {
if (isExtentionEnv()) {
chrome.windows.getAll({}, windows => {
resolve(windows)
})
} else {
resolve(mockWindowsData)
}
})
}
// 删除一个窗口
export const deleteWindow = windowId => {
if (isExtentionEnv()) {
return chrome.windows.remove(windowId)
}
}
const TabUtils = {
getAllWindow,
getCurrentWindowId,
deleteTab,
createNewTab,
deleteWindow,
moveTabs,
toggleTab,
getCurrentTab,
toggleWindow,
getTabLists,
createNewWindow,
}
export default TabUtils
卸载脚手架自带的 react-sctipts(这个也是内部通过webpack构建的)
- 安装vite相关包:
npm install --save-dev vite @vitejs/plugin-react
- 配置 vite.config.js 文件:
import { defineConfig } from 'vite'
import path from 'path'
import react from '@vitejs/plugin-react'
import reactRefresh from '@vitejs/plugin-react-refresh'
import AutoImport from 'unplugin-auto-import/vite'
import dotenv from 'dotenv'
dotenv.config()
export default defineConfig({
plugins: [
react(),
reactRefresh(), // 热更新
AutoImport({
include: [/\.[tj]sx?$/],
imports: ['react', 'react-router'],
}),
],
css: {
// less配置全局变量和公共样式
preprocessorOptions: {
less: {
additionalData: `@import "${path.resolve(__dirname, 'src/assests/index.less')}";`,
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
// 多页面打包,登录页要单独打包,下面会讲到为何这个样处理
build: {
emptyOutDir: true,
rollupOptions: {
input: {
index: path.resolve(__dirname, 'index.html'),
login: path.resolve(__dirname, '/pages/login.html'),
},
output: {
assetFileNames: 'assets/[name]-[hash].[ext]',
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
},
},
},
})
因为vite 本身提供了对.less文件的内置支持,所以直接安装包之后在vite.config.js 配上就好了:
css: {
preprocessorOptions: {
less: {
additionalData: `@import "${path.resolve(
__dirname,
"src/assests/var.less"
)}";`
}
}
},
npm install react-dom react-router-dom
- 根目录创建router文件夹
- 创建router对象,配置路由字典,这里路由模式为哈希模式(createHashRouter)
- **注意:**这里要用createHashRouter, createBrowserRouter浏览器可以运行但是build之后在插件popup会报错404,感觉可能是默认路由路径匹配的问题
注意在React的路由组件要大写开头:
// router.js
import { createHashRouter } from 'react-router-dom'
import PopupHome from '@/popup/pages/Home'
import Entry from '@/popup/pages/Entry'
import LaterPage from '@/popup/pages/LaterPage'
import TodoKeysPage from '@/popup/pages/TodoKeysPage'
import UrlsGroupPage from '@/popup/pages/UrlsGroupPage'
const routerConfigs = createHashRouter([
{
path: '/',
element: <PopupHome />,
},
{
path: '/popup',
element: <Entry />,
children: [
{
path: 'later',
name: '稍后再看',
element: <LaterPage />,
},
{
path: 'urlGroup',
name: '网页组',
element: <UrlsGroupPage />,
},
{
path: 'todoKeys',
name: '关键词记事本',
element: <TodoKeysPage />,
},
],
},
])
export default routerConfigs
- main.js 注册路由,项目的默认入口页面是popup页面
import React from "react"
import ReactDOM from "react-dom/client"
import Popup from "@/popup/index"
const root = ReactDOM.createRoot(document.getElementById("root"))
root.render(<Popup />)
// popup/index.js
import { RouterProvider } from 'react-router-dom'
import routers from '@/router/router.jsx'
function Entry() {
return <RouterProvider router={routers}></RouterProvider>
}
export default Entry
npm install --save redux react-reduxredux
npm install eslint eslint-plugin-react-hooks eslint-config-react-app --D
安装eslit和preitter就是项目基本操作了,具体可以看另一篇Prettier和Eslint.md
为什么有了axios文件还需要用fetch发请求呢?因为按照规范在background.js只能使用fetch发送请求,所以在项目里除了background.js里其他地方都使用axios处理请求
axios配置文件在/api/http.js
import axios from 'axios'
import storageUtils from '@/extentionUtils/storage'
import Store from '@/store/index'
axios.defaults.baseURL = import.meta.env.BASE_URL
axios.defaults.timeout = 10000
// 请求拦截器
axios.interceptors.request.use(
async config => {
return storageUtils.getStorageItem('token').then(val => {
val && (config.headers.Authorization = `Bearer ${val}`)
return config
})
},
error => {
return Promise.error(error)
}
)
// 响应拦截器
axios.interceptors.response.use(
response => {
if (response.status === 200 && response.data.error === 0) {
return Promise.resolve(response)
} else {
return Promise.reject(response)
}
},
// 服务器状态码不是2开头的的情况
error => {
if (error?.response?.status) {
switch (error.response.status) {
case 401:
case 403:
Store.dispatch({
type: 'get_user',
payload: {},
})
break
default:
console.log(JSON.stringify(error.response.data.msg))
}
return Promise.reject(error.response)
}
return Promise.reject(error)
}
)
/**
* get方法,对应get请求
* @param {String} url [请求的url地址]
* @param {Object} params [请求时携带的参数]
*/
export function get(url, params) {
return new Promise((resolve, reject) => {
axios
.get(url, {
params: params,
})
.then(res => {
resolve(res.data)
})
.catch(err => {
reject(err)
})
})
}
/**
* post方法,对应post请求
* @param {String} url [请求的url地址]
* @param {Object} params [请求时携带的参数]
*/
export function post(url, params) {
return new Promise((resolve, reject) => {
axios
.post(url, params)
.then(res => {
resolve(res.data)
})
.catch(err => {
reject(err)
})
})
}
popup页面主要是点击浏览器网页右边那个小图标展示的html,这里具体看你的业务需求
目前登录是采用的邮箱📮+验证码的方式,但是由于是插件的原因,在用户获取验证码之后打开新页面查看邮件的时候,浏览器是默认把插件的popup关闭,这样会导致拿到code后需要再次打开popup进入登录页面输入邮箱,所以考虑了两种方式:
- 因为验证码的有效期的30分钟,所以在每次用户打开popup页面时,判断用户为未登录状态&&已经点击过获取验证码(有效期内),自动跳转登录页面并回填邮箱
- **【目前实现】**把登录页面抽离插件成为一个单独的网页窗口,这样就可以避开切换选项卡popup页面关闭的问题了
如果把登录页面单独打包成一个页面,那就涉及了vite多页面打包的配置:
build: {
emptyOutDir: true,
rollupOptions: {
input: {
index: path.resolve(__dirname, 'index.html'),
// 把除了popup之外需要单独打包的页面都统一管理在pages文件夹下
login: path.resolve(__dirname, '/pages/login.html'),
},
output: {
assetFileNames: 'assets/[name]-[hash].[ext]',
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
},
},
},
background.js 就像是poup页面的js文件,但是不同的是:
- popup页面如果弹窗关闭就销毁了
- background 只要浏览器打开就是一直存在后台运行,可以在这里监听生命周期处理数据
这个项目在background.js 文件只要是在浏览器打开时,插件注册成功的时候初始化了右键菜单相关功能:
chrome.runtime.onInstalled.addListener(function () {
chrome.contextMenus.create({
id: 'tabs_extention_keys',
title: '添加到记事本',
type: 'normal',
contexts: ['selection'],
})
chrome.contextMenus.create({
id: 'tabs_extention_later',
title: '添加到稍后再看',
type: 'normal',
contexts: ['all'],
})
chrome.contextMenus.onClicked.addListener(async (menuInfo, tabInfo) => {
const token = await storageUtils.getStorageItem('token')
const { menuItemId } = menuInfo
switch (menuItemId) {
case 'tabs_extention_keys': // 关键词
case 'tabs_extention_later': // 稍后再看
postMenusMaps(menuItemId, menuInfo, tabInfo, token)
break
default:
break
}
})
})
content.js 主要是可以让我们的插件与浏览器页面进行交互(主要是对 DOM 元素进行增删改查等操作),比如修改页面样式,增加按钮、浮窗等控件,方便用户对网页进行操作,
content.js 还可以向当前页面注入 js 脚本,来自动测试网页、爬取网页数据等
后续悬浮窗等功能上线了,再来更新这里
我们需要将popup、background.js和content.js一起打包到dist文件,所以配置了三份vite.config.js文件,通过build.js 先将background和content打包到临时目录,再将文件copy到目标目录dist文件中
import path from 'path'
import fs from 'fs'
// 复制临时目录的文件到目标文件夹
const copyTemp2Build = (tempDir, tarDir) => {
if (!fs.existsSync(tarDir)) {
fs.mkdir(tarDir) // 如果不存在目标目录就创建目录
}
fs.readdirSync(tempDir).forEach(file => {
const tempPath = path.join(tempDir, file)
const tarPath = path.join(tarDir, file)
if (fs.lstatSync(tempPath).isDirectory()) {
copyTemp2Build(tempPath, tarPath)
} else {
fs.copyFileSync(tempPath, tarPath)
}
})
}
// 删除临时目录
const deleteTempDir = tempDir => {
if (fs.existsSync(tempDir)) {
fs.readdirSync(tempDir).forEach(file => {
const tempPath = path.join(tempDir, file)
if (fs.lstatSync(tempPath).isDirectory()) {
deleteTempDir(tempPath)
} else {
fs.unlinkSync(tempPath)
}
})
fs.rmdirSync(tempDir)
}
}
// content-script 临时打包目录
const tempContentDir = path.resolve(process.cwd(), process.env.TEMP_CONTENT_DIR)
const tempBackgroundDir = path.resolve(process.cwd(), process.env.TEMP_BACKGROUND_DIR)
const targetBuildDir = path.resolve(process.cwd(), process.env.TARGET_BUILD_DIR)
// 复制 content-script 和 background-script 的build 文件到最终build 目录中
copyTemp2Build(tempContentDir, targetBuildDir)
copyTemp2Build(tempBackgroundDir, targetBuildDir)
// 删除临时目录
deleteTempDir(tempContentDir)
deleteTempDir(tempBackgroundDir)
vite 默认打包入口是根目录的 index.html,这个用做popup页面,所以默认的vite.config.js主要是用来打包popup页面的
import { defineConfig } from 'vite'
import path from 'path'
import react from '@vitejs/plugin-react'
import reactRefresh from '@vitejs/plugin-react-refresh'
import AutoImport from 'unplugin-auto-import/vite'
export default defineConfig({
plugins: [
react(),
reactRefresh(), // 热更新
AutoImport({
include: [/\.[tj]sx?$/],
imports: ['react', 'react-router'],
}),
],
css: {
// CSS 预处理器的配置选项
preprocessorOptions: {
less: {
additionalData: `@import "${path.resolve(__dirname, 'src/assests/index.less')}";`, // 全局变量
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
build: {
emptyOutDir: true,
rollupOptions: {
input: {
// 配置所有页面路径,使得所有页面都会被打包
index: path.resolve(__dirname, 'index.html'),
login: path.resolve(__dirname, '/pages/login.html'),
},
output: {
assetFileNames: 'assets/[name]-[hash].[ext]',
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
},
},
},
})
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
build: {
outDir: process.env.TEMP_BACKGROUND_DIR,
lib: {
entry: [path.resolve(__dirname, 'src/background/index.jsx')],
formats: ['cjs'],
// 设置生成文件的文件名
fileName: () => {
// 将文件后缀名强制定为js,否则会生成cjs的后缀名
return 'background.js'
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
plugins: [react()],
})
import { defineConfig } from 'vite'
import path from 'path'
import react from '@vitejs/plugin-react'
// import { CRX_CONTENT_OUTDIR } from "./globalConfig"
import dotenv from 'dotenv'
dotenv.config()
export default defineConfig({
build: {
outDir: process.env.TEMP_CONTENT_DIR,
// https://cn.vitejs.dev/config/build-options.html#build-lib
lib: {
name: 'contentLib',
entry: path.resolve(__dirname, 'src/content/index.jsx'),
// content script不支持ES6,因此不用使用es模式,需要改为cjs模式
// formats: ["es", "cjs"],
fileName: () => {
return 'content.js'
},
},
rollupOptions: {
input: 'src/content/index.jsx',
output: {
assetFileNames: assetInfo => {
// 附属文件命名,content script会生成配套的css
return 'content.css'
},
},
},
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
define: {
'process.env.NODE_ENV': null,
},
plugins: [react()],
})
可以根据自己的需求选择打包单个文件或者全部文件
"scripts": {
"start": "vite",
"build": "vite build -c vite.config.js && vite build -c vite.content.config.js&& vite build -c vite.background.config.js && node build.js",
"build-content": "vite build -c vite.content.config.js && node build.js",
"build-popup": "vite build -c vite.config.js",
"build-background": "vite build -c vite.background.config.js",
},