VSCode 实现自动原子化 CSS 样式

本文主要是讨论自动实现原子化 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 实现的就是 HTMLCSS 的解耦。通俗来讲就是将未来的样式用已经定义好的 class 来书写。

CSS 的“关注点分离”

When you think about the relationship between HTML and CSS in terms of "separation of concerns", it's very black and white.

CSSHTML 的关系大致可以分为两种形态

  1. 关注点分离(CSS 依赖于 HTML
  • 根据具体内容来定义类的名字,这种方式其实可以看作一种 HTML 控制的钩子,通过这个钩子将 CSS 样式关联进来,由此产生的 CSS 是一种不独立的状态,HTML 不用关心具体的样式,由 CSS 在钩子上编写来决定。

  • 所以这种情况下, HTML 完全可以重新设置一个钩子,并且 CSS 基本无法复用。

  1. 混合关注点 (HTML 依赖于 CSS
  • 不根据内容来定义类名,CSS 类与内容无关,这样可以看做由 CSS 来决定钩子。HTML 在创建时去选择钩子,可以把这种情况看成一种粒度更细的 CSS 类来实现大量复用。

  • 此时 HTML 就不是独立的了,因为编写 HTML 时我要清晰了解都有什么钩子类可以让我适用,我把他们组合起来完成样式。

混合关注点的极限目前就是原子化 CSS,彻底将样式钩子交给 CSS

有关关注点分离的思想可以考虑 VueReactHTML 书写的区别。Vue 中的模板语法就是以典型的关注点分离。

Utility-first & Component-first

Building complex components from a constrained set of primitive utilities.

其实这个标题不是很准确,除了实用优先外,我们的解决方案绝对不是与组件内容强相关这么简单的对立关系,在 CSS 发展的各种阶段,衍生出了各种 CSS 设计模式,比如 OOCSSBEM 等等的分层概念,感兴趣可以关注以下 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 代码逻辑

一键原子化需要解决的技术问题主要有以下几点:

  1. 需要将 style 标签中的 CSS 样式转换成适合查找的对象形式
  2. style 标签中 CSS 类的嵌套问题,Less/Sass 这些 CSS 扩展语言都支持 CSS 的深层嵌套
  • 用户聚焦上层 CSS 类时,需要将当前类里面包含的内部类一起改造。
  • 用户聚焦底层 CSS 类时,需要确定当前类的作用域范围,避免替换到 HTML 中发生错误覆盖。
  1. HTML 中类的嵌套问题
  • HTML 标记语言在浏览器中会被转换成 AST,如何把嵌套的 CSS class 放入也存在嵌套关系的 HTML 标签中

VScode 插件代码逻辑都是从 activate 开始,auto-atomic-css 是在 activate 中以 provideCodeActions 作为入口。用户改变焦点时 provideCodeActions 都会传入新的用户焦点 Range

provideCodeActions
输入 描述
文档:TextDocument 命令被调用的文档。
范围:RangeSelection 调用命令的选择器或者范围。
上下文: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 中需要实现以下几个目的

  1. 获取当前焦点类以及其包含类的完整 CSS 样式代码,最后在将获取的样式转换成对象格式。
  2. 根据原子样式问价你的存储路径获取到原子样式文件,将公共文件内的原子类转换成方便查找的对象格式。
  3. 用原子类对象将当前类及嵌套类中符合条件的样式键值对做替换。
  4. HTML 转换成 AST 结构,递归查找 HTML 中可以原子化的标签。
  5. 编辑修复逻辑,输出原子化之后的 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 字符串时,有一部分换行符失效,可以暂时通过 ESLintPrettier 修复。
  • style class 标签中向上查找范围的逻辑还没有写完,不过很快。

后续如果需要扩展的话就要考虑 style 中的选择器那些需要覆盖,不过本身来讲这些 style 标签中的产物都是中间产物,原子化之后这些类是不需要存在的,所以覆盖还是不覆盖选择器都不影响功能本身。

原子化的修复逻辑因人而异,有问题欢迎评论区补充。

猜你喜欢

转载自juejin.im/post/7077814044152823838