使用 Styled Components 迭代 React 设计

Avatar of Cliff Hall
Cliff Hall

DigitalOcean 为您旅程的每个阶段提供云产品。立即开始使用 200 美元的免费额度!

在一个理想的世界里,我们的项目将拥有无限的资源和时间。我们的团队将使用经过精心思考和高度优化的 UX 设计开始编码。开发人员之间将就最佳的样式方法达成共识。团队中将有一位或多位 CSS 专家,他们可以确保功能和样式可以同时推出,而不会变成一团糟。

我确实在大型企业环境中见过这种情况。这是一件美好的事情。本文不适用于这些人。

另一方面,小型创业公司资金为零,只有一到两位前端开发人员,并且需要在很短的时间内展示一些功能。它不必看起来完美,但至少应该在台式机、平板电脑和手机上合理地呈现。这将使他们能够将其展示给顾问和早期用户;甚至可能向对该概念表示兴趣的潜在投资者展示。一旦他们从销售和/或投资中获得一些现金流,他们就可以获得专门的 UX 设计师并完善界面。

以下内容适用于后一组人员。

项目启动会议

让我们发明一家公司来推动项目发展。

Solar Excursions 是一家小型旅行社,旨在服务于近未来蓬勃发展的太空旅游行业。

我们的小型开发团队已同意使用 React 作为 UI。我们的一位前端开发人员非常喜欢 Sass,而另一位则迷恋于 JavaScript 中的 CSS。但他们很难完成其最初的冲刺目标;当然没有时间争论最佳的样式方法。两位编码人员都同意,从长远来看,选择并不重要,只要执行一致即可。他们确信,现在在压力下从头开始实现样式将产生技术债务,以后必须清理。

经过一番讨论,团队选择计划进行一个或多个“样式重构”冲刺。目前,我们将专注于使用 React-Bootstrap 在屏幕上显示一些内容。这样,我们就可以快速构建工作台式机和移动版布局,而无需太多麻烦。

在前端样式上花费的时间越少越好,因为我们还需要 UI 连接到我们的后端开发人员将要推出的服务。并且,随着我们的应用程序架构开始成形,两位前端开发人员都同意对其进行单元测试非常重要。他们的工作量很大。

根据我与上级领导的讨论,作为一名专门的项目经理,我至少在 Balsamiq 上辛苦工作了十分钟,为团队提供了台式机和移动设备上预订页面的线框图。我假设他们会使平板电脑介于两者之间并看起来合理。

A desktop and mobile mockup of the proposed layout for the page, side by side with the desktop mockup on the left. Both mockups use rough black and white layouts of the various components for the screen.
Solar Excursions 旅行预订页面在台式机(左)和移动设备(右)上的初始线框图。

冲刺零:审查会议

人人都有披萨!团队非常努力地实现了其目标,我们现在有一个布局接近线框图的预订页面。服务的架构正在逐步完善,但在我们将 UI 连接到它之前还有很长的路要走。在此期间,前端开发人员正在使用硬编码的模拟数据结构。

Screenshots of the page after the first round of development on desktop (left) and mobile (right). Both are approximately the same as the earlier mockups with most components looking exactly like the default user interface provided by the Bootstrap framework.
使用 react-bootstrap 在代码中创建的页面的第一个迭代。

以下是我们迄今为止的 UI 代码

这都是简单的 React。我们正在使用一些 Hooks 热点,但对于你们中的大多数人来说,这可能已经过时了。

这里需要注意的关键要点是,我们的五个应用程序组件中有四个导入并使用了来自 react-bootstrap 的组件。只有主 App 组件不受影响。这是因为它只是使用我们的自定义组件组合顶级视图。

// App.js imports
import React, { useState } from "react";
import Navigation from "./Navigation";
import Page from "./Page";

// Navigation.js imports
import React from "react";
import { Navbar, Dropdown, Nav } from "react-bootstrap";

// Page.js imports
import React from "react";
import PosterCarousel from "./PosterCarousel";
import DestinationLayout from "./DestinationLayout";
import { Container, Row, Col } from "react-bootstrap";

// PosterCarousel.js imports
import React from "react";
import { Alert, Carousel, Image } from "react-bootstrap";

// DestinationLayout.js imports
import React, { useState, useEffect } from "react";
import {
  Button,
  Card,
  Col,
  Container,
  Dropdown,
  Jumbotron,
  ListGroup,
  Row,
  ToggleButtonGroup,
  ToggleButton
} from "react-bootstrap";

