English | 简体中文
现在开发 Chrome 扩展程序,要想得到包含 HMR 等特性的丝滑开发体验,已经不用再去找脚手架或专门的魔改方案了,用 Rsbuild 简单配置一下足矣,我认为这是目前最佳的构建方案。
Rspack 不久前发布了正式版本,顾名思义,这是一个用 Rust 编写的用以替代 webpack 的高性能构建工具。Rsbuild 是它的上层封装,大大简化了配置。下面我举一个例子,看看使用 Rsbuild 构建 Chrome 扩展程序到底能有多简单。
这里假设你已经知道如何开发 Chrome 扩展程序,所以只展示使用 Rsbuild 的不同点,不再赘述基础知识。
创建一个基于 React 和 TS,名为 chrome-extension-zero 的项目,并安装好依赖:
yarn create rsbuild -d chrome-extension-zero -t react-ts
cd chrome-extension-zero
yarn
得到如下的文件目录结构,很精简:
├── node_modules
├── src
│ ├── App.css
│ ├── App.tsx
│ ├── env.d.ts
│ └── index.tsx
├── .gitignore
├── README.md
├── package.json
├── rsbuild.config.ts
├── tsconfig.json
└── yarn.lock
这时已经可以跑起来了,执行 yarn dev
即可看到页面。接下来要把它改成 Chrome 扩展程序,这个扩展程序只做一件事:当用户点击扩展图标时,打开内置页面。
修改 package.json
去掉 dev
命令的 --open
参数,因为开发 Chrome 扩展程序时不需要自动打开页面。
安装 TS 类型包:
yarn add -D @types/chrome @types/node
把 src
内的文件移动到 src/main
里,然后创建 src/background/index.ts
和 public/manifest.json
:
├── node_modules
├── public
│ └── manifest.json
├── src
│ ├── background
│ │ └── index.ts
│ └── main
│ ├── App.css
│ ├── App.tsx
│ ├── env.d.ts
│ └── index.tsx
├── .gitignore
├── README.md
├── package.json
├── rsbuild.config.ts
├── tsconfig.json
└── yarn.lock
修改 src/background/index.ts
的内容:
chrome.action.onClicked.addListener(() => {
chrome.tabs.create({ url: 'main.html' });
});
修改 public/manifest.json
的内容:
{
"manifest_version": 3,
"version": "1.0.0",
"name": "Chrome Extension Zero",
"description": "An example Chrome extension built with React and Rsbuild.",
"background": {
"service_worker": "static/js/background.js"
},
"action": {
"default_title": "Chrome Extension Zero"
},
}
修改 rsbuild.config.ts
内的配置,具体的配置说明请参考 Rsbuild 的官方文档:
import { defineConfig } from '@rsbuild/core';
import { pluginReact } from '@rsbuild/plugin-react';
// 此处暂时不用区分开发/正式环境
// const isProd = process.env.NODE_ENV === 'production';
const port = 3000;
export default defineConfig({
dev: {
client: {
port,
host: '0.0.0.0',
protocol: 'ws',
},
writeToDisk: true,
},
server: {
port,
strictPort: true,
publicDir: {
copyOnBuild: false,
},
},
output: {
filenameHash: false,
},
environments: {
web: {
plugins: [pluginReact()],
source: {
entry: {
main: './src/main/index.tsx',
},
},
html: {
title: 'chrome-extension-zero',
},
output: {
target: 'web',
copy: [{ from: './public' }],
},
},
webworker: {
source: {
entry: {
background: './src/background/index.ts',
},
},
output: {
target: 'web-worker',
},
},
},
});
最后执行 yarn dev
,在 chrome://extensions
页面里加载 dist
目录,就可以愉快地开发了。
有一个尚未解决的小缺点,就是每次热更新都会新增几个 hot-update 文件,而且不能在 writeToDisk
时忽略这些文件,这会导致 HMR 失效并自动降级到 liveReload。好在这些文件都很小,可以忍受。
例子太简单?那我们来点更复杂的,比如划词翻译等辅助工具,需要修改网页在页面中显示自己的 UI 组件。这类需求最麻烦的点是要通过 content script 向目标页面注入插件的 UI 组件,而 content script 和 background 一样都不好做 HMR,每次改动都需要 reload。没有 HMR 调试 UI 会非常蛋疼,所以我们应该将 UI 组件剥离出去使其可以独立调试,尽可能减少 content script 内的逻辑。
接下来我们进一步完善例子,实现在每个网页上增加一个计数按钮。
修改 rsbuild.config.ts
,新增 components
和 contentScript
两个入口:
export default defineConfig({
...
environments: {
web: {
plugins: [pluginReact()],
source: {
entry: {
main: './src/main/index.tsx',
+ components: './src/components/index.tsx',
},
},
html: {
title: '',
},
output: {
target: 'web',
copy: [{ from: './public' }],
},
},
webworker: {
+ plugins: [pluginReact()],
source: {
entry: {
background: './src/background/index.ts',
+ contentScript: './src/contentScript/index.tsx',
},
},
output: {
target: 'web-worker',
},
},
},
});
修改 public/manifest.json
的内容:
{
"manifest_version": 3,
...
+ "content_scripts": [
+ {
+ "matches": ["https://*/*"],
+ "js": ["static/js/contentScript.js"]
+ }
+ ],
+ "web_accessible_resources": [
+ {
+ "resources": ["*"],
+ "matches": ["https://*/*"]
+ }
+ ]
}
添加对应的文件:
├── node_modules
├── public
│ └── manifest.json
├── src
│ ├── background
│ │ └── index.ts
+│ ├── contentScript
+│ │ └── index.tsx
+│ ├── components
+│ │ └── Button
+│ │ ├── index.css
+│ │ └── index.tsx
+│ │ ├── env.d.ts
+│ │ └── index.tsx
│ └── main
│ ├── App.css
│ ├── App.tsx
│ ├── env.d.ts
│ └── index.tsx
├── .gitignore
├── README.md
├── package.json
├── rsbuild.config.ts
├── tsconfig.json
└── yarn.lock
src/components/env.d.ts
是 src/main/env.d.ts
的拷贝。
修改 src/components/Button/index.tsx
的内容:
import './index.css';
export interface Props {
count: number;
onClick: () => void;
}
export default function Button({ count, onClick }: Props) {
return (
<button className='primary-btn' onClick={onClick}>
CLICK ME: {count}
</button>
);
}
修改 src/components/Button/index.css
的内容:
.primary-btn {
padding: 1rem 2rem;
font-size: 16px;
color: black;
background-color: white;
border-color: black;
}
在 UI 组件中做样式管理和是平时完全一样的,如果你想用 Tailwind CSS 可以按照 Rsbuild 的文档来引入。但要使用插件自身的图片等资源时,就需要通过 chrome.runtime.getURL('xxx')
来获取 URL 了。
修改 src/components/index.tsx
的内容:
import { createRoot } from 'react-dom/client';
import { useState } from 'react';
import Button from './Button';
createRoot(document.getElementById('root')!).render(<Preview />);
function Preview() {
const [count, setCount] = useState(0);
return (
<Button count={count} onClick={() => setCount(count+1)} />
);
}
这充当了 UI 组件的预览入口,开发时我们可以打开 chrome-extension://<ID>/components.html
来调试 UI 组件,components
和 main
一样都支持 HMR。注意不要使用 http://localhost:3000/components
,这与组件的目标上下文(content script)不同。
修改 src/contentScript/index.tsx
的内容:
import { ReactNode, useState } from 'react';
import { createRoot } from 'react-dom/client';
import Button from '../components/Button';
document.addEventListener('DOMContentLoaded', () => {
const container = appendComponent(document.body, <Root />);
Object.assign(container.style, {
position: 'fixed',
top: '0',
left: '0',
zIndex: '9999',
} as CSSStyleDeclaration);
});
function appendComponent(parent: HTMLElement, component: ReactNode): HTMLElement {
const container = document.createElement('div');
const shadowRoot = container.attachShadow({ mode: 'open' });
parent.appendChild(container);
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = chrome.runtime.getURL('static/css/components.css');
shadowRoot.appendChild(link);
const componentRoot = document.createElement('div');
shadowRoot.appendChild(componentRoot);
createRoot(componentRoot).render(component);
return container;
}
function Root() {
const [count, setCount] = useState(0);
return (
<Button count={count} onClick={() => setCount(count+1)} />
);
}
UI 剥离出去后,content script 的逻辑就很简单了,上面的代码每次运行时,会以 Shadow DOM 的形式插入计数按钮,并定位在页面左上角。
完整的示例项目源码:chrome-extension-zero。