一键三连、自动导入:打造属于你自己的prettier工具

6,383 阅读6分钟

背景

你是否曾经被多而路径复杂的import语句搞得晕头转向?日常开发中我们经常会使用各种函数引用,如埋点、工具函数等。然而,这些引用的地方往往分散在代码的各个角落,非常复杂,给代码的维护和更新带来了很大的困扰,那么就离不开我们熟悉的CV。直到有一天...我懒得甚至连cv都按不动了。这时候有个大胆想法,可以通过分析代码,找到对应的作用域,把这些信息收集起来,继而判断对应文件有没有imported,有,跳过;没有,就加上。

在使用这个插件时,开发者只需要在代码中使用函数引用即可,无需手动添加import语句,插件会自动完成这个过程。总的来说,这个插件可以大大简化函数引用的过程,让开发者更加专注于业务逻辑的实现。

现有方案

  • 编辑器的代码片段,类似vscode中的代码片段。
  • 编辑器自动导入功能。(下面基于vscode)
    • 输入函数名,会有智能分析,回车会有自动导入
    • vscode中Auto Import插件,输入对应的函数名,会有import语句,不过只能针对于npm包中的分析。
    • ...
  • 打包方案: antfu大佬提供的unplugin-auto-importunplugin是antfu写的一系列构建工具插件)
  • 手动复制粘贴。
  • ...

思考

对于文件的分析,毫无疑问就是今天的主角babel,会进行:

  • 查找目标函数的引用
  • 对引用的文件打标
  • 检查打标文件是否已经import了,没有加上import
  • 对于babel的插件参数进行设计,支持动态匹配

以上动作完成后只是对ast的处理完成,并没有generate生成code。这一步至关重要~

我在设计这个插件的时候,想过...

  • 打包工具?webpack、vite、gulp...
  • node文件监听?Chokidar、fs.watch(watchFile)...
  • 又或者是vscode插件?watch?害,感觉被watch洗脑了😮‍💨
  • ...

不过这些统统不行!

  • 打包工具 ❌ 如果只是在打包的时候去插入import,那会导致源码可读性很差!哪天被CR代码的时候,你这个函数哪来的???
  • watch ❌ 文件change事件监听?如果文件操作频繁,每次触发势必影响性能。

但你要相信万能的JS社区总有令你满意的轮子!

相信很多小伙伴已经猜到了prettier,没错儿,就是它,结合vscode插件,在每次保存的时候统一注入import,简直不要太完美🤩

技术选型

  • 分析代码:babel
  • 代码输出:prettier

Prettier

官网插件开发:www.prettier.cn/docs/plugin…

开发插件

插件需要有5个模块。

module.exports = {
  languages: { // 通常是插件的描述信息
    name: string, // 插件名
    since?: string, 
    parsers: string[], // 用到的parser
    group?: string,
    tmScope?: string,
    aceMode?: string,
    codemirrorMode?: string,
    codemirrorMimeType?: string,
    aliases?: string[],
    extensions?: string[], // 格式化的文件后缀名
    filenames?: string[],
    linguistLanguageId?: number,
    vscodeLanguageIds?: string[],
  },
  parsers: { // 调用需要的解析器
    // key必须存在于languages的parsers
    [key]: (parse, locStart, locEnd, hasPragma, preprocess) => {
      // locStart, locEnd - node节点检测位置
      // parse - 解析器,https://www.prettier.cn/docs/options.html#parser定义了所有22种原生解析器
      // hasPragma - 过滤注释的函数
      // preprocess - 在parse之前的预处理钩子
  	}
  },
  printers, // prettier从ast到输出最终代码的中间格式Doc。https://www.prettier.cn/docs/plugins.html#printers
  options, // 定义了配置文件可传入的参数,SupportOption类型
  defaultOptions // 覆盖配置文件的属性配置
}

很明显想要实现我们的自动import的功能,肯定是在parsers或者printers中处理就好了。我们想一下,既然我们想把代码补充完整,说明交给解析器之前得添加好import,所以这里用preprocess最合适的。

效果

为了方便演示,会加入些不存在的import导入语句,大家主要看下效果就好🤪~ 下面栗子中jsonParser是一个加上try catch的JSON.parse简易封装函数。

