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

3,670 阅读21分钟

导航

导航:0. 导论

上一章节: 4. 定制组件库的打包体系

下一章节:5. 设计组件库的样式方案 - 下

本章节示例代码仓:Github

我们的组件库样式方案中,除了会使用到经典的 CSS 预处理器,还会纳入一个很新的 CSS 工具——UnoCSS。样式方案需要建立在先前构建方案的基础之上,甚至还需要对已有的构建方案做出改进。因此,这里推荐大家对之前的内容做一些回顾:

1. 基于 pnpm 搭建 monorepo 工程目录结构

2. 在 monorepo 模式下集成 Vite 和 TypeScript - 上

2. 在 monorepo 模式下集成 Vite 和 TypeScript - 下

4. 定制组件库的打包体系

组件库样式方案的洞察

对于组件库的样式方案,我们可能会有以下要求:

  1. 组件库的样式能否支持按需导入,使用户的项目产物体积得以最小化?
  2. 如何尽可能地减少组件库样式与用户样式的冲突?
  3. 如何让用户方便地修改微调组件样式?
  4. “换肤能力”称得上是当下组件库的标配,我们的方案能支持主题切换功能吗?

目前,主流的组件库方案按照以下思路达成这些要求:

分组件打包样式

为每一个组件单独打包出 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 变量,可以通过这些文章进行了解:

MDN:使用 CSS 自定义属性

张鑫旭:了解CSS变量var

如何在CSS中写变量?一文带你了解前端样式利器

一文讲透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 CSSWindi CSSUnoCSS,之所以将原子化 CSS 方案再度推向高潮,是因为其解决了上述的三大痛点:

  • 它们都推出了 VSCode 插件,为编写原子类提供了充分的提示与自动补全。
  • 它们都在构建阶段扫描代码,能够按照代码中的实际使用情况生成工具类,解决了原子类使 CSS 产物膨胀问题。
  • 针对原子类堆叠降低可读性的问题,提供了 @apply 语法支持在 CSS 中对多个原子类进行合并,与语义化 CSS 实现了很好的配合。

推荐大家阅读以下文章,更详细地了解原子化 CSS:

重新构想原子化 CSS

从 Tailwind CSS 到 UnoCSS —— 原子化真的是前端CSS的救星吗

简单集成 UnoCSS

既然提到 UnoCSS 是本章的主角,那么我们就先来试用一下,首先全局安装 UnoCSS

pnpm i -wD unocss

不安装插件会使我们频繁查阅文档,显著降低原子 CSS 的书写体验。因此我们应该先集成好 VSCode 插件,直接在插件市场搜索 UnoCSS 即可:

uno-vscode-ext.png

UnoCSS 插件的 ID antfu.unocss 加入 .vscode/extensions,以推荐新贡献者自动安装插件:

// .vscode/extensions.json
{
  "recommendations": [
    // ...
+   "antfu.unocss",
  ]
}

在根目录创建 uno.config.ts 来编写相关配置,只需集成 presetUno,就能够使用绝大多数的原子类,具体原子类的写法请参考:Tailwind CSSWindi 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 插件后原子类的编写体验有多么顺滑。

uno-vscode-ext-show.gif

接下来需要在 packages/button/src/main.ts 中引入 UnoCSS 的虚拟模块:

// packages/button/src/main.ts
import Button from './button.vue';
+import 'virtual:uno.css';

export { Button };

调整一下 buttonvite.config.ts 中的构建配置(回顾:4. 定制组件库的打包体系),集成 unocssVite 插件:

// 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 的确按需生成了对应的样式类:

uno-basic-production.png

探索 UnoCSS 与组件库样式的配合方式

但是,从上面的情况来看,原子化 CSS 似乎不适合用来构建组件库,下面摘录一条掘金评论来说明:

第三方库使用 UnoCSS 这样的原子化 css 是否合理,如果在业务中需要通过 css scoped 修改组件元素的样式,因为组件元素不具有特有的 css 类名,会不会不太方便?

的确,ml-2pxtext-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)',
    },
  },
});

