一种简洁优雅的纯客户端 Snapshot 方案

1 阅读9分钟

## 一、背景与问题

在现代 Web 应用中,首屏加载性能是影响用户体验的关键因素之一。传统的优化手段包括:

  • 服务端渲染 (SSR):需要服务端资源,增加运维成本
  • 静态站点生成 (SSG):适合内容固定的场景,不适用于动态数据
  • 预渲染:构建时生成 HTML,无法处理用户个性化内容

然而,对于像在线表格、文档编辑器这类数据驱动型应用,用户看到的内容是个性化的,传统方案难以适用。

核心痛点:如何在不依赖服务端的情况下,让用户在首屏看到「有意义的内容」,而不是漫长的白屏或 Loading 动画?

接下里,本文将介绍一种纯客户端的 Snapshot 方案,通过将页面 DOM 序列化为 JSON 并存储到 IndexedDB 中,实现首屏的「秒开」体验。

二、方案概述

2.1 核心思路

整体方案分为两个阶段:

  1. 保存阶段:在页面渲染完成后,将 DOM 结构和样式序列化为 JSON,存储到 IndexedDB
  2. 恢复阶段:在下次访问时,首屏渲染时从 IndexedDB 读取 JSON 形式的 Snapshot,还原为 DOM 并展示

2.2 整体流程图

2.2.1 保存阶段

graph TB
    A[页面渲染完成] --> B[遍历 DOM 树]
    B --> C[序列化为 JSON]
    C --> D[收集页面样式]
    D --> E[存储到 IndexedDB]
    E --> F[保存完成]

2.2.2 恢复阶段

graph TB
    A[页面加载] --> B{IndexedDB 有缓存?}
    B -->|是| C[读取 JSON 数据]
    C --> D[安全校验]
    D --> E[还原 DOM 节点]
    E --> F[插入页面展示]
    F --> G[用户立即看到内容]
    B -->|否| H[正常加载流程]
    H --> G

2.3 性能数据

以笔者的4年前的老款Mac 电脑为例,在有了 HTML 后,首屏内容可以稳定地在 200ms 左右就渲染成功

指标数值说明
首屏恢复耗时~200ms从 IndexedDB 读取并渲染
保存一次耗时~400ms包含 DOM 遍历和样式处理
存储空间占用~100-500KB单个快照的典型大小

三、核心实现

3.1 DOM 节点序列化

将 DOM 树转换为可存储的 JSON 结构是方案的核心。需要递归遍历节点,提取关键信息:

/**
 * 将 DOM 节点转换为 JSON 对象
 * @param {Node} node - 要转换的 DOM 节点
 * @returns {Object} - 序列化后的 JSON 对象
 */
function nodeToJSON(node) {
  // 处理文本节点
  if (node.nodeType === Node.TEXT_NODE) {
    const text = node.textContent?.trim();
    return text ? { textContent: text } : null;
  }

  // 处理元素节点
  if (node.nodeType === Node.ELEMENT_NODE) {
    const element = node;
    const json = {
      tagName: element.tagName.toLowerCase(),
      attributes: {},
      childNodes: []
    };

    // 收集属性
    for (const attr of element.attributes) {
      json.attributes[attr.name] = attr.value;
    }

    // 递归处理子节点
    for (const child of element.childNodes) {
      const childJson = nodeToJSON(child);
      if (childJson) {
        json.childNodes.push(childJson);
      }
    }

    return json;
  }

  return null;
}

3.2 安全还原机制

从存储中恢复 DOM 时,安全性是重中之重。必须防止 XSS 攻击,使用白名单机制过滤标签和属性:

// 允许的标签白名单
const ALLOWED_TAGS = ['div', 'span', 'a', 'svg', 'button', 'path', 'img'];

// 允许的属性白名单
const ALLOWED_ATTRIBUTES = new Set([
  'class', 'style', 'id', 'width', 'height', 'src', 'href', 'd',
  'fill', 'viewbox', 'xmlns', 'fill-rule', 'preserveaspectratio', 
  'type', 'disabled', 'data-testid'
]);

