昊昊是业务线前端工程师(专业页面仔),我是架构组工具链工程师(专业工具人),有一天昊昊和说我他维护的项目中没用到的模块太多了,其实能够删掉的,可是如今不知道哪些没用,就不敢删,问我是否是能够作一个工具来找出全部没有被引用的模块。毕竟是专业的工具人,这种需求难不倒我,因而花了半天多实现了这个工具。javascript
这个工具是一个通用的工具,node 项目、前端项目均可以用它来查找没有用到的模块,并且其中模块遍历器的思路能够应用到不少别的地方。因此我整理了实现思路,写了这篇文章。css
目标是找到项目中全部没用到的模块。项目中总有几个入口模块,代码会从这些模块开始打包或者运行。咱们首先要知道全部的入口模块。前端
有了入口模块以后,分析入口模块的用到(依赖)了哪些模块,而后再从用到的模块分析依赖,这样递归的进行分析,直到没有新的依赖。这个过程当中,全部遍历到的模块就是用到的,而没有被遍历到的就是没有用到的,就是咱们要找的能够删除的模块。java
咱们能够在遍历的过程当中把模块信息和模块之间的关系以对象和对象的关系保存,构形成一个依赖图(由于可能有一个模块被两个模块依赖,甚至循环依赖,因此是图)。以后对这个依赖图的数据结构的分析就是对模块之间依赖关系的分析。咱们这个需求只须要保存遍历到的模块路径就能够,能够不生成依赖图。node
遍历到不一样的模块要找到它依赖的哪些模块,对于不一样的模块有不一样的分析依赖的方式:webpack
并且拿到了依赖的路径也可能还要作一层处理,由于好比 webpack 能够配置 alias,typescript 能够配置 paths,还有 monorepo 的路径也有本身的特色,这些路径解析规则是咱们要处理的,处理以后才能找到模块真实路径是啥。git
通过从入口模块开始的依赖分析,对模块图完成遍历,把用到的模块路径保存下来,而后用全部模块路径过滤掉用到的,剩下的就是没有使用的模块。github
思路大概这样,咱们来实现一下:web
咱们要写一个模块遍历器,传入当前模块的路径和处理模块内容的回调函数,处理过程以下:typescript
const MODULE_TYPES = {
JS: 1 << 0,
CSS: 1 << 1,
JSON: 1 << 2
};
function getModuleType(modulePath) {
const moduleExt = extname(modulePath);
if (JS_EXTS.some(ext => ext === moduleExt)) {
return MODULE_TYPES.JS;
} else if (CSS_EXTS.some(ext => ext === moduleExt)) {
return MODULE_TYPES.CSS;
} else if (JSON_EXTS.some(ext => ext === moduleExt)) {
return MODULE_TYPES.JSON;
}
}
function traverseModule (curModulePath, callback) {
curModulePath = completeModulePath(curModulePath);
const moduleType = getModuleType(curModulePath);
if (moduleType & MODULE_TYPES.JS) {
traverseJsModule(curModulePath, callback);
} else if (moduleType & MODULE_TYPES.CSS) {
traverseCssModule(curModulePath, callback);
}
}
复制代码
遍历 js 模块须要分析其中的 import 和 require 依赖。咱们使用 babel 来作:
代码以下:
function traverseJsModule(curModulePath, callback) {
const moduleFileContent = fs.readFileSync(curModulePath, {
encoding: 'utf-8'
});
const ast = parser.parse(moduleFileContent, {
sourceType: 'unambiguous',
plugins: resolveBabelSyntaxtPlugins(curModulePath)
});
traverse(ast, {
ImportDeclaration(path) {
const subModulePath = moduleResolver(curModulePath, path.get('source.value').node);
if (!subModulePath) {
return;
}
callback && callback(subModulePath);
traverseModule(subModulePath, callback);
},
CallExpression(path) {
if (path.get('callee').toString() === 'require') {
const subModulePath = moduleResolver(curModulePath, path.get('arguments.0').toString().replace(/['"]/g, ''));
if (!subModulePath) {
return;
}
callback && callback(subModulePath);
traverseModule(subModulePath, callback);
}
}
})
}
复制代码
遍历 css 模块须要分析 @import 和 url()。咱们使用 postcss 来作:
代码以下:
function traverseCssModule(curModulePath, callback) {
const moduleFileConent = fs.readFileSync(curModulePath, {
encoding: 'utf-8'
});
const ast = postcss.parse(moduleFileConent, {
syntaxt: resolvePostcssSyntaxtPlugin(curModulePath)
});
ast.walkAtRules('import', rule => {
const subModulePath = moduleResolver(curModulePath, rule.params.replace(/['"]/g, ''));
if (!subModulePath) {
return;
}
callback && callback(subModulePath);
traverseModule(subModulePath, callback);
});
ast.walkDecls(decl => {
if (decl.value.includes('url(')) {
const url = /.*url\((.+)\).*/.exec(decl.value)[1].replace(/['"]/g, '');
const subModulePath = moduleResolver(curModulePath, url);
if (!subModulePath) {
return;
}
callback && callback(subModulePath);
}
} )
}
复制代码
不论是 css 仍是 js 模块都要在提取了路径以后进行处理:
代码以下:
const visitedModules = new Set();
function moduleResolver (curModulePath, requirePath) {
if (typeof requirePathResolver === 'function') {// requirePathResolver 是用户自定义的路径解析逻辑
const res = requirePathResolver(dirname(curModulePath), requirePath);
if (typeof res === 'string') {
requirePath = res;
}
}
requirePath = resolve(dirname(curModulePath), requirePath);
// 过滤掉第三方模块
if (requirePath.includes('node_modules')) {
return '';
}
requirePath = completeModulePath(requirePath);
if (visitedModules.has(requirePath)) {
return '';
} else {
visitedModules.add(requirePath);
}
return requirePath;
}
复制代码
这样咱们就完成了分析出的依赖路径到它真实的路径的转换。
写代码的时候是能够省略掉一些文件的后缀(.js、.tsx、.json 等)的,咱们要实现补全的逻辑:
const JS_EXTS = ['.js', '.jsx', '.ts', '.tsx'];
const JSON_EXTS = ['.json'];
function completeModulePath (modulePath) {
const EXTS = [...JSON_EXTS, ...JS_EXTS];
if (modulePath.match(/\.[a-zA-Z]+$/)) {
return modulePath;
}
function tryCompletePath (resolvePath) {
for (let i = 0; i < EXTS.length; i ++) {
let tryPath = resolvePath(EXTS[i]);
if (fs.existsSync(tryPath)) {
return tryPath;
}
}
}
function reportModuleNotFoundError (modulePath) {
throw chalk.red('module not found: ' + modulePath);
}
if (isDirectory(modulePath)) {
const tryModulePath = tryCompletePath((ext) => join(modulePath, 'index' + ext));
if (!tryModulePath) {
reportModuleNotFoundError(modulePath);
} else {
return tryModulePath;
}
} else if (!EXTS.some(ext => modulePath.endsWith(ext))) {
const tryModulePath = tryCompletePath((ext) => modulePath + ext);
if (!tryModulePath) {
reportModuleNotFoundError(modulePath);
} else {
return tryModulePath;
}
}
return modulePath;
}
复制代码
按照上面的思路,咱们实现了模块的遍历,找到了全部的用到的模块。
上面咱们找到了全部用到的模块,接下来只要用全部的模块过滤掉用到的模块,就是没有用到的模块。
咱们封装一个 findUnusedModule 的方法。
传入参数:
返回一个对象,包含:
处理过程:
const defaultOptions = {
cwd: '',
entries: [],
includes: ['**/*', '!node_modules'],
resolveRequirePath: () => {}
}
function findUnusedModule (options) {
let {
cwd,
entries,
includes,
resolveRequirePath
} = Object.assign(defaultOptions, options);
includes = includes.map(includePath => (cwd ? `${cwd}/${includePath}` : includePath));
const allFiles = fastGlob.sync(includes).map(item => normalize(item));
const entryModules = [];
const usedModules = [];
setRequirePathResolver(resolveRequirePath);
entries.forEach(entry => {
const entryPath = resolve(cwd, entry);
entryModules.push(entryPath);
traverseModule(entryPath, (modulePath) => {
usedModules.push(modulePath);
});
});
const unusedModules = allFiles.filter(filePath => {
const resolvedFilePath = resolve(filePath);
return !entryModules.includes(resolvedFilePath) && !usedModules.includes(resolvedFilePath);
});
return {
all: allFiles,
used: usedModules,
unused: unusedModules
}
}
复制代码
这样,咱们封装的 findUnusedModule 可以完成最初的需求:查找项目下没有用到的模块。
咱们来测试一下效果,用这个目录做为测试项目:
const { all, used, unused } = findUnusedModule({
cwd: process.cwd(),
entries: ['./demo-project/fre.js', './demo-project/suzhe2.js'],
includes: ['./demo-project/**/*'],
resolveRequirePath (curDir, requirePath) {
if (requirePath === 'b') {
return path.resolve(curDir, './lib/ssh.js');
}
return requirePath;
}
});
复制代码
结果以下:
成功的找出了没有用到的模块!(能够把代码拉下来跑一下试试)
咱们实现了一个模块遍历器,它能够对从某一个模块开始遍历。基于这个遍历器咱们实现了查找无用模块的需求,其实也能够用它来作别的分析需求,这个遍历的方式是通用的。
咱们知道 babel 能够用来作两件事情:
这个模块遍历器也能够作一样的事情:
咱们先分析了需求:找出项目中没用到的模块。这须要实现一个模块遍历器。
模块遍历要对 js 模块和 css 模块作不一样的处理:js 模块分析 import 和 require,css 分析 url() 和 @import。
以后要对分析出的路径作处理,变成真实路径。要处理 node_modules、webpack alias、typescript 的 types 等状况,咱们暴露了一个回调函数给开发者本身去扩展。
实现了模块遍历以后,只要指定全部的模块、入口模块,那么咱们就能够找出用到了哪些模块,没用到哪些模块。
通过测试,符合咱们的需求。
这个模块遍历器是通用的,能够用来作各类静态分析,也能够作后续的代码打印作成一个打包器。
代码的 github 地址在这,感兴趣能够拉下来跑跑,学会写模块遍历器仍是挺有帮助的。
当时给昊昊介绍这个功能的时候,写了一份实现思路的文档,也贴在这里吧:
昊昊: 光哥,总体的思路是什么样的啊,一上来就看代码比较乱
我: 模块是一个图的结构,指定从某个入口开始遍历,其实这是一个 dfs 的过程,可是有循环引用,要经过记录处理过的模块来解决。递归遍历这个图,处理到的模块就是用到的。
昊昊: dfs 一个模块,怎么肯定子模块呢?
我: 不一样的模块有不一样的处理方式,好比 js 模块,就要经过 import 或者 require 来肯定子模块,而 css 则要经过 @import 和 url() 来肯定。 可是这些只是提取路径,这个路径仍是不可用的,还须要转换成真实路径,要有一个 resolve path 的过程。
昊昊: resolve path 都作啥啊?
我: 就是处理 alias、过滤 node_modules 下的模块,由于咱们这里用不到,而后根据当前模块的路径肯定子模块的绝对路径。还要暴露出一个钩子函数去让用户可以自定义 require path 的 resolve 逻辑。
昊昊: 就是那个 requireRequirePath 么?
我: 对的,那个就是暴露出去让用户自定义 path resolve 逻辑的钩子。
昊昊: 我大致明白流程了?
我: 说说看
昊昊: 项目的模块构成依赖图,咱们要肯定没有用到的模块,那就要先找出用到的模块,以后把它们过滤掉。用到的模块要用几个入口模块开始作 dfs,遍历不一样的模块有不一样的提取 require path 的方式,提取出来之后还要对 path 进行 resolve,获得真实路径,而后递归进行子模块的处理。这样遍历完一遍就能肯定用到了哪些。同时还要处理循环引用问题,由于毕竟模块是一个图,进行 dfs 会有环在。
我: 对的,棒棒的。