news 2026/10/18 3:09:50

@pierre/theming Controller API 完全指南:掌握主题状态、颜色模式与解析的核心机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
@pierre/theming Controller API 完全指南:掌握主题状态、颜色模式与解析的核心机制

【免费下载链接】pierre

pierre’s open source code

项目地址:https://gitcode.com/gh_mirrors/pi/pierre
点击查看免费下载

导读

@pierre/theming是 pierre 开源仓库中的框架无关主题管理包,而createThemeController及其配套类型(ThemeController、ThemeControllerOptions、ThemeControllerState等)构成了该包最核心的状态层:它负责维护当前颜色模式(light / dark / system)、亮色与暗色主题的选择,以及激活主题的异步解析与缓存。读完本文,你将掌握 Controller API 的完整导出面、每个配置项与状态字段的精确语义,理解其「SSR 安全」设计与「先解析成功再发布状态」的错误处理策略,并能通过单控制器、多控制器、React 与原生 JavaScript 四种配方,把主题切换能力直接落地到自己的应用中。

一、Controller 在整个 theming 架构中的位置

在@pierre/theming的设计中,主题管理被拆成四个关注点(对应 skills/theming/SKILL.md):

  1. 元数据层:createThemeCollection/createThemeCatalog描述主题集合(名称、加载器、明暗标签);
  2. 解析层:createThemeResolver负责注册加载器、异步加载与缓存已解析的主题对象;
  3. 状态层:createThemeController把「用户当前选了什么」沉淀为可订阅、可持久化的状态;
  4. 映射层:normalizeThemeColors等工具把解析后的主题对象转换成可直接用于 UI 的颜色值。

Controller 是连接前三者的枢纽:它接收一个 catalog(或 resolver),内部完成加载器注册、初始状态构建、持久化读取、prefers-color-scheme监听,并在每次选择变更时驱动解析器重新加载激活主题。核心实现位于 packages/theming/src/modules/createThemeController.ts,所有导出统一从 packages/theming/src/index.ts 对外发布。

二、导出总览:Controller API 的完整导出面

原文档以一张表完整列出@pierre/theming主入口("."子路径)中与控制器相关的全部导出,这里完整继承并补充每个类型的源码位置:

ExportKindPurpose源码位置
createThemeControllerFunctionCreates state for a color mode and selected light and dark themes.createThemeController.ts
ThemeControllerTypeDefines controller state, mutations, subscription, and cleanup.createThemeController.ts
ThemeControllerOptionsTypeSelects a catalog or resolver and configures initial state.createThemeController.ts
ThemeControllerStateTypeDescribes the current selection and resolved theme.createThemeController.ts
PendingThemeResolutionTypeIdentifies the active theme load.createThemeController.ts
ThemeResolutionErrorTypeDescribes a failed active theme load.createThemeController.ts
ThemeResolutionErrorContextTypeIdentifies the name and scheme for a failed load callback.createThemeController.ts
ThemePersistenceTypeDefines customloadandsavemethods.createThemeController.ts
ThemeSelectionTypeDescribes the persisted mode and theme names.createThemeController.ts
ColorModeTypeAcceptslight,dark, orsystem.types.ts
ColorSchemeTypeAccepts concretelightordark.types.ts
ThemeLikeTypeDefines the shared resolved-theme shape.types.ts

2.1 基础类型先看:ColorMode、ColorScheme与ThemeLike

// packages/theming/src/modules/types.ts export interface ThemeLike { bg?: string; colors?: Record<string, string>; fg?: string; name?: string; type?: 'dark' | 'light'; } export type ColorScheme = 'dark' | 'light'; export type ColorMode = ColorScheme | 'system';
  • ColorMode是用户可见的选择,允许light、dark或跟随操作系统的system;
  • ColorScheme是具体生效的明暗方案,永远只有light或dark二值;
  • ThemeLike是控制器与解析器共同约定的最小主题对象形状,只有bg/fg/colors/name/type五个可选字段——解析器不依赖具体主题引擎(Shiki、VS Code 等)的完整结构,任何满足该形状的对象都可以参与解析。

三、ThemeControllerOptions:初始化配置的完整字段

原文档给出字段总表,下面是完整继承并逐一结合源码展开。

FieldPurpose
catalogSupplies descriptors and catalog default names.
resolverSupplies an existing resolver or shares one with other controllers.
defaultLightThemeNameOverrides the initial light theme name.
defaultDarkThemeNameOverrides the initial dark theme name.
defaultModeSets the initial color mode.
storageKeyStores the selection in browser local storage.
persistenceSupplies custom selection storage.
preloadInactiveResolves the inactive color-scheme theme too.
onResolutionErrorReceives an active theme load error.

核心约束:创建控制器时,必须传catalog、resolver,或「catalog + 共享 resolver」中的任意一种组合。源码中的校验逻辑(createThemeController.ts)明确抛错:

const selectedResolver = options.resolver ?? (catalog != null ? createThemeResolver() : undefined); if (selectedResolver == null) { throw new Error('createThemeController requires a catalog or resolver'); }

三种合法组合的具体行为:

  1. 只传catalog:控制器内部调用createThemeResolver()创建独立隔离的 resolver,并把 catalog 中的全部加载器通过registerInto注册进去(见 createThemeCollection.ts 的registerInto,内部使用registerThemeIfAbsent避免重复注册冲突)。测试 controller.test.ts 验证了这种隔离性:控制器解析器里注册的主题,不会泄漏到外部新建的无关 resolver。
  2. 只传resolver:这是「遗留路径」(源码注释称为 Legacy path),适用于调用方已经自行维护 resolver 注册的场景;此时 catalog 默认名回退到内置常量FALLBACK_LIGHT_THEME = 'pierre-light'与FALLBACK_DARK_THEME = 'pierre-dark'(createThemeController.ts)。
  3. 同时传catalog与resolver:catalog 的加载器会注册进外部传入的 resolver,从而允许多个控制器共享同一个加载器注册表和解析缓存(这正是多控制器配方的基石)。

3.1 各字段的默认值链与源码语义

从 createThemeController.ts 可以梳理出精确的默认值解析顺序:

字段默认值解析顺序
defaultDarkThemeNameoptions.defaultDarkThemeName→catalog?.defaultDarkThemeName→'pierre-dark'
defaultLightThemeNameoptions.defaultLightThemeName→catalog?.defaultLightThemeName→'pierre-light'
defaultModeoptions.defaultMode→'system'
preloadInactiveoptions.preloadInactive→false(仅解析激活主题)
storageKey省略则禁用内置持久化

preloadInactive的作用:默认false时,控制器只解析当前激活配色方案对应的那个主题;设为true后,会在激活主题解析完成后顺带解析另一个配色方案的主题(preloadInactiveFor,createThemeController.ts),这样用户从暗色切到亮色时主题对象已在缓存中,模式切换是瞬时的,无需再等一次网络/模块加载。代价是初始多加载一个主题对象。

storageKey与persistence的优先级:两者同时出现时persistence(自定义适配器)优先;只给storageKey时使用内置的「单 JSON 键」localStorage 适配器(createLocalStorageAdapter,createThemeController.ts),它把整个ThemeSelection序列化为一个 JSON 条目存储;两者都不给时持久化被禁用。源码注释特别提醒:如果宿主应用需要把 mode 和两个主题名分别存到已有的独立 key下(例如与旧的引导脚本兼容),就必须传入自定义persistence。

onResolutionError:接收激活主题加载失败的回调,签名是(error: unknown, context: ThemeResolutionErrorContext) => void;不传时默认落到console.error,错误信息形如[theming] Failed to resolve theme "xxx" for dark color scheme(createThemeController.ts)。

四、ThemeController成员:状态、变更、订阅与清理

原文档方法表完整继承如下:

MemberPurpose
resolverExposes the controller's resolver.
getState()Returns the current immutable state object.
setColorMode(mode)Selects light, dark, or system mode.
setThemeNameForScheme(scheme, name)Selects the theme for one color scheme.
subscribe(listener)Subscribes to state changes and returns an unsubscribe function.
destroy()Removes the system color-scheme listener.

4.1resolver成员

控制器把内部(或外部传入)的 resolver 直接暴露出来,调用方可以:

  • 读取hasRegisteredTheme(name)/hasResolvedTheme(name)做同步判断;
  • 主动resolveTheme预热主题;
  • 在需要显式 resolver 的消费场景中复用同一个缓存实例。

docs 应用正是这样做的:themeController.resolver被导出为docsThemeResolver别名,供仍需显式 resolver 的消费方使用,避免创建第二套缓存(apps/docs/components/themeController.ts)。

4.2setColorMode(mode)与setThemeNameForScheme(scheme, name)

  • setColorMode:切换light/dark/system三态。内部先调用resolveColorScheme把 mode 折叠成具体的明暗方案(system时查询matchMedia('(prefers-color-scheme: dark)'),见 createThemeController.ts),再走setActiveSelection触发解析。若新 mode 与当前相同则直接返回(幂等)。
  • setThemeNameForScheme:只更新某个配色方案槽位的主题名,不会切换模式(源码注释明确说明:切换模式由setColorMode负责)。关键在于它自动判断目标槽位当前是否激活:
    • 激活槽位 → 走setActiveSelection,需要重新解析主题;
    • 非激活槽位 → 走updateInactiveThemeName,只更新名字并立即持久化、通知,不触发解析(createThemeController.ts)。

测试 controller.test.ts 验证了激活槽位换主题会重新解析:setThemeNameForScheme('dark', 'test-dark-2')后resolvedTheme.name同步变为test-dark-2。

4.3subscribe与destroy的生命周期

  • subscribe(listener)把监听器加入内部Set,返回一个unsubscribe函数,用于从集合中移除该监听器(createThemeController.ts)。
  • destroy()是浏览器的清理入口:移除prefers-color-scheme的change事件监听并清空全部监听器(createThemeController.ts)。源码注释特别标注它「在服务端或重复调用都是安全的」。

测试 controller.test.ts 验证了订阅语义:subscribe后激活主题解析完成会触发通知;调用返回的unsubscribe后,再setColorMode('dark')通知计数不再增长。

五、ThemeControllerState:状态对象的全部字段

原文档字段表完整继承如下:

FieldPurpose
lightThemeNameStores the selected light theme name.
darkThemeNameStores the selected dark theme name.
modeStores the selected color mode.
resolvedColorSchemeProvides the active concrete light or dark scheme.
resolvedThemeProvides the active resolved theme object.
pendingThemeResolutionIdentifies an active theme load.
resolutionErrorDescribes the latest active load failure.

补充三个关键语义(源码注释 createThemeController.ts):

  1. resolvedColorScheme永远是light或dark,绝不可能是system。它是把mode与操作系统偏好折叠后的具体结果。源码注释强烈建议:用它驱动 DOM 的明暗应用(例如data-theme/data-color-scheme属性),而不要再自行推导一次。
  2. pendingThemeResolution与resolutionError是互斥的瞬态字段:前者在激活主题/mode 变更等待主题对象加载期间被设置(携带{ colorScheme, name }),后者在加载失败时被填充(携带{ colorScheme, error, name })。
  3. 状态对象是"新引用"语义:每次变更都会产出新的 state 对象,这保证了订阅端(尤其是 React 的useSyncExternalStore)可以依据引用相等性判断是否真的发生了变化,避免多余渲染。

5.1 错误处理的关键保证:绝不发布半成品状态

这是 Controller 最值得注意的设计(源码注释 createThemeController.ts):

激活主题解析失败时,控制器保留最后一次成功解析的选择与 resolvedTheme,而不是发布一个只有新名字、没有对应主题对象的"半成品"状态。

实现上,resolveActive会先检查缓存(resolver.getResolvedTheme(name)),缓存命中则同步提交;否则进入异步解析,把pendingSelectionPatch暂存起来,只有 Promiseresolve且解析 ID 仍为最新(resolutionId === activeResolutionId)时才真正提交补丁并notify();reject时则清空补丁、填充resolutionError、调用onResolutionError,但不改变已生效的mode/ 主题名 /resolvedTheme(createThemeController.ts)。

