往网页里长 UI:内容脚本界面注入
本教程共 45 篇 · 第 15 篇 · 更新于 2026-08-13 · 约 3 分钟阅读
本节目标:学会往网页里注入界面。三种方式(集成式、Shadow DOM、iframe)各有什么优缺点,CSS 怎么隔离,页面元素是动态出现的怎么办,以及单页应用(SPA)下内容脚本不重跑怎么处理。
内容脚本不只是改改文字,很多时候我们要在页面上「长」出自己的界面:评分徽章、翻译浮层、侧边助手。WXT 提供了三个现成的工具函数。
先解决 CSS
普通扩展里,内容脚本的 CSS 要单独写进 manifest 的 css 数组。WXT 简化了这一步:在入口文件里 import './style.css',构建时 WXT 会自动把打包后的 CSS 加进 manifest 的 css 数组。
如果你想要一个纯 CSS 的内容脚本(不注入 JS),可以建 entrypoints/example.content.css,再用 build:manifestGenerated 钩子把它手动加进 content_scripts,如 §30 所述。
三种 UI 注入方式
WXT 提供三个工具函数,选哪个取决于你要不要隔离:
| 方式 | 样式隔离 | 事件隔离 | HMR | 能用页面上下文 |
|---|---|---|---|---|
createIntegratedUi | ❌ | ❌ | ❌ | ✅ |
createShadowRootUi | ✅ | ✅(默认关) | ❌ | ✅ |
createIframeUi | ✅ | ✅ | ✅ | ❌ |
集成式:createIntegratedUi
界面直接插进页面 DOM,会被页面 CSS 影响,也会影响页面。适合样式简单、不追求隔离的小组件:
export default defineContentScript({
matches: ["<all_urls>"],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: "inline",
anchor: "body",
onMount: (container) => {
const app = document.createElement("p");
app.textContent = "Hello!";
container.append(app);
},
});
ui.mount();
},
});
position 还有 overlay、modal 等取值,完整列表见官方 API 参考。onMount 的返回值会传给 onRemove 做卸载,接 React/Vue 时很有用。
Shadow DOM:createShadowRootUi
Shadow DOM 是天然的样式隔离罩:组件内部样式和页面样式互不干扰。步骤比集成式多两步:
- 顶部
import './style.css' defineContentScript里设cssInjectionMode: "ui",让 CSS 只注入到 UI 的 shadow root 里,而不是整页- 用
createShadowRootUi定义界面(需要name) ui.mount()挂载
import "./style.css";
export default defineContentScript({
matches: ["<all_urls>"],
cssInjectionMode: "ui",
async main(ctx) {
const ui = await createShadowRootUi(ctx, {
name: "example-ui",
position: "inline",
anchor: "body",
onMount: (container) => {
const app = document.createElement("p");
app.textContent = "Hello world!";
container.append(app);
},
});
ui.mount();
},
});
WXT 会通过 all: initial 重置继承样式,但**rem 单位没法完全隔离**:<html> 的字号由页面决定,用了 Tailwind 这类 rem 体系时,你的界面在不同网站会忽大忽小。这是官方 FAQ 里的已知问题。
Notemkext 项目的域名评级徽章用了原生
attachShadow({ mode: "closed" })手动建封闭 Shadow DOM:既挡住 Google 的样式污染徽章,也让页面的 JavaScript 碰不到徽章内部。
iframe:createIframeUi
iframe 是三重隔离的终极方案,而且支持 HMR——开发时改界面不用整页刷新。代价是它只显示一个 HTML 页面,无法直接操作宿主页面上下文。
<!-- entrypoints/example-iframe.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Content Script IFrame</title>
</head>
<body></body>
</html>
页面要能被网页加载,得加进 web_accessible_resources:
export default defineConfig({
manifest: {
web_accessible_resources: [
{ resources: ["example-iframe.html"], matches: ["<all_urls>"] },
],
},
});
export default defineContentScript({
matches: ["<all_urls>"],
main(ctx) {
const ui = createIframeUi(ctx, {
page: "/example-iframe.html",
position: "inline",
anchor: "body",
onMount: (_wrapper, iframe) => {
iframe.width = "300";
},
});
ui.mount();
},
});
挂到动态出现的元素上
很多页面的目标元素是异步渲染的,脚本注入时它还不存在。把 anchor 写成选择器字符串,再调用 ui.autoMount(),WXT 就会用 MutationObserver 盯着它:出现时自动挂载,消失时自动卸载:
const ui = createIntegratedUi(ctx, {
position: "inline",
anchor: "#your-target-dynamic-element", // 选择器 => 自动观察
onMount: (container) => { /* ... */ },
});
ui.autoMount(); // 而不是 ui.mount()
Tip调用了
ui.remove()之后,autoMount的观察也会一起停止。
对付 SPA
单页应用用 history 模式切换路由时不会整页刷新,而内容脚本只在整页加载时注入一次。你在 YouTube 的视频页刷新能看到脚本,从首页点进去就看不到了。
解法是监听 WXT 提供的 wxt:locationchange 事件,URL 变化时自己判断要不要干活:
const watchPattern = new MatchPattern("*://*.youtube.com/watch*");
export default defineContentScript({
matches: ["*://*.youtube.com/*"],
main(ctx) {
ctx.addEventListener(window, "wxt:locationchange", ({ newUrl }) => {
if (watchPattern.includes(newUrl)) mountUi(ctx);
});
},
});
隔离世界与主世界
默认内容脚本跑在「隔离世界」(Isolated World)里,和页面共享 DOM 但不共享 JavaScript 环境。把 world: "MAIN" 可以进入主世界,但代价不小:仅 MV3 支持(Chrome 111 起、Firefox 128 起)、拿不到扩展 API。
WXT 更推荐 injectScript 手动注入主世界:它兼容所有浏览器,配合一个「父内容脚本」还能来回发消息、间接使用扩展 API。具体做法需要 unlisted 脚本 + web_accessible_resources,在 §13 讲过,这里不再展开。
小结
- 三个 UI 工具按需选:要页面上下文选集成式,要样式隔离选 Shadow DOM,要 HMR 选 iframe。
- 动态元素用
anchor选择器 +autoMount。 - SPA 用
wxt:locationchange事件补上「路由变了」的触发时机。 - 主世界需求优先考虑
injectScript,而不是world: "MAIN"。