/**
 * 将 JSON 对象还原为 DOM 节点
 * @param {Object} json - 序列化的 JSON 数据
 * @returns {Node} - 还原后的 DOM 节点
 */
function jsonToNode(json) {
  if (!json) return null;

  // 处理文本节点
  if (json.textContent !== undefined) {
    return document.createTextNode(json.textContent);
  }

  // 标签白名单检查
  const tagName = json.tagName;
  if (!ALLOWED_TAGS.includes(tagName)) {
    console.warn(`[安全提示]: 非法标签: ${tagName}`);
    return null;
  }

  // 创建元素(SVG 需要特殊命名空间)
  const svgNamespace = 'http://www.w3.org/2000/svg';
  const isSvgTag = ['svg', 'path'].includes(tagName);
  const node = isSvgTag 
    ? document.createElementNS(svgNamespace, tagName)
    : document.createElement(tagName);

  // 还原属性(带安全检查)
  if (json.attributes) {
    for (const [key, value] of Object.entries(json.attributes)) {
      const lowerKey = key.toLowerCase();
      
      // 属性白名单检查
      if (!ALLOWED_ATTRIBUTES.has(lowerKey)) {
        console.warn(`[安全提示]: 非法属性: ${key}`);
        continue;
      }
      
      // 防止 javascript: 协议注入
      if ((lowerKey === 'src' || lowerKey === 'href') &&
          value.trim().toLowerCase().startsWith('javascript:')) {
        console.warn(`[安全提示]: 禁止执行 JS`);
        continue;
      }
      
      node.setAttribute(key, value);
    }
  }

  // 递归还原子节点
  if (json.childNodes && Array.isArray(json.childNodes)) {
    json.childNodes.forEach(childJson => {
      const childNode = jsonToNode(childJson);
      if (childNode) {
        node.appendChild(childNode);
      }
    });
  }

  return node;
}

3.3 样式处理

这里仅收集 head 标签里的样式,页面上其余部分的样式视具体情况而定

页面样式的处理需要收集:

  1. 外部样式表<link rel="stylesheet"> 引用的 CSS 文件
  2. 内联样式<style> 标签中的内容
  3. 运行时样式:styled-components 等 CSS-in-JS 方案生成的样式
/**
 * 处理页面样式,将所有样式合并为一个字符串
 * @param {AbortSignal} signal - 用于取消操作的信号
 * @returns {Promise<string>} - 合并后的样式字符串
 */
async function processStylesheets(signal) {
  const styleElements = Array.from(
    document.querySelectorAll('head link[rel="stylesheet"], head style')
  );

  const textPromises = styleElements.map(el => extractStyleContent(el, signal));
  const texts = await Promise.all(textPromises);
  return texts.join('\n\n');
}

/**
 * 提取单个样式元素的内容
 */
async function extractStyleContent(el, signal) {
  if (signal.aborted) return '';

  try {
    // 内联 style 标签
    if (el.tagName.toLowerCase() === 'style' && el.textContent) {
      return el.textContent;
    }

    // 外部样式表
    if (el instanceof HTMLLinkElement && el.href) {
      const response = await fetch(el.href, { signal });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      return response.text();
    }

    // styled-components 运行时样式
    if (el instanceof HTMLStyleElement && 
        el.dataset.styled === 'active' && 
        el.sheet) {
      return Array.from(el.sheet.cssRules)
        .map(rule => rule.cssText)
        .join('\n\n');
    }

    return '';
  } catch (err) {
    if (err.name === 'AbortError') return '';
    return `/* 样式加载失败: ${err.message} */\n`;
  }
}

3.4 IndexedDB 存储管理

使用 IndexedDB 作为存储引擎,具有以下优势:

  • 容量大:通常可存储数百 MB 数据
  • 异步 API:不阻塞主线程
  • 持久化:数据在浏览器关闭后仍然保留
