在这里插入图片描述

一、我们要做什么

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 体积。

主题预览缩略图:在设置页中为每个主题生成实时缩略图,用户预览后再确认切换,提升交互体验。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