首页
/ Backstage 主题定制完全指南:从创建自定义 Theme 到替换 Logo 与图标

Backstage 主题定制完全指南:从创建自定义 Theme 到替换 Logo 与图标

2026-09-10 23:07:03作者:卓炯娓

导读

本文围绕 Backstage 官方教程《Customize your App's theme》展开,系统地讲解如何在新前端系统(new frontend system)下为你的 Backstage 应用定制主题外观:包括基于内置亮色/暗色主题派生自定义主题、通过 ThemeBlueprint 把主题注册为前端扩展、定制排版(Typography)与自定义字体、覆盖 Backstage 与 Material UI 组件样式、替换 Logo,以及通过 IconBundleBlueprint 覆盖/新增应用图标。读完本文,你将掌握一套完整、可直接落地的 Backstage 品牌化(white-labeling)方案,并理解这些 API 背后的源码实现原理。

说明:本文针对默认使用新前端系统的新建 Backstage 应用。若你的应用仍在使用旧前端系统,请参考仓库中的旧版指南 customize-theme--old.md

一、主题体系与核心概念

Backstage 自带一套默认主题,包含亮色(light)与暗色(dark)两种变体。这套主题由 @backstage/theme 包提供,该包同时导出了大量用于定制默认主题、甚至从零创建全新主题的工具函数。

在仓库中,内置主题的定义位于 packages/theme/src/unified/themes.ts,可以看到它们本质上就是 createUnifiedTheme 的直接调用:

export const themes = {
  light: createUnifiedTheme({ palette: palettes.light }),
  dark: createUnifiedTheme({ palette: palettes.dark }),
};

也就是说,官方内置的亮/暗主题并不是什么"魔法",而是 createUnifiedTheme 结合 palettes.light / palettes.dark 两个预设调色板的产物。理解这一点后,你就明白:自定义主题本质上就是换个调色板、换套字体、换组页面主题,再交给同一个工厂函数去生成。

UnifiedTheme:一份主题,同时支撑 MUI v4 与 v5

从源码 packages/theme/src/unified/UnifiedTheme.tsx 可以看出,createUnifiedTheme 返回的是一个 UnifiedThemeHolder 实例,它内部用 Map 同时保存了 Material UI v4 与 v5 两套主题对象:

export class UnifiedThemeHolder implements UnifiedTheme {
  #themes = new Map<SupportedVersions, SupportedThemes>();
  getTheme(version: SupportedVersions): SupportedThemes | undefined {
    return this.#themes.get(version);
  }
}

其核心实现(packages/theme/src/unified/UnifiedTheme.tsx)会:

  1. 调用 createBaseThemeOptions 生成基础主题选项;
  2. defaultComponentThemes(MUI v5 默认组件主题)与用户传入的 components 合并;
  3. 用 MUI v5 的 createTheme 生成 v5 主题;
  4. 通过 transformV5ComponentThemesToV4 把 v5 组件主题转换回 v4,再配合 MUI v4 的 createTheme 生成 v4 主题。

这意味着:你在自定义主题里写的 components 覆盖,会自动同时作用于 MUI v4 与 v5 两套组件体系,这也是 Backstage 生态从 v4 向 v5 平滑过渡的基础设施。

二、创建自定义主题:从内置主题派生

@backstage/theme 包导出的 createUnifiedTheme 函数是创建新主题最便捷的入口。你可以用它覆盖默认主题中的基础参数,例如调色板与字体。

官方建议在 packages/app/src 下新建一个 theme 目录存放主题文件,保持工程结构清晰。

下面是一个基于内置亮色主题派生的自定义主题示例,文件位于 packages/app/src/theme/myTheme.ts

import {
  createBaseThemeOptions,
  createUnifiedTheme,
  palettes,
} from '@backstage/theme';

export const myTheme = createUnifiedTheme({
  ...createBaseThemeOptions({
    palette: palettes.light,
  }),
  fontFamily: 'Comic Sans MS',
  defaultPageTheme: 'home',
});

