likes
comments
collection
share

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

作者站长头像
站长
· 阅读数 2

导航

本章节示例代码仓:Github

经过上半篇的探索,我们完成了所有实现组件库的样式方案的前期准备,下半篇我们将正式开始实践样式方案的实现、集成、优化、验证,逐步完善 @openxui/styles 模块。

📦styles
 ┣ 📂dist                   # 产物目录
 ┣ 📂node_modules           # 依赖目录
 ┣ 📂src
 ┃ ┃ 
 ┃ ┃ # 第一部分:UnoCSS 部分,运行在 Node.js 环境
 ┃ ┃ 
 ┃ ┣ 📂unocss
 ┃ ┃ ┣ 📂utils              # 生成 UnoCSS 预设需要的工具类
 ┃ ┃ ┃ ┣ 📜index.ts
 ┃ ┃ ┃ ┣ 📜shortcuts.ts
 ┃ ┃ ┃ ┗ 📜toSafeList.ts
 ┃ ┃ ┣ 📂button             # button 组件的 UnoCSS 预设
 ┃ ┃ ┃ ┣ 📜index.ts
 ┃ ┃ ┃ ┣ 📜rules.ts
 ┃ ┃ ┃ ┗ 📜shortcuts.ts
 ┃ ┃ ┣ 📜base.ts            # 组件库基础 UnoCSS 预设        
 ┃ ┃ ┣ 📜theme.ts           # 主题 UnoCSS 预设
 ┃ ┃ ┣ 📜...                # 更多组件的 UnoCSS 预设
 ┃ ┃ ┗ 📜index.ts                
 ┃ ┣ 📜unoPreset.ts         # 实现组件库专用的 UnoCSS 预设:openxuiPreset
 ┃ ┃ 
 ┃ ┃ # 第二部分:主题部分,运行在混合环境(SSR 场景下的 Node.js 环境或者浏览器运行环境)
 ┃ ┃ 
 ┃ ┣ 📂theme                # Vue 插件,实现主题的全局切换
 ┃ ┃ ┣ 📂presets            # 主题预设
 ┃ ┃ ┃ ┣ 📜index.ts
 ┃ ┃ ┃ ┗ 📜tiny.ts          # tiny 的主题预设
 ┃ ┃ ┗ 📜index.ts 
 ┃ ┣ 📂utils                # 实现样式生成相关的工具方法
 ┃ ┃ ┣ 📜colors.ts
 ┃ ┃ ┣ 📜cssVars.ts
 ┃ ┃ ┣ 📜index.ts
 ┃ ┃ ┗ 📜toTheme.ts
 ┃ ┣ 📂vars                 # 定义每个组件与模块的主题变量
 ┃ ┃ ┣ 📜button.ts          # 按钮的主题变量
 ┃ ┃ ┣ 📜theme.ts           # 基础主题变量
 ┃ ┃ ┣ 📜...                # 更多组件的主题变量
 ┃ ┃ ┗ 📜index.ts
 ┃ ┗ 📜index.ts

 ┣ 📜package.json
 ┗ 📜vite.config.ts

@openxui/stylespackage.json 文件可以完全参考其他子模块,需要注意一下依赖关系(实际操作时请去除注释):

// packages/styles/package.json
{
  // 其他相似配置省略...

  "peerDependencies": {
    "vue": ">=3.0.0",
    "unocss": ">=0.54.1"
  },
  "dependencies": {
    "@openxui/shared": "workspace:^"
  }
}

执行 pnpm i 更新依赖后,我们进入正题。

定义主题变量

@openxui/styles 包中的 src/vars 目录负责组件库主题变量的定义。其中 src/vars/theme.ts 中存放最基础的主题变量的定义,为了方便书写,这里隐去了命名空间前缀 op-,后续由工具方法进行统一添加。为了控制演示篇幅,这里只定义了颜色与边距相关的主题变量,读者可以按照同样的方式,尝试定义更多的内容:

// packages/styles/src/vars/theme.ts
/** 基础颜色主题变量 */
export const themeColors = {
  'color-primary': '#c7000b',
  'color-success': '#50d4ab',
  'color-warning': '#fbb175',
  'color-danger': '#f66f6a',
  'color-info': '#526ecc',
  'color-transparent': 'transparent',
  'color-black': '#000',
  'color-white': '#fff',

  // 背景色
  'color-page': '#f5f5f6',
  'color-card': '#fff',

  // 文字主色
  'color-header': '#252b3a',
  'color-regular': '#575d6c',
  'color-secondary': '#8a8e99',
  'color-placeholder': '#abb0b8',
  'color-disabled': '#c0c4cc',
  'color-reverse': '#fff',

  // 边框主色
  'color-bd_darker': '#cdd0d6',
  'color-bd_dark': '#d4d7de',
  'color-bd_base': '#dcdfe6',
  'color-bd_light': '#dfe1e6',
  'color-bd_lighter': '#ebeff5',
  'color-bd_lightest': '#f2f6fc',
};

/**
 * 需要生成色阶的颜色
 *
 * 例如 color-primary 将会生成 color-primary-light-[1-9] 以及 color-primary-dark-[1-9] 系列浅色与深色的变量。
 */
export const themeColorLevelsEnabledKeys: (keyof typeof themeColors)[] = [
  'color-primary',
  'color-success',
  'color-warning',
  'color-danger',
  'color-info',
];

/** 基础边距主题变量 */
export const themeSpacing = {
  'spacing-xs': '8px',
  'spacing-sm': '12px',
  'spacing-md': '16px',
  'spacing-lg': '24px',
  'spacing-xl': '32px',
};

/** 基础主题变量 */
export const themeVars = {
  ...themeColors,
  ...themeSpacing,
};

/** 基础主题变量类型 */
export type ThemeCssVarsConfig = Partial<typeof themeVars>;

除了最基础的主题变量,每一个组件也将在 src/vars 目录下继续定义各自的主题变量,例如 src/vars/button.tssrc/vars/input.ts 等等。

📦src
 ┣ 📂...
 ┣ 📂vars
 ┃ ┣ 📜theme.ts
 ┃ ┣ 📜button.ts
 ┃ ┣ 📜input.ts
 ┃ ┣ 📜...          # 更多组件的主题变量
 ┃ ┗ 📜index.ts
 ┗ 📜...

src/vars/index.ts 中,我们导出所有组件的主题变量,以及完整的组件库主题样式的类型:

// packages/styles/src/vars/index.ts
import { ThemeCssVarsConfig } from './theme';

// 引入其他组件的主题变量类型
// import { ComponentCssVarConfig } from './other-component';

/** 导出组件库主题样式的整体类型 */
export interface OpenxuiCssVarsConfig extends
  ThemeCssVarsConfig {
  [key: string]: string | undefined;
}

export * from './theme';

// 导出其他组件的主题变量
// export * from './other-component'

这些定义好的主题变量会在后续有以下用途:

  • 转化为 CSS 样式,通过 UnoCSSPreflights 功能注入组件样式中。
  • 转化为 UnoCSSTheme 主题,使 openxuiPreset 预设支持组件库主题相关的原子化 CSS。
  • 在实现运行时的主题切换能力时,提供 TypeScript 类型支持。

样式相关工具方法

我们若要达成上述将 JavaScript 主题变量对象转换为其他格式的目的,必须借助一些工具类。

颜色计算

在设置、转换主题变量的过程中,不可避免地涉及对 CSS 颜色的处理。我们倾向于将其他格式的颜色表示都转换为 RGBA 的形式,以 16 进制颜色 #c7000b 为例:

  • #c7000b 将被转化为 RGBA 对象 const rgba = new RGBA(199, 0, 11, 1)
  • rgba.rgbTxt 将获取 RGB 色值字符串:199,0,11。为什么要实现这种表示方法,我们之后会进行讨论。
  • rgba.rgba 将获取完整的 CSS rgba 形式的颜色表示:rgba(199, 0, 11, 1)

由于篇幅有限,我们实现的实例中只支持了 rgb/rgba 与十六进制颜色的转换,其他更多的颜色表示可以参考以下文章,读者有兴趣的话可以自己实现:

我们创建 src/utils/colors.ts 文件实现相关工具。

// packages/styles/src/utils/colors.ts
/** RGBA 颜色对象 */
interface RGBAColor {
  /** r、g、b、a 值 */
  args: [number, number, number, number];

  /** 获取 rgb 值,例如:255,255,255 */
  get rgbTxt(): string;

  /** 获取 rgba 完整表示,例如:rgba(255,255,255,1) */
  get rgba(): string;
}

