Skip to content

模块与异步编程

一个文件写到底,过两周自己也找不到东西在哪。模块系统的核心作用是:把代码按职责拆成文件,用 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 最早的模块系统,用 requiremodule.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/awaitthen/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.jsexecFile 检查服务状态,返回 { service, active } 对象。index.js 组合它们——处理参数、决定输出格式(JSON 还是 TSV)、返回退出码。三层依赖方向是单向的:CLI 层依赖业务层,业务层不反过来依赖 CLI 层。