普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月20日技术

都快2026了,还有人不会国际化和暗黑主题适配吗,一篇文章彻底解决

作者 Zsnoin能
2025年12月20日 11:50

国际化和暗黑主题适配

前端实现国际化和暗黑主题适配已成为现代产品的核心刚需:国际化能够突破语言、地区和文化壁垒,让产品适配全球不同用户的语言习惯、格式规范(日期/货币/地址)及合规要求,覆盖多语言网站、跨境电商、企业级SaaS等场景,是拓展用户群体、提升全球市场竞争力的基础;而暗黑主题适配不仅能在低光环境下保护用户视力、为OLED设备降低功耗,还能适配不同用户的视觉偏好与特殊人群的无障碍需求,同时契合科技、游戏等行业的产品调性,提升用户体验与使用舒适度;二者结合既满足了产品全球化的适配能力,又兼顾了用户个性化的体验诉求,是提升产品包容性、用户留存率和市场适配性的关键设计与开发策略。

1、国际化

国际化(i18n)是指让软件、网站或应用能够适配不同语言、地区和文化习惯的设计与开发过程,其核心目标是无需大规模修改代码,就能支持多语言、多地区的本地化部署 使用插件 i18n

1.1 定义存储当前语言的方法

src\utils\cookies.ts

import Cookies from 'js-cookie'

const languageKey = 'dd_language'
export const getLanguage = () => Cookies.get(languageKey)
export const setLanguage = (language:string) => Cookies.set(languageKey, language)

1.2 准备翻译文件

有的组件国际化语言较少,可能自行添加,比如vxe-table版本不是最新的,没有越南和俄罗斯翻译,需手动添加。

image.png

1.3 翻译文件

1.3.1 主文件内容
  • src\lang\index.ts
  • 注释已经很详细了,就是对翻译文件进行合并,然后根据当前选中的语言进行应用
  • 重点介绍一下远程翻译,很多时候翻译的不是很准确,客户可能需要能自行进行翻译,我们可以在这里定义一个 loadRemoteMessages 方法,用于加载远程翻译,后端返回给你远程自定义的翻译,然后通过扩展运算符进行合并,后面的数据会覆盖前面,从而实现替换,非常简单吧,根本没啥难度。
  • document.title 能直接修改网页标题,可以通过这个设置网页标题多语言。
import { createI18n } from 'vue-i18n';
import { getLanguage } from '../utils/cookies';
// import { fetchRemoteTranslations } from '../api/translation' // 引入远程接口

// vxe-table 组件翻译(保留本地,不被远程覆盖)
import vxe_zhCN from 'vxe-table/lib/locale/lang/zh-CN';
import vxe_enUS from 'vxe-table/lib/locale/lang/en-US';
import vxe_ruRU from '@/lang/vxeTabel/ru_RU';


// ant-design 组件翻译(保留本地,不被远程覆盖)
import ant_zhCN from 'ant-design-vue/lib/locale/zh_CN';
import ant_enUS from 'ant-design-vue/lib/locale/en_US';
import ant_ruRU from 'ant-design-vue/lib/locale/ru_RU';


// dayjs 本地化(无需修改)
import "dayjs/locale/zh-cn";
import "dayjs/locale/en";
import "dayjs/locale/ru";

// 本地业务翻译(可能被远程覆盖)
import enLocale from './en';
import zhLocale from './zh';
import ruLocale from './ru';



// 初始化本地消息(组件库翻译 + 本地业务翻译)
const baseMessages = {
    en_US: {
        ...ant_enUS,    // 组件库翻译(优先级最高,不被覆盖)
        ...vxe_enUS,
        ...enLocale     // 本地业务翻译(可能被远程覆盖)
    },
    zh_CN: {
        ...ant_zhCN,
        ...vxe_zhCN,
        ...zhLocale
    },
    ru_RU: {
        ...ant_ruRU,
        ...vxe_ruRU,
        ...ruLocale
    }
}

// 获取当前语言(复用你的现有逻辑)
export const getLocale = () => {
    const cookieLanguage = getLanguage()
    if (cookieLanguage) {
        document.documentElement.lang = cookieLanguage
        return cookieLanguage
    }
    const language = navigator.language.toLowerCase()
    const locales = Object.keys(baseMessages)
    for (const locale of locales) {
        if (language.indexOf(locale) > -1) {
            document.documentElement.lang = locale
            return locale
        }
    }
    return 'zh_CN' // 默认中文
}

// 创建基础 i18n 实例(先加载本地翻译)
const i18n = createI18n({
    legacy: false,
    globalInjection: true,
    locale: getLocale(),
    messages: baseMessages, // 先使用本地基础翻译
    silentTranslationWarn: true
})

// 核心:加载远程翻译并合并(远程覆盖本地业务翻译,不影响组件库翻译)
// 定义语言类型
type Locale = 'en_US' | 'zh_CN' | 'ru_RU';