快速使用 Bootstrap 的决定使我们能够实现冲刺目标,但我们已经开始积累 技术债务。这仅仅是四个受影响的组件,但随着应用程序的增长,我们计划进行的“样式重构”冲刺显然会变得越来越难。而且我们甚至没有太多自定义这些组件。一旦我们拥有数十个组件,所有组件都使用 Bootstrap 并使用大量内联样式来美化它们,重构它们以删除 react-bootstrap 依赖项将是一个非常可怕的提议。

团队决定,与其构建更多预订流程页面,不如在下一个冲刺中努力将 react-bootstrap 的使用隔离到自定义组件套件中,因为我们的服务仍在构建中。应用程序组件将仅使用此套件中的组件。这样,当我们需要摆脱 react-bootstrap 时,该过程就会容易得多。我们不必在整个应用程序中重构 30 个 react-bootstrap Button 的用法,而只需重写 KitButton 组件的内部。

冲刺一:审查会议

好吧,这很容易。击掌。 UI 的视觉外观没有变化,但我们现在在 React 源代码中的“组件”旁边有一个“套件”文件夹。它包含许多文件,例如 KitButton.js,这些文件基本上导出重命名的 react-bootstrap 组件。

我们套件中的一个示例组件如下所示

// KitButton.js
import { Button, ToggleButton, ToggleButtonGroup } from "react-bootstrap";
export const KitButton = Button;
export const KitToggleButton = ToggleButton;
export const KitToggleButtonGroup = ToggleButtonGroup;

我们将所有套件组件包装到类似这样的模块中

// kit/index.js
import { KitCard } from "./KitCard";
import { KitHero } from "./KitHero";
import { KitList } from "./KitList";
import { KitImage } from "./KitImage";
import { KitCarousel } from "./KitCarousel";
import { KitDropdown } from "./KitDropdown";
import { KitAttribution } from "./KitAttribution";
import { KitNavbar, KitNav } from "./KitNavbar";
import { KitContainer, KitRow, KitCol } from "./KitContainer";
import { KitButton, KitToggleButton, KitToggleButtonGroup } from "./KitButton";
export {
  KitCard,
  KitHero,
  KitList,
  KitImage,
  KitCarousel,
  KitDropdown,
  KitAttribution,
  KitButton,
  KitToggleButton,
  KitToggleButtonGroup,
  KitContainer,
  KitRow,
  KitCol,
  KitNavbar,
  KitNav
};

现在,我们的应用程序组件完全独立于 react-bootstrap。以下是受影响组件的导入

// Navigation.js imports
import React from "react";
import { KitNavbar, KitNav, KitDropdown } from "../kit";


// Page.js imports 
import React from "react";
import PosterCarousel from "./PosterCarousel";
import DestinationLayout from "./DestinationLayout";
import { KitContainer, KitRow, KitCol } from "../kit";


// PosterCarousel.js imports
import React from "react";
import { KitAttribution, KitImage, KitCarousel } from "../kit";


// DestinationLayout.js imports
import React, { useState, useEffect } from "react";
import {
  KitCard,
  KitHero,
  KitList,
  KitButton,
  KitToggleButton,
  KitToggleButtonGroup,
  KitDropdown,
  KitContainer,
  KitRow,
  KitCol
} from "../kit";

现在是前端代码库

尽管我们已将所有 react 导入都集中到套件组件中,但我们的应用程序组件仍然依赖于 react-bootstrap 实现,因为我们放置在套件组件实例上的属性与 react-bootstrap 的属性相同。这在重新实现套件组件时会限制我们,因为我们需要遵守相同的 API。例如

// From Navigation.js
<KitNavbar bg="dark" variant="dark" fixed="top">

理想情况下,在实例化 KitNavbar 时,我们不必添加那些特定于 react-bootstrap 的属性。

前端开发人员承诺在继续进行的过程中重构这些属性,因为我们现在已将它们识别为有问题的。任何对 react-bootstrap 组件的新引用都将进入我们的套件,而不是直接进入应用程序组件。

同时,我们已与服务器工程师共享了我们的模拟数据,服务器工程师正在努力构建单独的服务器环境,实现数据库模式,并向我们公开一些服务。

这使我们有时间在下一个冲刺中为我们的 UI 添加一些光泽——这很好,因为上级领导希望看到每个目的地的单独主题。当用户浏览目的地时,我们需要 UI 颜色方案更改以匹配显示的旅行海报。此外,我们希望尝试稍微修饰这些组件,以开始发展我们自己的外观和风格。一旦我们获得一些收入,我们将聘请设计师进行全面大修,但希望我们能够为我们的早期用户找到一个折衷方案。

冲刺二:审查会议

哇!团队在这个冲刺中确实全力以赴。我们获得了每个目的地的主题、自定义组件以及从应用程序组件中删除了许多残留的 react-bootstrap API 实现。

以下是台式机现在的样子

A more complete rendering of the landing page, this time using Mars as an example to show off a bright orange and red color theme. The interface components no longer look like they came directly from Bootstrap, but are still minimal in style, like dropdowns, buttons and text.
看看红色星球的太阳化主题!

为了实现这一点,前端开发人员引入了 Styled Components 库。它使为各个套件组件设置样式变得轻而易举,并添加了对多个主题的支持。

让我们看看他们这个冲刺中的一些亮点。

首先,对于导入字体和设置页面主体样式等全局内容,我们有一个名为 KitGlobal 的新套件组件。

// KitGlobal.js
import { createGlobalStyle } from "styled-components";
export const KitGlobal = createGlobalStyle`
  body {
    @import url('https://fonts.googleapis.com/css?family=Orbitron:500|Nunito:600|Alegreya+Sans+SC:700');
    background-color: ${props => props.theme.foreground};
    overflow-x: hidden;
  }
`;

它使用 createGlobalStyle 帮助程序来定义 body 元素的 CSS。它从 Google 导入我们所需的网页字体,将背景颜色设置为当前主题的“前景”值,并在 x 方向上关闭溢出以消除讨厌的水平滚动条。我们在 App 组件的 render 方法中使用该 KitGlobal 组件。

同样在 App 组件中,我们从 styled-components 中导入 ThemeProvider,以及从 ../theme 中导入名为“themes”的内容。我们使用 React 的 useState 将初始主题设置为 themes.luna,并使用 React 的 useEffect 在“destination”更改时调用 setTheme。返回的组件现在包装在 ThemeProvider 中,并将其“theme”作为 prop 传递。以下是完整的 App 组件。

// App.js
import React, { useState, useEffect } from "react";
import { ThemeProvider } from "styled-components";
import themes from "../theme/";
import { KitGlobal } from "../kit";
import Navigation from "./Navigation";
import Page from "./Page";
export default function App(props) {
  const [destinationIndex, setDestinationIndex] = useState(0);
  const [theme, setTheme] = useState(themes.luna);
  const destination = props.destinations[destinationIndex];
  useEffect(() => {
    setTheme(themes[destination.theme]);
  }, [destination]);

  return (
    <ThemeProvider theme={theme}>
      <React.Fragment>
        <KitGlobal />
        <Navigation
          {...props}
          destinationIndex={destinationIndex}
          setDestinationIndex={setDestinationIndex}
        />
        <Page
          {...props}
          destinationIndex={destinationIndex}
          setDestinationIndex={setDestinationIndex}
        />
      </React.Fragment>
    </ThemeProvider>
  );
}

KitGlobal 就像任何其他组件一样渲染。那里没有什么特别的,只是 body 标签受到影响。ThemeProvider 使用 React Context API 将 theme 传递给任何需要它的组件(即所有组件)。为了充分理解这一点,我们还需要查看主题的实际内容。

为了创建主题,我们的一位前端开发人员获取了所有旅行海报,并通过提取主要颜色为每个海报创建了调色板。这相当简单。

A screenshot of the Tiny Eye website showing the red color palette used for the Mars page theme. There are two images on the left from the page that Tiny Eye used to create a color palette in the center containing various shades of red and the hex values for them on the right.
我们为此使用了TinyEye

显然,我们不会使用所有颜色。主要的方法是将两个最常用的颜色分别命名为foregroundbackground。然后,我们又选择了三种颜色,通常按照从浅到深的顺序命名为accent1accent2accent3。最后,我们选择了两种对比色,分别命名为text1text2。对于上述目的地,看起来像这样

// theme/index.js (partial list)
const themes = {
  ...
  mars: {
    background: "#a53237",
    foreground: "#f66f40",
    accent1: "#f8986d",
    accent2: "#9c4952",
    accent3: "#f66f40",
    text1: "#f5e5e1",
    text2: "#354f55"
  },
  ...
};
export default themes;

一旦我们为每个目的地设置了主题,并且该主题被传递到所有组件(包括我们应用程序组件现在构建的套件组件),我们就需要使用styled-components来应用这些主题颜色以及我们自定义的视觉样式,例如面板角和“边框发光”。

这是一个简单的例子,我们使我们的KitHero组件将主题和自定义样式应用于Bootstrap Jumbotron

// KitHero.js
import styled from "styled-components";
import { Jumbotron } from "react-bootstrap";

export const KitHero = styled(Jumbotron)`
  background-color: ${props => props.theme.accent1};
  color: ${props => props.theme.text2};
  border-radius: 7px 25px;
  border-color: ${props => props.theme.accent3};
  border-style: solid;
  border-width: 1px;
  box-shadow: 0 0 1px 2px #fdb813, 0 0 3px 4px #f8986d;
  font-family: "Nunito", sans-serif;
  margin-bottom: 20px;
`;

