什么是原子化 css
定义:每一个css属性都通过一个css类来实现的一种 css 写法。
举个例子,我们平时是这么写 css 的:
<div class="text">这是一行文字</div>
<style>
.text {
font-size: 12px;
color: red;
line-height: 40px;
}
</style>
而使用原子化 css 就变成了这样:
<div class="text-12 text-red leading-40">这是一行文字</div>
<!-- 这里的样式可以使用工具自动生成 -->
<style>
.text-12 {
font-size: 12px;
}
.text-red {
color: red;
}
.leading-40 {
line-height: 40px;
}
</style>
这里我们能看出原子化 css 的一些优缺点:
原子化 css 的优势:
- 不必再浪费精力在定义类名上
细细想想我们平时在写 css 的时候都有思考哪些?
「根据所写功能定义类名 ---> 根据 DOM 层级定义 css 模块 ---> 编写 css 属性」
而在写原子化 css 时,我们可以跳过前两步直接编写css,并且不需要在 html 文件和 css 文件之间来回切换,开发效率大大大幅提高!
- 控制 CSS 体积
随着项目越来越大,css 体积一定是成等比增加的,就算有复用css,但也无法像原子化 css 那样精确到每一个 css 属性。在极致的原子化 css 开发中,我们能做到每一个 css 属性有且只有一个,复用性可达到最大。项目越大优势越明显
我自己项目中 row-y-center 这个类目前使用了 110 次,如果按不复用样式正常写 css 的话,那原子化css 写法所占用的css 体积基本可以忽略不计了
.row-y-center {
display: -webkit-box;
display: -ms-flexbox;
display: -webkit-flex;
display: flex;
-webkit-box-orient: horizontal;
-webkit-box-direction: normal;
-ms-flex-direction: row;
-webkit-flex-direction: row;
flex-direction: row;
-webkit-box-align: center;
-ms-flex-align: center;
-webkit-align-items: center;
align-items: center;
}
- 调试和修改都变得轻松
在 chrome devtools 里可以直接看到有啥样式,而且样式之间基本没有交叉,很容易调试:
普通 css 写法容易多个 class 的样式相互覆盖,还要确定优先级和顺序,因此更难调试:
在修改原子化 css 时不需要考虑 css 层级,也不需要担心修改一个类影响到其他类。
原子化 css 的不足和注意点:
- 类型过长
因为原子化 css 相当于把一部分 css 代码揉进了 html 里,所以有时 className 会变得很长
<div className="text-center mb-84 w-full text-555 text-20 leading-28 relative">按钮</div>
- 注意样式优先级 (这不是原子化 css 的不足,只是它的特性)
因为原子化 css 的类都是平铺的,所以两个样式之间是不会相互覆盖的;并且类是自动生成的,所以也不能确定最后生成的类谁在前谁在后,最多可以使用 !important 进行样式覆盖。
举个例子,比如有一个按钮,分为可点和不可点两种状态。
我们平时可能会这样写
<button className={`btn ${disabled ? 'disabled' : ''}`}>按钮</button>
<style>
.btn {
width: 100px;
height: 50px;
background: #f0f;
}
.btn.disabled {
background: #999;
}
</style>
其中 disabled 的样式是覆盖了原本的 btn 样式的,而在原子化 css 中就没有这种类权重的概念了,需要通过判断使用不同的类
<button className={`w-100 h-50 ${disabled ? 'bg-[#999]' : 'bg-[#f0f]'}`}>按钮</button>
如果实在是需要通过覆盖类来改变样式的话,可以在类名前加一个 !,这样生成的样式会自动增加 !important
<div class="!text-20></div>
<style>
.\!text-20 {
font-size: 20px !important;
}
</style>
- 需要记一些类名 😜
不可否认,这确实需要一些时间去记忆,不过相比于记英语单词,这简直就是小儿科~ 而且 vscode 提供了代码提示插件,更不需要强记忆了
我的项目是否适合用原子化 css?
根据亲身实践,我总结了以下几点场景比较合适使用原子化 css:
- 团队成员对原子化 css 的思想至少持开放态度,可以接受并学习使用
- 项目 UI 定制化程度较高,需要写大量 css
- 对项目体积有较高要求
- 如果是现有项目,要考虑迁移成本,将原有样式替换成原子化 css 样式也算是个费时费力的工程了🥲
实战
技术选型
原子化 css 最初是由 Thierry Koblentz (Yahoo!)在 2013 年挑战 CSS 最佳实践时使用的,经历了 10 年的打磨,目前市面上比较成熟的框架有 tailwindcss、windicss、unocss
- tailwindcss:基于 postcss 插件系统开发,自动收集用户编写的原子化 css,并生成 style 样式。可通过配置和自定义插件实现定制化样式,基于 rust 实现样式解析算法提高编译速度。tailwind是目前市场上生态最健全、最稳定的原子化 css 库之一
- windicss:设计初衷是作为 tailwindcss 替代品,并提供了很多亮眼特性,比如「自动值推导」、「属性化模式」、「无用样式剔除」等,很多特性也在 tailwindcss3.0 版本被吸取。不过很遗憾,目前这个库已经停止维护了,不过从使用上是不影响的,目前我自己的项目用的也是它。
- unocss:由原 windicss 团队成员创建,在技术设计上与 tailwind 完全不同,不再依赖 postcss,构建速度相比于 tailwindcss 有较大提升;windicss 有的它基本也都有,同时还增加了比如 Web fonts、CDN Runtime 等新特性,目前是 0.55 版本所以稳定性上还有待考证。
配置
这里贴下我项目的配置吧,底层库用的是 windicss,因为是在微信小程序中使用,所以有一些特性是无法使用的,比如 attributify
import { defineConfig } from 'windicss/helpers';
const fromEntries = function fromEntries(iterable: string[][]) {
return [...iterable].reduce<Record<string, any>>((obj, [key, val]) => {
obj[key] = val;
return obj;
}, {});
};
const range = (size: number) =>
fromEntries([...Array(size).keys()].slice(1).map((i) => [`${i}_${size}`, `${(i / size) * 100}%`]));
const generateSpacing = (num: number, unit = 'px') => {
return new Array(num).fill(1).reduce((cur, _next, index) => ({ ...cur, [index]: `${index}${unit}` }), {});
};
export default defineConfig({
separator: '_',
compile: false,
globalUtility: false,
darkMode: 'media',
attributify: false,
preflight: false,
prefixer: true,
extract: {
exclude: ['node_modules', '.git', 'dist'],
},
// important: true,
corePlugins: {
container: false,
space: false,
divideStyle: false,
divideWidth: false,
divideColor: false,
divideOpacity: false,
// 涉及到通配符(*),wx 小程序不支持
ringWidth: false,
ringColor: false,
ringOpacity: false,
ringOffsetWidth: false,
ringOffsetColor: false,
},
// 样式类名黑名单,包含在里面的不会被 windicss 解析,可以用于过滤一些有问题的类名
blocklist: [],
shortcuts: {
// #region flex 布局
row: 'flex flex-row',
'row-x-start': 'row justify-start',
'row-x-center': 'row justify-center',
'row-x-end': 'row justify-end',
'row-x-between': 'row justify-between',
'row-x-around': 'row justify-around',
'row-x-evenly': 'row justify-evenly',
'row-y-start': 'row items-start',
'row-y-center': 'row items-center',
'row-y-end': 'row items-end',
'row-y-baseline': 'row items-baseline',
'row-y-stretch': 'row items-stretch',
'row-center': 'row justify-center items-center',
column: 'flex flex-col',
'column-x-start': 'column items-start',
'column-x-center': 'column items-center',
'column-x-end': 'column items-end',
'column-x-baseline': 'column items-baseline',
'column-x-stretch': 'column items-stretch',
'column-y-start': 'column justify-start',
'column-y-center': 'column justify-center',
'column-y-end': 'column justify-end',
'column-y-between': 'column justify-between',
'column-y-around': 'column justify-around',
'column-y-evenly': 'column justify-evenly',
'column-center': 'column justify-center items-center',
// #endregion
// 单行文字溢出显示省略号
'text-overflow-ellipsis': 'truncate',
// 使用绝对定位实现的「垂直居中」
'absolute-y-center': 'absolute top-1_2 transform -translate-y-1_2',
},
// windicss 会主动编译这里的样式,适用于一些不得不拼接类名的场景
safelist: ['border-bottom-c222-z1', 'border-bottom-ce6e6e6-z1', 'border-bottom-cfff-z1'],
theme: {
extend: {
colors: {
primary: '#222',
input: {
/** 输入框字体颜色 */
value: '#333a4e',
placeholder: '#bbb',
},
green: {
theme: '#27AE60',
},
},
transitionProperty: {
height: 'height',
spacing: 'margin, padding',
},
},
spacing: {
...generateSpacing(301),
},
boxShadow: {
vehicle: '0px 4px 14px 0px #0000001a',
},
backgroundColor: (theme) => ({
...theme('colors'),
}),
borderColor: (theme) => ({
...theme('colors'),
}),
fontSize: (theme) => theme('spacing'),
borderWidth: (theme) => theme('spacing'),
lineHeight: (theme) => theme('spacing'),
},
plugins: [
require('windicss/plugin/line-clamp'),
require(path.resolve(__dirname, 'config/plugins/windicss/border')),
require(path.resolve(__dirname, 'config/plugins/windicss/safe-area/index')),
],
});
这里再分享一个自己写的一像素边框插件,使用方法如下:
border-[all|top|bottom|left|right]-c[不带#的颜色hex="fff"]-w[border-width="1"]-r[border-radius="0"]-z[z-index="auto"]-o[opacity="1"]
- 例子1:
border-top-c222-w2-r10-z1000-o3==> 颜色为 #222,宽度为 2px,圆角为 10px,层级为 1000,透明度为 0.3 的上边框 - 例子2:
border-bottom-c222===> 颜色为 #222,宽度为 1px,圆角为 0,层级为 auto,透明度是 1 的下边框 - 例子3:
border-all-r16-c333===> 颜色为 #333,宽度为 1px,圆角为 16px,层级为 auto,透明度是 1 的全边框
import plugin from 'windicss/plugin';
const generateStyles = (className: string) => {
const matchRes = `${className}-`.match(/(?<=-).+?(?=-)/g) || [];
const styles = {
borderColor: '#fff',
borderWidth: '2px',
borderRadius: '0',
zIndex: 'auto',
opacity: '1',
};
matchRes.forEach((item) => {
const fir = item.charAt(0);
const val = item.substr(1, item.length);
switch (fir) {
case 'c':
styles.borderColor = `#${val}`;
break;
case 'w':
styles.borderWidth = `${+val * 2}px`;
break;
case 'r':
styles.borderRadius = `${+val * 2}px`;
break;
case 'z':
styles.zIndex = val;
break;
case 'o':
styles.opacity = `0.${val}`;
break;
}
});
return styles;
};
module.exports = plugin(function ({ addDynamic }) {
/** 全边框 */
addDynamic(
'border-all',
({ Utility, Style }) => {
const { borderColor, borderWidth, borderRadius, zIndex, opacity } = generateStyles(
Utility.raw.replace('border-all', ''),
);
return Style.generate(Utility.class, {
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: '0',
left: '0',
width: '200%',
height: '200%',
transform: 'scale(0.5)',
transformOrigin: 'left top',
boxSizing: 'border-box',
pointerEvents: 'none',
zIndex,
opacity,
border: `${borderWidth} solid ${borderColor}`,
borderRadius: `${borderRadius}`,
},
});
},
{
group: 'border-all',
completions: ['border-all-c-w-r-z-o'],
// 自定义 className
respectSelector: true,
},
);
/** 上/下/左/右边框 */
['top', 'bottom', 'left', 'right'].forEach((item) => {
const name = `border-${item}`;
addDynamic(
name,
({ Utility, Style }) => {
const { borderColor, borderWidth, zIndex, opacity } = generateStyles(Utility.raw.replace(name, ''));
return Style.generate(Utility.class, {
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
transform: ['top', 'bottom'].includes(item) ? 'scaleY(0.5)' : 'scaleX(0.5)',
transformOrigin: '0, 0',
pointerEvents: 'none',
zIndex,
opacity,
background: borderColor,
[item]: '0',
...(['top', 'bottom'].includes(item)
? {
left: '0',
right: '0',
height: borderWidth,
}
: {
top: '0',
bottom: '0',
width: borderWidth,
}),
},
});
},
{
group: name,
completions: [`${name}-c-w-z-o`],
// 自定义 className
respectSelector: true,
},
);
});
});
源码解析(tailwindcss)
从本质上看,tailwind 是一个 postcss 插件,它基于 postcss 对 css 进行处理,那我们先来了解下 postcss
什么 是 postCss?
通俗的讲法:postCss 就是一个开发工具,是一个用 JavaScript 工具和插件转换 CSS 代码的工具。支持变量,混入,未来 CSS 语法,内联图像等等。
它具备以下特性与常见的功能:
- 增强代码的可读性:Autoprefixer 自动获取浏览器的流行度和能够支持的属性,并根据这些数据帮你自动为 CSS 规则添加前缀。
- 将未来的 CSS 特性带到今天!:帮你将最新的 CSS 语法转换成大多数浏览器都能理解的语法,并根据你的目标浏览器或运行时环境来确定你需要的 polyfills
- 终结全局 CSS:CSS 模块 能让你你永远不用担心命名太大众化而造成冲突,只要用最有意义的名字就行了。
- 避免 CSS 代码中的错误:通过使用 stylelint 强化一致性约束并避免样式表中的错误。stylelint 是一个现代化 CSS 代码检查工具。它支持最新的 CSS 语法,也包括类似 CSS 的语法,例如 SCSS
- 可以作为预处理器使用,类似:Sass, Less 和 Stylus。但是 PostCSS 是模块化的工具,比之前那些快3-30 倍,而且功能更强大。并演化出了一系列的插件来使用。
postCss的核心原理/工作流
PostCSS 包括 CSS 解析器,CSS 节点树 API,一个源映射生成器和一个节点树 stringifier。
PostCSS 主要的原理核心工作流:
- 通过 fs 读取CSS文件
- 通过 parser 将CSS解析成抽象语法树(AST树)
- 将AST树”传递”给任意数量的插件处理
- 诸多插件进行数据处理。插件间传递的数据就是AST树
- 通过 stringifier 将处理完毕的AST树重新转换成字符串
这一系列的工作流,讲简单一点就是对数据进行一系列的操作。为此PostCSS提供了一系列的数据操作API。比如:walkAtRules ,walkComments,walkDecls,walkRules等相关API, 具体相关文档可查看: postcss官方API文档
使用 astexplorer 我们可以看到 postcss 将 css 处理为 AtRule 的结果
tailwindcss 工作流
我们写一个简单的测试用例来说明 tailwindcss 是如何运行的
import { run, html, css } from './util/run'
test('font-size utilities can include a default line-height', () => {
let config = {
content: [{ raw: html`<div class="text-md text-sm text-lg"></div>` }],
theme: {
fontSize: {
sm: '12px',
md: ['16px', '24px'],
lg: ['20px', '28px'],
},
},
}
return run('@tailwind utilities', config).then((result) => {
expect(result.css).toMatchFormattedCss(css`
.text-lg {
font-size: 20px;
line-height: 28px;
}
.text-md {
font-size: 16px;
line-height: 24px;
}
.text-sm {
font-size: 12px;
}
`)
})
})
入口文件 src/plugin.js
module.exports = function tailwindcss(configOrPath) {
return {
postcssPlugin: 'tailwindcss',
plugins: [
// ...,
...handleImportAtRules(),
async function (root, result) {
// ...
// MARK: 核心编译方法
await processTailwindFeatures(context)(root, result)
},
function lightningCssPlugin(_root, result) {
// ...
// MARK: Lightning CSS 是一个用 Rust 编写的极快的CSS 解析器、转换器和压缩器。
let transformed = lightningcss.transform({
filename: result.opts.from,
code: Buffer.from(intermediateResult.css),
minify: false,
sourceMap: !!intermediateMap,
targets: lightningcss.browserslistToTargets(browserslist(browsersListConfig)),
drafts: {
nesting: true,
customMedia: true,
},
nonStandard: {
deepSelectorCombinator: true,
},
include: Features.Nesting,
exclude: Features.LogicalProperties,
})
// MARK: 生成最后的 css 代码
let code = transformed.code.toString()
result.root = postcss.parse(license() + code, {
...result.opts,
map: intermediateMap,
})
// ...
},
// ...
].filter(Boolean),
}
}
我们可以看到,这里注册了名称为 'tailwindcss' 的 postcss 插件,其中:
- 在
handleImportAtRules方法中注册了postcssImport这个插件,它的作用是 允许在CSS文件中使用@import语句导入其他CSS文件 processTailwindFeatures为核心编译方法,其中context为编译上下文对象,存放一些临时缓存数据和工具函数;root是 postcss 处理后的根节点AST;result为最终输出的编译结果。
核心编译方法 src/processTailwindFeatures.js
// MARK: 构建上下文环境
export default function processTailwindFeatures(setupContext) {
return async function (root, result) {
// ...
// MARK: 构建上下文对象
let context = setupContext({
tailwindDirectives,
applyDirectives,
registerDependency(dependency) {
result.messages.push({
plugin: 'tailwindcss',
parent: result.opts.from,
...dependency,
})
},
createContext(tailwindConfig, changedContent) {
return createContext(tailwindConfig, changedContent, root)
},
})(root, result)
// ...
// MARK: 编译生成 AtRule 对象
await expandTailwindAtRules(context)(root, result)
// ...
}
}
我们可以看到,第一步需要构建出 context 对象,在编译 css 过程中用到的 cache 数据和 utils 方法都保存在这个对象里,然后将 context、root、result 一起传给 expandTailwindAtRules 方法,我们打个断点看下 context 里都有哪些属性:
我们挑几个重点的看下:
-
candidateRuleMap是一个样式映射关系对象,里面存放了所有类名和样式的映射关系,在这里面我们可以找到我们在测试用例中设置的text-md -
changedContent存放我们要解析的html内容 -
tailwindConfig存放tailwindcss的配置
src/lib/expandTailwindAtRules.js
export default function expandTailwindAtRules(context) {
return async (root) => {
let layerNodes = {
base: null,
components: null,
utilities: null,
variants: null,
}
// MARK: 识别引入了哪些 @tailwind 拓展
root.walkAtRules((rule) => {
if (rule.name === 'tailwind') {
if (Object.keys(layerNodes).includes(rule.params)) {
layerNodes[rule.params] = rule
}
}
})
if (Object.values(layerNodes).every((n) => n === null)) {
return root
}
// ...
// Find potential rules in changed files
let candidates = new Set([...(context.candidates ?? []), sharedState.NOT_ON_DEMAND])
let seen = new Set()
// MARK: 这里选择解析 html 使用 rust 还是 js
if (flagEnabled(context.tailwindConfig, 'oxideParser')) {
let rustParserContent = []
let regexParserContent = []
for (let item of context.changedContent) {
// MARK: 这里可以通过 config 自定义一些转换规则,默认是没有转换逻辑的
let transformer = getTransformer(context.tailwindConfig, item.extension)
// MARK: 用来提取 html 中的 class,可以自定义,默认是通过 rust 提取
let extractor = getExtractor(context, item.extension)
if (transformer === builtInTransformers.DEFAULT && extractor?.DEFAULT_EXTRACTOR === true) {
rustParserContent.push(item)
} else {
regexParserContent.push([item, { transformer, extractor }])
}
}
// MARK: 使用 rust 提取 html 中的 class
if (rustParserContent.length > 0) {
for (let candidate of parseCandidateStrings(
rustParserContent,
IO.Parallel | Parsing.Parallel
)) {
candidates.add(candidate)
}
}
// MARK: candidates 为 rust 解析结果
console.log('candidates ---> ', candidates)
} else {
// ...
}
// MARK: 生成 Rules
generateRules(sortedCandidates, context)
// MARK: 将 context.ruleCache 根据 layer 字段进行分类
if (context.stylesheetCache === null || context.classCache.size !== classCacheCount) {
context.stylesheetCache = buildStylesheet([...context.ruleCache], context)
}
// ...
// MARK: 这里将 utilities 的 rules 赋值给了 root 对象
if (layerNodes.utilities) {
layerNodes.utilities.before(
cloneNodes([...utilityNodes], layerNodes.utilities.source, {
layer: 'utilities',
})
)
layerNodes.utilities.remove()
}
// ...
// MARK: root.nodes 就是最后生成的 postcss Rules 了
console.log('root ---> ', root)
}
}
代码有些长,我们提取下核心步骤
- 构建
layerNodes对象,根据引入的css拓展来赋值不同的规则,比如这里我们引入了@tailwind utilities,因此layerNodes.utilities引入了对应的rules - 创建
transformer和extractor,这两个我们都可以自定义。默认的transformer不做任何转换,默认的extractor使用内置的rust算法 - 在
transformer和extractor都使用默认方法的前提下,windicss使用rust提取出context.changedContent数组中每一段html的类名,我们看下提取结果(我对 rust 实在不熟悉,里面源码就不写了,具体方法在oxide/crates/node/index.js) - 调用
generateRules方法,根据sortedCandidates生成对应的Rules并挂载到context.ruleCache属性中 - 将
context.ruleCache根据layer字段进行分类,根据layerNodes筛选只留下用到的Rules - 将
utilityNodes挂载到root.nodes对象中,继续传递给lightningCssPlugin插件,我们看下最终生成的root.nodes
src/lib/generateRules.js
function generateRules(candidates, context) {
let allRules = []
let strategy = getImportantStrategy(context.tailwindConfig.important)
// MARK: 循环遍历每个 candidate
/**
* 第一个 candidate 是 * ,看起来是匹配出了所有规则
*/
for (let candidate of candidates) {
if (context.notClassCache.has(candidate)) {
continue
}
if (context.candidateRuleCache.has(candidate)) {
allRules = allRules.concat(Array.from(context.candidateRuleCache.get(candidate)))
continue
}
// MARK: 核心 context.candidateRuleMap 去匹配每个 candidate 生成 Declaration,赋值给 allRules 和 context.ruleCache
let matches = Array.from(resolveMatches(candidate, context))
if (matches.length === 0) {
context.notClassCache.add(candidate)
continue
}
context.classCache.set(candidate, matches)
let rules = context.candidateRuleCache.get(candidate) ?? new Set()
context.candidateRuleCache.set(candidate, rules)
for (const match of matches) {
let [{ sort, options }, rule] = match
if (options.respectImportant && strategy) {
let container = postcss.root({ nodes: [rule.clone()] })
container.walkRules(strategy)
rule = container.nodes[0]
}
let newEntry = [sort, rule]
rules.add(newEntry)
context.ruleCache.add(newEntry)
allRules.push(newEntry)
}
}
return allRules
}
generateRules 会遍历每一个 candidate,在 resolveMatches 方法中使用context.candidateRuleMap 去匹配每个 candidate 生成 Declaration,赋值给 allRules 和 context.ruleCache
lightningCssPlugin
function lightningCssPlugin(_root, result) {
// ...
// MARK: 通过 postcss 的 toResult 生成样式字符串
let intermediateResult = result.root.toResult({
map: map ? { inline: true } : false,
})
// MARK: Lightning CSS 是一个用 Rust 编写的极快的 CSS 解析器、转换器和压缩器。
let transformed = lightningcss.transform({
filename: result.opts.from,
code: Buffer.from(intermediateResult.css),
minify: false,
sourceMap: !!intermediateMap,
targets: lightningcss.browserslistToTargets(browserslist(browsersListConfig)),
drafts: {
nesting: true,
customMedia: true,
},
nonStandard: {
deepSelectorCombinator: true,
},
include: Features.Nesting,
exclude: Features.LogicalProperties,
})
// MARK: 生成最后的 css 代码
let code = transformed.code.toString()
result.root = postcss.parse(license() + code, {
...result.opts,
map: intermediateMap,
})
// ...
}
终于到了最后一步~
- 通过 postcss 的
toResult方法生成样式字符串 - 使用
lightningcss,对 css 进行一批操处理,比如「生成 sourceMap」、「自动增加厂商前缀」等 - 最终使用
code.toString()方法生成终版样式字符串,挂载到result.root上输出
总体流程
我们用流程图的方式将整体流程串起来
结语
有人说原子化 css 的出现是历史倒退,因为它看起来跟直接写 style 一样。从表面上确实如此,但能用如此看似原始的方式实现精美的界面,用「大道至简」来形容它再合适不过了~
原创文章,如有写错的地方欢迎指正~