node前端工具实战-svg引入整理工具

在之前我们开发了一个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导出比较简单,主要分两步

  1. 扫描svg文件,获取文件名,并将其转化为大驼峰命名

  2. 如果有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。然后做以下操作

  1. 根据其svg文件名生成一个新的大驼峰命名,记录与原引入命名的映射,之后用来替换JSX

  2. 记录引入的源文件夹

  3. 删除这个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

玩转 Commander.js ------ 你也是命令行大师

手把手带你入门 AST 抽象语法树 - 掘金

相关推荐
~甲壳虫16 分钟前
说说webpack中常见的Plugin?解决了什么问题?
前端·webpack·node.js
~甲壳虫1 小时前
说说webpack中常见的Loader?解决了什么问题?
前端·webpack·node.js
~甲壳虫1 小时前
说说webpack proxy工作原理?为什么能解决跨域
前端·webpack·node.js
熊的猫2 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
前端青山11 小时前
Node.js-增强 API 安全性和性能优化
开发语言·前端·javascript·性能优化·前端框架·node.js
GDAL14 小时前
npm入门教程1:npm简介
前端·npm·node.js
郑小憨21 小时前
Node.js简介以及安装部署 (基础介绍 一)
java·javascript·node.js
lin-lins1 天前
模块化开发 & webpack
前端·webpack·node.js
GDAL2 天前
npm入门教程13:npm workspace功能
前端·npm·node.js
wumu_Love2 天前
npm 和 node 总结
前端·npm·node.js