在之前我们开发了一个node脚手架,实现了node工具项目的快速搭建。现在就来用起这个脚手架来,开发一个小工具来解决一些项目中的问题。
背景
在工作的前端项目中,svg是直接在页面文件中引入ReactComponent来使用的
TypeScript
import { ReactComponent as LightIdea } from '@/style/images/lightIdea.svg';
这样的引入散落在各个组件,不好管理。同时其他页面想复用同样的icon时只能过来手动的复制修改,非常的不方便。
因此我们开发一个svg引入整理工具,将svg都导出到文件夹下的index.ts中,然后统一从index中引入组件,像这样
TypeScript
// index.ts
export { ReactComponent as PinIcon } from './pin-icon.svg';
// {{component}}.tsx
import { PinIcon } from '@/style/images'
开工
项目搭建
直接使用之前文章 从0开发一个node脚手架 做好的脚手架
为了给小工具以后添加功能留出空间,我们使用svg指令来执行svg整理,同时支持改变操作的文件夹。我们使用commander来解析命令
TypeScript
import { program } from 'commander';
program
.version('0.0.1')
.option('-P --path <path>', 'root path', 'src');
program
.command('svg')
.description('run svg tide up')
.action(function (res) {
const { path } = program.opts();
svgRestrain(path);
});
program.parse(process.argv);
统一svg在index.ts导出
处理svg导出比较简单,主要分两步
-
扫描svg文件,获取文件名,并将其转化为大驼峰命名
-
如果有index.ts,在其中添加导出。没有则创建一个,写入导出
这一部分比较简单,直接用fs扫描文件并直接用字符串写入即可
TypeScript
// 从svg文件名获取大驼峰命名
const svgFileToCamelCase = (value: string) => {
const test = /[_-](\w)/g;
const parsedValue = value.replace('.svg', '').replace(test, (_, letter) => {
return letter.toUpperCase();
});
return parsedValue[0].toUpperCase() + parsedValue.slice(1);
};
// 导出模版
const getSvgExport = (fileName: string) =>
`export { ReactComponent as ${svgFileToCamelCase(fileName)} } from './${fileName}';\n`;
Object.keys(dirMap).forEach((dir) => {
const indexFilePath = `${dir}/index.ts`;
const hasIndex = fs.existsSync(indexFilePath);
if (hasIndex) {
// 文件夹中已经有index.ts
const existedIndex = fs.readFileSync(indexFilePath).toString();
const reg = /([\w_-])+.svg/g;
// 筛选index.ts已经有的导出,并过滤
const importedSet = new Set<string>();
Array.from(existedIndex.matchAll(reg)).forEach((item) => {
importedSet.add(item[0]);
});
const filteredFilePath = dirMap[dir].filter((item) => {
return !importedSet.has(item);
});
// 追加写入到文件
fs.appendFileSync(indexFilePath, filteredFilePath.map((file) => getSvgExport(file)).join(''));
} else {
// 没有index.ts,直接写入
fs.writeFileSync(indexFilePath, dirMap[dir].map((file) => getSvgExport(file)).join(''));
}
});
统一tsx中svg导入
修改导入逻辑就比导出要略复杂,如果同文件夹已有导入的话需要添加导入,需要修改JSX中的Element,还需要把已有的ReactComponent引入删掉。这个时候字符串操作就不太够用了,所以我们来借助AST实现导入修改。
这里使用babel来操作AST,主要有以下几个包
-
@babel/parser:将代码转为AST
-
@babel/generator:将AST转为代码
-
@babel/traverse:用来遍历与更新节点,可以根据类型来遍历需要的节点
-
@babel/types:工具库,用来判断、生成各种类型的节点
代码转为AST
对于每一个tsx文件,首先我们将其转化为ast,直接使用@babel/parser处理
TypeScript
import { parse } from '@babel/parser';
parse(code, {
sourceType: 'module',
plugins: [
'jsx',
....
'topLevelAwait',
],
});
其中parser支持的plugins列表在官方文档babeljs.io/docs/babel-...
处理AST
在开始之前,首先分析一下import语句的AST是什么样的,方便我们修改。借助一下 astexplorer.net/
对应到js语句中是这个样子的
知道import知道是什么结构后,我们开始操作。
首先遍历所有的import声明(ImportDeclaration),找到引入为'.svg'结尾且是使用 {ReactComponent as XXXX }这种格式引入的import。然后做以下操作
-
根据其svg文件名生成一个新的大驼峰命名,记录与原引入命名的映射,之后用来替换JSX
-
记录引入的源文件夹
-
删除这个import声明
TypeScript
import traverse from '@babel/traverse';
// 记录新旧svg组件名的映射
const transferMap: Record<string, string> = {};
// 记录每个文件夹下引入的svg
const dirFilesMap: Record<string, string[]> = {};
traverse(ast, {
ImportDeclaration(path) {
const {
specifiers: [importSpecifier],
source: { value },
} = path.node;
// 引入了'.svg'文件并且使用ReactComponent as 形式
if (value.endsWith('.svg') && importSpecifier.imported?.name === 'ReactComponent') {
// 分离引入路径的文件名与文件夹名
const [dirName, svgName] = splitFileName(value);
// 获取新的大驼峰命名
const camelSvgName = svgFileToCamelCase(svgName);
const importedComponentName = importSpecifier.local.name;
// 记录组件名映射
transferMap[importedComponentName] = camelSvgName;
// 记录引入文件夹
if (dirFilesMap[dirName]) {
dirFilesMap[dirName].push(camelSvgName);
} else {
dirFilesMap[dirName] = [camelSvgName];
}
// 删除这个import语句
path.remove();
}
},
});
然后接下来我们需要将JSX中的组件名替换为新生成的组件名。然后将新的引入写入,有两种情况
-
有同文件夹的引用,直接添加一个新的specifiers
-
没有同文件夹引用,新添加一个ImportDeclaration
这需要我们再遍历两次才能实现。第一次遍历首先将有同文件夹引用的情况处理掉,顺便替换掉JSX组件名
TypeScript
import { importSpecifier, identifier, jsxIdentifier, importDeclaration,
stringLiteral } from '@babel/types';
traverse(ast, {
ImportDeclaration(path) {
const {
specifiers,
source: { value },
} = path.node;
const svgComponents = dirFilesMap[value];
if (svgComponents) {
// 已经有来自同一个文件夹的引入,写入新的specifiers
svgComponents.forEach((item) => {
// 使用 @babel/types中提供方法新建节点
const specifier = importSpecifier(identifier(item), identifier(item));
specifiers.push(specifier);
delete dirFilesMap[value];
});
}
},
JSXElement(path) {
// 替换JSX组件名,注意将开闭元素都给替换掉
const { openingElement, closingElement } = path.node;
const JSXName = openingElement.name.name;
if (transferMap[JSXName]) {
const newJSXName = jsxIdentifier(transferMap[JSXName]);
openingElement.name = newJSXName;
if (closingElement) {
closingElement.name = newJSXName;
}
}
},
});
然后再遍历一次,处理掉没有同文件夹引用的情况
TypeScript
traverse(ast, {
Program(path) {
const imports = Object.entries(dirFilesMap).map(([key, value]) => {
const specifiers = value.map((item) => {
return importSpecifier(identifier(item), identifier(item));
});
const source = stringLiteral(key);
// 创建新的importDeclaration
return importDeclaration(specifiers, source);
});
// 添加到AST body中
path.unshiftContainer('body', imports);
},
});
AST转回代码
这一步使用@babel/generator,注意指定retainLines为true来保持原有文件中的换行空格等。转换好后,将生成好的代码写回去。
TypeScript
import generator from '@babel/generator';
const { code } = generator(ast, { retainLines: true });
fs.writeFileSync(tsxFile, code);
对每一个tsx文件操作完后,就大功告成啦。
美化一下
最后再稍微美化一下我们的小工具。
添加几行打印,再使用node-progress添加一个进度条,让运行时的步骤美观一些,最后的效果如下
总结
这次我们开发了一个svg整理小工具,用于收敛项目中的svg引入,顺便学习AST相关的知识。小工具已经发布到npm
npm地址:www.npmjs.com/package/fro...
参考文章
babel官方文档:babeljs.io/docs