引子

最近公司在搭建部门的统一平台,我负责了统一登录前端的开发,因为要对接很多的系统,所以开发了统一登录的sdk,说是sdk其实就是一个组件库。以此为契机,外加之前开发其他的系统中也用了很多种不同的组件库(饿了没-ui/vant/antd/chakra-ui…),今天写一篇文章说说,什么样的组件设计是比较合理的,以及如何设计一个好用的组件。

研究chakra-ui

chakra-ui.png
比较来说我觉得设计比较有特色的一个组件库,就是 chakra-ui 了,不过国内使用的并不多,我也是在之前技术选型的时候偶然找到的,但是仔细读了她的文档之后我发现这个是一个很有特色的组件库,下面细说:

样式自定义

找到这个 chakra-ui 的时候,就是因为需要在使用 tailwind CSS 的同时使用一个组件库,chakra-ui 的一个特色就是使用了和 tailwind CSS 几乎相同的样式 api , 例如:

  1. import { Box } from "@chakra-ui/react"
  2. // m={2} refers to the value of `theme.space[2]`
  3. <Box m={2}>Tomato</Box>
  4. // You can also use custom values
  5. <Box maxW="960px" mx="auto" />
  6. // sets margin `8px` on all viewports and `16px` from the first breakpoint and up
  7. <Box m={[2, 3]} />

这样只要你记住了 tailwind CSS 的api, 那么就可以很快的上手 chakra-ui 。
那么这个是怎么实现的呢?
一开始我以为是组件库使用了tailwind CSS ,但是看了源码发现,chakra 将 styled-components 进行了二次封装,而这种 tailwindLike 的 api 是进行了模拟导致的。
/packages/styled-system/config/ 里面 写入了不同样式以及缩写,以 background 为例:

  1. export const background: Config = {
  2. background: t.colors("background"),
  3. ...
  4. bg: t.colors("background"),
  5. ...
  6. }

这样实现的,可以说把dirty的工作封装了起来,展示出来的结果都是好用的。

组件组合

书接上文,组件里面做了很多的映射封装,为了减少代码量,统一进行管理,组件库进行了组件的组合(compose)。从一个基本的组件出发,通过默认一些样式,创造了一些新的组件。
例如 Square Circle 组件,是基于 Box 组件extend而来的。

  1. export const Square = forwardRef<SquareProps, "div">((props, ref) => {
  2. const { size, centerContent = true, ...rest } = props
  3. const styles: SystemStyleObject = centerContent
  4. ? { display: "flex", alignItems: "center", justifyContent: "center" }
  5. : {}
  6. return (
  7. <Box
  8. ref={ref}
  9. boxSize={size}
  10. __css={{
  11. ...styles,
  12. flexShrink: 0,
  13. flexGrow: 0,
  14. }}
  15. {...rest}
  16. />
  17. )
  18. })
  19. if (__DEV__) {
  20. Square.displayName = "Square"
  21. }
  22. export const Circle = forwardRef<SquareProps, "div">((props, ref) => {
  23. const { size, ...rest } = props
  24. return <Square size={size} ref={ref} borderRadius="9999px" {...rest} />
  25. })
  26. if (__DEV__) {
  27. Circle.displayName = "Circle"
  28. }

这样写减少了重复的代码并且可以保持更好的可维护性。我们在开发的过程中也可以借鉴这种模式,开发出最抽象的组件,从这个最抽象的父类出发来进行派生。

Theminig

