动态换肤听起来简单,实际动手时不少人会发现:把颜色定义成Less变量之后,切换主题时页面纹丝不动。原因其实很直接——Less是编译型预处理语言,变量在构建阶段就被替换成了具体的颜色值,打包之后的产物里根本不存在@primary-color这样的东西,自然谈不上运行时修改。所以在Vue项目里做换肤,核心思路是想办法让颜色值在浏览器端可以被重新计算或重新加载。下面介绍三种经过验证的方案,从轻量到重量级依次展开。

方案一:CSS变量配合Less,最轻量的实现
这是目前我最推荐的做法,思路是用Less管理编译期的结构(嵌套、混入、计算),用CSS变量承接运行时需要变化的部分。CSS变量(自定义属性)是浏览器原生支持的,修改后所有引用它的样式会立即重新渲染,天然满足动态换肤的需求。
先在全局样式文件中定义一套变量映射:
// variables.less
:root {
--primary-color: #1890ff;
--success-color: #52c41a;
--warning-color: #faad14;
--text-color: #333333;
--bg-color: #ffffff;
}
// 暗色主题通过类名覆盖变量
.theme-dark {
--primary-color: #177ddc;
--success-color: #49aa19;
--warning-color: #d89614;
--text-color: #e8e8e8;
--bg-color: #141414;
}
然后在组件的Less代码中引用这些CSS变量,Less的编译期能力照常使用,比如利用darken函数生成衍生色:
.button {
background: var(--primary-color);
// Less函数在编译期计算,基于固定值生成悬停色
border: 1px solid darken(#1890ff, 10%);
color: var(--text-color);
&:hover {
opacity: 0.85;
}
}
切换主题时只需要操作body的类名,一行JS就够:
export function setTheme(theme) {
document.body.className = theme === 'dark' ? 'theme-dark' : '';
localStorage.setItem('app-theme', theme);
}
// 应用初始化时恢复
const saved = localStorage.getItem('app-theme');
if (saved) setTheme(saved);
这个方案的优点是零额外依赖、性能开销几乎为零、刷新后用localStorage恢复即可。需要注意的一点是,Less的编译期函数无法直接作用于CSS变量,所以像darken(var(--primary-color))这种写法是无效的,衍生色要么写死,要么利用CSS的color-mix函数在运行时计算。
方案二:运行时修改变量,基于window.less.modifyVars
如果项目已经大量使用Less变量,改造成本高,可以借助less.js在浏览器端重新编译样式。做法是把主题相关的Less源码通过less.modifyVars重新编译,从而改变最终输出的CSS。这种方案是Ant Design Pro早期换肤功能的基础。
首先引入运行时编译的less文件,并把主题样式单独抽成一个动态加载的link:
<link rel="stylesheet/less" type="text/css" href="/theme.less" id="theme-style" /> <script src="https://cdn.jsdelivr.net/npm/less@4/dist/less.min.js"></script>
theme.less中定义好变量并输出对应的样式规则:
@primary-color: #1890ff;
.btn-primary {
background: @primary-color;
border-color: @primary-color;
&:hover {
background: darken(@primary-color, 8%);
}
}
切换主题的封装函数调用modifyVars即可:
export async function changeTheme(color) {
window.less.modifyVars({
'@primary-color': color
}).then(() => {
localStorage.setItem('primary-color', color);
}).catch(err => {
console.error('主题编译失败', err);
});
}
这套方案的优势是兼容老项目,缺点也很明显:浏览器端编译有几百毫秒的延迟,切换瞬间可能出现闪烁;需要额外加载约150KB的less.js;且只能覆盖通过link引入的主题文件,vue文件里scoped的样式不参与运行时编译。如果主题色只影响少数公共组件,这个方案才划算。
方案三:多套主题文件切换,适合完整暗黑模式
当前后分离项目需要完整的深色模式时,最稳妥的方式是预先编译出两套样式,通过切换顶层类名来启用对应规则。这和方案一的区别在于:方案一只是替换颜色变量,方案三则是每个组件都可以为不同主题写完全不同的样式。
在vue.config.js中配置Webpack,利用Less的modifyVars能力在构建时生成多套产物:
// vue.config.js
const themeConfig = require('./theme.config');
module.exports = {
css: {
loaderOptions: {
less: {
lessOptions: {
modifyVars: themeConfig,
javascriptEnabled: true
}
}
}
}
};
更常见的落地方式是约定式的主题类,所有主题相关样式都写在对应的父级选择器下:
.card {
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.09);
.theme-dark & {
background: #1f1f1f;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
}
}
Vue 3的Composition API让逻辑封装更干净,可以写一个useTheme钩子统一管理:
import { ref, watchEffect } from 'vue';
const theme = ref(localStorage.getItem('theme') || 'light');
watchEffect(() => {
document.documentElement.setAttribute('data-theme', theme.value);
localStorage.setItem('theme', theme.value);
});
export function useTheme() {
const toggle = () => {
theme.value = theme.value === 'light' ? 'dark' : 'light';
};
return { theme, toggle };
}
这种方案的可靠性最高,样式全部在构建期产出,没有运行时编译开销,也方便设计师按主题逐页调整。代价是样式体积会增大,且团队必须遵守类名约定,否则某个组件漏写暗色规则就会出现刺眼的白块。建议配合代码评审和视觉走查来兜底。
组件库主题如何跟随切换
实际项目中换肤最麻烦的往往是Element UI这类组件库,因为组件内部样式不受你掌控。以Element UI为例,它的主题色就是Less变量@--color-primary体系,官方提供了element-theme工具,但更简单的路径是直接覆盖。
使用SCSS变量定制是Element的官方推荐,但在纯Less项目里,可以直接用CSS变量覆盖关键样式:
.theme-dark {
// 覆盖Element的背景与文字
.el-table {
background-color: transparent;
color: var(--text-color);
tr {
background-color: #1f1f1f;
}
}
.el-button--primary {
background: var(--primary-color);
border-color: var(--primary-color);
}
.el-dialog {
background: #1f1f1f;
}
}
覆盖时要遵循两个原则:一是优先利用组件库自身暴露的CSS类名体系,如el-button--primary这种语义化类,不要去猜内部随机类名;二是暗色模式下的表格、弹窗、下拉框这些带白色底的面板类组件要重点排查,它们是漏盖的重灾区。如果项目使用Element Plus,它从2.x开始内置了dark主题,引入element-plus/theme-chalk/dark/css-vars.css后切换html上的dark类即可,基本不需要自己写覆盖样式,这也是新项目建议直接上Element Plus的原因之一。
方案选型建议与常见坑
三个方案如何选?给出一个简单判断标准:如果只是品牌色可配置,选方案一,成本最低;如果是遗留项目、Less变量已经铺满全站,短期过渡用方案二;如果要做完整深色模式且长期维护,直接上方案三,配合Vue 3的话加上useTheme封装,体验和代码质量都在线。
最后列几个高频踩坑点。第一,CSS变量继承有作用域,定义在:root上的变量全局可用,但如果某处局部重定义了同名变量,子树会取局部值,排查换肤不生效时先检查是否被局部变量意外覆盖。第二,切换类名的目标建议放在html元素而非body,因为Element Plus等组件库的暗色选择器挂在html上,统一目标可以避免规则失配。第三,持久化不要只存localStorage,还要在index.html的head里加一段内联脚本,在Vue实例挂载前就把主题类设好,否则首屏会先白一下再变黑,这个闪烁在大屏设备上非常明显。把这三点处理好,换肤功能基本就稳定可用了。