返回笔记首页

系统级功能 - 面试指南

主题配置

5.1 权限系统设计

简历描述

项目经验 - 企业级权限系统

plain
设计并实现基于 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 和权限类型。

前端收到消息后,会检查是否是当前用户。如果是,就重新加载权限数据,然后做几件事:一是更新菜单和按钮的显示状态;二是检查当前页面是否还有权限访问,没有就跳转到首页;三是清空已有的请求缓存,避免用缓存数据绕过权限检查。

整个过程用户是有感知的,会弹一个提示'您的权限已变更,部分功能可能受到影响'。这样既保证了安全,又不会让用户摸不着头脑。"

技术实现

权限管理核心

typescript
// 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,
      }),
    }
  )
);

路由权限守卫

typescript
// 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();
}

权限指令和组件

typescript
// 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);
      }
    }
  },
};
typescript
// 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}</>;
}

动态菜单生成

typescript
// 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 国际化方案

简历描述

项目经验 - 国际化多语言支持

plain
实现支持 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 标签,告诉搜索引擎不同语言版本的关系:

html
<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,浏览器会根据文字方向自动调整。比如:

css
/* 原来 */
margin-left: 16px;

/* 改成 */
margin-inline-start: 16px;

Flexbox 和 Grid 也要调整 direction 属性。我在根元素上设置 dir="rtl",所有子元素自动适配。

图标和图片也要镜像,比如箭头要反向。我做了一个 flip 工具函数,RTL 语言会自动翻转图标:

css
.icon {
  transform: scaleX(var(--flip, 1));
}

[dir="rtl"] .icon {
  --flip: -1;
}

这套方案让我们的应用能支持 RTL 语言,只需要改很少的代码。"

亮点:翻译管理平台

"为了方便非技术人员管理翻译,我做了一个翻译管理平台。

左侧是翻译 key 的树形列表,按命名空间分组。右侧是翻译编辑器,可以同时看到所有语言的翻译,方便对比和修改。

还有个批量翻译功能,可以调用机器翻译 API,一键翻译所有缺失的内容。翻译后会标记为'机器翻译',提醒人工校对。

改完后可以导出 JSON 文件,提交到代码仓库。也可以直接在平台上发布,通过 API 推送到 CDN,前端动态加载最新的翻译。

这个平台大大降低了翻译的门槛,产品和运营都能自己管理多语言内容,不用每次都找开发。"

技术实现

i18n 配置

typescript
// 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;

翻译提取脚本

javascript
// 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

typescript
// 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 主题切换系统

简历描述

项目经验 - 动态主题系统

plain
实现支持自定义主题的动态主题系统,提供暗黑、明亮等 5+ 内置主题
- 基于 CSS Variables 设计主题系统,支持运行时切换,响应时间 < 50ms
- 通过 CSS-in-JS 和主题提供者实现组件级主题定制,覆盖率达 100%
- 实现主题持久化和预加载,避免页面闪烁,用户体验提升 40%

SOP 标准回答

面试官问:你们的主题切换是怎么实现的?

"我们项目支持明亮、暗黑、高对比等多个主题,还允许用户自定义主题颜色。技术实现上用的是 CSS Variables。

核心思路是把所有的颜色、字体、间距都定义成 CSS 变量,组件样式引用这些变量。切换主题时,只需要更新 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 值:

typescript
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 变量覆盖默认颜色:

typescript
<ConfigProvider
  theme={{
    token: {
      colorPrimary: 'var(--color-primary)',
      colorBgContainer: 'var(--color-bg)',
    },
  }}
>
  <App />
</ConfigProvider>

ECharts 需要手动设置主题。我监听主题变化,重新设置 ECharts 实例的主题:

typescript
useEffect(() => {
  if (chartInstance) {
    chartInstance.setOption({
      backgroundColor: theme.colors.bg,
      color: theme.colors.palette,
    });
  }
}, [theme, chartInstance]);

还有些组件没有主题 API,只能通过 CSS 覆盖样式。我用 CSS 变量写了一套覆盖样式,保证和全局主题一致。"

难点3:暗黑模式的图片处理

"暗黑模式下,有些图片看起来不协调,尤其是白底的图标和插图。

我做了几种处理:

  1. 对于图标,使用 SVG,颜色用 CSS 变量,自动适配主题
  2. 对于插图,准备明暗两套,根据主题切换
  3. 对于无法替换的图片,用 CSS 滤镜调整:
css
[data-theme="dark"] img {
  filter: brightness(0.8) contrast(1.2);
}

还有个细节是 Logo。白底 Logo 在暗黑模式下不好看,我用 CSS mix-blend-mode 让 Logo 适应背景:

css
.logo {
  mix-blend-mode: difference;
}

这样 Logo 会自动反色,在任何背景下都清晰可见。"

亮点:主题可视化编辑器

"为了方便设计师和产品调整主题,我做了一个可视化的主题编辑器。

界面分三部分。左侧是主题配置面板,可以选择预设主题或自定义颜色、字体、圆角等参数。中间是实时预览,会渲染常用的组件,比如按钮、表单、表格,实时看到主题效果。右侧是代码面板,自动生成主题配置的 JSON 和 CSS 代码,可以直接复制使用。

还有个对比功能,可以同时预览多个主题,方便选择最佳方案。

编辑好的主题可以导出为 JSON 文件,也可以上传到服务器,其他用户可以导入使用。我们内部还建了一个主题市场,大家可以分享自己设计的主题。"

技术实现

主题系统核心

typescript
// 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;
}

主题定义

typescript
// 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',
    },
  },
};

避免闪烁的内联脚本

html
<!-- 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>

这份文档涵盖了系统级功能的三个重要方面,包含了权限管理、国际化和主题切换的详细实现。所有内容都基于实际项目经验,提供了完整的代码示例和面试技巧。