chakra ui 的另外一个特点就是拥有一个高度自定义的主题系统, 使用的方式类似于 tailwind CSS 设置,也就是说你可同时将一个theme文件应用到两个库中,使用方法可以看一下chakra文档,那么这个主题是如何实现的呢?
首先 chakra ui 维护了一个default theme ,用于在没有自定义 theme 或者 自定义了一部分的theme的时候进行合并,合并的过程(toCSSVar)是使用了 createThemeVars 方法将自己配置的theme转化成css var变量,然后将默认的theme和生成的theme进行合并。最后在 :

  1. export const ThemeProvider = (props: ThemeProviderProps) => {
  2. const { cssVarsRoot = ":host, :root", theme, children } = props
  3. const computedTheme = React.useMemo(() => toCSSVar(theme), [theme])
  4. return (
  5. <EmotionThemeProvider theme={computedTheme}>
  6. <Global styles={(theme: any) => ({ [cssVarsRoot]: theme.__cssVars })} />
  7. {children}
  8. </EmotionThemeProvider>
  9. )
  10. }

这里是借用了 emotion 的 ThemeProvider。这么一看其实主题设置还是很简单的。这样可以很方便的设置了一个自定义的主题
除此之外,如果想在二次开发的主题上进行三次开发,可以使用 chakra-ui 提供的api Theme extensions。提供了一个类似于 HOC 的包裹函数,以withDefaultColorScheme为例:

  1. export function withDefaultColorScheme({colorScheme,components}): ThemeExtension {
  2. return (theme) => {
  3. let names = Object.keys(theme.components || {})
  4. // ....
  5. return mergeThemeOverride(theme, {
  6. components: Object.fromEntries(
  7. names.map((componentName) => {
  8. const withColorScheme = {
  9. defaultProps: {
  10. colorScheme,
  11. },
  12. }
  13. return [componentName, withColorScheme]
  14. }),
  15. ),
  16. })
  17. }
  18. }

将配置的颜色Scheme赋值给了配置的对应的组件。内部实现大同小异,都是调用了 mergeThemeOverride这个方法

  1. export function mergeThemeOverride<BaseTheme extends ChakraTheme = ChakraTheme>(
  2. ...overrides: ThemeOverride<BaseTheme>[]
  3. ): ThemeOverride<BaseTheme> {
  4. return mergeWith({}, ...overrides, mergeThemeCustomizer)
  5. }

内部使用了 lodash.mergewith的方法实现融合,对于此方法 chakra-ui 写了一个mergeThemeCustomizer 作为 lodash.mergwith 的第三个参数,这里的自定义mergeThemeCustomizer方法使用了递归的方式进行merge。

  1. function mergeThemeCustomizer(
  2. source: unknown,
  3. override: unknown,
  4. key: string,
  5. object: any,
  6. ) {
  7. if (
  8. (isFunction(source) || isFunction(override)) &&
  9. Object.prototype.hasOwnProperty.call(object, key)
  10. ) {
  11. return (...args: unknown[]) => {
  12. const sourceValue = isFunction(source) ? source(...args) : source
  13. const overrideValue = isFunction(override) ? override(...args) : override
  14. return mergeWith({}, sourceValue, overrideValue, mergeThemeCustomizer)
  15. }
  16. }
  17. // fallback to default behaviour
  18. return undefined
  19. }

外部组件与二次封装

从上面可以看到,组件库也不是全部从零开始,也使用了很多第三方的库,例如样式库 emotion, styled-components,工具方法库 lodash,这里没啥特别好说的。
另外chakra-ui官方也推荐将组件库和许多第三方的lib一起使用,例如 表单验证库formik,此外,在element-ui中也会直接封装throttle-debounce, async-validator等第三方的库。

提供escape

在使用其他的组件库的时候,很多情况下会出现某些组件的细节和设计要求不一致的情况,对于element-ui和ant design来说,由于使用了sass/less等预处理器,可以使用覆盖的方式来覆写样式。在 chakra ui 中,则提供了一个 sx Props 来直接向组件传入样式。

  1. <Box sx={{ "--my-color": "#53c8c4" }}>
  2. <Heading color="var(--my-color)" size="lg">
  3. This uses CSS Custom Properties!
  4. </Heading>
  5. </Box>

