ES6 模块化
背景介绍
ES Module 是 ECMAScript 2015(ES6)引入的官方标准化模块系统。在 ES6 之前,JavaScript 没有内置的模块化机制,开发者主要依赖 CommonJS、AMD 等社区解决方案。ES Module 的引入解决了 JavaScript 长期以来模块化的缺失问题,为大型应用开发提供了更好的代码组织和管理方式。
ES Module 基础
1. 导出(export)
// 命名导出
export const name = 'John'
export function sayHello() {
console.log('Hello!')
}
// 批量导出
const age = 30
const gender = 'male'
export { age, gender }
// 重命名导出
export { age as userAge, gender as userGender }
// 默认导出
export default function () {
console.log('default export')
}
2. 导入(import)
// 导入命名导出
import { name, sayHello } from './module'
// 重命名导入
import { name as userName } from './module'
// 导入所有内容
import * as moduleA from './module'
// 导入默认导出
import defaultExport from './module'
// 混合导入
import defaultExport, { name, age } from './module'
与 CommonJS 的区别
1. 语法对比
// CommonJS
const module = require('./module')
module.exports = {
/* ... */
}
// ES Module
import module from './module'
export /* ... */ {}
2. 加载机制
// CommonJS - 动态加载
if (condition) {
const module = require('./module') // 运行时加载
}
// ES Module - 静态加载
import module from './module' // 编译时加载
// 不能在条件语句中使用import
3. 值的处理
// CommonJS - 值的拷贝
const counter = require('./counter')
counter.count++ // 不会影响原模块
// ES Module - 值的引用
import { counter } from './counter'
counter.count++ // 会影响原模块
Tree Shaking 与静态分析
Tree Shaking 是一种通过消除未使用的代码来优化打包结果的技术,它在现代前端构建工具(如 Webpack、Rollup)中被广泛应用。ES Module 能够支持 Tree Shaking 而 CommonJS 不能,这主要源于两者的设计理念差异。
1. 静态结构 vs 动态结构
// ES Module - 静态导入导出
export function foo() {}
export function bar() {}
import { foo } from './module'
// CommonJS - 动态导入导出
exports.foo = function () {}
exports.bar = function () {}
const module = require('./module')
const { foo } = module
2. 为什么 ES Module 支持 Tree Shaking?
ES Module 具有以下特点使其支持 Tree Shaking:
- 静态导入导出:导入导出语句只能在模块顶层使用,不能在条件语句或函数中动态导入
- 编译时确定依赖:在代码执行前,模块的依赖关系就已经确定
- 只读引用:不能在运行时修改导入的模块
- 具名导入:精确指定导入的内容,如
import { foo } from './module'
这些特性使得构建工具可以静态分析出哪些导出被使用,哪些未被使用,从而可以安全地移除未使用的代码。
3. 为什么 CommonJS 不支持 Tree Shaking?
CommonJS 在设计上具有动态特性,不适合 Tree Shaking:
- 运行时加载:只有在执行到 require 语句时才加载模块
- 动态模块路径:可以使用变量或表达式确定模块路径,如
require(path + '/module')
- 动态属性访问:可以使用变量访问模块属性,如
module[funcName]()
- 可变导出:可以在任何时候修改 module.exports 对象
这些动态特性使得静态分析变得困难,构建工具无法准确判断哪些代码会被使用,哪些不会。
4. 实例对比
// ES Module - 可以Tree Shaking
// math.js
export function add(a, b) {
return a + b
}
export function subtract(a, b) {
return a - b
}
// app.js
import { add } from './math.js'
console.log(add(1, 2))
// subtract函数会被Tree Shaking删除
// CommonJS - 不能Tree Shaking
// math.js
exports.add = function (a, b) {
return a + b
}
exports.subtract = function (a, b) {
return a - b
}
// app.js
const math = require('./math.js')
console.log(math.add(1, 2))
// subtract函数仍会被打包进来
5. 优化 CommonJS 模块
虽然纯 CommonJS 模块不支持 Tree Shaking,但有一些方法可以优化:
- 使用 ES Module 语法:将代码库迁移到 ES Module
- 使用包装工具:一些工具可以将 CommonJS 模块转换为支持 Tree Shaking 的版本
- 手动优化导出:将大型模块拆分为多个小模块
- 使用 sideEffects 属性:在 package.json 中标记纯函数模块
// package.json
{
"name": "my-lib",
"sideEffects": false, // 或者指定有副作用的文件 ["./src/side-effects.js"]
}
动态导入
// 动态导入
async function loadModule() {
if (condition) {
const module = await import('./module.js')
module.doSomething()
}
}
// 条件导入
const moduleSpecifier = condition ? './module-a.js' : './module-b.js'
import(moduleSpecifier)
.then(module => module.doSomething())
.catch(err => console.error(err))
循环依赖处理
1. CommonJS 循环依赖
// a.js
const b = require('./b')
console.log('a.js')
exports.done = true
// b.js
const a = require('./a')
console.log('b.js')
console.log(a.done) // undefined
exports.done = true
2. ES Module 循环依赖
// a.mjs
import { b } from './b.mjs'
export let a = 1
console.log(b) // 2
// b.mjs
import { a } from './a.mjs'
export let b = 2
console.log(a) // 1
实际应用场景
1. 项目结构组织
// index.js - 聚合导出模式
export { default as Button } from './components/Button'
export { default as Input } from './components/Input'
export { default as Form } from './components/Form'
// 使用 - 一处导入多个组件
import { Button, Input, Form } from './components'
2. 按需加载
// 路由懒加载 - 提高初始加载性能
const Home = () => import('./views/Home.vue')
const About = () => import('./views/About.vue')
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
]
3. 库的封装
// 库的入口文件 - 方便使用者按需导入
export { default as util1 } from './utils/util1'
export { default as util2 } from './utils/util2'
// 使用方可以选择性导入
import { util1 } from 'your-library'
最佳实践
导出建议
- 优先使用命名导出,便于静态分析和 tree-shaking
- 一个文件只使用一个默认导出,避免混淆
- 导出时使用明确的命名,提高代码可读性
导入建议
- 使用明确的导入路径,避免省略文件扩展名
- 避免过度使用命名空间导入(
import * as
),不利于 tree-shaking - 合理使用动态导入,提高应用性能
模块设计
- 保持模块的单一职责,一个模块只做一件事
- 避免复杂的循环依赖,拆分逻辑减少耦合
- 合理拆分模块大小,既不过大也不过小
面试常见问题与参考答案
1. ES Module 和 CommonJS 的主要区别是什么?
参考答案:
ES Module 和 CommonJS 的主要区别在于:
- 语法:ES Module 使用
import/export
语法,CommonJS 使用require/module.exports
- 加载时机:ES Module 是静态加载(编译时),CommonJS 是动态加载(运行时)
- 值的处理:ES Module 导出的是值的引用,CommonJS 导出的是值的拷贝
- 异步性:ES Module 支持异步导入(
import()
),CommonJS 的require
是同步的 - 顶层作用域:ES Module 的顶层
this
是undefined
,CommonJS 的顶层this
指向module.exports
- 循环依赖:ES Module 处理循环依赖的能力更强,可以获取到尚未执行完的模块导出值
2. 如何处理模块的循环依赖?
参考答案:
处理模块循环依赖的最佳实践:
- 重构代码结构:最好的解决方案是通过重构消除循环依赖
- 使用 ES Module:ES Module 比 CommonJS 更好地处理循环依赖
- 提取共享逻辑:将共享的逻辑抽取到第三个模块中
- 使用依赖注入:通过参数传递依赖而不是直接导入
- 延迟加载:在 CommonJS 中,可以将 require 放在函数内部延迟执行
- 使用事件系统:通过发布订阅模式解耦模块间的直接依赖
3. import 和 require 的区别是什么?
参考答案:
import
和 require
的区别:
- 语法:
import
是 ES Module 语法,require
是 CommonJS 语法 - 加载时机:
import
是静态加载(编译时分析),require
是动态加载(运行时执行) - 条件使用:
import
语句不能在条件语句中使用,而require
可以 - 异步性:标准
import
是同步的,但支持import()
动态异步导入;require
始终是同步的 - 缓存:两者都有模块缓存,但实现机制不同
- this 指向:
import
的模块中顶层this
是undefined
,require
的模块中顶层this
指向module.exports
4. 什么是"死区"?如何避免?
参考答案:
在 ES Module 中,“死区”(Temporal Dead Zone,TDZ)是指从模块执行开始到变量声明被执行的这段区域,在此区域内引用变量会导致错误。
例如:
// moduleA.js
import { value } from './moduleB.js'
console.log(value) // 可能在死区内访问
// moduleB.js
import { something } from './moduleA.js'
export let value = 'hello'
避免方法:
- 避免模块间的循环依赖
- 使用默认导出而非命名导出
- 将导出的变量声明提前到模块顶部
- 使用函数封装,延迟访问变量
- 重构代码结构,消除复杂依赖关系
5. 动态导入的应用场景有哪些?
参考答案:
动态导入(import()
)的应用场景:
- 按需加载:根据用户交互或条件加载模块,减少初始加载体积
- 路由懒加载:SPA 应用中,只加载当前路由需要的组件
- 代码分割:将应用分割成多个小块,提高加载性能
- 条件加载模块:根据环境或配置加载不同的模块实现
- A/B 测试:动态加载不同的特性或实现
- 微前端架构:按需加载不同的微应用
- 大型库的部分加载:只加载大型库中实际需要的部分功能
6. 如何实现模块的按需加载?
参考答案:
实现模块按需加载的方法:
- 使用动态导入:
import()
函数返回 Promise,可以在需要时异步加载模块 - 结合 async/await:使用
async/await
简化动态导入的处理 - Webpack 代码分割:使用
import()
触发 Webpack 自动代码分割 - 路由懒加载:在路由配置中使用动态导入
- 状态管理懒加载:在 Redux/Vuex 中动态加载模块
- 组件级懒加载:使用 React.lazy 或 Vue 异步组件
- 设置加载指示器:在模块加载期间显示加载状态
例子:
// 按需加载
button.addEventListener('click', async () => {
const { default: module } = await import('./heavyModule.js')
module.doSomething()
})
7. ES Module 的优势是什么?
参考答案:
ES Module 的优势:
- 标准化:是 JavaScript 官方标准,未来兼容性有保证
- 静态分析:编译时分析依赖,支持 tree-shaking 优化
- 异步加载:支持动态导入,提高应用性能
- 值引用:导出的是值的引用,便于模块间状态共享
- 不可变导出:不能在导入后修改导出值,提高代码安全性
- 更好的循环依赖处理:相比 CommonJS 能更好地处理模块间循环依赖
- 作用域隔离:每个模块有自己的作用域,不会污染全局环境
- 更好的 IDE 支持:静态结构使得 IDE 能提供更好的自动补全和类型检查