Appearance
模块与异步编程
一个文件写到底,过两周自己也找不到东西在哪。模块系统的核心作用是:把代码按职责拆成文件,用 import/export 组织依赖关系。异步编程处理的是文件读写、网络请求、命令调用这类操作——不能让主线程干等着。
为什么拆模块
拆模块不是为了"架构好看",而是为了定位和复用。配置读取混在业务逻辑里,换一个 CLI 入口或想复用检查函数时,得从几百行文件里手动挑代码。
最小拆分原则:三类代码各自独立。
text
src/
config.js 读配置、校验配置
check-service.js 执行检查(核心业务逻辑)
index.js 处理 CLI 参数、打印结果、退出码核心业务函数不写 console.log、不读 process.argv、不直接 process.exit()。它拿到输入、返回数据——输出格式由 CLI 层或 Web 层决定。这个约束不是教条,而是当同一天写 CLI 和 HTTP API 两层入口时,不遵守它就要写两份业务逻辑。
CommonJS:老项目在用
CommonJS 是 Node.js 最早的模块系统,用 require 和 module.exports:
js
// check.cjs
function isActive(status) {
return status === "active";
}
module.exports = { isActive };js
// index.cjs
const { isActive } = require("./check.cjs");
console.log(isActive("active"));.cjs 扩展名显式声明这是 CommonJS 文件——在 "type": "module" 的项目里也能被 require。
CommonJS 的几个特点:模块加载同步执行(适合服务端启动时一次加载)、require 可以放在条件语句里(动态加载)、module.exports 暴露的是对象引用。一个项目里混着 CommonJS 和 ESM 会很痛苦——__dirname 不可用、默认导出行为不同、测试工具和打包配置跟着复杂。
ESM:新项目默认
新项目在 package.json 里声明 "type": "module",整个项目按 ESM 处理。
命名导出(最常用):
js
// math.js
export function add(a, b) {
return a + b;
}
export function sub(a, b) {
return a - b;
}js
// index.js
import { add, sub } from "./math.js";
// 本地文件导入必须写扩展名 .js——这是 ESM 规范要求
console.log(add(1, 2));默认导出:
js
// logger.js
export default function log(msg) {
console.log(`[app] ${msg}`);
}js
// index.js
import log from "./logger.js";
// 不加大括号的是默认导入——名字自己取,不一定要和导出时同名
log("started");命名导出的好处是重命名和跳转定义更直观——编辑器和 IDE 能精确定位导出来源。默认导出再混合命名导出的文件,import x from ... 看到时不知道 x 是什么。
ESM 要求本地文件导入写 .js 扩展名:
js
// 正确
import { loadConfig } from "./src/config.js";
// 错误——缺少扩展名
import { loadConfig } from "./src/config";这是 ESM 和 CommonJS 最显著的书写差异。TypeScript 项目中习惯不写扩展名,转成纯 JS 后很容易忘加。
同步和异步的本质
JavaScript 的主线程只有一个。同步代码执行时,整个进程卡在这行上什么都不能做:
js
import fs from "node:fs";
// 读一个大文件——整个进程阻塞,直到读完返回
const text = fs.readFileSync("huge-config.json", "utf8");
console.log(text);HTTP 服务里在请求处理中同步读文件——所有其他请求都在排队等这一行完成。短脚本一次性的可以接受,HTTP 服务和长期运行的进程不行。
异步操作把"发起请求"和"拿到结果"拆成两步——发起请求后主线程继续处理其他事,结果到了再通过 Promise/回调唤醒:
js
import { readFile } from "node:fs/promises";
// 发起异步读取,主线程不会卡在这里
const text = await readFile("config.json", "utf8");
// await 把异步操作"暂停",等结果回来再往下执行
console.log(text);await 让异步代码读起来像同步代码。但只有标记了 async 的函数里才能用 await。
Promise:异步操作的结果容器
Promise 有三种状态:pending(初始,结果还没回来)、fulfilled(成功,有了值)、rejected(失败,有了错误)。状态只能从 pending 变一次——要么成功要么失败,不会来回变。
js
import { readFile } from "node:fs/promises";
readFile("config.json", "utf8")
.then((text) => {
console.log(text); // 成功分支
})
.catch((err) => {
console.error(err.message); // 失败分支
});现在日常代码更多用 async/await,then/catch 链式写法在需要"同时发起多个异步操作"这类场景还有用。
一个常见的认识偏差:async 函数不意味着里面的代码立刻异步执行。await 之前的代码还是同步跑的;碰到第一个 await 时函数才"暂停",把控制权交回事件循环。
async/await 完整错误处理
js
import { readFile } from "node:fs/promises";
async function loadConfig(file) {
try {
const text = await readFile(file, "utf8");
return JSON.parse(text); // JSON.parse 可能抛出 SyntaxError
} catch (err) {
// 补上文件名——排查多个配置文件时能看到是哪个出错了
throw new Error(`failed to load ${file}: ${err.message}`);
}
}
async function main() {
const config = await loadConfig("config.json");
console.log(config);
}
main().catch((err) => {
console.error(err.message);
process.exit(1);
});三个层次:loadConfig 内部 try/catch 包住可能失败的每一步并补上下文信息;调用方可以继续 try/catch 决定怎么处理;入口处 .catch 兜底,保证任何没被上层捕获的错误最终有一条输出和一个非零退出码。
运维脚本的错误处理不能"吞掉异常"——不要写空的 catch 块。巡检脚本中间检查失败但外层看起来跑完了,后面判断会误以为一切正常。
js
// 反例——异常悄悄没了
try {
await riskyOperation();
} catch {}
// 看起来跑完了,实际什么都没发生串行和并发的选择
串行——当前操作等上一个完成再开始:
js
for (const service of ["nginx", "sshd", "crond"]) {
await checkService(service); // 一个一个查,顺序稳定
}并发——互不依赖的操作同时发起:
js
const services = ["nginx", "sshd", "crond"];
const results = await Promise.all(
services.map((s) => checkService(s)) // 同时发起所有检查
);Promise.all 的特点:所有 Promise 都成功才返回结果数组;任意一个失败,整体立刻失败。巡检场景里一个服务挂了不应该拖累其他服务的检查结果,适合给每个检查包装一层安全兜底:
js
async function safeCheck(service) {
try {
return await checkService(service);
} catch (err) {
return { service, ok: false, error: err.message };
// 失败不抛异常,返回包含错误信息的结果对象
}
}
const results = await Promise.all(
services.map(safeCheck)
);
// 每个服务独立返回结果——某台挂了不影响其他还有其他并发模式:
Promise.allSettled:等所有 Promise 都结束(不管成功失败),返回每个的结果和状态Promise.race:哪个先完成返回哪个——常用来做超时控制Promise.any:有一个成功就返回——多副本并发取最快的场景
超时控制
网络请求、命令执行、连接测试都必须设超时——没有超时的异步操作可能永远停在那里:
js
function withTimeout(promise, ms) {
const timer = new Promise((_, reject) => {
setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms);
});
return Promise.race([promise, timer]);
// race: 哪个先完成返回哪个——超时先到就 reject
}
try {
await withTimeout(checkService("nginx"), 3000);
} catch (err) {
console.error(err.message);
}这只能控制"等多久",不能真正取消底层操作。想实际取消 HTTP 请求,需要用 AbortController;想杀掉超时的子进程,要调 child.kill()。超时只是 upper bound,不是资源释放机制。
模块结构示例
一个服务检查小工具:
text
service-check/
src/
config.js export loadConfig / validate
check-service.js export checkService (async 函数)
index.js import 上面两个,处理 argv,打印结果src/config.js 读文件、解析 JSON、校验字段。src/check-service.js 调 execFile 检查服务状态,返回 { service, active } 对象。index.js 组合它们——处理参数、决定输出格式(JSON 还是 TSV)、返回退出码。三层依赖方向是单向的:CLI 层依赖业务层,业务层不反过来依赖 CLI 层。