// 修改 loadRemoteMessages 函数,添加类型约束
export const loadRemoteMessages = async (locale: Locale = getLocale()) => {
    try {
        // const remoteTrans = await fetchRemoteTranslations(locale)
        let remoteTrans = {
            99001: "测试远程翻译",
        }
        document.title = '波奇壁纸管理系统'
        if (locale == 'en_US') {
            remoteTrans = {
                99001: "test this a remote translation",
            }
            document.title = 'bocchi wallpaper management system'
        }
        // 2. 合并规则:远程翻译覆盖本地业务翻译,但不影响组件库翻译
        const merged = {
            ...baseMessages[locale], // 基础:组件库 + 本地业务
            ...remoteTrans          // 远程业务翻译覆盖本地业务
        }
        // 3. 更新 i18n 实例的翻译数据
        i18n.global.setLocaleMessage(locale, merged)
        console.log(`已加载 ${locale} 远程翻译`)
    } catch (err) {
        console.error(`远程翻译加载失败,使用本地翻译:`, err)
        // 失败时仍使用本地翻译
    }
}


// 初始化时立即加载远程翻译(确保页面首次渲染使用最新翻译)
loadRemoteMessages()

export default i18n
1.3.1 翻译文件示例

src\lang\en.ts

export default {
    // 系统 10
    10001: '',

    // 菜单 20
    20001: 'Home',
    20002: 'Wallpaper Management',
    20003: 'Wallpaper Information',
    20004: 'Image Upload',
    20005: 'Video Upload',
    20006: 'Data Management',
    20007: 'User Management',
    20008: 'User Information',
    20009: 'Long-term Inactive',
    20010: 'Entertainment Management',
    20011: 'System Management',
    20012: 'Port Information',
    20013: 'Software Information'
}

引用并挂载多语言 src\main.ts

import { createApp } from 'vue'
import App from '@/App.vue'
import 'ant-design-vue/dist/reset.css'
import '@/styles/global.css'
import '@/styles/vxeTable.css'
import router from './router'
import VXETable from 'vxe-table'
import store from './store'
import 'vxe-table/lib/style.css'
import '@/router/permisstion'
import globalComponents from '@/components/index'
import globalDirective from '@/directives/index'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import './auto-update' // 自动更新
import i18n from './lang'

VXETable.setConfig({
  size: 'mini', // 全局尺寸
  i18n: (key: string, args: any) => i18n.global.t(key, args),
  translate(key, args) {
    return i18n.global.t(key, args)
  }
})

const app = createApp(App)
// @ts-ignore
app.config.globalProperties.$i18n = i18n;

app.use(i18n) 
app.use(router)
app.use(VXETable)
store.use(piniaPluginPersistedstate)
app.use(store)
app.use(globalComponents)
app.use(globalDirective)
app.mount('#app')

1.4 定义多语言切换组件

  • src\components\layout\Language\index.vue
  • 在合适的地方引入即可实现多语言切换
<template>
    <div class="Language">
        <a-dropdown>
            <template #overlay>
                <a-menu @click="onLanguage">
                    <a-menu-item v-for="itm in languageList" :key="itm.value" :name="itm.label">
                        {{ itm.label }}
                    </a-menu-item>
                </a-menu>
            </template>
            <a-button style="width: 110px">
                {{ language }}
                <DownOutlined />
            </a-button>
        </a-dropdown>
    </div>
</template>
<script lang="ts" setup>
import { computed, onMounted } from 'vue';
import { DownOutlined } from '@ant-design/icons-vue';
import { useI18n } from "vue-i18n";
import { useUserStore } from '@/store/user'
import { storeToRefs } from 'pinia'
import { setLanguage } from '@/utils/cookies'
import { loadRemoteMessages } from '@/lang/index'

const { locale } = useI18n();
const store = useUserStore()
const { lang } = storeToRefs(store);

const emit = defineEmits(['on-click'])

//多语音
const language = computed(() => {
    switch (lang.value) {
        case 'zh_CN':
            return '简体中文';
        case 'en_US':
            return 'English';
        case 'ru_RU':
            return 'Russia';
        default:
            return '简体中文';
    }
})
const languageList = [
    { label: '简体中文', value: 'zh_CN' },
    { label: 'English', value: 'en_US' },
    { label: 'Russia', value: 'ru_RU' },
]

//切换语言
const onLanguage = async (v: any) => {
    // language.value = v.item.name;
    await loadRemoteMessages(v.key);
    locale.value = v.key;
    lang.value = v.key;
    setLanguage(v.key)
    emit('on-click')
}

onMounted(() => {
    setLanguage(lang.value)
})
</script>

<style scoped lang="scss"></style>

1.5 主UI库适配

  • src\App.vue
  • 这里以 ant-design-vue 为例,按照官网方法设置即可,其它 UI库同理。
<template>
  <ConfigProvider :locale="language" :theme="themeConfig">
    <router-view />
  </ConfigProvider>
</template>

<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import 'dayjs/locale/zh-cn'
import { useUserStore } from '@/store/user.ts'
import themeStore from '@/store/themeStore'
import { storeToRefs } from 'pinia'
import zhCN from 'ant-design-vue/lib/locale/zh_CN'
import enUS from 'ant-design-vue/lib/locale/en_US'
import ruRU from 'ant-design-vue/lib/locale/ru_RU'
import { ConfigProvider, theme } from "ant-design-vue";

const userStore = useUserStore()
const { lang } = storeToRefs(userStore)
const { isDark } = storeToRefs(themeStore())

const themeConfig = ref({
  algorithm: isDark.value ? theme.darkAlgorithm : theme.defaultAlgorithm, // 核心:切换明暗算法
});

