ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ES Module 报错:Cannot use import 的成因与修复全解

ES Module 报错:Cannot use import 的成因与修复全解 Uncaught SyntaxError: Cannot use import statement outside a module 这行红字几乎是每个前端人从「手写几个 HTML 页面」过渡到「用模块化组织代码」时必踩的第一颗雷。它出现的场景往往朴素得让人无语你写了一个工具文件里面用 export 导出几个函数又在主文件顶部写了一句import { foo } from ./utils.js结果打开浏览器控制台啪地一下甩出这条报错页面直接白屏而 Network 面板里那两份文件却明明白白都返回了 200。这篇文章就围着这一条报错展开把它的成因、浏览器模块加载的完整链路、从定位到修复的实操流程以及我这些年攒下来的排查套路一次性讲透。不管你是刚接触 ES Module 的新手还是在维护老项目、被第三方库坑到怀疑人生的老手都能在这里找到对应的解法。我不会只丢给你一句「加个typemodule就好」因为很多时候加完之后会冒出第二个、第三个报错那才是真正考验人的地方。1. 先把这条报错的脾气摸清楚1.1 一行 import 触发的连锁反应要理解这条报错得先接受一个事实它不是运行时错误而是解析阶段的语法错误。浏览器拿到script srcmain.js/script这个标签时会按照「经典脚本classic script」的语法规则去解析 main.js 的源码。经典脚本的语法目标里根本没有import声明和export声明这两个东西它们只存在于「模块module」这个语法目标中。所以当解析器扫到import这个关键字、发现后面跟的是一段合法的模块导入语法时它不会「试着理解一下」而是直接判定语法非法并抛出 SyntaxError。这个特性带来一个非常关键的排查结论整个文件里一行代码都不会执行。哪怕你把console.log(进来了)写在 import 语句的上一行它也不会打印。因为解析是执行的前置步骤解析失败意味着这份脚本被整体丢弃。我见过太多人在这上面绕圈反复在文件顶部加日志、加 try/catch结果什么都没输出就以为是缓存问题或者没加载到文件。其实文件加载得好好的就是被语法检查拦在门外了。至于报错尾巴上的(at XXX)那个 XXX 就是浏览器告诉你「我在哪个文件里被 import 语句绊倒的」。这个信息极其重要因为触发报错的文件往往不是你手写的那份主文件而是某个被间接引入的依赖。特别是在用 CDN 引入第三方库的时候at https://cdn.xxx.com/lib/index.mjs这种提示会直接把你带到真正的案发现场。1.2 为什么 Node、打包器、浏览器表现完全不同同一份带 import 的代码扔到不同环境里结果可能天差地别这也是很多人困惑的根源。在 Node.js 里一份.js文件到底按 ESM 还是 CommonJS 解析取决于最近的package.json里的type字段或者文件的扩展名是.mjs还是.cjs。Node 会主动去看这些声明然后决定用哪套语法目标解析。打包器Webpack、Rollup、Vite 的生产构建等则是另一套逻辑。它们自己实现了一套模块图构建流程在构建期就把所有 import/export 静态分析并重写成自己内部的模块注册形式最终输出的 bundle 里可能压根看不到 import 语句了。所以在打包器眼里import 是完全合法的输入格式它根本不关心浏览器怎么想。浏览器就没那么聪明了。它没有文件系统上下文没有 package.json也没有构建过程。它唯一的判断依据就是你在 HTML 里那个 script 标签上写的type属性。写了typemodule就按模块解析不写或者写成typetext/javascript就按经典脚本解析。这个决定在标签写下的那一刻就定了后面的 JS 代码没有任何办法改变它。记住这个分工Node 看扩展名和 package.json打包器看自己的模块图浏览器只看 script 标签的 type。三个环境各自为政混用就会出问题。1.3 三类典型触发场景与优先级判断实际工作中这条报错基本逃不出三种场景而每种场景的修复位置完全不同先判断属于哪一类能省下大量时间。第一类是手写的页面忘记加 typemodule。特点是报错的at XXX指向你自己写的业务文件文件内容你能完整看到里面确实有 import 语句。这种情况最省事改 HTML 就行。第二类是第三方依赖被浏览器直接加载。特点是你自己的代码明明在打包器里跑得好好的但某个页面或某个工具链比如某些在线编辑器、某段复制来的 CDN 示例代码直接把库的 ESM 产物丢给浏览器。报错的at XXX指向 CDN 地址或 node_modules 里的.mjs文件。这种情况要动的是引入方式不是那个库本身。第三类是构建配置导致的产物问题。特点是本地开发环境正常线上或者打出来的包才报错而且往往伴随着Failed to load module script之类的连带报错。这类最隐蔽需要对构建工具的产物格式有基本了解。判断顺序建议是先看报错文件是不是你自己写的是的话大概率第一类或第三类不是的话直奔第二类。别看这步简单我见过有同事在打包配置里折腾了两小时最后发现就是 HTML 模板里少写了四个字母。2. 核心机制拆解模块与经典脚本差在哪2.1 ES Module 与 CommonJS 的差别不只是语法很多人以为 ESM 和 CommonJS 的区别就是用import还是用require其实语法只是最表层的。更深层的差异直接影响你排查问题时的判断。CommonJS 是运行时加载require是一个普通函数调用可以写在 if 里、可以动态拼路径。ESM 是静态加载import 声明必须写在模块顶层路径必须是一个静态字符串因为浏览器需要在执行任何代码之前就把整个模块依赖图构建出来。这也是为什么报错出现在解析阶段——依赖图的构建早于代码执行。另一个关键差异是绑定方式。CommonJS 导出的是值的拷贝ESM 导出的是活绑定live binding。也就是说A 模块导出一个变量B 模块 import 进来当 A 模块内部修改这个变量时B 模块看到的值会同步变化。这个特性在计数器、配置对象这类场景里很容易踩坑。还有几个容易忽略的行为差异ESM 默认运行在严格模式不需要写use strict模块顶层的this是undefined而不是window模块里的var声明不会挂到全局对象上模块只被执行一次即使被多个地方 import也共享同一份实例。最后这条在写单例、写全局状态时是好事但在做测试隔离时可能变成麻烦。2.2 typemodule 到底改了什么给 script 标签加上typemodule浏览器会做一连串的切换理解这些切换能帮你预判后面会冒出来的新报错。解析目标从 script 切换成 moduleimport/export 合法了这是最直接的变化。脚本的执行时机变成默认 deferred也就是等 HTML 解析完成之后才执行顺序和文档中出现的顺序一致。这就意味着你不需要再手动加defer模块天然就是延迟执行的。模块内部自动进入严格模式。模块的顶层this变成undefined。最重要的是模块脚本通过 CORS 方式获取这是一个很多人没意识到的变化——经典脚本用 no-cors 方式加载模块脚本必须走 CORS 请求服务器必须返回正确的Access-Control-Allow-Origin同源情况下不需要跨域 CDN 需要而且响应头里的Content-Type必须是 JavaScript 的 MIME 类型。这个 CORS 变化正是「本地双击打开 HTML 就报错」的罪魁祸首。用file://协议打开页面时模块请求的 origin 是null服务器此时是本地的文件系统没法给出合法的 CORS 响应头请求直接被拒。所以只要用了模块就必须通过 HTTP 服务访问页面这是硬性约束没有绕过去的办法。2.3 浏览器不肯替你做的三件事即使你加了typemodule浏览器依然有三件事不会帮你做而打包器全都替你做了。这就是为什么同一个项目在打包环境里能跑直接放到浏览器里就不行。第一是裸模块说明符。import _ from lodash这种写法在打包器里天经地义打包器会去 node_modules 里找。但浏览器完全不知道lodash是个什么东西它只认相对路径、绝对路径和完整的 URL。要么改成./node_modules/lodash/lodash.js这种实际路径要么使用 Import Maps 显式告诉浏览器这个裸名字映射到哪里。第二是扩展名补全。在 Node 和打包器里import ./utils可以自动找到utils.js。浏览器不行路径必须写全./utils.js就是./utils.js少一个字符都不行目录下的index.js也不会自动补上。第三是跨域与协议限制。前面说过的 CORS 要求就属于这一类。此外模块路径里如果有多余的斜杠、大小写不匹配在 Linux 服务器上区分大小写本地 Windows 不区分都会导致 404而 404 接下来会引发一个更难懂的连带报错。2.4 那份让人一头雾水的 MIME 类型报错加了typemodule之后如果路径写错了你大概率会看到这样一条报错Failed to load module script: Expected a JavaScript-or-WASM module script but the server responded with a MIME type of text/html.这条报错看起来很唬人其实意思非常直白浏览器去请求那个模块文件结果服务器返回了一个 HTML 页面而不是 JavaScript 文件。什么时候服务器会返回 HTML最常见的就是请求了一个不存在的路径服务器走了 404 处理逻辑返回了自己的 404 页面而这个 404 页面的Content-Type是text/html。所以看到这条报错第一反应应该是去 Network 面板看那个请求的实际状态码和响应内容八成是个 404。另一种情况是开发服务器的静态资源目录配置有问题把.js文件当成了未知类型返回了text/plain或application/octet-stream。这时候要检查服务器的 MIME 映射配置。我用过的一些轻量级静态服务器在某些环境下确实会有这个问题换一个配置齐全的服务器或者显式补上 MIME 映射就好了。3. 实操过程从定位到修复的完整链路3.1 第一步永远是读准 at XXX别看这个动作简单读准at XXX能直接砍掉一半的排查时间。这里我总结了三种常见形态和对应的判断报错形态含义处理方向at http://localhost:3000/js/main.js:3你自己的业务文件第 3 行是 import改 HTML 的 script 标签at http://localhost:3000/js/utils.js:1被引入的模块文件本身也是被 classic 方式加载的检查是谁引入了它at https://cdn.example.com/lib/index.mjs第三方库的 ESM 产物换引入方式或用打包器at anonymous或没有文件信息内联脚本或动态创建的 script检查内联脚本和动态注入逻辑打开 Sources 面板用CtrlP搜索报错里的文件名能直接跳到那一行前后看几行就能确认是不是 import 语句。这一步做完你至少知道该改哪个文件了。3.2 场景一原生 HTML 直接引入改法最直接这是最经典的场景。修改前的写法!-- 修改前main.js 里写了 import浏览器按经典脚本解析 -- script src./js/main.js/script// js/main.js import { formatDate } from ./utils.js; console.log(formatDate(new Date()));修复只需要加一个属性!-- 修改后声明为模块脚本 -- script typemodule src./js/main.js/script如果是内联脚本同样要加script typemodule import { formatDate } from ./utils.js; console.log(formatDate(new Date())); /script改完之后有个细节要注意模块脚本是 deferred 执行的也就是说你在模块里拿 DOM 元素时DOM 已经解析完了不需要再包一层DOMContentLoaded。反过来如果你的老代码里依赖了「脚本同步执行、执行时下面的 DOM 还不存在」这种时序改成模块后行为会变得重新检查一遍。3.3 场景二本地开发时的协议与服务器问题如果你改完typemodule之后报错从「Cannot use import statement」变成了「Failed to load module script」并且你是直接双击 HTML 文件打开的那基本可以确定是file://协议的锅。验证方法很简单看地址栏。如果是file:///D:/project/index.html这种就说明你是直接打开的文件。改成用一个本地 HTTP 服务来访问问题通常立刻消失。最省事的做法是用编辑器自带的 Live Server 插件或者用一条命令起一个静态服务# Python 3 自带零依赖 python -m http.server 8080 # 或者用 Node 生态的 npx serve .然后访问http://localhost:8080/index.html。这里有个很容易忽略的点服务器要起的目录必须是项目根目录也就是包含 index.html 的那一层。如果你在不上一级的目录起服务路径就全错了会引发一堆 404。我头几年经常犯这个错明明看到 200 却还是白屏后来养成习惯起服务之前先ls一下确认目录里能看到 index.html。提示从file://切换到本地服务器之后浏览器缓存的行为也会变记得在 DevTools 里勾上 Disable cache或者用 CtrlShiftR 强制刷新。3.4 场景三CDN 引入第三方库时的取舍从 CDN 引库是最容易撞上这条报错的场景因为同一个库在 CDN 上往往提供好几种产物格式你随手复制的那一行代码可能是给你的也可能是给打包器用的。比较稳妥的做法是使用库文档里提供的「浏览器直接可用」版本这类产物通常是 UMD 或 IIFE 格式会把 API 挂到一个全局变量上用经典 script 标签引入即可script srchttps://cdn.example.com/lib/dist/lib.umd.js/script script // 库把自己挂到了 window.Lib 上 const result window.Lib.doSomething(); /script如果这个库只提供 ESM 产物那你有三条路。一是用 Import Maps 给裸模块名建立映射让浏览器知道去哪里找二是用动态import()它可以在经典脚本里使用返回一个 Promise三是老老实实上打包器。我一般推荐第二种改动最小script // 经典脚本里也能用动态 import注意路径要用完整 URL 或相对路径 import(https://cdn.example.com/lib/esm) .then((mod) { mod.doSomething(); }) .catch((err) { console.error(模块加载失败, err); }); /script动态 import 还有一个好处是能配合 try/catch 做降级模块加载失败时你可以给用户一个提示而不是整个页面白屏。这一点在生产环境里挺重要的尤其是依赖外部 CDN 的场景。3.5 场景四构建工具下的产物格式核对本地开发一切正常、打包上线才报这个错问题通常出在产物的模块格式上。需要核对几个地方。先看打包工具的 output 配置。Webpack 5 里如果开启了experiments.outputModule产物会是 ESM 格式那 HTML 里引入它的 script 标签就必须带typemodule。Vite 的生产构建默认会把入口打包成经典格式除非你配置了 library 模式但 Vite 的index.html里入口 script 本身就是typemodule这是它的标准写法别手贱去掉。再看 library 模式的配置。如果你是给别的项目提供库output.library.type设成module时产物是 ESM使用方必须用模块方式引入设成umd时产物通用经典脚本也能用。这两种导出格式对应的package.json字段也不一样module或exports里的import条件指向 ESMmain或require条件指向 CommonJS。使用方如果搞混了就会把 ESM 产物用经典脚本加载直接报这条错。最后检查 HTML 模板。有些项目用模板引擎生成 HTML模板里那个 script 标签的type属性可能被某个变量控制生产环境的变量值和开发环境不一致就会导致线上少写typemodule。这类问题看构建产物的 HTML 文件比看源码更快。3.6 修复完成后的回归验证清单改完之后别急着关掉 DevTools按下面这份清单过一遍能提前拦住 90% 的连带问题。Console 面板没有任何红色报错包括警告级别的模块相关提示Network 面板里所有.js请求状态码都是 200Content-Type都是 JavaScript 类型没有出现Failed to load module script这类 MIME 报错页面在无痕窗口里也正常排除缓存和插件干扰用CtrlShiftR强刷一次确认不是旧缓存掩盖了问题如果项目有跨域 CDN确认 CDN 响应头里有正确的跨域许可4. 常见问题与排查技巧实录4.1 加了 typemodule 之后冒出来的新报错这部分是真正的干货区。加属性只是开始下面这几个连带报错的频率高得惊人。报错一变量 undefined。你在模块 A 里声明了const config {...}然后在另一个经典脚本里直接写config.xxx报 undefined。原因是模块作用域是隔离的模块里的顶层声明不会挂到window上。解决办法是要么显式赋值window.config config要么把那段代码也改成模块用 import 引入。报错二执行时机变了导致初始化失败。模块是 deferred 的如果你的代码里有个经典脚本在模块之前执行并且依赖模块的产物就会拿到 undefined。这里没有银弹只能用事件或者轮询来解耦或者干脆把有依赖关系的脚本都统一改成模块让浏览器帮你保证顺序。报错三require is not defined。如果你把一份 CommonJS 代码直接加了typemodule就会从 import 报错变成 require 报错。模块里没有require这个函数。这时候要么改写法要么用打包器做转换。Node 20 以上提供了module.createRequire之类的工具但浏览器里没有对应方案只能改代码。报错四Cannot use import statement outside a module换个文件继续报。说明还有第二个文件也被经典方式加载了。这种时候建议全局搜索一下有没有其他script src标签漏了type属性或者有没有动态创建 script 的代码。我用过的一个排查技巧是在控制台里跑一段脚本把所有 script 标签的类型都打印出来一眼就能看出哪个漏了。4.2 打包产物依然报错的隐蔽原因有一种情况特别气人本地npm run dev一切正常npm run build出来的包放服务器上就报这条错。我踩过几次之后总结出几个高频原因。第一个是依赖里有 ESM-only 的包。有些现代库只发布 ESM 产物没有 CommonJS 版本。如果你的构建配置产出了 CommonJS 格式的 bundle而这个 ESM-only 的依赖被内联进去了就会出问题。解决办法是把构建目标的格式改成 ESM或者用动态 import 把这个依赖变成异步加载。第二个是多入口项目里某个 chunk 的格式不一致。Webpack 默认产出的 chunk 用的是 webpack 自己的模块格式通常不会报这个错但如果配置里混用了outputModule就可能出现部分 chunk 是 ESM、部分不是的情况。核对一下 output 配置的统一性。第三个是HTML 注入插件的配置。有些项目用 html-webpack-plugin 或类似的工具生成 HTML插件默认可能不给 script 标签加typemodule需要手动在 template 里写死或者配置插件参数。4.3 动态注入脚本时的坑document.createElement(script)创建出来的 script 元素默认是经典脚本。很多埋点库、SDK 的加载器都是这么注入的。如果你注入的目标是一个 ESM 文件就会直接报这条错。修正方式const script document.createElement(script); script.type module; script.src /js/analytics.js; document.head.appendChild(script);但要注意模块脚本是 deferred 执行的动态注入的模块也是异步的你不能在 appendChild 之后立刻去访问它导出的内容。要么用动态 import 的 Promise要么在模块内部通过自定义事件通知外部。4.4 排查速查表把这部分内容整理成一张表排查时按顺序对照基本能覆盖绝大多数情况。现象最可能的原因处理动作控制台报 Cannot use import statementscript 标签缺 typemodule补上属性加了属性后报 Failed to load module script路径 404服务器返回了 HTML检查路径、扩展名、大小写加了属性后报 MIME type 相关服务器未正确设置 Content-Type检查服务器 MIME 映射用了模块后某变量 undefined模块作用域隔离未挂到 window显式挂载或改用模块引入双击打开 HTML 就报错file:// 协议不支持模块跨域起本地 HTTP 服务打包后报错、开发环境正常产物格式与引入方式不匹配核对 output.module 与 HTML只有某个 CDN 库报错加载了 ESM 产物换 UMD 版本或动态 import动态注入的脚本报错createElement 默认是经典脚本手动设置 typemodule5. 配置规范与长期规避策略5.1 目录结构与引入写法约定想彻底少踩这个坑从项目结构上做约束最有效。我的习惯是把所有需要被浏览器直接加载的 ES 模块统一放在src/目录下HTML 里只用相对路径引入入口文件所有依赖关系由入口文件通过 import 拉起来HTML 里绝不出现第二个业务 script 标签。这样引入点只有一个漏写type的可能性降到最低。路径写法上也定几条硬规矩相对路径一律以./开头绝不写裸路径扩展名一律写全不依赖自动补全文件名全小写避免不同操作系统大小写敏感性差异导致的「本地好线上崩」。这几条看着啰嗦但团队协作里能省掉大量扯皮。!-- 推荐单一入口路径完整显式声明模块 -- script typemodule src./src/main.js/script !-- 不推荐多个 script 标签各自引入容易出现格式不一致 -- script src./src/utils.js/script script src./src/main.js/script5.2 package.json 的 type 字段与 .mjs/.cjs 的取舍Node 环境下要避免「同一个文件在不同工具里解释方式不同」的问题最稳妥的做法是在package.json里显式声明type字段并且立下扩展名的使用规则。我一般这样定项目整体是 ESM 的话package.json里写type: module源码一律用.js需要写 CommonJS 文件的时候用.cjs扩展名这种情况下 Node 会忽略type字段强制按 CommonJS 解析。反过来如果项目主体是 CommonJS那就用.mjs来标记 ESM 文件。这套规则的好处是「文件的实际格式不依赖上下文猜测」任何人打开一个文件看扩展名就知道它是什么格式不用回去翻 package.json。这个习惯我在接手老项目时受益很多尤其是那些 package.json 里没写type、又混用两种语法的项目光判断一个文件是什么格式就要花好几分钟。5.3 构建输出与 TypeScript 配置的对齐TypeScript 项目里还有一层配置要对齐。tsconfig.json的module字段决定编译器输出什么格式的模块代码target决定语法降级到什么程度。如果module设成了commonjs那你写再标准的 import 语法编译出来也是require放到浏览器里直接报require is not defined。做纯前端项目时module一般设成esnext或es2020把打包和模块格式统一交给打包器处理。打包器的输出格式也要和 HTML 对上。前面提过 Webpack 的experiments.outputModule和 Vite 的默认行为。这里补充一个实用的检查方法打包之后打开产物目录看入口 JS 文件的头几行如果里面是import/export语句那 HTML 引入时必须带typemodule如果是一个 IIFE 或者 UMD 包装那普通 script 标签就行。看产物比看配置文档快得多也不容易看漏。5.4 团队协作中的几条硬约定最后分享几条我在团队里推过的约定执行下来确实减少了很多同类问题。第一条代码评审时把 HTML 里的 script 标签当作重点检查项特别是有没有typemodule。听起来很无聊但这条约定拦住的问题次数是最多的。第二条项目的 README 里写清楚「必须用本地 HTTP 服务访问不要双击打开 HTML」并且把起服务的命令写进去。新人入职第一天就能避开file://这个坑。第三条第三方库的引入方式统一收口。要么全部走打包器要么全部走 CDN 的 UMD 版本不允许在一个项目里两种方式随意混用。混用是模块格式冲突的最大来源。第四条建立一个最小复现页面。项目里留一个只有几行代码的demo.html用于快速验证模块加载链路是否正常。当线上出现奇怪的模块问题时先用这个最小页面确认基础链路再往业务代码里查能有效缩小范围。我在实际排查这类问题时最深的体会是这条报错本身一点都不难修难的是它总是带着一串连带报错一起出现让人误以为问题很复杂。只要养成「先确认 script 标签的 type再看 Network 面板的状态码和 Content-Type」这个固定动作绝大多数情况在两分钟内就能定位到根因。踩过几次坑之后我现在已经形成了条件反射看到这行红字第一反应不是看代码而是打开 Elements 面板找那个 script 标签。至于后面那些 MIME、CORS、作用域隔离的问题都属于「模块加载链路」这一个知识体系里的东西把它完整理一遍以后遇到同类报错基本就是套模板了。
返回列表