超爽!使用 VSCode 插件实现自动原子化 CSS 样式

5,045 阅读14分钟

本文主要是讨论自动实现原子化 CSS 的实现方案,需要一些 VSCode 相关知识。

项目 Git 地址:github.com/balabalapup…


2022-04-01更新

marketplace.visualstudio.com/items?itemN… 项目已放在 VScode 市场中,后期考虑兼容 Tailwind CSS 扩大适用性,一些小更新直接看 readme吧。

2022-03-26更新

有关 HTML 解析最后还是选择了 parse5,比较稳定是一方面,另一方面是因为用 parse5 也能稳定解析出 Vue 模板中 class 的位置。具体方法已经更新在下文 HTML AST 解析与生成操作 章节中。

当前进度

  1. Vue2 中基本语法和类写法中的 bug 都解决了。等哪天有空扔到 VSCode 里面。Vue3 的模板语法还没有通过测试。
  2. React 由于大部分样式和 JSX 都不是写在一个文件中的,所以这个本插件无解。不过可以参考这个思路在手动插入 JSX 文件路径也可以解决。

image.png

项目背景

组内 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 设计思路

这个插件的实现思路其实很简单,至于要 HTMLCSS 两种 AST 解析树互相操作即可,具体思路我们慢慢看。

既然要原子化,那就要先找到原子样式存储的位置,我们小组内样式表都是用 LESS 预编译存储的,所以这里首先要把 LESS 文件转换成 CSS 文件,然后为了能更好的操作 CSS 我们还需要把 CSS 解析成 AST 树结构。

然后我们需要把 CSSAST 结构转换成更方便查找的样式,这里举个例子,假设因为原来的原子样式都是如下这种形式↓

.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 代码逻辑

image.png

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

  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[]>例如快速修复或重构的一系列代码操作。

框架一共就四个步骤:

  • auto-aotmic-css 适用于 VUE 的框架,所以要规范语言
  • 注册 auto-aotmic-css 命令
  • 打开目录定位公共样式位置
  • 把注册的命令激活
// src/extension.ts
export async function activate(context: vscode.ExtensionContext) {
    // 1. auto-aotmic-css 适用于 vue 的插件
  const actionsProvider = vscode.languages.registerCodeActionsProvider(
    "vue",
    new AutoAtomicCss(),
    {
      providedCodeActionKinds: [vscode.CodeActionKind.QuickFix],
    }
  );
  // 2. 注册 auto-aotmic-css 命令
  const disposable = vscode.commands.registerCommand(
    "auto-atomic-doing",
    () => {
      // Display a message box to the user
      vscode.window.showInformationMessage("auto-atomic-css start!");
      // 3. 打开目录定位公共样式位置
      vscode.window.showOpenDialog({
          // ...
        })
        .then((msg) => {
          // ...
          4. 把注册的命令激活
          context.subscriptions.push(actionsProvider);
        });
    }
  );
  context.subscriptions.push(disposable);
}
// 要激活的命令类
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
}
// 将焦点类截取去来的字符串转换成 ast 对象
/**
 * Convert the current style sheet in string form into js-readable object form output,
 * which is convenient for subsequent style processing.
 * @param resultText String format stylesheet for the current class in css / 截取的 css 字符串
 * @returns
 */
export function parseCurrentCSS({ resultText }: { resultText: string }): {
  outputClassName: string;
  transOutputStyleObject: DeepObjectType;
} {
  // .class { prop: value} TO ",class": { "prop": "value" } 
  const styleObject: DeepObjectType = transJSONText.replaceAll(/.../, ...); 
  return styleObject;
}

获取公共原子类

确定公共样式位置

我们需要先确定原子样式位置再启用插件,这样能保证 monorepo 仓库中也不会选择错误的样式库。

vscode.window.showOpenDialog({
  // 可选对象
  canSelectFiles: true, // 是否可选文件
  canSelectFolders: false, // 是否可选文件夹
  canSelectMany: true, // 是否可以选择多个
  defaultUri: vscode.Uri.file("/D:/"), // 默认打开本地路径
  openLabel: "set the address of common atomic entry",
})
.then((msg) => {
  if (!msg) return;
  entryPath = msg[0].path;
  context.subscriptions.push(actionsProvider);
});

解析 LESS 文件

因为组内使用了 LESS 预编译,所以这里我们可以使用 LESS 自带的 render 函数,不过这里有一个坑,我们需要在 less.render 第二个参数设置 filename: path.resolve(...),因为相当一部分样式表中都是用了 @import 引入其他 CSS 文件(具体问题可以跳转 这里),通过这个步骤的操作,我们就将多个样式文件改造成了 CSS 样式。

/**
 * read the result of less.render() and write result into root
 */
less.render(
  currentStyleFile.toString(),
  { filename: path.resolve(ROOTPATH, `./${ROOTNAME}`) },
  (err: string, data: CSSTYPE) => {
    const dirRoot = ATOMICPATH + TARGETPATH;
    fs.writeFile(dirRoot, data.css, (err: any) => {
      resolve("success");
    });
  }
);

