Build a DOM Diff Viewer Chrome Extension: Complete Developer's Guide

28 min read

Build a DOM Diff Viewer Chrome Extension: Complete Developer's Guide

Build a DOM Diff Viewer Chrome Extension: Complete Developer’s Guide

Web development often requires monitoring changes in web pages, debugging dynamic content, or tracking how websites evolve over time. Whether you are a web developer testing your own applications, a QA engineer verifying UI updates, or a researcher monitoring competitor websites, having a tool that can compare HTML and highlight differences is invaluable. In this comprehensive guide, we will walk through building a DOM diff viewer Chrome extension that captures page states, compares them, and visualizes differences in an intuitive way.

This project will teach you essential Chrome extension development skills while creating a practical tool that you can use daily. By the end of this guide, you will have a fully functional extension that can track page changes, compare HTML structures, and display visual diffs.


Why Build a DOM Diff Extension?

Before diving into the code, let us understand why a DOM diff extension is useful and what problems it solves. The primary use cases include:

Web Development and Testing

When building dynamic web applications, understanding how the DOM changes in response to user interactions or API responses is crucial. A DOM diff extension allows developers to capture the current state of a page, make changes, and then compare the before and after states. This is particularly useful for debugging React, Vue, or Angular applications where the DOM updates dynamically.

Competitive Monitoring

Marketing teams and researchers often need to track changes on competitor websites. A page change tracker extension can periodically capture snapshots and alert users when significant changes occur. This is useful for tracking price changes, content updates, or design modifications.

QA Automation

Quality assurance teams can use DOM diff tools to verify that UI changes have been applied correctly across different pages. Instead of manually inspecting each element, QA engineers can capture snapshots and generate detailed difference reports.

Accessibility Testing

Comparing DOM structures helps identify accessibility issues that might arise from improper element nesting or missing attributes. A diff tool can highlight structural changes that could impact screen reader compatibility.


Understanding the Core Concepts

Before writing code, we need to understand the key concepts that make a DOM diff extension work:

DOM Serialization

To compare two versions of a page’s DOM, we first need to serialize it into a format that can be compared. The DOM is a tree structure, and we need to convert it into a string representation. We will use JavaScript’s ability to serialize DOM nodes into HTML strings.

Diff Algorithms

Several algorithms exist for computing differences between two strings or trees. The most common approaches include:

  • Line-by-line diff: Simple but not suitable for HTML where formatting may vary
  • Tree-based diff: Compares the DOM as a tree structure, identifying added, removed, or modified nodes
  • Semantic diff: Understands HTML semantics and produces more meaningful results

For this project, we will use the diff library, which provides robust text diffing capabilities.

Visual Diff Rendering

Once we compute the differences, we need to render them in a way that is easy to understand. We will use color coding: green for additions, red for deletions, and yellow for modifications.


Setting Up the Project Structure

Let us start by creating the project structure for our Chrome extension. Create a new folder for your project and add the following files:

dom-diff-extension/
├── manifest.json
├── popup.html
├── popup.js
├── popup.css
├── background.js
├── content.js
├── diff-worker.js
├── lib/
│   └── diff.js
└── icons/
    ├── icon16.png
    ├── icon48.png
    └── icon128.png

The manifest.json file defines the extension configuration:

{
  "manifest_version": 3,
  "name": "DOM Diff Viewer",
  "version": "1.0.0",
  "description": "Compare DOM states and track page changes with visual diffs",
  "permissions": [
    "activeTab",
    "storage",
    "scripting"
  ],
  "host_permissions": [
    "<all_urls>"
  ],
  "action": {
    "default_popup": "popup.html",
    "default_icon": {
      "16": "icons/icon16.png",
      "48": "icons/icon48.png",
      "128": "icons/icon128.png"
    }
  },
  "background": {
    "service_worker": "background.js"
  },
  "content_scripts": [
    {
      "matches": ["<all_urls>"],
      "js": ["content.js"]
    }
  ]
}

This configuration grants the necessary permissions for capturing page content, storing snapshots, and running scripts in the context of web pages.


Implementing the Content Script

The content script runs in the context of web pages and is responsible for capturing the DOM state. Create the content.js file:

// content.js - Runs in the context of web pages

