第三十九天:ES Module 模块化 — 从单文件到工程
蜗牛往上爬 · 前端工业可视化学习笔记 第 39 篇
单文件项目写到 300 行就开始失控:全局变量满天飞、依赖靠人肉排序。ES Module 用 import/export 让每个文件成为独立模块——显式声明依赖、私有作用域、按需复用。今天学完,把设备管理系统拆成专业工程。
目录
一、为什么需要模块化
单文件的三大痛点:
<!-- 依赖顺序靠人肉维护,写错就崩 -->
<script src="device.js"></script>
<script src="manager.js"></script>
<script src="app.js"></script>
全局污染:所有变量共享全局作用域,命名冲突
顺序脆弱:依赖关系靠加载顺序,改一次崩一次
无法控制暴露:变量要么全公开要么全不可用
ESM 的解法:文件即模块,内部私有,import/export 显式声明依赖。
二、命名导出与默认导出
// ===== 命名导出:一个文件可导出多个 =====
// utils.js
export const API_URL = "https://api.example.com";
export const MAX_TEMP = 80;
export function formatTemp(temp) {
return `${temp.toFixed(1)}°C`;
}
// ===== 默认导出:一个文件只能有一个 =====
// Device.js
export default class Device {
constructor(id, name) {
this.id = id;
this.name = name;
}
}
三、导入的多种方式
// 命名导入(名字必须一致)
import { API_URL, formatTemp } from "./utils.js";
// 默认导入(名字随便起)
import Device from "./Device.js";
// 混合导入
import Device, { formatTemp, MAX_TEMP } from "./device-module.js";
// 重命名
import { formatTemp as fmt } from "./utils.js";
// 命名空间(一次全拿)
import * as utils from "./utils.js";
utils.formatTemp(65);
// 动态导入(返回 Promise,按需加载)
const { default: Device } = await import("./Device.js");
四、模块的四条规则
// 1. 模块内变量私有(不污染全局)
// counter.js
let count = 0; // 外部无法访问
export const increment = () => ++count;
// 2. 导入是只读视图
import { config } from "./config.js";
// config = {}; // ❌ 不能重新赋值
config.theme = "dark"; // ✅ 可以改属性
// 3. 模块是单例(导入多次只执行一次)
// 第一次 import 执行代码,之后共享同一份导出
// 4. 自动严格模式
// this 在模块顶层是 undefined
五、浏览器中使用
<!-- ⭐ 必须声明 type="module" -->
<script type="module" src="js/app.js"></script>
注意:
路径写清楚(
./utils.js,带后缀)本地开发建议用 Live Server 起服务(file:// 协议会有跨域限制)
模块自动 defer(DOM 加载完后执行)
六、综合实战:模块化设备管理系统
项目结构(分层架构):
device-manager/
├── index.html
└── js/
├── app.js # 入口:事件绑定
├── classes/Device.js # 领域模型
├── services/store.js # 数据与业务
├── utils/format.js # 工具函数
└── views/render.js # 渲染层
// js/classes/Device.js
export default class Device {
constructor(id, name, temp = 0) {
this.id = id;
this.name = name;
this.temp = temp;
}
get level() {
if (this.temp >= 80) return "危险";
if (this.temp >= 60) return "偏高";
return "正常";
}
}
// js/services/store.js —— ⭐ 模块级私有数据
import Device from "../classes/Device.js";
let devices = []; // 外部无法直接改!
let nextId = 1;
export function addDevice(name, temp) {
const device = new Device(`DEV-${String(nextId).padStart(3, "0")}`, name, temp);
devices.push(device);
nextId++;
return device;
}
export function removeDevice(id) {
devices = devices.filter(d => d.id !== id);
}
export function searchDevices(keyword = "") {
if (!keyword) return [...devices];
return devices.filter(d => d.name.includes(keyword));
}
// js/views/render.js —— 纯展示
import { formatTemp } from "../utils/format.js";
import { removeDevice } from "../services/store.js";
export function renderDeviceList(devices, container) {
container.innerHTML = devices
.map(d => `
<li class="device-item">
<strong>${d.id}</strong> ${d.name} <span>[${d.level}]</span>
<span>${formatTemp(d.temp)}</span>
<button data-id="${d.id}">删除</button>
</li>
`)
.join("");
container.querySelectorAll("button[data-id]").forEach(btn => {
btn.addEventListener("click", () => removeDevice(btn.dataset.id));
});
}
// js/app.js —— 入口
import { addDevice, searchDevices } from "./services/store.js";
import { renderDeviceList } from "./views/render.js";
const list = document.getElementById("deviceList");
function refresh() {
renderDeviceList(searchDevices(
document.getElementById("searchInput").value.trim()
), list);
}
document.getElementById("addBtn").addEventListener("click", () => {
addDevice(
document.getElementById("nameInput").value.trim(),
Number(document.getElementById("tempInput").value)
);
refresh();
});
document.getElementById("searchInput").addEventListener("input", refresh);
// 预置数据
addDevice("数控机床", 65);
addDevice("搬运机器人", 42);
refresh();
模块化收益对比:
分层:app.js(事件) → views/(渲染) → services/(业务) → classes/(模型) → utils/(工具)——这个思想就是阶段2 框架工程的前身。
七、总结
🎯 Class + 模块化打通了工程化地基。下周阶段1 收官:综合实战把 10 周所学融成一个完整项目!