SPA나 복잡한 웹 앱에 콘텐츠 스크립트를 주입할 때, 시작 직후에는 DOM에 어떤 요소가 있는지 알 수 없다. 처음부터 세밀한 옵저버를 설정하려 해도 대상 요소가 아직 존재하지 않아 동작하지 않는다.
계층적 DOM 관찰 전략은 이 문제를 두 단계로 해결한다.
- 대기 단계: 앱 루트 전체를 광범위하게 감시하며 “준비 완료 신호” 요소의 출현을 기다린다
- 활성 단계: 신호가 감지되면 각 영역에 스코프를 좁힌 옵저버로 전환한다. URL 변화가 감지되면 대기 단계로 되돌아간다
코드
type ObserverLayer = { selector: string callback: MutationCallback options?: MutationObserverInit}
const activeObservers = new Map<string, MutationObserver>()
const detachAll = (): void => { for (const obs of activeObservers.values()) { obs.disconnect() } activeObservers.clear()}
const attachLayer = (layer: ObserverLayer): void => { const el = document.querySelector(layer.selector) if (!el) return activeObservers.get(layer.selector)?.disconnect() const obs = new MutationObserver(layer.callback) obs.observe(el, layer.options ?? { childList: true, subtree: true }) activeObservers.set(layer.selector, obs)}
export const createLayeredObserver = ( appRootSelector: string, readySelector: string, layers: ObserverLayer[],): (() => void) => { let lastUrl = location.href let phase: 'waiting' | 'active' = 'waiting'
const rootObserver = new MutationObserver(() => { const currentUrl = location.href
if (currentUrl !== lastUrl) { lastUrl = currentUrl phase = 'waiting' detachAll() }
if (phase === 'waiting' && document.querySelector(readySelector)) { phase = 'active' detachAll() for (const layer of layers) { attachLayer(layer) } } })
const root = document.querySelector(appRootSelector) ?? document.body rootObserver.observe(root, { childList: true, subtree: true })
return () => { rootObserver.disconnect() detachAll() }}사용 예시
const teardown = createLayeredObserver( '#app', // 앱 루트 (광역 감시) '[data-ready]', // 준비 완료 신호 요소 [ { selector: '.stream', callback: (mutations) => { for (const m of mutations) console.log('stream:', m.type) }, }, { selector: '.sidebar', callback: (mutations) => { for (const m of mutations) console.log('sidebar:', m.type) }, }, { selector: '[role="dialog"]', callback: (mutations) => { for (const m of mutations) console.log('modal:', m.type) }, }, ],)
// 모든 옵저버 중지teardown()동작 원리
rootObserver는 앱 루트를{ childList: true, subtree: true }로 광범위하게 감시한다- 콜백이 호출될 때마다
location.href를 이전 값과 비교하여, URL이 변경되면 모든 레이어 옵저버를 해제하고phase를'waiting'으로 되돌린다 phase === 'waiting'이고readySelector가 DOM에 존재하면phase를'active'로 설정하고 각 레이어에 옵저버를 다시 부착한다- 루트 옵저버는 계속 실행되므로 SPA 내비게이션에도 자동으로 대응한다
응용
이 패턴은 특정 프레임워크나 셀렉터 세트에 의존하지 않는다. 다음 유스케이스에 응용할 수 있다.
- SPA 콘텐츠 스크립트: Chrome Extension이나 Userscript에서 라우트 변경에 연동하여 처리를 재초기화한다
- 서드파티 DOM 통합: 외부 서비스가 임베드된 페이지에 뒤늦게 옵저버를 설정한다
- 애널리틱스 오버레이: 특정 영역의 변화만 감시하여 이벤트를 전송한다
- 오토 인핸서: 복잡한 웹 앱에 주입하여 요소의 출현·소멸을 추적한다
hsb.horse