logo hsb.horse
← 스니펫 목록으로 돌아가기

Snippets

계층적 DOM 관찰 전략

앱이 준비되면 세밀한 옵저버로 단계적으로 전환하는 패턴. SPA 콘텐츠 스크립트와 서드파티 DOM 통합에 폭넓게 응용할 수 있다.

게시일: 수정일:

SPA나 복잡한 웹 앱에 콘텐츠 스크립트를 주입할 때, 시작 직후에는 DOM에 어떤 요소가 있는지 알 수 없다. 처음부터 세밀한 옵저버를 설정하려 해도 대상 요소가 아직 존재하지 않아 동작하지 않는다.

계층적 DOM 관찰 전략은 이 문제를 두 단계로 해결한다.

  1. 대기 단계: 앱 루트 전체를 광범위하게 감시하며 “준비 완료 신호” 요소의 출현을 기다린다
  2. 활성 단계: 신호가 감지되면 각 영역에 스코프를 좁힌 옵저버로 전환한다. 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()

동작 원리

  1. rootObserver는 앱 루트를 { childList: true, subtree: true }로 광범위하게 감시한다
  2. 콜백이 호출될 때마다 location.href를 이전 값과 비교하여, URL이 변경되면 모든 레이어 옵저버를 해제하고 phase'waiting'으로 되돌린다
  3. phase === 'waiting'이고 readySelector가 DOM에 존재하면 phase'active'로 설정하고 각 레이어에 옵저버를 다시 부착한다
  4. 루트 옵저버는 계속 실행되므로 SPA 내비게이션에도 자동으로 대응한다

응용

이 패턴은 특정 프레임워크나 셀렉터 세트에 의존하지 않는다. 다음 유스케이스에 응용할 수 있다.

  • SPA 콘텐츠 스크립트: Chrome Extension이나 Userscript에서 라우트 변경에 연동하여 처리를 재초기화한다
  • 서드파티 DOM 통합: 외부 서비스가 임베드된 페이지에 뒤늦게 옵저버를 설정한다
  • 애널리틱스 오버레이: 특정 영역의 변화만 감시하여 이벤트를 전송한다
  • 오토 인핸서: 복잡한 웹 앱에 주입하여 요소의 출현·소멸을 추적한다

관련 스니펫