const DB_NAME = 'SnapshotDB';
const DB_VERSION = 1;
const STORE_NAME = 'snapshots';

/**
 * 初始化 IndexedDB 数据库
 */
function initDB() {
  return new Promise((resolve, reject) => {
    const request = indexedDB.open(DB_NAME, DB_VERSION);

    request.onupgradeneeded = (event) => {
      const db = event.target.result;
      if (!db.objectStoreNames.contains(STORE_NAME)) {
        const store = db.createObjectStore(STORE_NAME, {
          keyPath: 'id',
          autoIncrement: true
        });
        // 创建唯一索引用于快速查询
        store.createIndex('uniqueId', 'uniqueId', { unique: true });
        store.createIndex('createdAt', 'createdAt', { unique: false });
      }
    };

    request.onsuccess = (event) => {
      resolve(event.target.result);
    };

    request.onerror = (event) => {
      reject(new Error('Failed to open IndexedDB'));
    };
  });
}

3.5 存储空间清理

为避免无限增长的存储占用,需要实现 LRU(最近最少使用)清理策略

const MAX_SNAPSHOT_COUNT = 200;      // 触发清理的阈值
const TARGET_SNAPSHOT_COUNT = 110;   // 清理后保留的数量

/**
 * 清理过期的 snapshot 记录
 */
async function cleanupExpiredSnapshots(db) {
  // 获取当前记录总数
  const count = await getSnapshotCount(db);
  
  // 未超过限制则无需清理
  if (count <= MAX_SNAPSHOT_COUNT) {
    return;
  }

  // 计算需要删除的数量
  const deleteCount = count - TARGET_SNAPSHOT_COUNT;
  
  // 获取最早创建的记录 ID
  const idsToDelete = await getOldestSnapshotIds(db, deleteCount);
  
  // 批量删除
  if (idsToDelete.length > 0) {
    await deleteSnapshotsByIds(db, idsToDelete);
  }
}

3.6 多页面并发控制

当用户同时打开多个页签时,需要防止多个页面同时执行清理操作,使用 localStorage 实现简单的分布式锁

const CLEANUP_LOCK_KEY = 'snapshot_cleanup_lock';
const CLEANUP_LOCK_TIMEOUT = 30000; // 30秒超时

/**
 * 尝试获取清理锁
 */
function tryAcquireCleanupLock() {
  try {
    const now = Date.now();
    const lockValue = localStorage.getItem(CLEANUP_LOCK_KEY);

    if (lockValue) {
      const lockTime = parseInt(lockValue, 10);
      // 锁未过期,说明有其他页面正在清理
      if (!isNaN(lockTime) && now - lockTime < CLEANUP_LOCK_TIMEOUT) {
        return false;
      }
    }

    // 设置锁
    localStorage.setItem(CLEANUP_LOCK_KEY, now.toString());
    
    // 乐观锁:双重检查
    return localStorage.getItem(CLEANUP_LOCK_KEY) === now.toString();
  } catch (e) {
    return true; // localStorage 不可用时降级
  }
}

/**
 * 释放清理锁
 */
function releaseCleanupLock() {
  try {
    localStorage.removeItem(CLEANUP_LOCK_KEY);
  } catch (e) {
    // 忽略错误
  }
}

四、完整 Demo