watch(isDark, (newVal) => {
  themeConfig.value.algorithm = newVal ? theme.darkAlgorithm : theme.defaultAlgorithm
  document.documentElement.classList.toggle('dark', newVal);
})


const obj: any = {
  'zh_CN': zhCN,
  'en_US': enUS,
  'ru_RU': ruRU
}

const language = computed(() => {
  return obj[lang.value]
})

onMounted(() => {
  document.documentElement.classList.toggle('dark', isDark.value);
})

</script>

<style lang="scss"></style>

1.6 使用

直接使用 $t('key') 或者 引入后使用 t('key') 都行

<template>
    <div>
        <hr>
        {{ t('99001') }}
        <hr>
        {{ $t('99001') }}
        <hr>
    </div>
</template>

<script lang="ts" setup>
import { useI18n } from "vue-i18n";

const { t } = useI18n();
</script>

1.7 效果预览

image.pngimage.png

2、暗黑模式

暗黑模式(Dark Mode)核心价值是适配特定行业调性。刚做的工厂mes系统,现场属于高亮环境,不同时间段还会有太阳光反射等情况,亮色模式下看不清,领导就要求加入暗黑模式,咱就给他加。

2.1 定义主题仓库

  • src\store\themeStore.ts
  • 用于记录主题和改变主题
  • 仓库已做持久化存储
import { defineStore } from 'pinia'

const themeStore = defineStore('theme', {
  state: (): {
    isDark: boolean,
  } => {
    return {
      isDark: false,
    }
  },
  persist: true,
  actions: {
    toggleTheme() {
      this.isDark = !this.isDark
    },
  },
  getters: {},
})

export default themeStore

2.2 定义主题参数

  • src\styles\global.css
2.2.1自定义 css 全局变量
  • 分别定义 暗黑主题 和 亮色主题 的css变量
/* 基础变量:覆盖 90% 通用场景,与 Antd 风格对齐 */
:root {
    --bg: white;
    /* ========== 背景色 ========== */
    --bg-primary: #ffffff;
    --bg-secondary: #f5f5f5;
    --bg-tertiary: #fafafa;
    --bg-overlay: #ffffff;

    /* ========== 文字色 ========== */
    --text-primary: black;
    --text-secondary: #4b5563;
    --text-tertiary: #9ca3af;
    --text-inverse: #ffffff;

    /* ========== 边框色 ========== */
    --border-primary: #e5e7eb;
    --border-secondary: #d1d5db;
    --border-hover: #9ca3af;

    /* ========== 功能色(与 Antd 主色对齐) ========== */
    --color-primary: #1890ff;
    --color-success: #52c41a;
    --color-warning: #faad14;
    --color-danger: #ff4d4f;
    --color-info: #1890ff;

    /* ========== 交互状态 ========== */
    --hover-bg: #f9fafb;
    --active-bg: #f3f4f6;
    --disabled-bg: #f9fafb;

    /* 偏白色的主色调 */
    --color-primary-light: #e6f7ff;
    --color-primary-lighter: #bae7ff;
    --color-primary-lightest: #69c0ff;
    --color-primary-darker: #40a9ff;
    --color-primary-darkest: #096dd9;

    /* 白色透明梯度色 */
    --color-gradient-light: rgba(255, 255, 255, 0.3);
    --color-gradient-lighter: rgba(255, 255, 255, 0.4);
    --color-gradient-lightest: rgba(255, 255, 255, 0.5);

    /* 表格相关 */
    --table-hover-bg: #f1f2ffe8;
    --table-active-bg: #cee9ffe2;



    /* vxe-table 专属变量 */
    --vxe-table-bg: var(--bg-primary);
    --vxe-table-header-bg: var(--bg-secondary);
    --vxe-table-row-hover-bg: #f9fafb;
    --vxe-table-row-active-bg: #f3f4f6;
    --vxe-table-border-color: var(--border-primary);
    --vxe-table-text-color: var(--text-primary);
    --vxe-table-header-text-color: var(--text-primary);
    --vxe-table-cell-hover-color: var(--color-primary);


}