/** 给与一个 CSS 表达式,试图将其转化为 RGBA 颜色对象 */
export function toRgba(str: string): RGBAColor | null {
  return hexToRgba(str) ||
    parseCssFunc(str);
}

/** 将 16 进制颜色表达式转换为 RGBA 颜色对象。 */
function hexToRgba(str: string): RGBAColor | null {
  if (str.charAt(0) !== '#') {
    return null;
  }
  if (str.length !== 4 && str.length !== 7) {
    return null;
  }

  let colorStr = str.slice(1);
  if (colorStr.length === 3) {
    colorStr = colorStr[0] + colorStr[0] + colorStr[1] + colorStr[1] + colorStr[2] + colorStr[2];
  }
  const r = parseInt(colorStr.slice(0, 2), 16);
  const g = parseInt(colorStr.slice(2, 4), 16);
  const b = parseInt(colorStr.slice(4, 6), 16);
  return createRgbaColor(r, g, b, 1);
}

/**
 * 暂时只支持 rgb 和 rgba
 * @todo 实现对 hsl 和 hsla 以及其他函数的支持
 */

/** 支持的 css 颜色函数类型 */
const cssColorFunctions = ['rgb', 'rgba'];

/** 将函数形式的 CSS 表达式转换为 RGBA 颜色对象。 */
function parseCssFunc(str: string): RGBAColor | null {
  const match = str.match(/^(.*)\((.+)\)$/i);
  if (!match) {
    return null;
  }

  const [, func, argsTxt] = match;
  if (!cssColorFunctions.includes(func)) {
    return null;
  }

  let argsArr = argsTxt.split(',');
  if (argsArr.length === 1) {
    argsArr = argsTxt.split(' ');
  }
  const args = argsArr.map(parseFloat).filter((item) => item);

  if (func === 'rgb' || func === 'rgba') {
    const [r, g, b, a] = args;
    return createRgbaColor(r, g, b, a || 1);
  }

  // 暂不实现对 hsl 和 hsla 以及其他函数的支持
  return null;
}

/** 给与 r、g、b、a 值,构造一个 RGBA 颜色对象。 */
function createRgbaColor(r: number, g: number, b: number, a: number = 1): RGBAColor {
  return {
    args: [r, g, b, a],
    get rgbTxt() {
      const [rr, gg, bb] = this.args;
      return `${rr}, ${gg}, ${bb}`;
    },
    get rgba() {
      return `rgba(${this.rgbTxt}, ${this.args[3] || 1})`;
    },
  };
}

/**
 * 颜色混合
 * @param source 起始色
 * @param target 目标色
 * @param percent 混合比例百分比
 * @returns 混合后的颜色
 */
export function mixRgbColor(source: RGBAColor, target: RGBAColor, percent: number): RGBAColor {
  const res = [
    source.args[0] + (target.args[0] - source.args[0]) * (percent / 100),
    source.args[1] + (target.args[1] - source.args[1]) * (percent / 100),
    source.args[2] + (target.args[2] - source.args[2]) * (percent / 100),
  ].map((item) => Math.round(item));
  const [rr, gg, bb] = res;
  return createRgbaColor(rr, gg, bb, source.args[3] || 1);
}

/**
 * 生成色阶对象。light 系列与白色一步步混合,dark 系列与黑色一步步混合。
 * @param color 基准颜色
 * @param levels 色阶数
 * @returns 色阶对象
 */
export function generateRgbColorLevels(color: RGBAColor, levels: number = 9) {
  const result = {
    light: <RGBAColor[]>[],
    dark: <RGBAColor[]>[],
  };

  if (color.rgbTxt === '0, 0, 0' || color.rgbTxt === '255, 255, 255') {
    return result;
  }

  const percent = 100 / (levels + 1);
  for (let i = 1; i < levels + 1; i++) {
    result.light.push(
      mixRgbColor(color, createRgbaColor(255, 255, 255), i * percent),
    );
    result.dark.push(
      mixRgbColor(color, createRgbaColor(0, 0, 0), i * percent),
    );
  }

  return result;
}

CSS 生成

为了将我们定义的主题变量对象转换成 CSS 样式:先使用 generateCssVars 方法将原始对象先转换为 CSS 变量对象,再用 cssVarsToString 将 CSS 变量对象转换为样式字符串。

// 定义主题变量的原始对象
const vars = {
  'color-primary': '#c7000b',
  'color-success': 'rgb(80, 212, 171)',
  'spacing-xs': '8px',
}

// 通过 generateCssVars(vars) 转换为 CSS 变量对象
const cssVars = {
  '--op-color-primary': '199,0,11',
  '--op-color-success': '80,212,171',
  '--op-spacing-xs': '8px',
}

// 通过 cssVarsToString(cssVars, ':root') 转换为 CSS 样式字符串
const cssString = `
:root {
  --op-color-primary: 199,0,11;
  --op-color-success: 80,212,171;
  --op-spacing-xs: 8px;
}
`

具体代码在 src/utils/cssVars.ts 中实现:

// packages/styles/src/utils/cssVars.ts
import { toRgba, generateRgbColorLevels } from './colors';

export type DefaultPrefix = 'op-';
/** 默认情况下,生成 CSS 变量时增加的前缀 */
export const DEFAULT_PREFIX: DefaultPrefix = 'op-';

/**
 * 生成 CSS 变量对象的选项
 * @typeParam K 需要生成色阶的键名
 * @typeParam P CSS 变量前缀
 */
export interface GenerateCssVarsOptions<
  K = string,
  P extends string = DefaultPrefix,
> {
  /**
   * 指定的键名所对应的 CSS 变量将会额外生成色阶变量。
   *
   * 例如 color-primary 将会生成 color-primary-light-[1-9] 以及 color-primary-dark-[1-9] 系列浅色与深色的变量。
   */
  colorLevelsEnabledKeys?: K[];

  /** 生成色阶变量的阶数 */
  colorLevels?: number;

  /** CSS 变量前缀 */
  prefix?: P;
}

/**
 * CSS 变量对象的类型
 * @typeParam T 原始对象的类型
 * @typeParam {@link GenerateCssVarsOptions}
 */
export type CssVarsObject<
  T extends Record<string, any> = Record<string, any>,
  K extends keyof T = keyof T,
  P extends string = DefaultPrefix,
> = {
  [Key in `--${P}${string & keyof T}`]: any;
} & {
  [Key in `--${P}${string & K}-light-${number}`]: any;
} & {
  [Key in `--${P}${string & K}-dark-${number}`]: any;
};

/**
 * 生成 CSS 变量对象
 * @typeParam {@link CssVarsObject}
 * @param origin 原始主题变量对象
 * @param options 选项 {@link GenerateCssVarsOptions}
 */
export function generateCssVars<
  T extends Record<string, any> = Record<string, any>,
  K extends keyof T = keyof T,
  P extends string = DefaultPrefix,
>(
  origin: T,
  options?: GenerateCssVarsOptions<K, P>,
): CssVarsObject<T, K, P> {
  const {
    prefix = DEFAULT_PREFIX,
    colorLevelsEnabledKeys = [],
    colorLevels = 9,
  } = options || {};

  const result: Record<string, any> = {};
  Object.entries(origin).forEach(([key, value]) => {
    const cssKey = `--${prefix}${key}`;
    const valueToRgba = toRgba(value);

    // 颜色 CSS 变量用 rgb 字符串(255,255,255)的方式表示,非颜色 CSS 变量不做转化。
    const finalValue = valueToRgba ? valueToRgba.rgbTxt : value;
    result[cssKey] = finalValue;

    // 对指定键值的变量生成色阶
    if (valueToRgba && colorLevelsEnabledKeys.includes(key as K)) {
      const rgbLevels = generateRgbColorLevels(valueToRgba, colorLevels);
      rgbLevels.light.forEach((light, index) => {
        const dark = rgbLevels.dark[index];
        result[`${cssKey}-light-${index + 1}`] = light.rgbTxt;
        result[`${cssKey}-dark-${index + 1}`] = dark.rgbTxt;
      });
    }
  });

  return result as CssVarsObject<T, K, P>;
}

/**
 * 将 css 变量对象转换为 css 样式字符串
 * @param cssVars CSS 变量对象
 * @param selector 应用样式的选择器
 */
export function cssVarsToString(cssVars: Record<string, any>, selector: string = ':root') {
  let result = `${selector}{`;
  Object.entries(cssVars).forEach(([key, value]) => {
    result += `${key}: ${value};`;
  });
  result += '}';
  return result;
}

/** 获取 css 变量字符串 var(xxxxx) */
export function getCssVar<
  T extends Record<string, any> = Record<string, any>,
>(name: keyof T, prefix: string = DEFAULT_PREFIX) {
  return `var(--${prefix}${name as string})`;
}

/** 将颜色 css 变量转换为有效颜色:255,255,255 => rgba(255,255,255,1) */
export function cssVarToRgba<
  T extends Record<string, any> = Record<string, any>,
>(name: keyof T, alpha: number = 1, prefix: string = DEFAULT_PREFIX) {
  return `rgba(${getCssVar(name, prefix)},${alpha})`;
}

主题生成

在基础预设 src/uno/base 中,我们将定义好的主题变量转换成 UnoCSS 的主题,就是为了生成组件库主题相关的原子类。

例如按照以下方式定义 UnoCSS 的主题:

import { defineConfig } from 'unocss';

export default defineConfig({
  theme: {
    colors: {
      primary: 'var(--op-color-primary)'
    }
  }
});

那么 UnoCSS 就允许我们使用 c-primary 来设置颜色,c-primary 对应的样式为:

.c-primary {
  color: var(--op-color-primary);
}

当我们改变 CSS 变量 --op-color-primary 的时候,c-primary 带来的颜色也会随之改变,使我们的组件库主题相关的原子类也能够适应主题的切换与修改。

这种方式虽然使原子类支持了 CSS 变量,但是不再支持颜色透明度的调整,例如 c-primary/20(参考:WindiCSS 文本颜色)。

当我们用 rgb() 的形式表示 CSS 变量相关的主题时,就能够支持颜色透明度的调整了(参考 Issue:Issue: How to configure colors with CSS variables (including opacity)?)。

import { defineConfig } from 'unocss';

export default defineConfig({
  theme: {
    colors: {
      primary: 'rgb(var(--op-color-primary))'
    }
  }
});

按照上述方式定义时,c-primaryc-primary/20 对应的样式分别为:

.c-primary {
  --un-text-opacity: 1;
  color: rgba(var(--op-color-primary), var(--un-text-opacity));
}
.c-primary\/20 {
  color: rgba(var(--op-color-primary), 0.2);
}

这就是为什么在生成主题 CSS 变量时,我们要求颜色的表示必须为 rgb 字符串(--op-color-primary: 199,0,11)的原因,是为了让 UnoCSS 生成的原子类既能支持 CSS 变量,又能支持透明度修改

按照上面总结的要点,我们在 src/utils/toTheme.ts 中实现将主题变量转换成 UnoCSS 主题的方法:

// packages/styles/src/utils/toTheme.ts
import {
  getCssVar,
  DEFAULT_PREFIX,
  DefaultPrefix,
  GenerateCssVarsOptions,
} from './cssVars';

/**
 * 主题生成选项
 * @typeParam {@link GenerateCssVarsOptions}
 */
export interface ToThemeOptions<
  K = string,
  P extends string = DefaultPrefix,
> extends GenerateCssVarsOptions<K, P> {
  /** 主题的类别 */
  type?: string,
}

/**
 * 根据主题变量的原始对象,生成 UnoCSS 的 Theme 对象
 * @param origin 原始主题变量对象
 * @param options 选项 {@link ToThemeOptions}
 */
export function toTheme<
  T extends Record<string, any> = Record<string, any>,
  K extends keyof T = keyof T,
  P extends string = DefaultPrefix,
>(
  origin: T,
  options?: ToThemeOptions<K, P>,
) {
  const {
    type = 'color',
    prefix = DEFAULT_PREFIX,
    colorLevelsEnabledKeys = [],
    colorLevels = 9,
  } = options || {};

  // 从原始对象中过滤出符合格式的键值
  const themeReg = new RegExp(`^${type}-(.*)$`);
  const keys = Object.keys(origin)
    .filter((key) => themeReg.test(key))
    .map((key) => key.replace(themeReg, '$1'));

  const result: Record<string, any> = {};
  keys.forEach((key) => {
    // 主题必须符合类似 rgb(var(--op-color-primary)) 的格式,这样 UnoCSS 能生成的原子类既能支持 CSS 变量,又能支持透明度修改
    result[key] = `rgb(${getCssVar(`${type}-${key}`, prefix)})`;

    // 处理色阶主题
    if (type === 'color' && colorLevelsEnabledKeys.includes(`${type}-${key}` as K)) {
      const lightColors: Record<string, any> = {};
      const darkColors: Record<string, any> = {};
      for (let i = 1; i < colorLevels + 1; i++) {
        lightColors[`${i}`] = `rgb(${getCssVar(`${type}-${key}-light-${i}`, prefix)})`;
        darkColors[`${i}`] = `rgb(${getCssVar(`${type}-${key}-dark-${i}`, prefix)})`;
      }
      result[`${key}_light`] = lightColors;
      result[`${key}_dark`] = darkColors;
    }
  });
  return result;
}

最后在 src/utils/index.ts 中集中导出工具方法:

// packages/styles/src/utils/index.ts
export * from './colors';
export * from './cssVars';
export * from './toTheme';

实现组件库 UnoCSS 预设

预设主体

@openxui/styles 模块下的 src/unoPreset.ts 实现 openxuiPreset 预设主体。由于我们的构建体系是分组件打包构建的,相对应的,我们的 UnoCSS 预设也需要有“分与合”的能力——既可以单独生成某个组件的预设、打包某个组件的样式;又可以生成完整预设,打包出全部样式。

  • 我们规定预设选项中的 include 字段指定名称的组件将会被集成,默认状态下(include 为空)集成全部组件的预设。
  • 基础预设 baseConfig 任何时候都会被集成,即使 include 字段传入空列表。
// packages/styles/src/unoPreset.ts
import { mergeConfigs, Preset, UserConfig } from 'unocss';
import { Theme } from 'unocss/preset-mini';
import {
  baseConfig,
  themeConfig,
  buttonConfig,
} from './unocss';

/** 组件名称与预设对象的关系表 */
const configMaps = {
  theme: themeConfig,
  button: buttonConfig,
} satisfies Record<string, UserConfig<Theme>>;

type ConfigKeys = keyof typeof configMaps;

/** 组件库预设选项 */
export interface OpenxuiPresetOptions {
  /** 指定集成哪些组件的 UnoCSS 预设,不设置时默认全部集成 */
  include?: ConfigKeys[];

  /** 指定剔除哪些组件的 UnoCSS 预设 */
  exclude?: ConfigKeys[];
}

/** 组件库预设 */
export function openxuiPreset(options: OpenxuiPresetOptions = {}): Preset {
  const {
    include = Object.keys(configMaps) as ConfigKeys[],
    exclude = [],
  } = options;

  // 根据 include 和 exclude 选项决定哪些组件的 UnoCSS 预设将要被集成
  const components = new Set<ConfigKeys>();
  include.forEach((key) => components.add(key));
  exclude.forEach((key) => components.delete(key));
  const configs = Array.from(components)
    .map((component) => configMaps[component])
    .filter((item) => item);

  // 基础预设任何时候都会生效
  configs.unshift(baseConfig);

  // 合并所有预设
  const mergedConfig = mergeConfigs(configs);

  return {
    name: 'openxui-preset',
    ...mergedConfig,
  };
}

下面给出用户使用 openxuiPreset 的案例:

// 用户的 uno.config.ts
import { defineConfig, presetUno } from 'unocss';
import { openxuiPreset } from '@openxui/styles';

export default defineConfig({
  presets: [
    presetUno(),

    // 集成完整预设。include 默认情况下集成全部组件的预设配置。
    // openxuiPreset()

    // 只集成 theme、button、input 组件的预设
    /*
    openxuiPreset({
      include: ['theme', 'button', 'input']
    })
    */

    // 只集成基础预设(包含预置预设、主题)
    openxuiPreset({
      include: []
    })
  ],
});

base 基础预设

openxuiPreset 中,主题的配置,无论是单组件集成还是全量集成的场景,都是需要一直生效的

我们在 src/unocss/base.ts 中实现基础预设:

// packages/styles/src/unocss/base.ts
import { UserConfig } from 'unocss';
import { Theme } from 'unocss/preset-mini';
import {
  themeColors,
  themeColorLevelsEnabledKeys,
  themeSpacing,
} from '../vars';
import { toTheme } from '../utils';

export const baseConfig: UserConfig<Theme> = {
  // 需要全局生效的主题
  theme: {
    // 颜色主题
    colors: toTheme(themeColors, {
      type: 'color',
      colorLevelsEnabledKeys: themeColorLevelsEnabledKeys,
      colorLevels: 9,
    }),
    // 边距相关主题
    spacing: toTheme(themeSpacing, { type: 'spacing' }),
    // 更多主题,自己定义...
  },
};

组件的预设

