React组件设计实践总结03 - 样式的管理

6,652 阅读16分钟

CSS 是前端开发的重要组成部分,但是它并不完美,本文主要探讨 React 样式管理方面的一些解决方案,目的是实现样式的高度可定制化, 让大型项目的样式代码更容易维护.

系列目录


目录




1. 认识 CSS 的局限性

vjeux-speak

2014 年vjeux一个 speak 深刻揭示的原生 CSS 的一些局限性. 虽然它有些争议, 对于开发者来说更多的是启发. 至从那之后出现了很多 CSS-in-js 解决方案.

1️⃣ 全局性

CSS 的选择器是没有隔离性的, 不管是使用命名空间还是 BEM 模式组织, 最终都会污染全局命名空间. 尤其是大型团队合作的项目, 很难确定某个特定的类或者元素是否已经赋过样式. 所以在大部分情况下我们都会绞尽脑汁新创建一个类名, 而不是复用已有的类型.

解决的方向: 生成唯一的类名; shadow dom; 内联样式; Vue-scoped 方案


2️⃣ 依赖

由于 CSS 的'全局性', 所以就产生了依赖问题:

一方面我们需要在组件渲染前就需要先将 CSS 加载完毕, 但是很难清晰地定义某个特定组件依赖于某段特定的 CSS 代码; 另一方面, 全局性导致你的样式可能被别的组件依赖(某种程度的细节耦合), 你不能随便修改你的样式, 以免破坏其他页面或组件的样式. 如果团队没有制定合适的 CSS 规范(例如 BEM, 不直接使用标签选择器, 减少选择器嵌套等等), 代码很快就会失控

解决的方向: 之前文章提到组件是一个内聚单元, 样式应该是和组件绑定的. 最基本的解决办法是使用类似 BEM 命名规范来避免组件之间的命名冲突, 再通过创建优于复用, 组合优于继承的原则, 来避免组件间样式耦合;


3️⃣ 无用代码的移除

由于上述'依赖'问题, 组件样式之间并没有明确的边界, 很难判断哪些样式属于那个组件; 在加上 CSS 的'叠层特性', 更无法确定删除样式会带来什么影响.

现代浏览器已支持 CSS 无用代码检查. 但对于无组织的 CSS 效果不会太大

解决的方向: 如果样式的依赖比较明确,则可以安全地移除无用代码


4️⃣ 压缩

选择器和类名的压缩可以减少文件的体积, 提高加载的性能. 因为原生 CSS 一般有开发者由配置类名(在 html 或 js 动态指定), 所以工具很难对类名进行控制.

压缩类名也会减低代码的可读性, 变得难以调试.

解决的方向: 由工具来转换或创建类名


5️⃣ 常量共享

常规的 CSS 很难做到在样式和 JS 之间共享变量, 例如自定义主题色, 通常通过内联样式来部分实现这种需求

解决的方向: CSS-in-js


6️⃣ CSS 解析方式的不确定性

CSS 规则的加载顺序是很重要的, 他会影响属性应用的优先级, 如果按需加载 CSS, 则无法确保他们的解析顺序, 进而导致错误的样式应用到元素上. 有些开发者为了解决这个问题, 使用!important 声明属性, 这无疑是进入了另一个坑.

解决方向:避免使用全局样式,组件样式隔离;样式加载和组件生命周期绑定




2. 组件的样式管理

1️⃣ 组件的样式应该高度可定制化

组件的样式应该是可以自由定制的, 开发者应该考虑组件的各种使用场景. 所以一个好的组件必须暴露相关的样式定制接口. 至少需要支持为顶层元素配置classNamestyle属性:

interface ButtonProps {
  className?: string;
  style?: React.CSSProperties;
}

这两个属性应该是每个展示型组件应该暴露的 props, 其他嵌套元素也要考虑支持配置样式, 例如 footerClassName, footerStyle.




2️⃣ 避免使用内联 CSS

  1. style props 添加的属性不能自动增加厂商前缀, 这可能会导致兼容性问题. 如果添加厂商前缀又会让代码变得啰嗦.
  2. 内联 CSS 不支持复杂的样式配置, 例如伪元素, 伪类, 动画定义, 媒体查询和媒体回退(对象不允许同名属性, 例如display: -webkit-flex; display: flex;)
  3. 内联样式通过 object 传入组件, 内联的 object 每次渲染会重新生成, 会导致组件重新渲染. 当然通过某些工具可以将静态的 object 提取出去
  4. 不方便调试和阅读 ...

所以 内联 CSS 适合用于设置动态且比较简单的样式属性

社区上有许多 CSS-in-js 方案是基于内联 CSS 的, 例如 Radium, 它使用 JS 添加事件处理器来模拟伪类, 另外也媒体查询和动画. 不过不是所有东西都可以通过 JS 模拟, 比如伪元素. 所以这类解决方案用得比较少




3️⃣ 使用 CSS-in-js

社区有很多 CSS 解决方案, 有个项目(MicheleBertoli/css-in-js)专门罗列和对比了这些方案. 读者也可以读这篇文章(What to use for React styling?)学习对 CSS 相关技术进行选型决策

社区上最流行的, 也是笔者觉得使用起来最舒服的是styled-components, styled-components 有下列特性:

  • 自动生成类名, 解决 CSS 的全局性和样式冲突. 通过组件名来标志样式, 自动生成唯一的类名, 开发者不需要为元素定义类名.
  • 绑定组件. 隔离了 CSS 的依赖问题, 让组件 JSX 更加简洁, 反过来开发者需要考虑更多组件的语义
  • 天生支持'关键 CSS'. 样式和组件绑定, 可以和组件一起进行代码分割和异步加载
  • 自动添加厂商前缀
  • 灵活的动态样式. 通过 props 和全局 theme 来动态控制样式
  • 提供了一些 CSS 预处理器的语法
  • 主题机制
  • 支持 react-native. 这个用起来比较爽
  • 支持 stylint, 编辑器高亮和智能提示
  • 支持服务端渲染
  • 符合分离展示组件和行为组件原则

推荐这篇文章: Stop using css-in-javascript for web development, styled-components 可以基本覆盖所有 CSS 的使用场景:


0. 基本用法

// 定义组件props
const Title = styled.h1<{ active?: boolean }>`
  color: ${props => (props.active ? 'red' : 'gray')};
`;

// 固定或计算组件props
const Input = styled.input.attrs({
  type: 'text',
  size: props => (props.small ? 5 : undefined),
})``;

1. 样式扩展

const Button = styled.button`
  color: palevioletred;
  font-size: 1em;
  margin: 1em;
  padding: 0.25em 1em;
  border: 2px solid palevioletred;
  border-radius: 3px;
`;

// 覆盖和扩展已有的组件, 包含styled生成的组件还是自定义组件(通过className传入)
const TomatoButton = styled(Button)`
  color: tomato;
  border-color: tomato;
`;

2. mixin 机制

在 SCSS 中, mixin 是重要的 CSS 复用机制, styled-components 也可以实现:

定义:

import { css } from 'styled-components';

// utils/styled-mixins.ts
export function truncate(width) {
  return css`
    width: ${width};
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  `;
}

使用:

import { truncate } from '~/utils/styled-mixins';

const Box = styled.div`
  // 混入
  ${truncate('250px')}
  background: papayawhip;
`;

3. 类 SCSS 的语法

const Example = styled(Component)`
  // 自动厂商前缀
  padding: 2em 1em;
  background: papayawhip;

  // 伪类
  &:hover {
    background: palevioletred;
  }

  // 提供样式优先级技巧
  &&& {
    color: palevioletred;
    font-weight: bold;
  }

  // 覆盖内联css样式
  &[style] {
    font-size: 12px !important;
    color: blue !important;
  }

  // 支持媒体查询
  @media (max-width: 600px) {
    background: tomato;

    // 嵌套规则
    &:hover {
      background: yellow;
    }
  }

  > p {
    /* descendant-selectors work as well, but are more of an escape hatch */
    text-decoration: underline;
  }

  /* Contextual selectors work as well */
  html.test & {
    display: none;
  }
`;

引用其他组件

由于 styled-components 的类名是自动生成的, 所以不能直接在选择器中声明他们, 但可以在模板字符串中引用其他组件:

const Icon = styled.svg`
  flex: none;
  transition: fill 0.25s;
  width: 48px;
  height: 48px;

  // 引用其他组件的类名. 这个组件必须是styled-components生成或者包装的组件
  ${Link}:hover & {
    fill: rebeccapurple;
  }
`;

5. JS 带来的动态性

媒体查询帮助方法:

// utils/styled.ts
const sizes = {
  giant: 1170,
  desktop: 992,
  tablet: 768,
  phone: 376,
};

export const media = Object.keys(sizes).reduce((accumulator, label) => {
  const emSize = sizes[label] / 16;
  accumulator[label] = (...args) => css`
    @media (max-width: ${emSize}em) {
      ${css(...args)}
    }
  `;
  return accumulator;
}, {});

使用:

const Container = styled.div`
  color: #333;
  ${media.desktop`padding: 0 20px;`}
  ${media.tablet`padding: 0 10px;`}
  ${media.phone`padding: 0 5px;`}
`;

SCSS 也提供了很多内置工具方法, 比如颜色的处理, 尺寸的计算. styled-components 提供了一个类似的 js 库: polished来满足这部分需求, 另外还集成了常用的 mixin, 如 clearfix. 通过 babel 插件可以在编译时转换为静态代码, 不需要运行时.


6. 绑定组件的全局样式

全局样式和组件生命周期绑定, 当组件卸载时也会删除全局样式. 全局样式通常用于覆盖一些第三方组件样式

const GlobalStyle = createGlobalStyle`
  body {
    color: ${props => (props.whiteColor ? 'white' : 'black')};
  }
`

// Test
<React.Fragment>
  <GlobalStyle whiteColor />
  <Navigation /> {/* example of other top-level stuff */}
</React.Fragment>

7. Theme 机制及 Theme 对象的设计

styled-components 的 ThemeProvider 可以用于取代 SCSS 的变量机制, 只不过它更加灵活, 可以被所有下级组件共享, 并动态变化.

关于 Theme 对象的设计我觉得可以参考传统的 UI 框架, 例如Foundation或者Bootstrap, 经过多年的迭代它们代码组织非常好, 非常值得学习. 以 Bootstrap 的项目结构为例:

.
├── _alert.scss
├── ...                # 定义各种组件的样式
├── _print.scss        # 打印样式适配
├── _root.scss         # 🔴根样式, 即全局样式
├── _transitions.scss  # 过渡效果
├── _type.scss         # 🔴基本排版样式
├── _reboot.scss       # 🔴浏览器重置样式, 类似于normalize.css
├── _functions.scss
├── _mixins.scss
├── _utilities.scss
├── _variables.scss    # 🔴变量配置, 包含全局配置和所有组件配置
├── bootstrap-grid.scss
├── bootstrap-reboot.scss
├── bootstrap.scss
├── mixins             # 各种mixin, 可复用的css代码
├── utilities          # 各种工具方法
└── vendor
    └── _rfs.scss

_variables.scss包含了以下配置:

  • 颜色系统: 调色盘配置

    • 灰阶颜色: 提供白色到黑色之间多个级别的灰阶颜色. 例如

    • 语义颜色: 根据 UI 上面的语义, 定义各种颜色. 这个也是 CSS 开发的常见模式

  • 尺寸系统: 多个级别的间距, 尺寸大小配置

  • 配置开关: 全局性的配置开关, 例如是否支持圆角, 阴影

  • 链接样式配置: 如颜色, 激活状态, decoration

  • 排版: 字体, 字体大小, font-weight, 行高, 边框, 标题等基本排版配置

  • 网格系统断点配置

bootstrap 将这些配置项有很高的参考意义. 组件可以认为是 UI 设计师 的产出, 如果你的应用有统一和规范的设计语言(参考antd), 这些配置会很有意义。样式可配置化可以让你的代码更灵活, 更稳定, 可复用性和可维护性更高. 不管对于 UI 设计还是客户端开发, 设计规范可以提高团队工作效率, 减少沟通成本.

styled-components 的 Theme 使用的是React Context API, 官方文档有详尽的描述, 这里就不展开了. 点击这里了解更多, 另外在这里了解如何在 Typescript 中声明 theme 类型


8. 提升开发体验

可以使用babel-plugin-styled-componentsbabel macro来支持服务端渲染、 样式压缩和提升 debug 体验. 推荐使用 macro 形式, 无需安装和配置 babel 插件. 在 create-react-app 中已内置支持:

import styled, { createGlobalStyle } from 'styled-components/macro';

const Thing = styled.div`
  color: red;
`;

详见Tooling


9. 了解 styled-components 的局限性

比较能想到的局限性是性能问题:

  1. css-in-js: 需要一个 JS 运行时, 会增加 js 包体积(大约 15KB)
  2. 相比原生 CSS 会有更多节点嵌套(例如 ThemeConsumer)和计算消耗. 这个对于复杂的组件树的渲染影响尤为明显
  3. 不能抽取为 CSS 文件, 这通常不算问题

官方benchmark

下面是基于 v4.0 基准测试对比图, 在众多 CSS-in-js 方案中, styled-components 处于中上水平:

styled-components benchmark


10. 一些开发规范

  • 避免无意义的组件名. 避免类似Div, Span这类直接照搬元素名的无意义的组件命名

  • 在一个文件中定义 styled-components 组件. 对于比较简单的组件, 一般会在同一个文件中定义 styled-components 组件就行了. 下面是典型组件的文件结构:

    import React, { FC } from 'react';
    import styled from 'styled-components/macro';
    
    // 在顶部定义所有styled-components组件
    const Header = styled.header``;
    const Title = styled.div``;
    const StepName = styled.div``;
    const StepBars = styled.div``;
    const StepBar = styled.div<{ active?: boolean }>``;
    const FormContainer = styled.div``;
    
    // 使用组件
    export const Steps: FC<StepsProps> = props => {
      return <>...</>;
    };
    
    export default Steps;
    

    然而对于比较复杂的页面组件来说, 会让文件变得很臃肿, 扰乱组件的主体, 所以笔者一般会像抽取到单独的styled.tsx文件中:

    import React, { FC } from 'react';
    import { Header, Title, StepName, StepBars, StepBar, FormContainer } from './styled';
    
    export const Steps: FC<StepsProps> = props => {
      return <>...</>;
    };
    
    export default Steps;
    

  • 考虑导出 styled-components 组件, 方便上级组件设置样式

    // ---Foo/index.ts---
    import * as Styled from './styled';
    
    export { Styled };
    // ...
    
    // ---Bar/index.ts----
    import { Styled } from '../Foo';
    
    const MyComponent = styled.div`
      & ${Styled.SomeComponent} {
        color: red;
      }
    `;
    

11. 其他 CSS-in-js 方案

  • CSS-module
  • JSS
  • emotion
  • glamorous

这里值得一提的是CSS-module, 这也是社区比较流行的解决方案. 严格来说, 这不是 CSS-in-js. 有兴趣的读者可以看这篇文章CSS Modules 详解及 React 中实践.

特性:

  • 比较轻量, 不需要 JS 运行时, 因为他在编译阶段进行计算
  • 所有样式默认都是 local, 通过导入模块方式可以导入这些生成的类名
  • 可以和 CSS proprocessor 配合
  • 采用非标准的语法, 例如:global, :local, :export, compose:

CSS module 同样也有外部样式覆盖问题, 所以需要通过其他手段对关键节点添加其他属性(如 data-name).

如果使用 css-module, 建议使用*.module.css来命名 css 文件, 和普通 CSS 区分开来.

扩展:




4️⃣ 通用的组件库不应该耦合 CSS-in-js/CSS-module 的方案

如果是作为第三方组件库形式开发, 个人觉得不应该耦合各种 CSS-in-js/CSS-module. 不能强求你的组件库使用者耦合这些技术栈, 而且部分技术是需要构建工具支持的. 建议使用原生 CSS 或者将 SCSS/Less 这些预处理工具作为增强方案




5️⃣ 优先使用原生 CSS

笔者的项目大部分都是使用styled-components, 但对于部分极致要求性能的组件, 一般我会回退使用原生 CSS, 再配合 BEM 命名规范. 这种最简单方式, 能够满足大部分需求.




6️⃣ 选择合适自己团队的技术栈

每个团队的情况和偏好不一样, 选择合适自己的才是最好的. 关于 CSS 方面的技术栈搭配也非常多样:

