这不是"浏览器抽风",而是语言层级 的规则在起作用。把"脚本(script)"和"模块(module)"分清楚,你就能一次性搞定
export
报错、import
无效、return
放哪儿、use strict
生效范围等一堆疑难杂症。
先给结论(省流篇)
- 不加
type="module"
的<script>
是"脚本文件(Script)" ,语法上不允许出现import / export
,所以直接报错。 - 加了
type="module"
的<script>
是"模块文件(Module)" ,才可以使用import / export
、top-level await
,并且天然处于严格模式。 - 脚本可包含:语句 。模块可包含:
import
声明、export
声明、语句(顺序无强制,但引擎会先处理导入/导出绑定)。
xml
<!-- ❌ 会报错:export 只能出现在模块中 -->
<script>
export const a = 1
</script>
<!-- ✅ 正确:这是模块 -->
<script type="module">
export const a = 1
</script>
一、脚本 vs 模块:同是 JS,生而不同
ES6 之前,只有 脚本(Script) 一种源文件形态;ES6 引入 模块(Module) 后,语法层面分了家。
能力点 | 脚本(Script) | 模块(Module) |
---|---|---|
语法支持 | 只允许普通语句 | 允许 import / export + 普通语句 |
this at top-level |
指向全局对象(非严格模式) | undefined (模块天然严格) |
严格模式 | 可选:由 "use strict" 控制 |
始终严格模式 |
作用域 | 顶层污染全局 | 私有顶层作用域(不污染全局) |
加载方式 | <script> 直接执行,顺序依赖 |
<script type="module"> 、import 、import() |
执行顺序 | 遇到就执行(可 defer/async ) |
天然 defer ,按依赖图静态解析、去重、只执行一次 |
top-level await |
不支持 | 支持 |
标题之问的根因 :默认
<script>
被当成"脚本",而export
是"模块语法",放错了"语法域"。
二、为什么 <script>
默认当"脚本"?
因为浏览器需要兼容 20+ 年的旧站点。如果不写 type
,规范规定按脚本处理 。要启用模块,必须显式写:
xml
<script type="module" src="/entry.js"></script>
type="module"
:告诉浏览器"按模块规范加载与解析"- 这会开启:静态依赖解析、严格模式、私有作用域、URL 解析、CORS 校验、Top-Level Await 支持 等一揽子能力
三、import
& export
:只在模块中合法
1)import
的 4 种常见形态
javascript
import "mod" // 仅执行 mod 的顶层代码(不拿任何绑定)
import v from "mod" // 默认导入
import { a as x, modify } from "mod" // 命名导入(带重命名)
import * as ns from "mod" // 整体命名空间导入
// 组合写法
import d, { a as x } from "mod"
import d, * as ns from "mod"
Live Binding(活绑定) :命名导入拿到的不是"复制的值",而是只读引用,原模块更新会同步映射。
2)export
的 3 类写法
javascript
// 声明式导出
export const a = 1
export function f() {}
export class C {}
// 批量导出"已存在的绑定"
const a = 1, b = 2
export { a, b as c }
// 默认导出(每个模块最多一个)
export default function () {}
export default class {}
// 或者导出一个表达式的值(与原变量"脱钩")
const obj = {}
export default obj // 之后再改 obj,不影响默认导出拿到的值
3)转发:export from
javascript
export { a as x } from "./a.js" // 直接把 a 转发出去
export * from "./a.js" // 转发所有"命名导出"
export { default as D } from "./a.js"
四、一次看懂"为什么我这里还是错?"
场景 A:没写 type="module"
xml
<script src="/index.js"></script> <!-- index.js 里写了 export -->
现象 :控制台报 SyntaxError: Unexpected token 'export'
原因 :以脚本解析,遇到模块语法 → 语法错误
修正 :<script type="module" src="/index.js"></script>
场景 B:混用了脚本与模块的变量
xml
<script>
const secret = 42
</script>
<script type="module">
console.log(window.secret) // undefined
console.log(secret) // ReferenceError: secret is not defined
</script>
原因 :模块顶层不再注入全局 ;脚本与模块顶层作用域隔离。
修正 :要么全部用模块,要么显式挂到 window.secret = 42
。
场景 C:想在脚本里"临时"用一下模块
脚本不能 import
,但可以用动态导入(它本身是表达式,允许在脚本内使用):
xml
<script>
(async () => {
const { add } = await import('/math.js') // ✅
console.log(add(1, 2))
})()
</script>
五、函数体、脚本、模块:谁能用什么语句?
三者都能装"语句列表",但模块多了导入导出,函数体多了
return
。
结构 | 语句 | import / export |
return |
严格模式 |
---|---|---|---|---|
脚本(Script) | ✅ | ❌ | ❌(顶层) | 可选 |
模块(Module) | ✅ | ✅ | ❌(顶层) | 始终严格 |
函数体(Function Body) | ✅ | ❌(顶层) | ✅ | 由所在环境决定/"use strict" |
另外还有四类函数体:普通、
async
、generator
、async generator
;决定能否使用await
/yield
。
六、预处理与指令序言:报错、提升,很多都在这里解释清楚
1)预处理(Hoisting-like 行为)
var
:在脚本/模块/函数体级 提前声明(不管有没有执行到),不穿透函数体边界function
:在顶层 既声明也赋值(能提前使用);在块级(if
等)仅预声明,赋值在执行期class
:在作用域内预留绑定,但不可访问(TDZ 抛错,行为更直觉)
javascript
console.log(fn) // function fn() {}
function fn() {}
console.log(C) // ReferenceError(TDZ)
class C {}
2)指令序言(Directive Prologs)
- 形如单个字符串字面量 的表达式,且必须位于最前
- 用途:最常见是
"use strict"
开启严格模式
javascript
"use strict";
function f(){ console.log(this) }
f.call(null) // 严格模式下为 null;非严格模式会变成全局对象
七、工程落地清单(团队可直接照抄)
1)页面里全量走模块
xml
<!-- 推荐:所有入口都改为模块 -->
<script type="module" src="/main.js"></script>
2)将脚本迁移为模块的要点
- 顶层不再往
window
挂变量(需要暴露就显式挂) - 跨文件通信改为
export / import
或事件/消息机制 - 资源加载需遵守 同源/CORS ,相对路径要以模块 URL为基准
- 可以用
top-level await
(注意并发与依赖链性能)
3)混合场景兜底
- 旧脚本中临时用模块:用
await import()
动态导入 - 构建工具(Vite/Webpack/Rspack)里固定
module
入口,避免"脚本/模块"混用
八、易错点速查(收藏级)
<script>
默认按脚本 解析;必须type="module"
才能写import/export
- 模块顶层是私有作用域 &严格模式 ,与全局隔离
- 命名导入是活绑定,不是值拷贝:原模块更新,导入端也会变
export default obj
与export { obj as default }
行为不同前者导出"值",后者导出"绑定"- 脚本里用模块:
await import('/foo.js')
- 模块允许 Top-Level Await;脚本不允许
class
有 TDZ:预留绑定但不可提前用 ,与function
不同
小作业
用你熟悉的 Babel/ESTree 工具,写一个小脚本,静态分析某个模块文件 ,打印出:
1)所有命名导出(含重命名)
2)是否存在默认导出
3)是否使用了
export from
转发
结语
export
在 <script>
中报错,并不是浏览器"挑事",而是你把模块语法 写进了脚本语法域 。
分清 脚本 vs 模块 ,再配合预处理 与指令序言的理解,你会发现:很多"莫名其妙"的错误,其实都能在语法层面一次性解释清楚。
你们项目现在是"全面模块化"还是"脚本+模块混合"? 留言说说你遇到的最诡异一个模块加载问题,我来帮你现场定位并给出最小修复方案 👇