先记住这个答案
import _ from "lodash" 里的 lodash 叫裸模块说明符:不是相对路径,也不是完整 URL。Node.js 能处理它,是因为它有一套包解析规则,从当前文件所在目录开始逐层往上找 node_modules/lodash,找到后读 package.json 的 exports 或 main 确定入口文件。浏览器没有这套规则,它只会把说明符当成 URL 去请求,而 lodash 算不出一个合法地址,所以直接报错。适配方法有三种:打包器在构建时改写路径、import map 在运行时提供映射、直接写成 CDN 的完整 URL。
- 裸说明符没有相对路径前缀
- Node 从包目录和导出映射解析
- 浏览器只能发请求,逐层试探代价太高,所以不支持
- 浏览器可用 import map 映射包名
同一句 import,两边的解析过程
假设 /app/src/utils/date.js 里写了 import dayjs from "dayjs"。
/app/src/utils/node_modules/dayjs 不存在
/app/src/node_modules/dayjs 不存在
/app/node_modules/dayjs 找到了
→ 读 /app/node_modules/dayjs/package.json
→ 按 exports(没有就看 main)确定入口
→ 得到 file:///app/node_modules/dayjs/esm/index.js这个过程要反复检查目录是否存在,在本地文件系统上很快。换到浏览器,每一步都是一次网络请求,大部分还是注定失败的 404,一个依赖可能要试好几轮,页面会慢得无法接受。所以浏览器的模块规范从一开始就没打算支持这种查找,只接受能直接算出 URL 的说明符。 浏览器遇到裸说明符时,错误发生在解析阶段,不会发出任何请求。控制台里看不到 404,只有一条「无法解析模块说明符」的报错。
三种适配方式
实际项目里基本都会用第一种,另外两种适合特定场景。
- 打包器、import map 和 CDN
- 打包器是最主流的办法。Vite、Webpack、Rollup 在构建时按 Node.js 的规则把
dayjs解析到具体文件,要么把代码合并进产物,要么把导入改写成带路径的地址。Vite 开发环境会把依赖预构建到node_modules/.vite下,再把import "dayjs"改写成/node_modules/.vite/deps/dayjs.js这种浏览器认得的路径,打开 Network 面板就能看到。 import map 是浏览器原生方案。在页面里写一段<script type="importmap">,声明"dayjs": "https://esm.sh/dayjs",之后业务代码照常写裸说明符,浏览器按映射加载。适合不想引入构建流程的小项目、演示页,或者微前端里统一共享依赖版本。 最原始的做法是直接写完整 URL,比如import dayjs from "https://esm.sh/dayjs"。能用,但版本号散落在各个文件里,升级和替换都麻烦。
容易答错的地方
- 以为是浏览器「还没来得及支持」
- 不是技术落后,而是有意的设计。浏览器不能假设服务器上有
node_modules目录,也不应该为了猜路径去发一堆请求。所以规范把「映射」交给了开发者显式声明,import map 就是为这个设计的。 - 以为写成
/node_modules/lodash/lodash.js就能解决 - 路径能被请求到,但很多包的入口是 CommonJS 格式,浏览器原生 ESM 执行不了
require和module.exports。而且这个包内部的导入如果也是裸说明符,照样会报错。所以用 import map 或 CDN 时,要选提供 ESM 版本的地址。
面试官还会怎么问?
Vite 为什么要做依赖预构建?
一是把 CommonJS 格式的依赖转成 ESM,浏览器才能直接执行;二是把一个包内部的几百个小模块合并成一个文件,避免开发时浏览器发出几百个请求。预构建的同时它也把裸说明符改写成了实际路径。
Deno 是怎么处理裸说明符的?
Deno 最初只支持完整 URL 导入,和浏览器一样。后来为了兼容 npm 生态,支持了 import map 风格的配置和 npm: 前缀,本质上也是在用显式映射代替 Node.js 那种隐式查找。
同一个依赖在 node_modules 里有两份会怎样?
Node.js 按离当前文件最近的 node_modules 解析,不同位置的文件可能拿到不同版本。两份就是两个模块实例,各自有一份状态。比如页面里有两份 React,就会触发 hooks 报错。用包管理器的去重命令或者打包器的 alias 统一到一份。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。