介绍风格化组件styled-components

300 阅读13分钟

这是一个奇妙的工具。在许多方面,它改变了我对CSS架构的看法,并帮助我保持代码库的整洁和模块化,就像React一样

它与React还有一个共同点:开发人员一开始往往不喜欢这个想法😅。"每个样式都是一个组件 "可能是一粒难以吞咽的药丸,就像 "你的视图现在是用XML/JS混合语言编写的"。

也许是这个原因,我发现很多开发者从来没有真正完全接受过风格化组件。他们在项目中加入了风格化组件,却没有更新他们关于风格化的心理模型。一脚进去,一脚出来。结果,他们错过了这个工具最好的一些部分。

如果你使用风格化组件,或者类似Emotion这样的工具,我希望这篇文章能帮助你从它身上获得最大利益。我把多年的实验和实践提炼成一些实用的技巧和方法。如果你应用这些想法,我真的相信你会成为一个更快乐的CSS开发者✨

预期的读者

这篇文章主要是为已经在使用styled-components或其他CSS-in-JS解决方案(如Emotion)的各种经验水平的React开发者而写的。

这篇文章不是为了介绍styled-components,也不是为了将其与其他工具进行比较或对比。

CSS变量

让我们从一个有趣的小提示开始。

假设我们有一个Backdrop 组件,它需要不透明度和颜色的道具。

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};
`;

这个方法很好用,但它的摩擦力很大。这也意味着每当这些值发生变化时,风格化组件将需要重新生成类,并将其重新注入到文档的<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变量来指定默认值。

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 ,"包含的链接 "这句话是用一个TextLink ,也就是同一个组件来呈现的。不过,我想应用一些不同的风格;我不喜欢在蓝色背景上有蓝色的文字。

这就是我所说的 "上下文风格"。同一个组件根据其上下文而改变外观。当你把一个TextLink 到一个Aside ,一些样式被添加/替换了。

你会如何解决这种情况呢?我经常看到这样的东西。

// Aside.js
const Aside = ({ children }) => {
  return (
    <Wrapper>
      {children}
    </Wrapper>
  );
}
const Wrapper = styled.aside`
  /* Base styles */
  a {
    color: var(--color-text);
    font-weight: var(--font-weight-bold);
  }
`;
export default Aside;

在我看来,这是一个五雷轰顶的情况。我们让推理我们的应用程序中的样式变得如此困难!你怎么能发现?

你怎么会发现TextLink 可以被赋予这些样式?你不可能在整个项目中搜索TextLink 。你必须用grep搜索a ,祝你好运。如果我们不知道Aside 应用这些样式,我们就永远无法预测它。

那么好吧,什么才是正确的方法?也许你已经想过用TextLink 而不是a 来指定这些样式。

// Aside.js
import TextLink from '../TextLink'
const Aside = ({ children }) => { /* Omitted for brevity */ }
const Wrapper = styled.aside`
  /* Base styles */
  ${TextLink} {
    color: var(--color-text);
    font-weight: var(--font-weight-bold);
  }
`;
export default Aside;

styled-components允许我们像这样将一个组件 "嵌入 "另一个组件中。当组件被渲染时,它会弹出适当的选择器,一个与TextLink styled-component匹配的类。

这无疑是更好的,但我还不是一个快乐的营员。我们还没有解决最大的问题,我们只是让它稍微容易解决。

让我们退一步,谈谈封装问题。

让我爱上React的原因是,它给了你一种将逻辑(状态、效果)和UI(JSX)打包到一个可重用的盒子里的方法。很多人关注 "可重用 "的方面,但在我看来,更酷的事情是它是一个盒子

一个React组件沿其周边设置了一个严格的边界。当你在一个组件中编写JSX时,你可以相信HTML只会在该组件中被修改;你不必担心在应用程序的另一端有其他组件 "伸手 "来篡改HTML。

再看一下那个TextLink 的解决方案。 **Aside 正在伸手干预TextLink's styles!**如果任何组件都可以覆盖任何其他组件的样式,那么我们就没有真正的封装了。

想象一下,如果你完全有把握地知道,一个给定元素的所有样式都是在这里定义的,就在有样式的组件本身中,那该有多好啊?

嗯,事实证明,我们可以做到这一点。这就是方法。

// Aside.js
const Aside = ({ children }) => { /* Omitted for brevity */ }
// Export this wrapper
export const Wrapper = styled.aside`
  /* styles */
