UnoCSS UI
- 项目地址
- 前言
- Monorepo 项目架构
- UnoCSS UI 的模块设计
- PNPM Monorepo 常用操作: --filter, -w
- Monorepo 中的依赖管理
- 项目整体结构
- 基于原子化 CSS 的组件封装方式
- 原子化 CSS 基础
- 原子化 VS 内联样式
- 原子化 VS class
- 原子化对组件封装的影响
- @unocss-ui/components 项目结构
- @unocss-ui/preset
- safelist
- theme
- example
- demo
- TODO
项目地址
Github 地址:https://github.com/cherryful/unocss-ui
example 预览地址:https://cherryful.github.io/unocss-ui/
前言
UnocssUI 是一个基于 UnoCSS0 带有原子化思想的现代化 Vue3 组件库,它的特点是简单,每个组件的实现之间没有任何依赖(只在 一个 .vue 文件实现),非常适合用来学习组件库的封装,同时它也在快速的迭代与建设中。
从该项目中你或许可以学习到:
- 基于 PNPM 的 Monorepo 项目架构:如何组织组件库的目录结构
- 基于原子化 CSS 的组件封装方式:原子化 CSS 极大的简化了组件的封装
- 一种倾向于简洁的编码思想:可以减少编码的心智负担
- 开发带有动态调试功能的文档:这是一种非常灵活的文档展现形式
- …
很多思想与方式都是个人参考很多开源项目 + 自己的理解所得到的,如果有问题或者更好的方案,欢迎交流
在开始前,先简单看一下组件库的使用方式:
需要安装三个依赖:unocss,unocss-ui,@unocss/reset
pnpm add unocss unocss-ui @unocss/reset
使用方式一:在 main.ts 进行全局注册,后续在 .vue
中使用无需引入
// 注意顺序
import '@unocss/reset/tailwind.css'
import 'unocss-ui/style.css' // 目前,无论使用哪种方式都必须引入组件库的 CSS 文件
import 'uno.css'
import unocssui from 'unocss-ui'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(unocssui).mount('#app')
使用方式二:不进行全局注册,需要在 .vue
页面文件中单独引入:
// App.vue
<script setup>
import { UButton } from 'unocss-ui'
</script>
<template>
<UButton> Hello </UButton>
</template>
方式二 TODO:后续实现按需引入并打包的功能(非常重要)
Monorepo 项目架构
Monorepo 是现在比较流行的一种前端项目架构,简单来说就是:一个仓库内包含多个开发项目(模块)。
- 整个项目的根目录下有一个 package.json
- 子目录下可能还会有 package.json 即子模块
- 基于 PNPM 的 Monorepo 由
pnpm-workspace.yaml
文件来指定哪些目录可能为子模块
参考文档:https://www.pnpm.cn/pnpm-workspace_yaml
本项目的 pnpm-workspace.yaml
内容如下:
# pnpm-workspace.yaml
packages:
- 'packages/*'
- example
- demo
其含义是指 packages 目录下的所有目录,及根目录下的 example 和 demo 可以为子模块。
即该项目总共有 5 个子模块,加上根模块,共计 6 个模块,在拉取本项目并执行 pnpm install 时也会提示。
UnoCSS UI 的模块设计
在 UnoCSS UI 项目中,最外层的根模块是 unocss-ui-monorepo
,其中包含了以下子模块:
example
:用于开发组件时进行实时预览,以及作为一个示例项目展示demo
:用于测试打包后的组件包是否正常(不能用来做开发时预览,因为导入的是打包后的 package)@unocss-ui/components
:组件的源码@unocss-ui/preset
:根据组件库自身需求抽取出来的 UnoCSS 的预设unocss-ui
:项目的入口文件,在这里引用@unocss-ui/components
和@unocss-ui/preset
这里解释一下 @unocss-ui/components
、@unocss-ui/preset
、unocss-ui
三个模块的关系
@unocss-ui/components
是组件库的源码,包含Button.vue
、Tag.vue
等文件,可以直接从该模块中引入组件:
// App.vue
import { UButton } from '@unocss-ui/components'
@unocss-ui/preset
是封装的 UnoCSS 预设文件,需要搭配其实现组件库的某些功能,引用方式:
// uno.config.ts
import { presetUnocssUI } from '@unocss-ui/preset'
unocss-ui
这个模块相当于是上面两个模块的汇总,使得用户只需要安装unocss-ui
模块即可使用其中的子模块:
// App.vue
import { UButton } from 'unocss-ui'
// uno.config.ts
import { presetUnocssUI } from 'unocss-ui'
如果你愿意,也可以分别安装
@unocss-ui/components
和@unocss-ui/preset
模块。
顺带一提,子模块的命名往往都是 @组织名/模块名
,UnoCSS UI 也采用这种设计。
PNPM Monorepo 常用操作: --filter, -w
PNPM 多模块下常用的命令:
--filter
或-F
后跟模块名,表示后面的操作是在该模块进行-w
表示在根模块下进行操作
根模块下安装的依赖,子模块是可以直接使用的,而子模块之间互相不共享依赖。
请熟悉以下几个例子:若不清楚 -D
、add
等指令含义,先查询资料补一些 NPM / PNPM 基础
# 给 example 模块添加 Vue 依赖
pnpm --filter example add vue
# 给根模块添加 eslint 开发依赖
pnpm add -wD eslint
# 在 @unocss-ui/components 模块下执行 pnpm build 操作
# 需要在 @unocss-ui/components 模块下的 scripts 中配置 build 脚本
pnpm --filter @unocss-ui/components build
# 在 example 模块下执行 pnpm dev 操作
# 需要在 example 模块下的 scripts 中配置 dev 脚本
pnpm --filter example dev
在根模块的 package.json 中,配置了一些执行脚本:根据上面的介绍能理解,就是对子模块的操作在根目录下进行了汇总。
"scripts": {
// 在 @unocss-ui/components 下执行 pnpm build
"build:components": "pnpm --filter @unocss-ui/components build",
"build:preset": "pnpm --filter @unocss-ui/preset build",
"build:unocss-ui": "pnpm --filter unocss-ui build",
// 同时执行上面 3 个构建操作
"build:all": "pnpm build:preset && pnpm build:components && pnpm build:unocss-ui",
// 在 example 下执行 pnpm dev
"dev:example": "pnpm --filter example dev",
"build:example": "pnpm --filter example build",
"preview:example": "pnpm --filter example preview",
"dev:demo": "pnpm --filter demo dev",
"build:demo": "pnpm --filter demo build",
"preview:demo": "pnpm --filter demo preview",
"lint": "eslint .",
"lint:fix": "eslint --fix ."
}
这样的好处是每次要操作子模块,只需要在根目录下进行操作,而无需不停的 cd 切换到子目录去执行操作。(当然进入子模块后再执
行操作也是可以的)
Monorepo 中的依赖管理
最偷懒的做法是将所有需要的依赖安装到根模块中,子模块中都可以直接使用。
但是我认为还是需要做一些区分的:
- 在根模块中安装一些所有子项目通用的开发时依赖,如:eslint,typescript,lint-staged 等。
- 在各个子模块安装其需要的依赖,例如 demo 和 example 都是 Vue 项目,可以分别安装 vue、vite 等依赖,而 @unocss-ui/preset 只是个标准的 TypeScript 项目,无需安装 vue 等依赖。
这样的好处是你把子模块视作单独的项目时也可以清楚的知道它必须有哪些依赖。
当然这些并不是强制性的,如果嫌麻烦,全部使用
pnpm -w add xxx
安装在根模块就可以了。
项目整体结构
经过上面的介绍,其实项目整体结构很简单,再梳理一下核心内容如下:
.
├── demo // 子项目 demo: 用于测试组件库打包的 Vue 项目
├── example // 子模块 example: 用于开发组件时进行实时预览,以及作为一个示例项目展示的 Vue 项目
├── LICENSE
├── package.json // 根模块 unocss-ui-monorepo
├── packages
│ ├── components // 子模块 @unocss-ui/components: 组件库源码
│ ├── preset // 子模块 @unocss-ui/preset: 组件库的 UnoCSS 预设
│ └── unocss-ui // 子模块 unocss-ui: 组件库的汇总
├── pnpm-workspace.yaml // 设置 pnpm monorepo 的子模块
├── README.md
├── tsconfig.json
└── // 省略一些配置文件如 .npmrc .eslintrc .gitignore 等
以上是这个项目的整体架构,知道了这些可以让你熟悉项目的结构,从而减少对其的恐惧。但是不可能项目每个文件都由我来介绍的清清楚楚,下面聊聊我认为比较合理的源码学习与查看方式。
对该项目的学习应该基于以下的原则:
1、从整体到局部,从根模块到子模块。首先应当看项目根目录下有哪些文件,例如本项目根目录下有一些我没有介绍到的文件:.eslintrc
,.npmrc
,tsconfig.json
等,因为这些并不影响你对项目的整体理解。但是我建议遇到没见过的后缀名就百度查询一下相关资料,至少需要对其有简单的概念(是怎么产生的,是否是某个开发依赖的配置文件,用来做什么的),久而久之的积累下来会发现:前端其实就那么些个配置文件!
对于每个子模块中的内容,可以不用一开始全部看完,但是需要知道这个模块是用来干嘛的(这个我已经在上面介绍过了)。
2、先从项目的依赖开始看起。package.json 中有非常多的配置项,其中大部分与打包与发布有关,初次学习应该将重点放在 dependencis
和 devDependencies
这两个配置上,看看是否所有的依赖项都认识,如果遇到不认识的依赖还是需要自行百度。
3、熟悉了整体结构,可以开始看细节。
基于上面的介绍,项目的整体结构应当是非常清晰的。
基于原子化 CSS 的组件封装方式
原子化 CSS 基础
如果你对原子化 CSS 没有任何概念,建议先从 TailwindCSS 开始了解并查询相关资料,理解它是什么,如何使用的。但是很多人哪怕接触过原子化 CSS,可能也无法理解它的好处。
在我看来,原子化 CSS 最大的好处其实是 减少人的编码心智负担。
或许你会感到奇怪,一大堆的类似内联样式的书写方式,把代码写在 HTML 标签里,怎么会减少人的心智负担呢,应该是加大了心智负担才对呀!如果这样能减少心智负担,那远古时期就应该流行内联样式才对!
原子化 VS 内联样式
首先原子化 CSS 和内联样式是不一样的,主要体现在它非常的简短,例如实现这么一个简单圆圈效果:
<!-- 使用内联样式 -->
<div style="width: 1.25rem; height: 1.25rem; background-color: white; border: 2px solid red; border-radius: 100%;" />
<!-- 使用原子化 CSS -->
<div class="m-15 h-15 w-15 border-5 border-red-500 rounded-full bg-white" />
从这个角度来看原子化 CSS 可以认为是内联样式的简写,毫无疑问比它短很多,样式越多越明显。
如果要再给这个圆圈添加一个鼠标悬浮效果呢?常规的内联样式无法实现,需要借助选择器。而在原子化 CSS 中只需要这样添加以下属性:hover:bg-gray-100
<div class="m-15 h-15 w-15 border-5 border-red-500 rounded-full bg-white hover:bg-gray-100" />
再比如要给所有子标签添加边距这么一个简单的功能:
<div>
<span> A </span>
<span style="margin-left: 16px;"> B </span>
<span style="margin-left: 16px;"> C </span>
</div>
使用原子化 CSS 非常简单:
<div class="space-x-4">
<span> A </span>
<span> B </span>
<span> C </span>
</div>
原子化 CSS 包含了很多功能性极强的 class,因此它其实是内联样式的全方面升级。
原子化 VS class
这点我认为没有太大比较的必要,class 的本质是将相同属性的标签抽象出一份来维护。原子化也可以通过 @apply
实现类似的效果,而且更加灵活:
<div class="flex gap-2">
<div class="circle border-red-500 ">
<div class="circle border-blue-500">
<div class="circle border-green-500">
</div>
.circle {
@apply m-15 h-15 w-15 border-5 rounded-full bg-white hover:bg-gray-100;
}
毫无疑问相比原生 css 它依旧突出一个简洁。
在原子化中,@apply 这种用法只是用来辅助,大部分情况下都我都更愿意直接用原子化写,如果实在是重复的太多了,就用它。
因为使用 class 很大程度上会有一个困扰,也就是起名问题,原子化可以避免这个问题。
原子化对组件封装的影响
使用原子化 CSS 可以减少心智负担主要体现在以下几个方面:
- 不用非常频繁的起类名,只会偶尔需要用到 class
- 虽然类似内联样式,但是从写法上来说比它简洁太多,视觉效果很好
- 写某个模块时不需要额外考虑对其他模块的影响,只需要注重当前内容
传统组件封装中由于 style 中的内容过多,我们一般会抽取出一个单独的 .css 文件,并且还要尽量想办法去进行各种抽取有共同点的 class 等操作,这些都是对 “心智负担” 的压力的体现。
使用了原子化的思路减少了巨量的 css,完全可以不考虑以上的操作。并且可以实现一个组件文件尽量减少其外部依赖,即我认为基于原子化 CSS 的组件文件完全可以只由一个 .vue
文件实现其全部内容,而不是在设计层面抽象出各种依赖再引入,这一点也实在是令人有巨大的心智负担。
尤其是在看别人的项目的时候,我经常会想着看看它的组件中的某一个很小的功能是如何实现的,奈何有些项目实在是将一个模块拆分的七零八碎,各种引入,令人眼花缭乱,更有甚者会滥用 “自动引入依赖” 这种功能,导致有时候在网页上看看代码根本无法理解一些逻辑的源自哪里。
UnoCSS UI 从开发之初的设计理念就是:简单就是最好的,因此:
- 项目中没有使用那些看起来甜甜的插件(自动导入等)
- 每个组件不过多的依赖于其他东西,所有组件只依靠一个
.vue
便实现其所有功能,如果你只想看某个组件的具体实现,只需要没有任何压力的看这个组件的 .vue 就可以了。
例如你可以查看这个 Button.vue 查看一下封装一个 Button 代码量其实非常的少 。
因此我认为在理解了整个项目的设计结构后,剩下的事情就非常简单了:挑选你感兴趣并想学习的组件源码去查看学习。
如果是萌新,尽量从比较简单的组件开始学习:Button,Tag,Alert 等。
@unocss-ui/components 项目结构
基于以上思想来看,其实 @unocss-ui/components
这个子项目的源码结构非常简单:
.
├── package.json
├── README.md
├── src
│ ├── components // 组件源码尽量简单独立、无依赖,一个 .vue 实现所有功能
│ │ ├── Alert.vue
│ │ ├── Badge.vue
│ │ ├── Button.vue
│ │ ├── Checkbox.vue
│ │ ├── ...
│ ├── composables // 一些可复用的函数
│ └── index.ts // 组件库入口文件,用于导出所有组件
├── tsconfig.json
├── tsconfig.node.json
├── uno.config.ts
└── vite.config.ts
@unocss-ui/preset
该 preset 没有做太复杂的事情,核心就是定义了 safelist 和 theme 两个属性。
safelist
就像之前所说的,该组件库中的组件都是不依赖于外部文件的,其实 preset 的主要意义是设置一些 safelist。
简单来说 safelist 解决的问题就是动态拼接 CSS 失效的问题,例如现在需要封装一个可以传入颜色的圆圈组件:
<script setup>
defineProps({
color: { type: String, default: 'red' },
})
</script>
<template>
<div
class="h-15 w-15 border-5 rounded-full bg-white hover:bg-gray-100"
:class="`border-${color}-500 `"
/>
</template>
<div class="m-15 flex gap-2">
<Circle color="red" />
<Circle color="blue" />
<Circle color="green" />
</div>
以上代码非常合理,但却不一定会生效,因为 其中包含字符串拼接的 class,如果想要确保它一定会生效,可以配置 safelist:
// uno.config.ts
safelist: ['bg-red-500', 'bg-blue-500', 'bg-green-500']
因此 @unocss-ui/preset
大部分的配置其实就是针对 safelist,来保证我在代码中使用动态拼接 class 是正常的。
theme
参考:https://unocss.dev/config/theme#theme
在项目中,往往都是有 “主题色” 的概念的,UnoCSS UI 预设中约定了主题色为以下:
- primary - indigo
- secondary - teal
- accent - pink
- success - green
- info - blue
- warning - yellow
- error - red
import { colors } from 'unocss/preset-mini'
// ...
theme: {
colors: {
primary: colors.indigo, // 简单的使用调色板颜色,否则需要自行书写 50, 100, 200, 300 这些数字对应的具体颜色
secondary: colors.teal,
accent: colors.pink,
success: colors.green,
info: colors.blue,
warning: colors.yellow,
error: colors.red,
},
},
// ...
使用 bg-primary-500
等价于 bg-indigo-500
,这样的好处是当你想一键切换主题只需要改一行配置即可。
同时,为了让代码中使用的更随心所欲,我们将其与 safelist 结合,来实现代码中可以使用动态拼接成主题色的 class
const types = ['primary', 'secondary', 'accent', 'success', 'info', 'warning', 'error']
// ...
safelist: [
...types.map(t => nums.map(n => `bg-${t}-${n}`)).flat(),
...types.map(t => nums.map(n => `text-${t}-${n}`)).flat(),
// ...
],
对于大部分组件来说,完全可以接受一个类型固定的 type,然后动态的拼接到 bg
,text
等 class 中
defineProps<{
type?: 'success' | 'info' | 'warning' | 'error' | 'primary' | 'secondary' | 'accent'
}()
// ...
<div
// ....
:class="text-${type}-700 bg-${type}-500"
>
// ...
</div>
目前的 preset 源码 也比较简单,只是遇到了需要动态拼接的 class 情况才放到这里,后面会按需增加:
import type { Preset } from 'unocss'
import { colors } from 'unocss/preset-mini'
const types = ['primary', 'secondary', 'accent', 'success', 'info', 'warning', 'error']
const nums = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900', '950']
const sizes = ['sm', 'md', 'lg', 'full']
export function presetUnocssUI(): Preset {
return {
name: '@unocss-ui/preset',
safelist: [
...types.map(t => nums.map(n => `bg-${t}-${n}`)).flat(),
...types.map(t => nums.map(n => `border-${t}-${n}`)).flat(),
...types.map(t => nums.map(n => `text-${t}-${n}`)).flat(),
...types.map(t => nums.map(n => `focus:ring-${t}-${n}`)).flat(),
...types.map(t => nums.map(n => `focus:border-${t}-${n}`)).flat(),
...types.map(t => `border-r-${t}-500`),
...sizes.map(s => `rounded-${s}`),
],
theme: {
colors: {
primary: colors.indigo,
secondary: colors.teal,
accent: colors.pink,
success: colors.green,
info: colors.blue,
warning: colors.yellow,
error: colors.red,
},
},
}
}
顺带一提,@unocss-ui/preset
不仅用于该组件库,也可以单独使用。
只要在你的项目中引用了该 preset,即可以使用 bg-primary-500
这种带有主题风格的颜色 class。
当你想覆盖 UnoCSS UI 默认的主题配置时,也需要引入其并重新覆盖 theme 即可:
import { defineConfig, presetUno } from 'unocss'
import { colors } from 'unocss/preset-mini'
import { presetUnocssUI } from 'unocss-ui'
export default defineConfig({
theme: {
colors: {
primary: colors.red, // 覆盖 primary
secondary: colors.blue, // 覆盖 secondary
accent: colors.green, // 覆盖 accent
},
},
presets: [
presetUno(),
presetUnocssUI(), // 引入该 preset
],
})
example
example 预览地址:https://cherryful.github.io/unocss-ui/
example 这个子项目目前的意义主要在于开发时的调试,以及本身作为一个示例项目来展示组件。
只需要通过以下指令运行起来,然后就可以直接修改 @unocss-ui/components
中的源码,就能进行实时的开发调试。
git clone https://github.com/cherryful/unocss-ui
cd unocss-ui
pnpm install
pnpm dev:example
demo
demo 这个子项目是用来测试组件库打包效果的,当你开发完 @unocss-ui/components 或 @unocss-ui/preset 后可以看看打包后的效果是否正常:
pnpm build:all
pnpm dev:demo
TODO
这篇文章想讲的东西还有很多(后面还会更新),并且组件库本身也在快速开发与迭代中,非常欢迎各位同学参与建设,学习源码,对我提出意见。
如果觉得本文能帮上你,或者认为这个项目还不错,欢迎给个 Star。
Github 地址:https://github.com/cherryful/unocss-ui