背景
最近在业务开发过程中,发现有一些框架支持以配置的方式,强制将所有样式文件视为 css module 文件。且不谈它会带来什么影响,假设我们现在需要关闭这个特性,矫正存量文件,并设置一个卡点,禁止这种行为,你会怎么做?
如果是单次矫正,使用编辑器的搜索替换功能就可以完成。即使想设置卡点,也可以编写正则匹配脚本来实现。
话虽如此,如果我们更进一步,需要打印出具体出错的位置,行号列号,像这样
又或者其他高级定制需求,此时再使用正则就有点力不从心了,而且也不利于长期维护。
好的,下面进入正题,带大家重新认识一下 typescript 这个 npm 包。
AST 解析
也许你会觉得但凡涉及到抽象语法树(AST),必定是高深莫测的。需要使用 babel,各种 parser,一堆插件,先这样,再那样...想想就觉得望而却步。殊不知,我们每天都在打交道的 typescript,本身就是一个非常便捷的解析器。
我们正好可以借此机会重新认识下 typescript 这个包,使用它进行一些简单的信息收集工作还是非常方便的。
如下所示,我们只使用 typescript
这个包,30 行就实现了一个可以遍历语法树的程序。
ts
import ts from 'typescript';
/**
* 遍历节点树 - 利用自身的 forEach 方法轻松实现递归遍历
* @param {ts.Node} node
* @param {(node: ts.Node) => void} cb
*/
export function walkTsNode(node, cb) {
cb(node);
node.forEachChild((child) => walkTsNode(child, cb));
}
/**
* Script AST traverse
* @param {string} source - script file content
* @param {(node: ts.Node) => void} - callback
*/
export function walkFile(source, callback) {
walkTsNode(
ts.createSourceFile(
// 随便起个名字就行,需要注意以 tsx 结尾,这样能同时兼容 jsx 和 js 语法
'.virtual-filename.tsx',
// 文件内容,例如使用 fs.readFileSync 读取的文本内容
source,
// 语法标准,使用最新即可
ts.ScriptTarget.Latest,
),
callback,
);
}
虽然使用正则解析也能做一个模糊的检查,但不可否认,使用语法树精准定位的过程并没有想象中那么复杂。我们只需要做两件事,遍历和判断。
上面的示例代码告诉了大家如何遍历,下面👇和大家介绍一个工具 astexplorer.net,可以辅助我们编写逻辑,在遍历的同时提取节点,收集信息。
我们需要点击右上角,选择 typescript 作为解析器,然后在左侧输入代码,即可在右侧查看解析好的语法树。
通过上面的示例,我们得知,需要的节点类型为 ImportDeclaration
,于是有以下伪代码
ts
// 处理 ImportDeclaration 节点
function handleImportDeclaration(node) {
if (ts.isImportDeclaration(node)) {
// logic
}
}
在我们的场景中:
-
判断 import 声明,找出文件中的样式引用路径(css,scss)
-
若使用了样式文件的 default 导出,需确保文件名称需以
.module.css
结尾- 不符合要求则校验失败,终止 CI
-
在上面☝️的逻辑中,我们同时记录下问题出现的文件名称和行列位置,就可以在检查结束之后进行自动修复。
自动修复
信息提取容易,要如何修改呢?在上面的逻辑中,我们提前记录下了问题节点在文件中的起始和结束位置。
ts
// test.ts
import styles from './style.css';
^ ^
start end
那么我们只需要将区间内的文本替换成新的文件路径即可,再将对应的文件重新命名,矫正过程便完成了。
ts
newVal = oldVal.slice(0, start) + replacement + oldVal.slice(end);
很快你察觉异样,如果一个文件中出现多处问题,前一处修改势必造成后方索引位置的更新,还要计算偏移 😮💨。
但是不要怕,其实我们可以换个思路,对区间进行排序,从后往前进行替换,就可以避免这种情况。
ts
function replaceSubstrings(str: string, ranges: ReplaceRange[]) {
// 按照开始索引进行降序排序,这样靠前的修改不会影响到后面
ranges.sort((a, b) => b.start - a.start);
for (const range of ranges) {
const start = range.start;
const end = range.end;
const replacement = range.replacement;
// 替换指定区间的内容
str = str.substring(0, start) + replacement + str.substring(end);
}
return str;
}
其实这些琐碎的问题,早有社区方案可用,顺便和大家推荐一个工具(magic-string),生态的力量开始展现 🎉!
ts
import MagicString from 'magic-string';
const ms = new MagicString(fileContent);
// 进行若干处修改
ms.update(11, 16, './xxx.module.css');
ms.update(25, 30, '../styles/xxx.module.scss');
// ...
const newFileContent = ms.toString();
// 然后写入磁盘
实际上,除了这些基础能力外,它还能生成 sourcemap!也因此被广泛应用在各种构建工具链中,包括我们熟知的 webpack/rollup/vite。
当然,思路永远是最重要的,基于这一套丝滑连招,可以衍生出非常多的花样和玩法,在此抛砖引玉,仅供参考。