以下是一个可以直接运行的最小化示例:

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Snapshot Demo</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
    
    #app { padding: 20px; }
    
    .header {
      background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
      color: white;
      padding: 20px;
      border-radius: 8px;
      margin-bottom: 20px;
    }
    
    .card {
      background: #fff;
      border: 1px solid #e0e0e0;
      border-radius: 8px;
      padding: 16px;
      margin-bottom: 12px;
      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
    }
    
    .btn {
      background: #4CAF50;
      color: white;
      border: none;
      padding: 10px 20px;
      border-radius: 4px;
      cursor: pointer;
      margin-right: 10px;
    }
    
    .btn:hover { background: #45a049; }
    .btn-danger { background: #f44336; }
    .btn-danger:hover { background: #da190b; }
    
    #snapshot-container {
      border: 2px dashed #ccc;
      padding: 10px;
      margin-top: 20px;
      min-height: 100px;
    }
    
    .status { 
      padding: 10px; 
      background: #e8f5e9; 
      border-radius: 4px; 
      margin-top: 10px;
    }
  </style>
</head>
<body>
  <div id="app">
    <div class="header">
      <h1>Snapshot 演示</h1>
      <p>纯客户端的页面快照方案</p>
    </div>
    
    <div class="card">
      <h3>操作面板</h3>
      <p style="margin: 10px 0; color: #666;">点击按钮测试 Snapshot 功能</p>
      <button class="btn" onclick="saveSnapshot()">保存快照</button>
      <button class="btn" onclick="loadSnapshot()">加载快照</button>
      <button class="btn btn-danger" onclick="clearSnapshot()">清除快照</button>
    </div>
    
    <div class="card">
      <h3>动态内容</h3>
      <p id="timestamp">当前时间: -</p>
    </div>
    
    <div id="status" class="status">状态: 就绪</div>
    
    <div id="snapshot-container">
      <p style="color: #999;">快照内容将显示在这里</p>
    </div>
  </div>

  <script>
    // ==================== 配置 ====================
    const DB_NAME = 'SnapshotDB';
    const DB_VERSION = 1;
    const STORE_NAME = 'snapshots';
    const SNAPSHOT_KEY = 'demo_snapshot';

    // 安全白名单
    const ALLOWED_TAGS = ['div', 'span', 'a', 'h1', 'h2', 'h3', 'p', 'button', 'svg', 'path', 'img'];
    const ALLOWED_ATTRIBUTES = new Set([
      'class', 'style', 'id', 'width', 'height', 'src', 'href', 'd',
      'fill', 'viewbox', 'xmlns', 'type', 'disabled'
    ]);

    let db = null;

    // ==================== IndexedDB 初始化 ====================
    async function initDB() {
      return new Promise((resolve, reject) => {
        const request = indexedDB.open(DB_NAME, DB_VERSION);

        request.onupgradeneeded = (event) => {
          const database = event.target.result;
          if (!database.objectStoreNames.contains(STORE_NAME)) {
            database.createObjectStore(STORE_NAME, { keyPath: 'id' });
          }
        };

        request.onsuccess = (event) => {
          db = event.target.result;
          resolve(db);
        };

        request.onerror = () => reject(new Error('数据库打开失败'));
      });
    }

    // ==================== DOM 序列化 ====================
    function nodeToJSON(node) {
      if (node.nodeType === Node.TEXT_NODE) {
        const text = node.textContent?.trim();
        return text ? { textContent: text } : null;
      }

      if (node.nodeType === Node.ELEMENT_NODE) {
        const element = node;
        const json = {
          tagName: element.tagName.toLowerCase(),
          attributes: {},
          childNodes: []
        };

        for (const attr of element.attributes) {
          json.attributes[attr.name] = attr.value;
        }

        for (const child of element.childNodes) {
          const childJson = nodeToJSON(child);
          if (childJson) {
            json.childNodes.push(childJson);
          }
        }

        return json;
      }

      return null;
    }

    // ==================== DOM 反序列化 ====================
    function jsonToNode(json) {
      if (!json) return null;

      if (json.textContent !== undefined) {
        return document.createTextNode(json.textContent);
      }

      const tagName = json.tagName;
      if (!ALLOWED_TAGS.includes(tagName)) {
        console.warn(`非法标签: ${tagName}`);
        return null;
      }

      const svgNamespace = 'http://www.w3.org/2000/svg';
      const isSvgTag = ['svg', 'path'].includes(tagName);
      const node = isSvgTag
        ? document.createElementNS(svgNamespace, tagName)
        : document.createElement(tagName);

      if (json.attributes) {
        for (const [key, value] of Object.entries(json.attributes)) {
          const lowerKey = key.toLowerCase();

          if (!ALLOWED_ATTRIBUTES.has(lowerKey)) continue;

          if ((lowerKey === 'src' || lowerKey === 'href') &&
              value.trim().toLowerCase().startsWith('javascript:')) {
            continue;
          }

          node.setAttribute(key, value);
        }
      }

      if (json.childNodes && Array.isArray(json.childNodes)) {
        json.childNodes.forEach(childJson => {
          const childNode = jsonToNode(childJson);
          if (childNode) node.appendChild(childNode);
        });
      }

      return node;
    }

    // ==================== 样式收集 ====================
    function collectStyles() {
      const styles = [];
      document.querySelectorAll('head style').forEach(el => {
        if (el.textContent) styles.push(el.textContent);
      });
      return styles.join('\n\n');
    }

    // ==================== 保存快照 ====================
    async function saveSnapshot() {
      const startTime = performance.now();
      updateStatus('正在保存快照...');

      try {
        if (!db) await initDB();

        const appNode = document.getElementById('app');
        const snapshot = nodeToJSON(appNode);
        const pageStyle = collectStyles();

        const record = {
          id: SNAPSHOT_KEY,
          snapshot,
          pageStyle,
          createdAt: Date.now()
        };

        await new Promise((resolve, reject) => {
          const transaction = db.transaction(STORE_NAME, 'readwrite');
          const store = transaction.objectStore(STORE_NAME);
          const request = store.put(record);
          request.onsuccess = resolve;
          request.onerror = reject;
        });

        const elapsed = (performance.now() - startTime).toFixed(2);
        updateStatus(`快照保存成功!耗时: ${elapsed}ms`);
      } catch (err) {
        updateStatus(`保存失败: ${err.message}`);
      }
    }

    // ==================== 加载快照 ====================
    async function loadSnapshot() {
      const startTime = performance.now();
      updateStatus('正在加载快照...');

      try {
        if (!db) await initDB();

        const record = await new Promise((resolve, reject) => {
          const transaction = db.transaction(STORE_NAME, 'readonly');
          const store = transaction.objectStore(STORE_NAME);
          const request = store.get(SNAPSHOT_KEY);
          request.onsuccess = () => resolve(request.result);
          request.onerror = reject;
        });

        if (!record) {
          updateStatus('没有找到快照,请先保存');
          return;
        }

        const container = document.getElementById('snapshot-container');
        container.innerHTML = '';

        // 恢复样式
        if (record.pageStyle) {
          const styleEl = document.createElement('style');
          styleEl.textContent = record.pageStyle;
          container.appendChild(styleEl);
        }

        // 恢复 DOM
        const node = jsonToNode(record.snapshot);
        if (node) {
          node.style.border = '2px solid #4CAF50';
          node.style.background = '#f9f9f9';
          container.appendChild(node);
        }

        const elapsed = (performance.now() - startTime).toFixed(2);
        updateStatus(`快照加载成功!耗时: ${elapsed}ms`);
      } catch (err) {
        updateStatus(`加载失败: ${err.message}`);
      }
    }

    // ==================== 清除快照 ====================
    async function clearSnapshot() {
      try {
        if (!db) await initDB();

        await new Promise((resolve, reject) => {
          const transaction = db.transaction(STORE_NAME, 'readwrite');
          const store = transaction.objectStore(STORE_NAME);
          const request = store.delete(SNAPSHOT_KEY);
          request.onsuccess = resolve;
          request.onerror = reject;
        });

        document.getElementById('snapshot-container').innerHTML = 
          '<p style="color: #999;">快照内容将显示在这里</p>';
        updateStatus('快照已清除');
      } catch (err) {
        updateStatus(`清除失败: ${err.message}`);
      }
    }

    // ==================== 辅助函数 ====================
    function updateStatus(msg) {
      document.getElementById('status').textContent = `状态: ${msg}`;
    }

    // 更新时间戳,模拟动态内容
    function updateTimestamp() {
      document.getElementById('timestamp').textContent = 
        `当前时间: ${new Date().toLocaleString()}`;
    }

    // ==================== 初始化 ====================
    (async function init() {
      await initDB();
      updateTimestamp();
      setInterval(updateTimestamp, 1000);
      updateStatus('就绪');
    })();
  </script>
</body>
</html>

五、方案优势

5.1 完全不依赖服务端

与传统 SSR/SSG 方案不同,本方案的所有数据存储和计算都在客户端完成:

对比维度SSRSSG本方案
服务端资源需要构建时需要不需要
个性化内容支持不支持支持
CDN 友好部分完全完全
运维成本

5.2 首屏体验显著提升

传统 SPA 的首屏加载流程:

HTML 下载 → JS 下载 → JS 解析执行 → 数据请求 → 渲染

使用 Snapshot 后的首屏加载流程:

HTML 下载 → 读取 IndexedDB → 立即渲染 (同时进行正常加载)

用户在 200ms 内即可看到有意义的内容,而不是白屏或 Loading。

5.3 安全性保障

通过多层安全机制防止 XSS 攻击:

  1. 标签白名单:只允许渲染预定义的安全标签
  2. 属性白名单:过滤掉潜在危险的属性(如 onclick)
  3. 协议检查:禁止 javascript: 等危险协议
  4. 内容隔离:快照内容与主应用逻辑分离

5.4 智能的存储管理

采用 LRU 策略自动清理过期快照,配合跨页面分布式锁,确保:

  • 存储空间不会无限增长
  • 多页签同时操作不会冲突
  • 清理操作不影响用户体验

六、未来扩展方向

6.1 多语言支持

当前方案可以扩展支持多语言场景:

// 根据语言生成不同的快照 ID
const uniqueId = `${pageId}_${locale}`;

这样每种语言都有独立的快照,切换语言时可以立即展示对应语言的缓存内容。

6.2 版本控制

为快照添加版本标识,当应用更新后自动失效旧版本快照:

const record = {
  uniqueId,
  appVersion: '2.1.0',  // 应用版本
  snapshot,
  pageStyle
};

// 恢复时检查版本
if (record.appVersion !== currentVersion) {
  // 版本不匹配,使用正常加载流程
  return null;
}

6.3 并发支持

因为保存页面上的 DOM 结构为 snapshot 是1个异步的操作,如果我们的应用在首屏上有多个tab,那么在快速切换时就会存在竞态的问题,这里可以考虑用 AbortController 来做竞态管理。伪代码如下:

function saveHTMLToSnapshot(){
    // 1. 如果有正在进行的任务,立即中断它
    if (this.currentAbortController) {
      this.currentAbortController.abort();
    }

    // 2. 创建新的 AbortController
    this.currentAbortController = new AbortController();
    const { signal } = this.currentAbortController;
    
    // ...... 执行一些操作,例如保存样式
    
    // 3. 检查当前保存 snapshot 的行为是否已被中断
    if (signal.aborted) return;
}
    

七、总结

本文介绍的纯客户端 Snapshot 方案,通过将 DOM 和样式序列化存储到 IndexedDB,实现了:

  • 首屏 200ms 内展示有意义的内容,大幅提升用户体验
  • 完全不依赖服务端资源,降低运维成本和架构复杂度
  • 支持个性化内容,适用于数据驱动型应用
  • 安全可靠,多层白名单机制防止 XSS 攻击
  • 智能管理存储空间,LRU 策略 + 分布式锁保障稳定性

该方案特别适用于:

  • 在线表格、文档编辑器等数据密集型应用
  • 需要快速首屏但难以使用 SSR 的场景
  • 对服务端资源敏感的项目