浏览器原生 ESM 使用指南

6 阅读8分钟

目录

  1. 什么是原生 ESM
  2. 浏览器支持情况
  3. 基础使用方法
  4. ESM 语法详解
  5. 实际应用场景
  6. 模块路径和解析
  7. 动态导入
  8. 与构建工具的区别
  9. 最佳实践
  10. 常见问题解决
  11. 总结

什么是原生 ESM

定义

原生 ESM(ES Modules) 是 JavaScript 的官方模块系统,浏览器原生支持,无需构建工具即可在浏览器中直接使用。

核心特点

  • 浏览器原生支持:无需打包工具
  • 按需加载:只加载需要的模块
  • 异步加载:不阻塞页面渲染
  • 严格模式:自动启用严格模式
  • 静态分析:编译时确定依赖关系

与传统方式的对比

<!-- 传统方式:使用全局变量 -->
<script src="jquery.js"></script>
<script src="app.js"></script>
<!-- 问题:全局污染、依赖顺序、难以管理 -->

<!-- 原生 ESM:模块化 -->
<script type="module" src="app.js"></script>
<!-- 优势:模块化、按需加载、无全局污染 -->

浏览器支持情况

支持情况

浏览器版本支持情况
Chrome61+✅ 完全支持
Firefox60+✅ 完全支持
Safari10.1+✅ 完全支持
Edge16+✅ 完全支持
IE 11-❌ 不支持

检查支持

// 检查浏览器是否支持 ESM
if ('noModule' in HTMLScriptElement.prototype) {
  console.log('支持原生 ESM');
} else {
  console.log('不支持原生 ESM');
}

降级方案

<!-- 为不支持 ESM 的浏览器提供降级 -->
<script type="module" src="app.js"></script>
<script nomodule src="app-legacy.js"></script>
<!-- 支持 ESM 的浏览器:加载 app.js -->
<!-- 不支持 ESM 的浏览器:加载 app-legacy.js -->

基础使用方法

1. 内联脚本

<!DOCTYPE html>
<html>
<head>
  <title>原生 ESM 示例</title>
</head>
<body>
  <script type="module">
    // 导入模块
    import { add, subtract } from './math.js';
    
    // 使用模块
    console.log(add(1, 2));      // 3
    console.log(subtract(5, 2));  // 3
  </script>
</body>
</html>

2. 外部脚本

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>原生 ESM 示例</title>
</head>
<body>
  <script type="module" src="app.js"></script>
</body>
</html>
// app.js
import { add } from './math.js';
import { formatDate } from './utils.js';

console.log(add(1, 2));
console.log(formatDate(new Date()));

3. 模块文件结构

project/
├── index.html
├── app.js
├── math.js
└── utils.js
// math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}
// utils.js
export function formatDate(date) {
  return date.toISOString();
}
// app.js
import { add, subtract } from './math.js';
import { formatDate } from './utils.js';

console.log(add(1, 2));
console.log(formatDate(new Date()));

ESM 语法详解

1. 命名导出(Named Exports)

// math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

export const PI = 3.14159;

// 或者
function multiply(a, b) {
  return a * b;
}

export { multiply };
// app.js
import { add, subtract, PI } from './math.js';

console.log(add(1, 2));
console.log(PI);

2. 默认导出(Default Export)

// Calculator.js
export default class Calculator {
  add(a, b) {
    return a + b;
  }
  
  subtract(a, b) {
    return a - b;
  }
}
// app.js
import Calculator from './Calculator.js';

const calc = new Calculator();
console.log(calc.add(1, 2));

3. 混合导出

// utils.js
export default function formatDate(date) {
  return date.toISOString();
}

export function formatCurrency(amount) {
  return `$${amount.toFixed(2)}`;
}

export const VERSION = '1.0.0';
// app.js
import formatDate, { formatCurrency, VERSION } from './utils.js';

console.log(formatDate(new Date()));
console.log(formatCurrency(100));
console.log(VERSION);

4. 全部导入(Namespace Import)

// math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

export function multiply(a, b) {
  return a * b;
}
// app.js
import * as math from './math.js';

console.log(math.add(1, 2));
console.log(math.subtract(5, 2));
console.log(math.multiply(2, 3));

5. 重新导出(Re-export)

// math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}
// operations.js
export { add, subtract } from './math.js';

// 或者
export * from './math.js';
// app.js
import { add, subtract } from './operations.js';

6. 重命名导入/导出

// math.js
export function add(a, b) {
  return a + b;
}
// app.js
import { add as sum } from './math.js';

console.log(sum(1, 2));
// math.js
function add(a, b) {
  return a + b;
}

export { add as sum };

实际应用场景

场景 1:简单工具函数库

