5.1 权限系统设计
简历描述
项目经验 - 企业级权限系统
设计并实现基于 RBAC 模型的前端权限系统,支持页面、功能、数据三级权限控制
- 通过路由守卫和组件级权限指令,实现细粒度的权限控制,安全性提升 95%
- 设计权限缓存和预加载策略,权限校验耗时从 500ms 降至 5ms,用户体验无感知
- 实现动态菜单和按钮权限,支持 10+ 角色、200+ 权限点的灵活配置
SOP 标准回答
面试官问:你们的权限系统是怎么设计的?
"我们项目用的是 RBAC(基于角色的访问控制)模型,分三个层级:页面权限、功能权限、数据权限。
页面权限是最粗粒度的,控制用户能访问哪些页面。实现上,后端会返回用户的权限列表,前端根据这个列表动态生成路由。没有权限的路由不会注册,用户访问时会跳转到 403 页面。我在路由配置里加了 meta.permissions 字段,注册路由前会检查用户是否有对应权限。
功能权限更细,控制页面内的按钮、操作。比如同一个列表页,普通用户只能查看,管理员能编辑删除。我封装了一个 usePermission hook 和 Permission 组件,使用时传入权限码,组件会自动判断是否显示。
数据权限最复杂,控制用户能看到哪些数据。比如销售只能看自己的客户,经理能看整个团队的。这个主要靠后端接口控制,前端会在请求参数里带上数据范围,后端根据权限过滤数据。
权限数据会缓存在 localStorage,登录后一次性加载,后续操作直接读缓存,不用每次都请求。还做了权限预加载,用户打开应用时就在后台加载权限,等用户操作时权限数据已经准备好了。
难点在于权限变更的实时性。管理员在后台修改了用户权限,前端要能立即生效,不能让用户重新登录。我用 WebSocket 推送权限变更通知,收到通知后重新加载权限并刷新页面。"
项目难点与亮点
难点1:动态路由的性能优化
"动态路由有个问题,就是路由很多的时候,注册路由会很慢。我们项目有 200+ 个路由,如果登录后一次性全部注册,首屏要等好几秒。
我做了按需加载。初始只注册一级路由和当前访问的页面,其他路由懒加载。用户访问某个菜单时,才动态添加对应的子路由。这样首屏加载时间从 3 秒降到了 500ms。
还有个问题是路由守卫的性能。每次路由跳转都要检查权限,权限列表有几百个,遍历匹配会很慢。我用 Set 存储用户的权限码,检查权限从 O(n) 降到 O(1)。
另外我做了路由权限的缓存,同一个路由第一次检查权限后,结果会缓存起来,下次直接用缓存。这个优化让路由跳转的耗时从平均 100ms 降到了 10ms 以内。"
难点2:权限粒度的平衡
"权限粒度太粗,控制不够细;太细,配置和维护成本太高。找到平衡点很重要。
我们的做法是用资源树的方式组织权限。每个模块是一个资源节点,下面有操作子节点,比如'用户管理'是一个节点,下面有'查看'、'新增'、'编辑'、'删除'四个子节点。管理员配置权限时,可以一键勾选整个模块,也可以单独选某些操作。
前端使用时,可以用模块权限(users),也可以用精确权限(users:create)。模块权限会自动展开成所有子权限,精确权限只检查单个操作。
还有个设计是权限继承。子页面会继承父页面的权限,除非明确指定不继承。比如'用户详情'页面默认继承'用户列表'的查看权限,如果需要编辑权限,就额外配置 users:edit。这样减少了很多重复配置。"
难点3:前后端权限的一致性
"前端权限控制主要是 UI 层面,真正的安全要靠后端。但前后端的权限配置要保持一致,否则会出现前端能看到按钮但后端接口拒绝的尴尬情况。
我们的方案是权限配置统一管理。后端有一个权限配置中心,定义所有的权限点和它们的层级关系。前端从这个配置中心拉取权限定义,生成权限树和权限枚举。
部署时,会运行一个检查脚本,扫描前端代码里使用的权限码,和后端定义对比,如果有不匹配的就报错,阻止发布。这个机制保证了前后端权限定义的一致性。
接口层面,我做了一个拦截器,会在请求头里带上操作的权限码,后端可以用这个信息做审计和监控。如果发现前端请求了没有权限的接口,会记录日志并告警,方便及时发现问题。"
亮点:可视化的权限配置
"为了降低权限配置的门槛,我做了一个可视化的权限配置界面。
左侧是资源树,展示所有的模块和功能。右侧是角色列表,可以拖拽资源到角色上,自动生成权限配置。还支持批量操作,比如给所有销售角色添加某个功能权限。
配置完成后可以实时预览,选择一个用户,界面会模拟这个用户看到的菜单和按钮,方便验证配置是否正确。
还有个实用功能是权限对比。可以选择两个角色,对比它们的权限差异,高亮显示不同的地方。这个功能在调整权限时特别有用,能快速看出改动的影响范围。"
亮点:权限变更的实时通知
"权限变更的实时性很重要。如果管理员撤销了某个用户的权限,但用户还能继续操作,就会有安全隐患。
我用 WebSocket 实现了权限变更的实时推送。后端修改权限后,会通过 WebSocket 推送一条消息到前端,消息里包含变更的用户 ID 和权限类型。
前端收到消息后,会检查是否是当前用户。如果是,就重新加载权限数据,然后做几件事:一是更新菜单和按钮的显示状态;二是检查当前页面是否还有权限访问,没有就跳转到首页;三是清空已有的请求缓存,避免用缓存数据绕过权限检查。
整个过程用户是有感知的,会弹一个提示'您的权限已变更,部分功能可能受到影响'。这样既保证了安全,又不会让用户摸不着头脑。"
技术实现
权限管理核心
// store/permission.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface Permission {
id: string;
code: string;
name: string;
type: 'page' | 'action' | 'data';
parentId?: string;
}
interface PermissionStore {
// 用户权限列表
permissions: Permission[];
// 权限码集合(用于快速查找)
permissionCodes: Set<string>;
// 角色信息
roles: string[];
// 数据权限范围
dataScope: 'all' | 'dept' | 'self';
// 设置权限
setPermissions: (permissions: Permission[]) => void;
// 检查权限
hasPermission: (code: string | string[]) => boolean;
// 检查是否有任一权限
hasAnyPermission: (codes: string[]) => boolean;
// 检查是否有全部权限
hasAllPermissions: (codes: string[]) => boolean;
// 清空权限
clearPermissions: () => void;
}
export const usePermissionStore = create<PermissionStore>()(
persist(
(set, get) => ({
permissions: [],
permissionCodes: new Set(),
roles: [],
dataScope: 'self',
setPermissions: (permissions) => {
const codes = new Set(permissions.map(p => p.code));
// 展开父权限
permissions.forEach(permission => {
if (permission.parentId) {
const parts = permission.code.split(':');
// 添加父权限码
for (let i = 1; i < parts.length; i++) {
codes.add(parts.slice(0, i).join(':'));
}
}
});
set({
permissions,
permissionCodes: codes,
});
},
hasPermission: (code) => {
const { permissionCodes } = get();
if (typeof code === 'string') {
return permissionCodes.has(code);
}
// 数组形式,检查是否有任一权限
return code.some(c => permissionCodes.has(c));
},
hasAnyPermission: (codes) => {
const { permissionCodes } = get();
return codes.some(code => permissionCodes.has(code));
},
hasAllPermissions: (codes) => {
const { permissionCodes } = get();
return codes.every(code => permissionCodes.has(code));
},
clearPermissions: () => {
set({
permissions: [],
permissionCodes: new Set(),
roles: [],
dataScope: 'self',
});
},
}),
{
name: 'permission-storage',
// 只持久化必要的数据
partialize: (state) => ({
permissions: state.permissions,
roles: state.roles,
dataScope: state.dataScope,
}),
}
)
);
路由权限守卫
// router/guards.ts
import { RouteLocationNormalized, NavigationGuardNext } from 'vue-router';
import { usePermissionStore } from '@/store/permission';
import { useUserStore } from '@/store/user';
// 权限检查缓存
const permissionCache = new Map<string, boolean>();
export function createPermissionGuard() {
return async (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
next: NavigationGuardNext
) => {
const userStore = useUserStore();
const permissionStore = usePermissionStore();
// 未登录,跳转登录页
if (!userStore.token) {
next({ path: '/login', query: { redirect: to.fullPath } });
return;
}
// 加载权限(如果还没加载)
if (permissionStore.permissions.length === 0) {
try {
await userStore.fetchPermissions();
} catch (error) {
console.error('加载权限失败:', error);
next({ path: '/403' });
return;
}
}
// 检查路由权限
const requiredPermissions = to.meta.permissions as string[] | undefined;
if (!requiredPermissions || requiredPermissions.length === 0) {
// 无权限要求,直接放行
next();
return;
}
// 检查缓存
const cacheKey = `${to.path}:${requiredPermissions.join(',')}`;
if (permissionCache.has(cacheKey)) {
const hasPermission = permissionCache.get(cacheKey)!;
if (hasPermission) {
next();
} else {
next({ path: '/403' });
}
return;
}
// 检查权限(或的关系,有任一权限即可)
const hasPermission = permissionStore.hasAnyPermission(requiredPermissions);
// 缓存结果
permissionCache.set(cacheKey, hasPermission);
if (hasPermission) {
next();
} else {
next({ path: '/403' });
}
};
}
// 清空权限缓存
export function clearPermissionCache() {
permissionCache.clear();
}
权限指令和组件
// directives/permission.ts
import { Directive } from 'vue';
import { usePermissionStore } from '@/store/permission';
export const vPermission: Directive = {
mounted(el, binding) {
const { value } = binding;
const permissionStore = usePermissionStore();
if (value && value.length > 0) {
const hasPermission = permissionStore.hasAnyPermission(
Array.isArray(value) ? value : [value]
);
if (!hasPermission) {
// 移除元素
el.parentNode?.removeChild(el);
}
}
},
};
// components/Permission/Permission.tsx
import { usePermissionStore } from '@/store/permission';
interface PermissionProps {
code: string | string[];
mode?: 'any' | 'all'; // any: 有任一权限即显示, all: 需要全部权限
fallback?: React.ReactNode;
children: React.ReactNode;
}
export function Permission({
code,
mode = 'any',
fallback = null,
children,
}: PermissionProps) {
const permissionStore = usePermissionStore();
const codes = Array.isArray(code) ? code : [code];
const hasPermission =
mode === 'all'
? permissionStore.hasAllPermissions(codes)
: permissionStore.hasAnyPermission(codes);
return hasPermission ? <>{children}</> : <>{fallback}</>;
}
动态菜单生成
// utils/menu.ts
import { RouteRecordRaw } from 'vue-router';
import { Permission } from '@/store/permission';
interface MenuItem {
id: string;
name: string;
path: string;
icon?: string;
children?: MenuItem[];
permissions?: string[];
}
export function filterMenusByPermission(
menus: MenuItem[],
permissionCodes: Set<string>
): MenuItem[] {
return menus
.filter(menu => {
// 没有权限要求,直接显示
if (!menu.permissions || menu.permissions.length === 0) {
return true;
}
// 检查是否有权限
return menu.permissions.some(code => permissionCodes.has(code));
})
.map(menu => {
// 递归过滤子菜单
if (menu.children && menu.children.length > 0) {
return {
...menu,
children: filterMenusByPermission(menu.children, permissionCodes),
};
}
return menu;
})
.filter(menu => {
// 过滤掉没有子菜单的父菜单
if (menu.children) {
return menu.children.length > 0;
}
return true;
});
}
export function generateRoutesFromMenus(menus: MenuItem[]): RouteRecordRaw[] {
return menus.map(menu => {
const route: RouteRecordRaw = {
path: menu.path,
name: menu.id,
meta: {
title: menu.name,
icon: menu.icon,
permissions: menu.permissions,
},
component: () => import(`@/views${menu.path}.vue`),
};
if (menu.children && menu.children.length > 0) {
route.children = generateRoutesFromMenus(menu.children);
}
return route;
});
}
5.2 国际化方案
简历描述
项目经验 - 国际化多语言支持
实现支持 10+ 语言的国际化方案,覆盖前端全部文本内容
- 基于 react-i18next 设计翻译管理系统,支持命名空间、嵌套翻译、动态参数等特性
- 通过自动提取和翻译脚本,新增多语言工作量减少 80%,翻译遗漏率从 15% 降至 0%
- 实现语言包懒加载和缓存,切换语言响应时间从 2s 降至 200ms
SOP 标准回答
面试官问:你们的国际化是怎么实现的?
"我们项目需要支持中文、英文、日文等 10 种语言。技术选型上用的是 react-i18next,它功能强大,社区活跃。
国际化的核心是翻译文件的管理。我按模块划分了命名空间,比如 common、user、order,每个模块有自己的翻译文件。这样既方便管理,也能实现按需加载,不用一次性加载所有翻译。
使用时很简单,用 useTranslation hook 获取 t 函数,调用 t('key') 就能得到对应语言的文本。还支持参数插值,比如 t('welcome', { name: '张三' }) 会替换掉文本里的占位符。
难点在于翻译内容的维护。手动写翻译文件容易遗漏,也不好管理。我写了一个自动提取脚本,会扫描代码里所有的 t() 调用,提取出 key,生成 JSON 文件。翻译人员只需要填写对应的翻译,不需要关心技术细节。
还做了翻译校验,检查所有语言的 key 是否一致,有缺失的会在 CI 里报错。这样保证了所有语言的翻译是完整的。
性能方面,语言包是懒加载的,切换语言时才动态加载对应的翻译文件。加载后会缓存,下次直接用缓存,不用重新请求。现在切换语言基本是秒切,用户无感知。"
项目难点与亮点
难点1:动态内容的翻译
"有些内容是后端返回的,比如状态、分类、错误信息,这些也需要翻译。但后端返回的是 key,前端要根据当前语言找到对应的翻译。
我的方案是前后端约定一套翻译 key 的规范。后端返回的状态比如 'order.status.pending',前端翻译文件里有对应的 key。前端拿到后直接 t('order.status.pending') 就能得到翻译。
还有些场景是后端返回的是原始文本,比如用户输入的评论。这种就没法翻译,但至少要保证界面的标签和按钮是多语言的。我做了一个区分,系统文本用翻译 key,用户内容保持原样。
还遇到过一个问题是数字和日期的格式化。不同地区的格式不一样,比如中国用'年月日',美国用'月/日/年'。我用了 date-fns 和 Intl API,会根据当前语言自动格式化。"
难点2:SEO 和多语言路由
"多语言网站的 SEO 很重要。我做了几个优化。
首先是 URL 多语言化,中文版是 /zh/about,英文版是 /en/about。这样搜索引擎能索引不同语言的页面。路由配置里加了语言前缀,切换语言时会跳转到对应的 URL。
其次是 HTML lang 属性,会根据当前语言设置 <html lang="zh">,帮助搜索引擎识别页面语言。
还有 hreflang 标签,告诉搜索引擎不同语言版本的关系:
<link rel="alternate" hreflang="zh" href="https://example.com/zh/about" />
<link rel="alternate" hreflang="en" href="https://example.com/en/about" />
SSR 的话,服务端会根据请求的语言路径渲染对应语言的内容,保证爬虫能抓取到正确的文本。"
难点3:RTL 语言的支持
"阿拉伯语、希伯来语是从右到左书写的,布局要镜像翻转。这个改造成本很高,尤其是已有的项目。
我用了 CSS 逻辑属性,把 left/right 改成 inline-start/inline-end,浏览器会根据文字方向自动调整。比如:
/* 原来 */
margin-left: 16px;
/* 改成 */
margin-inline-start: 16px;
Flexbox 和 Grid 也要调整 direction 属性。我在根元素上设置 dir="rtl",所有子元素自动适配。
图标和图片也要镜像,比如箭头要反向。我做了一个 flip 工具函数,RTL 语言会自动翻转图标:
.icon {
transform: scaleX(var(--flip, 1));
}
[dir="rtl"] .icon {
--flip: -1;
}
这套方案让我们的应用能支持 RTL 语言,只需要改很少的代码。"
亮点:翻译管理平台
"为了方便非技术人员管理翻译,我做了一个翻译管理平台。
左侧是翻译 key 的树形列表,按命名空间分组。右侧是翻译编辑器,可以同时看到所有语言的翻译,方便对比和修改。
还有个批量翻译功能,可以调用机器翻译 API,一键翻译所有缺失的内容。翻译后会标记为'机器翻译',提醒人工校对。
改完后可以导出 JSON 文件,提交到代码仓库。也可以直接在平台上发布,通过 API 推送到 CDN,前端动态加载最新的翻译。
这个平台大大降低了翻译的门槛,产品和运营都能自己管理多语言内容,不用每次都找开发。"
技术实现
i18n 配置
// i18n/index.ts
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import Backend from 'i18next-http-backend';
i18n
.use(Backend) // 懒加载翻译文件
.use(LanguageDetector) // 自动检测用户语言
.use(initReactI18next)
.init({
// 默认语言
fallbackLng: 'zh',
// 调试模式
debug: process.env.NODE_ENV === 'development',
// 命名空间
ns: ['common', 'user', 'order'],
defaultNS: 'common',
// 插值配置
interpolation: {
escapeValue: false, // React 已经做了 XSS 防护
},
// 后端配置
backend: {
loadPath: '/locales/{{lng}}/{{ns}}.json',
// 添加版本号,利用缓存
queryStringParams: { v: '1.0.0' },
},
// 语言检测配置
detection: {
// 检测顺序
order: ['querystring', 'localStorage', 'navigator'],
// 缓存用户语言选择
caches: ['localStorage'],
// URL 参数名
lookupQuerystring: 'lang',
},
// React 配置
react: {
// 使用 Suspense 等待翻译加载
useSuspense: true,
},
});
export default i18n;
翻译提取脚本
// scripts/extract-translations.js
const fs = require('fs-extra');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const glob = require('glob');
// 扫描所有源文件
const files = glob.sync('src/**/*.{ts,tsx}', {
ignore: ['**/*.test.ts', '**/*.spec.ts'],
});
// 提取的翻译 key
const translationKeys = new Set();
files.forEach(file => {
const content = fs.readFileSync(file, 'utf-8');
try {
const ast = parser.parse(content, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
});
traverse(ast, {
// 匹配 t('key') 调用
CallExpression(path) {
const { node } = path;
// 检查是否是 t 函数调用
if (
(node.callee.name === 't' ||
(node.callee.property && node.callee.property.name === 't')) &&
node.arguments.length > 0
) {
const firstArg = node.arguments[0];
// 只处理字符串字面量
if (firstArg.type === 'StringLiteral') {
translationKeys.add(firstArg.value);
}
}
},
});
} catch (error) {
console.error(`解析文件失败: ${file}`, error.message);
}
});
// 按命名空间分组
const keysByNamespace = {};
translationKeys.forEach(key => {
const parts = key.split('.');
const namespace = parts.length > 1 ? parts[0] : 'common';
if (!keysByNamespace[namespace]) {
keysByNamespace[namespace] = new Set();
}
keysByNamespace[namespace].add(key);
});
// 生成 JSON 文件
const localesDir = path.resolve(__dirname, '../public/locales');
const languages = ['zh', 'en', 'ja'];
languages.forEach(lang => {
Object.entries(keysByNamespace).forEach(([namespace, keys]) => {
const filePath = path.join(localesDir, lang, `${namespace}.json`);
// 读取现有翻译
let existing = {};
if (fs.existsSync(filePath)) {
existing = fs.readJSONSync(filePath);
}
// 合并新 key
const translations = {};
Array.from(keys).sort().forEach(key => {
// 移除命名空间前缀
const translationKey = key.replace(`${namespace}.`, '');
// 构建嵌套对象
const parts = translationKey.split('.');
let current = translations;
parts.forEach((part, index) => {
if (index === parts.length - 1) {
// 最后一层,设置翻译值
current[part] = existing[translationKey] || `[${translationKey}]`;
} else {
// 中间层,创建对象
current[part] = current[part] || {};
current = current[part];
}
});
});
// 写入文件
fs.ensureDirSync(path.dirname(filePath));
fs.writeJSONSync(filePath, translations, { spaces: 2 });
console.log(`✓ 已生成 ${lang}/${namespace}.json (${keys.size} 个key)`);
});
});
console.log(`\n总计提取 ${translationKeys.size} 个翻译 key`);
语言切换 Hook
// hooks/useLanguage.ts
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useLocation } from 'react-router-dom';
const SUPPORTED_LANGUAGES = [
{ code: 'zh', name: '简体中文', direction: 'ltr' },
{ code: 'en', name: 'English', direction: 'ltr' },
{ code: 'ja', name: '日本語', direction: 'ltr' },
{ code: 'ar', name: 'العربية', direction: 'rtl' },
];
export function useLanguage() {
const { i18n } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const currentLanguage = i18n.language;
const currentLangConfig = SUPPORTED_LANGUAGES.find(
lang => lang.code === currentLanguage
);
// 切换语言
const changeLanguage = useCallback(
async (langCode: string) => {
const langConfig = SUPPORTED_LANGUAGES.find(
lang => lang.code === langCode
);
if (!langConfig) {
console.error('不支持的语言:', langCode);
return;
}
// 更新 i18n 语言
await i18n.changeLanguage(langCode);
// 更新 HTML lang 属性
document.documentElement.lang = langCode;
// 更新文字方向
document.documentElement.dir = langConfig.direction;
// 更新 URL(如果使用语言前缀路由)
const pathWithoutLang = location.pathname.replace(/^\/[a-z]{2}/, '');
navigate(`/${langCode}${pathWithoutLang}${location.search}`);
},
[i18n, navigate, location]
);
return {
currentLanguage,
currentDirection: currentLangConfig?.direction || 'ltr',
supportedLanguages: SUPPORTED_LANGUAGES,
changeLanguage,
};
}
5.3 主题切换系统
简历描述
项目经验 - 动态主题系统
实现支持自定义主题的动态主题系统,提供暗黑、明亮等 5+ 内置主题
- 基于 CSS Variables 设计主题系统,支持运行时切换,响应时间 < 50ms
- 通过 CSS-in-JS 和主题提供者实现组件级主题定制,覆盖率达 100%
- 实现主题持久化和预加载,避免页面闪烁,用户体验提升 40%
SOP 标准回答
面试官问:你们的主题切换是怎么实现的?
"我们项目支持明亮、暗黑、高对比等多个主题,还允许用户自定义主题颜色。技术实现上用的是 CSS Variables。
核心思路是把所有的颜色、字体、间距都定义成 CSS 变量,组件样式引用这些变量。切换主题时,只需要更新 CSS 变量的值,所有引用的地方会自动更新,不需要重新渲染组件。
比如定义一个主题:
:root {
--color-primary: #1890ff;
--color-bg: #ffffff;
--color-text: #000000;
}
[data-theme="dark"] {
--color-primary: #177ddc;
--color-bg: #141414;
--color-text: #ffffff;
}
切换主题时,只需要在 html 元素上设置 data-theme 属性,CSS 会自动应用对应的变量值。
难点在于主题的管理和持久化。我用 Context 提供主题状态和切换方法,所有组件通过 useTheme hook 访问。主题选择会保存到 localStorage,刷新页面后能恢复。
还有个细节是避免页面闪烁。如果先渲染默认主题再切换到用户选择的主题,会有明显的颜色闪变。我在 HTML 里内联了一段脚本,在页面加载前读取 localStorage,立即设置主题,保证首屏就是正确的主题。
性能方面,CSS 变量的切换非常快,基本是瞬间完成。唯一的开销是重绘,但现代浏览器优化得很好,用户感受不到延迟。"
项目难点与亮点
难点1:动态生成主题色板
"用户可以自定义主题的主色调,系统要根据主色调自动生成一套完整的色板,包括不同深浅的颜色、悬停态、禁用态等。
我参考了 Ant Design 的色板生成算法,基于主色调生成 10 个梯度的颜色。算法会考虑颜色的明度、饱和度,保证生成的颜色和谐统一。
代码实现用了 TinyColor 这个库,可以轻松操作颜色的 HSL 值:
function generatePalette(primaryColor: string) {
const color = new TinyColor(primaryColor);
const palette = [];
for (let i = 1; i <= 10; i++) {
const lighten = i < 6 ? (6 - i) * 10 : 0;
const darken = i > 6 ? (i - 6) * 10 : 0;
let newColor = color.clone();
if (lighten > 0) {
newColor = newColor.lighten(lighten);
}
if (darken > 0) {
newColor = newColor.darken(darken);
}
palette.push(newColor.toHexString());
}
return palette;
}
生成的色板会注入到 CSS 变量里,比如 --color-primary-1 到 --color-primary-10。"
难点2:第三方组件的主题适配
"项目里用了很多第三方组件库,比如 Ant Design、ECharts。这些组件有自己的主题系统,要和我们的主题保持一致。
Ant Design 比较好办,它支持 CSS 变量模式。我在 ConfigProvider 里传入主题配置,用 CSS 变量覆盖默认颜色:
<ConfigProvider
theme={{
token: {
colorPrimary: 'var(--color-primary)',
colorBgContainer: 'var(--color-bg)',
},
}}
>
<App />
</ConfigProvider>
ECharts 需要手动设置主题。我监听主题变化,重新设置 ECharts 实例的主题:
useEffect(() => {
if (chartInstance) {
chartInstance.setOption({
backgroundColor: theme.colors.bg,
color: theme.colors.palette,
});
}
}, [theme, chartInstance]);
还有些组件没有主题 API,只能通过 CSS 覆盖样式。我用 CSS 变量写了一套覆盖样式,保证和全局主题一致。"
难点3:暗黑模式的图片处理
"暗黑模式下,有些图片看起来不协调,尤其是白底的图标和插图。
我做了几种处理:
- 对于图标,使用 SVG,颜色用 CSS 变量,自动适配主题
- 对于插图,准备明暗两套,根据主题切换
- 对于无法替换的图片,用 CSS 滤镜调整:
[data-theme="dark"] img {
filter: brightness(0.8) contrast(1.2);
}
还有个细节是 Logo。白底 Logo 在暗黑模式下不好看,我用 CSS mix-blend-mode 让 Logo 适应背景:
.logo {
mix-blend-mode: difference;
}
这样 Logo 会自动反色,在任何背景下都清晰可见。"
亮点:主题可视化编辑器
"为了方便设计师和产品调整主题,我做了一个可视化的主题编辑器。
界面分三部分。左侧是主题配置面板,可以选择预设主题或自定义颜色、字体、圆角等参数。中间是实时预览,会渲染常用的组件,比如按钮、表单、表格,实时看到主题效果。右侧是代码面板,自动生成主题配置的 JSON 和 CSS 代码,可以直接复制使用。
还有个对比功能,可以同时预览多个主题,方便选择最佳方案。
编辑好的主题可以导出为 JSON 文件,也可以上传到服务器,其他用户可以导入使用。我们内部还建了一个主题市场,大家可以分享自己设计的主题。"
技术实现
主题系统核心
// theme/ThemeProvider.tsx
import React, { createContext, useContext, useEffect, useState } from 'react';
import { themes, Theme, ThemeMode } from './themes';
interface ThemeContextValue {
mode: ThemeMode;
theme: Theme;
setMode: (mode: ThemeMode) => void;
setCustomTheme: (theme: Partial<Theme>) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [mode, setMode] = useState<ThemeMode>(() => {
// 从 localStorage 读取主题
const saved = localStorage.getItem('theme-mode');
return (saved as ThemeMode) || 'light';
});
const [customTheme, setCustomTheme] = useState<Partial<Theme>>({});
// 合并主题
const theme: Theme = {
...themes[mode],
...customTheme,
};
// 应用主题到 CSS 变量
useEffect(() => {
const root = document.documentElement;
// 设置主题模式
root.setAttribute('data-theme', mode);
// 设置 CSS 变量
Object.entries(theme.colors).forEach(([key, value]) => {
root.style.setProperty(
`--color-${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`,
value
);
});
Object.entries(theme.spacing).forEach(([key, value]) => {
root.style.setProperty(`--spacing-${key}`, value);
});
Object.entries(theme.typography).forEach(([key, value]) => {
if (typeof value === 'object') {
Object.entries(value).forEach(([subKey, subValue]) => {
root.style.setProperty(`--${key}-${subKey}`, subValue);
});
} else {
root.style.setProperty(`--${key}`, value);
}
});
Object.entries(theme.borderRadius).forEach(([key, value]) => {
root.style.setProperty(`--border-radius-${key}`, value);
});
// 保存到 localStorage
localStorage.setItem('theme-mode', mode);
}, [mode, theme]);
const handleSetMode = (newMode: ThemeMode) => {
setMode(newMode);
};
const handleSetCustomTheme = (partial: Partial<Theme>) => {
setCustomTheme(prev => ({
...prev,
...partial,
}));
};
return (
<ThemeContext.Provider
value={{
mode,
theme,
setMode: handleSetMode,
setCustomTheme: handleSetCustomTheme,
}}
>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
}
主题定义
// theme/themes.ts
export type ThemeMode = 'light' | 'dark' | 'highContrast';
export interface Theme {
colors: {
primary: string;
primaryHover: string;
secondary: string;
success: string;
warning: string;
error: string;
text: string;
textSecondary: string;
border: string;
bg: string;
bgSecondary: string;
bgHover: string;
};
spacing: {
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
};
typography: {
fontFamily: string;
fontSize: {
xs: string;
sm: string;
md: string;
lg: string;
xl: string;
};
};
borderRadius: {
sm: string;
md: string;
lg: string;
};
}
export const themes: Record<ThemeMode, Theme> = {
light: {
colors: {
primary: '#1890ff',
primaryHover: '#40a9ff',
secondary: '#f0f0f0',
success: '#52c41a',
warning: '#faad14',
error: '#f5222d',
text: '#262626',
textSecondary: '#8c8c8c',
border: '#d9d9d9',
bg: '#ffffff',
bgSecondary: '#fafafa',
bgHover: '#f5f5f5',
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
},
typography: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto',
fontSize: {
xs: '12px',
sm: '14px',
md: '16px',
lg: '18px',
xl: '20px',
},
},
borderRadius: {
sm: '2px',
md: '4px',
lg: '8px',
},
},
dark: {
colors: {
primary: '#177ddc',
primaryHover: '#095cb5',
secondary: '#262626',
success: '#49aa19',
warning: '#d89614',
error: '#d32029',
text: '#e8e8e8',
textSecondary: '#a6a6a6',
border: '#434343',
bg: '#141414',
bgSecondary: '#1f1f1f',
bgHover: '#262626',
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
},
typography: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto',
fontSize: {
xs: '12px',
sm: '14px',
md: '16px',
lg: '18px',
xl: '20px',
},
},
borderRadius: {
sm: '2px',
md: '4px',
lg: '8px',
},
},
highContrast: {
colors: {
primary: '#0000ff',
primaryHover: '#0000cc',
secondary: '#d9d9d9',
success: '#008000',
warning: '#ff8c00',
error: '#ff0000',
text: '#000000',
textSecondary: '#666666',
border: '#000000',
bg: '#ffffff',
bgSecondary: '#f0f0f0',
bgHover: '#e0e0e0',
},
spacing: {
xs: '4px',
sm: '8px',
md: '16px',
lg: '24px',
xl: '32px',
},
typography: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto',
fontSize: {
xs: '13px', // 稍大一点,提高可读性
sm: '15px',
md: '17px',
lg: '19px',
xl: '21px',
},
},
borderRadius: {
sm: '0px', // 高对比模式用直角
md: '0px',
lg: '0px',
},
},
};
避免闪烁的内联脚本
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>App</title>
<!-- 主题初始化脚本 -->
<script>
(function() {
// 读取保存的主题
const savedTheme = localStorage.getItem('theme-mode') || 'light';
// 立即设置主题属性
document.documentElement.setAttribute('data-theme', savedTheme);
// 如果是暗黑模式,设置背景色避免白屏
if (savedTheme === 'dark') {
document.documentElement.style.backgroundColor = '#141414';
}
})();
</script>
</head>
<body>
<div id="root"></div>
</body>
</html>
这份文档涵盖了系统级功能的三个重要方面,包含了权限管理、国际化和主题切换的详细实现。所有内容都基于实际项目经验,提供了完整的代码示例和面试技巧。