/* 暗黑模式变量:基于 Antd 暗黑风格优化 */
.dark {
    --bg: black;
    /* ========== 背景色 ========== */
    --bg-primary: #141414;
    --bg-secondary: #1f1f1f;
    --bg-tertiary: #272727;
    --bg-overlay: #1f1f1f;

    /* ========== 文字色 ========== */
    --text-primary: white;
    --text-secondary: #9ca3af;
    --text-tertiary: #6b7280;
    --text-inverse: #141414;

    /* ========== 边框色 ========== */
    --border-primary: #303030;
    --border-secondary: #3d3d3d;
    --border-hover: #474747;

    /* ========== 功能色(暗黑模式优化) ========== */
    --color-primary: #359eff;
    --color-success: #67c23a;
    --color-warning: #feb019;
    --color-danger: #ff7875;
    --color-info: #359eff;

    /* ========== 交互状态 ========== */
    --hover-bg: #272727;
    --active-bg: #303030;
    --disabled-bg: #1f1f1f;

    /* 暗黑模式 - 偏暗的主色调(匹配原亮色蓝色系,适配深色背景) */
    --color-primary-light: #142f40;
    --color-primary-lighter: #103c58;
    --color-primary-lightest: #2386c8;
    --color-primary-darker: #2994e0;
    --color-primary-darkest: #359eff;

    /* 黑色透明梯度色 */
    --color-gradient-light: rgba(0, 0, 0, 0.5);
    --color-gradient-lighter: rgba(0, 0, 0, 0.6);
    --color-gradient-lightest: rgba(0, 0, 0, 0.7);

    /* 表格相关 */
    --table-hover-bg: #4e4e4e;
    --table-active-bg: #000a3aa4;


    /* vxe-table 专属变量(适配暗黑) */
    --vxe-table-bg: var(--bg-primary);
    --vxe-table-header-bg: var(--bg-secondary);
    --vxe-table-row-hover-bg: #272727;
    --vxe-table-row-active-bg: #303030;
    --vxe-table-border-color: var(--border-primary);
    --vxe-table-text-color: var(--text-primary);
    --vxe-table-header-text-color: var(--text-primary);
    --vxe-table-cell-hover-color: var(--color-primary);
}
2.2.2 覆盖插件样式
  • 对于一些没有适配暗黑模式或者暗黑模式不清晰的插件,可以对样式进行覆盖
  • 这里以 vxe-table 为例,官网的设置暗黑方法不存在,可能是版本原因吧
  • 其它插件同理覆盖即可
/* 覆盖 vxe-table 基础样式 - 全局生效 */
.vxe-table {
    --vxe-font-color: var(--vxe-table-text-color) !important;
    --vxe-table-header-font-color: var(--vxe-table-header-text-color) !important;
    --vxe-table-row-hover-bg-color: var(--vxe-table-row-hover-bg) !important;
    --vxe-table-row-current-bg-color: var(--vxe-table-row-active-bg) !important;
    --vxe-table-row-hover-font-color: var(--vxe-table-text-color) !important;
    --vxe-border-color: var(--vxe-table-border-color) !important;
    --vxe-table-header-bg-color: var(--vxe-table-header-bg) !important;
    --vxe-table-body-bg-color: var(--vxe-table-bg) !important;
    --vxe-table-empty-bg-color: var(--vxe-table-bg) !important;
    --vxe-input-border-color: var(--vxe-table-border-color) !important;
    --vxe-input-bg-color: var(--vxe-table-bg) !important;
    --vxe-input-font-color: var(--vxe-table-text-color) !important;
    --vxe-border-color: var(--border-primary) !important;
    --vxe-table-border-color: var(--border-primary) !important;
}


.vxe-header--row {
    background-color: var(--bg-primary) !important;
}

.row--level-0 {
    background-color: var(--bg-secondary) !important;
}

.row--stripe {
    background-color: var(--bg-tertiary) !important;
}


.vxe-pager {
    background-color: var(--bg-primary) !important;
}

.vxe-loading {
    background-color: var(--color-gradient-light) !important;
}

.vxe-input--inner,
.vxe-pager--jump-prev,
.vxe-select--panel-wrapper,
.vxe-pager--prev-btn,
.vxe-pager--num-btn,
.vxe-pager--next-btn,
.vxe-pager--jump-next,
.vxe-pager--goto,
.vxe-pager {
    background-color: var(--bg-secondary) !important;
    border-color: var(--border-primary) !important;
    color: var(--text-secondary) !important;
}


/* 进入行 */
.row--hover {
    background-color: var(--table-hover-bg) !important;
}

/* 选中行 */
.row--current {
    background-color: var(--table-active-bg) !important;
}


.vxe-table--body-wrapper {
    background-color: var(--bg-secondary) !important;
}


.vxe-toolbar {
    background-color: var(--bg-primary) !important;
    border-color: var(--border-primary) !important;
    color: var(--text-secondary) !important;
}

/*滚动条整体部分*/
.vxe-table ::-webkit-scrollbar {
    width: 8px !important;
    height: 8px !important;
}

/*滚动条的轨道*/
.vxe-table ::-webkit-scrollbar-track {
    background: transparent !important;
}

/*滚动条里面的小方块,能向上向下移动*/
.vxe-table ::-webkit-scrollbar-thumb {
    background-color: var(--bg-secondary) !important;
    border-radius: 5px !important;
    border: 1px solid var(--border-primary) !important;
    box-shadow: inset 0 0 6px var(--text-secondary) !important;
}

/*边角,即两个滚动条的交汇处*/
.vxe-table ::-webkit-scrollbar-corner {
    background-color: var(--bg-secondary) !important;
}

.vxe-pager--btn-wrapper .is--active {
    background-color: var(--color-primary) !important;
    color: white !important;
}

/* 覆盖 vxe-table 基础样式 - 全局生效 */
.vxe-table {
    --vxe-font-color: var(--vxe-table-text-color) !important;
    --vxe-table-header-font-color: var(--vxe-table-header-text-color) !important;
    --vxe-table-row-hover-bg-color: var(--vxe-table-row-hover-bg) !important;
    --vxe-table-row-current-bg-color: var(--vxe-table-row-active-bg) !important;
    --vxe-table-row-hover-font-color: var(--vxe-table-text-color) !important;
    --vxe-border-color: var(--vxe-table-border-color) !important;
    --vxe-table-header-bg-color: var(--vxe-table-header-bg) !important;
    --vxe-table-body-bg-color: var(--vxe-table-bg) !important;
    --vxe-table-empty-bg-color: var(--vxe-table-bg) !important;
    --vxe-input-border-color: var(--vxe-table-border-color) !important;
    --vxe-input-bg-color: var(--vxe-table-bg) !important;
    --vxe-input-font-color: var(--vxe-table-text-color) !important;
    --vxe-border-color: var(--border-primary) !important;
    --vxe-table-border-color: var(--border-primary) !important;
}


.vxe-header--row {
    background-color: var(--bg-primary) !important;
}

.row--level-0 {
    background-color: var(--bg-secondary) !important;
}

.row--stripe {
    background-color: var(--bg-tertiary) !important;
}


.vxe-pager {
    background-color: var(--bg-primary) !important;
}

.vxe-loading {
    background-color: var(--color-gradient-light) !important;
}

.vxe-input--inner,
.vxe-pager--jump-prev,
.vxe-select--panel-wrapper,
.vxe-pager--prev-btn,
.vxe-pager--num-btn,
.vxe-pager--next-btn,
.vxe-pager--jump-next,
.vxe-pager--goto,
.vxe-pager {
    background-color: var(--bg-secondary) !important;
    border-color: var(--border-primary) !important;
    color: var(--text-secondary) !important;
}


/* 进入行 */
.row--hover {
    background-color: var(--table-hover-bg) !important;
}

/* 选中行 */
.row--current {
    background-color: var(--table-active-bg) !important;
}


.vxe-table--body-wrapper {
    background-color: var(--bg-secondary) !important;
}


.vxe-toolbar {
    background-color: var(--bg-primary) !important;
    border-color: var(--border-primary) !important;
    color: var(--text-secondary) !important;
}

/*滚动条整体部分*/
.vxe-table ::-webkit-scrollbar {
    width: 8px !important;
    height: 8px !important;
}

/*滚动条的轨道*/
.vxe-table ::-webkit-scrollbar-track {
    background: transparent !important;
}

/*滚动条里面的小方块,能向上向下移动*/
.vxe-table ::-webkit-scrollbar-thumb {
    background-color: var(--bg-secondary) !important;
    border-radius: 5px !important;
    border: 1px solid var(--border-primary) !important;
    box-shadow: inset 0 0 6px var(--text-secondary) !important;
}

/*边角,即两个滚动条的交汇处*/
.vxe-table ::-webkit-scrollbar-corner {
    background-color: var(--bg-secondary) !important;
}

.vxe-pager--btn-wrapper .is--active {
    background-color: var(--color-primary) !important;
    color: white !important;
}


.vxe-icon-checkbox-unchecked {
    color: var(--text-tertiary) !important;
}

2.3 引入定义好的变量和样式

  • src\main.ts
import { createApp } from 'vue'
import App from '@/App.vue'
import 'ant-design-vue/dist/reset.css'
import '@/styles/global.css'
import '@/styles/vxeTable.css'
import router from './router'
import VXETable from 'vxe-table'
import store from './store'
import 'vxe-table/lib/style.css'
import '@/router/permisstion'
import globalComponents from '@/components/index'
import globalDirective from '@/directives/index'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import './auto-update' // 自动更新
import i18n from './lang'

VXETable.setConfig({
  size: 'mini', // 全局尺寸
  i18n: (key: string, args: any) => i18n.global.t(key, args),
  translate(key, args) {
    return i18n.global.t(key, args)
  }
})

const app = createApp(App)
// @ts-ignore
app.config.globalProperties.$i18n = i18n;

app.use(i18n) 
app.use(router)
app.use(VXETable)
store.use(piniaPluginPersistedstate)
app.use(store)
app.use(globalComponents)
app.use(globalDirective)
app.mount('#app')

2.4 定义主题切换组件

  • src\components\layout\theme\index.vue
  • 在合适的地方引入即可实现主题切换
<template>
        <a-button class="themeButton"  @click="toggleDark">
            <img :src="isDark ? dark : light" alt="" style="width: 20px; height: 20px">
        </a-button>
</template>

<script setup lang="ts">
import themeStore from '@/store/themeStore'
import { storeToRefs } from 'pinia'
import dark from '@/assets/images/dark.png'
import light from '@/assets/images/light.png'

const themes = themeStore()
const { isDark } = storeToRefs(themes)

const toggleDark = () => {
    isDark.value = !isDark.value;
};
</script>

<style scoped lang="scss">
.themeButton{
    cursor: pointer;
    height: 32px;
    width: 32px;
    box-sizing: border-box;
    border: 1px solid var(--border-primary);
    border-radius: 16px;
    display: flex;
    align-items: center;
    justify-content: center;
}
</style>

2.5 主UI库适配、应用主题

  • src\App.vue
  • 这里以 ant-design-vue 为例,按照官网方法设置即可,其它 UI库同理。
  • 原理很简单,就是监听是否是暗黑主题,如果是则为根元素添加 dark 类名,不是则移除。
  • 记得初始化时默认设置一次类名
<template>
  <ConfigProvider :locale="language" :theme="themeConfig">
    <router-view />
  </ConfigProvider>
</template>