// utils/string.js
export function capitalize(str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
}

export function reverse(str) {
  return str.split('').reverse().join('');
}
// utils/number.js
export function formatNumber(num) {
  return num.toLocaleString();
}

export function random(min, max) {
  return Math.floor(Math.random() * (max - min + 1)) + min;
}
// utils/index.js
export * from './string.js';
export * from './number.js';
// app.js
import { capitalize, formatNumber } from './utils/index.js';

console.log(capitalize('hello'));      // Hello
console.log(formatNumber(1234567));   // 1,234,567

场景 2:组件化开发

// components/Button.js
export default class Button {
  constructor(text) {
    this.text = text;
    this.element = document.createElement('button');
    this.element.textContent = text;
  }
  
  render() {
    return this.element;
  }
}
// components/Card.js
export default class Card {
  constructor(title, content) {
    this.title = title;
    this.content = content;
  }
  
  render() {
    const card = document.createElement('div');
    card.className = 'card';
    card.innerHTML = `
      <h3>${this.title}</h3>
      <p>${this.content}</p>
    `;
    return card;
  }
}
// app.js
import Button from './components/Button.js';
import Card from './components/Card.js';

const button = new Button('Click me');
const card = new Card('Title', 'Content');

document.body.appendChild(button.render());
document.body.appendChild(card.render());

场景 3:API 封装

// api/users.js
export async function getUsers() {
  const response = await fetch('/api/users');
  return response.json();
}

export async function getUser(id) {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}

export async function createUser(user) {
  const response = await fetch('/api/users', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(user)
  });
  return response.json();
}
// app.js
import { getUsers, getUser, createUser } from './api/users.js';

async function init() {
  const users = await getUsers();
  console.log(users);
  
  const user = await getUser(1);
  console.log(user);
}

init();

场景 4:配置管理

// config.js
export const API_BASE_URL = 'https://api.example.com';
export const API_VERSION = 'v1';
export const TIMEOUT = 5000;

export default {
  api: {
    baseURL: API_BASE_URL,
    version: API_VERSION,
    timeout: TIMEOUT
  },
  features: {
    enableCache: true,
    enableLogging: false
  }
};
// app.js
import config, { API_BASE_URL } from './config.js';

console.log(config.api.baseURL);
console.log(API_BASE_URL);

模块路径和解析

1. 相对路径

// 当前目录
import { func } from './module.js';

// 父目录
import { func } from '../module.js';

// 子目录
import { func } from './utils/module.js';

2. 绝对路径(需要服务器配置)

// 需要配置服务器路径映射
import { func } from '/src/module.js';

3. URL 路径

// 可以从 CDN 导入
import { createApp } from 'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.js';

4. 文件扩展名

// ✅ 推荐:使用扩展名
import { func } from './module.js';

// ⚠️ 可能有问题:无扩展名
import { func } from './module';

5. Import Maps(实验性)

<!-- 使用 Import Maps 映射模块路径 -->
<script type="importmap">
{
  "imports": {
    "vue": "https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.js",
    "lodash": "https://cdn.jsdelivr.net/npm/lodash-es@4/lodash.js"
  }
}
</script>

<script type="module">
  import { createApp } from 'vue';
  import { debounce } from 'lodash';
</script>

动态导入

1. 基础动态导入

// 动态导入返回 Promise
const module = await import('./module.js');
const { func } = module;

func();

2. 条件导入

// 根据条件动态加载模块
async function loadModule(condition) {
  if (condition) {
    const module = await import('./moduleA.js');
    return module;
  } else {
    const module = await import('./moduleB.js');
    return module;
  }
}

3. 懒加载组件

// 路由懒加载
async function loadRoute(route) {
  switch (route) {
    case '/home':
      const Home = await import('./pages/Home.js');
      return new Home();
    case '/about':
      const About = await import('./pages/About.js');
      return new About();
    default:
      const NotFound = await import('./pages/NotFound.js');
      return new NotFound();
  }
}

4. 按需加载

// 点击按钮时加载模块
document.getElementById('loadBtn').addEventListener('click', async () => {
  const module = await import('./heavy-module.js');
  module.init();
});

5. 错误处理

try {
  const module = await import('./module.js');
  module.func();
} catch (error) {
  console.error('加载模块失败:', error);
  // 降级处理
}

与构建工具的区别

原生 ESM vs 构建工具

特性原生 ESM构建工具(Webpack/Vite)
开发速度⚡ 极快(无需构建)🐌 需要构建
浏览器支持现代浏览器所有浏览器
代码分割✅ 原生支持✅ 需要配置
Tree Shaking✅ 原生支持✅ 需要配置
TypeScript❌ 不支持✅ 支持
JSX❌ 不支持✅ 支持
CSS 模块❌ 不支持✅ 支持
生产优化❌ 无优化✅ 压缩、优化

使用场景

// ✅ 适合原生 ESM:
// - 简单项目
// - 原型开发
// - 学习演示
// - 现代浏览器项目

// ✅ 适合构建工具:
// - 复杂项目
// - 需要 TypeScript/JSX
// - 需要兼容旧浏览器
// - 需要生产优化

最佳实践

1. 使用文件扩展名

// ✅ 推荐
import { func } from './module.js';

// ❌ 不推荐
import { func } from './module';

2. 组织模块结构

project/
├── index.html
├── app.js
├── modules/
│   ├── utils.js
│   ├── api.js
│   └── components.js
└── components/
    ├── Button.js
    └── Card.js

3. 使用默认导出

// 单个主要功能使用默认导出
export default class Calculator {
  // ...
}

// 多个相关功能使用命名导出
export function add() {}
export function subtract() {}

4. 错误处理

// 使用 try-catch 处理导入错误
try {
  const module = await import('./module.js');
} catch (error) {
  console.error('模块加载失败:', error);
}

5. 提供降级方案

<!-- 为不支持 ESM 的浏览器提供降级 -->
<script type="module" src="app.js"></script>
<script nomodule src="app-legacy.js"></script>

6. 使用 Import Maps

<!-- 统一管理依赖路径 -->
<script type="importmap">
{
  "imports": {
    "vue": "https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.js"
  }
}
</script>

7. 代码分割

// 使用动态导入实现代码分割
const module = await import('./heavy-module.js');

8. 性能优化

<!-- 预加载关键模块 -->
<link rel="modulepreload" href="./critical-module.js">

<!-- 预获取模块 -->
<link rel="prefetch" href="./lazy-module.js">

modulepreloadprefetch 的区别

rel="modulepreload"(模块预加载)

  • 立即执行:浏览器会立即下载并解析模块及其依赖关系
  • 高优先级:会优先处理,可能阻塞其他资源加载
  • 解析依赖:会递归解析并预加载模块的所有依赖
  • 🎯 适用场景:当前页面必须使用的关键模块,需要尽快加载以提升首屏性能
  • 📝 示例:主应用入口模块、关键业务逻辑模块

rel="prefetch"(资源预获取)

  • ⏱️ 延迟执行:浏览器在空闲时间才下载资源
  • ⏱️ 低优先级:不会阻塞当前页面的关键资源加载
  • ⚠️ 不解析依赖:只下载文件本身,不会解析模块依赖
  • 🎯 适用场景可能在未来使用的资源(如下一页可能用到的模块、懒加载组件)
  • 📝 示例:路由懒加载模块、按需加载的功能模块

使用建议

  • 关键路径上的模块 → 使用 modulepreload
  • 非关键、可能用到的模块 → 使用 prefetch

常见问题解决

问题 1:CORS 错误

# 错误信息
Access to script at 'file:///...' from origin 'null' has been blocked by CORS policy

# 原因:使用 file:// 协议打开 HTML 文件

# 解决方案:使用 HTTP 服务器
# 方式 1:使用 Python
python -m http.server 8000

# 方式 2:使用 Node.js
npx serve .

# 方式 3:使用 VS Code Live Server 扩展

问题 2:模块路径错误

// 错误:找不到模块
import { func } from './module';

// 解决:使用完整路径和扩展名
import { func } from './module.js';

问题 3:循环依赖

// moduleA.js
import { funcB } from './moduleB.js';
export function funcA() {
  funcB();
}

// moduleB.js
import { funcA } from './moduleA.js';
export function funcB() {
  funcA();
}

// 解决:重构代码,避免循环依赖
// 或者使用动态导入

问题 4:不支持 ESM 的浏览器

<!-- 解决方案:提供降级版本 -->
<script type="module" src="app.js"></script>
<script nomodule src="app-legacy.js"></script>

文件命名说明

  • 无特殊要求:文件名可以是任何合法的文件名,app.jsapp-legacy.js 只是示例
  • 命名建议
    • 现代版本:app.jsmain.jsindex.js
    • 降级版本:app-legacy.jsapp.bundle.jsapp.polyfill.js 等(通常包含打包后的代码和 polyfill)
  • 📝 工作原理
    • 支持 ESM 的浏览器:执行 type="module" 的脚本,忽略 nomodule 的脚本
    • 不支持 ESM 的浏览器:忽略 type="module" 的脚本,执行 nomodule 的脚本
  • ⚠️ 注意事项
    • nomodule 脚本通常需要是打包后的版本(包含所有依赖)
    • 可以使用 Babel、Webpack 等工具将 ESM 代码转换为兼容旧浏览器的版本