这样,如果组件库的用户集成了我们的预设,他还可以使用组件库主题相关的原子类!

uno-theme-atomic-css.png

UnoCSS 实现组件库样式方案,其中一个巨大优势就是能“附赠”用户大量组件库主题相关的原子类。

生成语义化 CSS

UnoCSSRulesShortcuts 主要决定 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

button-css-generated.gif

我们的选择

梳理一下上面的思路,用 UnoCSS 实现组件库的样式方案,其实就是组合其生成 CSS 的各种能力:RulesShortcutsVariantsPreflightsTheme 等,为每个组件都生成一套预设,打包出组件需要的语义化 CSS。所谓预设 Preset 其实就是 UnoCSS 配置项的大集合,UnoCSS 会在初始化时深度合并所有待加载的预设对象。

由于 ShortcutsRules 的写法还是不够灵活,特别是需要大量使用关系选择器伪类和伪元素选择器的时候,最后虽然能够生成预期的样式,但是这个写法需要用 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 和预处理器都会被使用:

  • 使用 UnoCSSPreflight 特性注入主题 CSS 变量。由于 CSS 变量是在 ts 中定义的,后续实现换肤功能时可以复用主题变量对象的类型。
  • 使用 UnoCSSTheme 特性将组件库的主题集成进去,为用户提供大量组件库主题相关的原子类。
  • 由于 ShortcutsRules 定义的语义化样式可读性较差,因此这部分我们还是采取 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.tsvite.config.ts 正确使用,它在依赖使用方面需要注意:
    1. 不引用其他子包内部依赖。
    2. 谨慎使用只提供 esm 产物的外部依赖。
  • 第二部分是主题部分,它的产物包括提供全局换肤能力的 Vue 插件、各个组件主题变量的定义、主题样式相关的工具方法。这个模块会在组件库的运行时生效,因此它在依赖使用方面需要注意:不能混用 UnoCSS 的部分只能在 Node.js 环境下运行的方法。

因为文章的重点在于演示,篇幅有限,对于具体的组件,我们只实现 @openxui/button 的样式作为一个样例,其他组件的实现方式可以以此类推。

修改构建体系

经过上面的讨论,我们发现组件样式方案对打包体系提出了新的要求,我们需要对 4. 定制组件库的打包体系 中的打包体系进行以下调整:

  • 打包组件时,我们需要增加 UnoCSSVite 插件,并集成我们的 UnoCSS 预设 openxuiPreset
  • 由于打包组件时产物中会多出一个 style.css 样式文件,我们需要在 exports 字段中额外声明样式产物的入口。
  • @openxui/styles 子包甚至需要支持分模块多入口的构建方式。

优化多入口构建

首先,为了支持 package.jsonexports 字段的多入口声明(了解 exports 字段可以回顾:1. 基于 pnpm 搭建 monorepo 工程目录结构),我们先调整 @openxui/build 包中的 src/generateConfig/options.ts

  • 增加 exports 选项,支持将构建产物的相对路径写入 package.jsonexports 模块入口对象的其他字段(不再仅支持 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.jsonexports 字段。
// 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;
}

如何使用调整后的组件构建预设,我们将在后文进行阐述:

结尾与资料汇总

到这里,也许大家会有所疑惑:openxuiPreset 到底是做什么的?为何要这样调用?怎样实现的?

由于篇幅关系,我们只能在上篇完成准备工作——技术方向的讨论与探索、样式方案的整体规划、构建体系的配合调整。万事具备后,我们会在下半篇完成整个 @openxui/styles 模块的实现。届时,上面的问题都会得到解答。

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

官网与文档:

UnoCSS

Tailwind CSS

Windi CSS

Sass

element-plus

Vant

分享博文:

MDN:使用 CSS 自定义属性

张鑫旭:了解CSS变量var

如何在CSS中写变量?一文带你了解前端样式利器

一文讲透CSS变量和动态主题的内在联系

重新构想原子化 CSS

从 Tailwind CSS 到 UnoCSS —— 原子化真的是前端CSS的救星吗