React 项目中的“死代码”静态扫描:针对超大规模工程的性能瘦身

各位,大家好。

今天我们要聊点“狠”的。

如果你是个 React 开发者,你现在脑子里是不是正飘着一股淡淡的哀愁?这种感觉就像是你发现床底下堆满了三年前买回来的快递盒子,结果现在想找一只拖鞋都得搬开半个城市。

在超大规模的前端工程里,这不仅仅是快递盒,这是“代码尸体”。我们的项目越来越大,依赖越来越多,包体积越来越臃肿。有时候,你打开 node_modules 的时候,就像是在逛一个充满了旧时代的各种遗留技术的混乱集市。

今天,我们不讲 useMemo,不讲 React.memo,也不讲 CSS-in-JS 的优化。我们要讲的是前端工程的“外科手术”——死代码静态扫描。我们的目标很简单:把你那些早已弃用的、没人调用的、幽灵般的代码揪出来,把它们送进火葬场。

别以为这事儿简单,这可是个技术活儿,甚至可以说是一场“大清洗”。

第一部分:代码界的“杂物间”现象

咱们先来盘一盘,这个“死代码”到底是个什么鬼东西?

在很多团队,尤其是那种写了五年的项目里,死代码无处不在。

你打开一个五年前写的组件文件,可能看到这样的景象:

// OldLoginModal.tsx
import React, { useState, useEffect, useMemo } from 'react';
import { Button, Input } from 'antd';

// 这个组件大概三年前就被废弃了,改名叫 NewLoginModal 了
// 但是没人删,因为删了怕出 Bug
export const OldLoginModal = () => {
  const [loading, setLoading] = useState(false);

  // 这是一个很久以前写的函数,现在根本没地方用到
  // 也就是所谓的“死函数”
  const handleLegacySubmit = () => {
    console.log('Legacy submit');
  };

  return (
    <div>
      <h1>Old Login</h1>
      <Button onClick={handleLegacySubmit}>Submit</Button>
    </div>
  );
};

// 这是一堆从未被导出的工具函数,仿佛生在深闺人未识
function formatDate(date: Date): string {
  return date.toISOString();
}

function calculateTax(amount: number): number {
  return amount * 0.8;
}

你看,OldLoginModal 虽然导出了,但是没人引用它。formatDatecalculateTax 根本没被用上。这些代码不仅占着硬盘空间,更可怕的是,它们像病毒一样,污染了你的包体积。

当你运行 npm install 的时候,这些死代码可能随着某个依赖包一起进了你的 node_modules。当你打包构建的时候,打包工具(Webpack, Vite, Turbopack)虽然很努力,但它们不是超能力者,它们也有盲区。

更绝的是,有时候代码虽然被引用了,但运行时根本没走到那个逻辑分支。那也是死代码。

第二部分:死代码的三个等级

要想清理干净,我们不能眉毛胡子一把抓。我们需要给死代码分个级。这就像玩扫雷,你得先扫清明雷。

第一级:名字引用(名字未引用)

这是最简单的一类。编译器或者打包工具扫描所有的导入语句,看看这些导入的名字有没有被使用。

比如:

// utils.ts
export const formatDate = (date: Date) => date.toISOString();
export const formatMoney = (amount: number) => `$${amount}`;
// App.tsx
import { formatDate } from './utils'; // 导入了 formatDate
import { formatMoney } from './utils'; // 导入了 formatMoney

// 但是!
export default function App() {
  // 我只用了 formatDate,formatMoney 去哪了?不知道,也许以后用吧。
  return <div>{formatDate(new Date())}</div>;
}

formatMoney 就是第一级死代码。Webpack 会告诉你:“嘿,哥们,你把 formatMoney 带进包里了,但你这辈子都没用一下。”

第二级:引用与渲染(组件未渲染)

这个稍微复杂点,尤其是对 React 来说。你导入了组件,但你可能只是把它藏在某个角落。

// components/LogoutButton.tsx
import { Button } from 'antd';

export const LogoutButton = ({ onLogout }: { onLogout: () => void }) => (
  <Button type="primary" onClick={onLogout}>
    Logout
  </Button>
);
// App.tsx
import { LogoutButton } from './components/LogoutButton'; // 导入了
import { UserMenu } from './components/UserMenu'; // 导入了

export default function App() {
  // 这是一个极度复杂的权限管理系统
  // 只有当 user.role === 'admin' 时才渲染 UserMenu
  // 但是,这个文件里甚至没有 if (user.role === 'admin') 语句!
  // 这意味着 UserMenu 永远不会被渲染,它是第二级死代码。

  return (
    <div>
      <h1>Welcome</h1>
      {/* 这里压根没用到 LogoutButton */}
    </div>
  );
}

这就像是买了张电影票(导入组件),结果进了电影院发现电影根本不播放(未渲染)。虽然票钱(构建体积)已经付了,但你什么都没看到。

第三级:依赖与文件(文件未导出)

这是最隐蔽的一层。你写了一个文件,里面写了很多工具函数,结果最后 export 的时候只导出了一半。

或者,你有一个文件,虽然它导出了东西,但整个工程里没有任何一个地方 import 它。这种文件在构建工具里会被视为“孤立文件”,虽然它们不占包体积(除非它们内部有副作用),但它们会让代码库变得难以维护。

还有更惨的,依赖未使用

npm install 了一堆包,比如 moment, lodash, axios,结果代码里全都是用原生 API 写的,或者用的别的库。这些包占着 50MB 的磁盘空间,却连个影都没见着。

第三部分:怎么干?(技术实现与工具)

好了,废话不多说,怎么动手?我们要构建一个“死代码扫描仪”。

别慌,我们不需要造火箭,我们只需要理解 AST(抽象语法树)。

核心原理:AST 遍历

想象一下,JavaScript 代码是像“乱糟糟的树”一样的结构。编译器把它转变成一棵整齐的 JSON 树,这就是 AST。

我们的扫描器要做的事情就是:拿着这棵树,把“叶子”都摘掉。

举个例子,AST 结构大概长这样:

{
  "type": "Program",
  "body": [
    {
      "type": "ImportDeclaration",
      "specifiers": [
        { "type": "ImportSpecifier", "local": { "name": "formatDate" }, "imported": { "name": "formatDate" } }
      ],
      "source": { "value": "./utils" }
    },
    {
      "type": "FunctionDeclaration",
      "id": { "name": "formatDate" },
      // ... 函数体 ...
    }
  ]
}

实战代码:手写一个简单的死代码扫描插件

为了让大家听明白,我写一个基于 Babel 插件的扫描器原型。这玩意儿能帮你找到第一级死代码。

// babel-plugin-dead-code-scanner.js
module.exports = function ({ types: t }) {
  return {
    visitor: {
      ImportDeclaration(path, state) {
        const sourceValue = path.node.source.value;

        // 遍历这个导入语句里所有的引入的变量
        path.node.specifiers.forEach(specifier => {
          let name;

          // 处理导入单个变量: import { formatDate } from '...'
          if (t.isImportSpecifier(specifier)) {
            name = specifier.local.name;
          } 
          // 处理全量导入: import * as utils from '...'
          else if (t.isImportNamespaceSpecifier(specifier)) {
            name = specifier.local.name;
          }

          // 关键步骤:检查这个名字在整个文件里有没有被使用
          // 我们通过查找 Program 下的所有 VariableDeclarator 和 FunctionDeclaration 来判断
          const binding = path.scope.getBinding(name);

          if (binding && binding.constantViolations.length === 0) {
             // 如果没有被修改过,我们再查查有没有被引用
             const references = binding.referencePaths.length;
             if (references === 0) {
               console.log(`发现死代码:文件 ${path.hub.file.opts.filename} 中引入了未使用的变量: ${name}`);
               // 在这里,你可以通过 path.insertBefore(...) 插入警告代码
               // 或者直接报错阻止编译
             }
          }
        });
      }
    }
  };
};

这个插件逻辑很简单:拿到导入的变量名,去作用域里查一查,有没有被“动”过,如果没有被“动”过(constantViolations为0),再看看有没有被“提”到其他地方用过。如果都没有,那就是死代码。

进阶:React 组件渲染树扫描

光找出未使用的导入还不够,React 的坑在于“渲染树”。

如果我们写一个工具,它不解析代码逻辑,而是解析 React 的元素树呢?

  1. 我们把所有组件的 JSX 转换成 AST。
  2. 我们识别出根组件。
  3. 我们递归地查找每一个 <Component /> 标签。
  4. 我们建立一个“存活列表”。
  5. 我们遍历所有的文件,看谁不在“存活列表”里。

这就需要用到 react-jss 或者 @babel/parser 配合 babel-traverse 来构建完整的渲染树模型。

比如:

// render-tree-analyzer.js
function analyzeRenderTree(rootNode) {
  const aliveComponents = new Set();

  function traverse(node) {
    if (node.type === 'JSXElement') {
      // 获取组件名字,比如 <LogoutButton />
      const componentName = node.openingElement.name.name;
      aliveComponents.add(componentName);

      // 递归处理子节点
      node.children.forEach(traverse);
    }
  }

  traverse(rootNode);
  return aliveComponents;
}

通过这个分析,我们可以告诉用户:“你的 App.tsx 引用了 OldButton,但是它在整个渲染树里根本不存在。”

第四部分:超大规模工程的实战场景

现在,假设你的项目是一个拥有 5000 个文件的“巨石应用”。你上线了一个死代码扫描 CI。

场景一:僵尸依赖

package.json 里,你可能手滑多装了一个库。

{
  "dependencies": {
    "moment": "^2.29.4", // 想着以后算时间方便,结果一直用 Date.now()
    "lodash": "^4.17.21", // 其实只需要 cloneDeep,却装了整个库
    "antd": "^4.24.12",
    "my-dead-library": "^1.0.0" // 一个内部团队两年前写的,现在没人用了
  }
}

你的扫描工具会扫描 node_modules 下的代码引用关系。它发现 moment 没有任何一行代码引用它。它发现 my-dead-library 也没人引用。

结果: 你从 500MB 的 node_modules 里删掉了 50MB 的垃圾。构建时间从 3 分钟缩短到了 2 分钟。这在超大规模工程里,可是救命的速度。

场景二:未使用的 CSS 类名

这不仅仅是代码的问题,还有样式。如果你的项目用了 CSS Modules 或者 styled-components,你可能会发现 App.css 里有几百个类,但只有 10 个在用。

这就需要配合 CSS 分析工具(如 purgecss 或者 Webpack 的 MiniCssExtractPlugin),但在代码层面,我们扫描的是组件的引用。

如果你的组件 Header 被导入了,但从未被渲染,那么它里面的所有样式类统统都是死的。

场景三:TypeScript 的空接口

在大工程里,你会看到很多定义了接口却没用的现象。

// types/user.ts
export interface UserProfile {
  id: string;
  name: string;
}

export interface UserSettings {
  theme: 'dark' | 'light';
}

// types/product.ts
export interface Product {
  id: string;
  price: number;
}

然后你在整个工程里搜索 UserProfileUserSettings,发现这俩接口定义了,但谁也没用过。这就是典型的类型死代码。删掉它们,代码库瞬间清爽。

第五部分:别高兴得太早(坑与挑战)

好,现在大家都想上这个工具。但我得泼点冷水。死代码扫描不是万能药,它是个充满了陷阱的雷区。

陷阱一:动态导入

这是死代码扫描的噩梦。

if (window.location.hostname === 'production') {
  import('./HeavyFeature').then(module => module.init());
}

你的扫描器看到的是 import('./HeavyFeature'),它会认为这个文件是活的,因为它被引用了。但实际上,只有在生产环境才会加载它。

如果你的扫描器不够智能,可能会误报,或者在极端情况下报错。

陷阱二:副作用

有些文件导入了,不是为了用函数,而是为了“副作用”。

比如:

// init-db.js
// 这个文件虽然没导出任何东西,但它执行了连接数据库的操作
import 'db-connector';

// 但是代码里根本没 import 'init-db'

这种文件在 Webpack 里是“全局引用”,无论你用没用,它都会被加载。静态扫描器很难分辨这种意图,除非你人工标记。

陷阱三:误报与政治斗争

这是最难搞的一层。

开发人员A写了一个函数,写得很完美,注释写着 // 预留接口,未来可能用。结果三年过去了,也没用上。

你作为专家,跑了个扫描器,把 PreReserveFunction 标红,准备删除。

开发人员A火了:“你懂个屁!万一哪天老板说要改需求,我就要用这个函数!你删了它,我还得重写一遍!”

这就涉及到了工程管理的艺术。死代码扫描应该是一个“建议者”,而不是“刽子手”。它应该打印报告,指出哪里可能有冗余,而不是直接 git rm 你的文件。

解决方案:
设置一个“缓冲期”。扫描器只报告,不自动删除。团队开会讨论,如果两周后没人认领,再删。

第六部分:工具推荐与集成

既然我们要搞,就得用最趁手的家伙。

  1. ESLint 插件:

    • eslint-plugin-unused-imports: 这个非常火,专门干“未使用导入”的活儿。
    • 配置它,一旦你保存文件,如果导入了没用的东西,ESLint 直接报错。这就是把 CI/CD 变成了你的私人保洁员。
  2. Babel 插件:

    • 自己写插件或者用 babel-plugin-transform-remove-unused-exports(注意,这个可能会在生产环境删掉东西,慎用)。
    • 更安全的做法是写一个 Babel 插件,专门生成一个报告文件 dead-code-report.json
  3. Webpack 插件:

    • webpack-bundle-analyzer 虽然是分析包体积的,但配合插件可以排除死代码。
  4. 专门的 CLI 工具:

    • npx depcheck 这种工具,专门分析 package.json 的依赖。

集成方案:

我们要把这玩意儿塞进 CI 流水线里。

# .gitlab-ci.yml 或者 GitHub Actions
build:
  stage: build
  script:
    - npm install
    # 运行死代码扫描
    - npx depcheck --ignores="**/node_modules/**,**/*.test.ts" --json > dead-code-report.json
    # 解析 JSON,如果报告里有死代码,CI 失败
    - node scripts/check-report.js

第七部分:性能与维护的平衡

在超大规模工程里,死代码扫描本身也是有成本的。

如果你的项目有 10 万个文件,你用一个静态分析器跑一次,可能需要 5 分钟。这比构建本身还慢。

这就要求我们分层治理

  1. 个人层面: VSCode 插件。你在写代码的时候,右下角就提示你:“嘿,兄弟,你那个 getUserName 函数没用。” 这最快。
  2. Commit 钩子层面: Husky + ESLint。提交代码前必须过这一关。
  3. CI/CD 层面: 每天晚上跑一次全量扫描,生成周报发给团队Leader。周末大家不用上班,专门来清理代码。

第八部分:未来的趋势

随着前端工程越来越复杂,静态分析变得越来越智能。

现在的工具已经开始尝试跨文件引用。不仅仅是看当前文件,而是看 src/components 目录下,哪些组件被 src/pages 用了。

甚至更高级的,按需加载优化。扫描器会自动发现你引入了一个大组件库 import { Button, Modal, DatePicker } from 'antd',然后分析你只用了 Button。它会建议你改成 import { Button } from 'antd'。这能帮你减少几兆的体积。

总结(非AI总结版)

好了,今天的讲座就到这里。

我的建议很明确:

  1. 别做代码囤积癖。 代码越少,Bug 越少,理解起来越快。
  2. 拥抱死代码扫描。 它是你的好帮手,别怕它报错。
  3. 人工审核。 工具是死的,人是活的。对于不确定的代码,先留着,但要在代码里写注释,告诉自己为什么留着。
  4. 定期清理。 每个季度来一次大扫除,删掉那些 TODOFIXME

记住,一个干净、轻量级的代码库,就像一辆保养得当的跑车,不仅能跑得快,还能让你的头发掉得更慢(因为写新功能的时候更快乐)。

去清理你的杂物间吧,开工!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注