// Function to serialize the DOM into a clean HTML string
function serializeDOM() {
  // Clone the document to avoid modifying the original
  const clone = document.documentElement.cloneNode(true);
  
  // Remove script tags to avoid execution when re-rendering
  const scripts = clone.querySelectorAll('script');
  scripts.forEach(script => script.remove());
  
  // Remove development attributes that change frequently
  const removeAttrs = ['data-reactid', 'data-cy', 'data-testid', 'ng-binding'];
  clone.querySelectorAll('*').forEach(el => {
    removeAttrs.forEach(attr => el.removeAttribute(attr));
  });
  
  // Return formatted HTML
  return clone.outerHTML;
}

// Function to get a summary of the DOM structure
function getDOMSummary() {
  return {
    url: window.location.href,
    title: document.title,
    timestamp: new Date().toISOString(),
    elementCount: document.querySelectorAll('*').length,
    html: serializeDOM()
  };
}

// Listen for messages from the popup
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
  if (request.action === 'captureDOM') {
    const summary = getDOMSummary();
    sendResponse(summary);
  }
  return true;
});

This content script provides two main functions: serializing the DOM into a clean HTML string and capturing a summary of the page state. The serialization process removes script tags (to prevent re-execution) and strips development attributes that would cause false positives in diffs.


The popup is the user interface that appears when clicking the extension icon. Create popup.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>DOM Diff Viewer</title>
  <link rel="stylesheet" href="popup.css">
</head>
<body>
  <div class="container">
    <header>
      <h1>DOM Diff Viewer</h1>
      <p class="subtitle">Compare and track page changes</p>
    </header>

    <section class="actions">
      <button id="captureBtn" class="primary-btn">
        <span class="icon">📸</span> Capture Current State
      </button>
      <button id="compareBtn" class="secondary-btn" disabled>
        <span class="icon">🔍</span> Compare with Snapshot
      </button>
    </section>

    <section class="status">
      <div id="currentState" class="state-info">
        <h3>Current State</h3>
        <p id="currentUrl">No URL captured</p>
        <p id="currentTime">-</p>
      </div>
      
      <div id="snapshotState" class="state-info">
        <h3>Saved Snapshot</h3>
        <p id="snapshotUrl">No snapshot saved</p>
        <p id="snapshotTime">-</p>
      </div>
    </section>

    <section id="diffResults" class="diff-container hidden">
      <h3>Difference Results</h3>
      <div class="diff-stats">
        <span class="added">+ <span id="addedCount">0</span> additions</span>
        <span class="removed">- <span id="removedCount">0</span> deletions</span>
        <span class="modified">~ <span id="modifiedCount">0</span> changes</span>
      </div>
      <div id="diffOutput" class="diff-output"></div>
      <button id="clearBtn" class="text-btn">Clear Snapshot</button>
    </section>
  </div>
  <script src="popup.js"></script>
</body>
</html>

Now let us create the styling in popup.css:

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
  width: 400px;
  min-height: 300px;
  background: #f5f5f5;
  color: #333;
}

.container {
  padding: 20px;
}

header {
  text-align: center;
  margin-bottom: 20px;
}

h1 {
  font-size: 20px;
  color: #1a73e8;
  margin-bottom: 4px;
}

.subtitle {
  font-size: 12px;
  color: #666;
}

.actions {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}

button {
  flex: 1;
  padding: 12px 16px;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  transition: all 0.2s ease;
}

.primary-btn {
  background: #1a73e8;
  color: white;
}

.primary-btn:hover {
  background: #1557b0;
}

.secondary-btn {
  background: #e8f0fe;
  color: #1a73e8;
}

.secondary-btn:disabled {
  background: #e0e0e0;
  color: #9e9e9e;
  cursor: not-allowed;
}

.text-btn {
  background: none;
  color: #666;
  text-decoration: underline;
}

.status {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
  margin-bottom: 20px;
}

.state-info {
  background: white;
  padding: 12px;
  border-radius: 8px;
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}

