CSS 是前端应用的基石之一,但CSS的开发体验却饱受诟病,为什么会有这样的反差呢?
让我们从一道简单的面试题开始说起。
问题:下列HTML代码在浏览器中加载渲染后, body元素 中2个 div元素 的前景色 color 分别是什么?
<!DOCTYPE html>
<html>
<head>
<title>简单的 CSS 面试题</title>
<style>
.blue {
color: blue;
}
.red {
color: red;
}
</style>
</head>
<body>
<div class="blue red">我是什么颜色?</div>
<div class="red blue">我是什么颜色?</div>
</body>
</html>因为CSS样式最终是否生效,取决于CSS规则声明的先后顺序,而非CSS class的先后顺序。
</details>这是个易错题,即使有多年的CSS开发经验,也会因为对CSS隐式规则的疏忽而判断错误。
理解了这道面试题,CSS在开发中的痛点就可见一斑了。
CSS最显著的痛点就是可维护性差,具体表现在:
CSS并非编程语言,其语法缺乏逻辑控制,变量,循环等特性,不便于编写复杂的动态样式,对如今业务逻辑复杂的前端应用来说,语法堪称简陋。
CSS默认运行在全局作用域中,同时模块化能力又有限,类名样式对所有元素生效,导致类名冲突难以避免,样式容易被覆盖。
CSS的类名选择符特性依赖优先级生效,但优先级计算规则又相当复杂,对人类开发者并不友好。
CSS与JS、HTML配合紧密,代码却又相互隔离,缺乏关联,导致开发者需要在3者之间频繁切换注意力,对开发效率和体验都有负面影响。
避免开发者频繁切换注意力,从而提高开发效率,也是Vue.js提出单文件组件(即SFC、.vue文件)和React.js使用JSX的初衷。
CSS的样式最终是否生效,隐式的依赖许多难以控制的因素,例如:
style或link标签在HTML中的先后顺序。(即本篇开头面试题的考点)这些因素在开发实践中通常难以控制,尤其是现代的前端工程大都依赖打包构建工具,将模块化的代码,合并成产物文件。对于有成百上千个组件模块的前端项目,CSS代码合并后的顺序往往不能控制,甚至难以预测。
注:以Webpack为例,其对CSS代码进行打包时,是基于导入CSS文件的顺序,决定的产物代码的顺序。
具体到我们常用的组件开发模式,也就是Webpack遍历组件依赖关系时,确定的组件依赖顺序。如果有大量组件相互依赖、引用,这个依赖关系非常难以预测,也就很难控制CSS 产物代码的顺序。
注:CSS痛点导致开发问题的例子:
- Next.js 因 style 标签先后顺序导致样式错乱BUG,饱受困扰多年,仍未能修复:https://github.com/vercel/next.js/issues/16630
- Stack Overflow上关于CSS覆盖顺序的提问:https://stackoverflow.com/questions/9459062/in-which-order-do-css-stylesheets-override
- 代码分割后各 chunk 的 CSS 合并顺序同样难以预测,会触发样式覆盖问题(与上文 webpack issue 同源)。
有痛点就有解决方案,过去许多年来,业界也为解决CSS的痛点尝试过众多解决方案,接下来我们一起来了解6类CSS优化方案的利弊得失。
第一类解决方案是类名命名原则,代表工具是BEM方法论:https://getbem.com/。
这类解决方案提供了一套编写CSS的原则,约定在开发时将CSS的类名class分成3部分:
Block:是一个抽象概念,表示使用这个字符串作为类名的元素,都属于一个部分,有相对独立的功能。例如.block,.navbar类名。Element:块的组成部分,总是和块相连使用,以2个下划线作为分隔。例如.block__element, .navbar__dropdown类名。Modifier:用于标识块和元素的细节外观,以2个中横线作为分隔。例如 .block__element--modifier、.navbar__dropdown--disabled 类名。这3类概念组成的类名,对应的就是BEM的全称:Block Element Modifier。
BEM规则通过提供统一的CSS class命名规范,让开发者使用统一的大小写规则和下划线等符号,实现为不同的组件和元素,命名不同的CSS类名class。
同时致力于尽量让CSS类名选择符保持最低的优先级,只有一个类选择器,避免CSS缺乏作用域特性导致难以维护优先级的痛点。
.navigation__menu-item--active-with-submenu、.product__image-container--with-overlay-and-zoom,这样的类名让人阅读辨别并非易事。第二类解决方案是最常见的CSS 预处理,代表工具是Sass和Less。
这类方案基于配套的代码编译工具,拓展了CSS的语法,通过为CSS增加变量,函数,嵌套等特性,来解决CSS可维护性较差的痛点。
以Less为例,通过使用less-loader https://www.webpackjs.com/loaders/less-loader/,就能在基于webpack构建的前端项目中编写.less后缀的样式文件,使用原生CSS所没有的变量,嵌套,函数等特性。
Less的代码示例如下:
// src/style/demo.less
// 1. 函数 mixin
.bordered {
border-top: dotted 1px black;
border-bottom: solid 2px black;
}
// 2. 变量 variable
@fontSize: 16px;
// 3. 嵌套 nested
#header {
font-size: @fontSize;
}上述demo.less文件中的Less语法代码,在浏览器中不能直接解析生效,需要经过编译后,产生如下CSS代码,供我们在开发生产环境使用:
.bordered {
border-top: dotted 1px black;
border-bottom: solid 2px black;
}
#header {
font-size: 16px;
}Sass和Less的语法,有一定时间成本。Sass和Less编写的源码和最终浏览器中运行的产物CSS代码不一定能精确匹配,会增加开发调试的难度,一般需要额外配合CSS sourcemap使用。Sass和Less代码,需要使用专用的编译器,例如sass-loader,less-loader,编译后才能在生产环境中使用,会导致前端项目构建的耗时显著增加。第三类解决方案是后处理器,代表工具是PostCSS:https://postcss.org/。
和预处理器直接提供新语法、新特性不同,后处理器PostCSS通过提供一套类似Babel的CSS语法编译工具和插件系统,来对已有的CSS进行后置处理,更注重通过生态中的各类插件,实现特定功能,例如:
postcss.config.js配置文件,来设置使用的插件及其选项。// postcss.config.js
/** @type {import('postcss-load-config').Config} */
const config = {
plugins: [
require('autoprefixer'),
require('postcss-nested')
]
}
module.exports = config第四类解决方案,是近两年在前端领域非常流行的原子化CSS方案。
代表工具是 Tailwind CSS:https://tailwindcss.com/。
这类方案通过提供预定义的细粒度CSS样式和类名,来提高CSS的开发效率,减少自定义的样式和类名,从根本上尽可能避免CSS类名和样式容易冲突覆盖,难以维护的痛点。
以Tailwind为例,其特点有:
Tailwind提供的预定义CSS样式和类名,基于工具优先utility-first的思想,有独特的规律,例如:
样式化文字尺寸的:
text-xs:font-size: 0.75rem; /* 12px */ line-height: 1rem; /* 16px */
text-sm:font-size: 0.875rem; /* 14px */ line-height: 1.25rem; /* 20px */
text-lg:font-size: 1.125rem; /* 18px */ line-height: 1.75rem; /* 28px */
样式化尺寸大小的:
w-4:width: 1rem; /* 16px */
w-8:width: 2rem; /* 32px */
可配置性:Tailwind 还支持修改预定义类名的具体样式,可以通过专门的 tailwind.config.js 来配置,例如修改默认的文字大小配置:
module.exports = {
theme: {
fontSize: {
'xs': '0.75rem', // 自定义字体尺寸 'xs'
'sm': '0.875rem', // 自定义字体尺寸 'sm'
'lg': '1.125rem', // 自定义字体尺寸 'lg'
// 添加更多自定义的字体尺寸...
},
},
// 其他的配置...
}Tailwind显著提高了CSS的可维护性,有很多优点,但是他也存在缺点,主要有:
第五类解决方案是CSS In JS(下文简称CIJ),也就是把CSS和JS相结合,这类方案致力于实现在JS代码中编写CSS的解决方案,通过复用JS的作用域,逻辑判断等特性来强化CSS。
代表工具有:
以styled-components为例,CSS in JS的代码示例如下:https://styled-components.com/docs/basics#styling-any-component
const Link = ({ className, children }) => (
<a className={className}>
{children}
</a>
);
const StyledLink = styled(Link)`
color: #BF4F74;
font-weight: bold;
`;
render(
<div>
<Link>Unstyled, boring Link</Link>
<br />
<StyledLink>Styled, exciting Link</StyledLink>
</div>
);基于styled-components的styled()API,我们可以对React组件进行加工。
例如上述代码运行后,会生成一个哈希字符串作为类名,指向我们编写在styled()方法后的CSS样式,并将该类名添加到返回的新组件StyledLink上,让该组件被样式化。
例如 styled-components 生成的类名 .lmIyLq,就是这样一串由 CSS In JS 代码生成的哈希字符串类名。
因为是在JS中编写CSS代码的需求,我们还可以利用逻辑判断,方便地编写动态样式,例如在下列代码中:
const Button = styled.button`
background: ${(props) => (props.primary ? "#6495ED" : "#2b2b2b")};
color: white;
font-size: 24px;
padding: 12px;
cursor: pointer;
`;
// 使用时通过改变组件的 primary 属性来实现动态变化样式:
<Button primary>Hello, I am a Primary Button</Button>我们使用基于组件属性适配样式(adapting-based-on-props)的特性,在编写CIJ代码时,基于传入组件的props.primary,改变backgroundCSS规则的值。
这类CIJ方案的一大显著优势是学习成本极低,只要会写CSS代码,就可以无缝切换到编写CIJ代码中。
不过具体影响程度一般不大,尤其是相较于对开发体验带来的显著优化,这点负面影响可以忽略。
如果非常在意对运行时的影响,也可以考虑使用构建时生成静态文件的CSS in JS解决方案,规避这一缺点,例如linaria:https://github.com/callstack/linaria
第六类解决方案是CSS In JS的进阶优化版css prop,这类方案通过在CIJ的基础上,为前端组件例如JSX增加专门的css属性来容纳CSS代码,来实现极致简单高效的CSS开发方式。
代表工具是Emotion的 css prop:https://emotion.sh/docs/css-prop
完整代码示例,请参考:《feat: add css prop》:https://github.com/JuniorTour/fe-optimization-demo/pull/10
注意!下列示例只适用于React版本>=16.14.0的前端项目。
增加 Emotion CSS prop的步骤非常简单,只需要安装并引入@emotion/babel-plugin这一官方babel插件:
npm install --save @emotion/react @emotion/babel-plugin再修改babel配置,启用importSource: '@emotion/react',引入@emotion/babel-plugin即可:
// babel.config.js
module.exports = {
presets: [
[
'@babel/preset-react',
{ runtime: 'automatic', importSource: '@emotion/react' },
],
],
plugins: [
'@emotion/babel-plugin',
],
// 其他 Babel 配置...
}这样,我们就为前端项目增加了cssprop的支持,直接在JSX中的HTML标签编写css属性,就可以被编译成对应的CSS代码,并生成一个唯一的哈希字符串作为ID。
cssprop的代码示例如下:
import { StrictMode } from 'react';
import { render } from 'react-dom';
import { css } from '@emotion/react';
const dynamicColor = Math.random() % 2 === 0 ? 'darkgreen' : 'yellow';
render(
<StrictMode>
{/* 1. 对象风格 CIJ */}
<div
css={{
fontSize: '32px',
color: 'red',
}}
>
CSS In JS DEMO
</div>
{/* 2. 字符串风格 CIJ */}
<div
css={css`
background-color: hotpink;
&:hover {
color: ${dynamicColor};
}
`}
>
css prop 动态样式示例
</div>
</StrictMode>,
document.getElementById('root'),
);有2种写法:
font-size改为fontSize。css方法。此外,CSS prop不仅支持写入CSS规则,更支持写入有嵌套逻辑的CSS代码,例如:
<div
css={{
fontSize: '32px',
color: 'red',
'.child-class': {
color: 'blue',
'& button': {
backgroundColor: 'green',
},
},
}}
>
CSS In JS DEMO
<div className="child-class">
child-class div
<button type="button">Button</button>
</div>
</div>下面,我们通过对比:
css prop两种解决方案的异同来进一步理解css prop的优势。
| 对比项 | BEM 类名命名原则 | Emotion 的 css prop 解决方案 |
|---|---|---|
| 源码示例:实现动态样式 | 代码行数较多,约22行代码:<br /> <br />另外还需要配套JS逻辑,实现样式切换,代码示例如下: | 代码行数较少,约12行代码: |
| 生产环境代码 | 与源代码相同,包含3个class类名:.block__div、.block__div--color-green、.block__div--color-yellow | 编译后的代码,会自动生成2个唯一哈希字符串作为类名:.css-1gjlitb:hover { color: yellow; } .css-1gjlitb { background-color: hotpink; } |
| 痛点1:样式生效依赖隐式规则 | 未解决痛点<br />样式是否生效,仍然依赖CSS规则声明的先后顺序,一旦CSS规则的声明顺序变化,最终生效的样式就会变化,极易导致样式错乱。 | 痛点解决<br />每段样式都有各自独立的唯一哈希字符串作为CSS class,样式是否生效,不依赖CSS规则声明的先后顺序,不论CSS规则的声明顺序如何变化,样式都能保持稳定。 |
| 痛点2:CSS 选择符的优先级复杂 | 未解决痛点<br />开发者自行构思命名规则复杂的 class,消耗时间精力,还容易重复、优先级冲突,开发体验较差。 | 痛点解决<br />所有CSS样式都有自动生成的CSS class,无需区分对比优先级,从根本上避免了class优先级冲突覆盖,开发体验更好。 |
| 代码提示 | 有代码提示 | 有代码提示 |
基于CIJ和cssprop的这些特性,CSS的各类痛点得以解决:
最后,最重要的是,这一解决方案几乎没有学习成本,只要会写CSS,就会写css prop,不需要再学习任何规则。开发者几乎没有任何学习成本,就能解决大部分CSS维护性差的痛点,投入几乎为零,产出却非常巨大,是近年来非常流行的解决方案,值得结合业务场景尝试。