렌더링 파이프라인

잘 구조화된 렌더링 파이프라인은 일관된 업데이트와 좋은 성능을 보장합니다.

개요

렌더링 파이프라인은 문서 모델을 일련의 잘 정의된 단계를 통해 DOM 업데이트로 변환합니다. 이를 통해 예측 가능한 동작을 보장하고 각 단계에서 최적화를 가능하게 합니다.

렌더 단계

단계 분석

렌더링을 구별되는 단계로 나눕니다:

class RenderPipeline {
  render(document) {
    // Phase 1: Prepare
    const prepared = this.#prepare(document);
    
    // Phase 2: Transform
    const transformed = this.#transform(prepared);
    
    // Phase 3: Validate
    const validated = this.#validate(transformed);
    
    // Phase 4: Render
    const dom = this.#renderToDOM(validated);
    
    // Phase 5: Update
    this.#updateDOM(dom);
    
    // Phase 6: Post-process
    this.#postProcess();
  }
  
  #prepare(doc) {
    // Normalize structure
    // Resolve references
    // Prepare data structures
    return normalized;
  }
  
  #transform(doc) {
    // Apply transforms from plugins
    // Convert model nodes to render nodes
    return transformed;
  }
  
  #validate(doc) {
    // Ensure document is valid
    // Fix any issues
    return validated;
  }
  
  #renderToDOM(doc) {
    // Create DOM elements
    // Set attributes
    // Build tree
    return dom;
  }
  
  #updateDOM(newDOM) {
    // Diff with current DOM
    // Apply updates
    // Preserve selection
  }
  
  #postProcess() {
    // Scroll into view
    // Update selection
    // Trigger events
  }
}

단계 순서

단계 순서는 정확성을 위해 중요합니다:

  • Prepare: 입력을 정규화하기 위해 먼저 실행되어야 함
  • Transform: Prepare 이후, Validate 이전에 실행
  • Validate: 변환된 문서가 유효한지 확인
  • Render: 유효한 문서에서 DOM 생성
  • Update: 기존 DOM에 변경 사항 적용
  • Post-process: 최종 정리 및 부작용

업데이트 스케줄링

우선순위 스케줄링

블로킹을 피하기 위해 업데이트를 효율적으로 스케줄링합니다:

class UpdateScheduler {
  #pendingUpdates = new Set();
  #scheduled = false;
  
  scheduleUpdate(priority, update) {
    this.#pendingUpdates.add({ update, priority });
    
    if (priority === 'high') {
      // Immediate update
      this.#flush();
    } else {
      // Schedule for next frame
      this.#schedule();
    }
  }
  
  #schedule() {
    if (this.#scheduled) return;
    
    this.#scheduled = true;
    
    // Use MessageChannel for immediate scheduling
    const channel = new MessageChannel();
    channel.port1.onmessage = () => {
      this.#flush();
      this.#scheduled = false;
    };
    channel.port2.postMessage(null);
  }
  
  #flush() {
    const updates = Array.from(this.#pendingUpdates);
    this.#pendingUpdates.clear();
    
    // Group by priority
    const high = updates.filter(u => u.priority === 'high');
    const normal = updates.filter(u => u.priority === 'normal');
    const low = updates.filter(u => u.priority === 'low');
    
    // Execute in priority order
    high.forEach(({ update }) => update());
    normal.forEach(({ update }) => update());
    low.forEach(({ update }) => update());
  }
}

// Usage
scheduler.scheduleUpdate('high', () => {
  // Critical update (e.g., selection)
  updateSelection();
});

scheduler.scheduleUpdate('normal', () => {
  // Normal update (e.g., content)
  updateContent();
});

scheduler.scheduleUpdate('low', () => {
  // Low priority (e.g., syntax highlighting)
  updateSyntaxHighlighting();
});

프레임 스케줄링

부드러운 렌더링을 위해 requestAnimationFrame을 사용합니다:

class FrameScheduler {
  #pending = [];
  #scheduled = false;
  
  schedule(update) {
    this.#pending.push(update);
    
    if (!this.#scheduled) {
      this.#scheduled = true;
      requestAnimationFrame(() => {
        this.#flush();
        this.#scheduled = false;
      });
    }
  }
  
  #flush() {
    const updates = this.#pending;
    this.#pending = [];
    
    updates.forEach(update => {
      try {
        update();
      } catch (error) {
        console.error('Update failed:', error);
      }
    });
  }
  
  // Schedule with time budget
  scheduleWithBudget(update, budget = 5) {
    const start = performance.now();
    
    requestAnimationFrame(() => {
      update();
      
      const elapsed = performance.now() - start;
      if (elapsed > budget) {
        console.warn('Update exceeded budget:', elapsed);
      }
    });
  }
}

렌더 최적화

성능을 위한 렌더링 최적화:

class OptimizedPipeline extends RenderPipeline {
  #cache = new Map();
  #dirtyNodes = new Set();
  
  render(document) {
    // Only render dirty nodes
    const nodesToRender = this.#getDirtyNodes(document);
    
    if (nodesToRender.size === 0) {
      // No changes, return cached result
      return this.#cache.get(this.#getDocumentKey(document));
    }
    
    // Render only changed nodes
    const rendered = {};
    nodesToRender.forEach(nodeId => {
      const node = this.#getNodeById(nodeId);
      rendered[nodeId] = this.#renderNode(node);
    });
    
    // Merge with cached results
    const result = { ...this.#cache.get(this.#getDocumentKey(document)), ...rendered };
    
    // Update cache
    this.#cache.set(this.#getDocumentKey(document), result);
    this.#dirtyNodes.clear();
    
    return result;
  }
  
  markDirty(nodeId) {
    this.#dirtyNodes.add(nodeId);
    // Mark ancestors as dirty too
    this.#markAncestorsDirty(nodeId);
  }
  
  #markAncestorsDirty(nodeId) {
    let current = nodeId;
    while (current) {
      this.#dirtyNodes.add(current);
      current = this.#getParentId(current);
    }
  }
}