前不久发布的 UMI4 新增一个非常实用的功能:deadCode 检测。
随着项目不断迭代,项目中通常会有未使用的文件或导出,这些 deadcode 增加了工程维护的复杂度,降低代码的健壮性,靠人力去清理 deadcode 非常耗时。Umi 4 中通过配置 deadCode: {}
即可在 build 阶段做检测。如有发现,会有类似信息抛出。
bash
Warning: There are 3 unused files:
1. /mock/a.ts
2. /mock/b.ts
3. /pages/index.module.less
Please be careful if you want to remove them (¬º-°)¬.
1. 核心流程
UMI 源码中 deadCode 检测相关代码在这里:
packages/bundler-webpack/src/config/detectDeadCode.ts
核心源码如下:
ts
// 检测忽略的文件目录
export const ignores: string[] = [
'**/node_modules/**',
'**/.umi/**',
'**/.umi-production/**',
'**/.umi-test/**',
'coverage/**',
'dist/**',
'config/**',
'public/**',
'mock/**',
];
const detectDeadCode = (compilation: Compilation, options: Options) => {
// 获取 Webpack 打包资源
const assets: string[] = getWebpackAssets(compilation);
const compiledFilesDictionary: FileDictionary = convertFilesToDict(assets);
// 获取当前工作目录(提供给 glob)
const context = options.context!;
// 生成工程目录的 pattern(提供给 glob 解析用,默认是 `['src/**/*']`)
if (!options.patterns.length) {
options.patterns = getDefaultSourcePattern({ cwd: context });
}
// 使用 glob 批量读取工程目录下所有文件路径
const includedFiles: string[] = options.patterns
.map((pattern) => {
return glob.sync(pattern, {
ignore: [...ignores, ...options.exclude],
cwd: context,
absolute: true,
});
})
.flat();
// 过滤出未使用的文件
const unusedFiles: string[] = options.detectUnusedFiles
? includedFiles.filter((file) => !compiledFilesDictionary[file])
: [];
// 过滤出未使用的 `export` 导出
const unusedExportMap: ExportDictionary = options.detectUnusedExport
? getUnusedExportMap(convertFilesToDict(includedFiles), compilation)
: {};
// 打印 console
logUnusedFiles(unusedFiles);
logUnusedExportMap(unusedExportMap);
// 如果存在未使用文件或者 `export` 导出,且配置了 `options.failOnHint`
// 则调用 `process.exit(2)` 退出打包进程
const hasUnusedThings =
unusedFiles.length || Object.keys(unusedExportMap).length;
if (hasUnusedThings && options.failOnHint) {
process.exit(2);
}
}
2. 如何获取打包资源
这边涉及到一个 getWebpackAssets
方法用来获取 Webpack 打包资源,核心就是从 compilation.fileDependencies
和 compilation.getAssets()
获取打包的所有资料列表:
ts
const getWebpackAssets = (compilation: Compilation): string[] => {
const outputPath: string = compilation.getPath(
compilation.compiler.outputPath,
);
const assets: string[] = [
// `compilation.fileDependencies` 获取源文件的资源列表
...Array.from(compilation.fileDependencies),
// `compilation.getAssets()` 获取打包输出的资源列表
...compilation
.getAssets()
.map((asset) => path.join(outputPath, asset.name)),
];
return assets;
};
这边还需要找一个合适的 compiler hook
去操作 compilation.fileDependencies
,通过源码可知 UMI 用的是 compiler.hooks.afterEmit
:
ts
// packages/bundler-webpack/src/config/detectDeadCodePlugin.ts
class DetectDeadCodePlugin {
// ...
apply(compiler: Compiler) {
if (!this.options.context) {
this.options = {
...this.options,
context: compiler.context,
};
}
// 等到 Webpack 输出文件之后执行
compiler.hooks.afterEmit.tapAsync(
'DetectDeadCodePlugin',
this.handleAfterEmit,
);
}
handleAfterEmit = (
compilation: Compilation,
callback: InnerCallback<Error, any>,
) => {
detectDeadcode(compilation, this.options);
callback();
};
}
3. 如何检测未使用导出
从上面的核心流程中看出,检测未使用文件比较简单,只需要对比 glob 获取的文件路径和 Webpack 打包资源信息即可。那么未使用 export
导出是如何检测的呢,其实就是逐一分析每个 module 的 export
,再从 chunk 获取 export
使用情况,将两个集合相减就可得到模块未使用导出。看下 getUnusedExportMap
源码实现:
ts
const getUnusedExportMap = (
includedFileMap: FileDictionary,
compilation: Compilation,
): ExportDictionary => {
// 存放未使用 export 导出的 map
const unusedExportMap: ExportDictionary = {};
// 遍历 chunks
compilation.chunks.forEach((chunk) => {
// 针对每个 chunk 遍历其中的 module
compilation.chunkGraph.getChunkModules(chunk).forEach((module) => {
// 分析每个 module 的未使用导出
outputUnusedExportMap(
compilation,
chunk,
module,
includedFileMap,
unusedExportMap,
);
});
});
return unusedExportMap;
};
const outputUnusedExportMap = (
compilation: Compilation,
chunk: Chunk,
module: Module,
includedFileMap: FileDictionary,
unusedExportMap: ExportDictionary,
) => {
if (!(module instanceof NormalModule) || !module.resource) {
return;
}
const path = winPath(module.resource);
// 不检测第三方库的未使用导出
if (!/^((?!(node_modules)).)*$/.test(path)) return;
// 实现 deadcode 检测核心逻辑
const providedExports =
compilation.chunkGraph.moduleGraph.getProvidedExports(module);
const usedExports = compilation.chunkGraph.moduleGraph.getUsedExports(
module,
chunk.runtime,
);
if (
usedExports !== true &&
providedExports !== true &&
includedFileMap[path]
) {
if (usedExports === false) {
if (providedExports?.length) {
// 模块所有 export 都未使用
unusedExportMap[path] = providedExports;
}
} else if (providedExports instanceof Array) {
// 模块部分 export 未使用
const unusedExports = providedExports.filter(
(item) => usedExports && !usedExports.has(item),
);
if (unusedExports.length) {
unusedExportMap[path] = unusedExports;
}
}
}
};
注意上面代码中有两个核心方法:
ts
/**
* 获取当前模块的 `export`
*
* true => only the runtime knows if it is provided
* string[] => it is provided
* null => it was not determined if it is provided
*/
const providedExports = compilation.chunkGraph.moduleGraph.getProvidedExports(module);
/**
* 从 `chunk.runtime` 获取当前模块的 `export` 使用情况
*
* false => 模块 export 全部都未使用
* true => 模块 export 全部都被使用
* SortableSet<string> => 模块 export 部分被使用
* empty SortableSet<string> => 模块被使用,但没有 export
* null => unknown
*/
const usedExports = compilation.chunkGraph.moduleGraph.getUsedExports(module, chunk.runtime);
这两个方法主要是 Webpack 内部用于实现 Tree-Shaking 的,官方文档上并没有说明,本人猜测作者实现 deadcode 检测的时候,应该参考了 Tree-Shaking 源码实现。具体可以参考这里:
4. 不是 UMI 的工程如何支持 deadcode 检测
如果有些不是 UMI 的工程怎么支持 deadcode 检测呢?UMI 官方提供了一个 DetectDeadCodePlugin
,直接开箱即用:
packages/bundler-webpack/src/config/detectDeadCodePlugin.ts