UMI 4 新增的 deadCode 检测是如何实现的

前不久发布的 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.fileDependenciescompilation.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 源码实现。具体可以参考这里:

github.com/webpack/web...

4. 不是 UMI 的工程如何支持 deadcode 检测

如果有些不是 UMI 的工程怎么支持 deadcode 检测呢?UMI 官方提供了一个 DetectDeadCodePlugin,直接开箱即用:

packages/bundler-webpack/src/config/detectDeadCodePlugin.ts

相关推荐
GISer_Jing40 分钟前
前端面试通关:Cesium+Three+React优化+TypeScript实战+ECharts性能方案
前端·react.js·面试
落霞的思绪2 小时前
CSS复习
前端·css
咖啡の猫4 小时前
Shell脚本-for循环应用案例
前端·chrome
百万蹄蹄向前冲6 小时前
Trae分析Phaser.js游戏《洋葱头捡星星》
前端·游戏开发·trae
朝阳5816 小时前
在浏览器端使用 xml2js 遇到的报错及解决方法
前端
GIS之路7 小时前
GeoTools 读取影像元数据
前端
ssshooter7 小时前
VSCode 自带的 TS 版本可能跟项目TS 版本不一样
前端·面试·typescript
你的人类朋友7 小时前
【Node.js】什么是Node.js
javascript·后端·node.js
Jerry8 小时前
Jetpack Compose 中的状态
前端
dae bal9 小时前
关于RSA和AES加密
前端·vue.js