几个关键点:

  • createBaseThemeOptions 负责把 palettefontFamilyhtmlFontSizedefaultPageThemepageThemetypography 整理成 MUI 可消费的主题选项;
  • defaultPageTheme: 'home' 指定默认页面主题(决定每个页面的头部渐变与背景形状),默认值就是 'home'(见 packages/theme/src/base/createBaseThemeOptions.ts 中的 DEFAULT_PAGE_THEME);
  • 若传入的 defaultPageTheme 不在 pageTheme 中,createBaseThemeOptions 会直接抛错(packages/theme/src/base/createBaseThemeOptions.ts)。

如果你不想基于内置主题派生,也可以从零构造一个满足 BackstageTheme 类型(@backstage/theme 导出)的完整主题对象,此时可参考 Material UI 官方的 Theming 文档了解底层机制。

三、安装自定义主题:ThemeBlueprint 扩展机制

在新前端系统中,主题是以扩展(extension)的形式安装的。具体流程是:用 @backstage/plugin-app-react 提供的 ThemeBlueprint 创建主题扩展 → 把扩展打包进一个前端模块(frontend module)→ 通过 createApp 传入应用。

首先安装所需依赖(在 Backstage 根目录执行):

yarn --cwd packages/app add @backstage/frontend-plugin-api @backstage/plugin-app-react

然后在 packages/app/src/App.tsx 中创建主题扩展并安装:

import { createApp } from '@backstage/frontend-defaults';
import { createFrontendModule } from '@backstage/frontend-plugin-api';
import { ThemeBlueprint } from '@backstage/plugin-app-react';
import { UnifiedThemeProvider } from '@backstage/theme';
import LightIcon from '@material-ui/icons/WbSunny';
import { myTheme } from './theme/myTheme';

const myThemeExtension = ThemeBlueprint.make({
  name: 'my-theme',
  params: {
    theme: {
      id: 'my-theme',
      title: 'My Custom Theme',
      variant: 'light',
      icon: <LightIcon />,
      Provider: ({ children }) => (
        <UnifiedThemeProvider theme={myTheme} children={children} />
      ),
    },
  },
});

const app = createApp({
  features: [
    createFrontendModule({
      pluginId: 'app',
      extensions: [myThemeExtension],
    }),
  ],
});

export default app.createRoot();

从源码层面看,ThemeBlueprint 定义于 plugins/app-react/src/blueprints/ThemeBlueprint.ts,其关键特征包括:

  • kind: 'theme':扩展种类为 theme
  • attachTo: { id: 'api:app/app-theme', input: 'themes' }:主题扩展会挂载到应用主题 API 的 themes 输入上,这正是它能够与内置亮/暗主题并列出现的原因;
  • 通过 themeDataRef 输出一个 AppTheme 对象,包含 idtitlevarianticonProvider 等字段。

Provider 使用 UnifiedThemeProvider(见 packages/theme/src/unified/UnifiedThemeProvider.tsx),它会根据当前应用运行的 MUI 版本(v4 或 v5)从 UnifiedTheme 中取出对应版本的主题对象提供给组件树。

禁用内置主题

你的自定义主题扩展会与内置亮/暗主题同时出现在主题切换器中。如果希望自定义主题替换默认主题,可以在 app-config.yaml 中禁用它们:

app:
  extensions:
    - theme:app/light: false
    - theme:app/dark: false

注意这里的 theme:app/lighttheme:app/dark 是内置扩展的完整 ID(kind 为 theme,插件为 app,扩展名为 light/dark),禁用后应用将只保留你注册的主题。

四、完整示例:一套完整的自定义调色板与页面主题

官方教程给出了一个相当完整的自定义主题示例,它同时覆盖了调色板(palette)、页面主题(pageTheme)与字体。文件位于 packages/app/src/theme/myTheme.ts

import {
  createBaseThemeOptions,
  createUnifiedTheme,
  genPageTheme,
  palettes,
  shapes,
} from '@backstage/theme';

