likes
comments
collection
share

【目前最好的react组件库教程】主题切换功能设计

作者站长头像
站长
· 阅读数 22

前言

@popper-js改造的代码后面会出,这次谈谈组件库主题切换功能的设计。因为自己的项目正在改造这一块,所以就提前谈谈了,后面应该会接着写增强版@popper-js和组件库搭建这块(pnpm 的 monorepo)。

目前组件库有10多个组件,全部代码会再次改造一下,github地址:mx-deisgn,预览地址:官网,组件库官网是自己写的,没有用storybook或者dumi。所有组件还在迭代中。

ant design5的样式设计的功能从以前的less迁移到了css-in-js,其中有一个原因就是社区反应对ant做定制化的css改造很困难(所以主题切换更困难),大家可能并不知道困难的原因点所在,以及如何基于less做ant的定制化样式。

ant deisgn5之前定制化样式困难的原因

你知道为什么用以下的方式导入ant的组件,为什么css样式也一同导入进来了吗?

import { message } from 'antd';

是因为ant为了实现css的按需加载(就是我使用了message组件,我只加载message组件的css样式,不加载别的组件的css),使用babel-plugin-import,使用方法如下

【目前最好的react组件库教程】主题切换功能设计

也就是babel在编译你的代码的时候,会自动帮你引入对应组件的css。

可是成也babel-plugin-import,败也babel-plugin-import,这样确实很方便,对于新手用户而言。但问题又很明显,很多人用ant-deisgn都不知道为什么像我们上面那样导入组件,css也导入进来了,这样的话你根本没办法对ant做样式上的定制化改造。

所以很多项目,包括大厂很多项目,甚至我在用飞书的时候,飞书最开始也存在的问题,就是css覆盖,大家普遍的思路都是用新的,同名的css去覆盖ant的样式。

问题也很明显,css维护起来很糟心,尤其是大型项目,因为每个模块可能都是不同的项目组在维护,很可能一个组覆盖的css影响了另一个组。

正确的解决办法是什么呢?

首先,不要引入babel-plugin-import,然后单独封装每个组件的css,举个例子,我们有一个ant4版本的Button组件,我们这样改造:

// Button组件
import { Button } from 'antd' ;
// 这里引入的是css,当然你的项目用sass或者less,改成相应的就行了
import './style/index.css'

一般你只有用ant的less,改造成本才比较小

/** 这里复制粘贴ant button的css 然后改其中的css样式*/

当然,如果你

import './style/index.css'

改为

import './style/index.less'

但还需要把less里的公共变量也需要一起改了,这个就更复杂一些。

所以你可以看到,ant design在更改样式这块,实在是太难改了,这也是ant5为什么放弃less,转向了css-in-js,因为css-in-js相当于把css全部交给js去处理,这样我们在js里修改变量相当于修改了css。

css-in-js真的解决了定制化样式的问题了吗?

css-in-js 相当于给你开放一些css变量让你动态设置,我们看下ant5的文档是怎么使用的

【目前最好的react组件库教程】主题切换功能设计

也就是我们其实也不是随心所欲的去修改css,也是在人家开放接口的范围内修改。如果你想修改的css样式不在其提供的修改样式的接口里,你还是不能定制化自己的样式。

所以css-in-js并没有想象中那么完美。

如果我们用less或者sass,也能做到类似的功能,怎么做呢,休息喝口水,咋接着看

我的组件库是如何做的

我们拿button组件为例,最终打包的组件库,我会生成一个index.css,这个css中包含了一些css变量,例如

.mx-base-button {
  display: inline-flex;
  position: relative;
  align-items: center;
  justify-content: center;
  outline: none;
  padding: var(--btn-padding);
  height: var(--btn-height);
  appearance: none;
  user-select: none;
  cursor: pointer;
  white-space: nowrap;
  transition: all 0.2s var(--transition-timing-function-standard);
  box-sizing: border-box;
  line-height: 1.5715;
  border-radius: var(--btn-radius);
}

然后,对外暴露一个修改css变量的方法,例如:

import { isObject } from './is';

/**
 * 更换css变量的方法
 */
export function setCssVariables(variables: Record<string, any>, root = document.body) {
  if (variables && isObject(variables)) {
    Object.keys(variables).forEach((themKey) => {
      root.style.setProperty(themKey, variables[themKey]);
    });
  }
}