各个组件部分的预设就简单很多,主要是将我们在 js 中定义好的主题 CSS 变量注入到样式文件中。

基础主题预设放在 src/unocss/theme.ts 中实现,它从 src/vars 中获取了相关的主题变量,通过工具方法转换成了 CSS 样式字符串,注入到预设中。

// packages/styles/src/unocss/theme.ts
import { UserConfig } from 'unocss';
import { Theme } from 'unocss/preset-mini';
import { themeVars, themeColorLevelsEnabledKeys } from '../vars';
import { generateCssVars, cssVarsToString } from '../utils';

/** 主题部分预设 */
export const themeConfig: UserConfig<Theme> = {
  preflights: [
    {
      // 在生成的 css 样式文件中填入所有主题变量的定义
      getCSS: () => cssVarsToString(
        generateCssVars(themeVars, {
          colorLevelsEnabledKeys: themeColorLevelsEnabledKeys,
          colorLevels: 9,
        }),
      ),
    },
  ],
};

同样地,如果我们要实现其他组件的预设部分,需要继续在 src/unocss 中创建对应的文件:

📦src
 ┣ 📂...
 ┣ 📂unocss
 ┃ ┣ 📜base.ts
 ┃ ┣ 📜theme.ts
 ┃ ┣ 📜button.ts
 ┃ ┣ 📜... 
 ┃ ┗ 📜index.ts
 ┣ 📂...
 ┗ 📜...

最后我们在 src/unocss/index.ts 中导出各个模块的预设

// packages/styles/src/unocss/index.ts
export * from './base';
export * from './theme';

// 导出其他组件的 UnoCSS 预设
// export * from './other-component';

VSCode 插件集成预设

回到项目根目录下的 uno.config.ts,这个配置文件主要是传给 VSCode UnoCSS 插件,为我们的整个组件库项目提供原子类书写提示服务的。我们将刚完成的 openxuiPreset 集成进去。

// uno.config.ts
import { defineConfig, presetUno } from 'unocss';
+import transformerDirectives from '@unocss/transformer-directives';
+import { openxuiPreset } from './packages/styles/src/unoPreset';

export default defineConfig({
- presets: [presetUno()],
+ presets: [
+   presetUno(),
+   openxuiPreset(),
+ ]
+ transformers: [
+   transformerDirectives(),
+ ],
});

集成之后,借助 UnoCSS VSCode 插件的能力,我们在组件库工程中也可以获得主题原子类的提示:

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

单组件样式的完整实现

准备好了工具方法、规定好了各组件定义主题变量与实现 UnoCSS 预设的规则后,下面我们以 @openxui/button 按钮组件为例,完整地实现这个组件的样式:

1. 首先要处理 @openxui/styles 包,在 src/vars/button.ts 中,定义按钮需要用到的主题变量:

// packages/styles/src/vars/button.ts
import { getCssVar, cssVarToRgba } from '../utils';
import { ThemeCssVarsConfig } from './theme';

/** 按钮组件的主题变量定义 */
export const buttonVars = {
  'button-color': cssVarToRgba<ThemeCssVarsConfig>('color-regular'),
  'button-bg-color': cssVarToRgba<ThemeCssVarsConfig>('color-card'),
  'button-border-color': cssVarToRgba<ThemeCssVarsConfig>('color-bd_base'),
  'button-hover-color': cssVarToRgba<ThemeCssVarsConfig>('color-primary'),
  'button-hover-bg-color': cssVarToRgba('color-primary-light-9'),
  'button-hover-border-color': cssVarToRgba('color-primary-light-7'),
  'button-active-color': cssVarToRgba<ThemeCssVarsConfig>('color-primary'),
  'button-active-bg-color': cssVarToRgba('color-primary-light-9'),
  'button-active-border-color': cssVarToRgba<ThemeCssVarsConfig>('color-primary'),
  'button-disabled-color': cssVarToRgba<ThemeCssVarsConfig>('color-placeholder'),
  'button-disabled-bg-color': cssVarToRgba<ThemeCssVarsConfig>('color-card'),
  'button-disabled-border-color': cssVarToRgba<ThemeCssVarsConfig>('color-bd_light'),
  'button-padding-x': getCssVar<ThemeCssVarsConfig>('spacing-md'),
  'button-padding-y': getCssVar<ThemeCssVarsConfig>('spacing-xs'),
};

/** 按钮组件主题变量类型 */
export type ButtonCssVarsConfig = Partial<typeof buttonVars>;

2. 在 src/vars/index.ts 中导出变量,拓展主题变量的类型:

// packages/styles/src/vars/index.ts
import { ThemeCssVarsConfig } from './theme';
+import { ButtonCssVarsConfig } from './button';
// 引入其他组件的主题变量类型
// import { ComponentCssVarConfig } from './other-component';

/** 导出组件库主题样式的整体类型 */
export interface OpenxuiCssVarsConfig extends
  ThemeCssVarsConfig,
+ ButtonCssVarsConfig {
  [key: string]: string | undefined;
}

export * from './theme';
+export * from './button';

3. 接着,在 src/unocss 目录下创建 button 组件的 UnoCSS 预设

// packages/styles/src/unocss/button/index.ts
import { UserConfig } from 'unocss';
import { buttonVars } from '../../vars';
import {
  cssVarsToString,
  generateCssVars,
} from '../../utils';
// import { toSafeList } from '../utils';
// import { buttonShortcuts } from './shortcuts';
// import { buttonRules } from './rules';

export const buttonConfig: UserConfig = {
  /*
  rules: buttonRules,
  shortcuts: buttonShortcuts,
  safelist: [
    ...toSafeList(buttonRules),
    ...toSafeList(buttonShortcuts),
  ],
  */
  preflights: [
    {
      getCSS: () => cssVarsToString(
        generateCssVars(buttonVars),
      ),
    },
  ],
};

4. 在 src/unocss/index.ts 中导出新实现的 button 预设:

// packages/styles/src/unocss/index.ts
export * from './base';
export * from './theme';
+export * from './button';

5. 在 src/unoPreset.ts 中,补充导入 button 预设:

// packages/styles/src/unoPreset.ts
import { mergeConfigs, Preset, UserConfig } from 'unocss';
import { Theme } from 'unocss/preset-mini';
import {
  baseConfig,
  themeConfig,
+ buttonConfig,
  BaseConfigOptions,
} from './unocss';

/** 组件名称与预设对象的关系表 */
const configMaps = {
  theme: themeConfig,
+ button: buttonConfig,
} satisfies Record<string, UserConfig<Theme>>;

// 其他内容省略...

6. 完整地实现按钮组件:

我们回到 @openxui/button 包中,重新在源码目录下建立以下文件:

📦button
 ┣ ...
 ┣ 📂src
 ┃ ┣ 📜button.ts      # 接口声明、方法声明、hooks 声明
 ┃ ┣ 📜button.scss    # 组件样式
 ┃ ┣ 📜button.vue     # 组件的具体实现
 ┃ ┗ 📜index.ts       # 出口
 ┣ ...

首先在 @openxui/button 包的 src/index.ts 中完成必要的导入与导出。

// packages/button/src/index.ts
import Button from './button.vue';
import './button.scss';
// 导入 UnoCSS 虚拟模块,确保 UnoCSS 定义的 CSS 变量部分能够被注入到样式产物中
import 'virtual:uno.css';

export { Button };
export * from './button';

接着在 src/button.ts 以及 src/button.vue 中,正式实现组件:

// packages/button/src/button.ts
import { InferVueDefaults } from '@openxui/shared';
import type Button from './button.vue';

export interface ButtonProps {
  /** 按钮的类型 */
  type?: '' | 'primary' | 'success' | 'info' | 'warning' | 'danger';

  /** 按钮是否为朴素样式 */
  plain?: boolean;

  /** 按钮是否不可用 */
  disabled?: boolean;
}

export function defaultButtonProps(): Required<InferVueDefaults<ButtonProps>> {
  return {
    type: '',
    plain: false,
    disabled: false,
  };
}

export type ButtonInstance = InstanceType<typeof Button>;

<script setup lang="ts">
// packages/button/src/button.vue
import { computed } from 'vue';
import { defaultButtonProps, ButtonProps } from './button';

const props = withDefaults(
  defineProps<ButtonProps>(),
  defaultButtonProps(),
);

const classes = computed(() => {
  const result: string[] = [];
  if (props.type) {
    result.push(`op-button--${props.type}`);
  }

  if (props.plain) {
    result.push('op-button--plain');
  }

  if (props.disabled) {
    result.push('op-button--disabled');
  }

  return result;
});

</script>

<template>
  <button
    class="op-button"
    :class="classes">
    <slot />
  </button>
