序
到《这里有从零开始构建现代化前端UI组件库所需要的一切(四)》为止,其实我们已经基本上实现了现代化前端 UI 组件库所需的基础框架和功能,其中包括设计理念选择、工程结构建设、开发流程优化、自动化测试、文档生成、版本管理、发布等方面的内容。当然在这个阶段,我们仍然可以在一些方向上来进一步完善和优化我们的组件库:
-
优化性能
: 检查组件库的性能,并采取措施进行优化。这可能包括代码拆分、懒加载、缓存策略等,以确保在各种场景下都能提供流畅的用户体验。 -
增加复杂组件
: 考虑引入一些更复杂、更功能丰富的组件,如表格、模态框、导航栏等,以满足更广泛的应用场景。 -
响应式设计
: 确保组件库能够良好地适应不同屏幕尺寸和设备,实现响应式设计,提供一致的用户体验。 -
文档和示例
: 完善组件库的文档,提供清晰的使用说明和示例代码。良好的文档可以帮助用户更容易上手和使用你的组件库。 -
测试覆盖率
: 增加自动化测试的覆盖范围,确保组件库的稳定性和可靠性。可以考虑引入更多的单元测试、集成测试等。 -
用户体验提升
: 关注用户体验,通过用户反馈和数据分析,不断改进组件的设计、交互和动画效果,提供更好的用户感受。 -
国际化和本地化
: 如果你的组件库可能被用于不同语言环境的项目中,考虑实现国际化和本地化支持,以提供更广泛的应用范围。 -
主题定制
: 如果可能,考虑提供主题定制的功能,使用户能够根据自身项目的设计规范进行定制。 -
浏览器兼容性
: 确保组件库在主流浏览器中的兼容性,修复可能存在的浏览器兼容性问题。 -
安全性
: 审查组件库的代码,确保没有安全漏洞,并采取必要的安全措施,以保护用户和项目的安全。 -
监控和分析
: 集成监控和分析工具,收集组件库的使用数据和性能指标,以便及时发现和解决问题。
通过在这些方向上的努力,无论是开源或者公司内部使用,你都可以打造出一个更加完善、稳定和用户友好的 UI 组件库,而我们今天这一篇文章也会向着这个方向继续前进。
那么今天我们将探讨如何为组件库实现出一套灵活易用且可扩展的主题功能,当然同时我们也会考虑到代码的组织方式,不会出现复杂混乱的代码的和样式表。
因为本次代码改动很多,也没有花时间去整理,所以今天这一篇文章建议大家先把对应的
commit
的代码先同步到本地,然后跟随文章一起熟悉它们:代码:commit 288af89
简述主题切换的实现以及方案的确定
其实主题切换的实现方式有很多,但这里我们主要采用最主流也是最适合 UI 组件库中的实现方式:CSS-in-JS
,但这里我们采用的并不是Runtime(运行时)
的方案,而是使用偏向构建时生成样式的Zero-Runtime(接近零运行时)
的方案,基于 vanilla-extract/style 库来实现。
在开始之前我们先通过几个最简单的例子来了解一下主题切换的实现原理(当然主题切换的实现方案有很多种,我们这里就围绕最主流也是最符合CSS-in-JS
的方案来讲解):
-
通过
class
类选择器(后代选择器)来实现html<!DOCTYPE html> <html lang="en" class="light/dark"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <style> body { margin: 0; } .container { height: 100vh; } .light .container { background-color: #fff; } .dark .container { background-color: #000; } </style> </head> <body> <div class="container"></div> </body> </html>
通过切换
html
标签的class
为light
或者dark
就可以实现div.container
的背景颜色随着主题而变化了。在一些CSS
预处理器(Sass
、Less
orStylus
)中,可以借助Mixin
来提升效率(这里以Sass
为例):scss@mixin light-theme() { .light & { @content; } } @mixin dark-theme() { .dark & { @content; } } .container { // basic css @include light-theme { background-color: #ffffff; } @include dark-theme() { background-color: #000000; } }
-
CSS 变量
: 使用 CSS 变量定义主题相关的样式属性,通过修改变量值来改变主题。这种方式在现代浏览器中得到广泛支持,同时也是和后面我们将使用的vanilla-extract
最为接近的方式。html<!DOCTYPE html> <html lang="en" class="light/dark"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <style> body{ margin: 0; } :root, .light { --color-background: #fff; } .dark { --color-background: #000; } .container { height: 100vh; background-color: var(--color-background); } </style> </head> <body> <div class="container"></div> </body> </html>
这里切换
html
标签的class
为light
或者dark
也可以实现div.container
的背景颜色随着主题而变化。
当然也有一些其它的方式,比如:
- 样式表替换: 切换不同的样式表文件,每个样式表文件对应一个主题。通过更改文档中链接的样式表路径或动态插入样式表元素来实现主题切换。
- JavaScript 动态修改样式: 使用 JavaScript 动态修改 DOM 元素的样式属性。这可以通过操作样式对象或者直接设置元素的 style 属性来实现。
这里先补充一下有关 vanilla-extract/style 的知识:vanilla-extract/style
是一个用于构建可维护和高性能样式的工具,而 vanilla-extract/style
是其中的一部分,用于定义和组织样式。
-
特点和用法:
-
原子化的样式定义:
vanilla-extract/style
鼓励原子化的样式定义,即将样式拆分为小的、可复用的原子类。 -
CSS-in-JS 风格: 类似于 CSS-in-JS 的方式,但更强调原子类的使用。样式可以通过 JavaScript 对象的方式进行定义。
-
可组合性: 支持样式的组合,可以将多个原子类组合在一起,实现更复杂的样式。
-
TypeScript 支持:
vanilla-extract
与 TypeScript 集成良好,提供了强类型支持。样式定义和使用时可以得到类型检查。 -
性能优化:
vanilla-extract
生成的样式是静态的,可以在构建时进行提取和优化,减少运行时的样式计算开销,从而提高性能,也就是Zero-Runtime(接近零运行时)
的。
示例:
tsx// styles.css.ts import { style } from '@vanilla-extract/style'; export const button = style({ padding: '10px', borderRadius: '5px', backgroundColor: 'blue', color: 'white', }); // Component.tsx import React from 'react'; import { button } from './styles.css.ts'; const MyComponent: React.FC = () => { return <button className={button}>Click me</button>; };
在上面的示例中,
button
样式被定义为一个原子类,并在组件中应用。在构建时,vanilla-extract
将生成一个优化后的样式表,其中包含静态的 CSS 样式,以提高性能。 -
vanilla-extract/style
提供了一种结构化、类型安全且高性能的样式定义方式,适用于构建现代化的、可维护的前端应用。还有一点vanilla-extract/style
支持Variants API
(受 Stitches 启发,但因为一些 原因 目前Stitches
已不再积极维护),可以与 TypeScript
集成,提供类型安全的样式定义,提供一流的开发体验:
ts
// button.css.ts
import { recipe } from "@vanilla-extract/recipes";
export const button = recipe({
base: {
borderRadius: 6,
},
variants: {
color: {
neutral: { background: "whitesmoke" },
brand: { background: "blueviolet" },
accent: { background: "slateblue" },
},
size: {
small: { padding: 12 },
medium: { padding: 16 },
large: { padding: 24 },
},
rounded: {
true: { borderRadius: 999 },
},
},
// Applied when multiple variants are set at once
compoundVariants: [
{
variants: {
color: "neutral",
size: "large",
},
style: {
background: "ghostwhite",
},
},
],
defaultVariants: {
color: "accent",
size: "medium",
},
});
// Get the type
export type ButtonVariantProps = RecipeVariants<typeof button>;
export { button };
ts
import { useMemo } from "react";
import { button,type ButtonVariantProps } from "./button.css";
// type ButtonVariantProps =
// | {
// color?: "neutral" | "brand" | "accent" | undefined;
// size?: "small" | "medium" | "large" | undefined;
// }
// | undefined;
export type ButtonProps = ButtonVariantProps & {
children: React.ReactNode;
/**
* The native button click event handler.
*/
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
export const Button = ({ color, size, children, ...props }) => {
const styles = useMemo(() => button({ color, size }), [color, size]);
return (
<button type="button" className={styles} {...props}>
{children}
</button>
);
};
通过这种方式我们在开发组件的时候就可以专注于组件的逻辑了,后面我们主要会使用这种方式来组织组件的CSS
。
当然这种方案也有缺点,就是一旦你选择vanilla-extract/style
作为 UI 组件的CSS
方案,那么在使用组件的时候就会对项目有一点要求,就是该项目得安装vanilla-extract/style
得相关包作为依赖。不过有一点很好,vanilla-extract/style
是构建时运行的,所以只需要将相关包作为devDependencies
依赖即可:pnpm add @vanilla-extract/css --save-dev
。
这里补充一点: vanilla-extract/style
是同时支持runtime(运行时)
和build-time(构建时)
的,这取决于你的使用方式,简而言之就是,任何在以*.css.ts
为后缀的文件中的内容都只会在build-time
发生的,反之则是runtime
的。大家只要记住这个口诀即可。
集成vanilla-extract/style
接下来我们开始将vanilla-extract/style
集成到项目中来。
-
首先我们在目录
packages/core/theme
新建一个@blankui-org/theme
的子项目,目录结构如下:lua|-- theme |-- src |-- index.ts |-- package.json |-- tsconfig.json |-- tsup.config.json
其中
package.json
文件如下:json{ "name": "@blankui-org/theme", "version": "1.0.0", "description": "The default theme for BlankUI components", "keywords": [ "theme", "theming", "design", "ui", "components", "vanilla-extract", "style" ], "author": "", "license": "MIT", "main": "src/index.ts", "sideEffects": false, "files": [ "dist" ], "publishConfig": { "access": "public" }, "scripts": { "build": "tsup src --dts", "clean": "rimraf dist .turbo" }, "peerDependencies": { "@vanilla-extract/css": "*" }, "dependencies": { "@types/lodash.kebabcase": "^4.1.9", "@types/lodash.mapkeys": "^4.6.9", "@vanilla-extract/recipes": "^0.5.1", "@vanilla-extract/sprinkles": "^1.6.1", "color2k": "^2.0.3", "deepmerge": "^4.3.1", "flat": "^6.0.1", "lodash.kebabcase": "^4.1.1", "lodash.mapkeys": "^4.6.0" }, "devDependencies": { "@types/flat": "^5.0.5", "@vanilla-extract/css": "^1.14.0" } }
(不要忘记根目录下运行
pnpm install
) -
然后在
theme/src/
目录下新建components/
目录,该目录结构如下:lua|-- conponents |-- index.ts |-- button.css.ts
ts// index.ts export * from "./button.css"; // button.css.ts import { RecipeVariants, recipe } from "@vanilla-extract/recipes"; const button = recipe({ base: [ { outline: "none", borderWidth: 0, cursor: "pointer", transitionProperty: "transform,color,background,background-color,border-color,text-decoration-color,fill,stroke,opacity", transitionDuration: "0.25s", transitionTimingFunction: "ease", }, { ":hover": { opacity: 0.9, }, ":active": { transform: "scale(0.97)", }, }, ], variants: { color: { default: { color: "#fff", backgroundColor: "#3f3f46", }, primary: { color: "#fff", backgroundColor: "#006FEE", }, secondary: { color: "#fff", backgroundColor: "#9353d3", }, success: { color: "#000", backgroundColor: "#17c964", }, warning: { color: "#000", backgroundColor: "#f5a524", }, danger: { color: "#fff", backgroundColor: "#f31260", }, }, radius: { sm: { borderRadius: "4px", }, md: { borderRadius: "6px", }, lg: { borderRadius: "8px", }, }, size: { sm: { minWidth: "4rem", height: "2rem", paddingLeft: "0.75rem", paddingRight: "0.75rem", fontSize: "0.75rem", lineHeight: "1rem", }, md: { minWidth: "5rem", height: "2.5rem", paddingLeft: "1rem", paddingRight: "1rem", fontSize: "0.875rem", lineHeight: "1.25rem", }, lg: { minWidth: "6rem", height: "3rem", paddingLeft: "1.5rem", paddingRight: "1.5rem", fontSize: "1.125rem", lineHeight: "1.75rem", }, }, }, defaultVariants: { color: "default", radius: "md", size: "md", }, }); // Get the type export type ButtonVariantProps = RecipeVariants<typeof button>; export { button };
-
然后我们改造我们的
@blankui-org/button
的代码:ts// button.ts import { useMemo } from "react"; import { button, ButtonVariantProps } from "@blankui-org/theme"; export type ButtonProps = ButtonVariantProps & { children: React.ReactNode; /** * The native button click event handler. */ onClick?: React.MouseEventHandler<HTMLButtonElement>; }; export const Button: React.FC<ButtonProps> = ({ color, radius, size, children, ...props }) => { const styles = useMemo(() => button({ color, radius, size }), [color, radius, size]); return ( <button className={styles} {...props}> {children} </button> ); };
修改
button.stories.tsx
文件:tsimport type { Meta, StoryObj } from "@storybook/react"; import { Button } from "../src"; // More on how to set up stories at: https://storybook.js.org/docs/writing-stories#default-export const meta = { title: "Components/Button", component: Button, parameters: { // Optional parameter to center the component in the Canvas. More info: https://storybook.js.org/docs/configure/story-layout layout: "centered", }, // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs tags: ["autodocs"], // More on argTypes: https://storybook.js.org/docs/api/argtypes argTypes: { color: { control: { type: "select", }, options: ["default", "primary", "secondary"], }, size: { control: { type: "select", }, options: ["sm", "md", "lg"], }, }, } satisfies Meta<typeof Button>; export default meta; type Story = StoryObj<typeof meta>; const defaultProps = { children: "Button", }; // More on writing stories with args: https://storybook.js.org/docs/writing-stories/args export const Primary: Story = { args: { color: "primary", ...defaultProps, }, }; export const Secondary: Story = { args: { color: "secondary", ...defaultProps, }, }; export const Large: Story = { args: { size: "lg", ...defaultProps, }, }; export const Small: Story = { args: { size: "sm", ...defaultProps, }, };
这时候启动storybook
服务,我们可以看到:
到这里为止我们已经将vanilla-extract/style
集成到我们的项目里面了,通过以上的代码相信大家对其也有了初步的了解,不过还是强烈建议去阅读它的官方文档深入学习一下。
当然目前我们的样式都是写死的,接下来我们开始实现主题功能。
UI 组件库主题功能的具体实现
vanilla-extract/style
提供了创建主题的 API:createGlobalTheme & createGlobalThemeContract,其实现原理就和一开始我们提到的创建CSS
变量的方式实现主题功能一样:
css
:root, .light {
--color-background: #fff;
}
.dark {
--color-background: #000;
}
具体的后面我们就知道了,这里就不展开说明了,不过建议大家看一下它的关于主题化的文档:Theming。
-
@blankui-org-theme
下新建src/theme/contract.css.ts
文件:tsimport { createGlobalTheme, createGlobalThemeContract, } from "@vanilla-extract/css"; const vars = createGlobalThemeContract({ colors: { white: "color-white", black: "color-black", default: "color-default", primary: "color-primary", secondary: "color-secondary", success: "color-success", warning: "color-warning", danger: "color-danger", }, }); // 亮色主题 同时也是默认的主题 createGlobalTheme(":root,.light", vars, { colors: { white: "#fff", black: "#000", default: "#3f3f46", primary: "#006FEE", secondary: "#9353d3", success: "#17c964", warning: "#f5a524", danger: "#f31260", }, }); // 暗黑主题 只是为了演示,所以就随便填了几个颜色:红黄绿 createGlobalTheme(".dark", vars, { colors: { white: "#fff", black: "#000", default: "green", primary: "red", secondary: "yello", success: "#17c964", warning: "#f5a524", danger: "#f31260", }, }); export { vars };
-
然后我们在
button.css.ts
中这么使用:tsimport { RecipeVariants, recipe } from "@vanilla-extract/recipes"; import { vars } from "../theme"; const button = recipe({ // ... variants: { color: { default: { color: vars.colors.white, // 这里的值其实就是 "var(--color-white)" CSS变量 下面同理 backgroundColor: vars.colors.default, // "var(--color-default)" }, primary: { color: vars.colors.white, backgroundColor: vars.colors.primary, // "var(--color-primary)" }, secondary: { color: vars.colors.white, backgroundColor: vars.colors.secondary, // "var(--color-secondary)" }, success: { color: vars.colors.black, backgroundColor: vars.colors.success, }, warning: { color: vars.colors.black, backgroundColor: vars.colors.warning, }, danger: { color: vars.colors.white, backgroundColor: vars.colors.danger, }, }, // ... }, defaultVariants: { color: "default", radius: "md", size: "md", }, }); // Get the type export type ButtonVariantProps = RecipeVariants<typeof button>; export { button };
这里为了方便演示我只将colors
放在主题配置里面动态创建,后面也会将layout
相关的属性放进来统一配置。
这时候我们在storybook
中切换主题:
Button
的颜色改变了,同时我们打开控制台发现:
其实就是创建了对应的CSS
变量,然后通过切换变量的值来让不同主题下样式生效。那么此时这些colors
是不是就可以作为我们设计主题系统中的tokens
呢,然后通过给它们配置不同的值然后实现主题扩展的功能。
那么我们继续,接下来我们将主题的token
分为两类:colors
& layouts
,顾名思义就是颜色和布局属性,而一般主题系统基本上都是围绕着这这类来展开的。
-
colors
:我们先将我们的 UI 组件所需要用的颜色全部放在统一的地方进行配置:
src/colors/
文件夹下: 内容基本如上图所示,就是我们的主题系统所有的基础的颜色配置,然后通过这些配置生成createGlobalTheme
&createGlobalThemeContract
所需要的格式。 -
layouts
: 同理,我们的layouts
如下:tsimport { LayoutTheme } from "../utils/types"; const defaultLayout: LayoutTheme = { spacingUnit: 4, disabledOpacity: ".5", dividerWeight: "1px", fontSize: { tiny: "0.75rem", small: "0.875rem", medium: "1rem", large: "1.125rem", }, lineHeight: { tiny: "1rem", small: "1.25rem", medium: "1.5rem", large: "1.75rem", }, radius: { small: "8px", medium: "12px", large: "14px", }, borderWidth: { small: "1px", medium: "2px", large: "3px", }, boxShadow: { small: "0px 0px 5px 0px rgb(0 0 0 / 0.02), 0px 2px 10px 0px rgb(0 0 0 / 0.06), 0px 0px 1px 0px rgb(0 0 0 / 0.3)", medium: "0px 0px 15px 0px rgb(0 0 0 / 0.03), 0px 2px 30px 0px rgb(0 0 0 / 0.08), 0px 0px 1px 0px rgb(0 0 0 / 0.3)", large: "0px 0px 30px 0px rgb(0 0 0 / 0.04), 0px 30px 60px 0px rgb(0 0 0 / 0.12), 0px 0px 1px 0px rgb(0 0 0 / 0.3)", }, }; export const lightLayout: LayoutTheme = { ...defaultLayout, hoverOpacity: ".8", }; export const darkLayout: LayoutTheme = { ...defaultLayout, hoverOpacity: ".9", boxShadow: { small: "0px 0px 5px 0px rgb(0 0 0 / 0.05), 0px 2px 10px 0px rgb(0 0 0 / 0.2), inset 0px 0px 1px 0px rgb(255 255 255 / 0.15)", medium: "0px 0px 15px 0px rgb(0 0 0 / 0.06), 0px 2px 30px 0px rgb(0 0 0 / 0.22), inset 0px 0px 1px 0px rgb(255 255 255 / 0.15)", large: "0px 0px 30px 0px rgb(0 0 0 / 0.07), 0px 30px 60px 0px rgb(0 0 0 / 0.26), inset 0px 0px 1px 0px rgb(255 255 255 / 0.15)", }, };
这里注意,这些都是一些常用的colors
&layouts
,大家可根据实际情况或者自己的爱好随便更改,包括它们的代码的组织方式。
然后我们更改contract.css.ts
:
ts
import {
createGlobalTheme,
createGlobalThemeContract,
} from "@vanilla-extract/css";
import { semanticColors } from "../colors";
import { lightLayout as defaultLayout, darkLayout } from "./layout";
import { flattenThemeObject, layoutParser } from "../utils";
// 这里只贴出了核心的代码
// 这些utils里面的方式其实都是将colors和layouts的数据转换成createGlobalThemeContract能用的格式
const flatLightLayout = layoutParser(defaultLayout);
const flatDarkLayout = layoutParser(darkLayout);
// const flatCommonColors = flattenThemeObject(commonColors) as Record<
// string,
// string
// >;
// const commonColorsVars = Object.keys(flatCommonColors).reduce<
// Record<string, string>
// >((acc, cur) => {
// acc[cur] = `color-${cur}`;
// return acc;
// }, {});
const { light: defaultColors, dark } = semanticColors;
const flatLightColors = flattenThemeObject(defaultColors) as Record<
string,
string
>;
const flatDarkColors = flattenThemeObject(dark) as Record<string, string>;
const semanticColorsVars = Object.keys(flatLightColors).reduce<
Record<string, string>
>((acc, cur) => {
acc[cur] = `color-${cur}`;
return acc;
}, {});
// 例如
// {
// // ...
// "primary": "var(--blankui-color-primary)"
// }
const themeVars = createGlobalThemeContract(
{
colors: {
// ...commonColorsVars,
...semanticColorsVars,
},
layouts: Object.keys(flatLightLayout).reduce<Record<string, string>>(
(acc, cur) => {
acc[cur] = cur;
return acc;
},
{},
),
},
(value) => `blankui-${value}`,
);
createGlobalTheme(":root,.light,[data-theme=light]", themeVars, {
colors: {
// ...flatCommonColors,
...flatLightColors,
},
layouts: flatLightLayout,
});
createGlobalTheme(".dark,[data-theme=dark]", themeVars, {
colors: {
// ...flatCommonColors,
...flatDarkColors,
},
layouts: flatDarkLayout,
});
export { themeVars, defaultColors, defaultLayout };
这些代码简单过一下就行,你只要知道就是通过读取上面的colors
&layouts
,然后将它们转换成createGlobalThemeContract
&createGlobalTheme
所需要的格式,最终页面里会生成这些CSS
变量:
接着我们在组件中这样使用这些CSS
变量:
-
大家需要先了解一下 Sprinkles ,它也是
vanilla-extract/style
的一员,这里使用它主要是为了将一些样式聚合起来使用:tsimport { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles"; import { themeVars } from "./contract.css"; const { colors, layouts } = themeVars; type LayoutProperties = { borderRadius: Record<string, string>; lineHeight: Record<string, string>; fontSize: Record<string, string>; opacity: Record<string, string>; spacingUnit: Record<string, string>; }; const { spacingUnit, fontSize, lineHeight, borderRadius, opacity, ...layoutProperties } = Object.entries(layouts).reduce<LayoutProperties>( (acc, [key, value]) => { if (key.startsWith("radius")) { acc["borderRadius"][key] = value; } if (key.startsWith("line-height")) { acc["lineHeight"][key] = value; } if (key.startsWith("font-size")) { acc["fontSize"][key] = value; } if (key.endsWith("opacity")) { acc["opacity"][key] = value; } if (key.startsWith("spacing-unit")) { acc["spacingUnit"][key] = value; } return acc; }, { borderRadius: {}, lineHeight: {}, fontSize: {}, opacity: {}, spacingUnit: {}, }, ); const opacityProperties = defineProperties({ conditions: { hover: { selector: "&:hover" }, disabled: { selector: "&:disabled" }, }, defaultCondition: false, properties: { opacity, }, }); const commonProperties = defineProperties({ properties: { ...layoutProperties, color: colors, backgroundColor: colors, borderRadius, paddingTop: spacingUnit, paddingBottom: spacingUnit, paddingLeft: spacingUnit, paddingRight: spacingUnit, minWidth: spacingUnit, minHeight: spacingUnit, width: spacingUnit, height: spacingUnit, fontSize, lineHeight, boxColor: { default: { color: colors["default-foreground"], backgroundColor: colors["default"], }, primary: { color: colors["primary-foreground"], backgroundColor: colors["primary"], }, secondary: { color: colors["secondary-foreground"], backgroundColor: colors["secondary"], }, success: { color: colors["success-foreground"], backgroundColor: colors["success"], }, warning: { color: colors["warning-foreground"], backgroundColor: colors["warning"], }, danger: { color: colors["danger-foreground"], backgroundColor: colors["danger"], }, }, buttonSize: { sm: { minWidth: spacingUnit["spacing-unit-16"], height: spacingUnit["spacing-unit-8"], paddingLeft: spacingUnit["spacing-unit-3"], paddingRight: spacingUnit["spacing-unit-3"], fontSize: fontSize["font-size-tiny"], lineHeight: lineHeight["line-height-tiny"], }, md: { minWidth: spacingUnit["spacing-unit-20"], height: spacingUnit["spacing-unit-10"], paddingLeft: spacingUnit["spacing-unit-4"], paddingRight: spacingUnit["spacing-unit-4"], fontSize: fontSize["font-size-small"], lineHeight: lineHeight["line-height-small"], }, lg: { minWidth: spacingUnit["spacing-unit-24"], height: spacingUnit["spacing-unit-12"], paddingLeft: spacingUnit["spacing-unit-6"], paddingRight: spacingUnit["spacing-unit-6"], fontSize: fontSize["font-size-medium"], lineHeight: lineHeight["line-height-medium"], }, }, }, shorthands: { padding: ["paddingTop", "paddingBottom", "paddingLeft", "paddingRight"], paddingX: ["paddingLeft", "paddingRight"], paddingY: ["paddingTop", "paddingBottom"], }, }); export const sprinkles = createSprinkles(commonProperties, opacityProperties);
-
button.css.ts
:tsimport { RecipeVariants, recipe } from "@vanilla-extract/recipes"; import { sprinkles } from "../theme"; const button = recipe({ base: [ { outline: "none", borderWidth: 0, cursor: "pointer", transitionProperty: "transform,color,background,background-color,border-color,text-decoration-color,fill,stroke,opacity", transitionDuration: "0.25s", transitionTimingFunction: "ease", }, sprinkles({ opacity: { hover: "hover-opacity", }, }), { ":active": { transform: "scale(0.97)", }, }, ], variants: { color: { default: sprinkles({ boxColor: "default", }), primary: sprinkles({ boxColor: "primary", }), secondary: sprinkles({ boxColor: "secondary", }), success: sprinkles({ boxColor: "success", }), warning: sprinkles({ boxColor: "warning", }), danger: sprinkles({ boxColor: "danger", }), }, radius: { sm: sprinkles({ borderRadius: "radius-small", }), md: sprinkles({ borderRadius: "radius-medium", }), lg: sprinkles({ borderRadius: "radius-large", }), }, size: { sm: sprinkles({ buttonSize: "sm", }), md: sprinkles({ buttonSize: "md", }), lg: sprinkles({ buttonSize: "lg", }), }, }, defaultVariants: { color: "default", radius: "md", size: "md", }, }); // Get the type export type ButtonVariantProps = RecipeVariants<typeof button>; export { button };
这样我们的button.css.ts
的代码也将更加简洁,同时功能也不受影响,并且代码都可以被其它组件复用,这个后续我们会新建一个Link
组件,然后展示如何共用这些代码。这时候页面功能也都正常:
新建主题功能
我们封装出一个API,用来让用户在使用我们的 UI 组件库的时候能够根据自己的需求新建主题:
theme-create.ts
:
ts
import { createGlobalTheme } from "@vanilla-extract/css";
import deepmerge from "deepmerge";
import { defaultColors, defaultLayout, themeVars } from "./theme/contract.css";
import { flattenThemeObject, layoutParser } from "./utils";
import type { BlankUIConfig } from "./utils/types";
export const createBlankUITheme = ({
selector,
tokens = {},
}: BlankUIConfig) => {
// 将新配置的值合并覆盖到默认的token中
const { colors = {}, layout = {} } = tokens;
const flatColors = flattenThemeObject(
deepmerge(defaultColors, colors),
) as Record<string, string>;
const flatLayout = layoutParser(deepmerge(defaultLayout, layout));
createGlobalTheme(selector, themeVars, {
colors: { ...flatColors },
layouts: { ...flatLayout },
});
};
然后用户在使用的时候style.css.ts
:
ts
import { createBlankUITheme } from "@blankui-org/theme";
createBlankUITheme({
selector: ".warm",
tokens: {
colors: {
// 更改 primary 的颜色为红色
primary: {
DEFAULT: "red",
},
overlay: {
DEFAULT:"red"
}
},
// 加大
layout: {
spacingUnit: 8,
},
},
});
这样就创建出了一个新的主题:
Link 组件
我们在packages/components/
下快速新建一个Link
组件,相关代码如下:
ts
// link.ts
import { useMemo } from "react";
import { forwardRef, type HTMLBlankUIProps } from "@blankui-org/system";
import { link, LinkVariantProps } from "@blankui-org/theme";
import { useDOMRef } from "@blankui-org/react-utils";
export type LinkProps = HTMLBlankUIProps<"a"> &
LinkVariantProps & {
children: React.ReactNode;
};
export const Link = forwardRef<"a", LinkProps>(
({ as, color, children, ...props }, ref) => {
const domRef = useDOMRef(ref);
const styles = useMemo(() => link({ color }), [color]);
const Component = as || "a";
return (
<Component ref={domRef} className={styles} {...props}>
{children}
</Component>
);
},
);
然后@blankui-org/theme
下为Link
组件添加基于主题的样式:
ts
// link.css.ts
import { RecipeVariants, recipe } from "@vanilla-extract/recipes";
import { sprinkles } from "../theme";
const link = recipe({
base: {
cursor: "pointer",
},
variants: {
color: {
default: sprinkles({
color: "default",
}),
primary: sprinkles({
color: "primary",
}),
secondary: sprinkles({
color: "secondary",
}),
success: sprinkles({
color: "success",
}),
warning: sprinkles({
color: "warning",
}),
danger: sprinkles({
color: "danger",
}),
},
},
defaultVariants: {
color: "default",
},
});
// Get the type
export type LinkVariantProps = RecipeVariants<typeof link>;
export { link };
这时候:
一些额外的功能
-
我们添加一些在封装组件的时候常用的关于
typescript
的类型的问题:在
packages/core/system
目录下新建一个@blankui-org/system
的子项目,这里会放置一些ts
的类型声明文件:ts// forward-ref.ts import { forwardRef as forwardReactRef } from "react"; import { As, ComponentWithAs, PropsOf, RightJoinProps } from "./system.types"; export function forwardRef< Component extends As, Props extends object, OmitKeys extends keyof any = never, >( component: React.ForwardRefRenderFunction< any, RightJoinProps<PropsOf<Component>, Props> & { as?: As; } >, ) { return forwardReactRef(component) as ComponentWithAs< Component, Props, OmitKeys >; } // system.types.tsx /** * Part of this code is taken from @chakra-ui/system ❤️ */ export type As = React.ElementType; export type DOMElements = keyof JSX.IntrinsicElements; export interface DOMElement extends Element, HTMLOrSVGElement {} type DataAttributes = { [dataAttr: string]: any; }; export type DOMAttributes<T = DOMElement> = React.AriaAttributes & React.DOMAttributes<T> & DataAttributes & { id?: string; role?: React.AriaRole; tabIndex?: number; style?: React.CSSProperties; }; /** * Extract the props of a React element or component */ export type PropsOf<T extends As> = React.ComponentPropsWithoutRef<T> & { as?: As; }; export type OmitCommonProps< Target, OmitAdditionalProps extends keyof any = never, > = Omit< Target, "transition" | "as" | "color" | "translate" | OmitAdditionalProps > & { htmlTranslate?: "yes" | "no" | undefined; }; export type RightJoinProps< SourceProps extends object = {}, OverrideProps extends object = {}, > = OmitCommonProps<SourceProps, keyof OverrideProps> & OverrideProps; export type MergeWithAs< ComponentProps extends object, AsProps extends object, AdditionalProps extends object = {}, AsComponent extends As = As, > = ( | RightJoinProps<ComponentProps, AdditionalProps> | RightJoinProps<AsProps, AdditionalProps> ) & { as?: AsComponent; }; export type ComponentWithAs< Component extends As, Props extends object = {}, OmitKeys extends keyof any = never, > = { <AsComponent extends As = Component>( props: MergeWithAs< React.ComponentPropsWithoutRef<Component>, Omit<React.ComponentPropsWithoutRef<AsComponent>, OmitKeys>, Props, AsComponent >, ): React.ReactElement | null; readonly $$typeof: symbol; displayName?: string; propTypes?: React.WeakValidationMap<Props> | undefined; contextTypes?: React.ValidationMap<any>; id?: string; }; export type Merge<M, N> = N extends Record<string, unknown> ? M : Omit<M, keyof N> & N; export type HTMLBlankUIProps< T extends As = "div", OmitKeys extends keyof any = never, > = Omit<PropsOf<T>, "ref" | "color" | "slot" | "size" | OmitKeys> & { as?: As; }; export type PropGetter<P = Record<string, unknown>, R = DOMAttributes> = ( props?: Merge<DOMAttributes, P>, ref?: React.Ref<any>, ) => R & React.RefAttributes<any>;
这个就是上面
Link
标签所用到的forwardRef
API,可以为我们创建类型安装的组件,同时根据不同的标签默认填充该标签在ts
中默认的标准的Props
类型:自动填充所有
a
标签的标准属性。 -
@blankui-org/react-utils
子项目,这里保存我们 UI 组件库中一些基于React
框架下的utils
方法,比如hooks
:ts// dom.ts import { Ref, RefObject, MutableRefObject, useImperativeHandle, useLayoutEffect, useRef, } from "react"; export function canUseDOM(): boolean { return !!( typeof window !== "undefined" && window.document && window.document.createElement ); } export const isBrowser = canUseDOM(); export function getUserAgentBrowser(navigator: Navigator) { const { userAgent: ua, vendor } = navigator; const android = /(android)/i.test(ua); switch (true) { case /CriOS/.test(ua): return "Chrome for iOS"; case /Edg\//.test(ua): return "Edge"; case android && /Silk\//.test(ua): return "Silk"; case /Chrome/.test(ua) && /Google Inc/.test(vendor): return "Chrome"; case /Firefox\/\d+\.\d+$/.test(ua): return "Firefox"; case android: return "AOSP"; case /MSIE|Trident/.test(ua): return "IE"; case /Safari/.test(navigator.userAgent) && /Apple Computer/.test(ua): return "Safari"; case /AppleWebKit/.test(ua): return "WebKit"; default: return null; } } export type UserAgentBrowser = NonNullable< ReturnType<typeof getUserAgentBrowser> >; export function getUserAgentOS(navigator: Navigator) { const { userAgent: ua, platform } = navigator; switch (true) { case /Android/.test(ua): return "Android"; case /iPhone|iPad|iPod/.test(platform): return "iOS"; case /Win/.test(platform): return "Windows"; case /Mac/.test(platform): return "Mac"; case /CrOS/.test(ua): return "Chrome OS"; case /Firefox/.test(ua): return "Firefox OS"; default: return null; } } export type UserAgentOS = NonNullable<ReturnType<typeof getUserAgentOS>>; export function detectDeviceType(navigator: Navigator) { const { userAgent: ua } = navigator; if (/(tablet)|(iPad)|(Nexus 9)/i.test(ua)) return "tablet"; if (/(mobi)/i.test(ua)) return "phone"; return "desktop"; } export type UserAgentDeviceType = NonNullable< ReturnType<typeof detectDeviceType> >; export function detectOS(os: UserAgentOS) { if (!isBrowser) return false; return getUserAgentOS(window.navigator) === os; } export function detectBrowser(browser: UserAgentBrowser) { if (!isBrowser) return false; return getUserAgentBrowser(window.navigator) === browser; } export function detectTouch() { if (!isBrowser) return false; return ( window.ontouchstart === null && window.ontouchmove === null && window.ontouchend === null ); } export function useDOMRef<T extends HTMLElement = HTMLElement>( ref?: RefObject<T | null> | Ref<T | null>, ) { const domRef = useRef<T>(null); useImperativeHandle(ref, () => domRef.current); return domRef; } export interface ContextValue<T> { ref?: MutableRefObject<T>; } // Syncs ref from context with ref passed to hook export function useSyncRef<T>( context: ContextValue<T | null>, ref: RefObject<T>, ) { useLayoutEffect(() => { if (context && context.ref && ref && ref.current) { context.ref.current = ref.current; return () => { if (context.ref?.current) { context.ref.current = null; } }; } }, [context, ref]); }
最后
至此关于CSS
及其主题相关这一篇文章就到此结束了。
当然大家觉得还有什么遗漏或者需要支持一些在开发 UI 组件库常见的其它的功能请求的话还请随时留言。
或者后面我会在写一篇关于在基于tailwindcss
基础下实现组件库主题的功能,不过它也和这里我们使用vanilla-extract/style
很类似,只不过会更方便一些,对用户也会更加友好,因为主题扩展和新建主题的功能就直接可以放在一个tailwindcss
的plugin
中实现了。
好啦,这篇文章就到此结束啦,下次见~