{T}

DarkModeToggle 与 ThemeSettings 暗黑模式联动

概述

暗黑模式开关看似只是个布尔值切换,真正的工程难点在于「局部控件吐出的值,如何干净地进入布局层的主题系统」。本节以 DarkModeToggle 为入口,讲清它只做状态输出、Header 作为中间层把事件汇总回写整份 settings、用深度 watch 统一往外同步替代逐字段 emit,以及工具栏开关与 ThemeSettings 抽屉必须共享同一份 settings.darkMode。

学习目标

  • 区分 DarkModeToggle 用 defineEmits 输出事件与 defineModel 双向绑定的职责差异
  • 理解 Header 中间层要回写的是整份 settings.darkMode,而非孤立布尔值
  • 用深度 watch 统一监听本地 settings,避免每个处理函数都手写 emit
  • 确保工具栏暗黑开关与抽屉 darkMode 指向同一份状态,杜绝状态漂移
  • 用默认值补齐替代非空断言,并为后续升级具名 v-model 预留空间

一、DarkModeToggle 只做状态输出器

DarkModeToggle 自身不应持有全局暗黑状态,更适合做一个「状态输出器」:接收当前 dark 值,点击后只把新的布尔值抛出去,不直接改全局状态。

课程里讨论过两种对外方案:一种是把它改成 defineModel,一种是保留 props 只补一个 change 事件。两者都成立,最终选第二种,是因为组件已有稳定的 props 结构,继续扩展 change 事件改造成本更低:

ts
const props = defineProps<{ dark: boolean }>()
const emit = defineEmits<{ change: [value: boolean] }>()

function handleToggle() {
  emit("change", !props.dark)
}

经验法则:单一值双向绑定用 defineModel 更简洁;已有稳定 props + emits 结构时,扩展 change 事件更稳。事件名要保持统一,避免一会儿 change、一会儿 update:dark、一会儿 toggle

二、Header 中间层要回写整份 settings

真正的难点不是拿到布尔值,而是拿到以后怎么把它回写到 settings.darkMode——因为 DefaultLayout 最终维护的是整份主题配置对象,不是一个孤立的 dark 开关。

ts
const localState = reactive({
  settings: { ...props.settings }
})

function handleDarkModeToggle(dark: boolean) {
  localState.settings.darkMode = dark
}

注意不要在子组件里直接改 props.settings.darkMode,这违反 Vue 单向数据流。中间层需要汇总多个子组件事件时,本地 reactive 中转是合理的;如果中间层只是纯透传、没有额外逻辑,优先考虑直接 v-model:settings

三、深度 watch 统一同步替代逐字段 emit

settings 里字段越来越多(darkModeshowLogoshowTagsViewfixedHeader 都要从 Header 中转),在每个事件处理函数里手动 emit('settings-change', localState.settings) 会越来越重复。更稳妥的做法是:本地只负责修改 localState.settings,再统一用一个深度 watch 监听,一旦变化就整体向外同步。

ts
watch(
  () => localState.settings,
  (value) => {
    emit("settings-change", { ...value })
  },
  { deep: true }
)

深度监听适合中小型配置对象,向外 emit 时最好返回新对象,避免内外层共享同一引用导致副作用难排查。如果只是监听单个布尔值,没必要上 deep: true

四、工具栏与抽屉共享同一份 darkMode

无论是点击 Header 里的暗黑开关,还是在 ThemeSettings 抽屉中切换 darkMode,最终都应落到同一个字段 settings.darkMode。只有这样,关闭抽屉后全局样式与工具栏状态才能保持一致。

链路是:DarkModeToggle → emit change(boolean) → Header.handleDarkModeToggle → localState.settings.darkMode = dark → emit('settings-change') → DefaultLayout 更新全局主题。不要让工具栏维护一份 isDark、抽屉再维护一份 darkMode;暗黑模式是布局级主题能力,状态归属应尽量靠上,持久化也基于这份统一 settings

五、默认值补齐优于非空断言

课程里出现过「左侧表达式不能是可选属性访问」的提示,随后用非空断言压掉报错。这类处理能让代码先跑起来,但不是最稳的长期方案——非空断言 ! 适合少量边界点,不适合整条链路都靠它兜底。

主题配置对象最适合在入口层就补齐默认值:

ts
const localState = reactive({
  settings: {
    ...defaultThemeSettings,
    ...props.settings
  }
})

默认值完整后,watch、模板绑定和事件透传都会稳定很多。

六、工程化升级为具名 v-model

课程方案的优点是改造小、能快速跑通;但如果后面 ThemeSettingsDarkModeToggleLogoShowTagsView 都走同一套链路,工程上更推荐把 settings 升级成具名 v-model,减少中间层样板代码:

ts
const settings = defineModel<ThemeSettings>("settings", { required: true })
Vue SFC
<Header v-model:settings="settings" />

defineModel 需要 Vue 3.4+;版本较低就继续用 defineProps + defineEmits('update:settings')。课程里的事件透传方案没有错,只是更偏「阶段性推进」而非最终最简写法。


常见问题

问题原因解决方案
Header 能拿到 dark 却改不了全局主题只拿到单个布尔值,没回写 settings.darkMode维护本地 settings 副本,统一向外同步
切换开关后抽屉状态不一致工具栏与抽屉各维护一份暗黑状态统一收口到 settings.darkMode
每新增设置项就重复写一次 emit每个回调都手动抛整份配置用深度 watch 统一监听 localState.settings
TS 提示可选链不可用settings 可能为空却直接写回嵌套字段入口层补齐默认值,减少非空断言
直接改 props.settings.darkMode 报错子组件改了父传入的 props改本地中转状态或具名 v-model
Header 代码越来越重既要中转又要手写大量同步版本允许时升级成 v-model:settings

延伸阅读