各位,大家好。
今天我们要聊点“狠”的。
如果你是个 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 虽然导出了,但是没人引用它。formatDate 和 calculateTax 根本没被用上。这些代码不仅占着硬盘空间,更可怕的是,它们像病毒一样,污染了你的包体积。
当你运行 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 的元素树呢?
- 我们把所有组件的 JSX 转换成 AST。
- 我们识别出根组件。
- 我们递归地查找每一个
<Component />标签。 - 我们建立一个“存活列表”。
- 我们遍历所有的文件,看谁不在“存活列表”里。
这就需要用到 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;
}
然后你在整个工程里搜索 UserProfile 和 UserSettings,发现这俩接口定义了,但谁也没用过。这就是典型的类型死代码。删掉它们,代码库瞬间清爽。
第五部分:别高兴得太早(坑与挑战)
好,现在大家都想上这个工具。但我得泼点冷水。死代码扫描不是万能药,它是个充满了陷阱的雷区。
陷阱一:动态导入
这是死代码扫描的噩梦。
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 你的文件。
解决方案:
设置一个“缓冲期”。扫描器只报告,不自动删除。团队开会讨论,如果两周后没人认领,再删。
第六部分:工具推荐与集成
既然我们要搞,就得用最趁手的家伙。
-
ESLint 插件:
eslint-plugin-unused-imports: 这个非常火,专门干“未使用导入”的活儿。- 配置它,一旦你保存文件,如果导入了没用的东西,ESLint 直接报错。这就是把 CI/CD 变成了你的私人保洁员。
-
Babel 插件:
- 自己写插件或者用
babel-plugin-transform-remove-unused-exports(注意,这个可能会在生产环境删掉东西,慎用)。 - 更安全的做法是写一个 Babel 插件,专门生成一个报告文件
dead-code-report.json。
- 自己写插件或者用
-
Webpack 插件:
webpack-bundle-analyzer虽然是分析包体积的,但配合插件可以排除死代码。
-
专门的 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 分钟。这比构建本身还慢。
这就要求我们分层治理:
- 个人层面: VSCode 插件。你在写代码的时候,右下角就提示你:“嘿,兄弟,你那个
getUserName函数没用。” 这最快。 - Commit 钩子层面: Husky + ESLint。提交代码前必须过这一关。
- CI/CD 层面: 每天晚上跑一次全量扫描,生成周报发给团队Leader。周末大家不用上班,专门来清理代码。
第八部分:未来的趋势
随着前端工程越来越复杂,静态分析变得越来越智能。
现在的工具已经开始尝试跨文件引用。不仅仅是看当前文件,而是看 src/components 目录下,哪些组件被 src/pages 用了。
甚至更高级的,按需加载优化。扫描器会自动发现你引入了一个大组件库 import { Button, Modal, DatePicker } from 'antd',然后分析你只用了 Button。它会建议你改成 import { Button } from 'antd'。这能帮你减少几兆的体积。
总结(非AI总结版)
好了,今天的讲座就到这里。
我的建议很明确:
- 别做代码囤积癖。 代码越少,Bug 越少,理解起来越快。
- 拥抱死代码扫描。 它是你的好帮手,别怕它报错。
- 人工审核。 工具是死的,人是活的。对于不确定的代码,先留着,但要在代码里写注释,告诉自己为什么留着。
- 定期清理。 每个季度来一次大扫除,删掉那些
TODO和FIXME。
记住,一个干净、轻量级的代码库,就像一辆保养得当的跑车,不仅能跑得快,还能让你的头发掉得更慢(因为写新功能的时候更快乐)。
去清理你的杂物间吧,开工!