强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

Chrome 扩展开发完全指南 / 第 6 章:选项页面(Options Page)

第 6 章:选项页面(Options Page)

选项页面(Options Page)是扩展提供给用户的配置界面。与 Popup 不同,Options Page 是一个独立的标签页或内嵌面板,适合展示复杂的设置项和配置界面。


6.1 Options Page 基础

6.1.1 两种类型

类型声明方式展示方式适用场景
独立页面"options_page": "options.html"新标签页打开复杂设置界面
内嵌页面"options_ui": { "page": "options.html", "open_in_tab": false }在扩展管理页内嵌简单配置

6.1.2 manifest.json 声明

{
  "options_ui": {
    "page": "options/options.html",
    "open_in_tab": true
  }
}

⚠️ 注意options_uioptions_page 二选一。推荐使用 options_ui,它支持 open_in_tab 参数。


6.2 Options 页面结构

HTML 模板

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <title>扩展设置</title>
  <link rel="stylesheet" href="options.css">
</head>
<body>
  <div class="options-container">
    <header class="options-header">
      <h1>⚙️ 扩展设置</h1>
      <p class="subtitle">自定义您的扩展体验</p>
    </header>

    <main class="options-main">
      <!-- 通知设置 -->
      <section class="setting-section">
        <h2>通知</h2>
        <div class="setting-item">
          <div class="setting-info">
            <label for="enableNotifications">启用通知</label>
            <p class="setting-desc">接收重要更新和提醒</p>
          </div>
          <label class="toggle-switch">
            <input type="checkbox" id="enableNotifications" checked>
            <span class="slider"></span>
          </label>
        </div>
      </section>

      <!-- 外观设置 -->
      <section class="setting-section">
        <h2>外观</h2>
        <div class="setting-item">
          <div class="setting-info">
            <label for="theme">主题</label>
            <p class="setting-desc">选择扩展的显示主题</p>
          </div>
          <select id="theme" class="setting-select">
            <option value="system">跟随系统</option>
            <option value="light">浅色</option>
            <option value="dark">深色</option>
          </select>
        </div>
      </section>

      <!-- 数据管理 -->
      <section class="setting-section">
        <h2>数据</h2>
        <div class="setting-item">
          <div class="setting-info">
            <label>导出数据</label>
            <p class="setting-desc">将您的配置和数据导出为 JSON 文件</p>
          </div>
          <button id="exportBtn" class="btn btn-secondary">导出</button>
        </div>
        <div class="setting-item">
          <div class="setting-info">
            <label>导入数据</label>
            <p class="setting-desc">从 JSON 文件恢复配置和数据</label>
            </p>
          </div>
          <button id="importBtn" class="btn btn-secondary">导入</button>
          <input type="file" id="importFile" accept=".json" hidden>
        </div>
        <div class="setting-item danger-zone">
          <div class="setting-info">
            <label>重置设置</label>
            <p class="setting-desc">恢复所有设置为默认值</p>
          </div>
          <button id="resetBtn" class="btn btn-danger">重置</button>
        </div>
      </section>
    </main>

    <!-- 保存提示 -->
    <div id="toast" class="toast hidden">设置已保存</div>
  </div>

  <script src="options.js"></script>
</body>
</html>

CSS 样式

/* options/options.css */
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --primary: #4285f4;
  --danger: #ea4335;
  --success: #34a853;
  --bg: #f8f9fa;
  --card-bg: #ffffff;
  --text: #202124;
  --text-secondary: #5f6368;
  --border: #dadce0;
}

body {
  font-family: 'Segoe UI', system-ui, sans-serif;
  background: var(--bg);
  color: var(--text);
  line-height: 1.6;
}

.options-container {
  max-width: 720px;
  margin: 0 auto;
  padding: 24px;
}

.options-header {
  margin-bottom: 32px;
}

.options-header h1 {
  font-size: 28px;
  font-weight: 600;
}

.subtitle {
  color: var(--text-secondary);
  margin-top: 4px;
}

/* 设置区块 */
.setting-section {
  background: var(--card-bg);
  border-radius: 12px;
  padding: 24px;
  margin-bottom: 16px;
  box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}

