Bobi.ink
2019-05-14
CSS 是前端开发的重要组成部分,但是它并不完美,本文主要探讨 React 样式管理方面的一些解决方案,目的是实现样式的高度可定制化, 让大型项目的样式代码更容易维护.
系列目录
目录
2014 年vjeux一个 speak 深刻揭示的原生 CSS 的一些局限性. 虽然它有些争议, 对于开发者来说更多的是启发. 至从那之后出现了很多 CSS-in-js
解决方案.
CSS 的选择器是没有隔离性的, 不管是使用命名空间还是 BEM 模式组织, 最终都会污染全局命名空间. 尤其是大型团队合作的项目, 很难确定某个特定的类或者元素是否已经赋过样式. 所以在大部分情况下我们都会绞尽脑汁新创建一个类名, 而不是复用已有的类型.
解决的方向: 生成唯一的类名; shadow dom; 内联样式; Vue-scoped 方案
由于 CSS 的’全局性’, 所以就产生了依赖问题:
一方面我们需要在组件渲染前就需要先将 CSS 加载完毕, 但是很难清晰地定义某个特定组件依赖于某段特定的 CSS 代码; 另一方面, 全局性导致你的样式可能被别的组件依赖(某种程度的细节耦合), 你不能随便修改你的样式, 以免破坏其他页面或组件的样式. 如果团队没有制定合适的 CSS 规范(例如 BEM, 不直接使用标签选择器, 减少选择器嵌套等等), 代码很快就会失控
解决的方向: 之前文章提到组件是一个内聚单元, 样式应该是和组件绑定的. 最基本的解决办法是使用类似 BEM 命名规范来避免组件之间的命名冲突, 再通过创建优于复用, 组合优于继承的原则, 来避免组件间样式耦合;
由于上述’依赖’问题, 组件样式之间并没有明确的边界, 很难判断哪些样式属于那个组件; 在加上 CSS 的’叠层特性’, 更无法确定删除样式会带来什么影响.
现代浏览器已支持 CSS 无用代码检查. 但对于无组织的 CSS 效果不会太大
解决的方向: 如果样式的依赖比较明确,则可以安全地移除无用代码
选择器和类名的压缩可以减少文件的体积, 提高加载的性能. 因为原生 CSS 一般有开发者由配置类名(在 html 或 js 动态指定), 所以工具很难对类名进行控制.
压缩类名也会降低代码的可读性, 变得难以调试.
解决的方向: 由工具来转换或创建类名
常规的 CSS 很难做到在样式和 JS 之间共享变量, 例如自定义主题色, 通常通过内联样式来部分实现这种需求
解决的方向: CSS-in-js
CSS 规则的加载顺序是很重要的, 他会影响属性应用的优先级, 如果按需加载 CSS, 则无法确保他们的解析顺序, 进而导致错误的样式应用到元素上. 有些开发者为了解决这个问题, 使用!important 声明属性, 这无疑是进入了另一个坑.
解决方向:避免使用全局样式,组件样式隔离;样式加载和组件生命周期绑定
组件的样式应该是可以自由定制的, 开发者应该考虑组件的各种使用场景. 所以一个好的组件必须暴露相关的样式定制接口. 至少需要支持为顶层元素配置className
和style
属性:
interface ButtonProps {
className?: string;
style?: React.CSSProperties;
}
这两个属性应该是每个展示型组件应该暴露的 props, 其他嵌套元素也要考虑支持配置样式, 例如 footerClassName, footerStyle.
display: -webkit-flex; display: flex;
)所以 内联 CSS 适合用于设置动态且比较简单的样式属性
社区上有许多 CSS-in-js 方案是基于内联 CSS 的, 例如 Radium, 它使用 JS 添加事件处理器来模拟伪类, 另外也媒体查询和动画. 不过不是所有东西都可以通过 JS 模拟, 比如伪元素. 所以这类解决方案用得比较少
社区有很多 CSS 解决方案, 有个项目(MicheleBertoli/css-in-js)专门罗列和对比了这些方案. 读者也可以读这篇文章(What to use for React styling?)学习对 CSS 相关技术进行选型决策
社区上最流行的, 也是笔者觉得使用起来最舒服的是styled-components
, styled-components 有下列特性:
推荐这篇文章: Stop using css-in-javascript for web development, styled-components 可以基本覆盖所有 CSS 的使用场景:
// 定义组件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),
})``;
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;
`;
在 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;
`;
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;
}
`;
媒体查询帮助方法:
// 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 插件可以在编译时转换为静态代码, 不需要运行时.
全局样式
全局样式和组件生命周期绑定, 当组件卸载时也会删除全局样式. 全局样式通常用于覆盖一些第三方组件样式
const GlobalStyle = createGlobalStyle`
body {
color: ${props => (props.whiteColor ? 'white' : 'black')};
}
`
// Test
<React.Fragment>
<GlobalStyle whiteColor />
<Navigation /> {/* example of other top-level stuff */}
</React.Fragment>
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
包含了以下配置:
bootstrap 将这些配置项有很高的参考意义. 组件可以认为是 UI 设计师 的产出, 如果你的应用有统一和规范的设计语言(参考antd), 这些配置会很有意义。样式可配置化可以让你的代码更灵活, 更稳定, 可复用性和可维护性更高. 不管对于 UI 设计还是客户端开发, 设计规范可以提高团队工作效率, 减少沟通成本.
styled-components 的 Theme 使用的是React Context
API, 官方文档有详尽的描述, 这里就不展开了. 点击这里了解更多, 另外在这里了解如何在 Typescript 中声明 theme 类型
可以使用babel-plugin-styled-components
或babel macro
来支持服务端渲染、 样式压缩和提升 debug 体验. 推荐使用 macro 形式, 无需安装和配置 babel 插件. 在 create-react-app 中已内置支持:
import styled, { createGlobalStyle } from 'styled-components/macro';
const Thing = styled.div`
color: red;
`;
详见Tooling
比较能想到的局限性是性能问题:
下面是基于 v4.0 基准测试对比图, 在众多 CSS-in-js 方案中, styled-components 处于中上水平:
Div
, Span
这类直接照搬元素名的无意义的组件命名; 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.SomeComponent} { color: red; }
; 这里值得一提的是CSS-module, 这也是社区比较流行的解决方案. 严格来说, 这不是 CSS-in-js. 有兴趣的读者可以看这篇文章CSS Modules 详解及 React 中实践.
特性:
CSS module 同样也有外部样式覆盖问题, 所以需要通过其他手段对关键节点添加其他属性(如 data-name).
如果使用 css-module, 建议使用
*.module.css
来命名 css 文件, 和普通 CSS 区分开来.
扩展:
如果是作为第三方组件库形式开发, 个人觉得不应该耦合各种 CSS-in-js/CSS-module. 不能强求你的组件库使用者耦合这些技术栈, 而且部分技术是需要构建工具支持的. 建议使用原生 CSS 或者将 SCSS/Less 这些预处理工具作为增强方案
笔者的项目大部分都是使用styled-components
, 但对于部分极致要求性能的组件, 一般我会回退使用原生 CSS, 再配合 BEM 命名规范. 这种最简单方式, 能够满足大部分需求.
每个团队的情况和偏好不一样, 选择合适自己的才是最好的. 关于 CSS 方面的技术栈搭配也非常多样:
综上所述, CSS-in-js 和 CSS 方案各有适用场景. 比如对于组件库, 如 antd 则选择了 Preprocessor 方案; 对于一般应用笔者建议使用 CSS-in-js 方案, 它学习成本很低, 并且There's Only One Way To Do It
没有太多心智负担, 不需要学习冗杂的方法论, 代码相对比较可控; 另外它还支持跨平台, 在 ReactNative 下, styled-components 是更好的选择. 而 CSS 方案, 对于大型应用要做到有组织有纪律和规划化, 需要花费较大的精力, 尤其是团队成员能力不均情况下, 很容易失控
如今 CSS-Image-Sprite 早已被 SVG-Sprite 取代. 而在 React 生态中使用svgr
更加方便, 它可以将 svg 文件转换为 React 组件, 也就是一个普通的 JS 模块, 它有以下优势:
基本用法:
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
Bootstrap v4 全面使用 rem 作为基本单位, 这使得所有组件都可以响应浏览器字体的调整:
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:
扩展:
上文已经阐述了 UI 设计规范的重要性, 有兴趣的读者可以看看这篇文章开发和设计沟通有多难? - 你只差一个设计规范. 简单总结一下:
可以参考以下规范:
Please enable JavaScript to view the comments powered by Disqus.