其实这里差不多就实现了自定义主题。其实实现的效果跟css-in-js差不多,但是彼此优劣也很明显,首先我们的css变量是语法更加标准,因为没有js到css转化,所以性能肯定比css-in-js更好,对react没有入侵。

这两种方式并不能说谁绝对的好和绝对的坏,我的组件库采取后者的主要原因是我看到了一篇文章叫做《我们为何放弃css-in-js》中提到了一条:

  1. css-in-js 运行时解析的实现版本增加了运行时性能压力,尤其在 React18 调度机制模式下,存在无法解决的性能问题(运行时插入样式会导致 React 渲染暂停,浏览器解析一遍样式,渲染再继续,然后浏览器又解析一遍样式)。

所以为了性能我放弃了css-in-js这个方案。我们接着来看看我的这种借助css变量实现的主题切换方案,如何跟react相结合。

我们的实现的目标为:

1、局部样式改变

比如所有的button默认都是蓝色,但是某个button我想是黄色,允许单独给button传递主题色。

2、全局样式改变

比如我想对所有的button的主色都设为绿色,提供一个全局更换颜色配置的入口。

全局更换主题色

我们在react层面提供一个ThemeProvider,把配置的主题色能够下发到每个组件,然后可以把开放的所有组件的css变量在这个ThemeProvider中完成修改,例如:

function setCssVariables(variables: Record<string, any>, root = document.body) {
  if (variables && isObject(variables)) {
    Object.keys(variables).forEach((themKey) => {
      root.style.setProperty(themKey, variables[themKey]);
    });
  }

function useIsFirstRender(): boolean {
  const isFirst = useRef(true)

  if (isFirst.current) {
    isFirst.current = false

    return true
  }

  return isFirst.current
}

function usePrevious(value) {
  const ref = useRef();
  useEffect(() => {
    ref.current = value;
  });
  return ref.current;
}

export function MxThemeProvider(variables) {
  const variablesProps = useMemo(() => ({ ...variables }), [variables]);
  const previousVariablesProps = usePrevious(variablesProps);
  const isFirst = useIsFirstRender()

  if (isFirst || variablesProps !== previousVariablesProps) setCssVariables(variables);

  return (
      <ThemeContext.Provider value={config}>{children}</ConfigContext.Provider>
  );
}

局部更换主题色

最开始我是想像css-in-js那样,传递参数,然后用style来设置样式,但是毫无疑问不能这样做,这样会影响css优先级,有些同学更改了class可能因为没有style优先级高而样式不生效,造成奇怪的体验。

当然这也是普通css不如css-in-js的地方,可以像传入js变量一样更改css。但是我们也有办法,之前说了,这样独立样式的button毕竟不是常见的需求,因为一般大家的ui都有一套设计规范,我们在全局更换主题色即可。

这样单独修改的需求可以将从打包好的css样式中提取出对应组件的css,把样式单独更改后,和js一起导出。

也可以采取覆盖局部样式的方式。(不推荐)

现在想来,局部更换主题色似乎不太好做,还好,原生css变量支持css变量的作用域,什么意思呢?在mdn中,这种作用域被称之为继承性。以下转自mdn对继承性的解释和案例:

自定义属性会继承。这意味着如果在一个给定的元素上,没有为这个自定义属性设置值,在其父元素上的值会被使用。看这一段 HTML:

<div class="one">
  <div class="two">
    <div class="three"></div>
    <div class="four"></div>
  </div>
</div>

配套的 CSS:

.two {
  --test: 10px;
}

.three {
  --test: 2em;
}

在这个情况下, var(--test) 的结果分别是:

  • 对于元素 class="two" :10px
  • 对于元素 class="three" :2em
  • 对于元素 class="four" :10px (继承自父属性)

啥意思呢,就是我的button组件,我可以默认用全局变量的样式,比如我设置在body上,然后button组件包裹一个div,div上也有同名的一个变量,那么button组件会优先使用div上的变量。

基于以上原理我可以在button组件里,直接在style中用来设置css变量,代码如下

const localBtnTheme = {
    "--btn-color": "red",
    "--btn-width": 12
};

<div style={{ style, ...localBtnTheme }} >

其中style是正常外面传给组件的style, localBtnTheme是指css变量

如果你有其他组件库主题切换的方案,欢迎在评论区,或者我的github上,微信组件库讨论群一起讨论哦