.setting-section h2 {
  font-size: 18px;
  font-weight: 600;
  margin-bottom: 16px;
  padding-bottom: 12px;
  border-bottom: 1px solid var(--border);
}

.setting-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 0;
  border-bottom: 1px solid #f0f0f0;
}

.setting-item:last-child {
  border-bottom: none;
}

.setting-info label {
  font-weight: 500;
  cursor: pointer;
}

.setting-desc {
  font-size: 13px;
  color: var(--text-secondary);
  margin-top: 2px;
}

/* Toggle Switch */
.toggle-switch {
  position: relative;
  width: 48px;
  height: 26px;
  cursor: pointer;
}

.toggle-switch input {
  opacity: 0;
  width: 0;
  height: 0;
}

.slider {
  position: absolute;
  inset: 0;
  background: #ccc;
  border-radius: 26px;
  transition: 0.3s;
}

.slider::before {
  content: '';
  position: absolute;
  width: 20px;
  height: 20px;
  left: 3px;
  bottom: 3px;
  background: white;
  border-radius: 50%;
  transition: 0.3s;
}

.toggle-switch input:checked + .slider {
  background: var(--primary);
}

.toggle-switch input:checked + .slider::before {
  transform: translateX(22px);
}

/* Select */
.setting-select {
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  font-size: 14px;
  background: white;
  cursor: pointer;
}

/* Buttons */
.btn {
  padding: 8px 20px;
  border: none;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.2s;
}

.btn-secondary {
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--border);
}

.btn-secondary:hover {
  background: #e8eaed;
}

.btn-danger {
  background: #fce8e6;
  color: var(--danger);
}

.btn-danger:hover {
  background: #f8d7da;
}

.danger-zone {
  background: #fff8f8;
  margin: 0 -24px -24px;
  padding: 16px 24px;
  border-radius: 0 0 12px 12px;
}

/* Toast */
.toast {
  position: fixed;
  bottom: 24px;
  left: 50%;
  transform: translateX(-50%);
  background: #323232;
  color: white;
  padding: 12px 24px;
  border-radius: 8px;
  font-size: 14px;
  z-index: 1000;
  transition: opacity 0.3s;
}

.hidden { display: none; }

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #202124;
    --card-bg: #292a2d;
    --text: #e8eaed;
    --text-secondary: #9aa0a6;
    --border: #5f6368;
  }
}

6.3 Options JavaScript 逻辑

6.3.1 设置管理类

// options/options.js

class SettingsManager {
  constructor() {
    this.defaults = {
      enableNotifications: true,
      theme: 'system',
      autoSync: false,
      syncInterval: 60,
      language: 'zh-CN',
      maxHistoryItems: 100,
      exportFormat: 'json'
    };

    this.settings = {};
    this.init();
  }

  async init() {
    await this.loadSettings();
    this.renderSettings();
    this.bindEvents();
  }

  async loadSettings() {
    const stored = await chrome.storage.sync.get('settings');
    this.settings = { ...this.defaults, ...stored.settings };
  }

  async saveSettings() {
    await chrome.storage.sync.set({ settings: this.settings });
    this.showToast('设置已保存');
  }

  renderSettings() {
    // 遍历设置项并更新 UI
    Object.entries(this.settings).forEach(([key, value]) => {
      const element = document.getElementById(key);
      if (!element) return;

      if (element.type === 'checkbox') {
        element.checked = value;
      } else if (element.tagName === 'SELECT' ||
                 element.tagName === 'INPUT') {
        element.value = value;
      }
    });
  }

  bindEvents() {
    // 自动保存:开关和选择器
    document.querySelectorAll('input[type="checkbox"], select')
      .forEach(element => {
      element.addEventListener('change', (e) => {
        const key = e.target.id;
        const value = e.target.type === 'checkbox'
          ? e.target.checked
          : e.target.value;

        this.settings[key] = value;
        this.saveSettings();
      });
    });

    // 数字输入延迟保存
    document.querySelectorAll('input[type="number"]')
      .forEach(element => {
      let timeout;
      element.addEventListener('input', (e) => {
        clearTimeout(timeout);
        timeout = setTimeout(() => {
          this.settings[e.target.id] = Number(e.target.value);
          this.saveSettings();
        }, 500);
      });
    });

    // 导出数据
    document.getElementById('exportBtn')
      .addEventListener('click', () => this.exportData());

    // 导入数据
    document.getElementById('importBtn')
      .addEventListener('click', () => {
      document.getElementById('importFile').click();
    });
    document.getElementById('importFile')
      .addEventListener('change', (e) => this.importData(e));

    // 重置设置
    document.getElementById('resetBtn')
      .addEventListener('click', () => this.resetSettings());
  }