在这种情况下,我们可以使用styled-components返回的内容,因此我们只需将其命名为KitHero并导出它。

当我们在应用程序中使用它时,它看起来像这样

// DestinationLayout.js (partial code)
const renderHero = () => {
  return (
    <KitHero>
      <h2>{destination.header}</h2>
      <p>{destination.blurb}</p>
      <KitButton>Book Your Trip Now!</KitButton>
    </KitHero>
  );
};

然后还有更复杂的情况,我们希望预设react-bootstrap组件的一些属性。例如,我们之前识别出的KitNavbar组件,它具有一堆我们宁愿不从应用程序的组件声明中传递的react-bootstrap属性。

现在让我们看看它是如何处理的

// KitNavbar.js (partial code)
import React, { Component } from "react";
import styled from "styled-components";
import { Navbar } from "react-bootstrap";

const StyledBootstrapNavbar = styled(Navbar)`
  background-color: ${props => props.theme.background};
  box-shadow: 0 0 1px 2px #fdb813, 0 0 3px 4px #f8986d;
  display: flex;
  flex-direction: horizontal;
  justify-content: space-between;
  font-family: "Nunito", sans-serif;
`;

export class KitNavbar extends Component {
  render() {
    const { ...props } = this.props;
    return <StyledBootstrapNavbar fixed="top" {...props} />;
  }
}

首先,我们使用styled-components创建一个名为StyledBootstrapNavbar的组件。我们能够使用传递给styled-components的CSS处理一些属性。但是为了继续利用(目前)组件粘贴到屏幕顶部的可靠性,同时其他所有内容都被滚动,我们的前端开发人员选择继续使用react-bootstrap的fixed属性。为此,我们必须创建一个KitNavbar组件,该组件呈现一个带有fixed=top属性的StyledBootstrapNavbar实例。我们还传递了所有道具,包括其子元素。

只有当我们希望在我们的套件组件中默认显式设置某些属性时,我们才需要创建一个单独的类来呈现styled-component的工作并将其道具传递给它。在大多数情况下,我们只需命名和返回styled-component的输出,并像上面对KitHero所做的那样使用它。

现在,当我们在应用程序的Navigation组件中渲染KitNavbar时,它看起来像这样

// Navigation.js (partial code)
return (
  <KitNavbar>
    <KitNavbarBrand>
      <KitLogo />
      Solar Excursions
    </KitNavbarBrand>
    {renderDestinationMenu()}
  </KitNavbar>
);

最后,我们第一次尝试将我们的套件组件从react-bootstrap中重构出来。KitAttribution组件是一个Bootstrap Alert,就我们的目的而言,它只不过是一个普通的div。我们能够轻松地重构以消除其对react-bootstrap的依赖。

这是该组件在上一冲刺中产生的样子

// KitAttribution.js (using react-bootstrap)
import { Alert } from "react-bootstrap";
export const KitAttribution = Alert;

现在它看起来像这样

// KitAttribution.js
import styled from "styled-components";
export const KitAttribution = styled.div`
  text-align: center;
  background-color: ${props => props.theme.accent1};
  color: ${props => props.theme.text2};
  border-radius: 7px 25px;
  border-color: ${props => props.theme.accent3};
  border-style: solid;
  border-width: 1px;
  box-shadow: 0 0 1px 2px #fdb813, 0 0 3px 4px #f8986d;
  font-family: "Alegreya Sans SC", sans-serif;
  > a {
    color: ${props => props.theme.text2};
    font-family: "Nunito", sans-serif;
  }
  > a:hover {
    color: ${props => props.theme.background};
    text-decoration-color: ${props => props.theme.accent3};
  }
`;

注意我们不再导入react-bootstrap,并且我们使用styled.div作为组件基础。它们不会都那么容易,但这是一个过程。

以下是我们的团队在第二轮冲刺中进行的样式和主题工作的结果

单独查看主题页面此处

结论

经过三个冲刺,我们的团队已在为UI建立可扩展的组件架构的道路上取得了良好的进展。

  • 由于react-bootstrap,我们正在快速前进,但不再因此积累大量技术债务。
  • 借助styled-components,我们能够实现多个主题(就像如今互联网上几乎每个应用程序都支持深色和浅色模式一样)。我们也不再像一个开箱即用的Bootstrap应用程序了。
  • 通过实现一个包含所有对react-bootstrap的引用的自定义组件套件,我们可以随着时间的推移将其重构出来。

在GitHub上分叉最终代码库