export const myTheme = createUnifiedTheme({
  ...createBaseThemeOptions({
    palette: {
      ...palettes.light,
      primary: {
        main: '#343b58',
      },
      secondary: {
        main: '#565a6e',
      },
      error: {
        main: '#8c4351',
      },
      warning: {
        main: '#8f5e15',
      },
      info: {
        main: '#34548a',
      },
      success: {
        main: '#485e30',
      },
      background: {
        default: '#d5d6db',
        paper: '#d5d6db',
      },
      banner: {
        info: '#34548a',
        error: '#8c4351',
        text: '#343b58',
        link: '#565a6e',
      },
      errorBackground: '#8c4351',
      warningBackground: '#8f5e15',
      infoBackground: '#343b58',
      navigation: {
        background: '#343b58',
        indicator: '#8f5e15',
        color: '#d5d6db',
        selectedColor: '#ffffff',
      },
    },
  }),
  defaultPageTheme: 'home',
  fontFamily: 'Comic Sans MS',
  /* below drives the header colors */
  pageTheme: {
    home: genPageTheme({ colors: ['#8c4351', '#343b58'], shape: shapes.wave }),
    documentation: genPageTheme({
      colors: ['#8c4351', '#343b58'],
      shape: shapes.wave2,
    }),
    tool: genPageTheme({ colors: ['#8c4351', '#343b58'], shape: shapes.round }),
    service: genPageTheme({
      colors: ['#8c4351', '#343b58'],
      shape: shapes.wave,
    }),
    website: genPageTheme({
      colors: ['#8c4351', '#343b58'],
      shape: shapes.wave,
    }),
    library: genPageTheme({
      colors: ['#8c4351', '#343b58'],
      shape: shapes.wave,
    }),
    other: genPageTheme({ colors: ['#8c4351', '#343b58'], shape: shapes.wave }),
    app: genPageTheme({ colors: ['#8c4351', '#343b58'], shape: shapes.wave }),
    apis: genPageTheme({ colors: ['#8c4351', '#343b58'], shape: shapes.wave }),
  },
});

这个示例非常有参考价值,它揭示了主题系统几个重要层次:

  1. 调色板(palette)...palettes.light 先展开内置亮色调色板,再逐项覆盖 primarysecondaryerrorwarninginfosuccessbackgroundbannernavigation 等语义色。内置调色板完整定义可查看 packages/theme/src/base/palettes.ts,其中除了上述字段,还包含 status(ok/warning/error/running/pending/aborted)、burstsbordertextSubtlelinkgold 等大量语义 token,均可按需覆盖。
  2. 页面主题(pageTheme)genPageTheme({ colors, shape }) 用于生成每个页面类型(home、documentation、tool、service、website、library、other、app、apis、card)的头部背景。其实现位于 packages/theme/src/base/pageTheme.tscolors 会被拼成 90 度线性渐变,shape 是预置的 SVG 形状(wave、wave2、round、square),二者合成 backgroundImage,并默认使用白色前景字(可通过 options.fontColor 覆盖)。内置的形状定义与配色变体(colorVariants)也在该文件中,例如内置 home 页面主题使用 teal 配色与 wave 形状。
  3. 默认页面主题defaultPageTheme: 'home' 决定所有未单独指定页面主题的页面使用哪一个主题,createBaseThemeOptions 还会生成 getPageTheme 函数,让任何页面都能按 themeId 查找到对应主题(未命中时回退到默认页面主题,见 packages/theme/src/base/createBaseThemeOptions.ts)。

更完整、包含 Backstage 与 Material UI 组件覆盖的示例可以参考 Backstage demo 站点中的 Aperture 主题。

五、自定义排版(Typography)

createBaseThemeOptions 也支持传入完整的 typography 配置,用来定制各级标题与正文字体。

5.1 完整覆盖排版

下面的示例基于简化主题定制全部标题样式,文件位于 packages/app/src/theme/myTheme.ts

import {
  createBaseThemeOptions,
  createUnifiedTheme,
  palettes,
} from '@backstage/theme';