问题 5:第三方库不支持 ESM

// 问题:某些库只提供 CommonJS 版本

// 解决方案 1:使用 CDN 的 ESM 版本
import Vue from 'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.js';

// 解决方案 2:使用构建工具转换
// 或者寻找 ESM 替代品

问题 6:相对路径混乱

// 问题:深层嵌套时路径复杂
import { func } from '../../../../utils/module.js';

// 解决:使用 Import Maps
<script type="importmap">
{
  "imports": {
    "@utils/": "/src/utils/"
  }
}
</script>

<script type="module">
  import { func } from '@utils/module.js';
</script>

配置多个路径映射

  • 支持多个路径别名:可以在 imports 对象中配置多个路径映射
  • 📝 示例
<script type="importmap">
{
  "imports": {
    "@utils/": "/src/utils/",
    "@components/": "/src/components/",
    "@api/": "/src/api/",
    "@config/": "/src/config/",
    "vue": "https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.js"
  }
}
</script>

<script type="module">
  // 使用多个路径别名
  import { formatDate } from '@utils/date.js';
  import Button from '@components/Button.js';
  import { getUser } from '@api/users.js';
  import config from '@config/index.js';
  import { createApp } from 'vue';
</script>

路径映射规则

  • 目录映射:使用 @alias/ 格式,末尾的 / 表示目录
  • 文件映射:直接使用别名,如 "vue": "https://..." 映射到具体文件
  • 相对路径:也可以映射到相对路径,如 "@utils/": "./src/utils/"

实际案例

案例 1:Todo 应用

<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
  <title>Todo App</title>
</head>
<body>
  <div id="app"></div>
  <script type="module" src="app.js"></script>
</body>
</html>
// app.js
import TodoList from './components/TodoList.js';
import TodoItem from './components/TodoItem.js';

const app = document.getElementById('app');
const todoList = new TodoList();

app.appendChild(todoList.render());
// components/TodoList.js
import TodoItem from './TodoItem.js';

export default class TodoList {
  constructor() {
    this.todos = [];
  }
  
  addTodo(text) {
    this.todos.push({ id: Date.now(), text, completed: false });
    this.render();
  }
  
  render() {
    const list = document.createElement('ul');
    this.todos.forEach(todo => {
      const item = new TodoItem(todo);
      list.appendChild(item.render());
    });
    return list;
  }
}
// components/TodoItem.js
export default class TodoItem {
  constructor(todo) {
    this.todo = todo;
  }
  
  render() {
    const li = document.createElement('li');
    li.textContent = this.todo.text;
    return li;
  }
}

案例 2:使用 CDN 模块

<!DOCTYPE html>
<html>
<head>
  <title>Vue App</title>
</head>
<body>
  <div id="app"></div>
  <script type="module">
    import { createApp } from 'https://cdn.jsdelivr.net/npm/vue@3/dist/vue.esm-browser.js';
    
    createApp({
      data() {
        return {
          message: 'Hello Vue!'
        }
      }
    }).mount('#app');
  </script>
</body>
</html>

案例 3:动态路由

// router.js
export default class Router {
  constructor() {
    this.routes = {};
  }
  
  addRoute(path, modulePath) {
    this.routes[path] = modulePath;
  }
  
  async navigate(path) {
    const modulePath = this.routes[path];
    if (modulePath) {
      const module = await import(modulePath);
      return module.default;
    }
    return null;
  }
}
// app.js
import Router from './router.js';

const router = new Router();
router.addRoute('/', './pages/Home.js');
router.addRoute('/about', './pages/About.js');

async function init() {
  const path = window.location.pathname;
  const Page = await router.navigate(path);
  if (Page) {
    const page = new Page();
    document.body.appendChild(page.render());
  }
}

init();

总结

核心要点

  1. 基础使用

    <script type="module" src="app.js"></script>
    
  2. 导出语法

    export function func() {}
    export default class MyClass {}
    
  3. 导入语法

    import { func } from './module.js';
    import MyClass from './MyClass.js';
    
  4. 动态导入

    const module = await import('./module.js');
    
  5. 浏览器支持

    • Chrome 61+
    • Firefox 60+
    • Safari 10.1+
    • Edge 16+

优势

  • ✅ 无需构建工具
  • ✅ 原生支持
  • ✅ 按需加载
  • ✅ 开发速度快

限制

  • ❌ 需要现代浏览器
  • ❌ 不支持 TypeScript/JSX
  • ❌ 需要 HTTP 服务器(不能 file://)
  • ❌ 无生产优化

适用场景

  • ✅ 简单项目
  • ✅ 原型开发
  • ✅ 学习演示
  • ✅ 现代浏览器项目

相关资源