开发思路
💡:本章节思路可适用于编写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
这样,本篇文章的内容到这里就全部结束了,希望对各位有所帮助,下一篇文章(或者视频)我们再见!
✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨
世之奇伟、瑰怪、非常之观,
常在于险远,而人之所罕至焉,
故非有志者不能至也。
— 王安石《游褒禅山记》—
🍉诸君共勉,加油 🍉
✨✨✨✨✨✨✨✨✨✨✨✨✨✨✨