本文主要是讨论自动实现原子化 CSS 的实现方案,需要一些 VSCode 相关知识。
项目 Git 地址:github.com/balabalapup…
项目背景
组内 CSS 使用自定义的原子化样式表,但是这种样式表共同的缺点都是存在一定的记忆负担,对于新人影响更深。
而且这种颗粒度很小的约定在实际开发中经常会出现问题,因此比较蠢的办法就是先在 CSS 中把记不住的样式写在里面,等样式完成时再依次填充到模板中。我估计相当一部分用户在不熟悉原子样式表时都是这么写的,或者去翻文档依次对照。
vscode-auto-atomic-css 就是一个可以自动将 <style>
标签中的样式填充到页面 HTML 元素中的 VSCode 插件,通过这个插件可以用户完全不用关注原子样式到底有多少,一切都交给插件。
为什么难用还要用原子化 CSS?
强制一致性
在小团队或者约束能力较弱的后台产品中会出现很多 margin
/padding
边界模糊的情况,通过原子化统一规范可以限制成员对 CSS 的使用,并且如果设计团队有成熟 & 很少变动的设计规范,那么原子化方案应该是一种最高效的 CSS 方案了。而且就算是体量较大的公司也会在不经意间产出不同 CSS。
- GitLab: 402 text colors, 239 background colors, 59 font sizes
- Buffer: 124 text colors, 86 background colors, 54 font sizes
- HelpScout: 198 text colors, 133 background colors, 67 font sizes
- Gumroad: 91 text colors, 28 background colors, 48 font sizes
- Stripe: 189 text colors, 90 background colors, 35 font sizes
- GitHub: 163 text colors, 147 background colors, 56 font sizes
- ConvertKit: 128 text colors, 124 background colors, 70 font sizes
尤其是在以往大量使用的 BEM 规范中,随着项目体积增大,这种情况基本都会或多或少出现。可以大概对比一下数据,facebook 首页重构之后只有72kb。可以看一下下面三家公司 CSS 的文件大小和 CSS 中类的数量。
CSS 规范有很多种解决方案,我们可以通过固定的 UI 框架来达成一个样式的统一,但是那也不是一个最好的解决方案。原子化 CSS 实现的就是 HTML 和 CSS 的解耦。通俗来讲就是将未来的样式用已经定义好的 class 来书写。
CSS 的“关注点分离”
When you think about the relationship between HTML and CSS in terms of "separation of concerns", it's very black and white.
CSS 和 HTML 的关系大致可以分为两种形态
- 关注点分离(CSS 依赖于 HTML)
-
根据具体内容来定义类的名字,这种方式其实可以看作一种 HTML 控制的钩子,通过这个钩子将 CSS 样式关联进来,由此产生的 CSS 是一种不独立的状态,HTML 不用关心具体的样式,由 CSS 在钩子上编写来决定。
-
所以这种情况下, HTML 完全可以重新设置一个钩子,并且 CSS 基本无法复用。
- 混合关注点 (HTML 依赖于 CSS)
-
不根据内容来定义类名,CSS 类与内容无关,这样可以看做由 CSS 来决定钩子。HTML 在创建时去选择钩子,可以把这种情况看成一种粒度更细的 CSS 类来实现大量复用。
-
此时 HTML 就不是独立的了,因为编写 HTML 时我要清晰了解都有什么钩子类可以让我适用,我把他们组合起来完成样式。
混合关注点的极限目前就是原子化 CSS,彻底将样式钩子交给 CSS。
有关关注点分离的思想可以考虑 Vue 和 React 中 HTML 书写的区别。Vue 中的模板语法就是以典型的关注点分离。
Utility-first & Component-first
Building complex components from a constrained set of primitive utilities.
其实这个标题不是很准确,除了实用优先外,我们的解决方案绝对不是与组件内容强相关这么简单的对立关系,在 CSS 发展的各种阶段,衍生出了各种 CSS 设计模式,比如 OOCSS、BEM 等等的分层概念,感兴趣可以关注以下 CSS 各阶段发展。
实用优先这个概念也是目前 CSS 框架最优先的关注点。Tailwind 核心功能第一句话就在讨论实用工具优先,这里没必要去讨论更多的原子化优劣了。具体可以参考官网。
即使在 Utility-first 思维下,我们也可以在定义组件时将创建的原子样式类划归到一个概念类中。这个行为也是现在 CSS 广泛使用的,比如 Tailwind 中的提取组件章节。
技术选型
既然决定要把 <style>
标签中的类一键导出,那就要想哪个过程可以执行插入逻辑,这里大致有两种插入方式:
webpack loader
首先要明确 webpack 是可以承接这项需求的,这里参考了 broke-css 的实现方案。把样式写在模板中,等编译时通过 loader 转译出来。
这个方案确实可以实现目的,不过这里有几点需要思考
-
把页面修改样式的控制权完全交给 loader 进行是否合适,一旦发生误删情况该怎么处理?
-
有一些类可能是其他文件公用的公共类,这些类在编译时如何处理?
而且样式处理的控制权交给 loader 来处理总归担心影响线上环境,如果为了一个样式修复插件导致线上样式错乱,那也是得不偿失。
VSCode 插件
所以 vscode-auto-atomic-css 通过 VSCode 实现的自动原子化,这里最后选择了可以针对每个单独的 CSS 类做单独的原子化改造方案来保障修复独立性。其次如果插件存在异常,不至于影响线上环境,哪里错了改哪里。
auto-atomic-css 设计思路
既然要原子化,那就要先找到原子样式存储的位置,把样式都拿出来先转化成方便查找的对象格式,假设因为原来的原子样式都是如下这种形式
.fz-12{
font-size: '12px'
}
复制代码
这种格式是很难查找具体属性的,比如我们要找键为 font-size
,值为 12 的字体大小,就要去每一个对象中查找是否是 font-size
值为 12,再把对象名取出。如果改造成下面的格式在查找时就可以轻松取出。
原子化样式表取出来之后,就要开始 VSCode 插件开发了。这里当鼠标点击到 <style>
标签中的具体 class
时,才会自动原子化,所以这里要用到 VSCode 提供的代码操作程序 provideCodeActions。这个函数在下文会细说。
还需要考虑点击的如果是嵌套类,内层的子类也需要转化,所以这里需要做一个深层次嵌套的对象。这样在样式替换时就会将当前类中的子类一起替换到 HTML 中。
有了当前类的样式,有了原子样式,接下来就是怎么改造模板语法中的 html 代码了。
我们可以用通过确定首尾 <template>
</template>
标签的位置来大致预测出当前页面的 HTML 范围,通过 document.getText(templateRaneg)
将这部分 HTML 完整取出,这样就可以把 HTML 转换成 AST 树结构。 最后只需要递归遍历 AST 树,分层次查找 class
是否在处理过的 CSS 对象中有对应的类,再依次替换即可。
auto-atomic-css 代码逻辑
一键原子化需要解决的技术问题主要有以下几点:
- 需要将
style
标签中的 CSS 样式转换成适合查找的对象形式 style
标签中 CSS 类的嵌套问题,Less/Sass 这些 CSS 扩展语言都支持 CSS 的深层嵌套
- 用户聚焦上层 CSS 类时,需要将当前类里面包含的内部类一起改造。
- 用户聚焦底层 CSS 类时,需要确定当前类的作用域范围,避免替换到 HTML 中发生错误覆盖。
- HTML 中类的嵌套问题
- HTML 标记语言在浏览器中会被转换成 AST,如何把嵌套的 CSS class 放入也存在嵌套关系的 HTML 标签中
VScode 插件代码逻辑都是从 activate
开始,auto-atomic-css 是在 activate 中以 provideCodeActions
作为入口。用户改变焦点时 provideCodeActions
都会传入新的用户焦点 Range
。
provideCodeActions | |
---|---|
输入 | 描述 |
文档:TextDocument | 命令被调用的文档。 |
范围:Range 、 Selection | 调用命令的选择器或者范围。 |
上下文:CodeActionContext | 携带附加信息的上下文。 |
令牌:CancellationToken | 取消令牌。 |
返回值 | 描述 |
ProviderResult<CommandT[]> | 例如快速修复或重构的一系列代码操作。 |
// src/extension.ts
export async function activate(context: vscode.ExtensionContext) {
const actionsProvider = vscode.languages.registerCodeActionsProvider(
"vue",
new AutoAtomicCss(),
{
providedCodeActionKinds: [vscode.CodeActionKind.QuickFix],
}
);
context.subscriptions.push(actionsProvider);
}
export class AutoAtomicCss implements vscode.CodeActionProvider {
provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range
): vscode.CodeAction[] {
// 1. 判断当前焦点是不是 style 中的类
if(!isAtStartOfSmiley(document, range)) return;
// ...
}
}
export function isAtStartOfSmiley(
document: vscode.TextDocument,
range: vscode.Range
) {
const start = range.start;
const line = document.lineAt(start.line);
const { text = "" } = line;
var reg = /^.[(\w)-]+\s{$/;
return reg.test(text.trim());
}
复制代码
provideCodeActions
中需要实现以下几个目的
- 获取当前焦点类以及其包含类的完整 CSS 样式代码,最后在将获取的样式转换成对象格式。
- 根据原子样式问价你的存储路径获取到原子样式文件,将公共文件内的原子类转换成方便查找的对象格式。
- 用原子类对象将当前类及嵌套类中符合条件的样式键值对做替换。
- 将 HTML 转换成 AST 结构,递归查找 HTML 中可以原子化的标签。
- 编辑修复逻辑,输出原子化之后的 HTML 结构和 CSS 结构,替换原文本内容。
这里需要重点介绍几个关键步骤
焦点类转换成对象
获取当前焦点类的完整样式需要确定他的上下文关系,假设当前我们点击的是 demo-div
类,向上查找是因为要确定 demo-div
的作用域范围,向下查找是要确定 demo-div
内部还包含多少个其他的嵌套类,auto-atomic-css 会将他们一起替换。
目前版本只做了向下递归,要做到向上查找需要截取整个 style
标签,将所有 CSS 转换成对象并缓存起来,思路是一致的。
// 获取焦点类的完整 css 样式
function getClassInStyle(document, range) {
let lineCount = range.start.line;
while() {
const { text: _text } = document.lineAt(lineCount);
// 没找到当前 class 的尾部就不断往下一行解析
lineCount ++;
}
// 找到尾结点, 输出当前范围
let newRange: vscode.Range = new vscode.Range(
range.start.translate(0, -range.start.character),
range.end.translate(styleLineCount - range.end.line - 1, 0)
);
return newRange
}
// 将焦点类截取去来的字符串转换成对象
function handleSplitNameAndAttribute(resultText: string){
// .class { prop: value} TO ",class": { "prop": "value" }
const styleObject: DeepObjectType = transJSONText.replaceAll(/.../, ...);
return styleObject;
}
复制代码
公共原子类对与焦点类做对比
首先要将公共原子类类转换成方便查找的格式,再递归选中的 CSS 类转换的对象,依次插入进去即可。把修改过的类存入 fixedClassName
中。
function dfsConvertCSS(cssObjet, styleObject) {
const analyzedClassLabel: AnalyzedClassLabelType = {
fixedClassName: name, // 修复后生成的类名字符串 e.g. '.demo fz-14 mr-8 ml-8'
notFixedCss: {}, // 与公共原子类对比之后仍然找不到原子样式的类,这部分要继续留在 css 中
children: {}, // 焦点类中的子节点
};
// currentLayerCSS: 当前层次中的 css 类名结构 e.g. {.demo1: {}, .demo2: {}}
currentLayerCSS.forEach((_item) => {
const key = cssObjet[_item];
// 查找当前样式属性在公共样式库中是否能对比到
if (key in styleStore[_item]) {
// 有对比结果,将结果放入 fixedClassName
analyzedClassLabel.fixedClassName =
analyzedClassLabel.fixedClassName.concat(
styleStore[_item][key]
);
} else {
// 没有结果,存入 notFixedCss
analyzedClassLabel.notFixedCss[_item] = key;
}
});
}
复制代码
HTML 字符串互相转换
替换 HTML 的方式有很多,这里主要使用的是 HTML 转换成 AST。
用对比结果和 HTML 生成的 AST 结构组织修复逻辑。
const edit = new vscode.WorkspaceEdit();
// classInStyleRange: style css 的修复结果
edit.replace(document.uri, classInStyleRange, resultString);
createFix(...);
function createFix(
document: vscode.TextDocument, // vscode 上下文
textEditor: vscode.TextEditor, // 当前打开的文件编辑器
convertedCssStyle: ConvertedCssType, // 对比结果
edit: vscode.WorkspaceEdit // fix.edit 修复逻辑存放的位置
){
// 1. 获取 template 标签的范围。
const templateRange = new vscode.Range(
new vscode.Position(sl, 0),
new vscode.Position(el + 1, 0)
);
// 2. 将 template 标签产生的字符转转换成 AST 结构。
const currentPageTemplace = document.getText(templateRange);
const htmlToAst: AstType[] = _HTML.parse(currentPageTemplace);
// 3. 递归 AST,将 AST 中的类与结果对象对比,找到合适的 class 直接修改
handleChangeHtmlAst(htmlToAst[index], targetMainAttribute, targetMainClass);
// 4. AST 结果转换回字符串
const astToString = _HTML.stringify(htmlToAst);
// 5. 把结果放入编辑器中
edit.replace(document.uri, templateRange, astToString);
return edit;
}
复制代码
输出修复逻辑
这里用 WorkspaceEdit 的编辑替换逻辑即可。
new vscode.WorkspaceEdit().replace | |
---|---|
范围 | 描述 |
uri: Uri | 资源标识,可以从 document.uri 中获取 |
range: Range | 修改的范围 |
newText: string | 修改的文本 |
provideCodeActions(document: vscode.TextDocument, range: vscode.Range ): vscode.CodeAction[] {
const edit = new vscode.WorkspaceEdit();
edit.replace(document.uri, classInStyleRange, resultString);
const replaceWithSFixedStyle: vscode.WorkspaceEdit = createFix(...);
const fix = new vscode.CodeAction('this class can convert to atomic css',vscode.CodeActionKind.QuickFix);
fix.edit = replaceWithSFixedStyle;
return [fix];
}
复制代码
结语
至此 vscode-auto-atomic-css 的执行逻辑就说完了,目前 vscode-auto-atomic-css 还是 0.0.1 版本,如果本人不忙的话大概月底可以处理好现在个人测试的 Bug。当前的 Bug 点主要有以下几点:
- HTML AST 生成
string
字符串时,有一部分换行符失效,可以暂时通过 ESLint 或 Prettier 修复。 style
class 标签中向上查找范围的逻辑还没有写完,不过很快。
后续如果需要扩展的话就要考虑 style
中的选择器那些需要覆盖,不过本身来讲这些 style
标签中的产物都是中间产物,原子化之后这些类是不需要存在的,所以覆盖还是不覆盖选择器都不影响功能本身。
原子化的修复逻辑因人而异,有问题欢迎评论区补充。