导航
导航:0. 导论
上一章节: 4. 定制组件库的打包体系
下一章节:5. 设计组件库的样式方案 - 下
本章节示例代码仓:Github
我们的组件库样式方案中,除了会使用到经典的 CSS 预处理器,还会纳入一个很新的 CSS 工具——UnoCSS。样式方案需要建立在先前构建方案的基础之上,甚至还需要对已有的构建方案做出改进。因此,这里推荐大家对之前的内容做一些回顾:
2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上
2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下
组件库样式方案的洞察
对于组件库的样式方案,我们可能会有以下要求:
- 组件库的样式能否支持按需导入,使用户的项目产物体积得以最小化?
- 如何尽可能地减少组件库样式与用户样式的冲突?
- 如何让用户方便地修改微调组件样式?
- “换肤能力”称得上是当下组件库的标配,我们的方案能支持主题切换功能吗?
目前,主流的组件库方案按照以下思路达成这些要求:
分组件打包样式
为每一个组件单独打包出 style.css
样式,类似下图的形式:
📦openx-ui
┣ 📂packages
┃ ┣ 📂button
┃ ┃ ┣ 📂dist # 组件产物目录
┃ ┃ ┃ ┣ 📜style.css # button 组件的样式
┃ ┃ ┃ ┗ 📜index.mjs
┃ ┃ ┣ 📜package.json
┃ ┣ 📂input
┃ ┃ ┣ 📂dist # 组件产物目录
┃ ┃ ┃ ┣ 📜style.css # input 组件的样式
┃ ┃ ┃ ┗ 📜index.mjs
┃ ┃ ┣ 📜package.json
┃ ┗ 📂ui # 组件库主包,各组件的统一出口
┃ ┗ 📜...
┣ 📜package.json
于是,用户只要按照按需加载的方式使用组件,就可以避免未使用的组件样式被打包进最终产物中,从而最小化产物体积:
<script setup lang="ts">
// 引入组件
import { Button } from '@openxui/button'
// 引入组件样式
import '@openxui/button/style.css'
</script>
<template>
<Button>Button</Button>
</template>
规范化 class 名称
通常情况下,组件库的样式 class
名称都有严格的命名规范,例如 element-plus 的类名都为 el-xxx
的格式;Vant 的类名都为 vant-xxx
的格式。且这些 class
名称都十分注重语义化,尽量能表现出对应元素的功用和特点。
于是,只要遵循组件库 class
的命名规则,用户可以很方便地自定义组件的样式,而无需担心与项目中的自定义样式存在冲突问题:
/* elememt-plus 全局自定义组件样式 */
/* element.scss(该样式已在项目入口中引入) */
/* Button 按钮
-------------------------- */
.el-button {
// 相邻按钮间距
+ .el-button {
margin-left: 16px;
}
// primary按钮禁用样式
.el-button--primary.is-disabled,
.el-button--primary.is-disabled:hover,
.el-button--primary.is-disabled:focus,
.el-button--primary.is-disabled:active {
color: var(--el-text-color-placeholder);
background-color: var(--el-color-white);
border-color: var(--el-border-color-lighter);
}
}
<!-- elememt-plus 局部自定义组件样式 -->
<template>
<el-button>Button</el-button>
</template>
<style scoped>
:deep(.el-button) {
padding: 4px 8px;
}
</style>
使用 CSS 变量
为了支持用户灵活地设置组件样式,甚至于实现主题切换功能,组件库的样式一般不会被设定为具体的值,例如:
/* BAD */
.el-button {
color: #fff;
font-size: 14px;
}
而是引用 CSS 变量
/* GOOD */
.el-button {
color: var(--el-text-color-regular);
font-size: var(--el-font-size-base);
}
在组件库中,这些 CSS 变量往往被称为主题变量,通常都挂载在 :root
节点上,只需要改变这些变量值,对应组件的样式就会一齐发生变化。在此基础上,批量设置主题变量就能达成“一键换肤”的主题切换效果。
:root {
--el-color-white: #ffffff;
--el-color-black: #000000;
--el-color-primary: #409eff;
--el-color-success: #67c23a;
--el-color-warning: #e6a23c;
--el-color-danger: #f56c6c;
/* 其他主题变量 */
}
如果你不那么熟悉 CSS 变量,可以通过这些文章进行了解:
UnoCSS
UnoCSS 是一款即时按需生成内容的原子 CSS 引擎,它具有高度的灵活性与拓展性。其核心是非固定的,所有 CSS 工具类都来自于自定义预设。
UnoCSS
属于原子化 CSS 的一种较新的解决方案,在对其展开介绍之前,我们先简单了解一下原子化 CSS。
关于原子化 CSS
原子化 CSS 是一种 CSS 的架构方式,它倾向于小巧且用途单一的 class,并且会以视觉效果进行命名。
例如
.m-0 { margin: 0; }
.m-1 { margin: 1px; }
/* ... */
.m-100 { margin: 100px; }
.text-red { color: red; }
.text-blue { color: blue; }
/* ... */
.text-white { color: white; }
之后,我们若想要声明一个文字色为红色、且外边距为 6px 的元素,就可以这样编写:
<div class="m-6 text-red"></div>
事实上,这并不是一种新型方案,UI 库 Bootstrap 很早就在提供大量原子化 CSS 工具类。不过在应用开发中,由于原子化 CSS 自身的劣势——原子类的编写缺少提示、原子类的定义使 CSS 最终产物膨胀、原子类的大量堆叠降低 html
的可读性,语义化 CSS 逐渐成为了主流,原子 CSS 慢慢被边缘化,成为一种辅助的手段。
现代的原子化 CSS 方案——Tailwind CSS、Windi CSS、UnoCSS,之所以将原子化 CSS 方案再度推向高潮,是因为其解决了上述的三大痛点:
- 它们都推出了 VSCode 插件,为编写原子类提供了充分的提示与自动补全。
- 它们都在构建阶段扫描代码,能够按照代码中的实际使用情况生成工具类,解决了原子类使 CSS 产物膨胀问题。
- 针对原子类堆叠降低可读性的问题,提供了
@apply
语法支持在 CSS 中对多个原子类进行合并,与语义化 CSS 实现了很好的配合。
推荐大家阅读以下文章,更详细地了解原子化 CSS:
从 Tailwind CSS 到 UnoCSS —— 原子化真的是前端CSS的救星吗
简单集成 UnoCSS
既然提到 UnoCSS
是本章的主角,那么我们就先来试用一下,首先全局安装 UnoCSS
:
pnpm i -wD unocss
不安装插件会使我们频繁查阅文档,显著降低原子 CSS 的书写体验。因此我们应该先集成好 VSCode 插件,直接在插件市场搜索 UnoCSS
即可:
将 UnoCSS
插件的 ID antfu.unocss
加入 .vscode/extensions
,以推荐新贡献者自动安装插件:
// .vscode/extensions.json
{
"recommendations": [
// ...
+ "antfu.unocss",
]
}
在根目录创建 uno.config.ts
来编写相关配置,只需集成 presetUno
,就能够使用绝大多数的原子类,具体原子类的写法请参考:Tailwind CSS、Windi CSS。(UnoCSS
让很多新人困惑的原因就在与此——自己的文档查不到原子类的写法,而需要去其他引擎的文档中查。)
// uno.config.ts
import { defineConfig, presetUno } from 'unocss';
export default defineConfig({
presets: [presetUno()],
});
之后,来到之前编写的按钮组件 packages/button/src/button.vue
中,试着写入一些原子类:
<script setup lang="ts">
// packages/button/src/button.vue
// ...
</script>
<template>
<button
- class="openx-button"
+ class="openx-button text-blue ml-2px cursor-pointer"
@click="clickHandler"
>
<slot />
</button>
</template>
可以看到集成了 VSCode 插件后原子类的编写体验有多么顺滑。
接下来需要在 packages/button/src/main.ts
中引入 UnoCSS
的虚拟模块:
// packages/button/src/main.ts
import Button from './button.vue';
+import 'virtual:uno.css';
export { Button };
调整一下 button
包 vite.config.ts
中的构建配置(回顾:4. 定制组件库的打包体系),集成 unocss
的 Vite
插件:
// packages/button/vite.config.ts
import { generateVueConfig } from '../build/build.config';
export default generateVueConfig({}, {
plugins: [
unocss(),
],
});
UnoCSS
会自动向上寻找最近的 uno.config.ts
配置文件,在 packages/button
没有配置文件的情况下,根目录的 uno.config.ts
文件会生效。最后,我们执行命令打包 button
包,观察产物:
pnpm --filter @openxui/button run build
# 结果
> @openxui/button@0.0.0 build D:\learning\openx-ui\packages\button
> vite build
vite v4.4.4 building for production...
✓ 4 modules transformed.
dist/style.css 2.26 kB │ gzip: 0.45 kB
dist/openxui-button.mjs 0.66 kB │ gzip: 0.38 kB
No name was provided for external module "vue" in "output.globals" – guessing "vue".
No name was provided for external module "@openxui/shared" in "output.globals" – guessing "shared".
dist/openxui-button.umd.js 1.14 kB │ gzip: 0.58 kB
✓ built in 352ms
检查 packages/button/dist/style.css
样式产物,UnoCSS
的确按需生成了对应的样式类:
探索 UnoCSS 与组件库样式的配合方式
但是,从上面的情况来看,原子化 CSS 似乎不适合用来构建组件库,下面摘录一条掘金评论来说明:
第三方库使用
UnoCSS
这样的原子化 css 是否合理,如果在业务中需要通过 css scoped 修改组件元素的样式,因为组件元素不具有特有的 css 类名,会不会不太方便?
的确,ml-2px
、text-blue
这样的类名不具有独特性,一方面不方便修改——如果其他组件也使用了同样的原子类将会受到牵连;另一方面也容易与用户的自定义 class
冲突。组件库的样式还是更适合语义化 CSS 的方案。
所以 UnoCSS
不适合做组件库样式方案吗?
我们不要忽略一个重要的点,即 UnoCSS
并不像 TailwindCSS
一般是一个原子化 CSS 框架,而是一个CSS 生成工具,其支持的 TailwindCSS
以及 WindiCSS
的写法完全是通过预设来实现的。那为什么我们不能通过预设来实现组件库语义化 CSS 的生成呢? 从这个角度出发,UnoCSS
完全可以尝试实现组件库样式方案!
生成主题 CSS 变量
UnoCSS
提供了 Preflights 功能,支持我们注入原生 CSS,我们可以用来定义主题变量。
// uno.config.ts
import { defineConfig, presetUno } from 'unocss';
export default defineConfig({
// 其他配置...
preflights: [
{
getCSS: () => `
:root {
--color-primary: #c7000b;
--color-success: #50d4ab;
--color-warning: #fbb175;
--color-danger: #f66f6a;
--color-info: #526ecc;
}
`
}
]
});
生成主题
我们充分使用 UnoCSS
提供的 Theme 主题 功能,将 CSS 变量与主题结合起来。这里只演示了颜色主题,其实边距、字号、圆角、行高等其他主题配置项都可以与主题 CSS 变量配合起来。
// uno.config.ts
import { defineConfig, presetUno } from 'unocss';
export default defineConfig({
// 其他配置...
theme: {
colors: {
primary: 'var(--color-primary)',
success: 'var(--color-success)',
warning: 'var(--color-warning)',
danger: 'var(--color-danger)',
info: 'var(--color-info)',
},
},
});
这样,如果组件库的用户集成了我们的预设,他还可以使用组件库主题相关的原子类!。
用 UnoCSS
实现组件库样式方案,其中一个巨大优势就是能“附赠”用户大量组件库主题相关的原子类。
生成语义化 CSS
UnoCSS
的 Rules 和 Shortcuts 主要决定 CSS 工具类的生成规则。官方演示的都是原子化 CSS 的生成案例,我们则要用他们生成语义化 CSS。
Rules 很好理解,就是定义 class
名称与 CSS 属性的对应关系。
// uno.config.ts
import { defineConfig, presetUno } from 'unocss';
export default defineConfig({
// 其他配置...
rules: [
['button-base', {
cursor: 'pointer',
display: 'inline-flex',
padding: '6px 12px',
}],
],
});
/* 上述内容的产出 */
.button-base {
cursor: pointer,
display: inline-flex,
padding: 6px 12px,
}
Shortcuts 相对麻烦一些,但是比 Rules 要灵活很多。它的主要能力是将一系列 class
对应的样式聚合到另一个 class
中,例如:
// uno.config.ts
import { defineConfig, presetUno } from 'unocss';
export default defineConfig({
// 其他配置...
shortcuts: [
['button', 'button-base text-14px c-primary bg-success'],
],
});
/* 上述内容的产出 */
.button {
background-color: var(--color-success);
font-size: 14px;
color: var(--color-primary);
cursor: pointer;
display: inline-flex;
padding: 6px 12px;
}
Shortcuts 之所以灵活,是因为他可以和预定义好的 Variants(presetUno
中已定义) 配合起来,实现 伪类和伪元素选择器、关系选择器,真正做到对 CSS 的自由组合。
// uno.config.ts
import { defineConfig, presetUno } from 'unocss';
export default defineConfig({
// 其他配置...
shortcuts: [
['button', `
'button-base text-14px c-primary bg-success'
hover:bg-warning
before:text-14px
[&.button-danger]:bg-danger
[&.button-info]:bg-info
`],
['button-danger', 'c-danger'],
['button-info', 'c-info'],
],
});
/* 上述内容的产出 */
.button {
background-color: var(--color-success);
font-size: 14px;
color: var(--color-primary);
cursor: pointer;
display: inline-flex;
padding: 6px 12px;
}
.button.button-danger {
background-color: var(--color-danger);
}
.button.button-info {
background-color: var(--color-info);
}
.button:hover {
background-color: var(--color-warning);
}
.button::before {
font-size: 14px;
}
.button-danger {
color: var(--color-danger);
}
.button-info {
color: var(--color-info);
}
最后,由于用户是组件库的集成方,UnoCSS
并不会扫描组件库源码按需产生 CSS,这就需要用到 UnoCSS
提供的 Safelist 机制——使特定的 CSS 类任何时候都生成。我们需要将所有组件用到的 class
都放入 safelist
列表中:
// uno.config.ts
import { defineConfig, presetUno } from 'unocss';
export default defineConfig({
// 其他配置...
safelist: [
'button',
'button-danger',
'button-info'
],
});
之后我们再次运行 @openxui/button
包的构建命令,就可以看到所生成的组件样式了。
pnpm --filter @openxui/button run build
我们的选择
梳理一下上面的思路,用 UnoCSS
实现组件库的样式方案,其实就是组合其生成 CSS 的各种能力:Rules
、Shortcuts
、Variants
、Preflights
、Theme
等,为每个组件都生成一套预设,打包出组件需要的语义化 CSS。所谓预设 Preset 其实就是 UnoCSS
配置项的大集合,UnoCSS
会在初始化时深度合并所有待加载的预设对象。
由于 Shortcuts
和 Rules
的写法还是不够灵活,特别是需要大量使用关系选择器和伪类和伪元素选择器的时候,最后虽然能够生成预期的样式,但是这个写法需要用 JS 实现工具类进行配合,且充斥着大量模板字符串的拼接,可读性比较差,大家可以从下面的例子中看出。
// packages/styles/src/unocss/button/rules.ts
// @unocss-include
import { Rule } from 'unocss';
import { Theme } from 'unocss/preset-mini';
import { CSSProperties } from 'vue';
import { getCssVar } from '../../utils';
import { ButtonCssVarsConfig } from '../../vars';
export const buttonRules: Rule<Theme>[] = [
[
'op-button-base',
<CSSProperties>{
'box-sizing': 'border-box',
'white-space': 'nowrap',
'user-select': 'none',
cursor: 'pointer',
outline: 'none',
display: 'inline-flex',
'align-items': 'center',
'justify-content': 'center',
'font-weight': 'normal',
'font-size': '14px',
'text-align': 'center',
'line-height': '1',
color: getCssVar<ButtonCssVarsConfig>('button-color'),
'background-color': getCssVar<ButtonCssVarsConfig>('button-bg-color'),
'border-width': '1px',
'border-style': 'solid',
'border-color': getCssVar<ButtonCssVarsConfig>('button-border-color'),
'border-radius': '4px',
padding: `${getCssVar<ButtonCssVarsConfig>('button-padding-y')} ${getCssVar<ButtonCssVarsConfig>('button-padding-x')}`,
} as any,
{
notInSafelist: true,
},
],
];
// packages/styles/src/unocss/button/shortcuts.ts
// @unocss-include
import { StaticShortcut, DynamicShortcut } from 'unocss';
import { Theme } from 'unocss/preset-mini';
import { CSSProperties } from 'vue';
import { cssVarToRgba, getCssVar } from '../../utils';
import { cssVarToShortcuts, stylesToShortcuts } from '../utils';
import { ButtonCssVarsConfig, ThemeCssVarsConfig } from '../../vars';
const disabledStyle: CSSProperties = {
color: getCssVar<ButtonCssVarsConfig>('button-disabled-color'),
'background-color': getCssVar<ButtonCssVarsConfig>('button-disabled-bg-color'),
'border-color': getCssVar<ButtonCssVarsConfig>('button-disabled-border-color'),
cursor: 'not-allowed',
};
export const buttonShortcuts: (StaticShortcut | DynamicShortcut<Theme>)[] = [
['op-button', `
op-button-base
${stylesToShortcuts({
color: getCssVar<ButtonCssVarsConfig>('button-hover-color'),
'background-color': getCssVar<ButtonCssVarsConfig>('button-hover-bg-color'),
'border-color': getCssVar<ButtonCssVarsConfig>('button-hover-border-color'),
}, 'hover')}
${stylesToShortcuts({
color: getCssVar<ButtonCssVarsConfig>('button-active-color'),
'background-color': getCssVar<ButtonCssVarsConfig>('button-active-bg-color'),
'border-color': getCssVar<ButtonCssVarsConfig>('button-active-border-color'),
}, 'active')}
`],
['op-button--disabled', `
${stylesToShortcuts(disabledStyle, '[&.op-button]')}
${stylesToShortcuts(disabledStyle, 'hover:[&.op-button]')}
${stylesToShortcuts(disabledStyle, 'active:[&.op-button]')}
`],
['op-button--primary', `${buttonTypeStyle('primary')}`],
['op-button--success', `${buttonTypeStyle('success')}`],
['op-button--warning', `${buttonTypeStyle('warning')}`],
['op-button--danger', `${buttonTypeStyle('danger')}`],
['op-button--info', `${buttonTypeStyle('info')}`],
['op-button--plain', `
${cssVarToShortcuts<ButtonCssVarsConfig>({
'button-hover-color': cssVarToRgba<ThemeCssVarsConfig>('color-primary'),
'button-hover-bg-color': cssVarToRgba<ThemeCssVarsConfig>('color-card'),
'button-hover-border-color': cssVarToRgba<ThemeCssVarsConfig>('color-primary'),
}, '[&.op-button]')}
${buttonPlainStyle('primary')}
${buttonPlainStyle('success')}
${buttonPlainStyle('warning')}
${buttonPlainStyle('danger')}
${buttonPlainStyle('info')}
`],
];
function buttonTypeStyle(type: string) {
return cssVarToShortcuts<ButtonCssVarsConfig>({
'button-color': cssVarToRgba<ThemeCssVarsConfig>('color-reverse'),
'button-bg-color': cssVarToRgba(`color-${type}`),
'button-border-color': cssVarToRgba(`color-${type}`),
'button-hover-color': cssVarToRgba<ThemeCssVarsConfig>('color-reverse'),
'button-hover-bg-color': cssVarToRgba(`color-${type}-light-3`),
'button-hover-border-color': cssVarToRgba(`color-${type}-light-3`),
'button-active-color': cssVarToRgba<ThemeCssVarsConfig>('color-reverse'),
'button-active-bg-color': cssVarToRgba(`color-${type}-dark-2`),
'button-active-border-color': cssVarToRgba(`color-${type}-dark-2`),
'button-disabled-color': cssVarToRgba<ThemeCssVarsConfig>('color-reverse'),
'button-disabled-bg-color': cssVarToRgba(`color-${type}-light-5`),
'button-disabled-border-color': cssVarToRgba(`color-${type}-light-5`),
}, '[&.op-button]');
}
function buttonPlainStyle(type: string) {
return cssVarToShortcuts<ButtonCssVarsConfig>({
'button-color': cssVarToRgba(`color-${type}`),
'button-bg-color': cssVarToRgba(`color-${type}-light-9`),
'button-border-color': cssVarToRgba(`color-${type}-light-5`),
'button-hover-color': cssVarToRgba<ThemeCssVarsConfig>('color-reverse'),
'button-hover-bg-color': cssVarToRgba(`color-${type}`),
'button-hover-border-color': cssVarToRgba(`color-${type}`),
'button-disabled-color': cssVarToRgba(`color-${type}-light-5`),
'button-disabled-bg-color': cssVarToRgba(`color-${type}-light-9`),
'button-disabled-border-color': cssVarToRgba(`color-${type}-light-8`),
}, `[&.op-button.op-button--${type}]`);
}
综合权衡之后,我们最终敲定了组件库样式方案的大方向,UnoCSS
和预处理器都会被使用:
- 使用
UnoCSS
的Preflight
特性注入主题 CSS 变量。由于 CSS 变量是在 ts 中定义的,后续实现换肤功能时可以复用主题变量对象的类型。 - 使用
UnoCSS
的Theme
特性将组件库的主题集成进去,为用户提供大量组件库主题相关的原子类。 - 由于
Shortcuts
和Rules
定义的语义化样式可读性较差,因此这部分我们还是采取 CSS 预处理器(Sass)编写,但是在实例源码中会保留纯UnoCSS
方案的代码供读者参考。
到此,在完成探索部分的内容后,建议复原 uno.config.ts
配置文件,为后续的正式编码做好准备。
// uno.config.ts
import { defineConfig, presetUno } from 'unocss';
export default defineConfig({
presets: [presetUno()],
});
样式模块的规划
我们计划建立子模块 @openxui/styles
,来负责处理组件库所有与样式相关的内容,这个模块主要负责:
- 提供各种与样式计算、样式生成相关的工具方法。
- 定义主题相关、组件相关的 CSS 变量。
- 实现组件库专用的
UnoCSS
预设。
📦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
模块被我们划分为了两个部分,我们完全可以把他们看做是一个子包内的两个独立模块:
- 第一部分是
UnoCSS
部分,它的产物是unoPreset.ts
提供的组件库专用UnoCSS
预设openxuiPreset
。这个模块是专门服务于构建的,只会在 Node.js 环境下运行。与上一章(4. 定制组件库的打包体系)的@openxui/build
打包体系类似,为了确保其源码能被uno.config.ts
、vite.config.ts
正确使用,它在依赖使用方面需要注意:- 不引用其他子包内部依赖。
- 谨慎使用只提供
esm
产物的外部依赖。
- 第二部分是主题部分,它的产物包括提供全局换肤能力的 Vue 插件、各个组件主题变量的定义、主题样式相关的工具方法。这个模块会在组件库的运行时生效,因此它在依赖使用方面需要注意:不能混用
UnoCSS
的部分只能在Node.js
环境下运行的方法。
因为文章的重点在于演示,篇幅有限,对于具体的组件,我们只实现 @openxui/button
的样式作为一个样例,其他组件的实现方式可以以此类推。
修改构建体系
经过上面的讨论,我们发现组件样式方案对打包体系提出了新的要求,我们需要对 4. 定制组件库的打包体系 中的打包体系进行以下调整:
- 打包组件时,我们需要增加
UnoCSS
的Vite
插件,并集成我们的UnoCSS
预设openxuiPreset
。 - 由于打包组件时产物中会多出一个
style.css
样式文件,我们需要在exports
字段中额外声明样式产物的入口。 @openxui/styles
子包甚至需要支持分模块多入口的构建方式。
优化多入口构建
首先,为了支持 package.json
中 exports
字段的多入口声明(了解 exports
字段可以回顾:1. 基于 pnpm 搭建 monorepo 工程目录结构),我们先调整 @openxui/build
包中的 src/generateConfig/options.ts
:
- 增加
exports
选项,支持将构建产物的相对路径写入package.json
中exports
模块入口对象的其他字段(不再仅支持exports['.']
)。 onSetPkg
钩子支持获取打包选项 options,更加方便我们对package.json
的修改。
// packages/build/src/generateConfig/options.ts
import { PackageJson } from 'type-fest';
import type { GenerateConfigPluginsOptions } from './plugins';
/** 自定义构建选项 */
export interface GenerateConfigOptions extends GenerateConfigPluginsOptions {
// 其他内容省略...
+ /**
+ * 是否将构建产物的相对路径回写到 package.json 的 exports 字段对应的 key 中。
+ *
+ * 必须在 mode 为 packages 时生效。
+ *
+ * 当取值为 '.' 时,还会同步写入 main、module、types 字段
+ */
+ exports?: string;
/**
* 是否将 d.ts 类型声明文件的产物从集中目录移动到产物目录,并将类型入口回写到 package.json 的 types 字段。
*
* 必须在 mode 为 packages 时生效。
*
* 输入 tsc 编译生成 d.ts 文件时所读取的 tsconfig 文件的路径。
*
* 空字符串或者 undefined 表示不处理 d.ts 文件的移动。
* @default ''
*/
dts?: string;
/**
* 完成构建后,准备回写 package.json 文件前对其对象进行更改的钩子。
*
* 必须在 mode 为 packages 时生效。
*/
- onSetPkg?: (pkg: PackageJson) => void | Promise<void>;
+ onSetPkg?: (pkg: PackageJson, options: Required<GenerateConfigOptions>) => void | Promise<void>;
}
/** 构建选项的默认值 */
export function defaultOptions(): Required<GenerateConfigOptions> {
return {
// 其他内容省略...
+ exports: '.',
// 其他内容省略...
};
}
// 其他内容省略...
随后我们调整 src/generateConfig/pluginSetPackageJson.ts
,对 exports
选项的新增以及 onSetPkg
选项的增强进行支持:
// packages/build/src/generateConfig/pluginSetPackageJson.ts
// 其他内容 ...
export function pluginSetPackageJson(
packageJson: PackageJson = {},
options: GenerateConfigOptions = {},
): PluginOption {
+ const finalOptions = getOptions(options);
const {
onSetPkg,
mode,
fileName,
outDir,
- dts,
+ exports,
- } = getOptions(options);
+ } = finalOptions;
if (mode !== 'package') {
return null;
}
const finalName = fileName || kebabCase(packageJson.name || '');
return {
name: 'set-package-json',
// 只在构建模式下执行
apply: 'build',
async closeBundle() {
const packageJsonObj = packageJson || {};
// 将 types main module exports 产物路径写入 package.json
const exportsData: Record<string, any> = {};
// 获取并设置 umd 产物的路径
const umd = relCwd(
absCwd(outDir, getOutFileName(finalName, 'umd', mode)),
false,
);
- packageJsonObj.main = umd;
exportsData.require = umd;
+ if (exports === '.') { packageJsonObj.main = umd; }
// 获取并设置 es 产物的路径
const es = relCwd(
absCwd(outDir, getOutFileName(finalName, 'es', mode)),
false,
);
- packageJsonObj.module = es;
exportsData.import = es;
+ if (exports === '.') { packageJsonObj.module = es; }
// 获取并设置 d.ts 产物的路径
- if (dts) {
const dtsEntry = getDtsPath(options);
- packageJsonObj.types = dtsEntry;
exportsData.types = dtsEntry;
+ if (exports === '.') { packageJsonObj.types = dtsEntry; }
- }
if (!isObjectLike(packageJsonObj.exports)) {
packageJsonObj.exports = {};
}
- Object.assign(packageJsonObj.exports, { '.': exportsData });
+ Object.assign(packageJsonObj.exports, {
+ [exports]: exportsData,
+ // 默认暴露的出口
+ './*': './*',
+ });
// 支持在构建选项中的 onSetPkg 钩子中对 package.json 对象进行进一步修改
if (isFunction(onSetPkg)) {
- await onSetPkg(packageJsonObj);
+ await onSetPkg(packageJsonObj, finalOptions);
}
// 回写入 package.json 文件
await writeJsonFile(absCwd('package.json'), packageJsonObj, null, 2);
},
};
}
// 其他内容 ...
构建预设调整
先前,我们在 packages/build/build.config.ts
里面存放构建预设,我们接下来对其做进一步规整:我们删除 build.config.ts
,建立 scripts
目录,分文件管理不同的构建预设。
📦openx-ui
┣ 📂...
┣ 📂packages
┃ ┣ 📂build
┃ ┃ ┣ 📂src
┃ ┃ ┃ ┣ 📜...
┃ ┃ ┃ ┗ 📜index.ts
+┃ ┃ ┣ 📂scripts # 新的构建预设
+┃ ┃ ┃ ┣ 📜vue.ts # vue 组件构建预设
+┃ ┃ ┃ ┣ 📜common.ts # 普通的 ts 库构建预设
+┃ ┃ ┃ ┗ 📜index.ts # 构建预设总出口
-┃ ┃ ┣ 📜build.config.ts # 旧的构建预设
┃ ┃ ┣ 📜package.json
┃ ┃ ┗ 📜vite.config.ts # 构建配置
┃ ┗ 📂...
┗ 📜...
packages/build/scripts/index.ts
导出所有构建预设:
// packages/build/scripts/index.ts
export * from './common';
export * from './vue';
packages/build/scripts/common.ts
中存放最基础的 ts
库(不涉及 vue
组件)构建预设:
// packages/build/scripts/common.ts
import { UserConfig } from 'vite';
import {
absCwd,
generateConfig as baseGenerateConfig,
GenerateConfigOptions,
} from '../src';
export function generateConfig(
customOptions?: GenerateConfigOptions,
viteConfig?: UserConfig,
) {
return baseGenerateConfig({
dts: absCwd('../../tsconfig.src.json'),
...customOptions,
}, viteConfig);
}
完成 packages/build/scripts/common.ts
的修改后,诸如 @openxui/shared
这样的纯 ts
库,也要根据构建预设位置的调整,修改其中的 import
导入:
// packages/shared/vite.config.ts
// packages/build/vite.config.ts
-import { generateConfig } from '../build/build.config';
+import { generateConfig } from '../build/scripts';
export default generateConfig();
在调整 vue
组件的构建预设之前,我们先全局安装 UnoCSS
的 @unocss/transformer-directives 作为开发依赖:
pnpm i -wD @unocss/transformer-directives
@unocss/transformer-directives 允许我们编写组件样式的时候,通过 @apply
语法聚合多个 UnoCSS
原子类,例如:
.op-button {
@apply text-14px cursor-pointer c-reverse bg-primary
}
/* 最终产物会被转化为 */
.op-button {
font-size: 14px;
}
回到构建预设部分,packages/build/scripts/vue.ts
中存放 vue
组件的构建预设,我们做了非常多的调整:
- 由于组件的构建过程中,许多样式需要 UnoCSS 来生成,因此要加入
UnoCSS
插件。 UnoCSS
插件中需要用到相应的预设:- 组件库的样式可能会借助
@apply
语法,聚合多个基础原子类,因此要使用@unocss/transformer-directives
,而对基础原子类的支持需要借助unocss/preset-uno
。 openxuiPreset
是我们的组件库相关的预设,辅助生成组件的样式,自然要在构建组件的过程中集成进来。
- 组件库的样式可能会借助
- 完成构建后,要将生成的样式文件
style.css
的入口写入package.json
的exports
字段。
// packages/build/scripts/vue.ts
import { mergeConfig, UserConfig } from 'vite';
import { presetUno, PresetUnoOptions } from 'unocss/preset-uno';
import unocss from 'unocss/vite';
import transformerDirectives from '@unocss/transformer-directives';
import { generateConfig } from './common';
import { absCwd, relCwd, GenerateConfigOptions } from '../src';
import { openxuiPreset, OpenxuiPresetOptions } from '../../styles/src/unoPreset';
/** 拓展构建选项 */
export interface GenerateVueConfigOptions extends GenerateConfigOptions {
/** 是否启用 UnoCSS 插件 */
pluginUno?: boolean;
/** 传递给 unocss/preset-uno 预设的配置 */
presetUnoOptions?: PresetUnoOptions;
/** 传递给组件库 UnoCSS 预设的选项 */
presetOpenxuiOptions?: OpenxuiPresetOptions;
}
export async function generateVueConfig(
customOptions?: GenerateVueConfigOptions,
viteConfig?: UserConfig,
) {
const {
pluginUno = true,
presetOpenxuiOptions,
presetUnoOptions,
} = customOptions || {};
const configPreset: UserConfig = {
plugins: [
pluginUno ? unocss({
/** 不应用 uno.config.ts 文件,所有配置直接传给插件 */
configFile: false,
presets: [
presetUno({
// 除了主题样式 theme,一般情况下,不打包 unocss/preset-uno 的预设
preflight: false,
...presetUnoOptions,
}),
// 集成组件库 UnoCSS 预设,组件的部分样式内容交由 UnoCSS 生成。
openxuiPreset(presetOpenxuiOptions),
],
transformers: [
// 支持在 css 中使用 @apply 语法聚合多个原子类
transformerDirectives(),
],
}) : null,
],
};
const optionsPreset: GenerateConfigOptions = {
pluginVue: true,
// 将组件样式文件的入口写入 package.json 的 exports 字段
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,
);
},
};
const res = await generateConfig({
...optionsPreset,
...customOptions,
}, mergeConfig(configPreset, viteConfig || {}));
return res;
}
如何使用调整后的组件构建预设,我们将在后文进行阐述:
- 5. 设计组件库的样式方案 - 下 - 实现组件库 UnoCSS 预设 会介绍
openxuiPreset
的具体实现。 - 5. 设计组件库的样式方案 - 下 - 单组件样式的完整实现 会以
@openxui/button
为例,介绍组件如何应用新的构建预设。
结尾与资料汇总
到这里,也许大家会有所疑惑:openxuiPreset
到底是做什么的?为何要这样调用?怎样实现的?
由于篇幅关系,我们只能在上篇完成准备工作——技术方向的讨论与探索、样式方案的整体规划、构建体系的配合调整。万事具备后,我们会在下半篇完成整个 @openxui/styles
模块的实现。届时,上面的问题都会得到解答。
本章涉及到的相关资料汇总如下:
官网与文档:
分享博文: