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

相关推荐
蟾宫曲3 小时前
在 Vue3 项目中实现计时器组件的使用(Vite+Vue3+Node+npm+Element-plus,附测试代码)
前端·npm·vue3·vite·element-plus·计时器
秋雨凉人心3 小时前
简单发布一个npm包
前端·javascript·webpack·npm·node.js
liuxin334455663 小时前
学籍管理系统:实现教育管理现代化
java·开发语言·前端·数据库·安全
qq13267029403 小时前
运行Zr.Admin项目(前端)
前端·vue2·zradmin前端·zradmin vue·运行zradmin·vue2版本zradmin
魏时烟5 小时前
css文字折行以及双端对齐实现方式
前端·css
哥谭居民00015 小时前
将一个组件的propName属性与父组件中的variable变量进行双向绑定的vue3(组件传值)
javascript·vue.js·typescript·npm·node.js·css3
踢足球的,程序猿6 小时前
Android native+html5的混合开发
javascript
2401_882726486 小时前
低代码配置式组态软件-BY组态
前端·物联网·低代码·前端框架·编辑器·web
web130933203986 小时前
ctfshow-web入门-文件包含(web82-web86)条件竞争实现session会话文件包含
前端·github
胡西风_foxww6 小时前
【ES6复习笔记】迭代器(10)
前端·笔记·迭代器·es6·iterator