写前端代码时,我们经常遇到两种用法:
<script src="./xxdemo.js"></script>
import {
hello } from './xxdemo.js'
同样是 JS 文件,为什么加载方式不同?为什么有些库会写 window.xxx,有些库却使用 export?IIFE 又是什么,为什么要让函数定义后立即执行?
1. JS、TS、ESM、npm,分别在说什么?
先分清它们描述的层面:
| 名称 | 描述的事情 | 简单理解 |
|---|---|---|
| JavaScript,简称 JS | 编程语言 | 编写浏览器执行的逻辑 |
| TypeScript,简称 TS | 带类型系统的语言 | 开发时增加类型检查,通常转换为 JS 后交付 |
| ESM | JavaScript 的标准模块机制 | 用 import 和 export 共享功能 |
| IIFE | 一种函数表达式写法 | 函数定义后立即调用,常用于隔离内部变量 |
| npm | 软件包管理和分发 | 安装依赖、管理版本、发布代码包 |
| Vite、esbuild 等 | 开发和构建工具 | 转换源码、处理依赖、生成发布文件 |
因此,“使用 JS 还是 ESM”并不是两个并列选项。采用 ESM 时,写的仍然可以是 JavaScript。
TypeScript 的类型主要在开发阶段帮助检查代码,不会作为类型检查逻辑自动带到浏览器中。参考:TypeScript 官方入门
2. 普通 script 是怎么使用 JS 的?
本文把没有 type="module" 的 JavaScript 脚本称为“普通脚本”,也叫经典脚本。
例如,一个完整的 HTML 页面可以这样写:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>xxdemo 普通脚本示例</title>
</head>
<body>
<p id="output"></p>
<script>
function hello(name) {
return '你好,' + name
}
document.getElementById('output').textContent = hello('xxdemo')
</script>
</body>
</html>
这里的代码由浏览器直接执行。也可以把脚本移到外部文件,再通过 src 加载。
文件以 .js 结尾,并不能说明它是普通脚本还是 ESM。 还要看文件内容,以及它被怎样加载。参考:MDN script 元素
3. window.xxx 是什么?
在浏览器页面里,window 是全局对象。下面的代码给它增加了一个属性:
window.XXDemo = {
hello(name) {
return '你好,' + name
}
}
其他脚本就可以使用:
window.XXDemo.hello('xxdemo')
在没有同名局部变量遮蔽等情况下,也可以写:
XXDemo.hello('xxdemo')
把功能集中在 XXDemo 下面,可以避免将许多名字分别放到全局,例如 window.hello、window.reset、window.start。
但 window.xxx 本身不是模块规范。普通脚本可以给它赋值,ESM 也可以给它赋值。
4. IIFE 为什么要立即执行?
IIFE 的全称是 Immediately Invoked Function Expression,中文叫“立即执行函数表达式”。
普通函数往往先定义,再调用:
function start() {
console.log('开始运行')
}
start()
IIFE 把这两个动作写在一起:
;(function () {
console.log('开始运行')
})()
前面的分号用于避免它与前一段未写分号的代码意外连在一起;最后的 () 执行前面的函数。箭头函数也可以这样写:
;(() => {
console.log('开始运行')
})()
在浏览器 SDK 中,IIFE 的一个常见用途是创建函数作用域,把内部变量隔离起来。参考:MDN IIFE
新建 xxdemo.global.js:
;(() => {
const prefix = '你好,'
function hello(name) {
return prefix + name
}
// 只把希望外部使用的功能公开出去
window.XXDemo = {
hello }
})()
再新建 global.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>xxdemo IIFE 示例</title>
</head>
<body>
<p id="output"></p>
<script src="./xxdemo.global.js"></script>
<script>
document.getElementById('output').textContent =
window.XXDemo.hello('xxdemo')
</script>
</body>
</html>
脚本执行时,外层函数立即运行,并设置 window.XXDemo。内部的 prefix 和 hello 不会作为同名全局变量公开。
这里“立即执行”的是外层初始化代码,不代表所有内部函数都执行一遍。hello() 仍然要等页面调用时才执行。
公开的 hello() 能继续访问内部 prefix,这是闭包的作用。它保留了对定义时所在环境的访问能力。
5. IIFE 必须写 window 吗?
不必须。IIFE 可以只执行一些操作,完全不提供外部接口:
;(() => {
console.log('执行一次初始化')
})()
也可以返回对象,由外部变量接住:
var XXDemo = (() => {
function hello(name) {
return '你好,' + name
}
return {
hello }
})()
当这段代码作为普通脚本在顶层执行时,顶层 var XXDemo 也会成为 window 的属性。所以,没有手写 window.,并不等于没有全局变量。
请把这个例子与上一种 xxdemo.global.js 写法分别试验,不需要同时放进同一页面。参考:MDN var
6. ESM 又是怎么共享功能的?
ESM 的全称是 ECMAScript Modules,是 JavaScript 的标准模块机制。入门时先记住两个关键字:
export:把功能提供给其他模块。import:引用其他模块提供的功能。
新建 xxdemo.esm.js:
const prefix = '你好,'
export function hello(name) {
return prefix + name
}
再新建 module.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>xxdemo ESM 示例</title>
</head>
<body>
<p id="output"></p>
<script type="module">
import {
hello } from './xxdemo.esm.js'
document.getElementById('output').textContent = hello('xxdemo')
</script>
</body>
</html>
ESM 自带模块作用域。未导出的 prefix 留在模块内部,调用方通过 import 获得 hello,不需要把它挂到 window 上。
静态 import 和 export 需要模块上下文。如果把上面的 type="module" 去掉,静态 import 就会报语法错误。参考:MDN import
7. 把两个例子真正跑起来
整理成下面的目录:
xxdemo/
├─ global.html
├─ xxdemo.global.js
├─ module.html
└─ xxdemo.esm.js
安装 Node.js 和 npm 后,在这个目录打开终端,执行:
npx --yes http-server . -p 8080 -c-1
这个命令通过 npm 获取并运行静态文件服务器,. 表示当前目录,8080 是端口,-c-1 关闭缓存以便调试。它只是启动开发用服务,不会把示例打包。参考:http-server 官方说明
分别访问:
http://localhost:8080/global.html
http://localhost:8080/module.html
两页的预期结果都是“你好,xxdemo”,但功能的提供和获取方式不同:
| 页面 | 功能由谁提供 | 页面如何获取 |
|---|---|---|
| global.html | 普通脚本设置的全局对象 | window.XXDemo.hello() |
| module.html | ESM 的导出 | import { hello } ... |
测试模块页面时,应通过 HTTP 服务访问,避免直接双击 HTML,以 file:// 打开时遇到模块加载限制。服务运行期间保持终端开启,停止时按 Ctrl+C。参考:MDN 模块指南
8. 普通脚本和 ESM 能混用吗?
可以。在同一个 HTML 页面中,先加载全局版本,再在模块中调用它:
<script src="./xxdemo.global.js"></script>
<script type="module">
console.log(window.XXDemo.hello('xxdemo'))
</script>
模块也可以访问页面中已经存在的全局对象。但这不等于下面的写法自动成立:
// xxdemo.global.js 没有导出 hello,不能这样获取具名导出
import {
hello } from './xxdemo.global.js'
访问全局属性和导入模块成员是两回事。一个只有 window.XXDemo = ... 的文件,没有因此获得名为 hello 的 ESM 导出。
如果直接导入整个文件只是为了执行它,某些仅含普通 JS 的文件也能作为模块执行,例如:
import './xxdemo.global.js'
console.log(window.XXDemo.hello('xxdemo'))
本例可行,是因为文件明确给 window 赋值。不能由此推断所有旧脚本都可以直接改成模块运行:模块的作用域、严格模式和顶层 this 等行为与普通脚本不同。
反过来,普通脚本也可以通过动态 import() 加载 ESM:
<script>
import('./xxdemo.esm.js').then(({
hello }) => {
console.log(hello('xxdemo'))
})
</script>
这里 import() 是异步加载表达式,返回 Promise;它与只能写在模块中的静态 import ... from ... 不同。参考:MDN 动态 import()
9. 没写 window 的普通脚本,模块还能用吗?
要看变量位于哪里,以及脚本如何执行。下面的例子单独放到一个 HTML 页面中测试:
<script>
var xxdemoCount = 1
const xxdemoMessage = '你好'
function xxdemoHello() {
return 'hello'
}
</script>
<script type="module">
console.log(xxdemoCount) // 1
console.log(window.xxdemoCount) // 1
console.log(xxdemoMessage) // 你好
console.log(window.xxdemoMessage) // undefined
console.log(xxdemoHello()) // hello
</script>
这个例子中的声明位于普通脚本顶层:var 和函数声明会成为全局对象的属性;let/const 创建的全局绑定则不会成为 window 的属性,但后续脚本仍然能通过名字访问。
因此,“全局可访问”与“是 window 的属性”并不完全相同。参考:MDN let
如果把功能藏在函数里面,不提供出口,其他脚本就无法直接访问:
<script>
;(() => {
function xxdemoPrivateHello() {
return '内部功能'
}
})()
</script>
<script type="module">
// 取消下一行注释,会报 ReferenceError
// xxdemoPrivateHello()
</script>
这时普通脚本同样访问不到该内部函数,与调用方是否使用 ESM 无关。
上述规则也不能照搬到模块顶层。模块里的顶层声明属于模块,不会像普通脚本的顶层声明一样自动成为页面全局绑定。
10. 一个入口,是不是只能有一个文件?
不是。把 module.html 中的模块代码移到 main.js:
// main.js
import {
hello } from './xxdemo.esm.js'
document.getElementById('output').textContent = hello('xxdemo')
HTML 保留 id="output" 的元素,将原有模块脚本替换为:
<script type="module" src="./main.js"></script>
HTML 只声明一个入口,浏览器仍会继续加载它所引用的模块:
module.html
↓
main.js
↓
xxdemo.esm.js
打开开发者工具的 Network 面板刷新页面,就可以观察这两个 JS 请求。一个入口描述的是接入方式,一个文件描述的是实际文件数量。
11. 为什么有些包同时有 TS、JS、ESM 和全局版本?
xxdemo/
├─ src/
│ └─ index.ts
└─ dist/
├─ xxdemo.esm.js
├─ xxdemo.global.js
└─ index.d.ts
- ESM 版本通过
import/export接入。 - 全局版本通过普通
<script>加载,提供约定的全局名称,常采用 IIFE 封装。 .d.ts提供类型声明和编辑器提示,本身不执行功能。
源码有 export,不代表每个构建结果都必须保留 export。构建工具可以把内部模块整理成 IIFE,再通过统一的出口公开功能。参考:Vite 库模式
除了 ESM,还会遇到以下名称:
| 名称 | 典型特征 |
|---|---|
| CommonJS,简称 CJS | 常见于 Node.js,使用 require()、module.exports |
| AMD | 使用 define() 等形式,配合对应模块加载器 |
| UMD | 根据环境适配 CommonJS、AMD 或浏览器全局对象 |
| IIFE | 立即执行函数表达式,常用于浏览器脚本封装 |
这些概念并不完全处在同一个层面:ESM、CommonJS、AMD 涉及模块组织与加载机制,UMD 是兼容不同环境的封装模式,IIFE 是函数表达式写法。
12. npm 安装和浏览器 import 有什么关系?
npm install 负责获取软件包及依赖;import 负责在代码里引用功能。npm 不是另一种 JS 语法,也不意味着一个包只能在某个框架中使用。参考:npm 官方介绍
下面两种引用也有差别:
import {
hello } from './xxdemo.esm.js'
import {
hello } from 'xxdemo'
前者给出了文件地址,后者使用裸模块名。浏览器不会看到 xxdemo 就自动执行 npm 安装,也不会自动按 Node.js 的方式查找 node_modules。
在工程中,通常由工具处理包名。在浏览器中,可以使用 import map 声明对应关系:
<script type="importmap">
{
"imports": {
"xxdemo": "./xxdemo.esm.js"
}
}
</script>
<script type="module">
import {
hello } from 'xxdemo'
console.log(hello('xxdemo'))
</script>
把它放到有对应文件的 HTML 页面中即可。import map 负责映射模块地址,不负责安装依赖或把任意 npm 包转换成浏览器代码;文件及其后续依赖仍需可以正确加载。参考:MDN import map
判断一个库该怎么使用时,可以按顺序查看:它的文档要求普通脚本还是模块脚本、入口是否提供 ESM 导出、是否设置全局对象,以及依赖需要怎样加载。比只看文件后缀,或只看有没有 window.,更能准确理解它的接入方式。