当我最初尝试像 Styled Components 和 Emotion 这样的 CSS-in-JS 方案时,最吸引我的一点就是能够直接将组件的状态或属性值传递到样式定义中。它真正践行了 React 中“UI 即状态的函数”这一核心思想。虽然相比传统类名拼接加预处理器的方式有了明显进步,但这类方案在实际工程中依然伴随着不少痛点。
为了理清这一问题,我们可以将 React 组件开发中遇到的动态样式划分为两大典型场景:
- 数值型(Values): 例如颜色、延迟时间或坐标位置。任何代表 CSS 属性单一具体值的场景。
- 状态型(States): 例如按钮的变体(Primary/Secondary)、加载中(Loading)状态,通常伴随一整套关联样式。
现状对比
在探讨新方案前,我们先回顾一下业界通常采用的传统方式:SCSS(配合 BEM 命名规范)以及 Styled Components。
数值型动态样式
在纯 CSS 或使用 SASS/LESS 的传统做法中,传递动态数值通常只能借由内联样式(inline styles)实现:
function Button({ color, children }) {
return (
<button className="button" style={{ backgroundColor: color }}>
{children}
</button>
);
}
这种做法的问题显而易见:内联样式拥有极高的选择器优先级,很难被外部样式覆盖,而且这些动态值也脱离了组件主体样式表的位置。
CSS-in-JS 方案通过将 props 直接注入样式模板解决了这一问题:
// 我们可以将 `color` 作为 prop 传入 styled component
function Button({ color, children }) {
return <StyledButton color={color}>{children}</StyledButton>;
}
const StyledButton = styled.button`
border: 0;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
color: dimgrey;
background-color: ${props => props.color};
`;
状态型动态样式
在传统 CSS 中,我们往往依赖类名拼接。尽管配合预处理器和 BEM 规范尚可接受,但 JSX 端往往显得杂乱且冗长:
function Button({ color, size, primary, children }) {
return (
<button
className={['button', `button--${size}`, primary ? 'button--primary' : null]
.filter(Boolean)
.join(' ')}
style={{ backgroundColor: color }}
>
{children}
</button>
);
}
.button {
border: 0;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
color: dimgrey;
background-color: whitesmoke;
&--primary {
background-color: $primary-color;
}
&--small {
height: 30px;
}
&--medium {
height: 40px;
}
&--large {
height: 60px;
}
}
而在 Styled Components 中,处理多状态时也并不轻松:
const StyledButton = styled.button`
border: 0;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
color: dimgrey;
background-color: whitesmoke;
${props => props.primary && css`
background-color: $primary-color;
`}
${props => props.size === 'small' && css`
height: 30px;
`}
${props => props.size === 'medium' && css`
height: 40px;
`}
${props => props.size === 'large' && css`
height: 60px;
`}
`;
大量的函数插值不仅加重了运行时解析负担,而且破坏了 CSS 代码的可读性与格式化体验。
更好的选择:现代原生 CSS
事实上,解决方案一直在我们身边:CSS 自定义属性(CSS Custom Properties,即 CSS 变量)。如今所有现代浏览器早已对其提供了近乎完美的全面支持。
回归现代原生 CSS 是一种返璞归真的趋势。平台标准的能力越来越完善,我们不再需要过度依赖厚重的中间层运行时。
优雅处理数值:CSS 变量注入
一个非常简洁明了的技巧是通过 style 属性注入 CSS 自定义变量:
function Button({ color, children }) {
return (
<button className="button" style={{ '--color': color }}>
{children}
</button>
);
}
.button {
border: 0;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
color: dimgrey;
background-color: var(--color);
}
这看起来像内联样式,但实质完全不同:样式的具体规则依然在 .button 类选择器中集中声明,没有选择器权重污染的问题,仅仅把动态值作为变量传递了下去。
在处理 transform、位移或裁剪路径等场景时,这一优势更为显著:
function Button({ offset, children }) {
return (
<button className="button" style={{ '--offset': `${offset}px` }}>
{children}
</button>
);
}
.button {
border: 0;
padding: 8px 12px;
font-size: 14px;
color: dimgrey;
background-color: whitesmoke;
transform: translate3d(0, var(--offset), 0);
}
优雅处理状态:Data 属性与原生嵌套
针对组件状态与变体,最佳实践是使用 data-* 属性。它与原生 CSS 嵌套语法高度契合,既语义化又利于维护:
function Button({ color, size, primary, children }) {
return (
<button className="button" data-size={size} data-primary={primary}>
{children}
</button>
);
}
.button {
border: 0;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
color: dimgrey;
background-color: whitesmoke;
&[data-primary='true'] {
background-color: var(--colorPrimary);
}
&[data-size='small'] {
height: 30px;
}
&[data-size='medium'] {
height: 40px;
}
&[data-size='large'] {
height: 60px;
}
}
在线交互示例:
这种方式彻底避免了难看的字符串拼接,清晰地将基础元素(类选择器)与状态修饰(属性选择器)进行了正交解耦。对于无障碍属性(如 aria-pressed、aria-disabled),同样可以直接作为状态样式触发源:
.button {
&[aria-pressed='true'] {
background-color: gainsboro;
}
&[disabled] {
opacity: 0.4;
}
}
至于样式作用域隔离,现代全栈框架内置的 CSS Modules 就能零成本搞定,无需引入庞大的 JS 运行时分析开销。
总结
现代 CSS 已经日新月异。随着嵌套语法(Nesting)、相对颜色(Relative Colors)等特性的全面普及,用平台标准解决动态样式问题已然成为最纯粹、高效的选择。
