基于React、Antd、Ts开发chrome拓展
官方API文档
API Reference - Chrome Developers
版本选择
- V2 (将支持到2023年)

- V3 (≥ chrome88)
优先选择mv3版本
核心部分
- manifest.json
- content
- background
- devtool-page
- popup-page
- options-page
项目配置
可以自定义配置或者通过CRA直接创建,原理一样
- 创建项目
yarn create react-app my-app --template typescript
cd my-app
yarn add babel-plugin-import less less-loader customize-cra html-webpack-plugin react-app-rewired @types/chrome cross-env -D
yarn add antd chrome-extension-core -S
- 修改package.json配置
"scripts": {
"start": "react-app-rewired start",
"start:hot": "cross-env CHROME_HOT=true node ./script/start.js start",
"start:disk": "cross-env CHROME_HOT=false WRITE_TO_DISK=true node ./script/start.js start"
}
- 部分目录结构(本质上就是一个多页打包)
├── script
│ ├── reload.js // reload服务配置
│ ├── start.js // 启动script
├── src
│ ├── reload // 开发节点reload插件
│ │ ├── background.ts
│ │ ├── event.ts
│ │ └── content.ts
│ ├── background // background入口文件
│ │ └── index.ts
│ ├── event
│ │ └── index.ts
│ ├── store
│ │ └── index.ts
│ ├── contentScript // contentScript 入口文件,将插入到页面dom结构里
│ │ ├── index.ts
│ │ └── visionContent.ts
│ ├── renderContent // 渲染content的react文件
│ │ ├── views
│ │ │ └── Home
│ │ │ └── index.tsx
│ │ └── index.tsx
│ └── pages
│ ├── popup // popup-pages
│ │ ├── index.less
│ │ ├── App
│ │ │ └── index.tsx
│ │ └── index.tsx
│ └── options // options-pages
│ ├── index.css
│ ├── index.tsx
│ └── App
│ └── index.tsx
├── public
│ ├── logo192.png
│ ├── options.html
│ ├── popup.html
│ └── manifest.json
- manifest配置
{
"action": {
"default_icon": "icon192.png",
"default_popup": "popup.html"
},
"background": {
"service_worker": "background.js"
},
"description": "chromeExtensionsTemplate",
"host_permissions": ["<all_urls>"],
"manifest_version": 3,
"name": "chromeExtensionsTemplate",
"offline_enabled": false,
"options_page": "options.html",
"permissions": [
"tabs",
"activeTab",
"storage",
"scripting"
],
"content_scripts": [
{
"matches": ["<all_urls>"],
"css": [],
"js": ["visionContentScript.js"],
"run_at": "document_end"
}
],
"version": "0.0.1",
"web_accessible_resources": [
{
"matches": ["<all_urls>"],
"resources": []
}
]
}
- webpack部分配置(通过customize-cra进行修改) 完整配置
// 检测是否热更新插件
const isHotReloadChrome = process.env.CHROME_HOT === 'true';
// 是否仅写入硬盘
const writeToDisk = process.env.WRITE_TO_DISK === 'true';
const removePlugin = (plugins, name) => {
const list = plugins.filter(
(it) => !(it.constructor && it.constructor.name && name === it.constructor.name),
);
if (list.length === plugins.length) {
throw new Error(`Can not found plugin: ${name}.`);
}
return list;
};
const getWebpackConfig = (webpackEnv) => {
const isEnvProduction = webpackEnv === 'production';
const htmlWebpackPluginConfig = isEnvProduction
? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}
: {};
return [
addWebpackPlugin(
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'public/popup.html'),
chunks: ['popup'],
filename: `popup.html`,
...htmlWebpackPluginConfig,
}),
),
addWebpackPlugin(
new HtmlWebpackPlugin({
template: path.resolve(__dirname, 'public/options.html'),
chunks: ['options'],
filename: `options.html`,
...htmlWebpackPluginConfig,
}),
),
].filter(Boolean);
};
module.exports = {
webpack: (config, webpackEnv) => {
// 多入口
config.entry = {
popup: './src/pages/popup/index.tsx',
options: './src/pages/options/index.tsx',
background: [
'./src/background/index.ts',
isHotReloadChrome && './src/reload/background.ts',
].filter(Boolean),
visionContentScript: [
'./src/contentScript/index.ts',
isHotReloadChrome && './src/reload/content.ts',
].filter(Boolean),
};
config.output = {
filename: '[name].js',
path: __dirname + '/build',
publicPath: './',
};
// 删除这几个默认插件
config.resolve.plugins = removePlugin(config.resolve.plugins, 'ModuleScopePlugin');
config.plugins = removePlugin(config.plugins, 'WebpackManifestPlugin');
config.plugins = removePlugin(config.plugins, 'HtmlWebpackPlugin');
return override(...getWebpackConfig(webpackEnv))(config, webpackEnv);
},
devServer: overrideDevServer((config) => {
return {
...config,
devMiddleware: {
...config.devMiddleware,
writeToDisk: writeToDisk || isHotReloadChrome || false, // 开发阶段实时生成文件
},
// 获取devServer,配置热更新
onBeforeSetupMiddleware: (devServer) => {
isHotReloadChrome && reload(devServer);
config.onBeforeSetupMiddleware(devServer);
},
};
}),
paths: function (paths, env) {
paths.appIndexJs = `${basePaths.appSrc}/pages/popup/index.tsx`;
paths.appHtml = `${basePaths.appPath}/public/popup.html`;
return paths;
},
};
热更新配置
对于popup、options、devtool静态页面来说,只需要配置webpack将开发阶段生成的文件写入磁盘即可。对于background来说则需要重新reload一遍才能生效,chrome正好提供了API[chrome.runtime.reload]可以使background重新reload。
- reload-server
本质就是通过启动的devServer配置一个长链接接口,SSE或者WS都可以,这里使用的是SSE,在webpack每次打包结束后hooks的done事件时发送消息到客户端
function ReloadServer({ app, compiler }) {
app.get('/reload', (request, response, next) => {
response.setHeader('Content-Type', 'text/event-stream');
response.setHeader('Connection', 'keep-alive');
response.setHeader('Cache-Control', 'no-cache');
response.setHeader('Access-Control-Allow-Origin', '*');
response.writeHead(200);
let isEnd = false;
compiler.hooks.done.tap('chrome reload plugin', () => {
if (isEnd) return;
response.flushHeaders();
const data = `data: ${JSON.stringify({ time: new Date() })}\n\n`;
response.write(data);
response.end();
isEnd = true;
});
request.on('close', () => {
isEnd = true;
response.end();
});
});
}
module.exports = ReloadServer;
- reload-client
background接收到更新消息之后再通过chrome.runtime.sendMessage发送消息到content,这里通过封装好的事件包 www.npmjs.com/package/chr…。
// /reload/background.ts
import { debounce } from '@/common/utils/function';
import { getTab } from 'chrome-extension-core';
import { hotReloadEvent } from './event';
const { DEV_PORT } = devConfig || {};
if (DEV_PORT) {
// 添加ssh长链接
const eventSource = new EventSource(`http://127.0.0.1:${DEV_PORT}/reload/`);
const onMessage = async () => {
const tabInfo = await getTab({ active: true });
// 发送消息给当前激活中的content,content进行reload
hotReloadEvent.emit('chromeHotReload', true, { type: 'tab', id: tabInfo?.id }).finally(() => {
chrome.runtime?.reload?.();
});
};
eventSource.onmessage = debounce(onMessage, 1000);
}
// /reload/content.ts
import { hotReloadEvent } from './event';
const { DEV_PORT } = devConfig || {};
hotReloadEvent.on('chromeHotReload', () => {
setTimeout(() => {
// 延迟500ms,避免后台更新时前台也刷新,开启header劫持时会出现crash
window.location.reload();
}, 500);
});
Content渲染
- renderContent
透出mount和unmount方法,可以在contentScript中控制何时渲染
import React from 'react';
import ReactDOM from 'react-dom';
import Home from '@/renderContent/views/Home';
type Props = {
container?: Element | null;
};
const render = (props: Props) => {
const { container } = props;
if (container) {
ReactDOM.render(
<Home></Home>,
container,
);
}
};
export async function mount(props: Props) {
render(props);
}
export async function unmount(props: Props) {
const { container } = props;
container && ReactDOM.unmountComponentAtNode(container);
}
- contentScript
自定义决定何时渲染和卸载,demo是由popup控制
import { mount, unmount } from '@/renderContent';
class Content {
container: HTMLDivElement | undefined;
id: string;
constructor() {
this.id = `chrome-extensions-template-${VERSION.split('.')?.join?.('-')}`;
}
create() {
if (!this.container && !this.getContainer()) {
this.container = document.createElement('div');
this.container.id = this.id;
document.body.appendChild(this.container);
mount({ container: this.container });
}
}
destroy() {
const container = this.container || this.getContainer();
if (container) {
unmount({ container });
this.container?.parentNode?.removeChild(container);
this.container = undefined;
}
}
private getContainer() {
return document.querySelector(`#${this.id}`);
}
}
function listener(content: Content) {
chromeEvent.on('createContent', (needSet) => {
if (needSet) {
content.create();
} else {
content.destroy();
}
});
}
const content = new Content();
listener(content);
样式隔离
content将会直接插入到dom结构中,必然会出现样式污染,使用styleModule也无法解决组件库的样式污染。
引入antd组件会插入影响全局样式的style,可能会导致样式错乱。
极端情况可能会出现,整个背景被影响
隔离样式主要有两个方案:
-
iframe隔离,chrome提供了API可以直接嵌入iframe page,或者也可以手动写入iframe节点来完成样式隔离。
优点:完全隔离,兼容性较好
缺点:受限于iframe的视窗限制,较难实现message或者dialog等弹窗。
-
shadowDom隔离
优点:样式隔离,没有视窗限制,和正常dom一样
缺点:某些富文本无法使用,需要支持shadowDom的富文本。css需要额外处理插入到shadom节点里。
以下是基于shadowDom的隔离方案
- 配置less-loader
shadowDom需要把css嵌入shadow节点里,故此需要获取到css
// 复制addLessLoader进行修改
// 添加resourceQuery
resourceQuery: /toString/,
// 针对toString类型的less去除style-loader
const loaders = [
needStyleLoader && isEnvDevelopment && require.resolve('style-loader'),
needStyleLoader &&
isEnvProduction && {
loader: MiniCssExtractPlugin.loader,
options: shouldUseRelativeAssetPaths ? { publicPath: '../../' } : {},
}
]
全部代码参考:
chrome-extensions-template/addLessLoader.js at main · crywolfx/chrome-extensions-template
- 安装react-shadow
yarn add react-shadow -S
- 配置Style组件
export default function Style(props: { styles: any[] }) {
return (
<>
{props.styles?.map?.((item, key) => {
return item ? (
// eslint-disable-next-line react/no-array-index-key
<style type="text/css" key={key}>
{item?.toString?.()}
</style>
) : null;
})}
</>
);
}
- 配置Shadow组件
import type { ReactNode } from 'react';
import { useEffect, useRef, useMemo } from 'react';
import root from 'react-shadow';
import Style from '@/components/Shadow/Style';
export default function ShadowRoot(props: {
id: string;
antdComponentUsed?: string[]; // 传入用到的antd组件名,用于动态引入组件css
css?: string; // 自定义css
children?: ReactNode;
mode?: 'open' | 'closed';
onCreated?: (s?: ShadowRoot | null) => void;
}) {
const { antdComponentUsed = [], css, mode = 'open', onCreated } = props;
const antdCss = useMemo(
() =>
antdComponentUsed.map(
(name) =>
require('antd/es/' + name.toLocaleLowerCase() + '/style/index.less?toString')?.default,
),
[antdComponentUsed],
);
// 额外引入组件的动画样式
let animate = '';
if (antdCss.length) {
animate = require('antd/es/style/index.less?toString')?.default;
}
const ref = useRef<HTMLElement>(null);
const onCreatedRef = useRef(onCreated);
useEffect(() => {
onCreatedRef.current = onCreated;
}, [onCreated]);
useEffect(() => {
const shadowRoot = ref.current?.shadowRoot;
onCreatedRef.current?.(shadowRoot);
}, []);
return (
<root.div ref={ref} id={props.id} mode={mode}>
<Style styles={[animate, ...antdCss, css]} />
{props.children}
</root.div>
);
}
- 外层组件
// Root.tsx
import ShadowRoot from '@/components/Shadow';
import css from '@/renderContent/style/root.less?toString';
import Home from '@/renderContent/views/Home';
export default function Root () {
return (
<ShadowRoot
id="chromeExtensionTemplateRoot"
css={css}
antdComponentUsed={[
'button',
]}
mode="open"
>
<Home />
</ShadowRoot>
)
}
- css文件
// root.less
@import url('@/renderContent/views/Home/index.less');
完整仓库地址: