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

相关推荐
在云端易逍遥2 分钟前
前端必学的 CSS Grid 布局体系
前端·css
EMT2 分钟前
在 Vue 项目中使用 URL Query 保存和恢复搜索条件
javascript·vue.js
ccnocare3 分钟前
选择文件夹路径
前端
艾小码3 分钟前
还在被超长列表卡到崩溃?3招搞定虚拟滚动,性能直接起飞!
前端·javascript·react.js
闰五月4 分钟前
JavaScript作用域与作用域链详解
前端·面试
泉城老铁8 分钟前
idea 优化卡顿
前端·后端·敏捷开发
前端康师傅8 分钟前
JavaScript 作用域常见问题及解决方案
前端·javascript
司宸9 分钟前
Prompt结构化输出:从入门到精通的系统指南
前端
我是日安10 分钟前
从零到一打造 Vue3 响应式系统 Day 9 - Effect:调度器实现与应用
前端·vue.js