<script setup lang="ts">
import { computed, ref, watch, onMounted } from 'vue'
import 'dayjs/locale/zh-cn'
import { useUserStore } from '@/store/user.ts'
import themeStore from '@/store/themeStore'
import { storeToRefs } from 'pinia'
import zhCN from 'ant-design-vue/lib/locale/zh_CN'
import enUS from 'ant-design-vue/lib/locale/en_US'
import ruRU from 'ant-design-vue/lib/locale/ru_RU'
import { ConfigProvider, theme } from "ant-design-vue";

const userStore = useUserStore()
const { lang } = storeToRefs(userStore)
const { isDark } = storeToRefs(themeStore())

const themeConfig = ref({
  algorithm: isDark.value ? theme.darkAlgorithm : theme.defaultAlgorithm, // 核心:切换明暗算法
});

watch(isDark, (newVal) => {
  themeConfig.value.algorithm = newVal ? theme.darkAlgorithm : theme.defaultAlgorithm
  document.documentElement.classList.toggle('dark', newVal);
})


const obj: any = {
  'zh_CN': zhCN,
  'en_US': enUS,
  'ru_RU': ruRU
}

const language = computed(() => {
  return obj[lang.value]
})

onMounted(() => {
  document.documentElement.classList.toggle('dark', isDark.value);
})

</script>

<style lang="scss"></style>

2.6 使用

  • 使用就非常简单了,通过 var() 使用自定义的css变量即可。
.form{
    background-color: var(--bg-primary);
    color: var(--text-primary);
}

2.7 效果预览

  • 效果非常明显,暗黑模式在高亮环境下依然清晰可见。
image.pngimage.pngimage.pngimage.png

3、测试项目地址

gitee.com/zsnoin-can/…

爬楼梯?不,你在攀登算法的珠穆朗玛峰!

作者 wwwwW
2025年12月20日 11:19

爬楼梯?不,你在攀登算法的珠穆朗玛峰!

一道看似“幼儿园难度”的面试题:
“每次能爬1阶或2阶,问爬到第n阶有几种方法?”
却暗藏递归、动态规划、记忆化、空间优化四大内功心法——
它不是考你会不会算数,而是看你有没有系统性思维


🧗‍♂️ 初见:天真递归 —— “我能行!”(然后爆栈了)

最直觉的解法?当然是递归!

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  return climbStairs(n - 1) + climbStairs(n - 2);
}

逻辑完美

  • 要到第 n 阶,要么从 n-1 上来,要么从 n-2 跳上来
  • 所以 f(n) = f(n-1) + f(n-2) —— 这不就是斐波那契?

但问题来了:
当你调用 climbStairs(45),电脑会疯狂重复计算:

  • f(43) 被算两次
  • f(42) 被算三次
  • ……
    时间复杂度 O(2ⁿ) —— 指数爆炸!

就像你让一个人背完整本字典来查一个词——可行,但荒谬。


🧠 进阶:记忆化递归 —— “我记住了!”

既然重复计算是罪魁祸首,那就把算过的答案存起来

const memo = {};
function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  if (memo[n]) return memo[n]; // ← 关键:查缓存!
  memo[n] = climbStairs(n - 1) + climbStairs(n - 2);
  return memo[n];
}

效果:每个 f(k) 只算一次 → 时间复杂度 O(n)
思想空间换时间,典型的自顶向下动态规划(Top-down DP)

但有个小瑕疵:memo 是全局变量,容易被污染。


🔒 优雅封装:闭包 + 记忆化 —— “我的缓存,外人别碰!”

闭包memo 私有化,打造一个“智能函数”:

const climbStairs = (function() {
  const memo = {}; // ← 外部无法访问!
  return function climb(n) {
    if (n === 1) return 1;
    if (n === 2) return 2;
    if (memo[n]) return memo[n];
    memo[n] = climb(n - 1) + climb(n - 2);
    return memo[n];
  };
})();

优势

  • 多次调用共享缓存(越用越快)
  • 状态私有,安全可靠
  • 接口干净:用户只需 climbStairs(n)

这不是函数,这是一个会学习、有记忆、懂封装的智能体


🚀 终极优化:自底向上 + 滚动变量 —— “我不需要递归!”

其实,我们根本不需要递归,也不需要存所有中间值!

观察规律:

f(1) = 1
f(2) = 2
f(3) = f(2) + f(1) = 3
f(4) = f(3) + f(2) = 5
...

只需要两个变量,就能滚动计算出结果:

function climbStairs(n) {
  if (n === 1) return 1;
  if (n === 2) return 2;
  
  let prevPrev = 1; // f(i-2)
  let prev = 2;     // f(i-1)
  
  for (let i = 3; i <= n; i++) {
    const current = prev + prevPrev; // f(i)
    prevPrev = prev;   // 滚动窗口
    prev = current;
  }
  
  return prev;
}

时间复杂度:O(n)
空间复杂度:O(1) —— 极致优化!
无递归:避免调用栈溢出(n 很大时更安全)

这就是自底向上的动态规划(Bottom-up DP) —— 从已知出发,一步步推导未知。


📊 四种解法对比

方法 时间复杂度 空间复杂度 是否递归 适用场景
暴力递归 O(2ⁿ) O(n) 教学演示
记忆化递归 O(n) O(n) 中等规模,逻辑清晰
闭包记忆化 O(n) O(n) 需要缓存复用
滚动变量 O(n) O(1) 生产环境首选

💡 面试加分回答

