Skip to content
This page has been auto-translated and may contain errors.View in English

组件样式

React 本身没有样式系统。你写普通的 CSS,JSX 提供两种方式把它应用到元素上:类名(适用于几乎所有场景)和 style 属性(用于样式表无法预知的那些值)。本章会介绍这两种方式,还有介于两者之间的模式:根据属性或状态构建类名,让元素随着数据变化而自动重新设置样式。

JSX 中的类名

class 在 JavaScript 中是保留字,所以 JSX 把这个属性叫做 className。属性值是一个普通字符串,React 会把它设置为 DOM 中元素的 class 属性,这意味着另一边的 CSS 写法完全没有变化。

jsx
function Avatar() {
  return <img className="avatar avatar--large" src={photo} alt="北京" />
}

一个字符串里写两个类名,用空格分开,就像 HTML 一样。JSX 中的驼峰命名规则只应用于标签的属性名;类名本身是你的自由,所以串串命名、BEM 或其他你样式表已经在用的约定都能直接迁移过来。

导入样式表

组件文件在顶部用一条裸导入语句来加载它需要的 CSS:

jsx
import './App.css'

export default function App() {
  return <h1 className="title">React 事实</h1>
}

这个导入语句左边没有变量,组件也不会去读这个文件。它是给构建工具的指令:这个模块依赖那个 CSS,所以要把它包括进来。Vite 会在开发时把规则注入页面,在生产构建中生成一个真正的 .css 文件。配置 React 项目 里介绍了处理图片的同样机制。

导入语句放在哪里是一个组织问题。在 Button.jsx 顶部导入 Button.css 可以把样式放在使用它的组件旁边。规则本身仍然是全局的:.title 选择器会匹配页面任何地方带有那个类的元素,不管是哪个文件导入的。BEM 这样的命名约定存在的目的就是让这个共享命名空间可控,CSS Modules 和 CSS-in-JS 库一旦应用超过了单靠命名规范能管理的程度,就会为你限定规则的作用域。

版本说明

React 19 增加了从组件内部渲染 <link rel="stylesheet"><style> 的支持,还有一个 precedence 属性让 React 把它们提升到文档 head 中并按可预测的顺序排列。早期版本需要用 react-helmet 这样的库。上面的构建工具导入方式在所有版本中的行为都一样。

基于数据的类名

className 在花括号里面,就像任何其他 JSX 表达式一样,所以字符串可以被计算出来。以一个掷骰子游戏为例,每个骰子可以在掷骰之间被"冻结"。骰子是否被冻结的状态存在状态里面,通过属性传到这里来:

jsx
function Die({ value, isHeld }) {
  return (
    <button className={`die ${isHeld ? 'die--held' : ''}`}>
      {value}
    </button>
  )
}

每个骰子都有 die 类。被冻结的骰子还会额外获得 die--held。样式表中存放真实的颜色和边框,组件的唯一职责就是决定哪个类应该被应用。当 isHeld 改变时,React 更新 class 属性,浏览器重新绘制。组件内部没有任何代码知道"冻结"长什么样,所以这个外观可以改变而这个文件完全不需要动。

有一个小问题:当 isHeld 是 false 时,模板字符串会生成 "die " 带有尾部空格。浏览器会忽略它,不过在开发者工具里看起来不太整洁。数组的方式可以更干净地处理这种情况,而且可以扩展到多个条件:

jsx
const classes = ['die', isHeld && 'die--held'].filter(Boolean).join(' ')

return <button className={classes}>{value}</button>

当条件失败时 isHeld && 'die--held' 计算为 falsefilter(Boolean) 会把它删除掉,join(' ') 在剩下的任何东西之间放一个空格。

style 属性

style 属性接收一个 JavaScript 对象。属性名是 CSS 属性名的驼峰版本,两个花括号是常规的表达式槽配合对象字面量:

jsx
<div style={{ backgroundColor: 'darkslateblue', paddingTop: 12 }}>
  内联样式
</div>

background-color 变成 backgroundColorpadding-top 变成 paddingTop。值通常是字符串。传一个裸数字,React 会为接受长度值的属性自动追加 px,所以 paddingTop: 12 会渲染成 padding-top: 12px。CSS 把某些属性当作无单位的,那些属性会保持原始数字:lineHeightopacityflexGrowzIndexfontWeight 都会直接传过去。任何其他单位都要写成字符串,比如 width: '60%'margin: '2rem'

大多数样式应该放在样式表中,一个类可以包含十几条声明,被十几个元素共享。style 属性在值只在运行时才知道、而 CSS 无法预知的情况下才值得一用:

jsx
function ProgressBar({ percent }) {
  return (
    <div className="progress">
      <div className="progress__fill" style={{ width: `${percent}%` }} />
    </div>
  )
}

填充条的颜色、高度、过渡效果和圆角都放在 .progress__fill 中。样式表无法知道的唯一值是宽度,因为它来自数据,所以这一个值走内联。背景图片的 URL 来自 API 也是同样的模式:

jsx
<div className="hero" style={{ backgroundImage: `url(${photo.url})` }} />

大小、定位和遮罩都在 .hero 中,只有 URL 通过属性传进来。

内联样式也有硬性的限制。一个 style 对象描述一个元素的声明,所以没有办法在其中表达悬停状态、媒体查询或关键帧动画。这些不管其他样式有多动态,都必须放在 CSS 中。

一个三元运算符嵌在模板字符串里读起来没问题。两三个堆在同一个字符串里就变成了反引号和问号的一堵墙,没人想去编辑。组件一旦有超过一个条件,把计算移到 return 上面,让数组形式来处理:

jsx
function Button({ variant, size, isDisabled, isLoading, children }) {
  const classes = [
    'btn',
    `btn--${variant}`,
    `btn--${size}`,
    isDisabled && 'btn--disabled',
    isLoading && 'btn--loading',
  ]
    .filter(Boolean)
    .join(' ')

  return (
    <button className={classes} disabled={isDisabled}>
      {children}
    </button>
  )
}

每一行是一个决策,所以加第四个条件就是加一行而不是重构表达式,JSX 保持可读因为属性是一个单独的标识符。

当多个类互相排斥时,查找对象比一串三元运算符更好:

jsx
const statusClasses = {
  idle: 'card--idle',
  loading: 'card--loading',
  error: 'card--error',
}

const classes = ['card', statusClasses[status]].filter(Boolean).join(' ')

无法识别的 status 会产生 undefinedfilter(Boolean) 会删除它,所以元素回退到基础 card 类,而不是把字面文本"undefined"渲染进属性。

一旦你在每个组件中都写这种代码,clsxclassnames 包在几百字节内做同样的事,还有条件部分的对象形式:clsx('btn', { 'btn--disabled': isDisabled })。它们是对上面数组版本的便利封装,先理解数组版本是值得的。

style 属性接收对象因为它直接映射到 DOM 暴露样式的方式。每个元素都有一个 style 属性,一个 CSSStyleDeclaration,它的成员就是驼峰版的 CSS 属性名:node.style.backgroundColor。React 逐个分配那些成员。字符串意味着要写 cssText,这会在每次任何一个声明改变时重新解析并替换元素上的每个声明。

逐属性分配是让 style 更新可以 diff 的原因。React 逐键比较前一个 style 对象和下一个:值匹配的键保持不变,改变的键被写入,消失的键被重置为空字符串。只有真正改变的属性会接触 DOM。CSS 字符串无法表达这种部分更新。

带供应商前缀的属性遵循同样的驼峰命名规则,第一个字母大写,比如 WebkitLineClampms 是例外会保持小写(msOverflowStyle)。React 不会代你添加前缀。

自定义属性是唯一一个键保持完全按 CSS 拼写的情况:

jsx
<div className="card" style={{ '--accent': team.color }}>

React 看到开头的 -- 并通过 setProperty 路由它,逐字传递值。不会追加 px,所以单位由你提供。这是两种方法之间有用的桥梁:从数据内联设置一个自定义属性,让样式表在尽可能多的规则、伪类和媒体查询中消费它。

身份成本值得了解。直接写在 JSX 中的内联 style 对象,就像上面的例子,每次渲染都会分配一个新对象。在宿主元素上这花不了多少成本,因为 React 比较值而不是引用,值匹配时不写任何东西。当对象跨越组件边界时开始重要了:把一个新构建的 style 对象传给用 memo 包装的子组件,浅属性比较看到每次渲染都有个新引用而无论如何都重新渲染子组件,打败了记忆化。className 字符串没有这样的问题,因为字符串按值比较。

修复是普通的:把静态 style 对象提升到模块作用域这样引用只创建一次,把动态的包在 useMemo 中,用它们导出的值作为键(见Hooks)。两种都只在性能分析显示重新渲染花费了什么时值得做,而选择类名完全避免了这个问题。类在浏览器中扩展性更好,因为一个匹配的规则集在所有带那个类的元素间共享,而内联声明在每个元素上重复,会相应地增大服务端渲染的 HTML。

Juno优先选择类名,其次才是内联样式 React 中的样式就是你已经在写的 CSS,通过 className 附加,因为 class 在 JavaScript 中是保留字。在文件顶部导入样式表,把规则放在里面,让组件通过从属性或状态构建字符串来决定哪个类应用。把 style 属性留给你的 CSS 文件无法预知的值,比如来自数据的宽度。
Juno优先选择类名,其次才是内联样式className 是一个表达式槽,所以一个模板字符串覆盖一个条件,一个带 filter(Boolean).join(' ') 的数组覆盖其他的。在 return 上面计算它这样 JSX 保持可扫描,当类互相排斥时用查找对象。style 属性接收驼峰键,为长度属性的裸数字追加 px,对 opacity 这样的无单位属性保持原样。它在运行时值的地方获得一席之地,而且完全无法表达悬停状态或媒体查询。
Juno优先选择类名,其次才是内联样式style 对象存在因为它映射到 CSSStyleDeclaration,让 React 能 diff 并写入单个属性而不是重新解析 cssText 字符串。以 -- 开头的键直接通过 setProperty,这让自定义属性成为从运行时数据连入样式表的最干净桥梁。陷阱是身份:每次渲染的新对象字面量在宿主元素上无形,对 memo 边界致命,所以把静态对象提升到模块作用域,用 useMemo 包装动态对象,有类名能用的地方就选择类名。

接下来:属性,组件开始从父组件获取数据的地方。