export const myTheme = createUnifiedTheme({
  ...createBaseThemeOptions({
    palette: palettes.light,
    typography: {
      htmlFontSize: 16,
      fontFamily: 'Arial, sans-serif',
      h1: {
        fontSize: 54,
        fontWeight: 700,
        marginBottom: 10,
      },
      h2: {
        fontSize: 40,
        fontWeight: 700,
        marginBottom: 8,
      },
      h3: {
        fontSize: 32,
        fontWeight: 700,
        marginBottom: 6,
      },
      h4: {
        fontWeight: 700,
        fontSize: 28,
        marginBottom: 6,
      },
      h5: {
        fontWeight: 700,
        fontSize: 24,
        marginBottom: 4,
      },
      h6: {
        fontWeight: 700,
        fontSize: 20,
        marginBottom: 2,
      },
    },
    defaultPageTheme: 'home',
  }),
});

可以看到,这里 h1~h6 的默认值与 defaultTypographypackages/theme/src/base/createBaseThemeOptions.ts)完全一致——也就是说,默认排版配置就是上面这套参数,其中 htmlFontSize 默认 16,默认字体族为 "Helvetica Neue", Helvetica, Roboto, Arial, sans-serif

5.2 只覆盖部分排版

如果只想覆盖某一级标题(例如只改 h1),可以先用 defaultTypography 展开默认值再局部覆盖,避免丢失其余标题的样式:

import {
  createBaseThemeOptions,
  createUnifiedTheme,
  defaultTypography,
  palettes,
} from '@backstage/theme';

export const myTheme = createUnifiedTheme({
  ...createBaseThemeOptions({
    palette: palettes.light,
    typography: {
      ...defaultTypography,
      htmlFontSize: 16,
      fontFamily: 'Roboto, sans-serif',
      h1: {
        fontSize: 72,
        fontWeight: 700,
        marginBottom: 10,
      },
    },
    defaultPageTheme: 'home',
  }),
});

一个小细节:createBaseThemeOptions 内部会直接修改 defaultTypography 对象的 htmlFontSizefontFamilypackages/theme/src/base/createBaseThemeOptions.ts),因此显式展开 ...defaultTypography 再覆盖,能保证你的修改精确生效、且不会遗漏默认字段。

六、自定义字体(Custom Fonts)

要使用自定义字体,首先需要把字体文件放到可被导入的位置。官方建议在应用 src 目录下创建 assets/fonts 目录存放字体文件。

然后按照 Material UI Typography 的 @font-face 语法声明字体,再通过 MuiCssBaseline 组件下的 styleOverrides 把字体挂载到 @font-face 数组中:

import MyCustomFont from '../assets/fonts/My-Custom-Font.woff2';

const myCustomFont = {
  fontFamily: 'My-Custom-Font',
  fontStyle: 'normal',
  fontDisplay: 'swap',
  fontWeight: 300,
  src: `
    local('My-Custom-Font'),
    url(${MyCustomFont}) format('woff2'),
  `,
};

export const myTheme = createUnifiedTheme({
  fontFamily: 'My-Custom-Font',
  palette: palettes.light,
  components: {
    MuiCssBaseline: {
      styleOverrides: {
        '@font-face': [myCustomFont],
      },
    },
  },
});

注意这里把 fontFamily 同时设在了主题顶层(作用于正文),并通过 components.MuiCssBaseline.styleOverrides['@font-face'] 注入字体定义——这也是 MUI v5 组件覆盖标准用法的一个典型示例。

多字体混用

如果希望正文与标题使用不同字体,可以这样组织:顶层 fontFamily 控制正文字体,typography 里再为各级标题单独指定 fontFamily

import MyCustomFont from '../assets/fonts/My-Custom-Font.woff2';
import myAwesomeFont from '../assets/fonts/My-Awesome-Font.woff2';

const myCustomFont = {
  fontFamily: 'My-Custom-Font',
  fontStyle: 'normal',
  fontDisplay: 'swap',
  fontWeight: 300,
  src: `
    local('My-Custom-Font'),
    url(${MyCustomFont}) format('woff2'),
  `,
};