当面试官问这道题,你可以这样说:

“我会根据场景选择方案:

  • 如果是教学或快速原型,用记忆化递归,逻辑直观;
  • 如果是高性能生产环境,用滚动变量的迭代法,O(1) 空间且无栈溢出风险。
    此外,我还会考虑边界情况(如 n ≤ 0)、类型校验,以及是否需要支持‘每次可爬1~k阶’的扩展。”

——瞬间从“会写代码”升级到“有工程思维”。


🌟 结语:小题大智慧

“爬楼梯”从来不是一道数学题,而是一面镜子:

  • 它照出你是否理解递归的本质
  • 它检验你是否掌握动态规划的思想
  • 它考验你能否在简洁、性能、可维护性之间做权衡

下次再有人说“这题太简单”,你可以微笑回应:

“是啊,简单到能写出四种境界。”

而这,正是优秀工程师和普通 coder 的分水岭。

🚀别再卷 Redux 了!Zustand 才是 React 状态管理的躺平神器

2025年12月20日 10:50

Zustand VS Redux

在文章开始前咱们先唠嗑一下,各位平时用哪个更多点呢?大数据不会骗人:

首先GitHub上的 Star 数量比较: image.png

image.png

其次每周的下载数量比较:

image.png

image.png

显然,想必用Zustand的可能大概也许应该会居多(单纯看数据来讲)。那么明明Redux才是大哥,为啥被Zustand这个小弟后来居上了?

给大家一个表:

对比项 Redux(老牌流程派) Zustand(新晋清爽党)
上手门槛 高:得记 action type、reducer、Provider 等一堆概念 低:会用 React Hook 就能写,几行代码起手
代码量 多:改个 count 得写 action、reducer 一堆模板代码 少:创建 store + 组件调用,加起来不到 20 行
组件里怎么用 得用 useSelector 取数据 + useDispatch 发动作 直接 useStore( state => state.xxx ) 一步到位
要不要包 Provider 必须包:得用 <Provider store={store}> 裹整个 App 不用包:组件直接调用 store,省一层嵌套
适合场景 大型复杂项目(多人协作、状态逻辑多) 中小型项目 / 快速开发(想少写代码、快速落地)

相信看完表大家已经很明了了,那么如果还想深入了解的可以自行去搜搜,我们唠嗑就到这,开始今天的学习。

具体资料大家去官网看:

www.npmjs.com/package/zus…

www.npmjs.com/package/rea…

前言

想象一下:你正在开发一个 React 项目,Home 组件要改个数字,About 组件得同步显示,List 组件还要从接口拉数据 —— 要是每个组件都自己存状态,代码早乱成一锅粥了!今天咱们就用 Zustand 这个躺平神器,把这些组件串成丝滑的整体,顺便解锁 React 全局状态的 “极简玩法”

一、先搭个 “状态仓库”:Zustand 初体验

Zustand 是啥?你可以把它理解成一个 “共享储物柜”:组件们不用再互相传 props,直接从这个柜子里拿数据、调方法就行。

首先你需要下载Zustand(在开篇的资料里也可以找到~):

image.png

先看我们的第一个 “储物格”——count.js(负责管理计数状态):

// src/store/count.js
import { create } from "zustand";

// 用 create 造一个“状态仓库”
const useCountStore = create((set) => ({
    // 存数据:初始计数是0,还有个默认年龄19
    count: 0,
    age: 19,
    // 存方法:点一下计数+1(set会自动更新视图)
    increase: () => set((state) => ({ count: state.count + 1 })),
    // 传个参数,计数直接减val
    decrease: (val) => set((state) => ({ count: state.count - val }))
}))

export default useCountStore;

就这么几行,一个能 “存数据 + 改数据” 的全局状态就搞定了 —— 比 Redux 轻量到没朋友!

二、组件 “抢着用”:状态共享原来这么丝滑

有了仓库,组件们就能 “按需取货” 了。先看 Home 组件(负责操作计数):

// src/components/Home.jsx
import useCountStore from '../store/count.js'

export default function Home() {
    // 从仓库里“拿”count数据
    let count = useCountStore((state) => state.count);
    // 从仓库里“拿”increase、decrease方法
    const increase = useCountStore((state) => state.increase);
    const decrease = useCountStore((state) => state.decrease);
    return (
        <div>
            {/* 点按钮直接调仓库里的方法,不用传参! */}
            <button onClick={increase}>发送-{count}</button>
            <button onClick={() => decrease(10)}>减少-{count}</button>
        </div>
    )
}

再看 About 组件(负责显示计数):

// src/components/About.jsx
import useCountStore from "../store/count"

export default function About() {
    // 同样从仓库拿count,Home改了这里自动更!
    let count = useCountStore((state) => state.count);
    return (
        <div>
            <h2>title-{count}</h2>
        </div>
    )
}

点击前:

image.png

点击10次发送后:

image.png

刷新然后点击10次减少后:

image.png

你看你看你看看看,Home 点按钮改了 count,About 里的标题直接同步更新 —— 连 props 都不用传,这丝滑感谁用谁知道!

三、进阶玩法:状态里塞接口请求

光存数字才哪到哪,还不够炫!咱们给仓库加个 “拉接口” 的功能。先写 list.js(负责管理列表数据):

