前言

在此之前,通过 Browser Extension Dev - 02. 使用 WXT 我们已经了解了扩展的基本结构和 WXT 的使用,下面我们将进一步演示如何在网页中注入复杂的 UI,使用 React、Tailwind CSS、shadcn、或任何需要的 npm 包是怎么做的。

你可能有几个问题

  1. 如果网站也使用了 Tailwind CSS,扩展重复使用不会导致样式冲突吗?
  2. React 之类的现代 Web 框架如何注入到现有的网站中?

下面我将通过实现一个 Youtube 视频截图扩展来进行演示。

1768305726842.jpg

思考

首先,需要明确期望的扩展 UI & UX 是什么样的

  1. 扩展的图标自动注入到视频右下角的工具栏中
  2. 点击图标自动截取视频当前帧为图片
  3. 自动复制图片到剪切板并自动下载

这里可能的问题是什么?

实现

首先,使用 WXT 来初始化一个使用 react 的模版项目。

1
pnpm dlx wxt@latest init 03-inject-ui --template react --pm pnpm

并为 wxt.config.ts 添加一些基本配置。其中 @wxt-dev/module-react 是 WXT 对 React 的集成支持,让我们可以直接在 Content Script 中使用 React。

1
2
3
4
5
6
7
8
9
10
11
12
13
import { defineConfig } from 'wxt'

export default defineConfig({
modules: ['@wxt-dev/module-react'],
manifestVersion: 3,
manifest: {
name: 'Youtube Video Screenshot',
description: 'Take screenshots of YouTube videos easily.',
},
webExt: {
disabled: true,
},
})

然后删除模版项目 entrypoints 目录下的所有内容。