  async exportData() {
    const allData = await chrome.storage.local.get(null);
    const exportData = {
      version: chrome.runtime.getManifest().version,
      exportedAt: new Date().toISOString(),
      settings: this.settings,
      data: allData
    };

    const blob = new Blob(
      [JSON.stringify(exportData, null, 2)],
      { type: 'application/json' }
    );
    const url = URL.createObjectURL(blob);

    const a = document.createElement('a');
    a.href = url;
    a.download = `extension-backup-${Date.now()}.json`;
    a.click();
    URL.revokeObjectURL(url);

    this.showToast('数据已导出');
  }

  async importData(event) {
    const file = event.target.files[0];
    if (!file) return;

    try {
      const text = await file.text();
      const imported = JSON.parse(text);

      if (imported.settings) {
        this.settings = { ...this.defaults, ...imported.settings };
        await chrome.storage.sync.set({ settings: this.settings });
      }

      if (imported.data) {
        await chrome.storage.local.set(imported.data);
      }

      this.renderSettings();
      this.showToast('数据已导入');
    } catch (error) {
      this.showToast('导入失败: 文件格式错误');
    }
  }

  async resetSettings() {
    if (!confirm('确定要重置所有设置吗?此操作不可恢复。')) {
      return;
    }

    this.settings = { ...this.defaults };
    await chrome.storage.sync.set({ settings: this.settings });
    this.renderSettings();
    this.showToast('设置已重置');
  }

  showToast(message) {
    const toast = document.getElementById('toast');
    toast.textContent = message;
    toast.classList.remove('hidden');

    setTimeout(() => {
      toast.classList.add('hidden');
    }, 2500);
  }
}

// 启动
document.addEventListener('DOMContentLoaded', () => {
  new SettingsManager();
});

6.4 从 Popup 打开 Options

// 方式一:使用 chrome.runtime.openOptionsPage()
document.getElementById('settingsBtn').addEventListener('click', () => {
  chrome.runtime.openOptionsPage();
});

// 方式二:直接打开 URL
document.getElementById('settingsBtn').addEventListener('click', () => {
  chrome.tabs.create({
    url: chrome.runtime.getURL('options/options.html')
  });
});

// Service Worker 中也可以打开
// chrome.runtime.openOptionsPage();

6.5 Settings 同步

跨设备同步

// 使用 chrome.storage.sync 实现跨设备同步
class SyncSettings {
  constructor() {
    this.defaults = { theme: 'system', fontSize: 14 };
  }

  async load() {
    const result = await chrome.storage.sync.get('settings');
    return { ...this.defaults, ...result.settings };
  }

  async save(settings) {
    await chrome.storage.sync.set({ settings });
    // sync 存储自动同步到同一 Google 账号的其他设备
  }

  // 监听其他设备的变更
  onChange(callback) {
    chrome.storage.onChanged.addListener((changes, area) => {
      if (area === 'sync' && changes.settings) {
        callback(changes.settings.newValue, changes.settings.oldValue);
      }
    });
  }
}

// Service Worker 中监听设置变更
const sync = new SyncSettings();
sync.onChange((newSettings, oldSettings) => {
  console.log('设置已更新:', newSettings);
  // 应用新设置
  applySettings(newSettings);
});

6.6 注意事项

问题说明解决方案
设置不生效Popup 未重新加载关闭后重新打开 Popup
同步延迟sync 存储有写入频率限制每分钟最多 120 次写入
存储超限sync 存储每项 8KB,总量 100KB使用 local 存储大数据
Open in Tab 不生效需要 open_in_tab: true检查 options_ui 配置

6.7 扩展阅读