// src/store/list.js
import { create } from "zustand";

// 先写个请求接口的函数
const fetchApi = async () => {
    const response = await fetch('https://mock.mengxuegu.com/mock/66585c4db462b81cb3916d3e/songer/songer');
    const res = await response.json();
    return res.data; // async函数的return会变成Promise的resolve值
}

// 造个存列表的仓库
const useListStore = create((set) => ({
    list: [], // 初始列表是空数组
    // 存个“拉列表”的方法,里面调用接口
    fetchList: async () => {
        const res = await fetchApi();
        set({ list: res }) // 拿到数据后更新list
    }
}))

export default useListStore;

然后让 List 组件 用这个仓库:

// src/components/List.jsx
import { useEffect } from "react";
import useListStore from "../store/list"

export default function List() {
    // 从仓库拿list数据和fetchList方法
    const list = useListStore((state) => state.list);
    const fetchList = useListStore((state) => state.fetchList);

    // 组件一加载就调用接口拉数据
    useEffect(() => {
        fetchList()
    }, [])

    return (
        <div>
            {/* 拿到数据直接map渲染 */}
            {list.map((item) => {
                return <div key={item.name}>{item.name}</div>
            })}
        </div>
    )
}

接口数据就出现在浏览器上啦:

image.png

打开页面,List 组件会自动拉接口、存数据、渲染列表 —— 状态管理 + 接口请求,一套流程直接在仓库里包圆了!

四、最后一步:把组件都塞进 App

最后在 App.jsx 里把这些组件拼起来:

import Home from "./components/Home"
import About from "./components/About"
import List from "./components/List"

export default function App() {
    return (
        <div>
            <Home></Home>
            <About></About>
            <List></List>
        </div>
    )
}

image.png

启动项目,你会看到:About 显示着计数,List 自动渲染接口数据 —— 这就是 Zustand 给 React 带来的 “状态自由”

总结

Zustand 堪称 React 状态管理的 “轻骑兵”:无需写冗余的 reducer、不用嵌套 Provider 包裹组件树,几行代码就能搭建全局状态仓库。它剥离了传统状态管理的繁琐仪式感,让我们彻底摆脱模板代码的束缚,聚焦业务本身。

结语

相比 Redux 的 “厚重” 和 Context API 在高频更新下的性能短板,Zustand 就像一把恰到好处的 “瑞士军刀”,轻巧却锋利,用最简单的方式解决了 React 组件间的状态共享难题,让开发者能把更多精力放在业务逻辑本身,而不是状态管理的 “套路” 里。

好的工具从来不是炫技的枷锁,而是让开发者回归创造本身的桥梁。

Java传参还在瞎传?这3个进阶基础技巧少走1年弯路

2025年12月19日 21:24
大家好,我是掘金打码仔~Java传参看着简单,实则藏着不少坑,新手容易懵、老鸟偶尔踩,今天分享3个进阶偏基础的传参技巧,实用性拉满,代码短小精悍,看完就能用! 1. 分清值传递vs引用传递,告别传参玄

CSS3 clip-path+animation实现不规则容器中的粒子下落

2025年12月19日 20:26

使用CSS3的clip-path实现不规则图形裁剪,结合CSS3 animation实现粒子下落动画效果,如下:

html: 创建不规则容器及下落的粒子节点;

<div class="particle">
  <i v-for="item of 20" :key="item" class="particle-item"></i>
</div>

style: 1、此demo使用less实现样式;

/* 不规则容器样式 */
.particle {
  position: absolute;
  top: 90px;
  left: 110px;
  width: 200px;
  height: 236px;
  background: linear-gradient(180deg, #F44336 0%, rgba(250, 33, 245, 0.4) 100%);
  clip-path: polygon(0 0, 100px 0, 100px 200px, 46px 236px, 0 200px);
}

/* 下落粒子样式 */
.particle-item {
  &::before,
  &::after {
    position: absolute;
    width: 4px;
    height: 4px;
    background: #fff;
    border-radius: 50%;
    content: '';
    box-shadow: 0 0 5px 0 rgba(255, 255, 255, 0.5);
  }
  /* 调用粒子下落样式函数 */
  .particle-selectors(20);
}

2、粒子下落样式函数主要计算粒子的初始位置及下落路径;

.particle-selectors(@n, @i:1) when (@i <= @n) {
  &:nth-child(@{i}) {
    &::before ,
    &::after {
      @w: `Math.floor(Math.random() * 100) `;
      @h: `Math.floor(Math.random() * -100) `;
      @d: `Math.random() * 0.2 `;
      @du: calc(~'@{d}s + 5s');
      @t: `Math.random() * -10 `;
      @ti: calc(~'@{t} * 0.6s');

      left: calc(~'@{w} * 1px');
      transform: translateY(calc(~'@{h} * 2px'));
      .animation(@du, @ti);
    }
  }
  .particle-selectors(@n,(@i + 1));
}

3、粒子下落动画;

.animation(@du, @de) {
  @keyframes frame {
    from {
      transform: translateY(-20px);
    }
    to {
      opacity: 0;
      transform: translateY(280px);
    }
  }
  animation: frame 10s infinite;
  animation-delay: @de;
  animation-duration: @du;
}

博客园地址:www.cnblogs.com/wttt123/p/1…

以上。

❌
❌