`;
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);
  }
`;

如果你不熟悉& 字符,它是一个生成类名的占位符。当styled-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 。这是一个营销页面,使用了我们的标准组件,但增加了一个诡异的字体和橙色的主题。

我们应该更新我们所有的可重复使用的组件,以包括这个变体吗?天哪,不。😅

显示更多

继承的属性

从某种程度上说,这有点像圆孔方钉的情况:组件的边界永远不会是完全密闭的,因为某些CSS属性是可以继承的。

不过,在我看来,这并不是什么大问题。

显示更多

孤立的CSS

好了,我还有一个大想法要分享。

比方说,我们希望Aside ,让它周围有一些空间,这样它就不会紧贴着它的同级段落和标题。

这里有一个方法可以做到。

// Aside.js
const Aside = ({ children }) => {
  return (
    <Wrapper>
      {children}
    </Wrapper>
  );
}
const Wrapper = styled.aside`
  margin-top: 32px;
  margin-bottom: 48px;
`;
export default Aside;

这将解决我们的问题,但我觉得这也有点先发制人。我们已经锁定了自己;如果我们决定在另一种情况下使用这个组件,一个有不同间距要求的情况,会发生什么?

Cute illustration of a personified gluestick, tipping its hat

以这种方式使用页边距,与可重用的通用组件的理念背道而驰。

Heydon Pickering对此有一段很好的论述。

"页边距就像在你还没有决定要把它粘在什么地方,或者它是否应该被粘在什么地方之前,就把胶水粘在什么地方。"

还有一个事实是,保证金很奇怪。它以令人惊讶和反直觉的方式折叠,这种方式会破坏封装;例如,如果我们把我们的<Aside> 放在一个<MainContent> 里面,那顶部的边距会把整个组推下去,就像MainContent有边距一样

越来越多的开发者选择完全不使用保证金。我还没有完全放弃这个习惯,但我认为避免像这样的 "漏网之鱼 "是一个很好的妥协,一个很好的开始。

我们如何在没有边距的情况下做间隔?有几个选择!

  • 如果是在CSS网格内,你可以用grid-gap ,让每个元素都有空间。

  • 如果它在一个Flex容器内,全新的gap 属性会有奇效(尽管你可能希望在[Safari增加支持]之前暂缓)。

  • 你可以[使用Spacer 组件],这是一个有争议但令人惊喜的选择。

  • 你可以使用一个[专门的布局组件,比如Stack],来自Braid设计系统。

归根结底,我们的目标是避免把我们自己画进一个角落。我相信,只要我们有意为之,并理解其中的利弊,偶尔使用保证金是没有问题的。

最后,我们需要谈谈堆叠上下文的问题。

仔细看一下这段代码。

// Flourish.js
const Flourish = styled.div`
  position: relative;
  z-index: 2;
  /* Omitted decorative properties */
`;
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的兄弟姐妹,它就会在位和其背景之间 "交错"。

<div>
  {/* Malcom will get stuck in the middle! */}
  <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;
`;
// The rest unchanged

这确保了任何同级元素都在这个元素的上方或下方。作为奖励,新的堆叠上下文没有z-index,所以我们可以纯粹依靠DOM顺序,或者在我们知道需要什么的时候传递一个特定的值。

杂项技巧和窍门

吁!我们已经涵盖了高层次的 "大 "字。我们已经涵盖了我想分享的高层次的 "大概念",但在我总结之前,我有一些小的花絮,我认为是值得的。让我们来看看这些。

作为 "道具

React开发者有一个名声,那就是对语义HTML一无所知,把<div> 作为一个万能的工具。

image.png

对styled-components的一个公平的批评是,它在JJSX和正在产生的HTML标签之间增加了一层间接性。我们需要意识到这一事实,以便我们能够对其进行解释

你创建的每一个样式化组件都接受一个as ,这个道具会改变使用的HTML元素。这对于标题来说非常方便,因为确切的标题级别将取决于具体情况。

// `level` is a number from 1 to 6, mapping to h1-h6
function Heading({ level, children }) {
  const tag = `h${level}`;
  return (
    <Wrapper as={tag}>
      {children}
    </Wrapper>
  );
}
// The `h2` down here doesn't really matter,
// since it'll always get overwritten!
const Wrapper = styled.h2`
  /* Stuff */
`;
It can also come in handy f

对于那些可以呈现为按钮或链接的组件来说,它也可以派上用场,这取决于具体情况。

function LinkButton({ href, children, ...delegated }) {
  const tag = typeof href === 'string'
    ? 'a'
    : 'button';
  return (
    <Wrapper as={tag} href={href} {...delegated}>
      {children}
    </Wrapper>
  );
}

语义HTML是非常重要的,而as ,对于所有使用风格化组件的开发人员来说,这是一个至关重要的知识点。

提高特异性

在大多数CSS方法学中,你偶尔会遇到这样的情况:你写的声明没有效果,因为另一个样式正在覆盖它。这被称为特异性问题,因为不受欢迎的样式 "更特异",因而获胜。

在大多数情况下,如果你遵循本文所阐述的技术,我保证你不会有特异性问题,除了在处理第三方CSS时可能出现。这个博客有大约1000个风格化的组件,我从来没有遇到过特定性问题。

我很犹豫要不要分享这个技巧,因为这是对一个真正应该避免的情况的逃避......但我也想现实一点。我们都在并不总是理想的代码库中工作,在你的工具箱中拥有一个额外的工具总是无害的。

这就是了。

const Wrapper = styled.div`
  p {
    color: blue;
  }
`
const Paragraph = styled.p`
  color: red;
  && {
    color: green;
  }
`;
// Somewhere:
<Wrapper>
  <Paragraph>I'm green!</Paragraph>
</Wrapper>

在这种情况下,我们有三个独立的color 声明,目标是同一个段落。

在底层,我们的段落使用标准的风格化组件语法被赋予红色文本。不幸的是,Wrapper 使用了一个后裔选择器,用蓝色文本覆盖了红色文本。

为了解决这个问题,我们可以使用一个双击器将其翻转为绿色文本。

正如我们前面所看到的,& 字符是生成类名的占位符。把它放两次会重复该类。而不是.paragraph ,它将是.paragraph.paragraph

通过对类的 "加倍",它的特异性增加了。.paragraph.paragraph.wrapper p 更加特异。

这个技巧对于提高特异性很有用,而不需要使用核选择,即!important 。但是这里有一个潘多拉的盒子:一旦你开始沿着特异性技巧的道路前进,你就走上了相互保证的毁灭之路。

babel插件

在生产中,样式化组件将为你创建的每个样式化组件生成唯一的哈希值,如.hNN0ug.gAJJhs 。这些简洁的名字是有益的,因为它们不会在我们的服务器渲染的HTML中占用太多空间,但它们对我们开发人员来说是完全不透明的。

值得庆幸的是,有一个[babel插件]存在!在开发过程中,它使用了语义学的类。在开发中,它使用语义类名称,以帮助我们追踪一个元素/样式的来源。

image.png

如果你使用[create-react-app],你可以从这个插件中获益,而不需要通过改变你所有的导入来弹出。

import styled from 'styled-components/macro';

在你的项目中快速查找和替换,将极大地改善你的开发经验!

早些时候,我们谈到了使用下级选择器会使你的应用程序中的样式更难追踪。但是,如果我们可以使用babel插件来追踪样式,这个问题不就消失了吗?

遗憾的是,不是。完全不是。

心智模式

在这篇文章中,我们看了一些针对风格化组件的API,但实际上我希望传达的思想比任何特定的工具或库都要大。

当我们把组件的思维方式扩展到我们的CSS时,我们就会获得各种新的超级力量。

  • 我们有能力自信地知道删除一个CSS声明是否安全(不可能影响到应用程序的某个完全不同的部分!)。

  • 完全不存在特异性问题,不再试图寻找技巧来提高特异性。

  • 一个整齐划一的心理模型,适合你的头脑,帮助你准确理解你的页面会是什么样子,而不需要做大量的手工测试。

styled-components相对来说没有什么主见,所以有很多不同的使用方法......但我必须承认,当我看到开发者把它当作一个花哨的类名生成器,或者 "Sass 2.0 "时,我有点难过。如果你认为风格化组件首先是组件,你就会从这个工具中得到更多。

当然,这些只是我的观点,但我很高兴了解到它们与推荐的做法是一致的。我把这篇文章的早期草稿发给了Max Stoiber,他是styled-components的创建者,下面是他的回复。

image.png

对我来说,很多东西已经淡出了我的视线,只有在经过几年的实验后才变得清晰起来。我希望这个帖子能为你节省一些时间和精力。