css determination

  • 选择 CSS-in-js 方案: 优点: 这个方案解决了大部分 CSS 的缺陷, 灵活, 动态性强, 学习成本比较低, 非常适合组件化的场景. 缺点: 性能相比静态 CSS 要弱, 不过这点已经慢慢在改善. 可以考虑在部分组件使用原生 CSS
  • 选择 CSS 方案:
    • 选择原生 CSS 方案: 这种方案最简单
    • 选择 Preprocessor: 添加 CSS 预处理器, 可以增强 CSS 的可编程性: 例如模块化, 变量, 函数, mixin. 优点: 预处理器可以减少代码重复, 让 CSS 更好维护. 适合组织性要求很高的大型项目. 缺点: 就是需要学习成本, 所以这里笔者建议使用标准的 cssnext 来代替 SCSS/Less 这些方案
    • 方法论: CSS 的各种方法论旨在提高 CSS 的组织性, 提供一些架构建议, 让 CSS 更好维护.
    • postcss: 对 CSS 进行优化增强, 例如添加厂商前缀
    • css-module: 隔离 CSS, 支持暴露变量给 JS, 解决 CSS 的一些缺陷, 让 CSS 适合组件化场景. 可选, 通过合适的命名和组织其实是可以规避 CSS 的缺陷

综上所述, CSS-in-js 和 CSS 方案各有适用场景. 比如对于组件库, 如 antd 则选择了 Preprocessor 方案; 对于一般应用笔者建议使用 CSS-in-js 方案, 它学习成本很低, 并且There's Only One Way To Do It 没有太多心智负担, 不需要学习冗杂的方法论, 代码相对比较可控; 另外它还支持跨平台, 在 ReactNative 下, styled-components 是更好的选择. 而 CSS 方案, 对于大型应用要做到有组织有纪律和规划化, 需要花费较大的精力, 尤其是团队成员能力不均情况下, 很容易失控




7️⃣ 使用 svgr 转换 svg 图标

如今 CSS-Image-Sprite 早已被 SVG-Sprite 取代. 而在 React 生态中使用svgr更加方便, 它可以将 svg 文件转换为 React 组件, 也就是一个普通的 JS 模块, 它有以下优势:

  • 转换为普通 JS 文件, 方便代码分割和异步加载
  • 相比 svg-sprite 和 iconfont 方案更容易管理
  • svg 可以通过 CSS/JS 配置, 可操作性更强; 相比 iconfont 支持多色
  • 支持 svgo 压缩

基本用法:

import starUrl, { ReactComponent as Star } from './star.svg';
const App = () => (
  <div>
    <img src={starUrl} alt="star" />
    <Star />
  </div>
);

了解更多

antd 3.9 之后使用 svg 图标代替了 font 图标
对比SVG vs Image, SVG vs Iconfont




8️⃣ 结合使用 rem 和 em 等相对单位, 创建更有弹性的组件

Bootstrap v4 全面使用 rem 作为基本单位, 这使得所有组件都可以响应浏览器字体的调整:

bootstrap

rem 可以让整个文档可以响应 html 字体的变化, 经常用于移动端等比例还原设计稿, 详见Rem 布局的原理解析. 我个人对于觉得弹性组件来说更重要的是 em 单位, 尤其是那些比例固定组件, 例如 Button, Switch, Icon. 比如我会这样定义 svg Icon 的样式:

.svg-icon {
  width: 1em;
  height: 1em;
  fill: currentColor;
}

像 iconfont 一样, 外部只需要设置font-size就可以配置 icon 到合适的尺寸, 默认则继承当前上下文的字体大小:

<MyIcon style={{ fontSize: 17 }} />

em 可以让Switch这类固定比例的组件的样式可以更容易的被配置, 可以配合函数将px转换为em:

Edit redux hooks

扩展:




3. 规范

1️⃣ 促进建立统一的 UI 设计规范

上文已经阐述了 UI 设计规范的重要性, 有兴趣的读者可以看看这篇文章开发和设计沟通有多难? - 你只差一个设计规范. 简单总结一下:

  • 提供团队协作效率
  • 提高组件的复用率. 统一的组件规范可以让组件更好管理
  • 保持产品迭代过程中品牌一致性

2️⃣ CSS 编写规范

可以参考以下规范:

3️⃣ 使用stylint进行样式规范检查




扩展