const myAwesomeFont = {
  fontFamily: 'My-Awesome-Font',
  fontStyle: 'normal',
  fontDisplay: 'swap',
  fontWeight: 300,
  src: `
    local('My-Awesome-Font'),
    url(${myAwesomeFont}) format('woff2'),
  `,
};

export const myTheme = createUnifiedTheme({
  fontFamily: 'My-Custom-Font',
  components: {
    MuiCssBaseline: {
      styleOverrides: {
        '@font-face': [myCustomFont, myAwesomeFont],
      },
    },
  },
  ...createBaseThemeOptions({
    palette: palettes.light,
    typography: {
      ...defaultTypography,
      htmlFontSize: 16,
      fontFamily: 'My-Custom-Font',
      h1: {
        fontSize: 72,
        fontWeight: 700,
        marginBottom: 10,
        fontFamily: 'My-Awesome-Font',
      },
    },
    defaultPageTheme: 'home',
  }),
});

这里两个 @font-face 声明都被放进数组,正文使用 My-Custom-Fonth1 标题则使用 My-Awesome-Font

七、覆盖 Backstage 与 Material UI 组件样式

自定义主题本质上是给组件的 CSS 规则提供不同的取值。以 Backstage 头部组件为例,其样式可能长这样:

const useStyles = makeStyles<BackstageTheme>(
  theme => ({
    header: {
      padding: theme.spacing(3),
      boxShadow: '0 0 8px 3px rgba(20, 20, 20, 0.3)',
      backgroundImage: theme.page.backgroundImage,
    },
  }),
  { name: 'BackstageHeader' },
);

这里 padding 取自 theme.spacingbackgroundImage 取自 theme.page.backgroundImage,因此通过自定义主题修改 spacing 或 pageTheme 就能影响这些属性。但 boxShadow 是硬编码值,不引用任何主题变量——仅靠主题无法改变它,也无法新增主题中不存在的 CSS 规则(例如 margin)。这类情况就需要创建组件覆盖(override)。

通过 createUnifiedThemecomponents 字段即可实现:

import {
  createBaseThemeOptions,
  createUnifiedTheme,
  palettes,
} from '@backstage/theme';

export const myTheme = createUnifiedTheme({
  ...createBaseThemeOptions({
    palette: palettes.light,
  }),
  fontFamily: 'Comic Sans MS',
  defaultPageTheme: 'home',
  components: {
    BackstageHeader: {
      styleOverrides: {
        header: ({ theme }) => ({
          width: 'auto',
          margin: '20px',
          boxShadow: 'none',
          borderBottom: `4px solid ${theme.palette.primary.main}`,
        }),
      },
    },
  },
});

styleOverrides.header 接收一个回调,参数 theme 即为当前主题对象,因此你可以引用 theme.palette.primary.main 等任意主题 token 来构建动态样式。同时,createUnifiedTheme 内部会把 defaultComponentThemes 与你的 components 合并(见 packages/theme/src/unified/UnifiedTheme.tsx),所以你只需写增量覆盖即可。

八、自定义 Logo

除了主题,你还可以替换站点最左上角显示的 Logo。在应用前端中找到 src/components/Root/ 目录,里面有两个组件:

  • LogoFull.tsx:侧边栏展开时使用的大 Logo;
  • LogoIcon.tsx:侧边栏收起时使用的小 Logo。

替换图片最简单的方式是直接在这两个组件中用 SVG 定义替换原有代码。也可以导入 PNG 等其他 web 图片格式:把新图片放到类似 src/components/Root/logo/my-company-logo.png 的子目录,然后:

import MyCustomLogoFull from './logo/my-company-logo.png';

const LogoFull = () => {
  return <img src={MyCustomLogoFull} />;
};

九、自定义与新增图标

9.1 覆盖内置图标(Custom Icons)

@backstage/plugin-app-react 提供了 IconBundleBlueprint,可创建扩展来覆盖内置图标。例如把 github 图标替换为自定义的 ExampleIcon

import { createApp } from '@backstage/frontend-defaults';
import { createFrontendModule } from '@backstage/frontend-plugin-api';
import { IconBundleBlueprint } from '@backstage/plugin-app-react';
import { ExampleIcon } from './assets/customIcons';

