这里有从零开始构建现代化前端UI组件库所需要的一切(五)

序

到《这里有从零开始构建现代化前端UI组件库所需要的一切(四)》为止,其实我们已经基本上实现了现代化前端 UI 组件库所需的基础框架和功能,其中包括设计理念选择、工程结构建设、开发流程优化、自动化测试、文档生成、版本管理、发布等方面的内容。当然在这个阶段,我们仍然可以在一些方向上来进一步完善和优化我们的组件库:

  1. 优化性能: 检查组件库的性能,并采取措施进行优化。这可能包括代码拆分、懒加载、缓存策略等,以确保在各种场景下都能提供流畅的用户体验。

  2. 增加复杂组件: 考虑引入一些更复杂、更功能丰富的组件,如表格、模态框、导航栏等,以满足更广泛的应用场景。

  3. 响应式设计: 确保组件库能够良好地适应不同屏幕尺寸和设备,实现响应式设计,提供一致的用户体验。

  4. 文档和示例: 完善组件库的文档,提供清晰的使用说明和示例代码。良好的文档可以帮助用户更容易上手和使用你的组件库。

  5. 测试覆盖率: 增加自动化测试的覆盖范围,确保组件库的稳定性和可靠性。可以考虑引入更多的单元测试、集成测试等。

  6. 用户体验提升: 关注用户体验,通过用户反馈和数据分析,不断改进组件的设计、交互和动画效果,提供更好的用户感受。

  7. 国际化和本地化: 如果你的组件库可能被用于不同语言环境的项目中,考虑实现国际化和本地化支持,以提供更广泛的应用范围。

  8. 主题定制: 如果可能,考虑提供主题定制的功能,使用户能够根据自身项目的设计规范进行定制。

  9. 浏览器兼容性: 确保组件库在主流浏览器中的兼容性,修复可能存在的浏览器兼容性问题。

  10. 安全性: 审查组件库的代码,确保没有安全漏洞,并采取必要的安全措施,以保护用户和项目的安全。

  11. 监控和分析: 集成监控和分析工具,收集组件库的使用数据和性能指标,以便及时发现和解决问题。

通过在这些方向上的努力,无论是开源或者公司内部使用,你都可以打造出一个更加完善、稳定和用户友好的 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 or Stylus)中,可以借助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 是其中的一部分,用于定义和组织样式。

  • 特点和用法:

    1. 原子化的样式定义: vanilla-extract/style 鼓励原子化的样式定义,即将样式拆分为小的、可复用的原子类。

    2. CSS-in-JS 风格: 类似于 CSS-in-JS 的方式,但更强调原子类的使用。样式可以通过 JavaScript 对象的方式进行定义。

    3. 可组合性: 支持样式的组合,可以将多个原子类组合在一起,实现更复杂的样式。

    4. TypeScript 支持: vanilla-extract 与 TypeScript 集成良好,提供了强类型支持。样式定义和使用时可以得到类型检查。

    5. 性能优化: 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文件:

    ts 复制代码
    import 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文件:

    ts 复制代码
    import {
      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中这么使用:

    ts 复制代码
    import { 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如下:

    ts 复制代码
    import { 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的一员,这里使用它主要是为了将一些样式聚合起来使用:

    ts 复制代码
    import { 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:

    ts 复制代码
    import { 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,
    },
  },
});

这样就创建出了一个新的主题:

我们在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及其主题相关这一篇文章就到此结束了。

代码:commit 288af89

当然大家觉得还有什么遗漏或者需要支持一些在开发 UI 组件库常见的其它的功能请求的话还请随时留言。

或者后面我会在写一篇关于在基于tailwindcss基础下实现组件库主题的功能,不过它也和这里我们使用vanilla-extract/style很类似,只不过会更方便一些,对用户也会更加友好,因为主题扩展和新建主题的功能就直接可以放在一个tailwindcss的plugin中实现了。

好啦,这篇文章就到此结束啦,下次见~

相关推荐
海宇服务17 小时前
零信任架构实战:基于海宇运营商近3个月欠费次数构建自动化履约能力评估管线
运维·人工智能·架构·自动化
代码方舟18 小时前
零信任架构实战:基于天远学历信息高级版构建自动化智库入驻审查网关
运维·人工智能·架构·自动化
mftang19 小时前
EtherCAT协议:从“飞读飞写”机制到分布式时钟同步的实时以太网架构深度解析
分布式·架构·ethercat·分布式时钟·从站控制器
Dawson Zhu20 小时前
智能体记忆系统:原理解析与工程实践
人工智能·语言模型·架构·aigc·agi
AOI小白新手上路21 小时前
韦东山《ARM 架构与编程》基于I.MX6ULL 3-1 硬件知识_LED 原理图 · 学习笔记
arm开发·学习·架构
诚心呈意共享外卖配送系统1 天前
共享骑手与聚合配送的区别及系统架构设计:运力池与订单聚合如何落地
架构·系统架构·创业创新
天远API1 天前
零信任架构实战:基于天远股权穿透构建自动化供应链授信穿透网关
java·人工智能·架构·自动化
Dawson Zhu1 天前
大语言模型对齐与微调:原理解析与工程实践
人工智能·语言模型·架构·aigc·agi
姜鱼问生1 天前
uptime-kuma 从 docker run 迁移到 compose:数据卷的坑
安全·架构
weixin_307779131 天前
从“人操作仪器”到“Agent 自主发现”:OPL 金属材料自驱动实验室 MVP 技术论述
开发语言·人工智能·算法·架构