这个夏天,给你的代码减个肥 🍉|let-s-refactor 插件开发之路(一)
大家好,我是寒草 🌿,一只工作近两年的草系码猿 🐒 如果喜欢我的文章,可以关注 ➕ 点赞,与我一同成长吧~ 加我微信:hancao97,邀你进群,一起学习交流,成为更优秀的工程师~
前言
你将得到什么?
通过本文,各位将了解(或者学习)到:
- es-module 导入导出
- 可能用的较少但比较好用的正则语法
- vscode 插件从设计到实现
- ...
本文内容较多,希望对你有所启发,如有建议或者纠正,也望不吝赐教。
代码”减肥“的起因
各位好久不见🌺,前两个月的时间经历了一个月的加班以及一个月的居家办公。在疯狂加班的那段时间手里接到了一个时间比较紧急,历史包袱也比较重的项目:
- 产品形态出现多次变更
- 开发人员几经转手
- ...
以及本次迭代版本服务端架构出现了比较大的变更,使得老的前端代码设计难以支撑新架构下服务端 api 的变更,于是催生出我的一个想法:
重构吧!
重构的结果还是好的,既赶上了提测,也在优化项目结构的基础上完成了 50% 代码量的减少,而 50% 对于本项目而言,大抵是相当于 10000 行左右的代码量。
在这次代码“减肥”的过程中我有了一些总结(或者说是一些比较让我揪心的体验),使得我有了后文中的想法。
读到这里可能大家会问:你加班了一个月,那居家办公那段时间下班后都干嘛去了?
我只能说,swicth 真好玩!
揪心体验
不知道各位在职业生涯中有没有遇到过以下体验:
- 一个文件完全没有被使用过,但是在项目目录中恬不知耻的存在着
- 一个方法,一个配置信息或者一个常量早早就已经过时了却依旧存在
若项目历史不久,此类信息倒是影响不大,但是随着版本迭代,他们会越来越多,并好似狗皮膏药一般深入到项目代码的各个角落,难以察觉,又恶心至极!
这种删又删不干净,又没事突然出现恶心你一下的感觉真的难以言表...
💡:无效的文件可能容易发现并且在适当时机进行删除,但是无效的常量或配置类内容就真的难以处理。
于是我在此次重构过程中几乎审查了所有的历史文件,所以我那代码量减少 50% 的壮举不全是我设计的功劳,很大一部分还是由于删除了大量的无效代码...(诶,真的很不想承认,但是做人要诚实🍉)
在删代码的过程中,我产生了如下想法:
这个过程,需要更加智能
做更“懒”的工程师
根据前面的铺垫,我想各位已经知道了我此次的目标:
- 自动识别无效文件
- 自动识别无效常量(配置信息/公共方法等)
然而,我喜欢画饼,在开发之初我就画了以下思维导图,插件命名为 let-s-refactor 也是希望我这个插件所做的事情不止于此,可以为提高代码库质量提供更多能力!
而对于代码的读取分析,最方便的手段也就是:vscode 插件。文章的后续内容便是插件的第一批功能清单以及开发思路的分享。
功能清单 V0.0.1
本次介绍 v0.0.1 版本的三个主要功能:
- 统计代码行数与文件数
显而易见的需求,不计算代码行数与文件数,怎么统计最终的成果(没错,主要工作还没做的我就已经开始思考成果的展示了🍉)
- 列出项目中的无效文件
将项目中无效的文件展示出来,以帮助进行后续的分析删除
- 列出项目中的无效导出
将项目中无效的 export 展示,可能包括常量,配置信息与公共方法。
注意:本插件现仅支持本公司统一框架下的项目,未来想通过配置文件的方式支持所有项目。
开发思路
💡:本章节思路可适用于编写nodejs脚本或者vscode插件。
代码行数与文件数
先来最简单的需求,总体思路如下:
- 第一步我先获取项目的业务文件列表(文件列表长度为文件个数)
- 读出每个文件内容并使用换行符分隔就可以得到代码行数
入口方法:
const fileList = getBusinessFileList();
const fileCount = fileList.length;
const lineCount = getFileLineCount(fileList);
获取文件列表(这里是一个简单递归,不多做赘述):
const getBusinessFileList = () => {
const dirPath = getProjectRoot();
if (!dirPath) return [];
return getFileList(dirPath);
}
const getFileList = (dirPath) => {
let dirSubItems = fs.readdirSync(dirPath);
const fileList = [];
for (const item of dirSubItems) {
const childPath = path.join(dirPath, item);
if (_isDir(childPath) && !excludedDirs.has(item)) {
fileList.push(...getFileList(childPath));
} else if (!_isDir(childPath) && includedFileSubfixes.has(path.extname(item))) {
fileList.push(childPath);
}
}
return fileList;
}
读文件并获取行数:
const getFileLineCount = (fileList) => {
let count = 0;
for (const file of fileList) {
const content = fs.readFileSync(file, {
encoding: 'utf-8'
});
count += content.split('\n').length;
}
return count;
}
无效文件
之后我们来说更加复杂的获取无效文件,思路如下:
- 第一步就是要获取到这些文件的列表
之前文章中有说到本插件暂时仅支持本公司框架下的项目,原因就是业务根文件的获取规则可能不同。
那么,什么是业务根文件呢?
这是我在此次实现中提出的概念(言外之意就是很不官方的说法)。本项目中的所有的引用关系都应该以业务根文件作为根节点,比如我列出的:pages,expose 目录下的文件,以及main.js,他们不被引用,且一定是有效的文件。
- 第二步就是要进行一个递归,获取到其引用的文件,以及其引用的文件所引用的文件(套娃)
这里注意要避免死循环
比如一个根文件引用了 A, A 引用了 B,那么根节点和 A,B 都是有用的文件,C 就是个没有被引用,即无效文件。
总结:一个文件如果既不是业务根文件又没有被业务根文件直接或者间接引用,则为无效文件
这里我列举出三种引用方式及其对应的 import 语法:
[
// import * from './example'
/(?<statement>import\s+.*?\s+from\s+['"](?<modulePath>.+?)['"])/g,
// import('./example')
/(?<statement>import\(['"](?<modulePath>.+?)['"]\))/g,
// import './example'
/(?<statement>import\s+['"](?<modulePath>.+?)['"])/g
]
这里有关于正则语法的细节:
- <>:可以通过尖括号中的变量名获取到匹配的内容,比如上面代码中的 modulePath 就是文件路径。
- ?:启用非贪婪模式
- 第三步就是获取全量的业务文件
- 最后就是将全量的业务文件与根文件及其直接或间接引用的文件做差集,最终就得到了项目中的无效文件
下面开始进行代码展示,首先是入口方法:
const businessFileList = getBusinessFileList();
const businessRootFileList = getBusinessRootFileList();
const importedFileSet = getImportedFileSet(businessRootFileList);
const unusedFileList = businessFileList.filter(file => !importedFileSet.has(file));
之后获取业务根文件:
const getBusinessRootFileList = () => {
const projectRoot = getProjectRoot();
if (!projectRoot) return [];
const fileList = [];
const pagePath = path.join(projectRoot, '/views/pages');
if (fs.existsSync(pagePath)) {
fileList.push(...getFileList(pagePath));
}
const exposePath = path.join(projectRoot, '/expose');
if (fs.existsSync(exposePath)) {
fileList.push(...getFileList(exposePath));
}
const mainPath = path.join(projectRoot, '/main.js');
if (fs.existsSync(mainPath)) {
fileList.push(mainPath);
}
return fileList;
}
const getFileList = (dirPath) => {
let dirSubItems = fs.readdirSync(dirPath);
const fileList = [];
for (const item of dirSubItems) {
const childPath = path.join(dirPath, item);
if (_isDir(childPath) && !excludedDirs.has(item)) {
fileList.push(...getFileList(childPath));
} else if (!_isDir(childPath) && includedFileSubfixes.has(path.extname(item))) {
fileList.push(childPath);
}
}
return fileList;
}
再之后获取根文件直接或者间接(递归)引用的文件:
这里我使用了 set,进行重复性检测,避免循环引用引起插件报错。
const getImportPathRegs = () => {
// TODO: 无法检测运行时生成的路径
return [
// import * from './example'
/(?<statement>import\s+.*?\s+from\s+['"](?<modulePath>.+?)['"])/g,
// import('./example')
/(?<statement>import\(['"](?<modulePath>.+?)['"]\))/g,
// import './example'
/(?<statement>import\s+['"](?<modulePath>.+?)['"])/g
]
}
const getImportedFileSet = (fileList, set = new Set([])) => {
const _fileList = [];
for (const file of fileList) {
if (set.has(file)) {
continue;
}
set.add(file);
const content = fs.readFileSync(file, {
encoding: 'utf-8'
});
const regReferences = getImportPathRegs();
for (const reg of regReferences) {
let matchResult;
while ((matchResult = reg.exec(content))) {
const { modulePath } = matchResult.groups;
const filePath = speculatePath(modulePath, file);
if (filePath && !set.has(filePath)) {
_fileList.push(filePath);
}
}
}
}
if (_fileList.length) getImportedFileSet(_fileList, set);
return set;
}
最后就是做差集了:
const unusedFileList = businessFileList.filter(file => !importedFileSet.has(file));
无效导出
最复杂的就是这个无效导出,大家写的 export 真的可能千奇百怪。实现的整体思路如下:
我们做这个需求,就要知道两个信息:
- 我们引用了哪些(必须是有效文件中的引用)
- 我们导出了什么(必须是业务根文件以外的文件中导出的)
什么是export提供者(exportProvider)?
根文件因为不会被引用所以不是 exportProvider,所以业务根文件以外的业务文件是exportProvider。
获取 exportProvider:
const businessFileList = getBusinessFileList();
const businessRootFileList = getBusinessRootFileList();
const businessRootFileSet = new Set(businessRootFileList);
const exportProviderList = businessFileList.filter(file => !businessRootFileSet.has(file));
获取 export 信息:
现在的获取规则还是比较 low,未来可能改为进行词法分析与语法分析,以及需要注意处理 as 语法(import中也需要考虑)。
export 规则如下:
- export const/class/function/var/default/- moduleName
- export const/class/function/var/default/- { moduleName }
- export default/function 匿名
如有遗漏后续再进行处理...确实有点多
const exportInfo = getExportInfo(exportProviderList);
const getExportRegs = () => {
// TODO: 无法检测运行时生成的路径
return [
// export const/class/function/var/default/- {xxx}/{xxx as yyy}
/export\s+(const|var|let|function|class|default)?\s*{(?<provide>[\w\W]+?)}/g,
// export const/class/function/var/default/- xxx
/export\s+(const|var|let|function|class|default)?\s*(?<provide>[\w-]+)/g
]
}
// TODO: 未来改用词法分析 + 语法分析
const getExportInfo = (fileList) => {
const exportInfo = {};
for (const file of fileList) {
if (path.extname(file) === '.js') {
const content = fs.readFileSync(file, {
encoding: 'utf-8'
});
const provideList = [];
const regReferences = getExportRegs();
for (const reg of regReferences) {
let matchResult;
while ((matchResult = reg.exec(content))) {
let { provide } = matchResult.groups;
// const|var|let|function|class|default
if (provide == 'default') {
provide = UNNAMED_DEFAULT;
} else if (provide == 'function') {
provide = UNNAMED_FUNCTION;
} else if (DECONSTRUCTION_STATEMENT_SYMBOLS.has(provide)) {
continue;
}
provideList.push(...provide.split(',').map(item => {
const temp = item.split(' as ');
if (temp[1]) {
return temp[1].replace(/\s/g, '');
} else {
return temp[0].replace(/\s/g, '');
}
}));
}
}
exportInfo[file] = provideList;
} else if (path.extname(file) === '.vue') {
exportInfo[file] = VUE_MODULE;
}
}
return exportInfo;
}
获取有效文件:
复用已有方法
const importedFileSet = getImportedFileSet(businessRootFileList);
const usedFileList = businessFileList.filter(file => importedFileSet.has(file));
获取 import 信息:
💡:import 很特殊,有解构引用的方式,也有全量的引用
const getImportInfo = (fileList) => {
const importInfo = {};
for (const file of fileList) {
const content = fs.readFileSync(file, {
encoding: 'utf-8'
});
let matchResult;
const deconstructionReg = /import\s+{(?<provide>[\w\W]+?)}\s+from\s+['"](?<modulePath>.+?)['"]/g;
// 解构
while ((matchResult = deconstructionReg.exec(content))) {
const { provide, modulePath } = matchResult.groups;
const filePath = speculatePath(modulePath, file);
if (filePath) {
const provideList = provide.split(',').map(item => item.split(' as ')[0].replace(/\s/g, ''))
if (!importInfo[filePath]) {
importInfo[filePath] = new Set(provideList);
} else if (importInfo[filePath] != IMPORT_ALL) {
importInfo[filePath].add(...provideList);
}
}
}
const constructionRegs = [
/import\s+(?<provide>[^{}]+?)\s+from\s+['"](?<modulePath>.+?)['"]/g,
// import('example')
/import\(['"](?<modulePath>.+?)['"]\)/g,
// import './example'
/import\s+['"](?<modulePath>.+?)['"]/g
]
for (const reg of constructionRegs) {
let matchResult;
while ((matchResult = reg.exec(content))) {
const { modulePath } = matchResult.groups;
const filePath = speculatePath(modulePath, file);
if (filePath) {
importInfo[filePath] = IMPORT_ALL;
}
}
}
}
return importInfo;
}
最后根据 import 和 export 信息作处理,得到无效export:
这里我的代码写得好丑...
const unusedExport = {};
Object.keys(exportInfo).forEach(key => {
if(exportInfo[key] === VUE_MODULE) {
if(importInfo[key] !== IMPORT_ALL) unusedExport[key] = [VUE_MODULE];
} else {
if(!importInfo[key]) {
unusedExport[key] = exportInfo[key];
} else if(importInfo[key] != IMPORT_ALL) {
const unusedExportList = exportInfo[key].filter(exportItem => {
return !importInfo[key].has(exportItem);
})
if(unusedExportList.length > 0) unusedExport[key] = unusedExportList;
}
}
});
import 的缺省匹配
众所周知,我们在写 import 的时候经常不写完整,比如:
- import 写到一个目录,这时候会匹配目录下的 index.js
- 不写引用文件的后缀名,这时候会默认匹配 xxx.js
- ...
以及我们可能会存在相对路径和绝对路径两种方式:
- @/xxxx 可能对应 src 目录
- ../../ 等相对路径
于是这里还需要一个路径推测方法:
const speculatePath = (source, basicPath) => {
let _source;
if (source.startsWith('@/')) {
const srcPath = getProjectRoot();
_source = `${srcPath}${source.replace('@', '')}`
} else {
_source = path.join(path.dirname(basicPath), source);
}
if (fs.existsSync(_source) && !_isDir(_source)) {
return _source;
}
let speculatePath;
if (fs.existsSync(_source) && _isDir(_source)) {
speculatePath = path.join(_source, '/index.js');
if (fs.existsSync(speculatePath)) {
return speculatePath;
}
speculatePath = path.join(_source, '/index.vue');
if (fs.existsSync(speculatePath)) {
return speculatePath;
}
return null;
}
if (!fs.existsSync(_source)) {
speculatePath = `${_source}.js`;
if (fs.existsSync(speculatePath)) {
return speculatePath;
}
speculatePath = `${_source}.vue`;
if (fs.existsSync(speculatePath)) {
return speculatePath;
}
return null;
}
return null;
}
Ending,is also beginning
这样,本篇文章的内容到这里就全部结束了,希望对各位有所帮助,下一篇文章(或者视频)我们再见!
✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
世之奇伟、瑰怪、非常之观, 常在于险远,而人之所罕至焉, 故非有志者不能至也。
— 王安石《游褒禅山记》—
🍉诸君共勉,加油 🍉
✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
各位的支持「点赞 ➕ 关注」是我源源不断的动力,可以加我微信:hancao97,邀你进群,一起学习交流,成为更优秀的前端工程师~
转载自:https://juejin.cn/post/7108515584681181220