const customIconBundle = IconBundleBlueprint.make({
  name: 'custom-icons',
  params: {
    icons: {
      github: ExampleIcon,
    },
  },
});

const app = createApp({
  features: [
    createFrontendModule({
      pluginId: 'app',
      extensions: [customIconBundle],
    }),
  ],
});

export default app.createRoot();

IconBundleBlueprintThemeBlueprint 同位于 plugins/app-react/src/blueprints,遵循相同的扩展蓝图机制。

9.2 新增图标(Adding Icons)

可以注册额外的图标,供实体链接(entity links)等场景使用。例如注册一个 alert 图标:

import { createApp } from '@backstage/frontend-defaults';
import { createFrontendModule } from '@backstage/frontend-plugin-api';
import AlarmIcon from '@material-ui/icons/Alarm';
import { IconBundleBlueprint } from '@backstage/plugin-app-react';

const extraIcons = IconBundleBlueprint.make({
  name: 'extra-icons',
  params: {
    icons: {
      alert: AlarmIcon,
    },
  },
});

const app = createApp({
  features: [
    createFrontendModule({
      pluginId: 'app',
      extensions: [extraIcons],
    }),
  ],
});

export default app.createRoot();

然后在实体定义的 links 中通过 icon 字段引用它:

apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: artist-lookup
  description: Artist Lookup
  links:
    - url: https://example.com/alert
      title: Alerts
      icon: alert

效果如下图所示:

在实体链接中使用自定义 alert 图标的运行效果

另一种使用方式是从 AppContext 中获取图标。如果你需要在多个位置复用某个图标,可以这样做:

import { useApp } from '@backstage/core-plugin-api';

const app = useApp();
const alertIcon = app.getSystemIcon('alert');

getSystemIcon 由 App 上下文实现(见 packages/core-plugin-api/src/app/useApp.tsx),它会按名称从已注册的系统图标集合中取出对应的 IconComponent

注意:如果请求的图标既不是默认图标、也不是你注册的自定义图标,系统会回退到 Material UI 的 LanguageIcon

十、自定义侧边栏与首页

侧边栏

在新前端系统中,侧边栏由内置的 app/nav 扩展管理。你可以创建 NavContentBlueprint 扩展来自定义它。关于如何创建带子菜单、自定义分组的侧边栏布局,详见 08-migrating.md 文档中的 "App root sidebar" 一节。

首页

除了自定义主题与 Logo,你还可以自定义应用的首页,完整指南见 homepage.md

十一、关于 Material UI v5 迁移

Backstage 现已支持 Material UI v5。如果你的应用仍在使用 v4,可以参考迁移指南 migrate-to-mui5.md 开始升级。正如前文所述,UnifiedTheme 的设计使得同一份主题配置可以同时产出 v4 与 v5 主题对象,这为渐进式迁移提供了坚实保障。

总结

Backstage 的主题定制是一条"分层覆盖"的路径:先用 createUnifiedTheme + createBaseThemeOptions 从调色板、字体、排版、页面主题四个维度定义主题本体;再用 ThemeBlueprint 把主题注册为新前端系统的扩展;随后按需通过 components 覆盖组件样式、替换 Logo、用 IconBundleBlueprint 覆盖或新增图标,最终在 app-config.yaml 中决定内置主题的去留。所有自定义代码都集中在 packages/app/src/theme 与应用根组件中,结构清晰、易于维护,且全部基于 @backstage/theme@backstage/plugin-app-react 的公开 API,可放心长期使用。

关键文件索引

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.16 K
2.78 K
kernelkernel
deepin linux kernel
C
34
18
docsdocs
暂无描述
Markdown
904
5.83 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
932
1.86 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
862
1.36 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.95 K
1.03 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.38 K
1.47 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
535
606
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
549
398
leetcodeleetcode
🔥LeetCode solutions in any programming language | 多种编程语言实现 LeetCode、《剑指 Offer(第 2 版)》、《程序员面试金典(第 6 版)》题解
Markdown
77
23