前端工程化(二):从模块系统到依赖图与代码分割
模块系统决定依赖如何被声明和分析;动态 `import()` 建立代码分割边界;Tree Shaking 负责在满足条件时移除未使用代码。三者不能混为一谈,最终结论必须分别通过源码、构建产物和浏览器网络行为验证。
ESM 和 CommonJS 的区别不只是写法不同
ESM 使用 import / export,依赖关系更适合在构建前进行静态分析。CommonJS 使用 require / module.exports,加载通常在运行时执行,导出对象也可能动态决定,因此打包器很难像分析 ESM 那样精确判断每个成员是否可以删除。
在 my-world 中,package.json 声明了 type: module,源码主要使用 ESM 风格的 TypeScript 模块引用,本次观察没有发现直接使用 require 或 module.exports 的业务源码。
静态依赖如何形成依赖图
打包器通常从入口文件开始,递归分析模块引用。例如页面入口静态引入多个区块组件时,这些组件及其上游依赖会进入同一张依赖图。打包器随后根据入口、模块关系和构建配置生成最终产物。
静态导入的价值是依赖关系明确,构建工具可以提前分析;动态导入则人为建立了一个分割边界,让模块不必全部进入首屏入口。
动态 import() 与 ssr: false 分别解决什么问题
动态 import() 主要控制加载时机。它通常会让相关模块生成独立 chunk,并在代码运行到导入语句时再加载。在 my-world 中,图片编辑器使用了动态导入:
const FilerobotImageEditor = dynamic(
() => import("react-filerobot-image-editor"),
{ ssr: false }
);这里的 import() 负责按需加载和代码分割,ssr: false 则限制渲染环境:该组件不参与服务端渲染,只在浏览器端加载和渲染。对于依赖 window、document 或图形 API 的组件,这可以避免服务端环境不兼容。
Tree Shaking 的前提和边界
如果一个模块导出了 formatDate 和 formatMoney,业务代码只使用 formatDate,生产构建可能移除未使用的 formatMoney。这就是 Tree Shaking 的基本目标。
但它不是无条件的文本删除。模块存在导入副作用时,例如修改全局变量、注册组件或注入样式,打包器不能随意删除整个模块。CommonJS 的运行时加载特征也会限制静态分析。
sideEffects: false 是开发者向打包器作出的“模块无副作用”声明,不是打包器自动验证的结果。如果 CSS、全局注册或 Polyfill 被错误地当作无副作用文件,可能被错误删除,最终表现为样式或初始化逻辑失效。没有配置 sideEffects 也不代表 Tree Shaking 一定关闭,只是打包器对副作用通常会更保守。
如何检查 .next 构建产物
仅看到 .next/static/chunks 中存在很多文件,只能证明构建产物被拆分,不能单独证明 Tree Shaking 已经充分生效。检查时要分三层:
- 查看
.next/static/chunks是否出现与动态导入对应的独立 JavaScript 文件,并记录入口和 chunk 的体积。 - 在 chunk 中搜索动态模块的特征代码,确认未使用导出是否仍然存在;必要时使用构建分析工具生成可视化报告。
- 启动生产服务,在浏览器 Network 面板中观察页面初始请求和触发功能后的新增请求,确认动态 chunk 是否真的按需加载。
本次 my-world 构建产物中,.next/static/chunks 观察到 81 个 JavaScript chunk 和 8 个 CSS chunk,总大小约 9.19 MB。这证明当前构建确实生成了多份静态资源,但还不能仅凭数量判断 Tree Shaking 效果或首屏性能,需要继续关联具体入口、请求时机和产物内容。
请求瀑布:代码分割不是越细越好
请求瀑布不是“短时间内请求很多文件”,而是前一个资源请求完成后才触发下一个请求,多个请求形成串行等待。动态 chunk 拆得过细、模块之间又存在依赖链时,每次请求的网络固定开销可能叠加,反而延长用户等待时间。
治理请求瀑布可以从四个方向入手:避免动态模块内部继续层层动态导入;对确定很快会使用的 chunk 做预加载或预取;合理控制拆包粒度;对互不依赖的模块并行加载。
有想法或建议? 联系我