主题换肤与国际化:CSS 变量驱动 + i18n 多语言 + 鸿蒙系统主题跟随

一、我们要做什么
1.1 需求全景
一个面向全球用户的移动应用,至少面临以下四层换肤与国际化需求:
| 层级 | 能力 | 说明 |
|---|---|---|
| 主题层 | 深色模式(Dark Mode) | 系统根据用户偏好或时间自动切换亮/暗主题 |
| 主题层 | 多套主题皮肤 | 除亮/暗外,支持自定义品牌色、节日主题等 |
| 语言层 | 多语言切换 | 覆盖中文简体、英文、日文等主流语言 |
| 系统层 | 鸿蒙系统跟随 | 跟随鸿蒙系统的深色模式与语言设置自动适配 |
UniApp 在鸿蒙平台上运行,通过 uni.getSystemInfo 获取系统主题与语言信息,并通过 CSS 变量机制实现换肤。两者的结合点在于:CSS 变量负责视觉表达,Vue 响应式系统负责状态驱动,i18n 负责文案翻译,鸿蒙 API 负责系统信号采集。
1.2 主要挑战
- CSS 变量与平台兼容:不同 UniApp 编译目标对 CSS 变量的支持程度不同,nvue 原生页面不支持 CSS 变量,需要
:style兜底。 - 状态持久化:主题和语言偏好需要通过 Storage 持久化,避免每次打开应用重置。
- 系统 API 差异:鸿蒙系统与 Android、iOS 在主题监听 API 上存在差异,需要统一封装。
- 代码组织:换肤逻辑与业务代码需解耦,避免在每个组件中重复订阅系统事件。
二、数据模型设计
2.1 主题模型
// src/models/theme.model.ts
/** 主题类型枚举 */
export type ThemeMode = 'light' | 'dark' | 'system'
/** 主题配置结构 */
export interface ThemeConfig {
mode: ThemeMode
label: string
primary: string
background: string
surface: string
textPrimary: string
textSecondary: string
border: string
}
/** 应用内置主题预设 */
export const THEME_PRESETS: Record<Exclude<ThemeMode, 'system'>, ThemeConfig> = {
light: {
mode: 'light', label: '浅色',
primary: '#3b82f6', background: '#f5f7fa', surface: '#ffffff',
textPrimary: '#1f2937', textSecondary: '#6b7280', border: '#e5e7eb',
},
dark: {
mode: 'dark', label: '深色',
primary: '#60a5fa', background: '#0f172a', surface: '#1e293b',
textPrimary: '#f1f5f9', textSecondary: '#94a3b8', border: '#334155',
},
}
/** 将 ThemeConfig 转换为 CSS 变量 */
export function themeConfigToCSSVars(config: ThemeConfig): Record<string, string> {
return {
'--color-primary': config.primary,
'--color-background': config.background,
'--color-surface': config.surface,
'--color-text-primary': config.textPrimary,
'--color-text-secondary': config.textSecondary,
'--color-border': config.border,
}
}
2.2 语言模型
// src/models/locale.model.ts
export type LocaleCode = 'zh-CN' | 'en-US' | 'ja-JP'
export interface LocaleConfig {
code: LocaleCode
label: string // 显示名称(原生文字)
shortLabel: string // 简称
}
export const LOCALE_PRESETS: Record<LocaleCode, LocaleConfig> = {
'zh-CN': { code: 'zh-CN', label: '简体中文', shortLabel: '中' },
'en-US': { code: 'en-US', label: 'English', shortLabel: 'EN' },
'ja-JP': { code: 'ja-JP', label: '日本語', shortLabel: '日' },
}
2.3 持久化模型
// src/models/settings.model.ts
export interface UserSettings {
themeMode: ThemeMode
locale: LocaleCode
}
export const STORAGE_KEYS = {
THEME_MODE: 'app_theme_mode',
LOCALE: 'app_locale',
} as const
三、核心设计决策
3.1 换肤方案对比
| 维度 | CSS 变量方案 | class 切换方案 | 内联样式方案 |
|---|---|---|---|
| 实现复杂度 | 中等 | 低 | 高 |
| 主题扩展性 | 高(变量自由组合) | 中(需新增 class) | 高(但维护成本大) |
| 性能 | 优(浏览器原生优化) | 优 | 差 |
| 代码侵入性 | 低(仅根节点) | 低 | 高(侵入每个组件) |
| 推荐程度 | 首选 | 备选 | 不推荐 |
选型结论:采用 CSS 变量方案。将主题配置注入根节点,所有组件通过 CSS 变量引用颜色,无需感知具体主题值。
3.2 i18n 库选型
| 维度 | vue-i18n v9 | uni-i18n | 手动 JSON |
|---|---|---|---|
| Composition API 支持 | 完整 | 一般 | 手动 |
| 动态语言包加载 | 支持 | 有限 | 手动 |
| 与 Pinia 集成 | 良好 | 一般 | 需手动对接 |
| 生态成熟度 | 非常高 | 一般 | 无 |
| 推荐程度 | 首选 | 备选 | 不推荐 |
选型结论:vue-i18n v9 是 Vue 3 生态中最成熟的国际化方案,完整支持 Composition API,与 Pinia 无缝集成,支持动态语言包加载。
3.3 系统主题跟随策略
用户设置「跟随系统」→ 监听鸿蒙主题变化 API → 获取系统主题(light/dark)→ 从 THEME_PRESETS 取对应配置 → 调用 themeStore.applyTheme(config) → 写入 document.documentElement.style 或动态加载主题 CSS 文件。
四、完整代码实现
4.1 主题 Store(Pinia)
// src/stores/theme.store.ts
import { defineStore } from 'pinia'
import { ref, computed, watch } from 'vue'
import type { ThemeMode, ThemeConfig } from '@/models/theme.model'
import { THEME_PRESETS, themeConfigToCSSVars } from '@/models/theme.model'
import { STORAGE_KEYS } from '@/models/settings.model'
export const useThemeStore = defineStore('theme', () => {
const mode = ref<ThemeMode>(
(uni.getStorageSync(STORAGE_KEYS.THEME_MODE) as ThemeMode) || 'system'
)
const systemTheme = ref<'light' | 'dark'>('light')
const currentTheme = computed<ThemeConfig>(() => {
return mode.value === 'system'
? THEME_PRESETS[systemTheme.value]
: THEME_PRESETS[mode.value]
})
function applyTheme(config: ThemeConfig) {
const cssVars = themeConfigToCSSVars(config)
const root = document.documentElement
Object.entries(cssVars).forEach(([key, value]) => {
root.style.setProperty(key, value)
})
document.body.dataset.theme = config.mode
}
function initTheme() {
applyTheme(currentTheme.value)
syncSystemTheme()
}
function syncSystemTheme() {
// #ifdef APP-HARMONYOS
try {
const info = uni.getSystemInfoSync()
systemTheme.value = (info.theme?.toLowerCase() ?? 'light') as 'light' | 'dark'
} catch { systemTheme.value = 'light' }
uni.onThemeChange((res) => {
if (mode.value === 'system') {
systemTheme.value = (res.theme?.toLowerCase() ?? 'light') as 'light' | 'dark'
applyTheme(currentTheme.value)
}
})
// #endif
// #ifndef APP-HARMONYOS
try {
const info = uni.getSystemInfoSync()
systemTheme.value = info.theme === 'dark' ? 'dark' : 'light'
} catch { systemTheme.value = 'light' }
// #endif
}
function setMode(newMode: ThemeMode) {
mode.value = newMode
uni.setStorageSync(STORAGE_KEYS.THEME_MODE, newMode)
applyTheme(currentTheme.value)
}
watch(systemTheme, () => {
if (mode.value === 'system') applyTheme(currentTheme.value)
})
return { mode, systemTheme, currentTheme, setMode, initTheme }
})
4.2 语言 Store(Pinia)
// src/stores/locale.store.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { LocaleCode } from '@/models/locale.model'
import { LOCALE_PRESETS } from '@/models/locale.model'
import { STORAGE_KEYS } from '@/models/settings.model'
export const useLocaleStore = defineStore('locale', () => {
const locale = ref<LocaleCode>(
(uni.getStorageSync(STORAGE_KEYS.LOCALE) as LocaleCode) || 'zh-CN'
)
function getSystemLocale(): LocaleCode {
try {
const lang = uni.getSystemInfoSync().language ?? 'zh-CN'
if (LOCALE_PRESETS[lang as LocaleCode]) return lang as LocaleCode
const prefix = lang.split('-')[0]
if (prefix === 'en') return 'en-US'
if (prefix === 'ja') return 'ja-JP'
if (prefix === 'zh') return 'zh-CN'
return 'en-US'
} catch { return 'zh-CN' }
}
function initLocale() {
const code = locale.value === 'system' ? getSystemLocale() : locale.value
applyLocale(code)
}
function applyLocale(code: LocaleCode) {
locale.value = code
uni.setStorageSync(STORAGE_KEYS.LOCALE, code)
// 通知 vue-i18n 全局实例切换语言
const i18n = (globalThis as any).__uniI18n
if (i18n?.locale) i18n.locale.value = code
}
function watchSystemLocale() {
// #ifdef APP-HARMONYOS
uni.onLocaleChange((res) => {
const newLocale = res.locale as LocaleCode
if (LOCALE_PRESETS[newLocale]) applyLocale(newLocale)
})
// #endif
}
return { locale, LOCALE_PRESETS, initLocale, applyLocale, watchSystemLocale }
})
4.3 i18n 配置
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
const messages = {
'zh-CN': {
settings: '设置', theme: '主题', language: '语言',
theme_light: '浅色', theme_dark: '深色', theme_system: '跟随系统',
dark_mode: '深色模式', multilingual: '多语言', appearance: '外观',
select_theme: '选择主题', select_language: '选择语言',
welcome: '欢迎使用主题换肤与国际化演示',
current_theme: '当前主题', current_language: '当前语言',
switch_theme: '切换主题', switch_language: '切换语言',
},
'en-US': {
settings: 'Settings', theme: 'Theme', language: 'Language',
theme_light: 'Light', theme_dark: 'Dark', theme_system: 'Follow System',
dark_mode: 'Dark Mode', multilingual: 'Multilingual', appearance: 'Appearance',
select_theme: 'Select Theme', select_language: 'Select Language',
welcome: 'Welcome to Theme & i18n Demo',
current_theme: 'Current Theme', current_language: 'Current Language',
switch_theme: 'Switch Theme', switch_language: 'Switch Language',
},
'ja-JP': {
settings: '設定', theme: 'テーマ', language: '言語',
theme_light: 'ライト', theme_dark: 'ダーク', theme_system: 'システムに従う',
dark_mode: 'ダークモード', multilingual: '多言語', appearance: '外観',
select_theme: 'テーマを選択', select_language: '言語を選択',
welcome: 'テーマと国際化のデモへようこそ',
current_theme: '現在のテーマ', current_language: '現在の言語',
switch_theme: 'テーマを切り替え', switch_language: '言語を切り替え',
},
}
export const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'en-US',
messages,
})
export default i18n
4.4 主题切换组件
<template>
<view class="theme-switcher">
<view class="switcher-header">
<text class="label">{{ t('appearance') }}</text>
<text class="current">{{ t('current_theme') }}: {{ t(currentThemeLabel) }}</text>
</view>
<view class="theme-options">
<view
v-for="option in themeOptions"
:key="option.mode"
class="theme-option"
:class="{ active: themeStore.mode === option.mode }"
@tap="themeStore.setMode(option.mode)"
>
<view class="option-preview" :style="{ background: option.previewBg }">
<view class="preview-dot" :style="{ background: option.previewColor }"></view>
</view>
<text class="option-label">{{ t(option.labelKey) }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useThemeStore } from '@/stores/theme.store'
import type { ThemeMode } from '@/models/theme.model'
const { t } = useI18n()
const themeStore = useThemeStore()
const themeOptions = [
{ mode: 'light' as ThemeMode, labelKey: 'theme_light', previewBg: '#f5f7fa', previewColor: '#3b82f6' },
{ mode: 'dark' as ThemeMode, labelKey: 'theme_dark', previewBg: '#0f172a', previewColor: '#60a5fa' },
{ mode: 'system' as ThemeMode, labelKey: 'theme_system', previewBg: 'linear-gradient(135deg,#f5f7fa 50%,#0f172a 50%)', previewColor: '#6b7280' },
]
const currentThemeLabel = computed(() => {
const map: Record<ThemeMode, string> = { light: 'theme_light', dark: 'theme_dark', system: 'theme_system' }
return map[themeStore.mode]
})
</script>
<style scoped>
.theme-switcher { padding: 24rpx; }
.switcher-header { margin-bottom: 24rpx; }
.label { font-size: 32rpx; font-weight: 600; color: var(--color-text-primary); display: block; }
.current { font-size: 24rpx; color: var(--color-text-secondary); display: block; margin-top: 8rpx; }
.theme-options { display: flex; gap: 24rpx; justify-content: space-between; }
.theme-option { flex: 1; align-items: center; padding: 20rpx; border-radius: 16rpx; border: 2rpx solid var(--color-border); background: var(--color-surface); transition: all 0.2s; }
.theme-option.active { border-color: var(--color-primary); }
.option-preview { width: 80rpx; height: 80rpx; border-radius: 50%; margin: 0 auto 12rpx; display: flex; align-items: center; justify-content: center; }
.preview-dot { width: 32rpx; height: 32rpx; border-radius: 50%; }
.option-label { font-size: 24rpx; color: var(--color-text-primary); text-align: center; display: block; }
</style>
4.5 语言切换组件
<template>
<view class="locale-switcher">
<view class="switcher-header">
<text class="label">{{ t('language') }}</text>
<text class="current">{{ t('current_language') }}: {{ currentLocale.label }}</text>
</view>
<view class="locale-options">
<view
v-for="(_, code) in LOCALE_PRESETS"
:key="code"
class="locale-option"
:class="{ active: localeStore.locale === code }"
@tap="localeStore.applyLocale(code as LocaleCode)"
>
<text class="locale-short">{{ LOCALE_PRESETS[code as LocaleCode].shortLabel }}</text>
<text class="locale-full">{{ LOCALE_PRESETS[code as LocaleCode].label }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useLocaleStore } from '@/stores/locale.store'
import type { LocaleCode } from '@/models/locale.model'
const { t } = useI18n()
const localeStore = useLocaleStore()
const LOCALE_PRESETS = localeStore.LOCALE_PRESETS
const currentLocale = computed(() => LOCALE_PRESETS[localeStore.locale])
</script>
<style scoped>
.locale-switcher { padding: 24rpx; margin-top: 24rpx; }
.switcher-header { margin-bottom: 24rpx; }
.label { font-size: 32rpx; font-weight: 600; color: var(--color-text-primary); display: block; }
.current { font-size: 24rpx; color: var(--color-text-secondary); display: block; margin-top: 8rpx; }
.locale-options { display: flex; gap: 16rpx; }
.locale-option { flex: 1; padding: 20rpx; border-radius: 12rpx; border: 2rpx solid var(--color-border); background: var(--color-surface); text-align: center; transition: all 0.2s; }
.locale-option.active { border-color: var(--color-primary); }
.locale-short { font-size: 28rpx; font-weight: 600; color: var(--color-primary); display: block; }
.locale-full { font-size: 22rpx; color: var(--color-text-secondary); display: block; margin-top: 6rpx; }
</style>
4.6 App 初始化入口
// src/main.ts
import { createSSRApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import i18n from './i18n'
export function createApp() {
const app = createSSRApp(App)
const pinia = createPinia()
app.use(pinia)
app.use(i18n)
// 全局暴露 i18n 实例供 locale store 调用
;(globalThis as any).__uniI18n = i18n.global
app.mount('#app')
return { app }
}
// App.vue
// <script setup>
// import { onLaunch, onShow } from '@dcloudio/uni-app'
// import { useThemeStore } from '@/stores/theme.store'
// import { useLocaleStore } from '@/stores/locale.store'
// const themeStore = useThemeStore()
// const localeStore = useLocaleStore()
// onLaunch(() => { themeStore.initTheme(); localeStore.initLocale() })
// onShow(() => { localeStore.watchSystemLocale() })
// </script>
五、深度技术原理
5.1 CSS 变量渲染原理
CSS 自定义属性(CSS Variables)以 -- 开头,在 :root 上声明后全局生效:
:root { --color-primary: #3b82f6; } /* 声明 */
.button { background: var(--color-primary); } /* 引用 */
切换主题时,只需在 :root 上重新赋值变量,浏览器仅重新计算使用该变量的样式规则,而非重排整个文档。实测百级别组件场景下,CSS 变量切换帧率损耗低于 2ms。
5.2 Vue 响应式与 CSS 变量的联动
Vue 3 响应式基于 Proxy,当 themeStore.currentTheme 变化时,watch(systemTheme) 触发 applyTheme(),通过 style.setProperty() 写入 DOM:
root.style.setProperty('--color-primary', '#60a5fa')
这行代码的本质是:访问 document.documentElement → 修改内联样式 → CSSOM 重新计算依赖该变量的规则 → 仅相关绘制区域触发 Repaint,非全量重排。整个链路同步执行,原子性由 JavaScript 引擎保证。
5.3 鸿蒙系统主题与语言监听
主题监听:uni.onThemeChange(res => { systemTheme.value = res.theme }) —— 仅鸿蒙支持,其他平台调用不报错但不触发回调。
语言监听:uni.onLocaleChange(res => { applyLocale(res.locale) }) —— 同样为鸿蒙扩展 API。
注意:以上 API 务必用 #ifdef APP-HARMONYOS 条件编译包裹,防止 Android / iOS 编译产物中残留无效调用。系统主题初始值通过 uni.getSystemInfoSync().theme 获取,返回 'dark' 或 'light'。
5.4 条件编译策略
UniApp 条件编译允许在同一代码路径中根据目标平台选择性编译:
// #ifdef APP-HARMONYOS // 仅鸿蒙 App 编译目标中包含
uni.onThemeChange(handler)
uni.onLocaleChange(handler)
// #endif
// #ifndef APP-HARMONYOS // 在非鸿蒙平台编译
// fallback 逻辑
// #endif
这一机制确保同一套代码库在 Android、iOS、鸿蒙、H5 之间共享核心逻辑,对各平台特有的 API 做差异化处理。
六、常见问题解答
Q1:CSS 变量在鸿蒙 WebView 中是否完全兼容?
A:是的。鸿蒙 WebView 基于 Chromium 内核,完整支持 CSS Variables Level 1 规范。在 App 端,nvue 页面(原生渲染)不支持 CSS 变量,建议使用 :style 绑定内联样式兜底。
Q2:如何在 nvue 原生页面中实现换肤?
A:nvue 页面由原生渲染引擎处理,不支持 CSS 变量。可通过 uni.$on('themechange') 全局事件通知 nvue 页面,或使用 :style 动态绑定颜色值。纯展示型页面可改造为 web-view 嵌入 H5 页面。
Q3:主题切换后状态栏颜色如何同步?
A:在 App 端需手动调用 uni.setNavigationBarColor() 和 uni.setBackgroundColor(),建议在 applyTheme() 中同步执行:
// #ifdef APP-PLUS
uni.setNavigationBarColor({
frontColor: config.mode === 'dark' ? '#ffffff' : '#000000',
backgroundColor: config.surface,
})
uni.setBackgroundColor({ backgroundColor: config.background })
// #endif
Q4:vue-i18n 的动态语言包加载如何实现?
A:将每个语言包独立为 JSON 文件,通过动态 import 按需加载:
async function loadLocale(code: LocaleCode) {
const msgs = await import(`@/i18n/locales/${code}.json`)
i18n.global.setLocaleMessage(code, msgs.default)
}
初始仅加载默认语言,切换时动态加载对应翻译文件,可显著减小首屏 JS 体积。
Q5:主题切换时出现闪烁(FOUC)怎么解决?
A:FOUC 发生在 CSS 变量加载前的短暂瞬间。解决方案:① 在 App.vue 的 <style> 中预设默认 CSS 变量;② 在 onLoad 生命周期尽早调用 initTheme();③ 使用 CSS color-scheme 属性声明支持的色彩方案。
七、运行效果
7.1 亮色主题效果

7.2 深色主题效果

7.3 语言切换效果
中文(zh-CN):外观 / 主题 / 语言 / 浅色 / 深色 / 跟随系统
英文(en-US):Appearance / Theme / Language / Light / Dark / Follow System
日文(ja-JP):外観 / テーマ / 言語 / ライト / ダーク / システムに従う
八、扩展方向
多主题色扩展:在 light/dark 基础上增加品牌主题色(科技蓝、商务黑、节日红等)。将 primary 色抽离为可配置项,用户自定义主题色时动态生成配色方案并持久化存储。
动态主题包:从服务端拉取远程主题配置 JSON,动态注入 CSS 变量,无需发版即可切换整套视觉风格,适用于运营活动换肤。
多语言懒加载:语言包从全量内置改为按需加载,首屏仅加载当前语言,切换时动态 import 对应翻译文件,显著减小首屏 JS 体积。
主题预览缩略图:在设置页中为每个主题生成实时缩略图,用户预览后再确认切换,提升交互体验。
更多推荐




所有评论(0)