原作地址:www.joshwcomeau.com/css/styled-…
原文作者:Josh Comeau
译:大力智能技术团队-前端 粽粽
几年来,在 React 应用里管理 CSS 的工具中, 💅 styled-components 一直是我最喜欢的。
styled-components 非常优秀,它在许多方面改变了我对 CSS 架构的看法,并帮助我保持代码仓库的干净整洁、模块清晰——这点像极了 React。
styled-components 与 React 还有一个共同点:一开始,很多人难以接受它们的理念 😅 。“每种样式都是一个组件” 的思路可能让人难以接受,就像“你的视图现在是用 XML/JS 混合编写的”。
也许正因如此,我发现许多开发人员从未认真接受 styled-components。开发者们虽然把在项目中引入它,但没有更新自己关于样式的思维模型[1]。浅入浅出的心态使他们与工具的最佳实践失之交臂。
如果你使用 styled-components,或者类似的工具,比如 Emotion,我希望这篇文章能帮助你充分利用它们。我已经将多年的实验和实践提炼成一些实用的技巧和技术。如果你将这些经验应用得当,相信我,你一定能更快乐地编写 css✨。
目标读者
本文主要面向已经在使用 styled-components 或其他 CSS-in-JS 解决方案(如 Emotion)的、不论是刚入门还是经验丰富的 React 开发人员。
本文不打算作为 styled-components 的介绍,也不打算将其与其他工具进行比较或对比。
CSS 变量
让我们从一个有趣的例子开始。
假设我们有一个背景组件,它具有透明度和颜色属性:
function Backdrop({ opacity, color, children }) {
return (
<Wrapper>
{children}
</Wrapper>
)
}
const Wrapper = styled.div`
/* 省略 */
`;
如何将这些属性应用于 Wrapper?
一种方法是使用插值函数:
function Backdrop({ opacity, color, children }) {
return (
<Wrapper opacity={opacity} color={color}>
{children}
</Wrapper>
)
}
const Wrapper = styled.div`
opacity: ${p => p.opacity};
background-color: ${p => p.color};
`;
这样的代码可以起作用,但有一些小问题。该段代码运行过程中,每当属性的值改变时,styled-components 会生成新的类名,并将其重新注入文档的 <head> 。这样做,有时候存在性能问题(例如:执行 JS 动画)。
要解决这个问题,还有另一种方法 —— 使用 CSS 变量:
function Backdrop({ opacity, color, children }) {
return (
<Wrapper
style={{
'--color': color,
'--opacity': opacity,
}}
>
{children}
</Wrapper>
)
}
const Wrapper = styled.div`
opacity: var(--opacity);
background-color: var(--color);
`;
CSS 变量能不断挖掘出新玩法。如果你不确定这里发生了什么,我的 React 开发中的 CSS 变量 会帮助你理解它(同时你还会学到一些其他的技巧!)。
我们还可以使用 CSS 变量来指定默认值:
function Backdrop({ opacity, color, children }) {
return (
<Wrapper
style={{
'--color': color,
'--opacity': opacity,
}}
>
{children}
</Wrapper>
)
}
const Wrapper = styled.div`
opacity: var(--opacity, 0.75);
background-color: var(--color, var(--color-gray-900));
`;
如果我们使用 <Backdrop> 时没有指定透明度或颜色,将使用 75% 透明度,以及主题颜色中的深灰色作为默认值。
这种指定属性值的方式感觉很好。它并没有改变游戏规则,却给我带来了一些乐趣。
而这仅仅是个开始,让我们看一些更有意义的东西。
样式的单一来源
如果通过本文只能让你学会一个技巧,那一定是这条。这才是一个真正的宝藏。
在这篇文章中,我有一个 TextLink 组件。它看起来是这样的:
const TextLink = styled.a`
color: var(--color-primary);
font-weight: var(--font-weight-medium);
`;
这是用于文章正文内链接的组件。下面是原博客文章中的一个例子:
在我的博客上,我有一个用于提供额外信息的 Aside 组件:
在这个 Aside 中,“an included link”是用 TextLink 呈现的,它与正文中的 TextLink 是非常相似的组件。不过,我想应用一些不同的样式——我不喜欢蓝色背景上的蓝色文字。
这就是所谓的“上下文样式”的概念:相同的组件根据其上下文更改外观。当你在 Aside 中使用 TextLink 时,会添加/替换一些样式。
针对这种场景,你会怎么解决这个问题呢?我经常看到这样的代码:
// Aside.js
const Aside = ({ children }) => {
return (
<Wrapper>
{children}
</Wrapper>
);
}
const Wrapper = styled.aside`
/* 基础样式 */
a {
color: var(--color-text);
font-weight: var(--font-weight-bold);
}
`;
export default Aside;
在我看来,这是非常不可取的写法。这使得样式回溯在我们的应用中变得非常困难 —— 如何才能知道 TextLink 被设置了哪些样式呢?对 TextLink 进行项目范围内的搜索并不可行,你得用 grep 搜索 a 才行,祝你好运。如果我们不知道 Aside 应用了这些样式,我们将永远无法预测 TextLink 的表现。
那么,正确的方法是什么?也许你已经考虑过使用 TextLink 代替 a 来指定这些样式:
// Aside.js
import TextLink from '../TextLink'
const Aside = ({ children }) => { /* 省略 */ }
const Wrapper = styled.aside`
/* 基础样式 */
${TextLink} {
color: var(--color-text);
font-weight: var(--font-weight-bold);
}
`;
export default Aside;
styled-components 允许我们像这样将一个组件“嵌入”到另一个组件中。当组件被呈现时,它会产生相应的选择器,一个匹配 TextLink 组件的类名。
这个肯定更好,但我还不够满意。我们还没有解决最大的问题,我们只是让它更容易解决。
让我们回过头来谈谈封装。
我之所以喜欢 React,是因为它可以将逻辑(状态、副作用)和 UI (JSX) 封装到可重用盒子中。很多人关注的是“可重用性”,但在我看来,更酷的地方在于它是一个“盒子”。
React 组件在其周边设置了严格的边界。当你在一个组件中编写 JSX 时,你可以相信 HTML 只会在该组件中被修改;你不必担心应用其他地方的组件“侵入”并篡改 HTML。
再看一下 TextLink 解决方案。Aside 在侵入并干预 TextLink 的样式!如果任何组件可以覆盖任何其他组件的样式,那么我们实际上根本就没有封装。
想象一下,如果你完全自信地确认给定元素的所有样式都定义在 styled-component 本身中,那该有多好?
事实证明,我们可以这么做[2]。方法如下:
// Aside 组件使用
import Aside from '@components/Aside';
import TextLink from '@components/TextLink';
const Section = () => {
return (
<Aside>
这是一个 Aside 组件的例子,包含一个
<TextLink>an included link.</TextLink>
</Aside>
)
}
// Aside.js
const Aside = ({ children }) => {
return (
<Wrapper>
{children}
</Wrapper>
)
}
// 导出这个 Wrapper
export const Wrapper = styled.aside`
/* 样式 */
`;
export default Aside;
// TextLink.js
import { Wrapper as AsideWrapper } from '../Aside'
const TextLink = styled.a`
color: var(--color-primary);
font-weight: var(--font-weight-medium);
${AsideWrapper} & {
color: var(--color-text);
font-weight: var(--font-weight-bold);
}
`;
如果你不熟悉 & 字符,它是最终生成的类名的占位符。当 styles-components 为该组件创建一个.textlink-abc123 类时,它也会用该选择器替换任何 & 字符。这是最终生成的 CSS:
.TextLink-abc123 {
color: var(--color-primary);
font-weight: var(--font-weight-medium);
}
.Aside-Wrapper-def789 .TextLink-abc123 {
color: var(--color-text);
font-weight: var(--font-weight-bold);
}
通过这个小技巧,我们把控制反转了。我们说“这是我的基础 TextLink 样式,这是我在AsideWrapper 中包装的 TextLink 样式”,这两种样式的声明都在同一个地方。
强大的 TextLink 将再次掌控自己的命运。我们的样式有单一的来源。
这样做真的好得多,下次你遇到这种情况,不妨试一试。
为场景选择合适的工具
当我们有像 Aside 和 TextLink 这样的两个通用组件时,这种形式的“控制反转”使事情变得如此美好和可控,但这适合所有的场景吗?
假设我们有一个组件,HalloweenSale.js。这是一个使用我们标准组件的营销页面,但添加了一个可怕的字体和橙色主题。
我们是否应该更新所有标准组件以支持此变体?当然不是。😅
首先,将 HalloweenSale 导入到 TextLink 中会增加我们的 JavaScript 包的体积:无论用户何时访问我们网站上的任何页面,他们都必须下载万圣节销售页面的所有标记和代码,即使是在 3 月中旬!
这么做还会使我们的 TextLink.js 文件变得杂乱,被一次性的变量塞满了,这些 99% 的时间都无关紧要的样式反而会挤掉了更相关的上下文样式。
还有一种替代方法 —— 组合 API:
// HalloweenPage.js
import TextLink from '../TextLink';
const HalloweenTextLink = styled(TextLink)`
font-family: 'Spooky Font', cursive;
`;
这两种情况之间的区别有点微妙,但却非常重要!
在万圣节的情景下,我会使用一个通用的标准组件,并将其组合成一个新组件——一个具有更专门用途的组件,以便以特定的方式使用。HalloweenTextLink 重用的方式与 TextLink 不一样。
我们能在 Aside/TextLink 的场景中做同样的事情,创建一个 AsideTextLink 包装器吗?当然可以,但我觉得不应该这么做:
- 与
HalloweenTextLink不同,我在各处使用Aside组件!当我使用TextLink时,了解这些样式是有益的,因为这是应用的核心部分。 - 我想要上下文样式被自动应用。我不希望开发人员在使用
Aside时,还要记得必须使用AsideTextLink:
<Aside>
你真觉得开发者会记得使用
<AsideTextLink href="">
这个组件变体吗
</AsideTextLink>?
</Aside>
我非常了解我自己,最多有 75% 的时间我会记得这么做。剩下的 25% 的时候,可能会导致一个相当不一致的 UI!
现在,我们要权衡一下了:对于 HalloweenTextLink 这个组件,其实我们失去了一些可见性。理论上,我可能会在更改 TextLink 的时候悄悄地破坏了 HalloweenTextLink,甚至不知道该组件的存在!
这很难办,没什么完美的解决方案,但是通过明确区分“核心变体”和“一次性变体”,我们可以确保优先处理最重要的内容,并把它们管理好(在 TextLink.js 中定义 30 个一次性变体真的有帮助吗?)。
继承的 CSS 属性
有时候还会有一些出乎意料的情况发生:组件边界永远不会是完全密封的,因为某些 CSS 属性是可继承的。
但在我看来,这不是什么大事。
首先,为了解释我到底在说什么,请考虑以下内容:
function App() {
return (
<div style={{ color: 'red' }}>
<Kid />
</div>
)
}
function Kid() {
return <p>Hello world</p>
}
Kid 组件没有设置样式,但它最终显示为红色的文本。这是因为颜色是一个继承的属性,所有后代元素将默认为红色文本。
技术上讲,这是一种泄漏,但它是一个相当无害的泄漏,原因如下:
- 我设置的任何样式都会推翻继承的样式,继承的样式永远不会赢得优先级之战。我确信显式设置的所有样式都会生效。组件样式还是组件自己说了算!
- 只有少数 CSS 属性是可继承的,而且它们基本上都是排版相关的。而布局属性如
padding或border不会被继承。一般来说,排版样式应该被继承;如果需要将当前的字体颜色重新应用到段落中的每个<strong>或<em>, 将非常烦人。 - 在 devtools 中,生效的组件样式来源非常清晰,我们很就能搞清楚样式是在哪里设置的。
全局样式也是如此,在我的大多数项目中,都引入了一个 CSS reset 和一些常规的标准样式(normalize)。它们都是作用于标签(例如 p,h1)上,以尽可能降低优先级。人生苦短,如果可以直接用 p 标签时,何苦要个入一个 Paragraph 组件呢?
单独谈谈 CSS
好了,我还有一个好东西要分享。
设想一下,如果我们想让 Aside 组件周围有一些空间,这样它就不会被卡在同级段落和标题之间。
这里有一种方法:
// Aside.js
const Aside = ({ children }) => {
return (
<Wrapper>
{children}
</Wrapper>
);
}
const Wrapper = styled.aside`
margin-top: 32px;
margin-bottom: 48px;
`;
export default Aside;
这可以解决我们的问题,但我觉得太重了。如果采用这样的思路,我们容易陷入困局 —— 比如当我们需要在另一种有不同间距要求的情况下使用这个组件时,我们要怎么办?
以这种方式使用 margin 与设计可重用的通用组件的思想是对立的。
Heydon Pickering 对此有句名言:
“margin 就像在你决定要把东西粘在什么上,或者是否应该粘在什么东西上之前,先给东西粘上胶水。”
还有一个问题是 margin 很奇怪,它可能一种出人意料的、违反直觉的方式坍塌,这时可能会打破封装。例如,我们把 <Aside> 放到 <MainContent> 中,顶部的边距就会把整个组往下推,就像 MainContent有边距一样。
我最近写了一篇关于 Margin 塌陷规则的文章。如果你对 margin 的这种表现感到惊讶,这篇文章对你非常有用!
越来越多的开发者选择不使用 margin,我还没有完全放弃这个习惯,但我认为避免像这样的“margin 泄漏”是一个很好的折中和起点。
我们怎么不使用 margin 进行布局?有几个选择!
- 如果它在一个 CSS 网格中,你可以使用
grid-gap来分隔每个元素 - 如果它在一个 Flex 容器中,全新的
gap属性会非常有效(尽管你可能希望等到 Safari 添加支持后再使用) - 你可以使用
Spacer组件,这是一个有争议但意外好用的选择 - 你可以使用专用的布局组件,比如 Braid 设计系统中的 Stack
最终的目标是避免把自己逼入绝境。我相信,只要我们是有意识地,并且理解这种权衡关系,偶尔用用 margin 解决实际问题也挺好的。
最后,我们再谈谈层叠上下文。
仔细看看下面的代码:
// Flourish.js
const Flourish = styled.div`
position: relative;
z-index: 2;
/* 忽略了一些样式 */
`;
export default Flourish;
发现问题了吗?和之前一样,我们预先给组件设置了一个 z-index 属性,我们希望将来所有情况下 2 都是正确的层级!
这个问题还可能更严重,看看下面的代码:
// Flourish.js
const Flourish = ({ children }) => {
return (
<Wrapper>
<DecorativeBit />
<DecorativeBackground />
</Wrapper>
);
}
const Wrapper = styled.div`
position: relative;
`;
const DecorativeBit = styled.div`
position: absolute;
z-index: 3;
`;
const DecorativeBackground = styled.div`
position: absolute;
z-index: 1;
`;
顶层样式组件 Wrapper 没有设置 z-index……当然,这肯定是没问题的吧?
但愿如此。实际上,这会导致一个非常混乱的问题。
如果我们的 Flourish 组件有一个 z-index 值在中间的兄弟组件,那么这个组件会和 Flourish 的背景产生“交错”:
<div>
<!-- Malcom 组件会被夹在中间! -->
<Malcolm
style={{ position: 'relative', zIndex: 2}}
/>
<Flourish />
</div>
我们可以通过使用 isolation 属性显式创建一个层叠上下文来解决这个问题:
const Flourish = ({ children }) => {
return (
<Wrapper>
<DecorativeBit />
<DecorativeBackground />
</Wrapper>
);
}
const Wrapper = styled.div`
position: relative;
isolation: isolate;
`;
// 剩余部分没有变化
这确保了任何兄弟元素将高于或低于这个元素。额外的好处是,新的层叠上下文没有 z-index,所以我们可以完全依赖于 DOM 顺序,也可以在必要时传递一个特定的值。
这个东西很复杂,超出了本文的范围。层叠上下文将在我即将到来的 CSS 课程中深入讨论,JavaScript 开发人员的 CSS 课。
提示和技巧
唷!我想要分享的高级“好东西”都讲完了,但是在结束之前,我还有几个自认为值得分享的小细节。我们来看看。
as 属性
React 开发人员以不懂 HTML 的语义而闻名,他们使用 <div> 处理所有情况。
对 styled-components 的一项公正批评是,它在 JSX 和生成的 HTML 标记之间添加了一层间接层。只有意识到这个事实,我才能讲明白下面的内容!
你创建的每个 styled-component 组件都接受一个属性,它会改变使用的是哪个 HTML 元素。这对于标题来说非常方便,因为具体的标题级别取决于场景:
// `level` 是一个取值为 1-6 的数字,可以映射为 h1-h6
function Heading({ level, children }) {
const tag = `h${level}`;
return (
<Wrapper as={tag}>
{children}
</Wrapper>
);
}
// 下面的 h2 并不重要,因为它总是被覆盖的
const Wrapper = styled.h2`
/* 样式内容 */
`;
它也可以方便地根据环境把组件渲染为 button 或 link:
function LinkButton({ href, children, ...delegated }) {
const tag = typeof href === 'string'
? 'a'
: 'button';
return (
<Wrapper as={tag} href={href} {...delegated}>
{children}
</Wrapper>
);
}
HTML 语义化非常重要,所有使用 styled-components 的开发人员都应该了解 as 属性,这个至关重要的知识。
增加优先级
在大多数 CSS 方法论中,你偶尔都会遇到这样的情况:由于另一种样式覆盖,你编写的声明没有效果。这被称为优先级问题,因为不想要的样式优先级更高。
在大多数情况下,如果你遵循本文列出的方法,我保证你不会遇到优先级问题,除了在处理第三方 CSS 时。这个博客有大约 1000 个样式组件,我从来没有遇到过优先级的问题。
我很犹豫要不要分享这个技巧,因为这是一个应该避免的情况的逃生口……但我也想现实一点。我们总是在并不理想的代码仓库中工作,并且在你的工具箱中额外增加一个工具永远不会有坏处。
像这样:
const Wrapper = styled.div`
p {
color: blue;
}
`
const Paragraph = styled.p`
color: red;
&& {
color: green;
}
`;
// 使用:
<Wrapper>
<Paragraph>I'm green!</Paragraph>
</Wrapper>
在这种情况下,针对同一个段落,我们有三个独立的颜色声明。
在基本级别,我们的段落是使用标准样式组件语法给出的红色文本。不幸的是,Wrapper 使用了后代选择器,并使用蓝色文本覆盖了红色文本。
为了解决这个问题,我们可以使用双 & 符将其转换为绿色文本。正如我们前面看到的,& 字符是生成的类名的占位符。把它放两遍就会重复这个类:不是 .paragraph,而是 .paragraph.paragraph。
通过双倍强调这个类名,它的优先级增加了。 .paragraph.paragraph 比 .wrapper p 优先级更高。
这个技巧可以在不动用核武器 !important 的情况下提高优先级,这很重要。但这也是一个潘多拉魔盒:一旦你开始沿着这个技巧的道路走下去,你就走上了一条注定毁灭的道路。
babel 插件
在生产环境中,styled-components 将为你创建的每个样式组件生成唯一的散列值,比如 .hnn0ug 或 .gajjhs。这些简洁的名称是有益的,因为它们不会在服务端渲染的 HTML 中占用太多空间,但对于开发人员来说,它们是完全不透明的。
幸运的是,存在一个 babel 插件!在开发过程中,它使用语义类名,帮助我们追踪元素/样式的来源:
如果你使用 create-react-app,你可以从这个插件中受益,而不需要通过改变所有的导入来使用:import styled from 'styled-components/macro';
在你的项目中快速查找和替换将极大地改善你的开发体验!
对于其他类型的项目,你可以遵循官方文档。
后代选择器
前文我们讨论了为何使用后代选择器使你的应用难以跟踪样式。但如果我们可以使用 babel 插件跟踪样式,可以消除这个顾虑吗?
很遗憾,并不能。完全不能。
再重复一次,以下是我希望你避免的一种模式:
// Aside.js
const Aside = ({ children }) => {
return (
<Wrapper>
{children}
</Wrapper>
);
}
const Wrapper = styled.aside`
/* 基础样式 */
a {
color: #F00;
}
`;
export default Aside;
如果我们在 devtools 中查看它,我们会发现 babel 插件并不能真正处理这种愚蠢的事情:
即使可以,我还是建议不要使用这种模式。我们不应该只有在代码实时渲染后才能够理解哪些样式生效了;而是最好只通过阅读源文件时就能做出这个判断,理想情况下只需查看一段代码。
这是否意味着每当我需要应用一个样式的时候,都要创建一个新的 styled.whatever?好吧,我可以降低一点要求,只要确保我使用后代选择器时,总是针对的是组件特有的元素:
// Whatever.js
const Whatever = () => {
return (
<Wrapper>
这是一个 <em>小例子</em>.
</Wrapper>
);
}
const Wrapper = styled.div`
& > em {
color: #F00;
}
`;
只要你不“侵入”修改另一个组件下的元素样式,这种模式还不算太糟。老实说,这仍然不理想,但我知道一直创建新的样式组件是很乏味的,这是一个合理的、务实的妥协。
思维模型
在本文中,我们讨论了一些 styled-component 的特定 API,但是我希望传达的思想比任何特定的工具或库都要重要。
当我们将组件思维扩展到 CSS 时,我们获得了各种新的超能力:
- 能够明确地知道是否可以安全地删除 CSS 声明(不会影响应用的其他模块!)
- 脱离优先级问题,不再试图寻找提高优先级的技巧
- 一套整洁明了的思维模型,它适合你的大脑,并帮助你准确地理解你的页面是什么样子,而不需要做一堆手动测试
styled-components 本身没什么倾向性,所以有很多不同的使用方式……但是我必须承认,当我看到开发人员把它当作一个花哨的类名生成器或“Sass 2.0”时,我有点难过。如果你认同样式化组件就是组件的理念,那么你一定能从该工具中收获更多。
当然,这些只是我的观点,但我很高兴知道它们符合推荐的实践。我将这篇文章的初稿发给了 styled-components 的创造者 Max Stoiber,以下是他的回应:
在我看来,许多类似的工具已经淡出视野。只有经过几年的实验,“如何在 React 中管理 CSS”的思路才变得清晰。我希望这篇文章能为你节省一些时间和精力。
译者注
[1]. 思维模型:原文是“Mental models”。大概可以理解为:解決问题时,人内在的思考和运作方式。
[2]. 示例代码:该写法比较容易产生循环引用。在项目中运用时需要规范代码结构、引入循环依赖的检测机制。