Skip to content

콘텐츠 스크립트 ​

콘텐츠 스크립트를 생성하려면 엔트리포인트 타입을 참고하세요.

Context ​

콘텐츠 스크립트의 main 함수에 전달되는 첫 번째 인자는 "context"입니다.

ts
// entrypoints/example.content.ts
export default defineContentScript({
  main(ctx) {},
});

이 객체는 콘텐츠 스크립트의 context가 "무효화"되었는지 여부를 추적하는 역할을 합니다. 대부분의 브라우저는 기본적으로 확장 프로그램이 제거되거나 업데이트되거나 비활성화되더라도 콘텐츠 스크립트를 중지하지 않습니다. 이 경우 콘텐츠 스크립트는 다음과 같은 오류를 보고하기 시작합니다:

Error: Extension context invalidated.

ctx 객체는 context가 무효화된 후 비동기 코드가 실행되지 않도록 도와주는 여러 헬퍼를 제공합니다:

ts
ctx.addEventListener(...);
ctx.setTimeout(...);
ctx.setInterval(...);
ctx.requestAnimationFrame(...);
// 그리고 더 많은 기능들

또한 context가 무효화되었는지 수동으로 확인할 수도 있습니다:

ts
if (ctx.isValid) {
  // 무언가를 실행
}
// 또는
if (ctx.isInvalid) {
  // 무언가를 실행
}

CSS ​

일반적인 웹 확장 프로그램에서 콘텐츠 스크립트의 CSS는 보통 별도의 CSS 파일로 존재하며, 매니페스트의 CSS 배열에 추가됩니다:

json
{
  "content_scripts": [
    {
      "css": ["content/style.css"],
      "js": ["content/index.js"],
      "matches": ["*://*/*"]
    }
  ]
}

WXT에서는 콘텐츠 스크립트에 CSS를 추가하려면, JS 엔트리포인트에 CSS 파일을 임포트하기만 하면 됩니다. 그러면 WXT가 자동으로 번들된 CSS 출력을 css 배열에 추가합니다.

ts
// entrypoints/example.content/index.ts
import './style.css';

export default defineContentScript({
  // ...
});

CSS 파일만 포함하는 독립적인 콘텐츠 스크립트를 생성하려면:

  1. CSS 파일을 생성합니다: entrypoints/example.content.css
  2. 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 영향을 받는다는 것을 의미합니다.

ts
// 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();
  },
});
ts
// 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();
  },
});
tsx
// 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();
  },
});
ts
// 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();
  },
});
tsx
// 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를 사용하려면 다음 단계를 따르세요:

  1. 콘텐츠 스크립트 상단에 CSS 파일을 임포트합니다.
  2. defineContentScript 내부에 cssInjectionMode: "ui"를 설정합니다.
  3. createShadowRootUi()로 UI를 정의합니다.
  4. 사용자에게 보이도록 UI를 마운트합니다.
ts
// 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();
  },
});
ts
// 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();
  },
});
tsx
// 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();
  },
});
ts
// 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();
  },
});
tsx
// 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를 제공합니다.

  1. 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>
  2. 매니페스트의 web_accessible_resources에 페이지를 추가합니다:

    ts
    // wxt.config.ts
    export default defineConfig({
      manifest: {
        web_accessible_resources: [
          {
            resources: ['example-iframe.html'],
            matches: [...],
          },
        ],
      },
    });
  3. IFrame을 생성하고 마운트합니다:

    ts
    export 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 옵션을 설정하면 됩니다:

ts
export default defineContentScript({
  world: 'MAIN',
});

하지만 이 방법에는 몇 가지 주의할 점이 있습니다:

  • MV2를 지원하지 않음
  • world: "MAIN"은 Chromium 브라우저에서만 지원됨
  • Main world 콘텐츠 스크립트는 확장 API에 접근할 수 없음

대신, WXT는 injectScript 함수를 사용해 수동으로 main world에 스크립트를 주입하는 것을 권장합니다. 이 방법은 앞서 언급한 단점을 해결할 수 있습니다.

  • injectScript는 MV2와 MV3 모두 지원
  • injectScript는 모든 브라우저에서 지원
  • "부모" 콘텐츠 스크립트가 있으면 메시지를 주고받을 수 있어 확장 API에 접근 가능

injectScript를 사용하려면 두 개의 엔트리포인트가 필요합니다. 하나는 콘텐츠 스크립트이고, 다른 하나는 비공개 스크립트입니다:

html
📂 entrypoints/
   📄 example.content.ts
   📄 example-main-world.ts
ts
// entrypoints/example-main-world.ts
export default defineUnlistedScript(() => {
  console.log('Hello from the main world');
});
ts
// 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!');
  },
});
json
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 옵션을 사용해 엘리먼트를 타겟팅하여, 해당 엘리먼트의 등장과 제거에 따라 자동으로 마운트 및 언마운트를 수행합니다.

ts
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를 추가하려는 경우를 살펴보겠습니다.

ts
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이 예상한 패턴과 일치할 때 콘텐츠 스크립트를 실행해야 합니다.

ts
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);
}