UI组件库之:如何做样式隔离

179 阅读10分钟

目标:理解组件库是如何设计样式隔离的,业务上写出和组件库结合较好的样式

先定个范围:不考虑换肤,只看如何处理样式隔离。

那么为什么需要样式隔离,简单理解:我们希望其他人写的样式不会影响到自己,场景包括:不同项目,或者同一个项目的不同模块。

同一个项目大家很多时候通过className来处理,比如生成唯一的className, css-in-js,那么不同的项目怎么影响呢?举个例子在微前端的架构下,子应用们选择了同一个UI库(antd)进行开发,一般也都会选择同一个,每个子应用改了一些UI库的基础样式,加载不同的子应用后就可能引起冲突。导致你的页面在加载了某个别的应用后出现样式问题。

简单提一下微前端:一个页面(主页面)想加载另一个别人开发的页面(子页面),可以通过iframe,但是通信麻烦,所以通过主页面来请求子页面的html,主页面来解析html的js,css,然后运行这些js,css。把它们渲染的东西放到一个DOM里。相当于主页面请求了很多其他的js, css。这些js,css和自己本身的看起来没啥区别。现在的微前端框架考虑了JS隔离,CSS的隔离由开发者自己控制。

这样请求回来的css里如果有一个组件库(比如antd)的基础样式(.antd-btn: {color: #xxx})被改了,很可能就把主的覆盖了。

其实为了解决这个问题,也是去修改className,比如这个应用叫a-prefixcls,那个叫b-prefixcls。

为了满足这个需求,咱们来看看组件库是怎么实现的。

选antd0.12,antd4,antd5和shadcn/ui

选用antd,考虑B端很多都是用它,而shadcn/ui是基于tailwindcss的,也比较有代表性。

先看antd,要了解一个UI最好还是看它最初的版本,便于理解。所以从0.12看下先。

antd-0.12

来看看它最初的样子

  • less文件里定一个了css-prefix @css-prefix: ant-;
  • Button组件里固定了prefix const prefix = 'ant-btn-',其他组件类似。

less文件

style/themes/default/custom.less:

@css-prefix: ant-;

组件(button, modal)里使用classname

选model组件是为了看到函数式调用时组件是怎么处理的

button组件的基础文件: components/button/button.jsx

// ...
const prefix = 'ant-btn-';

const classes = classNames({
  'ant-btn': true,
});

// ...
return (
  <button
    className={classes}
  </button>
);

button组件的样式文件: style/components/button.less:

@btn-prefix-cls: ~"@{css-prefix}btn";

.@{btn-prefix-cls} {
}

Modal组件的基础文件: components/modal/Modal.jsx

const AntModal = React.createClass({
  getDefaultProps() {
    return {
      prefixCls: 'ant-modal',
    };
  },
  
  return (
    <Dialog {...props}/>
  );
});

Modal组件的confirm文件: components/modal/confirm.jsx


  ReactDOM.render(<Dialog
    prefixCls="ant-modal"
    className="ant-confirm"
  </Dialog>);

Modal组件的样式文件: style/components/confirm.less


.@{confirm-prefix-cls} {

  .ant-modal-header {
    display: none;
  }
}
通过这个咱们了解antd是在组件和样式里都定义好前缀

既然这样咱们就再看看1.0版本,看看什么时候加入了ConfigProvider。

没有看到ConfigProvider,但是看到了locale-provider,简单看一下

components/locale-provider:

import React from 'react';
import { changeConfirmLocale } from '../modal/locale';

export default class LocaleProvider extends React.Component {

  componentDidUpdate() {
    const { locale } = this.props;
    changeConfirmLocale(locale && locale.Modal);
  }

  render() {
    return React.Children.only(this.props.children);
  }
}

继续看看2.0版本,没有。

继续看看3.0版本,有了, 摘了摘相关的代码。

config-provider(components/config-provider/index.tsx:):

ConfigConsumerConfigContext: Context API,用于跨组件传递配置数据。

getPrefixCls 生成带有前缀的类名

  • 如果传入了 customizePrefixCls,优先返回自定义的类名。
  • 如果没有,则拼接默认的 prefixCls(默认为 ant)和后缀 suffixCls

代码:

export const configConsumerProps = [
  'rootPrefixCls',
  'getPrefixCls',
];

export interface ConfigProviderProps {
  getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
  prefixCls?: string;
  children?: React.ReactNode;
}

class ConfigProvider extends React.Component<ConfigProviderProps> {
  getPrefixCls = (suffixCls: string, customizePrefixCls?: string) => {
    const { prefixCls = 'ant' } = this.props;

    if (customizePrefixCls) return customizePrefixCls;

    return suffixCls ? `${prefixCls}-${suffixCls}` : prefixCls;
  };

  renderProvider = () => {
    const {
      children,
    } = this.props;

    const config: ConfigConsumerProps = {
      getPrefixCls: this.getPrefixCls,
    };

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

  render() {
    return (
        <ConfigConsumer>
          {context => this.renderProvider(context)}
        </ConfigConsumer>
    );
  }
}

export default ConfigProvider;
ConfigContext(components/config-provider/context.tsx):
  • ConfigContext 是一个 React Context,用于共享全局配置。
  • withConfigConsumer 高阶组件,将 ConfigContext 中的配置注入到传入的 Component

代码:

export interface ConfigConsumerProps {
  rootPrefixCls?: string;
  getPrefixCls: (suffixCls: string, customizePrefixCls?: string) => string;
}

export const ConfigContext = createReactContext<ConfigConsumerProps>({
  // We provide a default function for Context without provider
  getPrefixCls: (suffixCls: string, customizePrefixCls?: string) => {
    if (customizePrefixCls) return customizePrefixCls;

    return `ant-${suffixCls}`;
  },

  renderEmpty: defaultRenderEmpty,
});

export const ConfigConsumer = ConfigContext.Consumer;

interface BasicExportProps {
  prefixCls?: string;
}

interface ConsumerConfig {
  prefixCls: string;
}

interface ConstructorProps {
  displayName?: string;
}

export function withConfigConsumer(config) {
  return function withConfigConsumerFunc(
    Component: IReactComponent,
  ): React.SFC {
    // Wrap with ConfigConsumer. Since we need compatible with react 15, be care when using ref methods
    const SFC = ((props) => (
      <ConfigConsumer>
        {(configProps: ConfigConsumerProps) => {
          const { prefixCls: basicPrefixCls } = config;
          const { getPrefixCls } = configProps;
          const { prefixCls: customizePrefixCls } = props;
          const prefixCls = getPrefixCls(basicPrefixCls, customizePrefixCls);
          return <Component {...configProps} {...props} prefixCls={prefixCls} />;
        }}
      </ConfigConsumer>
    ));
    return SFC;
  };
}

举个简单的的例子:

const MyComponent = ({ prefixCls }) => (
  <div className={`${prefixCls}-custom`}>
    Hello, Ant Design!
  </div>
);

const EnhancedComponent = withConfigConsumer({ prefixCls: 'my-component' })(MyComponent); // 渲染 <ConfigProvider prefixCls="custom-ant"> <EnhancedComponent /> </ConfigProvider>

最终生成的类名为:custom-ant-my-component.

这样,组件里的使用也随之发生了改变

看看button

// ...
renderButton = ({ getPrefixCls, autoInsertSpaceInButton }: ConfigConsumerProps) => {
const {
  prefixCls: customizePrefixCls,
} = this.props;

const prefixCls = getPrefixCls('btn', customizePrefixCls);

const classes = classNames(prefixCls, className, {
  [`${prefixCls}-${type}`]: type,
});

render() {
  return <ConfigConsumer>{this.renderButton}</ConfigConsumer>;
}

看看modal

翻文档的时候发现这么一个问题:

为什么 Modal 方法不能获取 context、redux、的内容和 ConfigProvider locale/prefixCls 配置?

直接调用 Modal 方法,antd 会通过 ReactDOM.render 动态创建新的 React 实体。其 context 与当前代码所在 context 并不相同,因而无法获取 context 信息。

你可以用以下的方法解决这个问题

设置 @ant-prefix'ant-prefix': 'antd3'

import { Modal } from 'antd';

// 使用
Modal.confirm({
  title: 'hello world',
  // 必须带上 '-modal' 后缀
  prefixCls: 'antd3-modal',
});

可以看到一些函数式的组件(通过ReactDOM.render渲染出来)需要设置一下配置。必须带上 '-modal' 后缀看着是less文件里写死了,所以需要带上。

举个例子修改less变量ant-prefix
  • 引入less文件,然后业务代码里修改
@import "~antd/dist/antd.less"; // 导入 Ant Design 的默认 LESS 文件 

// 修改 ant-prefix 
@ant-prefix: 'my-ant';
  • webpack
module: {
  rules: [
    {
      test: /\.less$/,
      use: [
        'style-loader',
        'css-loader',
        {
          loader: 'less-loader',
          options: {
            lessOptions: {
              modifyVars: {
                '@ant-prefix': 'my-ant',  // 修改 ant-prefix
              },
              javascriptEnabled: true,
            },
          },
        },
      ],
    },
  ],
}

antd4.x版本

增加了支持SSR的渲染CSS变量

if (canUseDom()) {
  updateCSS(style, `${dynamicStyleMark}-dynamic-theme`);
} else {
  warning(false, 'ConfigProvider', 'SSR do not support dynamic theme with css variables.');
}

components/config-provider/index.tsx,比3多了几个函数

  getPrefixCls: (suffixCls?: string, customizePrefixCls?: string) => {
    if (customizePrefixCls) return customizePrefixCls;
    return suffixCls ? `${getGlobalPrefixCls()}-${suffixCls}` : getGlobalPrefixCls();
  },
  getIconPrefixCls: getGlobalIconPrefixCls,
  getRootPrefixCls: (rootPrefixCls?: string, customizePrefixCls?: string) => {
    // Customize rootPrefixCls is first priority
    if (rootPrefixCls) {
      return rootPrefixCls;
    }

    // If Global prefixCls provided, use this
    if (globalPrefixCls) {
      return globalPrefixCls;
    }

    // [Legacy] If customize prefixCls provided, we cut it to get the prefixCls
    if (customizePrefixCls && customizePrefixCls.includes('-')) {
      return customizePrefixCls.replace(/^(.*)-[^-]*$/, '$1');
    }

    // Fallback to default prefixCls
    return getGlobalPrefixCls();
  },

有了rootPrefixCls和globalPrefixCls

  • 优先级
    • rootPrefixCls > globalPrefixCls > 默认值 'ant'
  • 覆盖范围
    • globalPrefixCls 是全局性的,会影响所有组件。
    • rootPrefixCls 通常是局部性的,仅在特定组件或上下文中使用。
  • 依赖关系
    • 如果没有显示设置 rootPrefixCls,会自动回退使用 globalPrefixCls

antd4版本的自定义className的小结

组件里通过Context和provider来进行全局属性的配置和消费,业务代码设置了什么就用什么,Less变量通过修改变量的值或者webpack配置来修改。

这样就可以对className进行自定义

在3版本的时候可以进行修改,4版本的时候更新了rootPrefixCls和globalPrefixCls,可以更加细化的修改。函数组件(Model),因为ReactDOM.render 动态创建新的 React 实体。其 context 与当前代码所在 context 并不相同,所以需要提供一个函数(confirm)来进行修改。

antd5

先看一下文档里怎么说

  • 弃用 less,采用 CSS-in-JS,更好地支持动态主题。底层使用 @ant-design/cssinjs 作为解决方案。

    • 所有 less 文件全部移除,less 变量不再支持透出。
    • 产物中不再包含 css 文件。由于 CSS-in-JS 支持按需引入,原本的 antd/dist/antd.css 也已经移除,如果需要重置一些基本样式请引入 antd/dist/reset.css
    • 如果需要组件重置样式,又不想引入 antd/dist/reset.css 从而导致污染全局样式的话,可以尝试在应用最外层使用App 组件,解决原生元素没有 antd 规范样式的问题。
  • 移除 css variables 以及在此之上构筑的动态主题方案。

那先不看ant-design/cssinjs,当作一个黑盒,后面再看,看看组件里面的调整

config-provider

1.prefixcls的配置

  • 生成:

    const defaultGetPrefixCls = (suffixCls?: string, customizePrefixCls?: string) => {
        if (customizePrefixCls) {
          return customizePrefixCls;
        }
        return suffixCls ? `${defaultPrefixCls}-${suffixCls}` : defaultPrefixCls;
    };
    
  • 使用

    const prefixCls = getPrefixCls('button');
    

    可以看到prefixcls的变化并不大, 多了一个对前端很敏感的单词:theme。

组件

1.prefixCls

  • 变化不大,使用如上所说const prefixCls = getPrefixCls('button');

2.less

  • 没有了less变量和文件,取而代之的是一段useStyle
      const [wrapCSSVar, hashId, cssVarCls] = useStyle(prefixCls);
    
      const classes = classNames(
          prefixCls,
          hashId,
          cssVarCls,
      )
      
      let buttonNode = (
          <button
              className={classes}
              style={fullStyle}
          ></button>
      );
      return wrapCSSVar(buttonNode);
    

这个antd/css-in-js暂时先不展开,只做一点点了解,先挖个坑,后面看源码。

但是做一点了解,antd的文档里也新增了很多token的变量,让我们在修改组件的样式时更加方便

看个简单的例子

const token = {
  colorPrimary: '#52c41a',
  borderRadius: '4px',
};

const useStyle = createUseStyles((token) => ({
  button: {
    backgroundColor: token.colorPrimary,
  },
}));

  • 基于 Token 的样式管理
  • cssinjs 会为每个组件生成唯一的样式类名
  • theme 配置是基于 cssinjs 实现的一种灵活的样式管理方案,通过 tokencomponents 实现全局与局部样式的动态生成和注入
  • 动态更新
antd小的总结

antd5之前是通过配置prefixcls和less的变量来做样式隔离,antd5使用了css-in-js, less改为了token.

翻了下资料看看区别

特性传统 CSS 文件Ant Design 的 cssinjs
动态主题切换需要加载多个文件实时生成,无需额外文件加载
样式隔离手动处理类名冲突自动隔离,类名哈希化
按需加载一次加载所有样式按需动态注入
响应式能力依赖媒体查询媒体查询 + JS 逻辑
全局污染风险较高极低
性能样式体积可能较大精确生成,性能更优

shadcn/ui

官网:ui.shadcn.com/docs/compon…

简单介绍一下使用

安装:

npx shadcn@latest add button

安装后它会固定将组件放到componet/ui下,使用的时候我们去引用它

import { Button } from "@/components/ui/button"

<Button variant="outline">Button</Button>

button组件的代码:

import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
  {
    variants: {
      variant: {
        default: "bg-primary text-primary-foreground hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground hover:bg-destructive/90",
        outline:
          "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
        secondary:
          "bg-secondary text-secondary-foreground hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-10 px-4 py-2",
        sm: "h-9 rounded-md px-3",
        lg: "h-11 rounded-md px-8",
        icon: "h-10 w-10",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }

它把源代码直接放到咱们的src里,当作源代码的一部分。className那可以看出来是TailwindCSS

Tailwind: tailwindcss.com/

  • Tailwind 是一个基于原子化 CSS 的框架,每个样式类只做一件事情。
  • 样式定义是全局的,但由于类名是原子化的,冲突风险低。

这样组件之间的样式隔离还是可以通过css-module来做。

全局的可以通过配置全局的prefix来处理

// tailwind.config.js
module.exports = {
  prefix: 'tw-', // 给所有类名加上 'tw-' 前缀
  content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
  theme: {
    extend: {},
  },
  plugins: [],
};

与 Ant Design 的对比

特性Ant Design (css-in-js)shadcn/ui (Tailwind + Scoped Styles)
样式生成方式动态生成 CSS预定义原子类和动态拼接
样式隔离哈希化类名原子类名和 Scoped 样式
灵活性高(基于 Token 的动态样式系统)高(类名动态拼接,支持 Scoped 扩展)
性能优化良好,但初始样式注入可能稍慢非常快(基于原子化类名的极简加载)
开发体验偏重(需理解复杂的 Token 和主题配置)轻量(直观的 Tailwind 样式编写方式)
样式扩展依赖主题系统或重新定义组件直接覆盖类名或扩展 @layer

总结

在做UI库的样式隔离,要考虑多版本,同版本多次引用的场景,这样需要基础的样式能自定义className

在自定义className这件事上,不同的设计有不同的方案

  • less通过修改less的变量值来实现
  • css-in-js通过useStyles来生成className
  • tailwindcss通过配置prefix来实现

不管怎么设计,都是需要提供定义class Name的能力

最后参考资料还可以使用Shadow DOM

举个例子

<style>
  p { color: green; }
</style>
<div id="host"></div>

<script>
  const shadowRoot = document.querySelector("#host").attachShadow({ mode: "open" });
  shadowRoot.innerHTML = `
    <style>
      p { color: red; }
    </style>
    <p>Inside Shadow DOM</p>
  `;
</script>

  • 外部的 p { color: green; } 不会影响 Shadow DOM 内部的 <p>
  • 内部 p { color: red; } 仅作用于 Shadow DOM 内部。

局限性 CSS 定制限制: 外部样式无法直接覆盖 Shadow DOM 内部样式。