前言在此之前,通过 Browser Extension Dev - 02. 使用 WXT 我们已经了解了扩展的基本结构和 WXT 的使用,下面我们将进一步演示如何在网页中注入复杂的 UI,使用 React、Tailwind CSS、shadcn、或任何需要的 npm 包是怎么做的。
你可能有几个问题
如果网站也使用了 Tailwind CSS,扩展重复使用不会导致样式冲突吗?
React 之类的现代 Web 框架如何注入到现有的网站中?
下面我将通过实现一个 Youtube 视频截图扩展来进行演示。
思考首先,需要明确期望的扩展 UI & UX 是什么样的
扩展的图标自动注入到视频右下角的工具栏中
点击图标自动截取视频当前帧为图片
自动复制图片到剪切板并自动下载
这里可能的问题是什么?
实现首先,使用 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 目录下的所有内容。
安装 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 元素。
接下来我们需要找到适合注入图标的位置,然后注入 UI。
Content Script 不同的 UI 注入模式参考 https://wxt.dev/guide/essentials/content-scripts.html#ui
注入按钮到工具栏首先我们需要找到在哪里注入合适。由于希望在右下角的工具栏中增加额外的图标,所以可以先找到找到容器元素,具体来说,就是 #movie_player .ytp-right-controls-left
然后修改之前的 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.
实际上还需要在 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!。
参考 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 () }, })
可以看到,已经成功注入了一个图标按钮到工具栏。
实现截图功能现在,我们要实现核心的截图功能。首先我们需要找到 video 元素,可以在 #movie_player video 看到。
然后需要使用 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。
复制到剪切板并下载现在我们有了一个 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
接下来实现图片保存,这里我们直接使用 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 函数中执行上面的代码。这样,当我们点击时就能看到图片确实复制到了剪切板,并且触发了下载。
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