.state-info h3 {
  font-size: 12px;
  color: #666;
  margin-bottom: 8px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

.state-info p {
  font-size: 11px;
  color: #333;
  word-break: break-all;
}

.diff-container {
  background: white;
  padding: 16px;
  border-radius: 8px;
  box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}

.diff-container.hidden {
  display: none;
}

.diff-stats {
  display: flex;
  gap: 16px;
  margin-bottom: 12px;
  font-size: 13px;
}

.diff-stats .added {
  color: #188038;
}

.diff-stats .removed {
  color: #d93025;
}

.diff-stats .modified {
  color: #f9ab00;
}

.diff-output {
  max-height: 300px;
  overflow-y: auto;
  font-family: 'Monaco', 'Menlo', monospace;
  font-size: 11px;
  line-height: 1.6;
  white-space: pre-wrap;
  word-break: break-all;
}

.diff-output .added-line {
  background: #e6ffed;
  color: #188038;
}

.diff-output .removed-line {
  background: #ffebe9;
  color: #d93025;
}

The popup JavaScript handles user interactions and coordinates between the content script and storage. Create popup.js:

// popup.js - Popup interface logic

let currentSnapshot = null;

// Initialize popup
document.addEventListener('DOMContentLoaded', () => {
  loadSnapshot();
  setupEventListeners();
});

function setupEventListeners() {
  document.getElementById('captureBtn').addEventListener('click', captureCurrentState);
  document.getElementById('compareBtn').addEventListener('click', compareWithSnapshot);
  document.getElementById('clearBtn').addEventListener('click', clearSnapshot);
}

async function captureCurrentState() {
  try {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    
    // Request DOM from content script
    const response = await chrome.tabs.sendMessage(tab.id, { action: 'captureDOM' });
    
    if (!response) {
      showError('Unable to capture DOM. Please refresh the page and try again.');
      return;
    }
    
    // Save as current snapshot
    currentSnapshot = response;
    await chrome.storage.local.set({ snapshot: response });
    
    // Update UI
    updateCurrentState(response);
    enableCompareButton();
    hideDiffResults();
    
  } catch (error) {
    console.error('Capture error:', error);
    showError('Failed to capture page state.');
  }
}

async function compareWithSnapshot() {
  if (!currentSnapshot) return;
  
  try {
    const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
    const currentState = await chrome.tabs.sendMessage(tab.id, { action: 'captureDOM' });
    
    if (!currentState) {
      showError('Unable to capture current DOM state.');
      return;
    }
    
    // Perform diff using the diff library
    const diffResult = computeDiff(currentSnapshot.html, currentState.html);
    
    // Display results
    displayDiffResults(diffResult, currentSnapshot, currentState);
    
  } catch (error) {
    console.error('Compare error:', error);
    showError('Failed to compare states.');
  }
}

function computeDiff(oldHtml, newHtml) {
  // Simple line-by-line diff implementation
  const oldLines = oldHtml.split('\n');
  const newLines = newHtml.split('\n');
  
  const result = [];
  let added = 0, removed = 0, modified = 0;
  
  // Use a simple LCS-based approach for diffing
  const lcs = computeLCS(oldLines, newLines);
  
  let oldIndex = 0, newIndex = 0, lcsIndex = 0;
  
  while (oldIndex < oldLines.length || newIndex < newLines.length) {
    if (lcsIndex < lcs.length && oldIndex < oldLines.length && newIndex < newLines.length) {
      if (oldLines[oldIndex] === lcs[lcsIndex] && newLines[newIndex] === lcs[lcsIndex]) {
        result.push({ type: 'unchanged', content: oldLines[oldIndex] });
        oldIndex++;
        newIndex++;
        lcsIndex++;
      } else if (oldLines[oldIndex] !== lcs[lcsIndex] && newLines[newIndex] !== lcs[lcsIndex]) {
        // Modified line
        result.push({ type: 'removed', content: oldLines[oldIndex] });
        result.push({ type: 'added', content: newLines[newIndex] });
        oldIndex++;
        newIndex++;
        modified++;
      } else if (oldLines[oldIndex] !== lcs[lcsIndex]) {
        // Removed line
        result.push({ type: 'removed', content: oldLines[oldIndex] });
        oldIndex++;
        removed++;
      } else {
        // Added line
        result.push({ type: 'added', content: newLines[newIndex] });
        newIndex++;
        added++;
      }
    } else {
      if (oldIndex < oldLines.length) {
        result.push({ type: 'removed', content: oldLines[oldIndex] });
        oldIndex++;
        removed++;
      }
      if (newIndex < newLines.length) {
        result.push({ type: 'added', content: newLines[newIndex] });
        newIndex++;
        added++;
      }
    }
  }
  
  return { changes: result, stats: { added, removed, modified } };
}

function computeLCS(arr1, arr2) {
  const m = arr1.length;
  const n = arr2.length;
  const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
  
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (arr1[i - 1] === arr2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
      }
    }
  }
  
  // Backtrack to find LCS
  const lcs = [];
  let i = m, j = n;
  while (i > 0 && j > 0) {
    if (arr1[i - 1] === arr2[j - 1]) {
      lcs.unshift(arr1[i - 1]);
      i--;
      j--;
    } else if (dp[i - 1][j] > dp[i][j - 1]) {
      i--;
    } else {
      j--;
    }
  }
  
  return lcs;
}