测试 controller.test.ts 完整验证了这一行为:把激活槽位换成加载失败的broken-dark后,darkThemeName仍停留在test-dark、resolvedTheme不变、savedSelections为空(失败不持久化)、resolutionError精确记录了{ colorScheme: 'dark', error, name: 'broken-dark' },且onResolutionError收到了包含上下文的回调。另一条测试 controller.test.ts 验证了setColorMode遇到失败主题时 mode 保持原值。

5.2system模式的实时跟随

控制器在创建时通过attachMediaListener挂上matchMedia('(prefers-color-scheme: dark)')的change监听(createThemeController.ts)。当操作系统切换明暗偏好、且当前 mode 为system时,maybeUpdateSystemColorScheme会重新解析 concrete scheme 并触发激活主题切换,让 UI 实时跟随 OS(createThemeController.ts)。destroy()移除的正是这个监听器。

六、持久化:ThemeSelection与ThemePersistence

ThemeSelection(createThemeController.ts)是持久化存储的最小形状,只含三件事——这正是「用户的已选内容」:

export interface ThemeSelection { darkThemeName: string; lightThemeName: string; mode: ColorMode; }

源码注释强调了一个重要原则:只持久化选择,绝不持久化已解析的主题对象。解析后的主题在每次加载时由 resolver 重新派生,避免把大对象写进 localStorage、也避免版本演进后旧数据失效。

ThemePersistence(createThemeController.ts)是自定义适配器的接口:

export interface ThemePersistence { load(): ThemeSelection | null; save(selection: ThemeSelection): void; }

load返回存储的选择,缺失或不可读时返回null;save写入选择。源码注释特别要求:实现方必须自行守护浏览器访问(localStorage 等),以保证控制器整体 SSR 安全——服务端环境下load/save只是 no-op。

6.1 默认 localStorage 适配器与防御性读取

内置适配器(createLocalStorageAdapter,createThemeController.ts)的关键细节:

  • load:读出 JSON 后校验parsed.mode是否存在(缺失视为损坏数据返回null);单个字段缺失时回退到默认主题名;JSON 解析异常(损坏值)返回null。
  • save:整个ThemeSelection序列化成一个 JSON 条目写入storageKey;写入抛错(配额/访问受限)被吞掉,因为「存储失败对主题功能是非致命的」。
  • 底层getStorage()(createThemeController.ts)在服务端或 localStorage 访问抛异常(如沙箱 iframe、禁用 cookie 的隐私模式)时返回undefined,从而让整个持久化链路静默降级。

七、实战配方:四种落地方式

下面的配方来自 skills 文档体系(controller.md、recipe-one-controller.md、recipe-multiple-controllers.md、recipe-react.md、recipe-vanilla.md),并补充了源码依据。

7.1 配方一:单控制器(默认推荐)

当应用全局共享一个颜色模式和一套明暗主题选择时,只用一个控制器。把 catalog 与 controller 放进同一个模块:

import { createThemeCatalog, createThemeController } from '@pierre/theming'; import { themes } from '@pierre/theming/themes'; export const themeCatalog = createThemeCatalog({ themes, defaultLightThemeName: 'pierre-light', defaultDarkThemeName: 'pierre-dark', }); export const themeController = createThemeController({ catalog: themeCatalog, defaultMode: 'system', storageKey: 'app-theme', });
  • themes来自@pierre/theming/themes子路径,是 Pierre 与 Shiki 两个集合的稳定合并(见 collections.md);
  • 在应用需要读取或变更主题状态的任何地方 import同一个controller 实例;
  • 应用拆除该模块时调用destroy()。

createThemeCatalog会对默认名做校验:默认亮/暗主题名必须存在于集合中,否则构造时直接抛错(createThemeCatalog.ts)。

7.2 配方二:多控制器(隔离区域)

当应用存在相互独立的区域(不同模式、不同选择、不同存储 key 或不同生命周期)时,使用多个控制器;若这些区域共享同一套加载器与缓存,就传入同一个 resolver:

import { createThemeCatalog, createThemeController, createThemeResolver, } from '@pierre/theming'; import { themes } from '@pierre/theming/themes'; const catalog = createThemeCatalog({ themes, defaultLightThemeName: 'pierre-light', defaultDarkThemeName: 'pierre-dark', }); const resolver = createThemeResolver(); export const workspaceTheme = createThemeController({ catalog, resolver, storageKey: 'workspace-theme', }); export const previewTheme = createThemeController({ catalog, resolver, defaultMode: 'light', storageKey: 'preview-theme', });

要点:每个区域订阅自己的controller;区域移除时对每个controller 调用destroy();选择需要相互独立时使用不同的 storageKey。共享 resolver 的意义在于:两个控制器注册的是同一套加载器、共享同一份解析缓存,主题对象只加载一次。

7.3 配方三:React 集成

控制器创建在组件外部的模块中,组件内用useThemeController读取状态(react.ts):

import { useEffect } from 'react'; import { useThemeController } from '@pierre/theming/react'; import { themeController } from './theme-controller'; export function ThemeControls() { const state = useThemeController(themeController); useEffect(() => { document.documentElement.dataset.colorScheme = state.resolvedColorScheme; }, [state.resolvedColorScheme]); return ( <button type="button" onClick={() => themeController.setColorMode( state.resolvedColorScheme === 'dark' ? 'light' : 'dark' ) } > Use {state.resolvedColorScheme === 'dark' ? 'light' : 'dark'} mode </button> ); }

useThemeController是useSyncExternalStore的薄封装(react.ts),本身不含任何业务逻辑:订阅用controller.subscribe,客户端快照与服务端快照都用controller.getState——服务端拿到初始选择,客户端水合后自动更新。由于控制器只在真正变化时发布新的state 对象,快照引用在渲染之间稳定,不会产生撕裂(tearing)或渲染循环。需要把state.resolvedTheme传给代码/文件树等 UI 表面;需要 workbench 级 UI 颜色时再用normalizeThemeColors。

7.4 配方四:原生 JavaScript / 其他框架

用「单控制器或双控制器」配方建好 controller 后,把每次状态变化应用到 document:

import { normalizeThemeColors } from '@pierre/theming/color'; import { themeController } from './theme-controller'; function applyTheme() { const state = themeController.getState(); document.documentElement.dataset.colorScheme = state.resolvedColorScheme; if (state.resolvedTheme == null) return; const { colors } = normalizeThemeColors(state.resolvedTheme); document.documentElement.style.setProperty( '--app-background', colors?.['editor.background'] ?? state.resolvedTheme.bg ?? '' ); document.documentElement.style.setProperty( '--app-foreground', colors?.['editor.foreground'] ?? state.resolvedTheme.fg ?? '' ); } applyTheme(); const unsubscribe = themeController.subscribe(applyTheme);
  • 首次applyTheme()立即应用当前状态;
  • themeController.setColorMode(mode)交给模式切换控件;
  • setThemeNameForScheme(scheme, name)交给主题选择器;
  • 宿主不再需要更新时调用unsubscribe()。

八、SSR 安全:控制器在服务端的行为

源码顶部注释(createThemeController.ts)明确声明了 SSR 安全设计:每一次浏览器访问(localStorage、matchMedia)都被守卫,因此控制器可以在服务端正常构造和运行,持久化与prefers-color-scheme监听在服务端只是 no-op,客户端再水合。

三条守卫路径分别是:

  1. getStorage():服务端或访问抛异常时返回undefined,持久化链路整体降级;
  2. systemPrefersDark():服务端或matchMedia不可用时返回false,system模式先落到亮色,等客户端上报真实偏好;
  3. attachMediaListener():matchMedia不可用时静默跳过监听挂载。

docs 应用正是利用这一点把 controller 做成模块单例:服务端每进程创建一次(浏览器守卫使其成为常量),客户端每次页面加载创建一次并跨客户端导航存活(apps/docs/components/themeController.ts)。

九、真实应用:docs 应用的自定义持久化

apps/docs/components/themeController.ts 是 Controller API 在仓库内的真实落地样本,其中docsPersistence展示了「内置storageKey不满足需求时如何写自定义适配器」:

const MODE_KEY = 'theme'; const LIGHT_THEME_KEY = 'diffshub-light-theme'; const DARK_THEME_KEY = 'diffshub-dark-theme'; const docsPersistence: ThemePersistence = { load() { const mode = readKey(MODE_KEY); const light = readKey(LIGHT_THEME_KEY); const dark = readKey(DARK_THEME_KEY); if (mode == null && light == null && dark == null) return null; const validMode = mode === 'light' || mode === 'dark' || mode === 'system' ? mode : 'system'; return { mode: validMode, lightThemeName: light ?? docsThemeCatalog.defaultLightThemeName, darkThemeName: dark ?? docsThemeCatalog.defaultDarkThemeName, }; }, save(selection) { writeKey(MODE_KEY, selection.mode); writeKey(LIGHT_THEME_KEY, selection.lightThemeName); writeKey(DARK_THEME_KEY, selection.darkThemeName); }, }; export const themeController = createThemeController({ catalog: docsThemeCatalog, persistence: docsPersistence, defaultMode: 'system', });

关键点:docs 应用需要把选择映射到三个既有的历史 key上(theme是防闪烁引导脚本读取的 key,另两个是 diffshub 的主题 key),以兼容旧版已保存的用户选择,因此不能用内置的单 JSON 存储,而是通过persistence自定义布局;load中把不合法 mode 归一到'system'、缺失主题名回退到 catalog 默认名。createThemeController的选项设计(persistence优先于storageKey)正是为这类场景准备的。

十、测试验证一览

@pierre/theming的控制器行为有完整的测试覆盖,位于 packages/theming/test/controller.test.ts,核心用例包括:

测试用例验证点
registers catalog loaders without an external resolver仅传 catalog 时自动注册全部加载器并解析激活主题
uses an isolated resolver for catalog-only controllers独立 resolver 的隔离性
registers catalog loaders into a supplied resolver before initial resolutioncatalog + resolver 组合的注册时机(先于初始解析)
exposes initial selection and resolves the active theme初始状态与激活主题解析
notifies subscribers when the active theme resolves订阅与退订语义
setColorMode switches the active theme模式切换驱动主题切换
setThemeNameForScheme on the active slot re-resolves激活槽位换主题重新解析
setThemeNameForScheme keeps the active selection when resolution fails失败不发布半成品、不持久化、上报错误上下文
setColorMode keeps the active mode when the next theme fails模式切换遇失败保持原 mode

结语

createThemeController把「明暗模式选择、双槽位主题名、激活主题解析、持久化、OS 偏好跟随」五件事收敛到一个框架无关、SSR 安全、且对外暴露完整 TypeScript 类型的单一实例中。理解其resolvedColorScheme与resolvedTheme的分工、失败回退语义、以及catalog/resolver/persistence三条配置线的优先级,就能在任意宿主(React、原生 JS 或其他框架)中以最小成本获得一致的、可持久化的主题切换体验。更多配套能力可继续查阅同目录下的 resolver.md(加载与缓存)、collections.md(集合与 catalog)以及 react.md(React 绑定)。

【免费下载链接】pierre

pierre’s open source code

项目地址:https://gitcode.com/gh_mirrors/pi/pierre
点击查看免费下载
上一篇:NextTrace代码覆盖率分析:确保核心功能的测试完整性
下一篇:FlexiViT架构深度解析:理解flexivit_large.300ep_in1k的多patch尺寸设计

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/15 10:58:35

基于PJ85718DM与STM32F401RB的远程温度监测方案设计

1. 项目缘起与整体设计思路温度监测这件事&#xff0c;听起来像是电子入门的第一课——读个传感器&#xff0c;串口打印一下&#xff0c;完事。但真正落到工业现场、HVAC&#xff08;供暖、通风与空调&#xff09;机组、嵌入式设备机柜里&#xff0c;事情就完全不是那么回事了。…

作者头像 李华
网站建设 2026/10/17 17:46:34

多租户自动化测试进阶:从租户池到并发隔离的工程实践

做过多租户系统自动化测试的朋友应该都有同感&#xff1a;单租户的用例写得再漂亮&#xff0c;一到多租户环境就容易翻车。接口单独调都是通的&#xff0c;全量回归一跑就出现各种诡异问题——A租户的数据出现在B租户的报表里&#xff0c;某个租户的配置被另一个租户的用例偷偷…

作者头像 李华