Uncaught SyntaxError: Cannot use import statement outside a module这行红字几乎是我每次带新人时都会见到的一道坎。它不挑框架不管你是写原生 JS、用 Vite 起 Vue3 项目、还是在 Node 里跑一个脚本只要import statement出现的位置不对控制台就会原封不动地把这句话甩给你。很多人第一次见它的反应是我明明装了包代码在别的地方能跑然后开始怀疑 Node 版本、怀疑缓存、怀疑人生。实际上这条报错的信息量很大它精确地告诉了你三件事出问题的是个 import 语句、这个语句所在的上下文不是一个 module、这件事发生在解析阶段而不是运行阶段。看懂这三点基本上就抓住了排查方向。这篇内容我想按自己平时定位问题的顺序把根因、场景、修法和踩过的坑一次讲清楚刚接触 ES Module 的朋友能直接拿去抄作业写过几年的人也可以当一份速查手册留在手边。1. 报错信息到底在说什么1.1 拆开这条报错看三个关键词先别急着改代码把这句话一个字一个字读完。Uncaught说明这个错误没有被任何 try/catch 接住直接冒到了全局通常在浏览器控制台或 Node 终端里以红色呈现。SyntaxError是关键它表示问题发生在语法解析阶段也就是 JS 引擎还没开始执行你的任何一行逻辑读文件的时候就判定这文件不合法。最后半句Cannot use import statement outside a module才是正主引擎在说我按普通脚本的身份来读这个文件结果读到一行import xxx from yyy而普通脚本的顶层是不允许出现 import 语句的。这里有个很容易被忽略的细节——报错末尾的(at XXX)里的 XXX 就是罪魁祸首文件名。我见过太多人盯着main.js改了半小时结果报错里写的是vendor/legacy.js。第一步永远是先看清是哪个文件在报错不同文件往往对应完全不同的修法。再补一句很多人不知道的冷知识import()这种函数调用形式的动态导入在普通脚本里其实是合法的。被禁的只是顶层的静态import ... from ...声明。这一点在排查时非常有用因为如果你看到的是import()报错那方向就完全跑偏了。1.2 浏览器眼里的两种脚本身份浏览器拿到一个script标签默认把它当成classic script传统脚本来对待。传统脚本有几个特征顶层作用域就是全局var声明的变量会挂到window上可以随便用document.write出现语法错误会阻塞后续脚本。它压根不知道 module 是什么东西所以import、export、import.meta、顶层await这些 ES Module 专属语法在它眼里全是非法字符。想要切换身份就得显式声明script typemodule src./src/main.js/script加上typemodule之后浏览器的处理方式会发生一连串变化这些变化很多都是隐形的坑。它会启用严格模式不管你在文件开头写没写use strict顶层的this变成undefined而不是window脚本的执行时机自动变成 defer 效果也就是等 HTML 解析完再执行顺序按标签出现顺序走每个 module 拥有独立的作用域不再往全局挂东西。还有一条最容易被忽略——module 脚本必须走 HTTP 协议加载你用file://直接双击打开 HTML浏览器会因为跨域策略直接拒绝加载控制台给出的是另一条看着完全不相关的报错。1.3 为什么换个环境就好了这是最让人困惑的地方同一份代码在 Node 里node index.js跑得好端端的扔进浏览器就炸或者在 Vite 项目里开发一切正常npm run build之后丢到静态服务器上又炸了。原因在于不同运行环境判断这个文件是不是模块的依据完全不同代码本身没有变是裁判的规则变了。Node 的判断链条是这样的.mjs文件一定是 ESM.cjs文件一定是 CommonJS.js文件则去看最近的package.json里的type字段写了module就当 ESM写了commonjs或者干脆没写就按 CommonJS 处理。所以在 Node 里报这个错八成是你文件里写了import但项目没配type也没改扩展名。浏览器的判断链条简单粗暴只看script标签有没有typemodule跟文件名叫什么、跟 package.json 一点关系都没有。所以有时候你在 Node 项目里习惯性地写了个index.js当入口搬到浏览器里忘了改标签报错就跑出来了。构建工具又是另一套逻辑。Vite 在开发模式下基本不动你的源码直接把.js文件当 ESM 推给浏览器靠浏览器的原生模块能力加载所以入口标签必须是 module 类型。webpack 则会把所有东西打包成一个 IIFE 形式的 bundle产出的文件里早就没有import了自然也不会报这个错——除非你开了outputModule之类的配置或者用了typemodule去加载一个打包产物。1.4 不同环境的模块判定速查运行环境判定依据典型触发场景浏览器 classic script只看script标签有无typemodule静态页面直接写 import浏览器 module scripttypemodule HTTP 协议 正确 MIME忘了起本地服务器file://打开Node CommonJSpackage.json的type未设或为 commonjs文件为.js老项目里新写 ESM 语法Node ESM文件为.mjs或type: module老项目里用.js写 importVite 开发态源码按 ESM 直出浏览器入口标签漏写typemodulewebpack 产物打包后无 import 语法产物被当 ESM 加载或 external 配置出错这张表我建议直接截图存下来。遇到报错先对号入座比盲目搜索效率高得多。2. 按场景选方案别乱试2.1 静态页面场景的两种走法如果你就是写个 demo一个 HTML 加几个 JS 文件那解决问题的路径只有两条选哪条取决于你的目标。第一条路拥抱 ES Module。把script加上typemodule把 import 路径写全然后起一个本地服务器。这是现代写法好处是支持按需加载、支持 tree-shaking、支持顶层 await坏处是必须走 HTTP且路径不能省略扩展名。起服务器最省事的两个命令一个是 Node 生态里的npx serve一个是 Python 自带的python3 -m http.server 8080后者不需要装任何东西我出差在外用临时电脑时基本都用它。第二条路退回传统脚本。把所有文件用多个script标签按依赖顺序排好删掉 import/export靠全局变量或者 IIFE 传参来组织代码。这条路在现代项目里不推荐但如果你要对接一个只能放静态资源的托管环境又懒得配构建它确实是最快的。注意混用这两条路是事故高发区。比如你给入口加了typemodule但它 import 进来的某个工具文件里用了var挂全局指望别的传统脚本能读到——读不到的module 作用域是隔离的。2.2 构建工具场景下的排查重点用 Vite 的时候这个报错通常出现在两种时刻。一种是你在index.html里手写了一个script src/src/xxx.js忘了加typemodule。Vite 的开发服务器返回的是未经打包的源码浏览器直接解析标签没写对就炸。另一种是你的 HTML 被某个模板引擎拼接出来属性被吃掉了这时候得去看最终渲染到浏览器里的 DOM而不是看源文件。webpack 场景下相对少见但如果你在配置里动过experiments.outputModule、output.library.type: module或者给某些依赖配了externals让它保留 import 语句产物里就可能残留 ESM 语法。这时候去加载产物的那个script标签同样要加typemodule两边必须匹配缺一边就报错。2.3 Node 端的对应改法Node 里遇到同样的报错判断路径更清晰。先看文件名后缀.mjs不用改是.js就去项目根目录翻package.json看有没有type: module。没有的话加上的那一刻要格外小心因为整个目录下所有.js文件都会从 CommonJS 切成 ESM原本写require、写__dirname、写module.exports的文件会集体报错。我一般的做法是局部改造把需要 ESM 的那几个文件改成.mjs其余保持不动。这样影响面可控回滚也方便。等到整个项目都清理干净了再统一切换type字段。2.4 几种方案横向对比方案改动成本影响范围适用场景加typemodule一行单个页面静态 demo、Vite 项目文件改.mjs改名 改引用单文件Node 项目局部引入 ESMpackage.json加type: module一行整个目录全新 Node 项目引入构建工具中高整个工程需要兼容老浏览器、需要打包优化退回传统脚本中整个页面无法起服务器的极端情况选方案的核心逻辑是改动越小、影响越可控越好。能改一行标签解决的事就不要去动 package.json。3. 从复现到修好的完整实操3.1 先手动复现一遍我一直觉得没亲手复现过的报错是记不住根因的。所以第一步我们造一个最小工程。新建一个空目录里面放三个文件。index.html内容如下!DOCTYPE html html langzh-CN head meta charsetUTF-8 titlemodule demo/title /head body script src./main.js/script /body /htmlmain.js里写一行最普通的 importimport { greet } from ./utils.js; greet(world);utils.js导出对应函数export function greet(name) { console.log(hello, name); }用浏览器直接打开index.html控制台立刻给出Uncaught SyntaxError: Cannot use import statement outside a module。报错位置指向main.js第一行。到这里复现完成我们手里有了一个可以反复折腾的最小样本。3.2 第一种修法给标签加上 module 身份打开index.html把那一行改成script typemodule src./main.js/script保存刷新。如果你是用file://打开的大概率会看到新的一条报错说什么跨域或者 script 加载失败。这不是我们改错了而是 module 脚本不允许从本地文件协议加载。这时候在目录下起一个服务python3 -m http.server 8080然后访问http://localhost:8080/index.html控制台应该干净了并且打印出hello, world。这一步走通说明你理解了浏览器判定模块身份的唯一依据。顺带说一个细节typemodule的脚本默认是延迟执行的等价于加了defer。如果你之前的代码依赖脚本在解析到就立即执行这个行为比如在脚本里document.write一段 HTML换成 module 之后行为会变。document.write在 module 里基本就是禁用状态会直接被忽略并给警告。3.3 第二种修法路径必须写全在刚才的例子里import { greet } from ./utils.js我特意写上了.js。这不是我啰嗦而是 ES Module 规范里不做扩展名补全也不做目录索引查找。这两条和 Node 的 CommonJS 完全不同是新人踩坑的重灾区。具体来说下面这两种写法在 ESM 里都不行import { greet } from ./utils; // 报错找不到模块 import { greet } from ./lib; // 报错不会自动找 ./lib/index.js第二种必须写成./lib/index.js才能生效。这个限制在浏览器和 Node 的 ESM 模式下是一致的所以记住一次就能一直用。如果你有大量文件需要批量补扩展名可以用 ESLint 的import/extensions规则来兜底也可以直接跑一个脚本批量替换。我之前的做法是先在eslint.config.js里把这条规则打开然后跑eslint --fix大部分遗漏的扩展名能自动补上剩下的是那些动态拼接的路径只能手工看。3.4 第三种修法Node 端的三种切换姿势浏览器端搞定之后回头看看 Node 端。假设你有个scripts/build.js里面写了 import运行node scripts/build.js报同样的错。三条路可以走。改扩展名把文件改成build.mjs然后node scripts/build.mjs立竿见影影响范围只有这一个文件。改 package.json在scripts/目录下新建一个package.json只写{ type: module }这样只有scripts/目录下的.js会被当 ESM项目其他部分不受影响。这是我最推荐的做法属于就近原则作用域清晰。全局切换在项目根package.json里加type: module整个项目所有.js都变 ESM。改之前一定要先跑一遍全量测试把require、__dirname、__filename、module.exports这些 CJS 专属的东西全找出来。__dirname在 ESM 里没有得用import.meta.url配合fileURLToPath手动算import { fileURLToPath } from node:url; import { dirname } from node:path; const __filename fileURLToPath(import.meta.url); const __dirname dirname(__filename);这段代码我大概抄过几十遍建议直接存成代码片段。3.5 改完之后还会冒出来的连带报错修好一条报错往往会蹦出新的这是正常的说明你在往深处走。下面这几个是我最常遇到的。Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of text/html。这条基本可以断定是路径写错导致 404服务器返回了 404 页面通常是 HTML浏览器拿到text/html就拒绝当模块执行。去 Network 面板看那条请求的状态码和响应内容一眼就能确认。还有一种情况是服务器配置错误把.js的 MIME 类型配成了text/plain这需要改服务端配置。Uncaught SyntaxError: Invalid or unexpected token。这条比前面那条更模糊通常是真的语法错误比如中英文标点混用、模板字符串里嵌套了反引号、JSON 文件里多了逗号。它的排查靠的是看报错的行列号然后逐字符检查那一行。有时候错误其实在前一行因为解析器要读到下一行才能确定上一行没结束。require is not defined in ES module scope。这就是我前面说的切到 ESM 之后老代码里残留的require在抗议。混用require和import在 ESM 里是不行的需要把require换成import或者用createRequire手动造一个 require 出来。import { createRequire } from node:module; const require createRequire(import.meta.url); const someCjsPkg require(some-cjs-pkg);这段在必须引入纯 CommonJS 包的时候很有用尤其是那些没提供 ESM 入口的老库。4. 排查思路和常见问题速查4.1 我平时用的四步定位法第一眼永远看报错里的文件名。控制台给的(at XXX)是精确定位不要跳过它去猜。如果报错来自一个你自己没写过的文件比如chunk-xxxx.js、vendor.js那说明问题出在构建产物或第三方依赖身上方向完全不同。第二步看网络面板。把这个文件在 Network 里搜出来看三件事状态码是不是 200、响应头的 Content-Type 是不是application/javascript或text/javascript、响应体是不是你期望的 JS 内容。这三项里任何一项不对根因就找到了。我遇到过好几次是 CDN 把.js请求重定向到了登录页返回一坨 HTML报错信息跟真实原因差着十万八千里。第三步看加载方式。HTML 里的script标签有没有typemoduleNode 里是.mjs还是.jspackage.json的type是什么这些都是判定身份的直接依据一定要核对。第四步才是怀疑代码本身。语法有没有问题、路径有没有写全、有没有循环依赖。把这几步走完绝大多数情况都能定位。4.2 常见问题速查表报错或现象最可能的原因处理方式Cannot use import statement outside a module标签缺typemodule补上属性同上Node 环境package.json无type且文件为.js改.mjs或加typeFailed to load module script (MIME)路径 404 或服务端 MIME 配错查 Network修路径或配置Invalid or unexpected token真实语法错误逐字符检查报错行附近require is not definedESM 里用了 CJS 语法换 import 或 createRequire本地双击 HTML 无法加载模块file://被跨域策略拦截起本地 HTTP 服务Cannot find module ./utilsESM 不补扩展名写成./utils.js动态import()报错其实允许可能是路径或类型问题检查传入的字符串路径4.3 几个容易被误判的长得像的报错搜索的时候你会发现热词列表里一堆报错看着眼熟但根因差得远。比如ModuleNotFoundError: No module named opencv、wandb 报错、detectron2 安装报错这些是 Python 的模块导入失败跟 JS 的模块系统毫无关系只是module这个词撞车了。还有像Qt unknown module in Qt: serialport、module ip6_tables not found属于 C 构建和内核模块的范畴。看到这类词的时候不要被带偏先分清自己处在哪个技术栈里。真正值得留意的近亲是这些Failed to load module script: Expected a JavaScript-or-WASM module script这是加载方式不匹配Unexpected token export这是产物里残留了 ESM 语法但被当传统脚本执行本质和本文这条报错是同一个病根import.meta is only valid in modules同样是身份判定错误。把这几个归成一类记忆遇到时反应会快很多。4.4 踩过的几个坑第一个坑是缓存。改完标签刷新没效果先别怀疑自己用无痕窗口或者禁用缓存重新加载。module 脚本的缓存策略比传统脚本更激进有时候改一行代码刷新半天看不到变化极为折磨。第二个坑是循环依赖。ESM 的循环依赖表现和 CommonJS 不同可能出现某个导入是undefined而不是报错。如果你的报错修好了但功能不对检查一下有没有 A 引 B、B 又引 A 的结构。第三个坑是顶层 await。这个语法只在 module 里可用一旦你为了用顶层 await 加了typemodule注意它会让整个依赖链的加载顺序发生变化某些原本同步就绪的代码可能变成异步。第四个坑是第三方脚本。有些第三方统计、埋点脚本是用传统方式写的你把整个页面的入口改成了 module它可能挂不上全局变量。这种时候要么保持它独立要么给它套一层window.xxx ...的显式导出。5. 工程化层面的预防手段5.1 用 lint 规则提前拦住靠人记规则是不现实的靠工具才是正解。ESLint 里有几条规则专门针对这类问题。import/extensions可以在团队里统一扩展名策略强制要求写全或者允许省略import/no-unresolved能在编辑阶段就告诉你哪个路径找不到node/no-unsupported-features/es-syntax可以限制某些语法在特定 Node 版本下使用。我一般会在项目里把这套配置固化下来配合编辑器的实时提示新人第一次写 import 就会看到波浪线比等到运行时报错再回头找高效得多。TypeScript 项目就更省心moduleResolution设成bundler或node16之后路径问题基本在编译期就暴露了。5.2 目录结构和命名约定一个稳定的约定能省掉大量沟通成本。我的习惯是项目里所有.js一律按 ESM 写package.json统一声明type: module不搞混合模式。确实需要 CommonJS 的地方文件后缀明明白白写成.cjs。这样任何人看文件名就知道该用什么语法不需要去翻配置。入口文件统一放在约定位置构建产物和不参与构建的脚本分开目录避免有人不小心把源码路径写进了生产环境的 HTML。这个规则听着琐碎但真出事的时候能救命。5.3 团队协作里的几个检查点代码评审的时候我会有意识地盯三处。HTML 里的script标签有没有typemodule尤其是新增的入口import 路径有没有写全扩展名新增的.js文件有没有配套更新package.json的type。这三点守住这个报错基本就不会再出现在主干分支上。本地开发环境也建议统一比如所有人的本地服务器都用同一个命令起端口号写在 README 里避免有人用file://打开、有人用 IDE 内置预览、有人用 http-server行为不一致导致问题难以复现。5.4 构建配置里的几个开关如果你用 Vitebuild.target决定了产物的语法级别设得太低可能会把 ESM 语法降级成传统脚本反而引入新的兼容问题。build.modulePreload影响模块预加载行为调试加载顺序问题时会用上。webpack 里experiments.outputModule打开后产物就是 ESM加载它的标签必须匹配。output.library.type设为module时同理。还有externals如果你把某个依赖声明为 external它在产物里会以 import 形式保留浏览器加载时就必须走模块模式。这几个配置平时不用动一旦动了就要回头检查页面的加载方式。6. 最后再聊几句实际体会这个报错我前后遇到过的次数大概比我这几年写过的console.log还多。最开始每次都去搜索引擎上翻半天后来慢慢形成条件反射一看文件名、二看标签、三看 Network、四看配置。四步走完九成情况当场解决。我在实际使用中最深的体会是不要试图去绕过模块系统。有些人在网上看到把 import 改成 require就照做在浏览器里直接懵掉因为浏览器根本不认识 require。也有人想用 CDN 上的 UMD 版本糊过去短期能跑长期会把依赖管理搞成一团乱麻。ES Module 是当前的标准方向早一点把项目的模块体系理清楚后面省下的时间远比折腾这一下的成本高。再分享一个我常用的小技巧如果你在排查一个复杂项目的加载问题先把所有typemodule的脚本临时缩到一个最小入口只 import 一个最简单、没有任何依赖的工具函数。如果这样能跑通说明基础设施是对的问题在依赖链深处如果这样都跑不通问题一定在加载方式或服务配置上。这个二分法我试过很多次定位速度比逐个文件排查快得多。最后提一句这个报错的知识点其实是可以向外扩展的理解了 ESM 的加载机制再去看 tree-shaking 为什么能生效、去看动态导入怎么做代码分割、去看import.meta能拿到哪些信息都会顺畅很多。这条报错看似是个拦路虎实际上它是个不错的入口顺着它往下挖能把整个前端模块化的脉络摸一遍。