单函数

jsonParser函数分别已经导入过json-handler文件和没导入过的效果

img

多函数

配置 - jsonParser和testFn函数

module.exports = [
  {
    importAutoPathName: "@/utils/json-handler",
    importAutoFnName: "jsonParser",
  },
  {
    importAutoPathName: "@/utils/testFn",
    importAutoFnName: "testFn",
  },
];

img

禁用

使用注释 auto-import-disable-next-line

img

结合Vue模板

vue模板本质上会把script标签中的内容提取出一个ts/js文件

img

tsx中的效果

img

注意:如果需要在tsx、jsx中使用,则在tools文件的parse加入jsx插件选项

parser.parse(code, {
  plugins: ["jsx"],
})

神仙打架

文件中已经存在了函数的其他引用

img

如果文件中已经存在了目标函数(如上图的testFn)的引用,则插件会继续添加配置的引用。综合考虑,会加上引用,后续交由EsLint检查,对于代码逻辑性的检查并不在prettier职责范围内,需要开发者自行判断处理。

代码实现

下载依赖

npm i @babel/core prettier typescript -D

配置prettier运行脚本语言

"scripts": {
  "format": "prettier --write \"**/*.{ts,js,css}\""
},

添加.prettierrc.js文件

const { resolve } = require("path");
module.exports = {
  useTabs: false,
  tabWidth: 2,
  overrides: [
    {
      files: "*.{json,babelrc,eslintrc,remarkrc}",
      options: {
        useTabs: false,
      },
    },
  ],
  "import-auto-config": resolve(__dirname, "import_auto_config.js"),
  plugins: ["./plugin/tools.js"],
};

添加插件文件plugin/tools.js

const babelParsers = require("prettier/parser-babel").parsers;
const typescriptParsers = require("prettier/parser-typescript").parsers;
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const generate = require("@babel/generator").default;
const temp = require("@babel/template").default;
const t = require("@babel/types");
const crypto = require("crypto");

const newLine = crypto.randomUUID();

function autoImportPreprocessor(code, opt) {
  // 获取配置
  const importConfig = opt["import-auto-config"];
  let config = [];
  try {
    const list = require(importConfig);
    if (list.length) {
      // 去除无效配置
      config = list
        .filter((item) => item.importAutoPathName && item.importAutoFnName)
        .map((item, index) => ({
          sort: index,
          ...item,
        }));
    }
  } catch (error) {}
  let importInfo;
  function init() {
    importInfo = {}; // 引用信息
  }

  const ast = parser.parse(code.replaceAll("\n\n", `\n"${newLine}";\n`), {
    sourceType: "unambiguous",
  });

  // 禁用信息收集
  const disableLineList = ast.comments
    .filter((item) => {
      return (
        item.type === "CommentLine" &&
        item.value.trim() === "auto-import-disable-next-line"
      );
    })
    .map((item) => item?.loc?.start?.line);

  traverse(ast, {
    Program: {
      enter(ProgramPath) {
        init();
        ProgramPath.traverse({
          ImportDeclaration(path) {
            // 找出类似 import { a,b } from "@/utils/json-handler"; 的 @/utils/json-handler 引用节点
            const source = path.node?.source;
            // 找出当前path节点的引用路径是否在配置文件中
            const idx = config.findIndex(
              (item) => item.importAutoPathName === source.value
            );
            if (idx < 0) return;
            if (
              t.isStringLiteral(source, {
                value: config[idx].importAutoPathName,
              })
            ) {
              // 获取该节点上的所有引用
              const imported =
                path.node?.specifiers
                  .filter((specifier) => {
                    return (
                      t.isImportSpecifier(specifier) &&
                      t.isIdentifier(specifier.imported)
                    );
                  })
                  .map((item) => {
                    return item.imported?.name;
                  }) || [];
              if (imported.length) {
                // 如果有引用,则删除该节点,Program.exit退出时统一修改ast添加
                path.remove();
              }
              // 添加importInfo信息,key为方法名
              importInfo[config[idx].importAutoFnName] = {
                ...config[idx],
                imported,
              };
            }
          },
          CallExpression(path) {
            // 找出目标函数的调用
            const callee = path.node?.callee;
            const calleeIdx = config.findIndex(
              (item) => item.importAutoFnName === callee.name
            );
            if (calleeIdx < 0) return;
            if (
              t.isIdentifier(callee, {
                name: config[calleeIdx].importAutoFnName,
              })
            ) {
              const info = importInfo?.[config[calleeIdx].importAutoFnName];
              const startLineNum = callee?.loc?.start?.line;
              if (info) {
                // info存在说明这个方法对应的文件已经被导入过了
                info.isNeedImport = true;
                info.startLineNum = startLineNum;
              } else {
                // 这个文件还未导入
                importInfo[config[calleeIdx].importAutoFnName] = {
                  ...config[calleeIdx],
                  imported: [],
                  isNeedImport: !disableLineList.includes(startLineNum),
                  startLineNum: startLineNum,
                };
              }
            }
          },
        });
      },
      exit(path) {
        const importInfoList = Object.values(importInfo);
        const importAstList = []; // 有sort的import的ast
        const extraList = []; // 无sort的import的ast
        importInfoList.forEach((item) => {
          if (!item.isNeedImport) return;
          const {
            imported,
            importAutoFnName,
            importAutoPathName,
            startLineNum,
            sort,
          } = item;
          // 整个Program节点中有 fnName 的调用
          let ast = null;
          if (imported.length) {
            // pathName 有引用过
            // 之前没有引用过 && 不在禁用列表中
            if (
              !imported.includes(importAutoFnName) &&
              !disableLineList.includes(startLineNum - 1)
            ) {
              imported.push(importAutoFnName);
            }
            ast = temp.ast(
              `import { ${imported.join(",")} } from "${importAutoPathName}";`
            );
          } else {
            // pathName 没有引用过
            if (!disableLineList.includes(startLineNum - 1)) {
              ast = temp.ast(
                `import { ${importAutoFnName} } from "${importAutoPathName}";`
              );
            }
          }
          // 根据配置排序插入import,避免import位置反复变化
          typeof sort === "number"
            ? importAstList.splice(sort, 0, ast)
            : extraList.push(ast);
        });

        const sortList = [...importAstList, ...extraList].reverse();
        // 插入import语句
        sortList.forEach((item) => path.unshiftContainer("body", item));
      },
    },
  });
  const formatCode = generate(ast).code;
  const result = formatCode.replaceAll(`"${newLine}";`, "\n");
  return result;
}

module.exports = {
  languages: [
    {
      name: "auto-import",
    },
  ],
  parsers: {
    typescript: {
      ...typescriptParsers.typescript,
      preprocess: autoImportPreprocessor,
    },
    babel: {
      ...babelParsers.babel,
      preprocess: autoImportPreprocessor,
    },
  },
  options: {
    "import-auto-config": {
      type: "string",
      default: "import_auto_config.js",
      category: "auto-import",
      description: "自动导入函数配置文件",
    },
  },
};

添加import_auto_config.js配置文件

属性类型说明
importAutoPathNamestring方法对应的path链接,需要一个绝对路径
importAutoFnNamestring方法名
module.exports = [
  {
    importAutoPathName: "@/utils/json-handler",
    importAutoFnName: "jsonParser",
  },
  {
    importAutoPathName: "@/utils/testFn",
    importAutoFnName: "testFn",
  },
];

添加tsconfig中的path别名定义

{
	"compilerOptions": {
    ...,
    "baseUrl": "./",
    "paths": {
      "@/*": ["src/*"]
    }
	}
}

总结

在日常的开发中,我们经常需要完成一些繁琐重复的工作,其中包括手动import模块、编写重复代码等。其中,自动import工具是一种非常实用的工具,它能够自动导入所需的代码,提高了开发效率,并且有效地减少了心智负担。针对这些问题,代码提效工具应运而生。另外,我们还可以总结工作中的重复性工作,进行代码封装和轮子制造,进一步提高工作效率。 总的来说,代码提效工具是促进程序员工作效率的重要工具,不仅能够减少工作负担,提高工作效率,还可以让开发人员有更多的时间思考和优化程序结构,创造更好的价值。