function displayDiffResults(diffResult, oldState, newState) {
  const container = document.getElementById('diffResults');
  const output = document.getElementById('diffOutput');
  
  document.getElementById('addedCount').textContent = diffResult.stats.added;
  document.getElementById('removedCount').textContent = diffResult.stats.removed;
  document.getElementById('modifiedCount').textContent = diffResult.stats.modified;
  
  output.innerHTML = '';
  
  // Limit displayed lines for performance
  const displayLimit = 500;
  const changes = diffResult.changes.slice(0, displayLimit);
  
  changes.forEach(change => {
    const line = document.createElement('div');
    line.className = `diff-line ${change.type}-line`;
    line.textContent = (change.type === 'added' ? '+ ' : change.type === 'removed' ? '- ' : '  ') + change.content;
    output.appendChild(line);
  });
  
  if (diffResult.changes.length > displayLimit) {
    const more = document.createElement('div');
    more.textContent = `... and ${diffResult.changes.length - displayLimit} more changes`;
    output.appendChild(more);
  }
  
  container.classList.remove('hidden');
}

async function loadSnapshot() {
  const result = await chrome.storage.local.get('snapshot');
  if (result.snapshot) {
    currentSnapshot = result.snapshot;
    updateSnapshotState(result.snapshot);
    enableCompareButton();
  }
}

function updateCurrentState(state) {
  document.getElementById('currentUrl').textContent = new URL(state.url).pathname.substring(0, 30);
  document.getElementById('currentTime').textContent = new Date(state.timestamp).toLocaleTimeString();
}

function updateSnapshotState(state) {
  document.getElementById('snapshotUrl').textContent = new URL(state.url).pathname.substring(0, 30);
  document.getElementById('snapshotTime').textContent = new Date(state.timestamp).toLocaleTimeString();
}

function enableCompareButton() {
  document.getElementById('compareBtn').disabled = false;
}

function hideDiffResults() {
  document.getElementById('diffResults').classList.add('hidden');
}

async function clearSnapshot() {
  await chrome.storage.local.remove('snapshot');
  currentSnapshot = null;
  document.getElementById('snapshotUrl').textContent = 'No snapshot saved';
  document.getElementById('snapshotTime').textContent = '-';
  document.getElementById('compareBtn').disabled = true;
  hideDiffResults();
}

function showError(message) {
  alert(message);
}

This popup logic handles capturing DOM states, storing snapshots using Chrome’s storage API, computing differences between two HTML strings, and displaying the results with color-coded highlighting.


Adding Background Service Worker

The background service worker handles extension lifecycle events. Create background.js:

// background.js - Service worker for extension lifecycle

// Handle extension installation
chrome.runtime.onInstalled.addListener((details) => {
  if (details.reason === 'install') {
    console.log('DOM Diff Viewer extension installed');
  } else if (details.reason === 'update') {
    console.log('DOM Diff Viewer extension updated');
  }
});

// Handle keyboard shortcut if needed
chrome.commands.onCommand.addListener((command) => {
  if (command === 'capture-dom') {
    chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
      chrome.tabs.sendMessage(tabs[0].id, { action: 'captureDOM' });
    });
  }
});

Advanced: Adding Page Change Tracking

One of the most powerful features of a DOM diff extension is the ability to track page changes over time. Let us add functionality to monitor pages and alert users when changes occur.

Creating a Monitoring Panel

Add this to your popup.html to enable page change tracking:

<section class="monitoring">
  <h3>Page Change Tracker</h3>
  <div class="monitor-options">
    <label>
      <input type="checkbox" id="autoMonitor">
      Auto-monitor this page
    </label>
  </div>
  <div id="monitorHistory" class="history-list"></div>