</template>

关于 InferVueDefaults,这个是 Vue 中推断默认 props 类型的类型工具,由于框架没有导出,我们在 @openxui/shared 中实现:

// packages/shared/src/types/InferVueDefaults.ts
type NativeType = null | number | string | boolean | symbol | Function;
type InferDefault<P, T> = ((props: P) => T & {}) | (T extends NativeType ? T : never);

/** 推断出 props 默认值的类型 */
export type InferVueDefaults<T> = {
  [K in keyof T]?: InferDefault<T, T[K]>;
};

7. 使用 CSS 预处理器实现按钮组件的具体样式。

我们摒弃了纯 UnoCSS 生成所有组件样式的方案,选择通过 CSS 预处理器(Sass)生成大部分组件样式。建立 src/button.scss 文件编写样式。

/* packages/button/src/button.scss */
$button-types: primary, success, warning, danger, info;

@mixin button-type-styles() {
  @each $type in $button-types {
    &.op-button--#{$type} {
      --op-button-color: rgb(var(--op-color-reverse));
      --op-button-bg-color: rgb(var(--op-color-#{$type}));
      --op-button-border-color: rgb(var(--op-color-#{$type}));
      --op-button-hover-color: rgb(var(--op-color-reverse));
      --op-button-hover-bg-color: rgb(var(--op-color-#{$type}-light-3));
      --op-button-hover-border-color: rgb(var(--op-color-#{$type}-light-3));
      --op-button-active-color: rgb(var(--op-color-reverse));
      --op-button-active-bg-color: rgb(var(--op-color-#{$type}-dark-2));
      --op-button-active-border-color: rgb(var(--op-color-#{$type}-dark-2));
      --op-button-disabled-color: rgb(var(--op-color-reverse));
      --op-button-disabled-bg-color: rgb(var(--op-color-#{$type}-light-5));
      --op-button-disabled-border-color: rgb(var(--op-color-#{$type}-light-5));
    }
  }
}

@mixin button-plain-styles() {
  @each $type in $button-types {
    &.op-button--#{$type} {
      --op-button-color: rgb(var(--op-color-#{$type}));
      --op-button-bg-color: rgb(var(--op-color-#{$type}-light-9));
      --op-button-border-color: rgb(var(--op-color-#{$type}-light-5));
      --op-button-hover-color: rgb(var(--op-color-reverse));
      --op-button-hover-bg-color: rgb(var(--op-color-#{$type}));
      --op-button-hover-border-color: rgb(var(--op-color-#{$type}));
      --op-button-disabled-color: rgb(var(--op-color-#{$type}-light-5));
      --op-button-disabled-bg-color: rgb(var(--op-color-#{$type}-light-9));
      --op-button-disabled-border-color: rgb(var(--op-color-#{$type}-light-8));
    }
  }
}

.op-button {
  box-sizing: border-box;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: var(--op-button-padding-y) var(--op-button-padding-x);
  font-size: 14px;
  font-weight: normal;
  line-height: 1;
  color: var(--op-button-color);
  text-align: center;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
  background-color: var(--op-button-bg-color);
  border-color: var(--op-button-border-color);
  border-style: solid;
  border-width: 1px;
  border-radius: 4px;
  outline: none;

  &:hover {
    color: var(--op-button-hover-color);
    background-color: var(--op-button-hover-bg-color);
    border-color: var(--op-button-hover-border-color);
  }

  &:active {
    color: var(--op-button-active-color);
    background-color: var(--op-button-active-bg-color);
    border-color: var(--op-button-active-border-color);
  }

  @include button-type-styles;

  &.op-button--plain {
    --op-button-hover-color: rgb(var(--op-color-primary));
    --op-button-hover-bg-color: rgb(var(--op-color-card));
    --op-button-hover-border-color: rgb(var(--color-primary));

    @include button-plain-styles;
  }

  &.op-button--disabled,
  &.op-button--disabled:hover,
  &.op-button--disabled:active {
    color: var(--op-button-disabled-color);
    cursor: not-allowed;
    background-color: var(--op-button-disabled-bg-color);
    border-color: var(--op-button-disabled-border-color);
  }
}

8. 配置打包构建。

// packages/button/vite.config.ts
import { generateVueConfig } from '../build/scripts';

export default generateVueConfig({
  presetOpenxuiOptions: {
    include: ['button'],
  },
});

最后,我们执行 button 构建命令,来检查一下生成的样式:

pnpm --filter @openxui/button run build

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

样式模块的构建

  • 一部分主要与 UnoCSS 预设相关,运行在 Node.js 环境。其入口是 src/unoPreset.ts,我们规定其构建命令为 vite build --mode unocss
  • 另一部分主要与组件库主题相关,主要运行在浏览器环境。其入口是 src/index.ts,我们规定其构建命令为 vite build --mode theme

首先在 @openxui/stylespackage.json 中定义两种不同的构建行为:

// packages/styles/package.json
{
  // 其他内容...
  "scripts": {
-   "build": "vite build",
+   "build:theme": "vite build --mode theme",
+   "build:unocss": "vite build --mode unocss",
+   "build": "pnpm run build:unocss && pnpm run build:theme",
    "test": "echo test"
  },
  // 其他内容...
}

vite.config.ts 中,根据不同的构建模式 mode,实现不同的构建行为:

// packages/styles/vite.config.ts
import {
  defineConfig,
  ConfigEnv,
} from 'vite';
import { generateConfig, generateVueConfig } from '../build/scripts';
import { absCwd, relCwd } from '../build/src';

export default defineConfig(({ mode }: ConfigEnv) => {
  if (mode === 'unocss') {
    // UnoCSS 预设部分是纯 ts 模块,可以使用基础构建预设
    return generateConfig({
      entry: 'src/unoPreset.ts',
      // 指定产物名称
      fileName: 'preset',
      // 不实现 d.ts 的移动,下一轮构建(--mode theme)时再进行移动
      dts: '',
      // 指定 exports 字段,将构建产物的相对路径写入 packages.json 中的 exports['./preset']
      exports: './preset',
    });
  }

  return generateVueConfig({
    // 在 package.json 的 exports['./style.css'] 为样式文件的人口
    onSetPkg: (pkg, options) => {
      const exports: Record<string, string> = {
        './style.css': relCwd(absCwd(options.outDir, 'style.css'), false),
      };
      Object.assign(
        pkg.exports as Record<string, any>,
        exports,
      );
    },
    presetOpenxuiOptions: {
      // 基础主题样式的 CSS 由 UnoCSS 生成,需要正确指定 openxuiPreset 的模块。
      include: ['theme'],
    },
  }, {
    build: {
      // 紧接着上一轮构建(--mode unocss),因此不用清空产物目录
      emptyOutDir: false,
    },
  });
});

之后,我们尝试执行 styles 包的构建命令进行验证:

# 因为 styles 包是第一次构建,先调用 vue-tsc 命令生成 d.ts 产物,方便移动 d.ts 时找不到目标。
pnpm run type:src

pnpm --filter @openxui/styles run build

生成产物以及自动回写入 package.json 的入口字段都符合预期,且能够互相对应。

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

UnoCSS 也正确生成了基础主题样式的 CSS 内容。

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

组件库主包的构建

组件库的主包 @openxui/ui 负责所有组件的汇总与导出,在每个组件包都生成了各自的 style.css 的背景下,组件库主包需要对这些样式文件也进行集中,就如同汇总各子模块的 js 导出模块一样。

我们需要在 @openxui/ui 的产物目录 dist 下,进一步建立一个样式目录 style,将每个组件各自的 css 样式文件放入其中,同时合并所有组件的样式文件内容,生成一个全量样式文件 index.css。在 packages/ui/vite.config.ts 文件内,我们实现一个 Vite 插件 pluginMoveStyles 来处理 css 移动的行为:

// packages/ui/vite.config.ts
import {
  defineConfig,
  PluginOption,
  ConfigEnv,
} from 'vite';
import {
  readdir,
  readFile,
  writeFile,
  cp,
} from 'node:fs/promises';
import { resolve, join } from 'node:path';
import {
  usePathAbs,
  absCwd,
  relCwd,
  GenerateConfigOptions,
} from '../build/src';
import { generateVueConfig } from '../build/scripts';

/** 本包产物相对本包根目录的路径 */
const OUT_REL = 'dist';

/** 本包样式相对本包根目录的路径 */
const STYLE_OUT_REL = join(OUT_REL, 'style');

/** 子包产物相对目录 */
const PACKAGE_OUT_REL = 'dist';

export default defineConfig(({ mode }: ConfigEnv) => generateVueConfig(
  {
    outDir: OUT_REL,
    mode: mode as GenerateConfigOptions['mode'],
    // 样式都来自构建好的子包,无需 UnoCSS 生成样式
    pluginUno: false,
    // 在 package.json 的 exports 字段声明样式文件的人口
    onSetPkg: (pkg, options) => {
      const exports: Record<string, string> = {
        './style/*': relCwd(absCwd(options.outDir, 'style/*'), false),
      };
      Object.assign(
        pkg.exports as Record<string, any>,
        exports,
      );
    },
  },
  {
    plugins: [
      // 使用 Vite 插件处理 css 移动的行为
      pluginMoveStyles(mode),
    ],
  },
));

function pluginMoveStyles(mode: string): PluginOption {
  if (mode !== 'package') {
    return null;
  }

  const absPackages = usePathAbs(resolve(process.cwd(), '..'));

  return {
    name: 'move-styles',
    // 只在构建模式下执行
    apply: 'build',
    async closeBundle() {
      // 遍历所有 packages 目录下的子包
      const packages = await readdir(absPackages());
      // 在待处理的子包中排除掉自己
      const uiIndex = packages.findIndex((pkg) => pkg === 'ui');
      if (uiIndex > 0) {
        packages.splice(uiIndex, 1);
      }

      // 主题样式放到队首,在合并 CSS 时具有最高优先级
      const themeIndex = packages.findIndex((pkg) => pkg === 'theme');
      if (themeIndex > 0) {
        packages.splice(themeIndex, 1);
        packages.unshift('theme');
      }

      // 一边移动每个组件各自的样式,一边拼接全量样式 index.css
      let indexCss = '';
      for (let i = 0; i < packages.length; i++) {
        const pkg = packages[i];

        console.log(`moving css of package: ${pkg}...`);
        const source = absPackages(pkg, PACKAGE_OUT_REL, 'style.css');
        const target = absCwd(STYLE_OUT_REL, `${pkg}.css`);
        try {
          // 只处理产物目录下有 index.css 的子包,不满足条件会被跳过
          const styleCss = await readFile(source, 'utf-8');
          indexCss += styleCss;
          await cp(source, target, { recursive: true, force: true });
          console.log(`${source} moved successfully!`);
        } catch (err) {
          console.log(`${source} not found!`);
        }
      }

      console.log('generating index.css...');
      await writeFile(absCwd(STYLE_OUT_REL, 'index.css'), indexCss, 'utf-8');
    },
  };
}

由于 @openxui/styles 包的加入,我们也要在主包 @openxui/ui 中导出新增的主题部分:

pnpm --filter @openxui/ui i -S @openxui/styles

// packages/ui/src/index
export * from '@openxui/button';
export * from '@openxui/input';
export * from '@openxui/shared';
+export * from '@openxui/styles';

到此,我们终于完成了样式方案主体的实现与集成!立即运行指令尝试一次整体构建吧!

pnpm run build:ui

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

大家可以试着检查样式产物,看看 index.css 是否正确集合了各个子模块的样式。

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

这样,用户使用我们的组件库时,即可以通过 import @openxui/ui/style/xxx.css 来导入组件库样式了。

// 用户的 main.ts
import { createApp } from 'vue';
import App from './App.vue';
// 导入全部组件库样式
import '@openxui/ui/style/index.css';

// 导入部分组件的样式
// import '@openxui/ui/style/button.css';

createApp(App).mount('#app');

demo 展示

进入 @openxui/demo 展示应用包中,进行以下修改:

// demo/vite.config.ts
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { join } from 'node:path';
+import unocss from 'unocss/vite';

export default defineConfig({
  plugins: [
    vue(),
+   unocss(),
  ],
  // ...
});

// demo/src/main.ts
import { createApp } from 'vue';
import App from './App.vue';
+import 'virtual:uno.css';

createApp(App).mount('#app');

<script setup lang="ts">
// demo/src/App.vue
import {
  Button,
  Input,
} from '@openxui/ui';
</script>

<template>
  <div>
    <div class="btns">
      <Button>Button</Button>
      <Button type="primary">
        Button
      </Button>
      <Button type="success">
        Button
      </Button>
      <Button type="danger">
        Button
      </Button>
      <Button type="warning">
        Button
      </Button>
      <Button type="info">
        Button
      </Button>
    </div>
    <div class="btns">
      <Button plain>
        Button
      </Button>
      <Button type="primary" plain>
        Button
      </Button>
      <Button type="success" plain>
        Button
      </Button>
      <Button type="danger" plain>
        Button
      </Button>
      <Button type="warning" plain>
        Button
      </Button>
      <Button type="info" plain>
        Button
      </Button>
    </div>
    <div class="btns">
      <Button disabled>
        Button
      </Button>
      <Button type="primary" disabled>
        Button
      </Button>
      <Button type="success" disabled>
        Button
      </Button>
      <Button type="danger" disabled>
        Button
      </Button>
      <Button type="warning" disabled>
        Button
      </Button>
      <Button type="info" disabled>
        Button
      </Button>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.btns {
  :deep(.op-button) {
    margin-bottom: 10px;

    &:not(:first-child) {
      margin-left: 10px;
    }
  }
}
</style>

运行 demo 应用,看一看展示出来的 button 按钮的样式是否符合我们的预期。

pnpm --filter @openxui/demo run dev

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

实现主题切换

全局切换主题

  • 实现 Vue 插件(Vue 插件) Theme,在插件的 install 方法中,用 provide 方法(Vue provide)将设置全局主题变量的 setTheme 方法注入到整个 Vue 应用中。
  • 向外暴露 useTheme 方法,可以使挂载了 Theme 插件的应用下的任何 Vue 组件通过 const { setTheme } = useTheme() 获取到设置主题变量的方法,useTheme 通过 inject 方法(Vue inject)方法获取到目标。
  • 之前生成 UnoCSS 预设时用到的 generateCssVars 可以在主题切换时再次复用,将主题变量进行加前缀、提取 RGB 字符串等处理,生成真正适配组件库现状的 CSS 变量对象。
// packages/styles/src/theme/index.ts
import { inject, App, Plugin } from 'vue';
import { isObjectLike } from '@openxui/shared';
import { generateCssVars } from '../utils';
import { themeColorLevelsEnabledKeys, OpenxuiCssVarsConfig } from '../vars';

const THEME_PROVIDE_KEY = '__OpenxUITheme__';

function useGlobalTheme(app: App, options?: OpenxuiCssVarsConfig) {
  /** 设置全局主题变量的方法 */
  function setTheme(styleObj: OpenxuiCssVarsConfig) {
    // 设置主题变量时,兼顾主题色的色阶
    const cssVars = generateCssVars(styleObj, {
      colorLevelsEnabledKeys: themeColorLevelsEnabledKeys,
      colorLevels: 9,
    });
    Object.entries(cssVars).forEach(([k, v]) => {
      document.documentElement.style.setProperty(k, v);
    });
  }

  const result = { setTheme };

  app.provide(THEME_PROVIDE_KEY, result);

  if (isObjectLike(options) && Object.keys(options).length > 0) {
    setTheme(options);
  }

  return result;
}

type OpenxUITheme = ReturnType<typeof useGlobalTheme>;

export function useTheme() {
  const result = inject<OpenxUITheme>(THEME_PROVIDE_KEY);
  if (!result) {
    throw new Error('useTheme() must be used after app.use(Theme)!');
  }
  return result;
}

export const Theme: Plugin<OpenxuiCssVarsConfig[]> = {
  install: (app, ...options) => {
    const finalOptions: OpenxuiCssVarsConfig = {};
    options.forEach((item) => {
      Object.assign(finalOptions, item);
    });
    useGlobalTheme(app, finalOptions);
  },
};

export * from './presets';

另外,在 src/theme/preset 目录下可以存放一些主题预设,为了方便之后的主题切换演示,我们可以设定一个叫 tiny 的预设:

// packages/styles/src/theme/preset/tiny.ts
import { OpenxuiCssVarsConfig } from '../../vars';

export const tinyThemeVars: OpenxuiCssVarsConfig = {
  'color-primary': '#5e7ce0',
  'color-success': '#50d4ab',
  'color-warning': '#fa9841',
  'color-error': '#c7000b',
  'color-info': '#252b3a',
};

// packages/styles/src/theme/preset/index.ts
export * from './tiny';

局部切换主题

Theme 插件主要实现的是全局主题变量的切换。对于局部主题切换的需求,我们要实现 @openxui/config-provider 组件,利用节点上挂载的 CSS 变量优先级更高,但是只对其子节点生效的特点达成目的。

📦config-provider
 ┣ 📂dist
 ┣ 📂node_modules
 ┣ 📂src
 ┃ ┣ 📜config-provider.ts
 ┃ ┣ 📜config-provider.vue
 ┃ ┗ 📜index.ts
 ┣ 📜package.json
 ┗ 📜vite.config.ts

// packages/config-provider/package.json
{
  // 配置可参考其他组件 ...
  "peerDependencies": {
    "vue": ">=3.0.0"
  },
  "dependencies": {
    "@openxui/styles": "workspace:^",
    "@openxui/shared": "workspace:^"
  }
}

// packages/config-provider/vite.config.ts
import { generateVueConfig } from '../build/scripts';

export default generateVueConfig({
  presetOpenxuiOptions: {
    // config-provider 组件暂时没有 UnoCSS 样式预设
    include: [],
  },
});

// packages/config-provider/src/index.ts
import ConfigProvider from './config-provider.vue';

export { ConfigProvider };
export * from './config-provider';

// packages/config-provider/src/config-provider.ts
import { Component } from 'vue';
import { OpenxuiCssVarsConfig } from '@openxui/styles';
import { InferVueDefaults } from '@openxui/shared';
import type ConfigProvider from './config-provider.vue';

export interface ConfigProviderProps {
  /** 组件的节点将被渲染的标签类型 */
  tag?: string | Component;

  /** 应用在该节点上的主题变量 */
  themeVars?: OpenxuiCssVarsConfig;
}

export function defaultConfigProviderProps(): Required<InferVueDefaults<ConfigProviderProps>> {
  return {
    tag: 'div',
    themeVars: () => ({}),
  };
}

export type ConfigProviderInstance = InstanceType<typeof ConfigProvider>;

<script setup lang="ts">
// packages/config-provider/src/config-provider.vue
import { computed } from 'vue';
import { generateCssVars, themeColorLevelsEnabledKeys } from '@openxui/styles';
import { ConfigProviderProps, defaultConfigProviderProps } from './config-provider';

const props = withDefaults(
  defineProps<ConfigProviderProps>(),
  defaultConfigProviderProps(),
);

const cssVars = computed(() => generateCssVars(props.themeVars, {
  colorLevelsEnabledKeys: themeColorLevelsEnabledKeys,
  colorLevels: 9,
}));

</script>

<template>
  <component :is="tag" :style="cssVars">
    <slot />
  </component>
</template>

config-provider 组件完成后,我们将其纳入 @openxui/ui 包中:

pnpm --filter @openxui/ui i -S @openxui/config-provider

// packages/ui/src/index.ts
export * from '@openxui/button';
export * from '@openxui/input';
+export * from '@openxui/config-provider';
export * from '@openxui/shared';
export * from '@openxui/styles';

主题切换演示

我们现在为 demo/src/App.vue 接入主题切换功能。点击“主题切换”按钮会在默认主题以及 tiny 主题之间切换,第一个按钮会切换全局的主题,第二个按钮只负责切换第二行按钮容器的主题。

<script setup lang="ts">
// demo/src/App.vue
import { ref, reactive } from 'vue';
import {
  Button,
  Input,
  ConfigProvider,
  useTheme,
  tinyThemeVars,
  themeVars,
  OpenxuiCssVarsConfig,
} from '@openxui/ui';

const { setTheme } = useTheme();

const currentGlobalTheme = ref<'default' | 'tiny'>('default');

// 全局主题切换
function switchGlobalTheme() {
  if (currentGlobalTheme.value === 'tiny') {
    currentGlobalTheme.value = 'default';
    setTheme(themeVars);
  } else {
    currentGlobalTheme.value = 'tiny';
    setTheme(tinyThemeVars);
  }
}

const currentSecondLineTheme = ref<'default' | 'tiny'>('default');
const secondLineThemeVars: OpenxuiCssVarsConfig = reactive({});
// 局部主题切换
function switchSecondLineTheme() {
  if (currentSecondLineTheme.value === 'tiny') {
    currentSecondLineTheme.value = 'default';
    Object.assign(secondLineThemeVars, themeVars);
  } else {
    currentSecondLineTheme.value = 'tiny';
    Object.assign(secondLineThemeVars, tinyThemeVars);
  }
}
</script>

<template>
  <div>
    <!-- 第一组 button 省略 。。。 -->
    <ConfigProvider class="btns" :theme-vars="secondLineThemeVars">
      <Button plain>
        Button
      </Button>
      <Button type="primary" plain>
        Button
      </Button>
      <Button type="success" plain>
        Button
      </Button>
      <Button type="danger" plain>
        Button
      </Button>
      <Button type="warning" plain>
        Button
      </Button>
      <Button type="info" plain>
        Button
      </Button>
    </ConfigProvider>
    <!-- 第三组 button 省略 。。。 -->
    <div class="btns">
      <Button @click="switchGlobalTheme">
        切换全局主题,当前:{{ currentGlobalTheme }}
      </Button>
      <Button @click="switchSecondLineTheme">
        切换第二行主题,当前:{{ currentSecondLineTheme }}
      </Button>
    </div>
    <Input />
  </div>
</template>

<!-- 省略样式定义 。。。 -->

检查换肤效果,可见全局主题切换与局部主题切换都完全实现了:

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

使用 iconify 实现矢量图标方案

一个完整的组件库,怎么能没有矢量图标的方案呢。不过,与 element-plus 采取的 Icon 图标组件 的方案不同,我们的组件库将尝试使用业界较新的图标方案:结合 Iconify 实现纯 CSS 图标。关于这一套图标方案的更多信息,可以阅读以下文章进行了解:

聊聊纯 CSS 图标

Iconify 可以将多个 svg 图标合并生成一份标准的 json 文件,各个工具都围绕着这个 json 文件进行工作。例如 UnoCSS 就提供了 预设 Icons preset 根据 Iconify json 按需生成对应图标的原子类:

import { presetIcons } from 'unocss'

export default defineConfig({
  presets: [
    presetIcons({
      collections: {
        // 允许我们通过 <i class="i-mdi-xxx"> 来使用 @iconify-json/mdi 中的图标
        mdi: () => import('@iconify-json/mdi/icons.json').then(i => i.default),
      }
    })
  ]
})

这样的图标方案具有以下优势:

  • 配合 UnoCSS,可以实现按需引入,打包时只生成实际使用到的图标。
  • 支持通过 CSS 样式,自由地调整图标的颜色(color)与尺寸(font-size)。
  • 只要有 svg 图标就可以使用,不局限于某个组件库。比起组件形式的 icon,纯 CSS 使用也不需要写那么多 import 导入语法。

我们建立 @openxui/icons 包实现将大量的 svg 图标转换为 Iconify json 文件。当然,考虑到部分用户可能不使用 UnoCSS,我们还要生成纯 css 的图标样式文件。@openxui/icons 的目录结构如下:

📦icons
 ┣ 📂dist
 ┣ 📂icons                # 存放所有 svg 图标文件
 ┃ ┣ 📜alert-marked.svg
 ┃ ┣ 📜alert.svg
 ┃ ┣ 📜...
 ┣ 📂node_modules
 ┣ 📂src
 ┃ ┗ 📜index.ts           # svg 图标转换主体方法实现
 ┣ 📜package.json
 ┗ 📜vite.config.ts

@openxui/iconspackage.json 与其他包相比也没什么不同,需要安装 @iconify 相关依赖,它们负责 svgiconify json 转换的具体过程。

// packages/icons/package.json
{
  // 配置可参考其他组件 ...
  "dependencies": {
    "@iconify/tools": "^3.0.5",
    "@iconify/utils": "^2.1.9"
  }
}

pnpm --filter @openxui/icons i -S @iconify/tools @iconify/utils

src/index.ts 负责实现将某个目录下所有的 svg 图标转换为 Iconify json 以及 css 样式文件的方法 generateIconify 因为处在源码目录中,后续会通过 Vite 将其构建为产物,使得安装了此包的用户也可以调用这个方法处理自己项目中的 svg 图标。

// packages/icons/src/index.ts
import { resolve, join } from 'node:path';
import { writeFile, mkdir } from 'node:fs/promises';
import {
  importDirectory,
  cleanupSVG,
  runSVGO,
  parseColors,
  isEmptyColor,
} from '@iconify/tools';
import { getIconsCSS } from '@iconify/utils';

/**
 * 写文件,当文件路径为字符串时,会用 mkdir 建好上级目录,避免目录不存在的错误
 * @param file 文件路径
 * @param data 写入内容
 * @param options 写文件配置
 */
const outputFile: typeof writeFile = async (file, data, options) => {
  if (typeof file === 'string') {
    const dir = join(file, '..');
    if (dir && dir !== '.' && dir !== '..') {
      // 当前路径,无需向上寻找
      await mkdir(join(file, '..'), { recursive: true });
    }
  }
  await writeFile(file, data, options);
};

function absCwd(...paths: string[]) {
  return resolve(process.cwd(), ...paths).replace(/\\/g, '/');
}

export interface GenerateIconifyOptions {
  /** svg 图标所在的目录 */
  iconsDir?: string;

  /** iconify 前缀 */
  prefix?: string;

  /** 生成的图标 css 文件的路径,为空代表不生成 css 文件 */
  cssOutput?: string;

  /** css icon 样式选择器的生成规则 */
  cssIconSelector?: string;

  /** css icon 基础样式选择器的生成规则 */
  cssCommonSelector?: string;

  /** 生成的 iconify 规范的 json 文件的路径 */
  jsonOutput?: string;
}

/** 指定一系列 svg 图标,生成 iconify 规范的 json 文件以及对应的图标 css 文件 */
export async function generateIconify(options: GenerateIconifyOptions = {}) {
  const {
    iconsDir = 'icons',
    prefix = 'op',
    cssIconSelector = `.i-${prefix}-{name}`,
    cssCommonSelector = '',
    cssOutput = '',
    jsonOutput = absCwd(iconsDir, 'icons.json'),
  } = options;

  const { log } = console;

  // Import icons
  const iconSet = await importDirectory(iconsDir, { prefix });

  const names: string[] = [];
  // Validate, clean up, fix palette and optimise
  await iconSet.forEach(async (name, type) => {
    if (type !== 'icon') {
      return;
    }

    const svg = iconSet.toSVG(name);
    if (!svg) {
      // Invalid icon
      iconSet.remove(name);
      return;
    }

    // Clean up and optimise icons
    try {
      // Clean up icon code
      await cleanupSVG(svg);

      // Assume icon is monotone: replace color with currentColor, add if missing
      // If icon is not monotone, remove this code
      await parseColors(svg, {
        defaultColor: 'currentColor',
        callback: (_attr, colorStr, color) => (!color || isEmptyColor(color) ? colorStr : 'currentColor'),
      });

      // Optimise
      await runSVGO(svg);
    } catch (err) {
      // Invalid icon
      log(`Error parsing ${name}:`, err);
      iconSet.remove(name);
      return;
    }

    // Update icon
    iconSet.fromSVG(name, svg);

    names.push(name);
  });

  const exportedJson = iconSet.export();
  // Export as IconifyJSON
  const exported = `${JSON.stringify(exportedJson, null, 2)}\n`;

  // Save json to file
  await outputFile(jsonOutput, exported, 'utf8');
  log(`Saved JSON (${exported.length} bytes)`);

  if (cssOutput) {
    // Get CSS
    // https://iconify.design/docs/libraries/utils/get-icons-css.html#simple-selector
    const css = getIconsCSS(exportedJson, names, {
      iconSelector: cssIconSelector,
      commonSelector: cssCommonSelector,
    });

    // Save css to file
    await outputFile(cssOutput, css, 'utf8');
    log(`Saved CSS (${css.length} bytes)`);
  }
}

在构建选项 vite.config.ts 中,我们在 Vite 插件的 closeBundle 阶段调用源码中的 generateIconify 方法进行转换,jsoncss 产物都在 dist 目录下:

// packages/icons/vite.config.ts
import { PluginOption } from 'vite';
import { generateIconify } from './src';
import { generateConfig } from '../build/scripts';
import { absCwd, relCwd } from '../build/src';

/** 本包产物相对本包根目录的路径 */
const OUT_REL = 'dist';

/** icons 图标集合相对路径 */
const ICONS_REL = 'icons';

/** 生成的产物文件名称 */
const FILE_NAME = 'icons';

export default generateConfig({
  outDir: OUT_REL,
  // 在 package.json 的 exports 字段声明样式文件的人口
  onSetPkg: (pkg, options) => {
    const exports: Record<string, string> = {
      [`./${FILE_NAME}.css`]: relCwd(absCwd(options.outDir, `${FILE_NAME}.css`), false),
      [`./${FILE_NAME}.json`]: relCwd(absCwd(options.outDir, `${FILE_NAME}.json`), false),
    };
    Object.assign(
      pkg.exports as Record<string, any>,
      exports,
    );
  },
}, {
  plugins: [
    pluginGenerateIconify(),
  ],
});

function pluginGenerateIconify(): PluginOption {
  return {
    name: 'generate-iconify',
    // 只在构建模式下执行
    apply: 'build',
    async closeBundle() {
      await generateIconify({
        iconsDir: absCwd(ICONS_REL),
        prefix: 'op',
        cssOutput: absCwd(OUT_REL, `${FILE_NAME}.css`),
        jsonOutput: absCwd(OUT_REL, `${FILE_NAME}.json`),
      });
    },
  };
}

运行命令构建 @openxui/icons 包:

# 因为 icons 包是第一次构建,先调用 vue-tsc 命令生成 d.ts 产物,方便移动 d.ts 时找不到目标。
pnpm run type:src

pnpm --filter @openxui/icons run build

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

回到根目录下的 uno.config.ts,我们先来自己集成 @openxui/icons 的成果:

// uno.config.ts
import {
  defineConfig,
  presetUno,
  presetIcons,
  UserConfig,
} from 'unocss';
import transformerDirectives from '@unocss/transformer-directives';
import { openxuiPreset } from './packages/styles/src/unoPreset';

export default <UserConfig>defineConfig({
  presets: [
    presetUno(),
    presetIcons({
      collections: {
        // Iconify json 集成,后续支持通过 <i class="i-op-xxx"> 来使用图标原子类,并支持按需打包
        op: () => import('./packages/icons/dist/icons.json').then((i) => i.default),
      },
    }),
    openxuiPreset(),
  ],
  transformers: [
    transformerDirectives(),
  ],
});

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

demo/src/App.vue 中,我们也可以尝试一下 CSS 图标支持自由修改尺寸与颜色的特点:

<!-- demo/src/App.vue -->

<!-- 省略脚本部分... -->

<template>
  <!-- 省略其他部分... -->
+ <div>
+   <i class="i-op-alert text-100px c-primary inline-block"></i>
+   <i class="i-op-alert-marked text-60px c-success inline-block"></i>
+ </div>
  <Input />
</template>

<!-- 省略样式部分... -->

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

结尾与资料汇总

【从 0 到 1 搭建 Vue 组件库框架】5. 设计组件库的样式方案 - 下

// tsconfig.node.json
{
  // 其他配置...
  "include": [
+   "packages/build/src",
+   "packages/styles/src/unoPreset.ts",
+   "packages/styles/src/unocss",
+   "packages/icons/src",
    "**/*.config.*",
    "**/scripts"
  ],
}

在文章的最后,我们对组件库的样式方案的探索过程做一个整体总结:

  1. 首先我们通过用户的使用习惯,以及观察学习其他组件库,认为一个合格的组件库样式应该做到:支持分组件引入样式、定义语义化的 class 名称,并具有严格的命名空间、使用 CSS 变量以支持主题切换。
  2. 对于新的 CSS 领域的技术方案 UnoCSS,由于它不仅仅是原子 CSS 框架,而是 CSS 生成器,因此它完全可以参与到组件库的样式构建中。
  3. 但是由于 UnoCSS 组装 CSS 的语法 RulesShortcut 的可读性不够高,我们还是决定仅用 UnoCSS 实现 CSS 变量注入、主题原子类的提供,语义化 CSS 的定义依然交给预处理器。
  4. 我们实现 @openxui/styles 包对组件库的样式做整体处理,样式模块计划分为两部分,一部分是参与构建过程的 UnoCSS 部分,另一部分是与运行时相关的主题、样式工具方法部分。它们将被分为两个不同的入口分别构建。
  5. 为了适应更复杂的构建模式,我们调整了 @openxui/build 构建体系,使其支持为 package.json 写入多产物入口。并重点改造了 vue 组件的构建预设,主要在其中加入了 UnoCSS 相关的 Vite 插件。
  6. 之后我们具体实现了 @openxui/styles 的基础部分,随后以 @openxui/button 单组件为例子,演示如何集成这种样式方案。
  7. 最后,我们对样式方案做了进一步的拓展和优化:使用 Vueprovide/inject 功能实装了主题切换的能力;使用 iconify 工具辅助实现了组件库的矢量 icon 方案。

本章涉及到的相关资料汇总如下:

官网与文档:

UnoCSS

Tailwind CSS

Windi CSS

Iconify

Vue

分享博文:

聊聊纯 CSS 图标