这个方式很强大,还支持嵌套样式,media query等。这里的sx是一个封装自@emotion/styled的方法,在 packages/system/src/system.ts, styled方法里面调用了 toCSSObject ,这里拿取到了输入的样式,而所有的ui 组件都会调用这个 styled方法,sx Props 就这样全局生效了。

  1. export function styled<T extends As, P = {}>(
  2. component: T,
  3. options?: StyledOptions,
  4. ) {
  5. const { baseStyle, ...styledOptions } = options ?? {}
  6. // ...
  7. const styleObject = toCSSObject({ baseStyle })
  8. return _styled(
  9. component as React.ComponentType<any>,
  10. styledOptions,
  11. )(styleObject) as ChakraComponent<T, P>
  12. }
  13. export const toCSSObject: GetStyleObject = ({ baseStyle }) => (props) => {
  14. const { theme, css: cssProp, __css, sx, ...rest } = props
  15. const styleProps = objectFilter(rest, (_, prop) => isStyleProp(prop))
  16. const finalBaseStyle = runIfFn(baseStyle, props)
  17. const finalStyles = Object.assign({}, __css, finalBaseStyle, styleProps, sx)
  18. const computedCSS = css(finalStyles)(props.theme)
  19. return cssProp ? [computedCSS, cssProp] : computedCSS
  20. }

如何设计一个好用的组件

参考了很多设计,那么如何设计一个好用的组件呢,这里以一个progressBar为例。

MVP 版本以及存在的问题

  1. import React, { useState, useEffect } from "react";
  2. import styled from "styled-components";
  3. const ProgressBarWrapper = styled.div<{ progress: number }>`
  4. width: 100%;
  5. height: 4px;
  6. position: fixed;
  7. top: 0;
  8. left: 0;
  9. right: 0;
  10. z-index: 9999;
  11. .bar-used {
  12. background: #34c;
  13. width: ${({ progress }) => progress + "%"};
  14. height: 100%;
  15. border-radius: 0 2px 2px 0;
  16. }
  17. `;
  18. const ProgressBar = () => {
  19. const [progress, setProgress] = useState(0);
  20. useEffect(() => {
  21. window.addEventListener("scroll", () => {
  22. setProgress(
  23. (document.documentElement.scrollTop /
  24. (document.body.scrollHeight - window.innerHeight)) *
  25. 100
  26. );
  27. });
  28. return () => {
  29. window.removeEventListener("scroll", () => {});
  30. };
  31. });
  32. return (
  33. <ProgressBarWrapper progress={progress}>
  34. <div className='bar-used'></div>
  35. </ProgressBarWrapper>
  36. );
  37. };
  38. export { ProgressBar };

这里展示了一个页面顶部进度条的组件,类似于 es6标准入门 这里的样式,上面的功能可以很快的就实现出来,但是只是比较符合单一的应用场景,进度条固定在顶部,只有从左往右增长一种情况。但是实际上的进度条可能会用到很多的地方,因此我们需要对照可能的场景以及代码中的变量进行判断,哪些是需要做成参数,并设置对应的默认值。
需求有以下几种:

  1. 颜色可调,位置可调,方向可调,这三个是比较全局的可调整类型
  2. 具体样式修改,高度修改,圆角修改,这些是其他的一些props,如果保持progressbar的功能不变可能不太会用到的props

此外,这里的progeressBar还存在的一个问题就是,这个组件将展示和逻辑杂糅在了一起,组件内部就有对于页面滚动进度的计算逻辑(useEffect),但是如果使用的时候不需要这个逻辑呢?
根据上面的一些要修改的点以及一些问题,我们来对这个组件进行拆分和重构。

重构

首先是把逻辑和展示分开。新建一个hook用于计算百分比。

  1. import { useState, useEffect } from "react";
  2. export function useProgress() {
  3. const [progress, setProgress] = useState(0);
  4. useEffect(() => {
  5. window.addEventListener("scroll", () => {
  6. setProgress(
  7. (document.documentElement.scrollTop /
  8. (document.body.scrollHeight - window.innerHeight)) *
  9. 100
  10. );
  11. });
  12. return () => {
  13. window.removeEventListener("scroll", () => {});
  14. };
  15. });
  16. return progress;
  17. }