</section>

Implementing Change Detection Logic

Add to popup.js:

// Page change tracking functionality
let monitorInterval = null;

async function startMonitoring(intervalMs = 5000) {
  if (monitorInterval) {
    clearInterval(monitorInterval);
  }
  
  const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
  const tabId = tab.id;
  
  monitorInterval = setInterval(async () => {
    try {
      const response = await chrome.tabs.sendMessage(tabId, { action: 'captureDOM' });
      const stored = await chrome.storage.local.get('snapshot');
      
      if (stored.snapshot && response) {
        const diff = computeDiff(stored.snapshot.html, response.html);
        
        // If significant changes detected
        if (diff.stats.added + diff.stats.removed > 10) {
          await notifyChange(diff.stats);
        }
      }
    } catch (error) {
      console.error('Monitoring error:', error);
    }
  }, intervalMs);
}

async function notifyChange(stats) {
  // Show notification
  chrome.notifications.create({
    type: 'basic',
    iconUrl: 'icons/icon128.png',
    title: 'Page Change Detected',
    message: `Found ${stats.added} additions, ${stats.removed} deletions`
  });
  
  // Optionally play a sound
  // const audio = new Audio('alert.mp3');
  // audio.play();
}

function stopMonitoring() {
  if (monitorInterval) {
    clearInterval(monitorInterval);
    monitorInterval = null;
  }
}

This page change tracker automatically monitors the active tab and notifies users when significant DOM changes are detected, making it perfect for watching dynamic pages, loading states, or real-time updates.


Testing Your Extension

Before deploying, thoroughly test your extension:

  1. Load the extension: Open chrome://extensions/, enable Developer mode, click “Load unpacked”, and select your extension folder.

  2. Test DOM capture: Navigate to any website, click the extension icon, and click “Capture Current State”. Verify that the URL and timestamp appear in the Current State section.

  3. Test snapshot comparison: Make a change on the page (click a button, expand an accordion, etc.), then click “Compare with Snapshot”. Verify that the diff results show the changes.

  4. Test page change tracking: Enable auto-monitoring, make changes on the page, and verify that notifications appear.

  5. Test edge cases: Try the extension on pages with:

    • Large DOM trees (1000+ elements)
    • Dynamic content (timers, animations)
    • Single-page applications
    • Frames and iframes

Performance Optimization Tips

When building a production-ready DOM diff extension, consider these optimizations:

  1. Debounce captures: Wait for the page to stabilize before capturing. Use mutation observers to detect when the DOM has finished changing.

  2. Limit diff size: For very large pages, limit the initial diff to specific selectors or paginate results.

  3. Use Web Workers: Move diff computation to a Web Worker to avoid blocking the UI thread.

  4. Cache results: Store comparison results to avoid recomputing when switching between views.

  5. Lazy load content scripts: Only inject content scripts when needed, not on every page load.


Publishing Your Extension

Once testing is complete, follow these steps to publish to the Chrome Web Store:

  1. Prepare for release: Update version in manifest.json, add a privacy policy if collecting data, and create promotional screenshots.

  2. Zip your extension: Create a ZIP file of your extension folder (excluding development files).

  3. Create developer account: Sign up at the Chrome Web Store Developer Dashboard.

  4. Upload and publish: Upload your ZIP file, fill in the store listing details, and submit for review.

  5. Monitor reviews: Address any issues flagged by Google’s review team.


Conclusion

You have now built a fully functional DOM diff viewer Chrome extension that can capture page states, compare HTML structures, and track page changes. This extension demonstrates core Chrome extension concepts including content scripts, popup interfaces, storage, and background service workers.

The skills you have learned in this project apply to many other extension types. You can extend this DOM diff viewer with features like side-by-side comparison views, exportable reports, integration with version control systems, or team collaboration features.

Remember that the Chrome extension ecosystem continues to evolve with Manifest V3, so stay updated with Google’s best practices and guidelines. With this foundation, you are well-equipped to build sophisticated developer tools that enhance productivity for yourself and your users.

Start by installing your extension, testing it on various websites, and iterating on the features to make it even more useful for your specific use cases. Happy building!


Additional Resources

No previous article
No next article