CSS AST 解析

这里很多现成的库,我在这里使用的是 read-css 这个解析包。把原子样式转换成 ast 结构之后就很好操作成我们想要的格式了。

const read = require("read-css");
/**
 * Read the less atomic css style sheet through less.render and convert it into css style sheet,
 * and then return it into ast format through read-css.
 * @returns Promise<DeepObjectType> the DeepObjectType is a css ast construct
 */
export default async function getCommonStyle(
  entry: string
): Promise<DeepObjectType> {
  return await new Promise(async (resolve, reject) => {
    // ...
    const dirRoot = ATOMICPATH + TARGETPATH;
    read(dirRoot, (err: Error, data: ReadCssType) => {
      const res: DeepObjectType = handleCallback(data);
      if (err) reject(err);
      resolve(res);
    });
  });
}

CSS AST 转换

这里我们需要把 AST 转换成我们需要的格式, 上文的图片这里在水一遍

/**
 * @param data the result of read-css ast structure,
 * we also use the declarations params to generate a resvered constructure
 * @returns a resvered constructure that the property values are before their class names
 */
function handleCallback(data: ReadCssType): DeepObjectType {
  const styleStore: DeepObjectType = {};
  if (!data.stylesheet) return styleStore;
  data.stylesheet.rules.forEach((item: ReadCssStyleRuleType) => {
    const { declarations, selectors } = item;
    declarations.forEach((_item: ReadCssStyleDeclarationsType) => {
      if (_item.type !== "declaration") return;
      const { property, value } = _item;
      if (styleStore[property]) {
        styleStore[property][value] = selectors[0].split(".")[1];
      } else {
        styleStore[property] = {
          [value]: selectors[0].split(".")[1],
        };
      }
    });
  });
  return styleStore;
}

公共原子类对与焦点类做对比

我们将 less 文件提取的 css 修改成 ast 结构之后操作 css就简单多了,这里我们

/**
 * Convert the current css ast structure, because the style sheet has a nested relationship,
 * this function needs to distinguish whether the current value is a style attribute or the class name of the next level.
 * @param param0
 * @returns TransferCSSDataByCommonCssConfigType
 */
//name                  修复后生成的类名字符串 e.g. '.demo fz-14 mr-8 ml-8'
//config                与公共原子类对比之后仍然找不到原子样式的类,这部分要继续留在 css 中, config就是 css 的A ST 树对象。
//commonStyleList       焦点类中的子节点
export function translateCurrentCSS({
  name, 
  config,
  commonStyleList,
}: {
  name: string;
  config: DFSObjectType; 
  commonStyleList: DeepObjectType; 
}): TransferCSSDataByCommonCssConfigType {
  const transferCSSDataByCommonCssConfig: TransferCSSDataByCommonCssConfigType =
    {
      fixedClassName: [name],
      notFixedCss: {},
      children: {},
    };
    // 当前层级 css
  const currentLayerStyle = Reflect.ownKeys(config).filter(
    (item) => !item.toString().includes(".")
  ) as string[];
  // 下一层级 css
  const nextLayerStyle = Reflect.ownKeys(config).filter((item) =>
    item.toString().includes(".")
  ) as string[];
  // 判断当前层的每个 css 属性是否在公共样式表中出现,分类保存
  currentLayerStyle.forEach((item: string) => {
    const currentLayerStyleKey = config[item] as string;
    if (commonStyleList[item] && commonStyleList[item][currentLayerStyleKey]) {
      transferCSSDataByCommonCssConfig.fixedClassName.push(
        commonStyleList[item][currentLayerStyleKey]
      );
    } else {
      transferCSSDataByCommonCssConfig.notFixedCss[item] = currentLayerStyleKey;
    }
  });
  // 递归遍历子层级 class
  nextLayerStyle.forEach((item: string) => {
    const currentLayerStyleKey = config[item] as DFSObjectType;
    transferCSSDataByCommonCssConfig.children[item] = translateCurrentCSS({
      name: item,
      config: currentLayerStyleKey,
      commonStyleList,
    });
  });
  return transferCSSDataByCommonCssConfig;
}

HTML AST 解析与生成操作

替换 HTML 的方式有很多,这里主要使用的是 HTML 转换成 AST

用对比结果和 HTML 生成的 AST 结构组织修复逻辑。

这里考虑再三还是选择了 parse5 来解析 HTML 成 AST,首先他的稳定性是不需要质疑的,并且经过测试解析速度也比较满意,最关键的是它根据的 class 具体定位足以满足我们的需求。

  1. 递归遍历 AST 结构。
  2. 我们通过 parse5 解析的 AST 结构来获取每一个层级中类的位置。
  3. 判断当前层级的类是否有我们要修改的 CSS 样式类。
  4. 命中则修改当前层级类,继续遍历 CSS 样式类的下一层级。
  5. 不管有没有命中都继续递归遍历 AST 直到结束。
/**
 * We use parse5 to convert html and generate ast structure, we need to use the class positioning in this ast structure
 * @param edit vscode editor
 * @param param1 currentPageTemplace is the content in the intercepted template tag,
 *  the convertedCssStyle is the converted css object
 * @param document VScode context
 */
export function handleHTMLBuParse5<T extends ASTType>(
  edit: vscode.WorkspaceEdit,
  { currentPageTemplace, convertedCssStyle }: T,
  document: vscode.TextDocument
) {
    // 通过 parse5 解析 HTML, 赶紧把他们转换了送到下一步递归
  const text: Document = parse(currentPageTemplace, {
    sourceCodeLocationInfo: true,
  });
  deepSearchASTFindAttribute(
    edit,
    text.childNodes[0],
    convertedCssStyle,
    document
  );
}

拿到 AST 之后我们需要做两步。

  1. 判断当前层级中有没有命中的类,有就处理这个类。
  2. 继续递归 AST 结构来往下查找。
/**
 * determine if the class in the current hierarchy is the class we want to modify,
 * if so, process this hierarchy.
 * In addition, no matter whether the class is hit or not, we have to keep recursing the ast structure to find the next level
 * where we can do some pruning optimization
 * @param edit vscode editor
 * @param currentNode AST childNode
 * @param convertedCssStyle the converted css object
 * @param document VScode context
 */
function deepSearchASTFindAttribute(
  edit: vscode.WorkspaceEdit,
  currentNode: any,
  convertedCssStyle: ConvertedCssStyleType,
  document: vscode.TextDocument
) {
// 拆分当前层级,开始查找 class 是否符合我们 css 中的类
  if (currentNode.attrs) {
    currentNode.attrs.forEach((item: any) => {
      if (item.name !== "class") return;
      const _classList = Reflect.ownKeys(convertedCssStyle); // { xxx: {a: 1}, ccc: {b: 3}}  [xxx, ccc]
      const _currentClass = item.value.split(" "); // ['xxx', 'xx1', 'xx2']
      _classList.forEach((_item) => {
        if (typeof _item !== "string") return;
        const _name = _item.split(".")[1];
        if (!_currentClass.includes(_name)) return;
        transferClassAttribute(
          edit,
          currentNode,
          _name,
          convertedCssStyle[_item],
          document
        );
      });
    });
  }
  // 递归 AST,不过这里递归结构主要有 childrenNode 和 content 两种,这里可以按顺序遍历,因为 content 一般都是虚拟节点,比如 template
  if (currentNode.childNodes.length > 0) {
    currentNode.childNodes.forEach((node: any) => {
      if (node.nodeName === "#text") return;
      deepSearchASTFindAttribute(edit, node, convertedCssStyle, document);
    });
  } else if (currentNode.content) {
    const childNodes = currentNode.content.childNodes;
    childNodes.forEach((node: any) => {
      if (node.nodeName === "#text") return;
      deepSearchASTFindAttribute(edit, node, convertedCssStyle, document);
    });
  }
}

最后我们将 edit 修复函数传入,将修改好的类放入 edit 中即可,非常简单。


/**
 * take out the current html structure,
 * now extract the current level and change it to the correct class, and then go down recursively
 */
function transferClassAttribute(
  edit: vscode.WorkspaceEdit,
  currentNode: any,
  currentStyleName: string,
  currentStyleLayerAttribute: TransferCSSDataByCommonCssConfigType,
  document: vscode.TextDocument
) {
  // 1. 拿到当前类的具体位置 
  const { startLine, startCol, endLine, endCol } =
    currentNode.sourceCodeLocation.attrs.class;
  // 2. 通过 range 生成当前类的范围对象
  const classRange = new vscode.Range(
    new vscode.Position(startLine - 1, startCol - 1),
    new vscode.Position(endLine - 1, endCol - 1)
  );
  // 4. 下面都是 class 的替换逻辑
  const currentNodeClass = currentNode.attrs.find(
    (item: any) => item.name === "class"
  );
  const currentNodeClassList = currentNodeClass.value.split(" ");
  const currentNodeClassIndex = currentNodeClassList.findIndex(
    (item: string) => item === currentStyleName
  );
  currentNodeClassList[currentNodeClassIndex] =
    currentStyleLayerAttribute.fixedClassName.join(" ");
  // 3. 把替换逻辑插入 edit 中
  edit.replace(
    document.uri,
    classRange,
    `class="${currentNodeClassList.join(" ")}"`
  );
}

CSS AST 生成字符串 & 输出修复逻辑

这里用 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];
 }

结语

image.png

至此 vscode-auto-atomic-css 的执行逻辑就说完了,目前 vscode-auto-atomic-css 还是 0.0.1 版本在个人测试中。 后续如果需要扩展的话就要考虑 style 中的选择器那些需要覆盖,虽然 CSS 也可以很简单的把他们转换成 AST 结构,不过本身来讲这些 style 标签中的产物都是中间产物,原子化之后这些类是不需要存在的,所以覆盖还是不覆盖选择器都不影响功能本身。

原子化的修复逻辑因人而异,有问题欢迎评论区补充。3/26 已经完成个人测试,如果不忙的话预计月底可以送他去 VSCode 下载。