文章

Astro 内容站:ThemeController 组件与 light/dark 主题切换

用一个独立的前端行为组件完成主题初始化、按钮状态同步与代码高亮联动,并让多语言页面共用同一套切换逻辑。

当前仓库里,主题切换已经不再和双语路由揉在同一篇实现说明里。

双语属于路径和文案层;主题切换属于组件行为层。现在这部分被明确收敛到 ThemeController.astroLayoutStyles.astro

目标

主题系统只做下面几件事:

  • 默认使用 light
  • 用户点击按钮后可切到 dark
  • 用户刷新页面后保留上次选择
  • 代码高亮跟着主题变化
  • 中英文页面共用同一套逻辑,只替换按钮文案

先把主题状态放到 html 根节点

当前实现不是给每个页面单独加 class,而是直接使用:

<html lang={lang} data-theme="light">

这样做的好处很直接:

  • 全局 CSS 变量可以统一挂在 :root
  • 页面切换时不需要给很多组件分别同步状态
  • 代码块、Header、正文、按钮都可以共用同一套主题变量

ThemeController 只管行为,不管视觉

src/components/layout/ThemeController.astro 只有一段内联脚本。它做两件事:

  1. 页面初始化时,从 localStorage 取回上次主题
  2. 找到 [data-theme-toggle] 按钮并绑定切换行为

核心逻辑如下:

<script is:inline>
  (() => {
    const root = document.documentElement;
    const getTheme = () => (root.dataset.theme === "dark" ? "dark" : "light");

    const applySavedTheme = () => {
      try {
        const theme = localStorage.getItem("theme");
        root.dataset.theme = theme === "dark" ? "dark" : "light";
      } catch {
        root.dataset.theme = "light";
      }
    };

    const initThemeToggle = () => {
      const button = document.querySelector("[data-theme-toggle]");
      if (!(button instanceof HTMLButtonElement) || button.dataset.themeReady === "true") {
        return;
      }

      const syncButton = () => {
        button.setAttribute("aria-pressed", String(getTheme() === "dark"));
      };

      syncButton();
      button.addEventListener("click", () => {
        const nextTheme = getTheme() === "dark" ? "light" : "dark";
        root.dataset.theme = nextTheme;
        localStorage.setItem("theme", nextTheme);
        syncButton();
      });
    };

    applySavedTheme();
    initThemeToggle();
  })();
</script>

这里最关键的一点,是 applySavedTheme() 先执行,再去初始化按钮。这样进入页面时,根节点主题已经先落下去了,不会等按钮绑定完才突然变色。

ThemeController 和 Header 通过 data attribute 解耦

按钮本身不在 ThemeController 里,而在 SiteHeader.astro 里:

<button
  class="site-button site-theme-toggle"
  type="button"
  data-theme-toggle
  aria-label={themeToggleDarkLabel}
  title={themeToggleDarkLabel}
  data-theme-label-light={themeToggleLightLabel}
  data-theme-label-dark={themeToggleDarkLabel}
>
  <span class="site-theme-toggle-icons" aria-hidden="true">...</span>
</button>

这意味着:

  • Header 只关心按钮长什么样、放在哪
  • ThemeController 只关心找到这个按钮后怎么工作,并同步当前可切换的提示文案

两边通过 data-theme-toggle 这个约定连接,不需要彼此 import。

视觉层统一放在 LayoutStyles

主题变量和代码高亮联动不放在脚本里,而放在 LayoutStyles.astro

:root {
  color-scheme: light;
  --page-bg: #fffdf8;
  --page-fg: #1f2328;
  --surface: #ffffff;
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --page-bg: #12161c;
  --page-fg: #eef2f6;
  --surface: #171c23;
}

:root[data-theme="dark"] .astro-code,
:root[data-theme="dark"] .astro-code span {
  color: var(--shiki-dark) !important;
  background-color: var(--shiki-dark-bg) !important;
}

这样主题切换触发的只是根节点属性变化,具体要变哪些颜色、代码块如何跟随,全部交给 CSS。

多语言页面为什么不需要写两套主题逻辑

中英文页面的差异,在当前仓库里只体现在这些地方:

  • 路由前缀不同
  • 页面 copy 不同
  • Header 中的切换按钮提示文案不同

主题脚本完全不用分语言。中文页和英文页共享同一个 ThemeController,只是在 site.ts 里给出不同的目标态文案:

  • 中文:切换到明亮模式 / 切换到暗黑模式
  • 英文:Switch to light mode / Switch to dark mode

因此“多语言主题切换”的正确拆法,不是做两套主题组件,而是让一套主题行为吃站点文案。

这次拆分真正得到的结果

现在主题系统的职责边界很清楚:

  • ThemeController 负责状态恢复和点击切换
  • SiteHeader 提供按钮入口
  • LayoutStyles 负责主题变量和代码高亮视觉
  • site.ts 负责中英文提示文案

这样之后,无论是继续调整配色、替换按钮样式,还是把主题开关挪到别的位置,都不需要重新改路由或页面数据层。

相关文章

Astro 内容站:PageHero 组件与页面头部统一

把列表页、首页、系列页和详情页里重复的标题区抽成 PageHero,让返回链接、eyebrow、标题和状态文案共用一套骨架。

2026-04-16

Astro 内容站:PageSectionHeader 组件与区块标题复用

把 section 标题、补充说明、右侧动作和分组计数统一到一个组件里,让首页、归档和索引列表共享稳定的区块头结构。

2026-04-16

Astro 内容站:组件组合重构、重复代码清理与文档同步

一次收口页面骨架、分页辅助、概览卡和旧文档漂移,把页面组合整理成更稳定的单组件文件结构。

2026-04-16

Astro 内容站:SummaryStatGrid 组件与归档总览卡

把归档页里重复的统计卡结构抽成 SummaryStatGrid,为总条目、文章数、章节数和最近更新时间提供统一的数值卡组件。

2026-04-16