之后是给需要的参数添加props,并设置默认值,这里只以高度为例,设置一个可选的高度参数,当传入的时候就使用传入的值否则是默认的。
同时注意颜色等可以使用一个theme系统。

  1. const ProgressBarWrapper = styled.div<{ progress: number; height?: string }>`
  2. width: 100%;
  3. height: ${({ height }) => (height ? height : "4px")};
  4. .bar-used {
  5. background: ${({ theme }) => theme.themeColor};
  6. width: ${({ progress }) => progress + "%"};
  7. height: 100%;
  8. border-radius: ${({ height }) =>
  9. height ? `0 calc( ${height}/ 2) calc(${height}/ 2) 0` : "0 2px 2px 0"};
  10. }
  11. `;

除此之外,将fixed布局抽象出来,方便后面进行组合

  1. const FixedTopWrapper = styled.div`
  2. position: fixed;
  3. top: 0;
  4. left: 0;
  5. right: 0;
  6. z-index: 9999;
  7. `;
  8. // 组合之后就是这样的
  9. const ProgressBarWrapperFixed = styled(FixedTopWrapper)<{
  10. progress: number;
  11. height?: string;
  12. }>`.....`;

这样组件就是这样的,分成了默认好用的 ProgressBar 和 自定义功能更多的 SimpleProgressBar

  1. interface ProgressProps {
  2. progress: number;
  3. height?: string;
  4. }
  5. const ProgressBar = ({
  6. height,
  7. }: Omit<ProgressProps, "progress">) => {
  8. const progress = useProgress();
  9. return (
  10. <ProgressBarWrapperFixed progress={progress} height={height}>
  11. <div className='bar-used'></div>
  12. </ProgressBarWrapperFixed>
  13. );
  14. };
  15. const SimpleProgressBar = ({
  16. progress,
  17. height,
  18. }: ProgressProps) => {
  19. return (
  20. <ProgressBarWrapper progress={progress} height={height}>
  21. <div className='bar-used'></div>
  22. </ProgressBarWrapper>
  23. );
  24. };

另外就是添加 合适的 escape,方便使用的时候如果不符合需要可以自行修改。这里直接在组件上添加一个 style参数,

  1. // usage
  2. <ProgressBar style={{ background: "#000" }}></ProgressBar>
  3. // 修改组件 添加rest参数接受附加的style,并且修改一下类型
  4. const ProgressBar = ({
  5. height,
  6. ...rest
  7. }: Omit<ProgressProps, "progress"> & React.HTMLAttributes<HTMLDivElement>) => {
  8. const progress = useProgress();
  9. return (
  10. <ProgressBarWrapperFixed {...rest} progress={progress} height={height}>
  11. <div className='bar-used'></div>
  12. </ProgressBarWrapperFixed>
  13. );
  14. };

这样就写好了一个好用的ProgressBar组件了,并且提供了SimpleProgressBar用于其他的自定义用途。
在线演示:https://codepen.io/alfxjx/pen/ZEJyygo?editors=0010
点击查看【codepen】

总结

经过上面对 chakra ui 组件库源码的研究以及一个示例,相信你以及知道了该如何设计一个好用的组件库了,希望你能为你的公司也开发一套组件库,能更好的完成你的kpi/okr/etc…

Reference

  1. https://github.com/chakra-ui/chakra-ui
  2. https://chakra-ui.com/
  3. https://emotion.sh/
  4. https://www.lodashjs.com/
  5. https://tailwindcss.com/
  6. https://element.eleme.cn/#/zh-CN
  7. https://ant.design/index-cn
  8. https://stackoverflow.com/questions/55318165/add-styled-components-to-codepen