背景
你是否曾经被多而路径复杂的import语句搞得晕头转向?日常开发中我们经常会使用各种函数引用,如埋点、工具函数等。然而,这些引用的地方往往分散在代码的各个角落,非常复杂,给代码的维护和更新带来了很大的困扰,那么就离不开我们熟悉的CV。直到有一天...我懒得甚至连cv都按不动了。这时候有个大胆想法,可以通过分析代码,找到对应的作用域,把这些信息收集起来,继而判断对应文件有没有imported,有,跳过;没有,就加上。
在使用这个插件时,开发者只需要在代码中使用函数引用即可,无需手动添加import语句,插件会自动完成这个过程。总的来说,这个插件可以大大简化函数引用的过程,让开发者更加专注于业务逻辑的实现。
现有方案
- 编辑器的代码片段,类似vscode中的代码片段。
- 编辑器自动导入功能。(下面基于vscode)
- 输入函数名,会有智能分析,回车会有自动导入
- vscode中Auto Import插件,输入对应的函数名,会有import语句,不过只能针对于npm包中的分析。
- ...
- 打包方案: antfu大佬提供的unplugin-auto-import(unplugin是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文件和没导入过的效果
多函数
配置 - jsonParser和testFn函数
module.exports = [
{
importAutoPathName: "@/utils/json-handler",
importAutoFnName: "jsonParser",
},
{
importAutoPathName: "@/utils/testFn",
importAutoFnName: "testFn",
},
];
禁用
使用注释 auto-import-disable-next-line
结合Vue模板
vue模板本质上会把script标签中的内容提取出一个ts/js文件
tsx中的效果
注意:如果需要在tsx、jsx中使用,则在tools文件的parse加入jsx插件选项
parser.parse(code, {
plugins: ["jsx"],
})
神仙打架
文件中已经存在了函数的其他引用
如果文件中已经存在了目标函数(如上图的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配置文件
| 属性 | 类型 | 说明 |
|---|---|---|
| importAutoPathName | string | 方法对应的path链接,需要一个绝对路径 |
| importAutoFnName | string | 方法名 |
module.exports = [
{
importAutoPathName: "@/utils/json-handler",
importAutoFnName: "jsonParser",
},
{
importAutoPathName: "@/utils/testFn",
importAutoFnName: "testFn",
},
];
添加tsconfig中的path别名定义
{
"compilerOptions": {
...,
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
}
}
}
总结
在日常的开发中,我们经常需要完成一些繁琐重复的工作,其中包括手动import模块、编写重复代码等。其中,自动import工具是一种非常实用的工具,它能够自动导入所需的代码,提高了开发效率,并且有效地减少了心智负担。针对这些问题,代码提效工具应运而生。另外,我们还可以总结工作中的重复性工作,进行代码封装和轮子制造,进一步提高工作效率。 总的来说,代码提效工具是促进程序员工作效率的重要工具,不仅能够减少工作负担,提高工作效率,还可以让开发人员有更多的时间思考和优化程序结构,创造更好的价值。