翻译:styled-components 快乐之路

研发 @ 字节跳动

原作地址: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 将再次掌控自己的命运。我们的样式有单一的来源。

这样做真的好得多,下次你遇到这种情况,不妨试一试。

为场景选择合适的工具

当我们有像 AsideTextLink 这样的两个通用组件时,这种形式的“控制反转”使事情变得如此美好和可控,但这适合所有的场景吗?

假设我们有一个组件,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 包装器吗?当然可以,但我觉得不应该这么做:

  1. HalloweenTextLink 不同,我在各处使用 Aside 组件!当我使用 TextLink 时,了解这些样式是有益的,因为这是应用的核心部分。
  2. 我想要上下文样式被自动应用。我不希望开发人员在使用 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 组件没有设置样式,但它最终显示为红色的文本。这是因为颜色是一个继承的属性,所有后代元素将默认为红色文本。

技术上讲,这是一种泄漏,但它是一个相当无害的泄漏,原因如下:

  1. 我设置的任何样式都会推翻继承的样式,继承的样式永远不会赢得优先级之战。我确信显式设置的所有样式都会生效。组件样式还是组件自己说了算!
  2. 只有少数 CSS 属性是可继承的,而且它们基本上都是排版相关的。而布局属性如 paddingborder 不会被继承。一般来说,排版样式应该被继承;如果需要将当前的字体颜色重新应用到段落中的每个 <strong><em>, 将非常烦人。
  3. 在 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`
  /* 样式内容 */
`;
复制代码

它也可以方便地根据环境把组件渲染为 buttonlink

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]. 示例代码:该写法比较容易产生循环引用。在项目中运用时需要规范代码结构、引入循环依赖的检测机制。

文章分类
前端
文章标签