导航
导航:0. 导论
上一章节: 5. 设计组件库的样式方案 - 上
本章节示例代码仓:Github
经过上半篇的探索,我们完成了所有实现组件库的样式方案的前期准备,下半篇我们将正式开始实践样式方案的实现、集成、优化、验证,逐步完善 @openxui/styles
模块。
这里我们先列出 5. 设计组件库的样式方案 - 上 中对 @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/styles
的 package.json
文件可以完全参考其他子模块,需要注意一下依赖关系(实际操作时请去除注释):
// packages/styles/package.json
{
// 其他相似配置省略...
"peerDependencies": {
"vue": ">=3.0.0",
"unocss": ">=0.54.1"
},
"dependencies": {
"@openxui/shared": "workspace:^"
}
}
执行 pnpm i
更新依赖后,我们进入正题。
定义主题变量
在 5. 设计组件库的样式方案 - 上 中我们提到,将组件库的样式设置为主题变量(CSS 变量),会给统一管理带来很大的便利,同时使主题切换功能的实现变得容易。
@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.ts
、src/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 样式,通过
UnoCSS
的 Preflights 功能注入组件样式中。 - 转化为
UnoCSS
的 Theme 主题,使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-primary
和 c-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 插件的能力,我们在组件库工程中也可以获得主题原子类的提示:
单组件样式的完整实现
准备好了工具方法、规定好了各组件定义主题变量与实现 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
预设。
可以注意到,我们注释掉了其中的 shortcuts
、rules
、safelist
选项,其原因在 5. 设计组件库的样式方案 - 上 中有所提及——纯粹使用 UnoCSS
的机制生成语义化 CSS 的代码可读性不够理想,但是在 源码实例 中还是保留了这些代码供读者参考。
// 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. 配置打包构建。
参考 5. 设计组件库的样式方案 - 上,我们使用调整后的 vue
组件构建预设来打包 @openxui/button
。在 vite.config.ts
中,我们将本包涉及的组件模块名称传给 openxuiPreset
预设,确保 UnoCSS
在打包组件时生产正确的内容。
// packages/button/vite.config.ts
import { generateVueConfig } from '../build/scripts';
export default generateVueConfig({
presetOpenxuiOptions: {
include: ['button'],
},
});
最后,我们执行 button
构建命令,来检查一下生成的样式:
pnpm --filter @openxui/button run build
样式模块的构建
在我们的先前的规划中,@openxui/styles
分为两个部分(回顾:5. 设计组件库的样式方案 - 上),我们规划在两次 Vite
构建进程中分别完成两个入口的打包,依然使用 Vite
的 构建模式 特性来区分不同的构建行为:
- 一部分主要与
UnoCSS
预设相关,运行在 Node.js 环境。其入口是src/unoPreset.ts
,我们规定其构建命令为vite build --mode unocss
。 - 另一部分主要与组件库主题相关,主要运行在浏览器环境。其入口是
src/index.ts
,我们规定其构建命令为vite build --mode theme
。
首先在 @openxui/styles
的 package.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
的入口字段都符合预期,且能够互相对应。
UnoCSS
也正确生成了基础主题样式的 CSS 内容。
组件库主包的构建
组件库的主包 @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
大家可以试着检查样式产物,看看 index.css
是否正确集合了各个子模块的样式。
这样,用户使用我们的组件库时,即可以通过 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
实现主题切换
全局切换主题
完成了主题方案的集成,接下来我们要进行一些拓展和优化。我们先来关注之前在 @openxui/styles
的规划中(5. 设计组件库的样式方案 - 上)尚未被实现的主题切换部分:src/theme
目录,建立 src/theme/index.ts
实现主题切换能力:
- 实现
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>
<!-- 省略样式定义 。。。 -->
检查换肤效果,可见全局主题切换与局部主题切换都完全实现了:
使用 iconify 实现矢量图标方案
一个完整的组件库,怎么能没有矢量图标的方案呢。不过,与 element-plus
采取的 Icon 图标组件 的方案不同,我们的组件库将尝试使用业界较新的图标方案:结合 Iconify 实现纯 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/icons
的 package.json
与其他包相比也没什么不同,需要安装 @iconify
相关依赖,它们负责 svg
到 iconify 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
方法进行转换,json
和 css
产物都在 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
回到根目录下的 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(),
],
});
在 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>
<!-- 省略样式部分... -->
结尾与资料汇总
补充内容: 如果 vite.config.ts
等构建配置脚本出现类似下图的错误,说明引用的源码并没有被 ts project
包含(参考:2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下)。请在 tsconfig.node.json
中将一些构建模块的源码添加到 include
字段中。
// tsconfig.node.json
{
// 其他配置...
"include": [
+ "packages/build/src",
+ "packages/styles/src/unoPreset.ts",
+ "packages/styles/src/unocss",
+ "packages/icons/src",
"**/*.config.*",
"**/scripts"
],
}
在文章的最后,我们对组件库的样式方案的探索过程做一个整体总结:
- 首先我们通过用户的使用习惯,以及观察学习其他组件库,认为一个合格的组件库样式应该做到:支持分组件引入样式、定义语义化的
class
名称,并具有严格的命名空间、使用 CSS 变量以支持主题切换。 - 对于新的 CSS 领域的技术方案
UnoCSS
,由于它不仅仅是原子 CSS 框架,而是 CSS 生成器,因此它完全可以参与到组件库的样式构建中。 - 但是由于
UnoCSS
组装 CSS 的语法Rules
、Shortcut
的可读性不够高,我们还是决定仅用UnoCSS
实现 CSS 变量注入、主题原子类的提供,语义化 CSS 的定义依然交给预处理器。 - 我们实现
@openxui/styles
包对组件库的样式做整体处理,样式模块计划分为两部分,一部分是参与构建过程的UnoCSS
部分,另一部分是与运行时相关的主题、样式工具方法部分。它们将被分为两个不同的入口分别构建。 - 为了适应更复杂的构建模式,我们调整了
@openxui/build
构建体系,使其支持为package.json
写入多产物入口。并重点改造了vue
组件的构建预设,主要在其中加入了UnoCSS
相关的Vite
插件。 - 之后我们具体实现了
@openxui/styles
的基础部分,随后以@openxui/button
单组件为例子,演示如何集成这种样式方案。 - 最后,我们对样式方案做了进一步的拓展和优化:使用
Vue
的provide/inject
功能实装了主题切换的能力;使用iconify
工具辅助实现了组件库的矢量 icon 方案。
本章涉及到的相关资料汇总如下:
官网与文档:
分享博文: