콘텐츠 스크립트
콘텐츠 스크립트를 생성하려면 엔트리포인트 타입을 참고하세요.
Context
콘텐츠 스크립트의 main
함수에 전달되는 첫 번째 인자는 "context"입니다.
// entrypoints/example.content.ts
export default defineContentScript({
main(ctx) {},
});
이 객체는 콘텐츠 스크립트의 context가 "무효화"되었는지 여부를 추적하는 역할을 합니다. 대부분의 브라우저는 기본적으로 확장 프로그램이 제거되거나 업데이트되거나 비활성화되더라도 콘텐츠 스크립트를 중지하지 않습니다. 이 경우 콘텐츠 스크립트는 다음과 같은 오류를 보고하기 시작합니다:
Error: Extension context invalidated.
ctx
객체는 context가 무효화된 후 비동기 코드가 실행되지 않도록 도와주는 여러 헬퍼를 제공합니다:
ctx.addEventListener(...);
ctx.setTimeout(...);
ctx.setInterval(...);
ctx.requestAnimationFrame(...);
// 그리고 더 많은 기능들
또한 context가 무효화되었는지 수동으로 확인할 수도 있습니다:
if (ctx.isValid) {
// 무언가를 실행
}
// 또는
if (ctx.isInvalid) {
// 무언가를 실행
}
CSS
일반적인 웹 확장 프로그램에서 콘텐츠 스크립트의 CSS는 보통 별도의 CSS 파일로 존재하며, 매니페스트의 CSS 배열에 추가됩니다:
{
"content_scripts": [
{
"css": ["content/style.css"],
"js": ["content/index.js"],
"matches": ["*://*/*"]
}
]
}
WXT에서는 콘텐츠 스크립트에 CSS를 추가하려면, JS 엔트리포인트에 CSS 파일을 임포트하기만 하면 됩니다. 그러면 WXT가 자동으로 번들된 CSS 출력을 css
배열에 추가합니다.
// entrypoints/example.content/index.ts
import './style.css';
export default defineContentScript({
// ...
});
CSS 파일만 포함하는 독립적인 콘텐츠 스크립트를 생성하려면:
- CSS 파일을 생성합니다:
entrypoints/example.content.css
build:manifestGenerated
훅을 사용하여 매니페스트에 콘텐츠 스크립트를 추가합니다:ts// wxt.config.ts export default defineConfig({ hooks: { "build:manifestGenerated": (wxt, manifest) => { manifest.content_scripts ??= []; manifest.content_scripts.push({ // CSS가 어디에 작성되는지 확인하려면 확장 프로그램을 한 번 빌드하세요 css: ["content-scripts/example.css"], matches: ["*://*/*"] ) } } })
UI
WXT는 콘텐츠 스크립트에서 페이지에 UI를 추가하기 위해 3가지 내장 유틸리티를 제공합니다:
각각 고유한 장단점이 있습니다.
방법 | 스타일 격리 | 이벤트 격리 | HMR | 페이지 컨텍스트 사용 |
---|---|---|---|---|
통합형 | ❌ | ❌ | ❌ | ✅ |
섀도우 루트 | ✅ | ✅ (기본값 꺼짐) | ❌ | ✅ |
IFrame | ✅ | ✅ | ✅ | ❌ |
통합
통합된 콘텐츠 스크립트 UI는 페이지의 콘텐츠와 함께 주입됩니다. 이는 해당 페이지의 CSS 영향을 받는다는 것을 의미합니다.
// entrypoints/example-ui.content.ts
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 = '...';
container.append(app);
},
});
// UI를 DOM에 추가하기 위해 mount 호출
ui.mount();
},
});
// entrypoints/example-ui.content/index.ts
import { createApp } from 'vue';
import App from './App.vue';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: 'body',
onMount: (container) => {
// 앱을 생성하고 UI 컨테이너에 마운트
const app = createApp(App);
app.mount(container);
return app;
},
onRemove: (app) => {
// UI가 제거될 때 앱 언마운트
app.unmount();
},
});
// UI를 DOM에 추가하기 위해 mount 호출
ui.mount();
},
});
// entrypoints/example-ui.content/index.tsx
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: 'body',
onMount: (container) => {
// UI 컨테이너에 루트를 생성하고 컴포넌트 렌더링
const root = ReactDOM.createRoot(container);
root.render(<App />);
return root;
},
onRemove: (root) => {
// UI가 제거될 때 루트 언마운트
root.unmount();
},
});
// UI를 DOM에 추가하기 위해 mount 호출
ui.mount();
},
});
// entrypoints/example-ui.content/index.ts
import App from './App.svelte';
import { mount, unmount } from 'svelte';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: 'body',
onMount: (container) => {
// UI 컨테이너 내부에 Svelte 앱 생성
mount(App, {
target: container,
});
},
onRemove: (app) => {
// UI가 제거될 때 앱 제거
unmount(app);
},
});
// UI를 DOM에 추가하기 위해 mount 호출
ui.mount();
},
});
// entrypoints/example-ui.content/index.ts
import { render } from 'solid-js/web';
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
anchor: 'body',
onMount: (container) => {
// UI 컨테이너에 앱 렌더링
const unmount = render(() => <div>...</div>, container);
return unmount;
},
onRemove: (unmount) => {
// UI가 제거될 때 앱 언마운트
unmount();
},
});
// UI를 DOM에 추가하기 위해 mount 호출
ui.mount();
},
});
전체 옵션 목록은 API 참조를 확인하세요.
Shadow Root
웹 확장 프로그램에서 콘텐츠 스크립트의 CSS가 페이지에 영향을 미치거나 그 반대의 상황을 원하지 않을 때가 있습니다. ShadowRoot
API는 이런 경우에 이상적입니다.
WXT의 createShadowRootUi
는 모든 ShadowRoot
설정을 추상화하여 페이지와 스타일이 격리된 UI를 쉽게 만들 수 있게 해줍니다. 또한 사용자 상호작용을 더욱 격리하기 위한 isolateEvents
파라미터도 지원합니다.
createShadowRootUi
를 사용하려면 다음 단계를 따르세요:
- 콘텐츠 스크립트 상단에 CSS 파일을 임포트합니다.
defineContentScript
내부에cssInjectionMode: "ui"
를 설정합니다.createShadowRootUi()
로 UI를 정의합니다.- 사용자에게 보이도록 UI를 마운트합니다.
// 1. 스타일 임포트
import './style.css';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. cssInjectionMode 설정
cssInjectionMode: 'ui',
async main(ctx) {
// 3. UI 정의
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: 'body',
onMount(container) {
// 컨테이너 내부에 UI를 마운트하는 방법 정의
const app = document.createElement('p');
app.textContent = 'Hello world!';
container.append(app);
},
});
// 4. UI 마운트
ui.mount();
},
});
// 1. 스타일 임포트
import './style.css';
import { createApp } from 'vue';
import App from './App.vue';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. cssInjectionMode 설정
cssInjectionMode: 'ui',
async main(ctx) {
// 3. UI 정의
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// 컨테이너 내부에 UI를 마운트하는 방법 정의
const app = createApp(App);
app.mount(container);
return app;
},
onRemove: (app) => {
// UI가 제거될 때 앱 언마운트
app?.unmount();
},
});
// 4. UI 마운트
ui.mount();
},
});
// 1. 스타일 임포트
import './style.css';
import ReactDOM from 'react-dom/client';
import App from './App.tsx';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. cssInjectionMode 설정
cssInjectionMode: 'ui',
async main(ctx) {
// 3. UI 정의
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// 컨테이너는 body이므로 React에서 body에 루트를 생성할 때 경고가 발생합니다. 따라서 래퍼 div를 생성합니다.
const app = document.createElement('div');
container.append(app);
// UI 컨테이너에 루트를 생성하고 컴포넌트를 렌더링합니다.
const root = ReactDOM.createRoot(app);
root.render(<App />);
return root;
},
onRemove: (root) => {
// UI가 제거될 때 루트 언마운트
root?.unmount();
},
});
// 4. UI 마운트
ui.mount();
},
});
// 1. 스타일 임포트
import './style.css';
import App from './App.svelte';
import { mount, unmount } from 'svelte';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. cssInjectionMode 설정
cssInjectionMode: 'ui',
async main(ctx) {
// 3. UI 정의
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// UI 컨테이너 내부에 Svelte 앱 생성
mount(App, {
target: container,
});
},
onRemove: () => {
// UI가 제거될 때 앱 제거
unmount(app);
},
});
// 4. UI 마운트
ui.mount();
},
});
// 1. 스타일 임포트
import './style.css';
import { render } from 'solid-js/web';
export default defineContentScript({
matches: ['<all_urls>'],
// 2. cssInjectionMode 설정
cssInjectionMode: 'ui',
async main(ctx) {
// 3. UI 정의
const ui = await createShadowRootUi(ctx, {
name: 'example-ui',
position: 'inline',
anchor: 'body',
onMount: (container) => {
// UI 컨테이너에 앱 렌더링
const unmount = render(() => <div>...</div>, container);
},
onRemove: (unmount) => {
// UI가 제거될 때 앱 언마운트
unmount?.();
},
});
// 4. UI 마운트
ui.mount();
},
});
전체 옵션 목록은 API Reference를 참조하세요.
전체 예제:
IFrame
콘텐츠 스크립트와 동일한 프레임에서 UI를 실행할 필요가 없다면, IFrame을 사용하여 UI를 호스팅할 수 있습니다. IFrame은 단순히 HTML 페이지를 호스팅하므로 HMR이 지원됩니다.
WXT는 IFrame 설정을 간소화하는 헬퍼 함수인 createIframeUi
를 제공합니다.
IFrame에 로드될 HTML 페이지를 생성합니다:
html<!-- entrypoints/example-iframe.html --> <!doctype html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Your Content Script IFrame</title> </head> <body> <!-- ... --> </body> </html>
매니페스트의
web_accessible_resources
에 페이지를 추가합니다:ts// wxt.config.ts export default defineConfig({ manifest: { web_accessible_resources: [ { resources: ['example-iframe.html'], matches: [...], }, ], }, });
IFrame을 생성하고 마운트합니다:
tsexport default defineContentScript({ matches: ['<all_urls>'], main(ctx) { // UI 정의 const ui = createIframeUi(ctx, { page: '/example-iframe.html', position: 'inline', anchor: 'body', onMount: (wrapper, iframe) => { // iframe에 너비와 같은 스타일 추가 iframe.width = '123'; }, }); // 사용자에게 UI 표시 ui.mount(); }, });
전체 옵션 목록은 API Reference를 참고하세요.
Isolated World vs Main World
기본적으로 모든 콘텐츠 스크립트는 웹페이지와 DOM만 공유하는 "isolated world"라는 독립된 컨텍스트에서 실행됩니다. MV3에서 Chromium은 콘텐츠 스크립트가 웹페이지에서 로드된 스크립트처럼 DOM뿐만 아니라 모든 것에 접근할 수 있는 "main" world에서 실행할 수 있는 기능을 도입했습니다.
콘텐츠 스크립트에서 이 기능을 활성화하려면 world
옵션을 설정하면 됩니다:
export default defineContentScript({
world: 'MAIN',
});
하지만 이 방법에는 몇 가지 주의할 점이 있습니다:
- MV2를 지원하지 않음
world: "MAIN"
은 Chromium 브라우저에서만 지원됨- Main world 콘텐츠 스크립트는 확장 API에 접근할 수 없음
대신, WXT는 injectScript
함수를 사용해 수동으로 main world에 스크립트를 주입하는 것을 권장합니다. 이 방법은 앞서 언급한 단점을 해결할 수 있습니다.
injectScript
는 MV2와 MV3 모두 지원injectScript
는 모든 브라우저에서 지원- "부모" 콘텐츠 스크립트가 있으면 메시지를 주고받을 수 있어 확장 API에 접근 가능
injectScript
를 사용하려면 두 개의 엔트리포인트가 필요합니다. 하나는 콘텐츠 스크립트이고, 다른 하나는 비공개 스크립트입니다:
📂 entrypoints/
📄 example.content.ts
📄 example-main-world.ts
// entrypoints/example-main-world.ts
export default defineUnlistedScript(() => {
console.log('Hello from the main world');
});
// entrypoints/example.content.ts
export default defineContentScript({
matches: ['*://*/*'],
async main() {
console.log('Injecting script...');
await injectScript('/example-main-world.js', {
keepInDom: true,
});
console.log('Done!');
},
});
export default defineConfig({
manifest: {
// ...
web_accessible_resources: [
{
resources: ["example-main-world.js"],
matches: ["*://*/*"],
}
]
}
});
injectScript
는 페이지에 스크립트 엘리먼트를 생성하여 스크립트를 로드합니다. 이렇게 하면 스크립트가 페이지의 컨텍스트에서 실행되어 main world에서 동작합니다.
injectScript
는 Promise를 반환하며, 이 Promise가 해결되면 브라우저가 스크립트를 평가했음을 의미하며, 이제 스크립트와 통신을 시작할 수 있습니다.
주의: run_at
주의사항
MV3에서는 injectScript
가 동기적으로 동작하며, 주입된 스크립트는 콘텐츠 스크립트의 run_at
과 동시에 평가됩니다.
하지만 MV2에서는 injectScript
가 스크립트의 텍스트 내용을 fetch
하고 인라인 <script>
블록을 생성해야 합니다. 이는 MV2에서 스크립트가 비동기적으로 주입되며, 콘텐츠 스크립트의 run_at
과 동시에 평가되지 않음을 의미합니다.
동적 엘리먼트에 UI 마운트하기
웹 페이지가 처음 로드될 때 존재하지 않는 DOM 엘리먼트에 UI를 마운트해야 하는 경우가 많습니다. 이를 처리하기 위해 autoMount
API를 사용하면 대상 엘리먼트가 동적으로 나타날 때 자동으로 UI를 마운트하고, 엘리먼트가 사라질 때 언마운트할 수 있습니다. WXT에서는 anchor
옵션을 사용해 엘리먼트를 타겟팅하여, 해당 엘리먼트의 등장과 제거에 따라 자동으로 마운트 및 언마운트를 수행합니다.
export default defineContentScript({
matches: ['<all_urls>'],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: 'inline',
// 앵커를 관찰합니다
anchor: '#your-target-dynamic-element',
onMount: (container) => {
// 컨테이너에 자식 엘리먼트를 추가합니다
const app = document.createElement('p');
app.textContent = '...';
container.append(app);
},
});
// autoMount를 호출해 앵커 엘리먼트의 추가/제거를 관찰합니다.
ui.autoMount();
},
});
TIP
ui.remove
가 호출되면 autoMount
도 중지됩니다.
전체 옵션 목록은 API 레퍼런스를 참고하세요.
SPA 처리하기
SPA(싱글 페이지 애플리케이션)와 HTML5 히스토리 모드를 사용하는 웹사이트에 콘텐츠 스크립트를 작성하는 것은 어렵습니다. 콘텐츠 스크립트는 전체 페이지가 다시 로드될 때만 실행되기 때문입니다. SPA와 HTML5 히스토리 모드를 사용하는 웹사이트는 경로를 변경할 때 전체 페이지를 다시 로드하지 않습니다. 따라서 콘텐츠 스크립트가 예상대로 실행되지 않을 수 있습니다.
예를 들어, 유튜브에서 비디오를 볼 때 UI를 추가하려는 경우를 살펴보겠습니다.
export default defineContentScript({
matches: ['*://*.youtube.com/watch*'],
main(ctx) {
console.log('YouTube content script loaded');
mountUi(ctx);
},
});
function mountUi(ctx: ContentScriptContext): void {
// ...
}
이 경우, "YouTube content script loaded" 메시지는 워치 페이지를 새로고침하거나 다른 웹사이트에서 직접 해당 페이지로 이동할 때만 볼 수 있습니다.
이 문제를 해결하려면, 경로가 변경될 때 수동으로 감지하고 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)) mainWatch(ctx);
});
},
});
function mainWatch(ctx: ContentScriptContext) {
mountUi(ctx);
}