从普通 script 到 ESM:一文搞懂 JavaScript 模块、IIFE 和 window

简介: 通过示例,从零讲清 JavaScript、TypeScript、ESM、IIFE 与 npm 的区别,理解普通 script 和模块脚本的加载方式、window 全局对象与作用域的关系。结合可运行代码,演示普通脚本与 ESM 如何混合使用,以及同一套源码如何提供不同的接入方式。

写前端代码时,我们经常遇到两种用法:

<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.,更能准确理解它的接入方式。

目录
相关文章
|
8天前
|
人工智能 JSON API
全网刷屏的 Jev 模型正式开放!一手实战测评 + 保姆级教程
全网爆火的 Jev 模型是什么?有什么用?怎么使用?怎么接入 AI 编程工具?效果真的好么?傻子可懂的 Jev 保姆级实战教程 + 项目实战测评来啦
7386 12
|
6天前
|
人工智能 测试技术 API
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
Jev是TypeSafe AI推出的“系统一模型”,不生成文本,专做毫秒级结构化决策:Choice(多选)、Score(打分)、Noul(是非概率)。响应快193倍、成本低444倍,适合工单路由、内容审核、测试定级等高频判断场景。
1545 4
最近全网爆火的 Jev 到底是什么?适合干什么、怎么用,一篇讲透!
|
7天前
|
人工智能 并行计算 PyTorch
秋叶 ComfyUI 2026 整合包 v3.2 完整部署教程:Python 3.13 + Torch 2.13 全栈升级
秋叶aaaki ComfyUI 2026年8月整合包v3.2正式发布!全面升级Python 3.13.11、PyTorch 2.13.0+cu130及ComfyUI v0.30.2,原生支持MiniMax H3、Wan 2.2、Qwen-Image-2.1等2026主流音视频/图像模型,解压即用,无需环境配置。
1008 8
|
3天前
|
人工智能 JavaScript 芯片
DeepSeek 官方偷偷上传 Harness 桌面端安装包,我已经用上了。。附最新下载地址
DeepSeek Harness 官方的桌面端安装包被网友扒出来了,2 分钟讲明白如何使用,体验如何,适合作为 AI 编程工具么?附最新 Windows 和 Mac 双端的下载地址
1200 1
|
20天前
|
人工智能 自然语言处理 安全
阿里云千问办公 QwenWork详细介绍:产品核心能力、典型场景、价格及常见问题解答
千问办公是阿里云推出的一站式AI办公平台,主打"不止于对话,更注重交付",依托通义千问旗舰大模型,用户一句话即可完成数据分析、PPT生成、视频剪辑等复杂任务,直接输出可用成果。产品深度打通钉钉生态与企业OA,覆盖桌面端、网页端,提供企业标准版198元/人/月等多档订阅方案,新用户注册即赠2000积分,适配工程师、HR、财务等多职业办公场景,成为能动手干活的"全能AI同事"。
3581 10
|
15天前
|
缓存 IDE Java
【保姆级】Android Studio下载、安装和汉化教程(2026最新)
Android Studio 是 Google 官方推出的免费 Android 应用开发集成环境,基于 IntelliJ IDEA,内置模拟器、调试器、性能分析及 Compose 界面工具,功能全面,文档丰富,是安卓开发首选工具。(239字)
1611 1
|
4天前
|
编解码 缓存 PyTorch
16G 显卡能跑 Qwen-Image 2.1 吗?
9月20日,阿里Qwen开源Qwen-Image-2.1:7B DiT图像模型+8B文本编码器+VAE,单模型支持文生图与图像编辑,原生输出2K PNG(含Alpha通道),支持10张参考图。在自建Qwen-Image-Bench达60.28分(开源模型第一),GenAI Showdown文生图排名7/15。16G显存可跑1024×1024(需INT8量化+ComfyUI优化),但2K需24G以上。注意其Qwen Research License限非商业用途。
506 1
|
5天前
|
人工智能 编解码 并行计算
MiniMax-H3 一键整合包技术文档:8G 显存运行 AI 漫剧制作 —— 角色替换 / 动作迁移 / 文图生视频部署与调参指南
MiniMax H3 是 MiniMax 开源的全模态视频生成模型,支持文/图/音/视多条件输入,输出最高2K、15秒带双声道音频视频。本文档详述其Int8量化版在8GB显存下的本地一键部署、三段式工作流(EDIT/REPLACE/CONTINUE)、参数调优及常见问题排查。(239字)

热门文章

最新文章