채팅 스포트라이트 기능을 만들기 위해 live 페이지의 채팅 영역에 스포트라이트 영역을 추가하는 작업을 했습니다.
이 작업을 하기 전 `document.querySelector`를 사용해 주입하고 싶은 위치를 콘솔에 찍어봤는데 분명히 존재하는 DOM이 null로 출력되었습니다.
이 문제는 치지직이 SPA로 구현되어 있어서 채팅 영역이 만들어지기 전에 Content-Scripts의 querySelector가 실행되어 발생한 문제였습니다.
Content-Scripts의 runAt 속성으로는 SPA에서 DOM이 로드되는 시점까지는 알 수 없기 때문에 채팅 영역이 존재할 때까지 기다렸다가 영역이 나타나면 기능 로직을 실행하도록 해야했습니다.
이 글에서는 SPA로 동작하는 페이지에서 DOM의 변경을 감지하고 조작하는 방법에 대해 설명합니다.
SPA에서 Content-Scripts를 사용할 때 주의할 점
- Content-Scripts의 실행 시점과 렌더링 시점의 불일치
Content-Script는 runAt 속성에 따라 실행 시점이 정해집니다. 이 실행 시점은 SPA의 렌더링 시점과는 전혀 상관이 없습니다. 그래서 브라우저에서는 존재하는 DOM이 Content-Scripts에서 콘솔에 출력해보면 null이 나오는 이유입니다. - SPA 라우팅으로는 Content-Scripts가 재실행되지 않음
예를 들어, chzzk.naver.com/live/*에서 동작하는 Content-Scripts가 있다고 할 때, chzzk.naver.com에서 채널 라이브를 눌러 페이지를 이동해도 Content-Scripts가 동작하지 않습니다. Content-Scripts는 페이지가 로드될 때의 URL을 기준으로 실행되는데 SPA는 URL이 바뀌어도 리로드되지 않기 때문에 Content-Scripts가 실행되지 않습니다.
MutationObserver
MutationObserver는 DOM 트리의 변경을 감지하는 웹 API로서 요소의 추가/삭제, 속성 변경, 텍스트 내용 변경 등을 옵저빙합니다.
기본 사용법
const observer = new MutationObserver(callback);
// callback은 마이크로태스트 큐에 쌓여 현재 실행 스택이 끝난 뒤 배치 처리됩니다.
observer.observe(targetNode, {
childList: true, // 자식 노드 추가/제거 감지
attributes: true, // 속성 변화 감지
subtree: tree, // 하위 트리 전체 감지
characterData: true // 텍스트 노드 변화 감지
})
WXT + MutationObserver
WXT를 사용하는 상황에서 MutationObserver를 사용해 SPA DOM을 감지하는 코드를 보겠습니다.
// chat-spotlight.content/index.ts
const waitForElement = (selector: string): Promise<Element> => {
return new Promise((resolve) => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver((_, observer) => {
const targetEl = document.querySelector(selector);
if (targetEl) {
observer.disconnect();
resolve(targetEl)
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
})
});
};
export default defineContentScript({
matches: ["https://chzzk.naver.com/live/*"],
async main(ctx) {
const SELECTOR = "aside#aside-chatting";
const asideEl = await waitForElement(SELECTOR);
// DOM 조작
}
});
위 코드로 Content-Scripts의 실행 시점과 렌더링 시점의 차이를 해결할 수 있습니다.
하지만 위 코드로는 SPA의 페이지 이동 시 Content-Scripts가 재실행되지 않는 문제를 해결할 수 없습니다.
이 문제를 해결하기 위해서는 matches의 범위를 넓히고 main 코드 안에서 MutationObserver로 DOM 변경 시 현재 페이지가 라이브 페이지 인지 확인하면 됩니다. 단, 단순히 페이지 URL에 live가 존재하느냐만 보면 A 라이브 페이지에서 B 라이브 페이지로 이동하는 경우를 놓치기 때문에 라이브 채널의 ID를 통해 라이브 채널의 진입과 이탈을 처리해야 합니다.
import type { ContentScriptContext } from "wxt/utils/content-script-context";
const ASIDE_SELECTOR = "aside#aside-chatting";
/**
* 선택자에 해당하는 요소가 DOM에 나타날 때까지 기다린다.
* - 이미 존재하면 즉시 resolve
* - 없으면 MutationObserver로 추가를 감시하다가 발견 시 resolve
* - signal이 abort되면 감시를 멈추고 null로 resolve (라이브 이탈 등으로 취소)
*/
const waitForElement = (
selector: string,
signal?: AbortSignal,
): Promise<Element | null> => {
return new Promise((resolve) => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const observer = new MutationObserver(() => {
const targetEl = document.querySelector(selector);
if (targetEl) {
observer.disconnect();
resolve(targetEl);
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
signal?.addEventListener("abort", () => {
observer.disconnect();
resolve(null);
});
});
};
/** 현재 URL에서 라이브 채널 ID를 추출한다. 라이브 페이지가 아니면 null */
const getLiveChannelId = () =>
location.pathname.match(/^\/live\/([^/]+)/)?.[1] ?? null;
interface LivePageCallbacks {
/** 라이브 페이지 진입 시 호출. signal은 이탈 시 abort된다. */
onEnter: (channelId: string, signal: AbortSignal) => void;
/** 라이브 페이지 이탈 시 호출. */
onLeave: () => void;
}
/**
* 라이브 페이지 진입/이탈을 감지하는 상태 기계.
* - SPA 이동은 반드시 DOM 변경을 동반하므로 MutationObserver에 얹혀 URL을 검사한다
* - 상태(현재 채널 ID)가 바뀐 순간에만 콜백을 호출한다
* → 라이브 A → 라이브 B 이동도 채널 ID가 다르므로 이탈 + 진입으로 감지된다
* - 진입마다 AbortController를 만들어 onEnter에 signal을 넘기고, 이탈 시 abort한다
* → onEnter 안에서 진행 중이던 대기 작업(waitForElement 등)이 함께 취소된다
* - ctx 무효화(확장 리로드) 시 감시를 중단하고 이탈 처리한다
*/
const watchLivePage = (
ctx: ContentScriptContext,
{ onEnter, onLeave }: LivePageCallbacks,
) => {
let currentChannelId: string | null = null;
let enterController: AbortController | null = null;
const enter = (channelId: string) => {
enterController = new AbortController();
onEnter(channelId, enterController.signal);
};
const leave = () => {
enterController?.abort();
enterController = null;
onLeave();
};
const sync = () => {
const next = getLiveChannelId();
if (next === currentChannelId) return; // 변화 없으면 무시
// 라이브에 있었다면 먼저 이탈 처리 (라이브 → 비라이브, 라이브 A → B 공통)
if (currentChannelId !== null) leave();
currentChannelId = next;
if (next !== null) enter(next);
};
const observer = new MutationObserver(sync);
observer.observe(document.body, { childList: true, subtree: true });
ctx.onInvalidated(() => {
observer.disconnect();
if (currentChannelId !== null) leave();
});
sync(); // 새로고침으로 /live/*에 바로 진입한 경우 즉시 반영
};
export default defineContentScript({
matches: ["https://chzzk.naver.com/*"],
main(ctx) {
watchLivePage(ctx, {
async onEnter(channelId, signal) {
console.log(`라이브 페이지 진입: ${channelId}`);
const asideSection = await waitForElement(ASIDE_SELECTOR, signal);
if (!asideSection) return; // 기다리는 중에 이탈해서 취소됨
console.log("aside 확보:", asideSection);
// DOM 추가 (mount)
},
onLeave() {
console.log("라이브 페이지 이탈");
// DOM 정리 (unmount)
},
});
},
});
위의 코드처럼 작성하면 치지직에서 어느 페이지에 있던 라이브 페이지로 이동할 때 콘솔에 라이브 페이지 진입과 이탈이 출력되는 것을 확인할 수 있습니다.
WXT의 createIntegratedUi
WXT는 Content-Scripts를 통해 UI를 추가하기 위한 빌트인 유틸리티 함수를 제공합니다. https://wxt.dev/guide/essentials/content-scripts.html#ui
Next-gen Web Extension Framework – WXT
WXT provides the best developer experience, making it quick, easy, and fun to develop web extensions. With built-in utilities for building, zipping, and publishing your extension, it's easy to get started.
wxt.dev
위 페이지를 통해 상황에 맞는 유틸리티 함수를 사용할 수 있습니다.
저는 치지직에서 사용되고 있는 채팅 DOM을 그대로 사용하고 싶어서 IntegratedUi를 사용했습니다.
export default defineContentScript({
matches: ["https://chzzk.naver.com/*"],
main(ctx) {
const ui = createIntegratedUi(ctx, {
position: "inline",
anchor: "aside#aside-chatting > [role='log']",
append: "first",
onMount: (container) => {
const anchor = container.parentElement;
if (!anchor) return;
console.log(anchor);
},
onRemove: (mounted) => {
},
});
ui.autoMount();
}
});
위의 코드는 matches 페이지에서 autoMount가 anchor가 나타날 때까지 DOM 변경을 감지한다. 는 코드입니다.
위 코드가 라이브 페이지에서만 동작해야 하지만 위에 적은 SPA 라우팅 문제로 인해 matches의 범위를 넓혔습니다.