尧图精选

ESM 报错:import statement outside a module

🕒 发布时间:2026/10/1 10:12:37 📁 来源:尧图网络
上周三晚上十点同事把一个跑了三年的老后台打包配置从 IIFE 换成了 ESM 输出本地npm run dev一切正常推到测试环境首页直接白屏控制台第一行红字就是Uncaught SyntaxError: Cannot use import statement outside a module (at app.4f2c8e1.js:1:1)。这个报错看起来像个新手错误但它出现的时机往往特别刁钻代码本身一行没错出问题的是谁在加载这份文件、以什么身份加载它。浏览器在解析阶段就把它按普通脚本处理了于是文件里的import语句触碰了语法红线解析器直接抛错整个文件一个字都不会执行。这篇内容我会把浏览器的判定逻辑、六类高频触发场景、构建工具链里各自的坑、以及一套我自己常用的排查链路都摊开讲清楚从刚接触模块语法的新手到带团队做迁移的人都用得上。核心关键词就三个Uncaught SyntaxError、Cannot use import statement outside a module、import statement把它们的关系捋顺了同类问题基本一次就能定位。1. 报错的字面意思不重要重要的是浏览器把这份文件当成了谁1.1 classic script 与 module script 是两条完全不同的解析路径HTML 规范里script元素只有两种身份判定标准简单到有点粗暴标签上有没有typemodule。有就是module script没有或者写的是text/javascript、application/javascript、干脆省略就是classic script。这两个身份走的是两条几乎不相干的处理管线。classic script 走的是老路子代码在全局作用域里求值var和函数声明会被挂到window上默认非严格模式同步阻塞 HTML 解析除非加了defer/async允许多次加载同一个文件并重复执行。module script 则完全是另一套它按 ECMAScript 的 Module Declaration 语法解析自带独立的作用域永远不会向window泄漏变量默认就是严格模式默认就是 deferred请求时带 CORS 模式同一个模块只会求值一次还允许顶层await。而Cannot use import statement outside a module这句话就是 JavaScript 解析器在按 classic script 的语法规则读文件时撞上了import关键字后发出的。它属于SyntaxError意味着报错发生在解析阶段而不是运行阶段。这一点非常关键你在文件第一行写的console.log也不会打印文件的全部代码都不曾运行过。很多人第一次遇到时习惯性去加try/catch或者上调试断点全都没用因为根本没有代码在跑。报错后缀的(at XXX)是浏览器的定位信息通常给的是文件名加行列号比如(at index.js:1:1)。如果报错位置显示的是一个 HTML 文件名说明问题出在 HTML 里的内联脚本块上而不是外部文件。这个定位信息别急着忽略它是后面排查链路的第一块拼图。1.2 为什么同一份文件在 Node 里跑得欢进浏览器就翻车这是最容易让人怀疑人生的地方。同一份.js文件node app.js跑得好好的扔到浏览器里就报这个错。原因在于决定一份文件是 CJS 还是 ESM 的在 Node 和浏览器里是两套完全独立、互不知情的规则。Node 的模块加载器看的是package.json里的type字段、文件扩展名.mjs/.cjs或者--input-type参数浏览器看的是 HTML 里script标签的属性。构建工具又是第三套规则看的是resolve配置和文件扩展名。三个环境三套判定任何一环不一致就会在某个环境里炸掉。运行环境谁负责判定模块类型判定依据典型报错位置浏览器HTML 解析器script标签有无typemodule控制台指向具体 js 文件或 HTML 行Node模块加载器package.json的type、.mjs/.cjs终端堆栈首行Webpack解析器resolve规则、扩展名、fullySpecified编译期输出Vite开发服务器 Rollup原生 ESM 直出 /build.target浏览器控制台dev或构建日志Jest转换器transform与transformIgnorePatterns测试结果输出ts-node / tsxTS 编译器 加载器tsconfig的module字段运行时堆栈看清这张表很多玄学问题就变成确定性问题了文件没变判定它的人变了。1.3 六类高频触发场景先对号入座我把这些年遇到的场景归了归类基本跑不出下面这六种。第一类手写 HTML 忘加属性。最常见也最好修。你写了个app.js里面有importHTML 里写的是script src./app.js/script改一个属性就好。第二类构建产物是 ESM宿主页面还是老模板。前端这边的构建配置升级到了 ESM 输出但渲染 HTML 的是后端模板引擎或者 CMS、老框架的布局文件模板里写死的还是普通 script 标签。这类问题最隐蔽因为开发环境的 HTML 由构建工具的插件自动生成属性是对的生产环境的 HTML 由后端拼出来属性是错的本地永远复现不了。第三类动态注入脚本。用document.createElement(script)或者某些老库的$.getScript方式加载文件时注入出来的默认是 classic script。哪怕你页面上原本有typemodule的标签也没用动态创建的元素得自己设置type。第四类测试环境。Jest 默认跑在 CommonJS 语义下遇到依赖包里带 ESM 语法的文件就会甩出这句报错。第五类Node 端混用。package.json没写type: module文件扩展名又是.js里面却写了importNode 会按 CJS 解析报的就是同一句话。第六类微前端或第三方 SDK。主应用是 classic script子应用或者某个 SDK 的产物是 ESM加载时没做身份转换。这类问题排查成本最高因为报错文件名可能完全陌生。提示只要报错文件名里带 hash比如chunk-a1b2c3.js基本可以断定是构建产物直接去构建配置里找答案不用逐行读代码。2. 补上 typemodule 只是第一步后面还有三道关2.1 最小改动、扩展名硬要求和 bare specifier 的另一个坑最小改动当然是在标签上加属性script typemodule src./src/main.js/script但很多人改完属性之后发现报错变了从Cannot use import statement outside a module变成了Failed to resolve module specifier。这不是没修好是进入了下一关。浏览器原生的模块解析器不会帮你补扩展名也不会做目录索引。Node 和 webpack 里能跑的import { a } from ./utils在浏览器里必须写成import { a } from ./utils.js。同理import _ from lodash这种裸模块名bare specifier浏览器完全不知道去哪找除非你配了 import map 或者用了构建工具的路径重写。还有几个容易被忽略的细节CSS 不能像 webpack 里那样直接import ./style.css浏览器只会把它当成一个 JS 模块去请求拿回 CSS 文本后解析失败JSON 文件需要用 import attributes 语法import data from ./d.json with { type: json }而且浏览器支持度要看版本require()在 module script 里根本不存在会报require is not defined。我把这几条整理成一张对照表迁移的时候照着改基本不会漏写法classic scriptmodule script浏览器原生import x from ./a.js不支持支持扩展名必填import x from ./a不支持报 specifier 解析失败import x from lodash不支持需要 import maprequire(./a)不支持更不支持直接报未定义import ./s.css不支持语法上不行需构建工具处理顶层await不支持支持顶层this指向windowundefined2.2 file:// 直接双击打开页面模块必然加载失败这一点值得单独拎出来说因为它会伪装成我配置没问题但就是跑不起来。你把写好的 HTML 双击用浏览器打开地址栏是file:///D:/project/index.html模块加载会失败。原因有两个叠加一是CORS。module script 的获取走的是 CORS 模式而file://协议下的源被当作不透明的null浏览器直接判定跨域并拦截控制台里会出现类似Access to script at file:///... from origin null has been blocked by CORS policy的提示。classic script 没有这个限制所以老代码双击能跑。二是MIME 类型。file://下的类型由操作系统根据扩展名推断有些环境会给出text/plain而模块脚本要求必须是 JavaScript 类型的 MIME不符合就拒绝执行。所以结论很干脆跑模块化代码必须经过 HTTP 服务哪怕只是本地。这不是配置能绕过去的限制是规范层面的设计。2.3 用本地静态服务器验证一行命令就够验证方式非常轻量不用起整个项目。项目根目录下执行任意一条# 方案一Node 环境无需安装 npx serve . # 方案二有 Python 环境 python -m http.server 8080 # 方案三PHP 环境 php -S localhost:8080然后访问http://localhost:端口/index.html模块就能正常加载了。如果是用 Vite、webpack dev server 这类工具它们本身自带服务不存在这个问题——这也是为什么很多人本地没问题、一上线就炸因为开发和生产的加载方式根本不是一回事。注意npx serve默认会正确处理.js、.mjs的 MIME 类型但你自己部署到对象存储或 CDN 时要确认这一点。我遇到过把.mjs上传到对象存储后服务端返回text/plain导致线上报模块类型错误的情况排查了两个小时才发现是存储的 Content-Type 没配。3. 构建工具链里同款报错的不同来源3.1 Vite 开发模式原生 ESM 与 build 产物的差异Vite 的开发服务器是直接把源码当原生 ESM 推给浏览器的所以index.html里的标签天然是typemodule你在本地几乎不可能遇到这个报错。问题通常出在两处。一处是build.rollupOptions.output.format被改成了iife或umd。这时候产物不是 ESM但产物里可能还残留着动态import()或者被保留的export混合使用就会出现语法层面的混乱。iife格式还有个硬限制不能做代码分割一旦有多入口或动态导入Rollup 会直接报错或者生成不符合预期的结果。另一处更常见HTML 不是 Vite 生成的。用 Vite 的库模式产出 JS然后由 Spring Boot、Django、ThinkPHP 这类后端应用的模板去引用。模板作者不知道要加typemodule于是线上白屏。这类项目我的建议是在构建配置里加一段校验或者在 CI 里跑一个脚本扫一遍模板文件里引用产物的 script 标签检查有没有typemodule。顺便说下vitejs/plugin-legacy。如果你的用户群体里有需要兼容老内核浏览器的情况用这个插件可以自动生成 legacy 版本它会插入一段检测逻辑来决定加载 ESM 产物还是降级产物。但要注意legacy 产物本身是 SystemJS 格式和模块语义有区别混用两套产物时全局变量的命名要隔离不然容易出现互相覆盖。3.2 Webpack 5 的 outputModule 与模板插件的配套改动Webpack 5 支持输出 ESM 产物但需要三处配置同时改到位漏一处就复现这个报错。// webpack.config.js module.exports { experiments: { outputModule: true, }, output: { module: true, filename: [name].[contenthash].js, chunkFormat: module, }, // 关键让 HtmlWebpackPlugin 生成 typemodule plugins: [ new HtmlWebpackPlugin({ scriptLoading: module, }), ], };scriptLoading这个选项默认值是defer生成出来的是普通 script 标签加 defer 属性——功能上很像但身份完全不同遇到import照样炸。我见过有人只改了前两处本地用 webpack-dev-server 时因为内存里的 HTML 是插件生成的属性没对一打开就报错来回折腾了很久。另外 Webpack 5 对.mjs文件有fullySpecified的默认行为要求导入路径写全扩展名。如果某个依赖包的 ESM 入口是按省略扩展名的方式写的会报Cant resolve或者import and export may appear only with sourceType: module这类解析错误。处理方式是在module.rules里针对.mjs关掉这个要求module: { rules: [ { test: /\.m?js$/, resolve: { fullySpecified: false }, }, ], }至于import and export may appear only with sourceType: module这个报错它和本篇报错是一对孪生兄弟一个是浏览器解析器在抱怨一个是构建工具解析器在抱怨。修法类似让处理该文件的 loader 用 ESM 语义解析或者把 Babel 的sourceType设成unambiguous让它自动判断。3.3 TypeScript、ts-node、ts-jest 各自的 module 配置TS 项目最容易在这里绕圈因为tsconfig.json的module字段决定了编译产物是 CJS 还是 ESM而运行时环境又有自己的判定两层叠加。浏览器侧module推荐ESNext或ES2022moduleResolution设bundler用打包工具时这样编译产物保留import语法交给打包器处理。如果误设成CommonJSTS 会把import编译成require浏览器里就会报require is not defined——报错变了根因其实一样。Node 侧如果package.json写了type: moduletsconfig的module至少要设成Node16或NodeNextmoduleResolution保持一致否则会出现编译通过但运行报语法错误的情况。用 ts-node 的话CJS 场景下module设CommonJS最省事真要走 ESM用 tsx 或者 ts-node 的 ESM 模式别硬扛。测试侧是重灾区。Jest 默认在 CommonJS 语义下跑两个常见处理方向一是让 Babel 转换那些带 ESM 语法的依赖把这几个包从transformIgnorePatterns的忽略列表里排除出去// jest.config.js module.exports { transform: { ^.\\.(t|j)sx?$: babel-jest, }, transformIgnorePatterns: [ /node_modules/(?!(some-esm-package|another-esm-lib)/), ], };二是启用 Jest 的原生 ESM 支持用node --experimental-vm-modules node_modules/jest/bin/jest.js方式启动并把extensionsToTreatAsEsm配上。这条路功能更完整但配置复杂度明显更高团队里如果没有专人维护测试基建走第一条路更稳。3.4 Node 端的模块身份type 字段、扩展名和 __dirnameNode 这边判定规则相对清晰但踩点也不少。三个要素决定身份package.json的type字段、文件扩展名、以及 CLI 参数。type: module之后__dirname和__filename都不存在了老代码里到处用的这两个变量会直接报未定义。替代写法import { fileURLToPath } from node:url; import { dirname } from node:path; const __filename fileURLToPath(import.meta.url); const __dirname dirname(__filename);还有一个高频报错是SyntaxError: The requested module node:util does not provide an export named ...。这类错误跟本篇报错是同一家族的Node 在加载 CJS 模块时靠静态分析猜测它有哪些具名导出猜不出来就报这个。解决方式是把具名导入改成默认导入再解构// 可能失败的写法 import { someFn } from some-cjs-package; // 更稳的写法 import pkg from some-cjs-package; const { someFn } pkg;至于ModuleNotFoundError: No module named xxx那一类 Python 报错虽然关键词看着像但完全是另一个技术栈的问题别被搜索结果带偏——Python 的包查找路径和 Node 的模块解析没有任何关系。4. 一次多页老项目的排查全程记录4.1 现象描述与第一轮误判回到开头那个白屏的后台项目。它是一个多页应用一共十二个页面共用一套布局模板前端用构建工具打包后端用模板引擎渲染 HTML。改造当天下午构建配置改成了 ESM 输出本地用 dev server 跑十二个页面全正常。晚上八点部署到测试环境首页白屏控制台只有一行报错就是本篇这句文件名是app.4f2c8e1.js。第一轮误判发生在看代码上。我先怀疑是产物里的语法有问题把文件下载下来看开头确实是标准的import语句语法没毛病。又去检查 Babel 配置也没错。花了大概二十分钟在这个方向上最后意识到一个更基础的事实浏览器报这个错说明它把这份文件当成了 classic script问题不在文件里在引用它的地方。4.2 用 Network 面板和 Elements 面板锁定注入点第二轮的排查路径我建议你直接抄。第一步Network 面板找请求。刷新页面筛选 JS找到app.4f2c8e1.js看它的Type列。如果是script说明是普通脚本加载正常应该是script但响应头里频Content-Type是 JavaScript 类型——这两件事不冲突Type 列反映的是请求分类。更关键的是Initiator列。第二步点 Initiator 看调用栈。如果显示的是index.html:128说明是 HTML 里写死的标签直接跳到那行看属性。如果显示的是另一个 js 文件说明是动态注入的回去翻那个文件的注入逻辑。我这次看到的就是 HTML 里的行号跳到模板文件对应位置一看果然是script src/static/js/app.4f2c8e1.js/script没有typemodule。第三步查为什么只有首页报错。这里有个关键点十二个页面共用布局如果模板有问题应该全都报错。但实际情况是只有首页白屏。进一步看发现只有首页用到了 ESM 产物的那个入口其它页面还在加载老版本的 IIFE 产物所以只有首页触发。这个细节很重要如果不查清楚很容易误以为只有首页的构建产物坏了把方向带偏。第四步Elements 面板确认运行时的 DOM。顺手在 Elements 里搜一下 script 标签确认浏览器实际解析到的属性。这一步主要是为了排除模板里其实写了但是被后处理删掉了的可能性。注意 View Source 看到的是初始 HTML动态注入的内容看不到必须用 Elements。4.3 修复方案取舍与回归验证清单修复本身有两条路各有代价得根据项目情况选。方案 A给模板加上typemodule。改动小一行的事。但要确认这个入口的所有依赖都在同一个模块图里以及没有任何依赖全局变量的遗留代码。这个项目的后台模板里确实有一些依赖window全局变量的图表脚本所以直接加属性之后又冒出了新问题。方案 B产物格式改回 IIFE只在需要的页面用 ESM。改动集中在构建配置风险可控代价是放弃代码分割带来的按需加载收益。这个项目最终选了 B因为后台系统的首屏不是性能瓶颈稳定性优先。如果选 A我这里有一份回归验证清单照着过一遍能挡掉大部分问题首页、二级页、弹窗里异步加载的 chunk每条路径都实际点开一遍Network 面板里所有 JS 请求都是 200且Content-Type是 JavaScript 类型所有原来挂在window上的全局变量改成显式赋值window.xxx xxx后仍然可用依赖DOMContentLoaded的初始化逻辑时序有没有变化老插件比如需要全局jQuery、ECharts的那种能不能正常拿到依赖如果有 CSP 策略typemodule的脚本和内联模块脚本需要单独授权那次修复过程中还有一个副产品发现模板里有一段用document.write注入脚本的代码。document.write在 module script 里是被禁止的即使加上了typemodule也会失效。这段代码在方案 B 下没受影响但如果走方案 A 就必须一并重构掉。5. 模块语义带来的连带变化和易混淆报错速查5.1 defer 时序、this 指向、window 挂载的连带差异把脚本身份从 classic 改成 module最容易被忽略的不是语法而是运行时行为的三个变化。时序变化。module script 默认就是 deferred会在 HTML 解析完成之后、DOMContentLoaded触发之前执行并且按照依赖图的顺序执行。如果你原来的代码是同步脚本在 HTML 解析到一半时就跑完了改成模块后执行时机推后了那些脚本在 DOM 元素之前执行造成的隐式依赖就会暴露出来。我建议迁移时显式把初始化逻辑包进DOMContentLoaded监听或者直接放在模块底部不要依赖时序巧合。严格模式。模块默认严格模式原来能被容忍的写法会直接报错给未声明变量赋值会抛ReferenceError而不是静默创建全局变量、with语句、八进制字面量0755、函数参数重名、给只读属性赋值。这些在迁移时是集中爆发的建议先跑一遍 lint 把非严格模式的问题清掉。this指向。顶层this在模块里是undefined在 classic script 里是window。有些库的初始化代码会检测this来判断环境迁移后行为会变。全局变量不再自动上window。这是最大的行为改变。原来脚本 A 里写var config {...}脚本 B 里能直接用config换成模块之后脚本 B 完全看不到它。跨脚本通信必须显式化三个可行方案显式挂载window.config config、用CustomEvent发布订阅、或者干脆统一走export/import组成一个模块图。第三种最干净但要求所有相关脚本都完成迁移。5.2 三个长得像但根因不同的报错怎么分搜索这个报错的时候你一定会刷到一堆看起来差不多的兄弟报错。它们的修复方向完全不同先分清再动手能省下大量时间。报错信息真正根因修复方向Cannot use import statement outside a module脚本身份不对被当 classic script 解析加typemodule或改产物格式Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of text/html文件请求被服务器拦截返回了 404 页面或 SPA 回退页检查服务器路由和 MIME 配置SyntaxError: Unexpected token export同样是身份问题只是引擎版本不同导致文案差异同上按模块身份处理require is not defined在 ESM 环境里用了 CJS 语法改成import或用createRequirerequire is not defined in ES module scopeNode 里type: module后混用require换import或建createRequire实例Failed to resolve module specifier裸模块名或省略扩展名补扩展名或配 import mapUncaught SyntaxError: Invalid or unexpected token字符编码或文件内容被破坏常见于服务端返回乱码检查Content-Type的 charset 与文件编码中间那条 MIME 报错特别值得说一句。我遇到过好几次Nginx 配了try_files $uri $uri/ /index.html结果某个 JS 路径写错了服务器不返回 404而是返回了index.html浏览器拿到text/html内容按模块解析就报了 MIME 错误。这时候你以为是 CORS 或者模块配置问题实际上是一个 404 被伪装成了别的问题。所以看到 MIME 相关报错第一反应应该是去 Network 面板看 Response 内容到底是不是 JS。5.3 import map 与动态 import不动构建链路的渐进迁移法如果你的项目庞大到没法一次性改构建配置或者要同时兼容老页面和新模块有两个不引入打包工具也能用的手段。import map可以让浏览器原生支持裸模块名。它必须是页面里第一个被处理的模块相关脚本写在任何 module script 之前script typeimportmap { imports: { lodash: /vendor/lodash-es.js, utils/: /js/utils/ } } /script script typemodule src./app.js/script一个页面只应该有一个 import map多个的话只有第一个生效而且现代浏览器会给出警告。它解决的是路径映射问题不解决转译问题——语法太新的文件还是得自己处理。动态import()是更实用的迁移利器。这个语法在 classic script 里也能用返回 Promise可以用来按需加载模块非常适合渐进式改造// 仍然运行在 classic script 里 document.getElementById(open-editor).addEventListener(click, async () { const { createEditor } await import(./editor.js); createEditor(#container); });这样就实现了老页面不动新功能用模块的过渡状态。要注意两点被动态导入的文件必须以正确的 MIME 返回否则会走到 MIME 报错那条路上另外动态导入的模块如果内部又用了import语句那它本身必须是合法的 ESM这个链条上任何一环身份不对都会失败。我个人在实际操作中的体会是模块化迁移真正难的不是某处配置而是同时存在于项目里的三种加载方式构建工具解析、浏览器原生解析、Node 加载器。每一次报错都是在提示你其中的某一环判定错了。我现在养成的习惯是任何改动产物格式的提交都要在描述里写清楚这次改的是谁的身份并且在 CI 里加一条检查扫一遍所有 HTML含后端模板里引用产物的 script 标签属性。这条检查加上之后同款报错在团队里基本再没复现过。另外一个小技巧报错文件名带不带 hash 是判断构建产物问题还是源码问题的最快分界线带 hash 就直接查构建配置和宿主模板不要浪费时间去读产物代码。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →