尧图精选

require、import()与静态import:动态引入、代码分割与Tree Shaking的取舍

🕒 发布时间:2026/10/2 16:12:00 📁 来源:尧图网络
上周帮同事排查一个运行时错误看到他把项目里一大部分import都改成了require理由是“动态引入才能按需加载、加快首屏”。结果构建产物不减反增tree shaking 几乎失效因为require的写法把打包器的静态分析全给堵死了。这个误会的背后正好是标题里那组让很多人绕不清楚的概念require、import()的动态引入与import的静态引入到底谁动、谁静、各自解决什么问题。这篇文章我就把在 Node 和浏览器两端这些年实际碰过的场景摊开讲清楚。1. 两套模块体系为何一直并存CommonJS 与 ESM 的前世今生1.1 从没有模块到社区各自发明轮子JavaScript 出生的时候根本没有模块这回事。一个页面引多个script变量全都挂到全局作用域谁后加载谁覆盖排查起来简直灾难。后来大家用 IIFE 包一层模拟私有作用域再后面 AMD、CMD、UMD 各种社区方案轮番登场写法五花八门。真正扭转局面的是 Node.js。2009 年 Node 出现时把 CommonJS 作为官方模块规范定下来服务端代码天然要读文件加载本地模块做成同步的require是最直接的方案。代码写到哪一行require到哪一行文件读出来立刻执行然后返回module.exports。对服务器来说这逻辑完全成立所以 CommonJS 在 Node 生态里一统治就是快十年。浏览器端就不一样了。浏览器不能直接跑require因为没法同步读本地文件所以后来出现了 webpack、Rollup 这类构建工具在打包阶段把 CommonJS 模块“翻译”成浏览器能执行的代码。这就埋下了一个长期共存的伏笔服务端是 CommonJS 的地盘前端靠打包器过日子。1.2 ESM 标准落地与 Node 的漫长适配2015 年 ES6 正式带来了import/export。这是语言层面的模块语法不是社区约定。它的核心特点是静态化模块的依赖关系在代码执行前就能被完整解析。浏览器原生支持import又花了好几年真正大规模普及其实是构建工具先在编译层替你搞定的。Node 这边适配 ESM 更是一波三折。一开始搞.mjs后缀后来加了package.json里的type: module字段再后来才有了.cjs后缀用来强制声明 CommonJS。到现在 Node 12.17 之后 ESM 才算逐步稳定新项目用 ES Module 已经是默认选项但存量 Node 生态里还有海量 CommonJS 代码。所以你现在翻开任何工程大概率能看到三套写法同时存在底层依赖库用require业务代码顶层用import需要懒加载或条件加载的地方用import()。这就是为什么必须把三者的差异彻底弄明白不然迁移、调试、优化构建的时候会处处碰壁。维度CommonJSES Module出身Node.js 社区规范ECMAScript 语言标准加载时机运行时同步编译期静态解析语法require/module.exportsimport/export浏览器原生不支持支持Node 支持默认支持需type: module或.mjs2. 静态 import 的价值不止是“写在顶部”这么简单2.1 “静态”到底指什么解析期就知道全部依赖import语句有三个硬性约束必须出现在模块顶层、路径必须是字符串字面量、不能写在条件分支里。也就是说import foo from ./foo.js里的路径不可能是一个变量拼接出来的结果。这些约束听起来像限制实际上是给解析器开了一扇大门。代码还没执行引擎和打包器就能把整张依赖图画出来像一个预先加载好的导航地图而不是走到路口才现问路。ES Module 的加载分了两个阶段先解析依赖关系并实例化模块再按依赖顺序执行求值。因为依赖图是完整的所以并行加载、预加载这些优化才有空间。2.2 树摇Tree Shaking为什么必须靠静态引入这是静态import最重要的红利之一。打包器在压缩时能看到你具体导入了哪个具名成员然后把它没用到的那部分从产物里剔除。// utils.js export function usedFn() { return used; } export function unusedFn() { return unused; } // app.js import { usedFn } from ./utils.js; console.log(usedFn());如果用import { usedFn }unusedFn在 production 构建时基本可以被安全删掉。如果改成const utils require(./utils.js)打包器拿到的是整个对象它没法判断你究竟会不会用到utils.unusedFn最稳妥的做法只能是全量保留。我见过太多项目因为图省事把所有import改成了require构建产物体积直接回到解放前就是这个原因。2.3 静态引入的限制和后遗症有一说一静态import也有让人头疼的地方。第一它不能动态决定加载时机。路由级别的懒加载、用户触发才加载的重组件用import是做不到的。第二import有提升hoisting行为无论你把import写在文件哪个位置它都会在模块初始化阶段先执行。如果一个模块在顶层有副作用代码你只要引了它就躲不掉。第三在浏览器原生 ESM 里如果模块路径写错或者网络失败错误会在依赖解析阶段暴露运行时错误排查起来更隐蔽。所以静态import的正确姿势是能静态就静态它让你的依赖关系可预测、可以被工具分析、可以参与 tree shaking。动态需求另找出口这就是import()存在的意义。3. require 是函数而不是语法它凭什么被称为动态引入的老将3.1 require 的执行时机与缓存机制先纠正一个常见误解require不是一个语法关键字它是 Node 在模块作用域里注入的一个普通函数。既然是函数它就不受“必须写在顶部”的限制。你可以写在函数体内、写在 if 分支里、写在循环里甚至可以拿变量拼接路径function loadPlugin(name) { return require(./plugins/ name .js); } if (process.env.NODE_ENV development) { const devLogger require(./dev-logger.js); }这才是require被称为“动态引入”的真正含义——它的加载行为发生在运行时由当时的执行条件决定。另一个被很多人忽略的点是缓存。Node 对每个require过的模块按绝对路径缓存第二次require同一个模块直接返回缓存对象。这意味着所有引用同一个 CJS 模块的地方拿到的都是同一个实例。很多“单例”工具喜欢用 CommonJS 实现就是吃了这个缓存红利。3.2 require 的“动态”和 import() 不是一回事但注意require的动态是同步动态。执行到require那一行Node 必须立刻把文件读出来、跑完、返回module.exports期间阻塞当前执行流程。在服务端场景这通常无所谓但在浏览器端这不是原生能力只能靠打包器处理。打包器遇到require(固定字符串)还能帮你静态分析遇到require(前缀 name .js)这种表达式路径它没法在编译期知道所有可能的路由只能退化成“上下文模块”context module把所有匹配模式的文件全部打包然后给你甩一个警告。这个问题的完整排查过程我放到后面踩坑部分细说。还有一点值得记牢CommonJS 里导出的是module.exports这个对象。基本类型在require那一刻就被固定成当时的快照对象则共享同一个引用。而 ES Module 的导入是 live binding模块内部后续对导出值做的修改在导入方也能观察到。这个差异在很多刁钻的 bug 里会突然跳出来咬你一口。4. import() 才是真正的异步动态引入代码分割的主战场4.1 语法形态与返回值import()看起来像函数调用其实是语言层面的动态导入语法。它接收一个表达式的路径返回一个 Promiseresolve 之后拿到模块命名空间对象Module Namespace Object。const chartType pie; const chartModule await import(./charts/${chartType}.js); chartModule.init();和require相比最核心的区别有两个。第一是异步import()不会阻塞当前代码适合网络加载场景第二是它按 ESM 语义返回命名空间具名导出直接挂在对象上module.exports则出现在default字段里这个后面有专门坑。因为是异步的它可以在任何地方调用——事件回调、条件判断、生命周期函数里都行。在浏览器端开销上用import()加载的模块可以和主包并行拉取体验优势非常明显。4.2 代码分割webpack 约定的标准写法动态import()是打包器做代码分割的标准抓手。webpack 看到import()后默认就会把目标模块拆成一个独立 chunk按需加载。还可以用魔法注释控制 chunk 名称import(/* webpackChunkName: chart-pie */ ./charts/pie.js);这比手工维护拆包配置优雅太多。我做过一个内部数据可视化平台图表库体积非常大改成按图表类型动态加载后首屏 JS 直接从 2.4MB 掉到 700KB。原理其实很简单静态import的模块全被打进主 bundleimport()的模块被单独拆出去用户跳到对应页面才触发请求。4.3 框架里的懒加载写法React 和 Vue 都把动态导入做成了“标准动作”const HeavyChart React.lazy(() import(./HeavyChart.js));const HeavyChart defineAsyncComponent(() import(./HeavyChart.vue));本质都是一回事先把组件的代码切成独立 chunk等真正渲染时才异步拉取。可以这么说今天你遇到“按需加载”、“懒加载”、“代码分割”这些需求第一选择永远是import()而不是require更不是把所有代码塞进静态import里硬扛。5. 一张对照表和选型原则解决日常 90% 的使用场景5.1 核心差异对照表对比维度import静态requireCommonJSimport()动态本质语法关键字运行时函数语法关键字表达式加载时机编译期分析模块初始化即加载运行到该行才同步加载调用时异步加载是否支持条件加载不支持支持支持路径是否支持变量不支持支持支持返回内容模块命名空间语法绑定module.exports对象Promise模块命名空间浏览器原生支持支持不支持支持是否利于 tree shaking是否是对分块后模块仍可分析是否触发代码分割不触发视打包器配置自动触发循环依赖处理live binding相对稳健易拿到半成品对象与 ESM 语义一致5.2 选型决策建议我的日常判断标准简单粗暴普通业务依赖、库依赖一律用静态import让打包器有最大的优化空间。路由懒加载、重组件按需加载、某个功能用户触发才用用import()。体积较大且无法静态分析的动态逻辑比如远程加载一个用户自定义的配置文件用import()配合一个明确的文件映射表。纯 Node 脚本、存量 CommonJS 项目、或者你在维护一个 npm 包需要兼容老工具链用require不丢人但心里要清楚它带来的分析盲区。还有一种情况值得单独说你在写一个共享库希望 ESM 消费者和 CJS 消费者都能拿到合适产物。可以在package.json里配条件导出{ exports: { .: { import: ./dist/index.mjs, require: ./dist/index.cjs } } }这样 Node 会根据加载方是 ESM 还是 CJS 自动选择入口两边互不污染。6. 我在切换与混用过程中真实踩过的坑6.1 循环依赖CJS 手里那个让人头疼的“半成品对象”有次我把一个工具库从 CommonJS 迁到 ESM迁移完发现某个模块初始化时拿到的依赖值是undefined。排查到最后问题根源是两个模块互相引用形成环。// a.js const b require(./b); console.log(a 拿到的 b 是, b); module.exports { name: a, describeB() { return b.name; } }; // b.js const a require(./a); console.log(b 拿到的 a 是, a); module.exports { name: b };在 CommonJS 世界里a.js先执行require(./b)时跳到b.jsb.js反过来require(./a)但此时a.js还没执行完module.exports还是默认的空对象。所以b.js拿到的a就是一个{}不是完整的模块。Node 的缓存机制在这里反而成了陷阱它为了避免无限递归只能返回一个“加载到一半”的导出对象。ESM 的 live binding 能在一定程度上缓解这个问题因为它引用的是内存里的真实绑定而不是加载瞬间的快照。但更稳妥的解法永远是重构依赖方向把共用的逻辑抽到第三层模块打破环路。千万别指望模块系统替你把环形依赖设计亡羊补牢。6.2 切到 ESM 后 __dirname 突然没了这个坑几乎所有从 CJS 迁 ESM 的人都会踩。在 CommonJS 里__dirname是拿来即用的全局变量。迁移后第一行代码写path.join(__dirname, config)直接抛出ReferenceError: __dirname is not defined in ES module scope原因很直白ESM 是标准语法没有 CommonJS 那套隐式变量。解法也很成熟通过import.meta.url自己算import { fileURLToPath } from node:url; import { dirname } from node:path; const __dirname dirname(fileURLToPath(import.meta.url));如果你用的 Node 版本够新20.11连这步都能省直接用import.meta.dirname。这类“迁移后环境变量消失”的问题排查链路只要抓住一个前提就好ESM 里一切作用域信息都得显式获取不要再假设 Node 会偷偷塞给你。6.3 动态拼接 require 路径触发 Critical dependency 警告我在一个 webpack 老项目里写过这样的代码用来按语言加载 locale 文件const localePath ../locales/ locale .json; const localeData require(localePath);构建时控制台疯狂刷警告Critical dependency: the request of a dependency is an expression当时的第一反应是“这不也跑得通吗”后来才意识到问题的严重性。webpack 无法在编译期知道locale有哪几种取值它不知道依赖图的完整边界第二是把所有这些可能文件全部打成一个巨大的上下文模块第三是你要的 tree shaking 彻底泡汤。正确的处理方式是把“动态”收敛成一张显式映射表const localeLoaders { zh: () import(../locales/zh.json), en: () import(../locales/en.json), ja: () import(../locales/ja.json), }; const data await localeLoaders[locale]();这样每个文件都是独立 chunk路径确定可分析构建器也没有歧义。所以“动态加载”不等于“可以随便写变量路径”它更需要你用明确的枚举把可能性圈起来。6.4 import() 和 require 混用时default 字段的坑在 Node 的 ESM 文件里动态引入一个 CommonJS 模块写的时候要特别留意互操作规则const mod await import(some-cjs-library); // 你以为直接是 module.exports其实它在 .default 里 console.log(mod.default);Node 会用词法分析工具尽量帮你识别一些具名导出但最保险的做法永远是把module.exports当作default来拿。反过来如果你在 CommonJS 文件里想用import()又牵扯另一个限制CJS 没有顶层 await。你必须把动态导入包进 async 函数或者用.then()链否则语法直接报错。这种来回混用的地方稍微偷懒就会在“运行时给的字段和你预期不一致”上栽跟头。我现在的习惯是CJS 文件里继续用requireESM 文件里统一用importimport()不跨体系混写。除非特殊互操作场景否则尽量别让自己身陷中间地带。6.5 其他容易忽视的体积与路径细节还有几个小坑值得一起提醒。JSON 模块的导入在 Node 原生 ESM 里一直有些折腾早两年是assert后来统一成with写法但你在 webpack 或 Vite 项目里直接import data from ./data.json反而更省心别自己给自己造兼容负担。压缩工具对import()的支持也不是没有代价。动态导入的模块如果体积很小拆分出来独立请求反而可能拖慢首屏。工程上常用/* webpackPrefetch: true */这类注解去控制预加载时机不要一刀切认为拆了包就一定快。最后再提醒一次import()返回的是 Promise在模块顶层直接同步使用时要配合顶层await而这个语法在 CJS 里不可用。写之前先确认自己所在文件到底是 ESM 还是 CJS否则一个SyntaxError就等着你。7. 写在最后的迁移建议从我自己的经历来看新写代码的默认选择应该是清晰的静态依赖走import懒加载和条件加载走import()存量 Node 脚本和需要兼容老工具链的包继续用require。如果你正打算把一个 CommonJS 项目迁到 ESM我建议按照这个顺序推进先在package.json里确认type: module或者把新文件设为.mjs。全项目搜索__dirname、__filename统一替换成import.meta的等价写法。处理循环依赖优先抽公共模块而不是指望 live binding 兜底。把所有“根据变量拼路径”的require改造成显式映射 import()。跑两遍构建对比 production 产物体积确认 tree shaking 真的生效了。我还留着一个贯穿始终的原则如果一个需求看起来“非动态不可”我会先反问自己能不能用显式映射把动态范围圈死。动态引入的价值在于异步和按需不在于路径可以随便乱拼。把选择权交给运行时之前一定要先想清楚编译器替你做的优化一旦你亲手关掉后面拿什么补都补不回来。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →