【免费下载链接】pierre
pierre’s open source code
导读
@pierre/theming是 pierre 开源仓库中的框架无关主题管理包,而createThemeController及其配套类型(ThemeController、ThemeControllerOptions、ThemeControllerState等)构成了该包最核心的状态层:它负责维护当前颜色模式(light / dark / system)、亮色与暗色主题的选择,以及激活主题的异步解析与缓存。读完本文,你将掌握 Controller API 的完整导出面、每个配置项与状态字段的精确语义,理解其「SSR 安全」设计与「先解析成功再发布状态」的错误处理策略,并能通过单控制器、多控制器、React 与原生 JavaScript 四种配方,把主题切换能力直接落地到自己的应用中。
一、Controller 在整个 theming 架构中的位置
在@pierre/theming的设计中,主题管理被拆成四个关注点(对应 skills/theming/SKILL.md):
- 元数据层:
createThemeCollection/createThemeCatalog描述主题集合(名称、加载器、明暗标签); - 解析层:
createThemeResolver负责注册加载器、异步加载与缓存已解析的主题对象; - 状态层:
createThemeController把「用户当前选了什么」沉淀为可订阅、可持久化的状态; - 映射层:
normalizeThemeColors等工具把解析后的主题对象转换成可直接用于 UI 的颜色值。
Controller 是连接前三者的枢纽:它接收一个 catalog(或 resolver),内部完成加载器注册、初始状态构建、持久化读取、prefers-color-scheme监听,并在每次选择变更时驱动解析器重新加载激活主题。核心实现位于 packages/theming/src/modules/createThemeController.ts,所有导出统一从 packages/theming/src/index.ts 对外发布。
二、导出总览:Controller API 的完整导出面
原文档以一张表完整列出@pierre/theming主入口("."子路径)中与控制器相关的全部导出,这里完整继承并补充每个类型的源码位置:
| Export | Kind | Purpose | 源码位置 |
|---|---|---|---|
createThemeController | Function | Creates state for a color mode and selected light and dark themes. | createThemeController.ts |
ThemeController | Type | Defines controller state, mutations, subscription, and cleanup. | createThemeController.ts |
ThemeControllerOptions | Type | Selects a catalog or resolver and configures initial state. | createThemeController.ts |
ThemeControllerState | Type | Describes the current selection and resolved theme. | createThemeController.ts |
PendingThemeResolution | Type | Identifies the active theme load. | createThemeController.ts |
ThemeResolutionError | Type | Describes a failed active theme load. | createThemeController.ts |
ThemeResolutionErrorContext | Type | Identifies the name and scheme for a failed load callback. | createThemeController.ts |
ThemePersistence | Type | Defines customloadandsavemethods. | createThemeController.ts |
ThemeSelection | Type | Describes the persisted mode and theme names. | createThemeController.ts |
ColorMode | Type | Acceptslight,dark, orsystem. | types.ts |
ColorScheme | Type | Accepts concretelightordark. | types.ts |
ThemeLike | Type | Defines 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:初始化配置的完整字段
原文档给出字段总表,下面是完整继承并逐一结合源码展开。
| Field | Purpose |
|---|---|
catalog | Supplies descriptors and catalog default names. |
resolver | Supplies an existing resolver or shares one with other controllers. |
defaultLightThemeName | Overrides the initial light theme name. |
defaultDarkThemeName | Overrides the initial dark theme name. |
defaultMode | Sets the initial color mode. |
storageKey | Stores the selection in browser local storage. |
persistence | Supplies custom selection storage. |
preloadInactive | Resolves the inactive color-scheme theme too. |
onResolutionError | Receives 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'); }三种合法组合的具体行为:
- 只传
catalog:控制器内部调用createThemeResolver()创建独立隔离的 resolver,并把 catalog 中的全部加载器通过registerInto注册进去(见 createThemeCollection.ts 的registerInto,内部使用registerThemeIfAbsent避免重复注册冲突)。测试 controller.test.ts 验证了这种隔离性:控制器解析器里注册的主题,不会泄漏到外部新建的无关 resolver。 - 只传
resolver:这是「遗留路径」(源码注释称为 Legacy path),适用于调用方已经自行维护 resolver 注册的场景;此时 catalog 默认名回退到内置常量FALLBACK_LIGHT_THEME = 'pierre-light'与FALLBACK_DARK_THEME = 'pierre-dark'(createThemeController.ts)。 - 同时传
catalog与resolver:catalog 的加载器会注册进外部传入的 resolver,从而允许多个控制器共享同一个加载器注册表和解析缓存(这正是多控制器配方的基石)。
3.1 各字段的默认值链与源码语义
从 createThemeController.ts 可以梳理出精确的默认值解析顺序:
| 字段 | 默认值解析顺序 |
|---|---|
defaultDarkThemeName | options.defaultDarkThemeName→catalog?.defaultDarkThemeName→'pierre-dark' |
defaultLightThemeName | options.defaultLightThemeName→catalog?.defaultLightThemeName→'pierre-light' |
defaultMode | options.defaultMode→'system' |
preloadInactive | options.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成员:状态、变更、订阅与清理
原文档方法表完整继承如下:
| Member | Purpose |
|---|---|
resolver | Exposes 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:状态对象的全部字段
原文档字段表完整继承如下:
| Field | Purpose |
|---|---|
lightThemeName | Stores the selected light theme name. |
darkThemeName | Stores the selected dark theme name. |
mode | Stores the selected color mode. |
resolvedColorScheme | Provides the active concrete light or dark scheme. |
resolvedTheme | Provides the active resolved theme object. |
pendingThemeResolution | Identifies an active theme load. |
resolutionError | Describes the latest active load failure. |
补充三个关键语义(源码注释 createThemeController.ts):
resolvedColorScheme永远是light或dark,绝不可能是system。它是把mode与操作系统偏好折叠后的具体结果。源码注释强烈建议:用它驱动 DOM 的明暗应用(例如data-theme/data-color-scheme属性),而不要再自行推导一次。pendingThemeResolution与resolutionError是互斥的瞬态字段:前者在激活主题/mode 变更等待主题对象加载期间被设置(携带{ colorScheme, name }),后者在加载失败时被填充(携带{ colorScheme, error, name })。- 状态对象是"新引用"语义:每次变更都会产出新的 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,客户端再水合。
三条守卫路径分别是:
getStorage():服务端或访问抛异常时返回undefined,持久化链路整体降级;systemPrefersDark():服务端或matchMedia不可用时返回false,system模式先落到亮色,等客户端上报真实偏好;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 resolution | catalog + 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
相关推荐
OpenProject 移动应用平板分屏导航指南:如何在 iPad 和 Android 平板上高效审阅工作包
OpenProject 移动应用平板分屏导航指南:如何在 iPad 和 Android 平板上高效审阅工作包 OpenProject 移动应用(Beta)是开源
Garden Skills 设计方向顾问:不知道要什么风格时如何挑对设计方向
Garden Skills 设计方向顾问:不知道要什么风格时如何挑对设计方向 garden skills 里 web design engineer 技能的 设
Pierre Theming 主题工具包完全指南:从 Shiki 主题到应用 UI 色的统一解析管线
Pierre Theming 主题工具包完全指南:从 Shiki 主题到应用 UI 色的统一解析管线 本指南围绕 Pierre 开源 UI 体系的主题基础包 @
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考