目标:理解组件库是如何设计样式隔离的,业务上写出和组件库结合较好的样式
先定个范围:不考虑换肤,只看如何处理样式隔离。
那么为什么需要样式隔离,简单理解:我们希望其他人写的样式不会影响到自己,场景包括:不同项目,或者同一个项目的不同模块。
同一个项目大家很多时候通过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:):
ConfigConsumer
和 ConfigContext
: 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
实现的一种灵活的样式管理方案,通过token
和components
实现全局与局部样式的动态生成和注入- 动态更新
antd小的总结
antd5之前是通过配置prefixcls和less的变量来做样式隔离,antd5使用了css-in-js, less改为了token.
翻了下资料看看区别
特性 | 传统 CSS 文件 | Ant Design 的 cssinjs |
---|---|---|
动态主题切换 | 需要加载多个文件 | 实时生成,无需额外文件加载 |
样式隔离 | 手动处理类名冲突 | 自动隔离,类名哈希化 |
按需加载 | 一次加载所有样式 | 按需动态注入 |
响应式能力 | 依赖媒体查询 | 媒体查询 + JS 逻辑 |
全局污染风险 | 较高 | 极低 |
性能 | 样式体积可能较大 | 精确生成,性能更优 |
shadcn/ui
简单介绍一下使用
安装:
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 内部样式。