@adstage/web-sdk
Version:
AdStage Web SDK - Production-ready marketing platform SDK with React Provider support for seamless integration
331 lines (288 loc) • 8.93 kB
text/typescript
/**
* SSR 안전한 DOM API 래퍼 클래스
* 서버사이드 렌더링 환경에서 DOM API 접근 시 오류를 방지합니다.
*/
export class DOMUtils {
/**
* 브라우저 환경 여부 체크
*/
static isBrowser(): boolean {
return typeof window !== 'undefined' && typeof document !== 'undefined';
}
/**
* SSR 환경 여부 체크
*/
static isSSR(): boolean {
return !this.isBrowser();
}
/**
* DOM 사용 가능 여부 체크
*/
static canUseDOM(): boolean {
return this.isBrowser() && document.readyState !== undefined;
}
/**
* 안전한 getElementById
*/
static safeGetElementById(id: string): HTMLElement | null {
if (!this.canUseDOM()) return null;
return document.getElementById(id);
}
/**
* 안전한 querySelector
*/
static safeQuerySelector(selector: string): HTMLElement | null {
if (!this.canUseDOM()) return null;
return document.querySelector(selector);
}
/**
* 안전한 querySelectorAll
*/
static safeQuerySelectorAll(selector: string): HTMLElement[] {
if (!this.canUseDOM()) return [];
return Array.from(document.querySelectorAll(selector)) as HTMLElement[];
}
/**
* 안전한 createElement
*/
static safeCreateElement(tagName: string): HTMLElement | null {
if (!this.canUseDOM()) return null;
return document.createElement(tagName);
}
/**
* 안전한 addEventListener
*/
static safeAddEventListener(
element: HTMLElement | null,
event: string,
handler: EventListener,
options?: boolean | AddEventListenerOptions
): void {
if (!this.canUseDOM() || !element) return;
element.addEventListener(event, handler, options);
}
/**
* 안전한 removeEventListener
*/
static safeRemoveEventListener(
element: HTMLElement | null,
event: string,
handler: EventListener,
options?: boolean | EventListenerOptions
): void {
if (!this.canUseDOM() || !element) return;
element.removeEventListener(event, handler, options);
}
/**
* 안전한 window 속성 접근
*/
static getWindowProperty<T>(property: keyof Window, defaultValue: T): T {
if (!this.isBrowser()) return defaultValue;
return (window[property] as T) ?? defaultValue;
}
/**
* 안전한 document 속성 접근
*/
static getDocumentProperty<T>(property: keyof Document, defaultValue: T): T {
if (!this.canUseDOM()) return defaultValue;
return (document[property] as T) ?? defaultValue;
}
/**
* 안전한 window.open
*/
static safeWindowOpen(url: string, target?: string, features?: string): Window | null {
if (!this.isBrowser()) return null;
return window.open(url, target, features);
}
/**
* 안전한 getComputedStyle
*/
static safeGetComputedStyle(element: HTMLElement): CSSStyleDeclaration | null {
if (!this.isBrowser() || !element) return null;
return window.getComputedStyle(element);
}
/**
* DOM Ready 상태 체크
*/
static isDOMReady(): boolean {
if (!this.canUseDOM()) return false;
return document.readyState !== 'loading';
}
/**
* DOM Ready 대기 (SSR 안전)
*/
static waitForDOM(): Promise<void> {
return new Promise((resolve) => {
if (!this.canUseDOM()) {
resolve(); // SSR 환경에서는 즉시 resolve
return;
}
if (this.isDOMReady()) {
resolve();
} else {
this.safeAddEventListener(document as any, 'DOMContentLoaded', () => resolve());
}
});
}
/**
* 안전한 스타일 적용
*/
static safeApplyStyles(element: HTMLElement | null, styles: Record<string, string>): void {
if (!this.canUseDOM() || !element) return;
Object.entries(styles).forEach(([property, value]) => {
element.style.setProperty(property, value);
});
}
/**
* 안전한 클래스 추가
*/
static safeAddClass(element: HTMLElement | null, className: string): void {
if (!this.canUseDOM() || !element) return;
element.classList.add(className);
}
/**
* 안전한 클래스 제거
*/
static safeRemoveClass(element: HTMLElement | null, className: string): void {
if (!this.canUseDOM() || !element) return;
element.classList.remove(className);
}
/**
* 안전한 텍스트 콘텐츠 설정
*/
static safeSetTextContent(element: HTMLElement | null, text: string): void {
if (!this.canUseDOM() || !element) return;
element.textContent = text;
}
/**
* 안전한 HTML 콘텐츠 설정
*/
static safeSetInnerHTML(element: HTMLElement | null, html: string): void {
if (!this.canUseDOM() || !element) return;
element.innerHTML = html;
}
/**
* 안전한 자식 요소 추가
*/
static safeAppendChild(parent: HTMLElement | null, child: HTMLElement | null): void {
if (!this.canUseDOM() || !parent || !child) return;
parent.appendChild(child);
}
/**
* 안전한 자식 요소 제거
*/
static safeRemoveChild(parent: HTMLElement | null, child: HTMLElement | null): void {
if (!this.canUseDOM() || !parent || !child) return;
parent.removeChild(child);
}
/**
* 현재 페이지 정보 가져오기 (SSR 안전)
*/
static getPageInfo() {
return {
url: this.getWindowProperty('location', { href: '' }).href,
title: this.getDocumentProperty('title', ''),
referrer: this.getDocumentProperty('referrer', ''),
};
}
/**
* 뷰포트 정보 가져오기 (SSR 안전)
*/
static getViewportInfo() {
return {
width: this.getWindowProperty('innerWidth', 0),
height: this.getWindowProperty('innerHeight', 0),
pixelRatio: this.getWindowProperty('devicePixelRatio', 1),
};
}
/**
* 스크롤 정보 가져오기 (SSR 안전)
*/
static getScrollInfo() {
const scrollTop = this.canUseDOM()
? (window.pageYOffset || document.documentElement.scrollTop)
: 0;
return {
scrollTop,
scrollLeft: this.getWindowProperty('pageXOffset', 0),
};
}
/**
* DOM 요소가 나타날 때까지 기다리기 (사용자 친화적 API)
*/
static async waitForElement(
id: string,
options: {
timeout?: number; // 최대 대기 시간 (ms), 기본값: 3000
retryInterval?: number; // 재시도 간격 (ms), 기본값: 100
debug?: boolean; // 디버그 로그 출력 여부
} = {}
): Promise<HTMLElement> {
const { timeout = 3000, retryInterval = 100, debug = false } = options;
if (!this.canUseDOM()) {
throw new Error('DOM을 사용할 수 없는 환경입니다.');
}
// 즉시 찾을 수 있으면 바로 반환
const immediateElement = document.getElementById(id);
if (immediateElement) {
if (debug) {
console.log(`✅ 컨테이너 즉시 발견: ${id}`);
}
return immediateElement;
}
if (debug) {
console.log(`⏳ 컨테이너 대기 시작: ${id} (최대 ${timeout}ms)`);
}
return new Promise((resolve, reject) => {
let attempts = 0;
const maxAttempts = Math.ceil(timeout / retryInterval);
const checkElement = () => {
attempts++;
const element = document.getElementById(id);
if (element) {
if (debug) {
console.log(`✅ 컨테이너 발견: ${id} (${attempts}번째 시도, ${attempts * retryInterval}ms 경과)`);
}
resolve(element);
return;
}
if (attempts >= maxAttempts) {
const errorMessage = `❌ 컨테이너를 찾을 수 없습니다: "${id}"
다음을 확인해보세요:
1. HTML에 id="${id}" 요소가 있는지 확인
2. React 등에서 컴포넌트가 렌더링된 후 SDK 호출
3. 철자가 정확한지 확인
4. 중복된 ID가 없는지 확인
대기 시간: ${timeout}ms (${attempts}번 시도)`;
if (debug) {
console.error(errorMessage);
}
reject(new Error(errorMessage));
return;
}
if (debug && attempts % 10 === 0) {
console.log(`⏳ 컨테이너 대기 중: ${id} (${attempts}/${maxAttempts})`);
}
// Exponential backoff: 처음엔 빠르게, 나중엔 느리게
const nextInterval = Math.min(retryInterval * Math.pow(1.2, attempts), 500);
setTimeout(checkElement, nextInterval);
};
// 첫 번째 체크는 즉시 실행
setTimeout(checkElement, retryInterval);
});
}
/**
* 여러 DOM 요소를 동시에 기다리기
*/
static async waitForElements(
ids: string[],
options: {
timeout?: number;
retryInterval?: number;
debug?: boolean;
} = {}
): Promise<HTMLElement[]> {
const promises = ids.map(id => this.waitForElement(id, options));
return Promise.all(promises);
}
}