CSS in JS
大约 12 分钟约 3499 字
CSS in JS
简介
CSS in JS 是将样式写在 JavaScript 中的方案,通过运行时或编译时生成唯一的 CSS 类名,实现组件级别的样式隔离。主流方案分为运行时(styled-components、Emotion)和编译时(Vanilla Extract、Panda CSS)两大流派。CSS in JS 解决了传统 CSS 的全局命名冲突问题,同时提供了根据 props 动态计算样式的能力。适用于 React 项目中的组件级样式隔离和主题系统构建。
特点
实现
styled-components
import styled, { css, keyframes, createGlobalStyle, ThemeProvider } from 'styled-components';
// ========== 基础样式组件 ==========
// transient props($ 前缀)不会传递到 DOM
const Button = styled.button<{
$variant?: 'primary' | 'danger' | 'ghost';
$size?: 'small' | 'medium' | 'large';
}>`
padding: ${({ $size }) => ({ small: '6px 12px', medium: '8px 16px', large: '12px 24px' }[$size || 'medium'])};
border-radius: 4px;
border: none;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
${({ $variant }) =>
$variant === 'primary' &&
css`
background: #1890ff;
color: white;
&:hover { background: #40a9ff; box-shadow: 0 2px 8px rgba(24, 144, 255, 0.4); }
&:active { background: #096dd9; }
`}
${({ $variant }) =>
$variant === 'danger' &&
css`
background: #ff4d4f;
color: white;
&:hover { background: #ff7875; }
&:active { background: #d9363e; }
`}
${({ $variant }) =>
$variant === 'ghost' &&
css`
background: transparent;
color: #333;
border: 1px solid #d9d9d9;
&:hover { color: #1890ff; border-color: #1890ff; }
`}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
// ========== 继承样式 ==========
const LargeButton = styled(Button)`
padding: 12px 24px;
font-size: 16px;
border-radius: 8px;
`;
const IconButton = styled(Button)`
padding: 8px;
border-radius: 50%;
width: 36px;
height: 36px;
`;
// ========== 动画 ==========
const fadeIn = keyframes`
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
`;
const Card = styled.div`
animation: ${fadeIn} 0.3s ease;
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
`;
// ========== 全局样式 ==========
const GlobalStyle = createGlobalStyle`
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f5;
color: #333;
}
`;
// ========== 主题系统 ==========
const theme = {
colors: {
primary: '#1890ff',
primaryHover: '#40a9ff',
danger: '#ff4d4f',
success: '#52c41a',
warning: '#faad14',
text: '#333',
textSecondary: '#666',
border: '#d9d9d9',
bg: '#fff',
bgGray: '#f5f5f5',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radius: { sm: 2, md: 4, lg: 8, xl: 12 },
fonts: {
body: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
mono: "'Fira Code', monospace",
},
};
// ========== 使用 ==========
function App() {
return (
<ThemeProvider theme={theme}>
<GlobalStyle />
<Card>
<Button $variant="primary">提交</Button>
<Button $variant="danger">删除</Button>
<Button $variant="ghost">取消</Button>
<LargeButton $variant="primary">大按钮</LargeButton>
</Card>
</ThemeProvider>
);
}
// 在组件中使用主题
const ThemedCard = styled.div`
background: ${({ theme }) => theme.colors.bg};
border: 1px solid ${({ theme }) => theme.colors.border};
border-radius: ${({ theme }) => theme.radius.lg}px;
padding: ${({ theme }) => theme.spacing.md}px;
color: ${({ theme }) => theme.colors.text};
`;Emotion
import { css, keyframes, Global } from '@emotion/css';
import styled from '@emotion/styled';
import { ThemeProvider } from '@emotion/react';
// ========== 对象样式 ==========
const cardStyle = css({
padding: 16,
borderRadius: 8,
background: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
'&:hover': {
boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
transform: 'translateY(-2px)',
},
transition: 'all 0.3s ease',
});
// ========== 模板字符串样式 ==========
const Container = styled.div`
max-width: 1200px;
margin: 0 auto;
padding: 0 ${props => props.theme.spacing.md}px;
@media (max-width: 768px) {
padding: 0 ${props => props.theme.spacing.sm}px;
}
`;
// ========== 动态样式 ==========
const StatusBadge = styled.span<{ $status: 'online' | 'offline' | 'warning' }>`
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
${({ $status }) => {
const styles = {
online: css`background: #f6ffed; color: #52c41a;`,
offline: css`background: #fff1f0; color: #ff4d4f;`,
warning: css`background: #fffbe6; color: #faad14;`,
};
return styles[$status];
}}
`;
// ========== 组合样式 ==========
const baseInputStyle = css`
border: 1px solid #d9d9d9;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
outline: none;
transition: border-color 0.3s;
&:focus {
border-color: #1890ff;
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2);
}
`;
const errorInputStyle = css`
border-color: #ff4d4f;
&:focus {
border-color: #ff4d4f;
box-shadow: 0 0 0 2px rgba(255, 77, 79, 0.2);
}
`;
const Input = styled.input<{ $hasError?: boolean }>`
${baseInputStyle}
${({ $hasError }) => $hasError && errorInputStyle}
width: 100%;
`;
// ========== 全局样式 ==========
<Global styles={{
'*': { margin: 0, padding: 0, box-sizing: 'border-box' },
body: {
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
backgroundColor: '#f5f5f5',
},
}} />编译时 CSS in JS — Vanilla Extract
// Vanilla Extract — 零运行时 CSS in JS
import { style, createVar, globalStyle, createTheme, assignVars } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
// ========== CSS 变量 ==========
const primaryColor = createVar();
const textColor = createVar();
// ========== 样式定义 ==========
const cardClass = style({
padding: 16,
borderRadius: 8,
background: '#fff',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
vars: {
[primaryColor]: '#1890ff',
[textColor]: '#333',
},
});
// ========== 全局样式 ==========
globalStyle('body', {
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
backgroundColor: '#f5f5f5',
color: textColor,
});
// ========== Recipe — 类似 styled-components 的变体 ==========
const buttonRecipe = recipe({
base: {
padding: '8px 16px',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
fontWeight: 500,
transition: 'all 0.3s',
},
variants: {
variant: {
primary: { background: '#1890ff', color: '#fff' },
danger: { background: '#ff4d4f', color: '#fff' },
ghost: { background: 'transparent', border: '1px solid #d9d9d9' },
},
size: {
small: { padding: '6px 12px', fontSize: '12px' },
medium: { padding: '8px 16px', fontSize: '14px' },
large: { padding: '12px 24px', fontSize: '16px' },
},
},
defaultVariants: {
variant: 'primary',
size: 'medium',
},
});
// 使用
<button className={buttonRecipe({ variant: 'primary', size: 'large' })}>
点击我
</button>主题系统设计
// ========== 深色主题 ==========
const darkTheme = {
colors: {
primary: '#177ddc',
primaryHover: '#3c9ae8',
danger: '#a61d24',
success: '#49aa19',
warning: '#d89614',
text: '#e8e8e8',
textSecondary: '#a6a6a6',
border: '#434343',
bg: '#141414',
bgGray: '#1f1f1f',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radius: { sm: 2, md: 4, lg: 8, xl: 12 },
};
// ========== 主题切换 ==========
function App() {
const [isDark, setIsDark] = useState(false);
return (
<ThemeProvider theme={isDark ? darkTheme : theme}>
<button onClick={() => setIsDark(!isDark)}>
切换{isDark ? '亮色' : '暗色'}
</button>
<Dashboard />
</ThemeProvider>
);
}
// ========== 自定义 Hook ==========
function useTheme() {
const theme = useContext(ThemeContext);
if (!theme) throw new Error('useTheme must be used within ThemeProvider');
return theme;
}
// 在组件中使用
const ThemedButton = styled.button`
background: ${({ theme }) => theme.colors.primary};
color: ${({ theme }) => theme.colors.bg};
padding: ${({ theme }) => theme.spacing.sm}px ${({ theme }) => theme.spacing.md}px;
border-radius: ${({ theme }) => theme.radius.md}px;
`;Stitches — 另一个编译时方案
Stitches 是一个接近零运行时的 CSS in JS 库,API 设计优秀,变体系统直观。
import { styled, css } from '@stitches/react';
const Button = styled('button', {
// 基础样式
padding: '8px 16px',
borderRadius: '4px',
border: 'none',
cursor: 'pointer',
fontWeight: 500,
transition: 'all 0.2s',
// 变体系统
variants: {
color: {
primary: { background: '#1890ff', color: '#fff' },
danger: { background: '#ff4d4f', color: '#fff' },
ghost: { background: 'transparent', border: '1px solid #d9d9d9' },
},
size: {
sm: { padding: '4px 12px', fontSize: '12px' },
md: { padding: '8px 16px', fontSize: '14px' },
lg: { padding: '12px 24px', fontSize: '16px' },
},
fullWidth: {
true: { width: '100%' },
},
},
// 默认变体
defaultVariants: {
color: 'primary',
size: 'md',
},
// 组合变体 — 当多个变体同时激活时
compoundVariants: [
{
color: 'primary',
size: 'lg',
css: { boxShadow: '0 4px 12px rgba(24, 144, 255, 0.4)' },
},
],
});
// 使用
<Button color="danger" size="lg" fullWidth>
删除所有
</Button>Panda CSS — Tailwind + CSS in JS 的融合
Panda CSS 在编译时将原子化 CSS 类提取为纯 CSS 文件,兼具 Tailwind 的性能和 CSS in JS 的开发体验。
import { css, styled } from '@panda/css';
// 使用 css 函数定义原子化样式
const cardStyles = css({
bg: 'white',
borderRadius: 'lg',
p: '6',
boxShadow: 'md',
_hover: { boxShadow: 'xl', transform: 'translateY(-2px)' },
transition: 'all 0.3s',
});
// 使用 styled 创建样式组件
const Card = styled('div', {
base: {
bg: 'white',
borderRadius: 'lg',
p: '6',
boxShadow: 'md',
},
variants: {
variant: {
elevated: { boxShadow: 'xl' },
outlined: { border: '1px solid', borderColor: 'gray.200', boxShadow: 'none' },
},
},
});
// 使用 CVA 模式(Class Variance Authority)
import { cva } from '@panda/css';
const button = cva({
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: 'md',
fontWeight: 'semibold',
transition: 'colors 0.2s',
_focus: { outline: 'none', ring: '2', ringColor: 'blue.500' },
},
variants: {
intent: {
primary: { bg: 'blue.500', color: 'white', _hover: { bg: 'blue.600' } },
secondary: { bg: 'gray.100', color: 'gray.900', _hover: { bg: 'gray.200' } },
danger: { bg: 'red.500', color: 'white', _hover: { bg: 'red.600' } },
},
size: {
sm: { px: '3', py: '1.5', fontSize: 'sm' },
md: { px: '4', py: '2', fontSize: 'base' },
lg: { px: '6', py: '3', fontSize: 'lg' },
},
},
defaultVariants: { intent: 'primary', size: 'md' },
});
// 使用
<button className={button({ intent: 'danger', size: 'lg' })}>删除</button>CSS in JS 性能优化策略
运行时 CSS in JS 的性能开销主要来自样式解析和插入 DOM。以下是关键优化技巧:
// 1. 避免在渲染函数中创建样式 — 每次渲染都会重新解析
function BadComponent() {
// 错误:每次渲染都创建新的样式规则
const dynamicStyle = css({ color: Math.random() > 0.5 ? 'red' : 'blue' });
return <div className={dynamicStyle}>内容</div>;
}
// 正确:使用 styled 组件 + props 驱动
const StyledDiv = styled.div<{ $active: boolean }>`
color: ${({ $active }) => $active ? 'red' : 'blue'};
`;
function GoodComponent() {
const [active, setActive] = useState(false);
return <StyledDiv $active={active}>内容</StyledDiv>;
}
// 2. 使用 shouldForwardProp 过滤不需要的 props
const StyledInput = styled('input').withConfig({
shouldForwardProp: (prop) => !['$customProp'].includes(prop),
})<{ $customProp: string }>`
// 样式...
`;
// 3. 使用 babel 插件预编译模板字面量
// .babelrc 或 babel.config.js
// {
// "plugins": ["babel-plugin-styled-components"]
// }
// 该插件会在编译时将模板字面量转换为更高效的函数调用
// 4. Emotion 的 css prop 与 cache 优化
import { CacheProvider, css } from '@emotion/react';
import createCache from '@emotion/cache';
import stylisPluginRtl from 'stylis-plugin-rtl';
// RTL 支持
const rtlCache = createCache({ key: 'rtl', stylisPlugins: [stylisPluginRtl] });
function RTLWrapper({ children, dir }: { children: React.ReactNode; dir: 'ltr' | 'rtl' }) {
if (dir === 'rtl') {
return <CacheProvider value={rtlCache}>{children}</CacheProvider>;
}
return <>{children}</>;
}服务端渲染(SSR)中的 CSS in JS
SSR 场景下需要确保样式在服务端正确提取并注入到 HTML 中。
// styled-components SSR 配置(Next.js Pages Router)
import Document, { DocumentContext } from 'next/document';
import { ServerStyleSheet } from 'styled-components';
export default class MyDocument extends Document {
static async getInitialProps(ctx: DocumentContext) {
const sheet = new ServerStyleSheet();
const originalRenderPage = ctx.renderPage;
try {
ctx.renderPage = () =>
originalRenderPage({
enhanceApp: (App) => (props) =>
sheet.collectStyles(<App {...props} />),
});
const initialProps = await Document.getInitialProps(ctx);
return {
...initialProps,
styles: (
<>
{initialProps.styles}
{sheet.getStyleElement()}
</>
),
};
} finally {
sheet.seal();
}
}
}
// Emotion SSR 配置(Next.js App Router + Emotion)
import createCache from '@emotion/cache';
import { CacheProvider } from '@emotion/react';
import { EmotionCache } from '@emotion/cache';
// 创建缓存实例
const emotionCache: EmotionCache = createCache({ key: 'css', prepend: true });
export default function EmotionProvider({ children }: { children: React.ReactNode }) {
return <CacheProvider value={emotionCache}>{children}</CacheProvider>;
}
// 服务端提取样式(Emotion)
import { extractCritical } from '@emotion/server';
// 在服务端处理函数中
function handleSSR(html: string) {
const { html: styledHtml, ids, css } = extractCritical(html);
return `
<!DOCTYPE html>
<html>
<head>
<style data-emotion="${ids.join(' ')}">${css}</style>
</head>
<body>${styledHtml}</body>
</html>
`;
}与 CSS Modules 的对比与选择
// CSS Modules 方式 — 传统文件分离
// Button.module.css
// .primary { background: #1890ff; color: #fff; }
// .danger { background: #ff4d4f; color: #fff; }
// Button.tsx
import styles from './Button.module.css';
import classNames from 'classnames';
interface ButtonProps {
variant?: 'primary' | 'danger';
disabled?: boolean;
}
function Button({ variant = 'primary', disabled, children }: ButtonProps) {
return (
<button
className={classNames(
styles.button,
styles[variant],
{ [styles.disabled]: disabled }
)}
>
{children}
</button>
);
}
// CSS in JS 方式 — 样式与逻辑同文件
const StyledButton = styled.button<{ $variant?: 'primary' | 'danger' }>`
padding: 8px 16px;
border-radius: 4px;
border: none;
cursor: pointer;
background: ${({ $variant }) => $variant === 'danger' ? '#ff4d4f' : '#1890ff'};
color: #fff;
`;
// 选择建议:
// - 简单项目、纯静态页面 → CSS Modules 或纯 CSS
// - 需要动态样式、主题系统 → CSS in JS
// - 性能极致要求 → Vanilla Extract / Panda CSS
// - 已有 Tailwind 基础 → Panda CSS响应式设计与媒体查询
// styled-components 中的响应式设计
const ResponsiveGrid = styled.div`
display: grid;
gap: 16px;
padding: 16px;
// 移动端 — 单列
grid-template-columns: 1fr;
// 平板 — 双列
@media (min-width: 768px) {
grid-template-columns: repeat(2, 1fr);
gap: 24px;
padding: 24px;
}
// 桌面 — 三列
@media (min-width: 1024px) {
grid-template-columns: repeat(3, 1fr);
gap: 32px;
padding: 32px;
}
`;
// 使用 Emotion 的响应式工具
const breakpoints = [640, 768, 1024, 1280];
const mq = breakpoints.map(bp => `@media (min-width: ${bp}px)`);
const ResponsiveCard = styled.div`
padding: 12px;
${mq[0]} { padding: 16px; }
${mq[1]} { padding: 20px; }
${mq[2]} { padding: 24px; }
`;
// Vanilla Extract 中的响应式
import { style } from '@vanilla-extract/css';
import { theme } from './theme.css';
const responsiveContainer = style({
display: 'grid',
gap: '16px',
'@media': {
'screen and (min-width: 768px)': {
gridTemplateColumns: 'repeat(2, 1fr)',
},
'screen and (min-width: 1024px)': {
gridTemplateColumns: 'repeat(3, 1fr)',
},
},
});自定义样式工具函数
// 常用 mixins 工具
const mixins = {
// 文本省略
ellipsis: (lines: number = 1) => css`
overflow: hidden;
text-overflow: ellipsis;
${lines > 1 ? css`
display: -webkit-box;
-webkit-line-clamp: ${lines};
-webkit-box-orient: vertical;
` : css`
white-space: nowrap;
`}
`,
// Flex 居中
flexCenter: css`
display: flex;
align-items: center;
justify-content: center;
`,
// 固定定位遮罩层
overlay: css`
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
`,
// 滚动容器
scrollable: (direction: 'x' | 'y' | 'both' = 'y') => css`
overflow: hidden;
${direction !== 'x' && 'overflow-y: auto;'}
${direction !== 'y' && 'overflow-x: auto;'}
-webkit-overflow-scrolling: touch;
&::-webkit-scrollbar { width: 6px; height: 6px; }
&::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
`,
// 安全区域适配(刘海屏)
safeArea: (property: string, fallback: string, direction: 'top' | 'bottom' | 'left' | 'right') => css`
${property}: ${fallback};
${property}: calc(${fallback} + env(safe-area-inset-${direction}));
`,
};
// 使用示例
const CardTitle = styled.h2`
font-size: 16px;
font-weight: 600;
${mixins.ellipsis(2)}
`;
const ModalOverlay = styled.div`
${mixins.overlay}
backdrop-filter: blur(4px);
`;
const ContentList = styled.div`
${mixins.scrollable('y')}
max-height: 400px;
padding: 16px;
`;样式组件的测试
import { render, screen } from '@testing-library/react';
import 'jest-styled-components';
// 测试样式组件
describe('Button', () => {
it('renders with primary variant styles', () => {
render(<Button $variant="primary">点击</Button>);
const button = screen.getByText('点击');
expect(button).toHaveStyleRule('background', '#1890ff');
expect(button).toHaveStyleRule('color', 'white');
});
it('applies hover styles', () => {
render(<Button $variant="primary">点击</Button>);
const button = screen.getByText('点击');
expect(button).toHaveStyleRule('background', '#40a9ff', {
modifier: ':hover',
});
});
it('applies disabled styles', () => {
render(<Button disabled>点击</Button>);
const button = screen.getByText('点击');
expect(button).toHaveStyleRule('opacity', '0.5');
expect(button).toHaveStyleRule('cursor', 'not-allowed');
});
});
// Emotion 样式测试
import { render } from '@testing-library/react';
describe('Emotion styles', () => {
it('applies correct CSS classes', () => {
const { container } = render(
<div className={css({ color: 'red', fontSize: '16px' })}>文本</div>
);
expect(container.firstChild).toHaveStyle('color: red');
expect(container.firstChild).toHaveStyle('font-size: 16px');
});
});优点
缺点
总结
CSS in JS 通过 JavaScript 编写组件样式,实现样式隔离和动态样式。运行时方案(styled-components、Emotion)灵活但有序行时开销,编译时方案(Vanilla Extract、Panda CSS)零运行时但灵活性稍低。主题通过 React Context 传递,ThemeProvider 包裹组件树。建议大型项目使用编译时方案减少性能开销,小型项目使用 styled-components 快速上手。
关键知识点
- CSS in JS 分为运行时(styled-components)和编译时(Vanilla Extract)两大流派。
- $ 前缀的 props 不会传递到 DOM 元素(transient props)。
- 样式优先级与 CSS 层叠规则一致,后定义的覆盖先定义的。
- 主题通过 React Context 传递,ThemeProvider 包裹组件树。
项目落地视角
- 选定一种 CSS in JS 方案统一使用,不要混用多种。
- 建立主题 Theme 对象,所有颜色和间距通过主题变量引用。
- 性能敏感场景考虑零运行时方案(Vanilla Extract)。
常见误区
- 在 styled 组件中写过多业务逻辑。
- 动态样式频繁变化导致性能问题(如每帧都创建新样式)。
- 忽略 SSR 场景的样式提取配置。
- 忘记使用 transient props($前缀)导致无关属性传递到 DOM。
进阶路线
- 学习 Vanilla Extract 零运行时 CSS in JS。
- 研究 Panda CSS 作为 Tailwind + CSS in JS 的结合方案。
- 了解 CSS Modules 作为 CSS in JS 的替代方案。
适用场景
- React 组件库需要样式隔离和可定制主题。
- 动态样式需求多(根据 props 改变外观)。
- 团队偏好组件化开发模式。
落地建议
- 统一使用一种方案,建立样式编写规范。
- 为共享样式创建 mixins/recipes 函数。
- 注意性能监控,避免运行时样式计算开销。
排错清单
- 检查 props 是否正确传递到样式组件。
- 用 React DevTools 检查生成的类名。
- 检查 SSR 样式是否正确提取。
- 确认 ThemeProvider 是否包裹了使用主题的组件。
复盘问题
- CSS in JS 与 CSS Modules 在你的项目中各有什么优劣?
- 运行时 CSS in JS 的性能影响是否可接受?
- 如何在组件库中支持用户自定义主题覆盖?