1
rm -rf ./entrypoints/*

安装 Tailwind CSS 和 shadcn 组件

首先来 Tailwind CSS 和 shadcn,由于是在扩展程序中,所以 shadcn 的自动安装命令基本都不可用,需要手动安装并配置,参考 https://ui.shadcn.com/docs/installation/manual

安装 Tailwind CSS 和 shadcn 需要的依赖

1
2
pnpm i -D tailwindcss @tailwindcss/vite
pnpm i clsx tailwind-merge tw-animate-css class-variance-authority

更新 tsconfig.json,添加 baseUrl 和 paths 字段

1
2
3
4
5
6
7
8
9
10
11
{
"extends": "./.wxt/tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}

更新 wxt.config.ts,增加 Tailwind CSS 插件

1
2
3
4
5
6
7
8
9
import { defineConfig } from 'wxt'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
// other config...
vite: () => ({
plugins: [tailwindcss() as any],
}),
})

添加 css 全局样式文件,需要注意,WXT 在 Shadow DOM 模式下会使用 all: initial !important;
重置所有样式,所以这里需要显式指定 html 的高度,否则按钮可能无法正确显示。参考 https://wxt.dev/api/reference/wxt/utils/content-script-ui/shadow-root/type-aliases/ShadowRootContentScriptUiOptions.html#inheritstyles

entrypoints/content/styles.css
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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
@import 'tailwindcss';
@import 'tw-animate-css';

@custom-variant dark (&:is(.dark *));

:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}

.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.145 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.145 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.985 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.396 0.141 25.723);
--destructive-foreground: oklch(0.637 0.237 25.331);
--border: oklch(0.269 0 0);
--input: oklch(0.269 0 0);
--ring: oklch(0.439 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.269 0 0);
--sidebar-ring: oklch(0.439 0 0);
}

@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}

@layer base {
* {
@apply border-border outline-ring/50;
}
html {
height: 40px;
}
body {
height: 100%;
display: flex;
align-items: center;
}
}

添加 shadcn 需要的辅助函数 lib/utils.ts

1
2
3
4
5
6
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

然后添加 shadcn 安装的组件的配置文件 components.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "entrypoints/content/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

现在,尝试使用 shadcn 命令安装一个 button 组件,如果你能看到类似下面这样的输出,就说明你的配置完全正确,可以进行下一步了。

1
2
3
4
5
$ pnpm dlx shadcn@latest add button --yes
✔ Checking registry.
✔ Installing dependencies.
✔ Created 1 file:
- components/ui/button.tsx

在 Content Script 中使用 shadcn

这是很多人转不过来弯的地方,但 shadcn 最终也只是 JavaScript 和 CSS,而扩展程序允许我们注入这些,所以,我们确实可以使用它。

第一步是创建 entrypoints/content/index.tsx 入口文件

1
2
3
4
export default defineContentScript({
matches: ['https://www.youtube.com/*'],
main() {},
})

然后我们使用 Shadow Root 模式注入 UI,这能很好的隔离样式,网站的 CSS 不会影响扩展,反之亦然,参考 https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM,这解决了我们最初提到的第一个问题(样式冲突)。

而 React 作为 JavaScript 库,会在构建时被打包到 .output/chrome-mv3/content-scripts/content.js 中,随 Content Script 一起注入页面。换句话说:Content Script 能运行任何 JavaScript,所以我们能用任何 JavaScript 框架 —— React、Vue、Svelte 都可以,这就解决了第二个问题。可以在 WXT 官方文档中看到多个 Web 框架的使用示例 https://wxt.dev/guide/essentials/content-scripts.html#shadow-root

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
import './styles.css' // 引入 css,会在 bundle 时自动处理
import { Button } from '@/components/ui/button'
import { createRoot } from 'react-dom/client'

function App() {
return (
<div
className={
'fixed z-[999999] top-0 left-0 w-full py-2 bg-white text-center'
}
>
<Button>Click me</Button>
</div>
)
}

export default defineContentScript({
matches: ['https://www.youtube.com/*'],
cssInjectionMode: 'ui', // 这里的配置要求 WXT 将 CSS 动态注入到页面中
async main(ctx) {
// 这里使用了 Shadow Root 模式注入 UI,意味着能够正确地避免我们的 Tailwind CSS "污染"到网页本身
const ui = await createShadowRootUi(ctx, {
name: 'inject-ui-app',
position: 'inline',
anchor: 'body',
onMount: (container) => {
const root = createRoot(container)
root.render(<App />)
return root
},
onRemove: (root) => {
root?.unmount()
},
})

ui.mount()
},
})

可以看到,我们确实成功在顶部注入了一个按钮,在 Devtools > Elements 中也可以看到注入的 inject-ui-app Shadow DOM 元素。

1768358577940.jpg

接下来我们需要找到适合注入图标的位置,然后注入 UI。

Content Script 不同的 UI 注入模式参考 https://wxt.dev/guide/essentials/content-scripts.html#ui

注入按钮到工具栏

首先我们需要找到在哪里注入合适。由于希望在右下角的工具栏中增加额外的图标,所以可以先找到找到容器元素,具体来说,就是 #movie_player .ytp-right-controls-left

1768351993485.jpg

然后修改之前的 Content Script 脚本,修改 App 组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function App() {
function onTakeScreenshot() {
alert('Take screenshot!')
}
return (
<Button
className={'h-[80%] px-6 bg-transparent hover:bg-white/10 rounded-full'}
onClick={onTakeScreenshot}
>
<img
src={browser.runtime.getURL('/icon/32.png')} // 这里直接使用扩展图标作为操作 icon
alt={'icon'}
className={'w-[20px] h-[20px]'}
/>
</Button>
)
}

在创建 Shadow DOM 容器时使用 anchor 指定放置的位置,并使用 insertBefore 添加到工具栏的最左边。指定的容器元素有可能不存在,但 WXT 会正确在容器元素出现和消失时自动挂载和卸载 UI,参考 https://wxt.dev/guide/essentials/content-scripts.html#mounting-ui-to-dynamic-element

1
2
3
4
5
6
7
const ui = await createShadowRootUi(ctx, {
anchor: '#movie_player .ytp-right-controls-left', // 注入的容器元素
append(anchor, ui) {
anchor.insertBefore(ui, anchor.firstChild) // 添加到最左边
},
// other code...
})

现在,我们应该可以在右下角看到我们注入的 icon 了。但实际上,只得到了一个错误,提示无法加载这个资源。

1
Denying load of chrome-extension://aheclehodijmphbifdolliophgjiagof/icon/32.png. Resources must be listed in the web_accessible_resources manifest key in order to be loaded by pages outside the extension.

1768352597267.jpg

实际上还需要在 wxt.config.ts 中配置 web_accessible_resources,允许网页访问扩展的资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
export default defineConfig({
// other config...
manifest: {
// other config...
web_accessible_resources: [
{
resources: ['icon/*'],
matches: ['https://www.youtube.com/*'],
},
],
},
// other config...
})

然后,我们就能在网页中看到注入的 icon 按钮了,点击就会弹出 Take screenshot!

1768353258840.jpg

参考 Chrome 官方文档 https://developer.chrome.com/docs/extensions/reference/manifest/web-accessible-resources

完整代码
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
import './styles.css'
import { Button } from '@/components/ui/button'
import { createRoot } from 'react-dom/client'

function App() {
function onTakeScreenshot() {
alert('Take screenshot!')
}
return (
<Button
className={'h-[80%] px-6 bg-transparent hover:bg-white/10 rounded-full'}
onClick={onTakeScreenshot}
>
<img
src={browser.runtime.getURL('/icon/32.png')}
alt={'icon'}
className={'w-[20px] h-[20px]'}
/>
</Button>
)
}

export default defineContentScript({
matches: ['https://www.youtube.com/*'],
cssInjectionMode: 'ui',
async main(ctx) {
const ui = await createShadowRootUi(ctx, {
name: 'inject-ui-app',
position: 'inline',
anchor: '#movie_player .ytp-right-controls-left',
append(anchor, ui) {
anchor.insertBefore(ui, anchor.firstChild)
},
onMount: (container) => {
const root = createRoot(container)
root.render(<App />)
return root
},
onRemove: (root) => {
root?.unmount()
},
})

ui.mount()
},
})

可以看到,已经成功注入了一个图标按钮到工具栏。

1768320393726.jpg

实现截图功能

现在,我们要实现核心的截图功能。首先我们需要找到 video 元素,可以在 #movie_player video 看到。

1768353496838.jpg

然后需要使用 canvas 来截取视频的一帧,其中看起来最神奇的代码莫过于 ctx.drawImage(video, 0, 0),将一个视频传入了画布,但实际上这是支持的,参考 drawImage 文档 https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage#image,可以看到其中不仅允许 HTMLImageElement(图片),还允许 HTMLVideoElement(视频)甚至 HTMLCanvasElement(其他画布)。

1
2
3
4
5
6
7
8
const video = document.querySelector('#movie_player video')
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(video, 0, 0)
const blob = await new Promise((r) => canvas.toBlob(r, 'image/png', 1))
blob

在 Devtools > Console 中运行这段代码,可以看到我们确实得到了一个 image Blob。

1768354207025.jpg

复制到剪切板并下载

现在我们有了一个 Blob,想要复制到剪切板很简单

1
2
const data = [new ClipboardItem({ [blob.type]: blob })]
await navigator.clipboard.write(data)

如果直接粘贴到 Devtools > Console 执行,会出现错误,这是因为访问剪切板需要在用户操作触发时才能使用,参考 https://developer.mozilla.org/en-US/docs/Web/API/Clipboard_API#security_considerations。不过没关系,稍后我们会把这些代码都连接到一起放到 icon 的点击事件里。

1
2
Uncaught NotAllowedError: Failed to execute 'write' on 'Clipboard': Document is not focused.
at <anonymous>:2:27

1768355046936.jpg

接下来实现图片保存,这里我们直接使用 file-saver 来保存,为了生成使用日期的文件名,还需要使用 dayjs

首先安装依赖

1
2
pnpm i file-saver dayjs
pnpm i -D @types/file-saver

然后只需要使用 saveAs 方法即可下载 Blob。

1
2
3
4
5
6
7
import { saveAs } from 'file-saver'
import dayjs from 'dayjs'

const filename = `Youtube-Screenshot_${dayjs().format(
'YYYY-MM-DD_HH-mm-ss',
)}.png` // 会得到类似 Youtube-Screenshot_2026-01-14_09-49-06.png 这样的文件名
saveAs(blob, filename)

连接起来

现在,修改 App 组件,在 onTakeScreenshot 函数中执行上面的代码。这样,当我们点击时就能看到图片确实复制到了剪切板,并且触发了下载。

1768356044728.jpg

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
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import './styles.css'
import { Button } from '@/components/ui/button'
import saveAs from 'file-saver'
import dayjs from 'dayjs'

function App() {
// 这里有可能出现错误,例如 video 元素不存在、剪切板权限被拒绝、下载失败等等,但这里暂时忽略
async function onTakeScreenshot() {
const video = document.querySelector(
'#movie_player video',
) as HTMLVideoElement
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const ctx = canvas.getContext('2d')!
ctx.drawImage(video, 0, 0)
const blob = (await new Promise<Blob | null>((r) =>
canvas.toBlob(r, 'image/png', 1),
))!

const data = [new ClipboardItem({ [blob.type]: blob })]
await navigator.clipboard.write(data)

const filename = `Youtube-Screenshot_${dayjs().format(
'YYYY-MM-DD_HH-mm-ss',
)}.png` // 会得到类似 Youtube-Screenshot_2026-01-14_09-49-06.png 这样的文件名
saveAs(blob, filename)
}
return (
<Button
className={'h-[80%] px-6 bg-transparent hover:bg-white/10 rounded-full'}
onClick={onTakeScreenshot}
>
<img
src={browser.runtime.getURL('/icon/32.png')}
alt={'icon'}
className={'w-[20px] h-[20px]'}
/>
</Button>
)
}

总结

现在,我们完成了 Youtube 视频截图的扩展,仍然有许多边缘情况没有处理,例如

  • 在截图复制到剪切板时可能出现异常情况
  • 一开始视频未播放时无法获取到视频帧
  • 广告播放时的如何正确处理

但关键功能已经实现。而且使用了 React、Tailwind CSS、shadcn 和一些 npm 包,你觉得怎么样?在下一章中,我们将介绍 Background Script,它允许访问所有扩展 API,但同时无法访问 DOM API,我们将演示只有它能做到的事情。

如果有任何问题,欢迎加入 Discord 群组讨论。
https://discord.gg/VxbAqE7gj2

完整代码:https://github.com/rxliuli/browser-extension-dev-examples/tree/main/packages/03-inject-ui