阅读视图

发现新文章,点击刷新页面。

Node.js 进程是单线程,就可以放心追加日志吗?

在开发 Node.js 服务或 CLI 工具时,日志系统是我们的“眼睛”。很多同学认为: “既然 Node.js 是单线程的,那我用 fs.appendFile 往文件里写日志,肯定不会乱序或者冲突,对吧?”

答案是:对了一半,但忽略了操作系统层面的真相。

今天我们就从单线程逻辑、多进程竞争、原子性写入三个维度,深度拆解 Node.js 日志追加的“正确姿势”。


一、 逻辑上的“绝对安全”:单线程与事件循环

从 Node.js 进程内部看,你的直觉是对的。

由于 Node.js 的 Event Loop(事件循环)  机制,虽然底层的 I/O 是异步非阻塞的,但你在 JavaScript 层发起的日志写入请求会按顺序排队。

javascript

// 即使你连续调用两次,JS 引擎也会保证它们的入队顺序
fs.appendFile(logPath, 'Log Line 1\n', () => {});
fs.appendFile(logPath, 'Log Line 2\n', () => {});

请谨慎使用此类代码。

单进程环境下,你永远不需要担心“第一行日志写到一半,第二行就插进来”这种交错现象。因为在同一时刻,只有一个 V8 实例在处理你的写入逻辑。


二、 物理上的“暗箭难防”:多进程并发竞争

然而,现代应用往往是多进程的(如使用 PM2 开启 Cluster 模式,或手动 fork 子进程)。

当多个进程同时向同一个文件执行 append 时,灾难就可能发生了:

  1. 覆盖风险:如果两个进程同时读取文件末尾指针并写入,后写的可能会覆盖先写的。
  2. 内容交错:在高并发下,进程 A 的数据和进程 B 的数据可能在物理层面上被操作系统“混”在一起,导致日志文件无法解析。

🚀 资深架构的解决方案:一进程一文件(隔离思想)

在我们的讨论中,一种非常高明的方案是:避开竞争,直接物理隔离。

  • 唯一命名:每个进程启动时,利用 crypto.randomUUID() 和时间戳生成唯一的日志文件名(如 20260118-uuid.log)。
  • 父子关联:子进程在日志头部记录父进程的日志 ID。

这种设计通过  “无锁化”  规避了复杂的底层锁竞争,性能最高,且天然支持跨机器的日志归档。


三、 深度细节:日志写入的“原子性”

即使是单进程,还有一个隐蔽的坑:原子性(Atomicity)

POSIX 标准规定,如果写入的数据块超过了系统的 PIPE_BUF(通常是 4KB 或 8KB),系统可能会将其拆分为多次 I/O 操作。

  • 场景:如果你的一条日志由于记录了巨大的 JSON 堆栈而达到了 1MB。
  • 风险:虽然 JS 是单线程,但在操作系统底层,如果此时发生系统中断或内存压力,这 1MB 的数据可能是不连续写入的。

2026 年的最佳实践

为了保证绝对的健壮性,建议在 Node.js 中遵循以下原则:

  1. 放弃外部包,拥抱原生原生

不要再引用 uuid 或 dayjs 来处理基础的日志元数据了。2026 年,Node.js 原生 API 已经足够强大且性能更高:

javascript

import { randomUUID } from 'node:crypto'; // 高性能 UUID
const logTime = new Intl.DateTimeFormat('zh-CN', { ... }).format(new Date()); // 标准时间

请谨慎使用此类代码。

  1. 使用 fs.createWriteStream

对于高频日志,频繁打开/关闭文件句柄(appendFileSync)是有开销的。建议维护一个持久的可写流:

javascript

const logStream = fs.createWriteStream(logPath, { flags: 'a' }); // 'a' 代表追加

export const writeLog = (content) => {
  const line = `[${getLogTime()}] [PID:${process.pid}] ${content}\n`;
  logStream.write(line); // 这里的写入在单进程内是绝对顺序的
};

请谨慎使用此类代码。

  1. 跨进程配置同步

如果你在 Monorepo 或多进程环境中,利用 globalThis(内存单例)配合 process.env(进程快照)来同步日志路径等配置,是目前最稳健的架构设计。


四、 总结

Node.js 是单线程的,这确实为我们提供了逻辑上的追加安全;但真正健壮的日志系统,必须考虑到操作系统层面的进程隔离

核心结论:

  • 单进程场景:放心追加,只需关注单条日志不要过大(建议控制在 8KB 以内)。
  • 多进程场景:不要头铁去争夺同一个文件, “一进程一文件 + 唯一标识 + 逻辑关联”  才是通往资深工程师的进阶之路。

从 CLI 到 MCP:Node.js 工具包改造与 ESLint 规范实战

前言

随着 AI Agent 的流行,Model Context Protocol (MCP) 成为连接 LLM 与本地工具的桥梁。但在将现有的 Node.js CLI 工具改造为 MCP Server 的过程中,开发者往往会遇到两个极端问题:一是工具分发困难(npx 跑不动),二是日志输出干扰协议导致通信崩溃。

本文记录了我在改造 @xxx/cli 过程中的避坑指南,涵盖分发、规范约束及 MCP 场景适配。


一、 让你的 CLI 随时随地可运行

在 Monorepo 场景下,我们希望用户不创建项目也能通过 npx @xxx/cli 直接调用。如果发现调用失败,请检查以下三点:

  1. Bin 字段的玄学:在 package.json 中,确保 bin 的键名与包名后缀一致。例如包名是 @xxx/clibin 设置为 "cli": "index.js",这样 npx 的识别率最高。
  2. Shebang 必不可少:入口文件第一行必须是 #!/usr/bin/env node
  3. 无环境执行:若在非 npm 项目下运行,确保包已发布且非私有。若是私有包,请确保环境中有 NPM_TOKEN

二、 严格禁绝 Console:基于 AST 的 ESLint 进阶

在 MCP 模式下,标准输出 (STDOUT) 是神圣不可侵犯的。MCP 依靠 STDOUT 进行 JSON-RPC 通信,任何一行 console.log 都会导致协议解析失败。

  1. 为什么 no-console: error 还不够?

原生的 no-console 报错信息太死板。在团队协作中,我们希望报错的同时告诉成员:“请使用项目封装的 log 方法,它会自动处理 MCP 静默策略”。

  1. 实战:定制化 AST 校验规则

在 Monorepo 的 Node.js 项目中,由于插件冲突(如 Alloy 等),普通的规则常被覆盖。建议使用 overrides 并配合 no-restricted-syntax

javascript

// .eslintrc.js
module.exports = {
  overrides: [
    {
      files: ['packages/*/src/**/*.{ts,tsx,js,jsx}'],
      rules: {
        // 1. 关闭原生规则
        "no-console": "off",
        // 2. 利用 AST 选择器精准拦截
        "no-restricted-syntax": [
          "error",
          {
            // 匹配所有对 console 对象的成员访问(log, warn, error等)
            "selector": "MemberExpression[object.name='console']",
            "message": "❌ [MCP规范] 禁止直接调用 console。请使用 logger.log(),它在 MCP 模式下会自动静默,防止破坏通信协议。"
          }
        ]
      }
    }
  ]
}

请谨慎使用此类代码。

注:使用 MemberExpression 方案比 CallExpression 更稳定,能防止任何形式的 console 调用。


三、 MCP 模式下的日志与进程收拢

  1. 全局变量标识

在 CLI 入口处,根据启动参数或环境变量设置全局标识:

typescript

// cli.ts
if (process.argv.includes('--mcp')) {
  global.IS_MCP_MODE = true;
}

请谨慎使用此类代码。

  1. 自定义 Logger 的分流策略

typescript

export const logger = {
  info: (msg: string) => {
    // MCP 模式下必须禁绝 STDOUT 输出
    if (!global.IS_MCP_MODE) {
      console.log(msg);
    }
  },
  error: (msg: string) => {
    if (global.IS_MCP_MODE) {
      // MCP 场景下,报错应直接抛出,由顶层 Server 捕获并转为 JSON-RPC Error
      throw new Error(msg);
    } else {
      console.error(msg);
      process.exit(1);
    }
  }
};

请谨慎使用此类代码。

  1. 子进程 exec 的静默陷阱

在 CLI 中经常会调用 exec 或 spawn绝对不要在 MCP 模式下使用 stdio: 'inherit'

  • 错误示范execSync('npm install', { stdio: 'inherit' }) —— 这会把子进程的日志直接塞进 MCP 管道。

  • 正确示范

    typescript

    const stdioSetting = global.IS_MCP_MODE ? 'pipe' : 'inherit';
    const result = execSync('npm install', { stdio: ['ignore', stdioSetting, 'inherit'] });
    

    请谨慎使用此类代码。

    注意:stderr 设置为 inherit 通常是安全的,因为 MCP 客户端一般会忽略或单独记录 STDERR。


四、 总结

将 Node.js 工具适配 MCP 不仅仅是写一个 Server 接口,更是一场关于“输出管理”的修行:

  • 分发层:搞定 bin 和 npx
  • 规范层:用 ESLint AST 强制引导团队使用收拢的 Logger。
  • 执行层:对 console 和 child_process 进行严格的流重定向。

守住了 STDOUT,就守住了 MCP 的生命线。

2026 年 Node.js + TS 开发:别再纠结 nodemon 了,聊聊热编译的最优解

在开发 Node.js 服务端时,“修改代码 -> 自动生效”的开发体验(即热编译/热更新)是影响效率的关键。随着 Node.js 23+  原生支持 TS 以及 Vite 5 的普及,我们的工具链已经发生了巨大的更迭。

今天我们深度拆解三种主流的 Node.js TS 开发实现方式,帮你选出最适合 2026 年架构的方案。


一、 方案对比大盘点

方案 核心原理 优点 缺点 适用场景
tsx (Watch Mode) 基于 esbuild 的极速重启 零配置、性能强、生态位替代 nodemon 每次修改重启整个进程,状态丢失 小型服务、工具脚本
vite-node 基于 Vite 的模块加载器 完美继承 Vite 配置、支持模块级 HMR 配置相对复杂,需手动处理 HMR 逻辑 中大型 Vite 全栈项目
Node.js 原生 Node 23+ Type Stripping 无需第三方依赖,官方标准 需高版本 Node,功能相对单一 追求极简、前瞻性实验

二、 方案详解

  1. 现代替代者:tsx —— 告别 nodemon + ts-node

过去我们常用 nodemon --exec ts-node,但在 ESM 时代,这套组合经常报 ERR_UNKNOWN_FILE_EXTENSION 错误。

tsx 内部集成了 esbuild,它是目前 Node 18+ 环境下最稳健的方案。

  • 实现热编译:

    bash

    npx tsx --watch src/index.ts
    

    请谨慎使用此类代码。

  • 为什么选它:  它不需要额外的加载器配置(--loader),且 watch 模式非常智能,重启速度在毫秒级。

  1. 开发者体验天花板:vite-node —— 真正的 HMR

如果你已经在项目中使用 Vite 5,那么 vite-node 是不二之选。它不仅是“重启”,而是“热替换”。

  • 核心优势:

    • 共享配置:直接复用 vite.config.ts 中的 alias 和插件。
    • 按需编译:只编译当前运行到的模块,项目越大优势越明显。
  • 实现热更新(不重启进程):

    typescript

    // src/index.ts
    import { app } from './app';
    let server = app.listen(3000);
    
    if (import.meta.hot) {
      import.meta.hot.accept('./app', (newModule) => {
        server.close(); // 优雅关闭旧服务
        server = newModule.app.listen(3000); // 启动新逻辑,DB连接可复用
      });
    }
    

    请谨慎使用此类代码。

  1. 官方正统:Node.js 原生支持

如果你能使用 Node.js 23.6+ ,那么可以摆脱所有构建工具。

  • 运行:  node --watch src/index.ts
  • 点评:  这是未来的趋势,但在 2026 年,由于生产环境往往还停留在 Node 18/20 LTS,该方案目前更多用于本地轻量级开发。

三、 避坑指南:Vite 5 打包 Node 服务的报错

在实现热编译的过程中,如果你尝试用 Vite 打包 Node 服务,可能会遇到:

Invalid value for option "preserveEntrySignatures" - setting this option to false is not supported for "output.preserveModules"

原因:  当你开启 preserveModules: true 想保持源码目录结构输出时,Rollup 无法在“强制保留模块”的同时又“摇树优化(Tree Shaking)”掉入口导出。

修复方案:
在 vite.config.ts 中明确设置:

typescript

build: {
  rollupOptions: {
    preserveEntrySignatures: 'exports-only', // 显式声明保留导出
    output: {
      preserveModules: true
    }
  }
}

请谨慎使用此类代码。


四、 总结:我该选哪个?

  1. 如果你只想快速写个接口,不想折腾配置:请直接使用 tsx。它是 2026 年 nodemon 的完美继承者。
  2. 如果你在做复杂全栈项目,或者有大量的路径别名:请使用 vite-node。它能让你在 Node 端获得跟前端 React/Vue 编写时一样丝滑的 HMR 体验。
  3. 如果是为了部署生产环境:无论开发环境用什么,生产环境请务必通过 vite build 产出纯净的 JS,并使用 node dist/index.js 运行。

Sass 模块化革命:告别 @import,拥抱 @use 和 @forward

为什么你的 Sass 代码突然开始报错?是时候彻底理解 Sass 的模块化系统了!

最近很多前端开发者突然发现自己的 Sass 代码开始报出各种警告和错误:

  • @import rules are deprecated
  • There's already a module with namespace "math"
  • Using / for division is deprecated

这一切都源于 Dart Sass 的模块化革命。如果你还在使用传统的 @import,那么这篇文章将带你彻底理解新的模块系统,并手把手教你如何迁移。

为什么要弃用 @import?

传统 @import 的问题

让我们先回顾一下 @import 的常见用法:

// variables.scss
$primary-color: #1890ff;
$font-size: 14px;

// main.scss
@import "variables";
@import "mixins";
@import "components/button";

.button {
  color: $primary-color;
  font-size: $font-size;
}

看起来很简单对吧?但 @import 有几个致命缺陷:

  1. 全局污染:所有变量、mixin、函数都混入全局作用域
  2. 无法避免冲突:同名变量会被覆盖,且很难追踪来源
  3. 无法控制可见性:无法隐藏私有变量
  4. 性能问题:每次 @import 都会重新计算
  5. 依赖混乱:无法知道模块间的依赖关系

新系统的优势

@use@forward 组成的模块系统解决了所有这些问题:

  • 命名空间隔离:每个模块有自己的作用域
  • 明确的依赖关系:清晰知道每个变量来自哪里
  • 更好的封装:可以隐藏私有成员
  • 更快的编译:模块只被计算一次

核心概念:@use vs @forward

@use:使用模块

@use 用于在当前文件中使用其他模块的功能。

// 基本用法
@use "sass:math";
@use "variables";

// 通过命名空间访问
.element {
  width: math.div(100%, 3);
  color: variables.$primary-color;
}

// 使用通配符(类似旧版行为)
@use "variables" as *;
.element {
  color: $primary-color; // 直接使用,无需前缀
}

// 自定义命名空间
@use "variables" as vars;
.element {
  color: vars.$primary-color;
}

@forward:转发模块

@forward 用于转发模块的成员,但不直接使用它们。常见于库的入口文件。

// 转发整个模块
@forward "variables";

// 选择性转发
@forward "sass:math" show div, ceil, floor;
@forward "components/button" hide _private-mixin;

// 重命名转发
@forward "sass:math" as math-*;
// 使用时会变成:math-div(), math-ceil()

实战迁移指南

场景1:基础变量和工具迁移

迁移前(@import):

// styles/variables.scss
$primary-color: #1890ff;
$border-radius: 4px;

// styles/mixins.scss
@mixin rounded-corners($radius: $border-radius) {
  border-radius: $radius;
}

// main.scss
@import "styles/variables";
@import "styles/mixins";

.button {
  color: $primary-color;
  @include rounded-corners;
}

迁移方案A:直接使用

// main.scss
@use "styles/variables" as vars;
@use "styles/mixins";

.button {
  color: vars.$primary-color;
  @include mixins.rounded-corners;
}

迁移方案B:创建库入口

// styles/_index.scss (库入口)
@forward "variables";
@forward "mixins";

// main.scss
@use "styles" as *; // 所有成员直接可用

.button {
  color: $primary-color;
  @include rounded-corners;
}

场景2:处理第三方库冲突

问题场景: 第三方库和你的代码都需要 sass:math

// ❌ 可能冲突的情况
// element-plus 内部已使用: @use "sass:math" as math;
// 你的代码中也使用: @use "sass:math" as math;

// ✅ 解决方案1:使用不同命名空间
@use "sass:math" as original-math;

.element {
  width: original-math.div(100%, 3);
}

// ✅ 解决方案2:创建包装函数
// utils/_math-utils.scss
@use "sass:math" as sass-math;

@function divide($a, $b) {
  @return sass-math.div($a, $b);
}

// 使用
@use "utils/math-utils" as math;
.element {
  width: math.divide(100%, 3);
}

场景3:构建组件库

项目结构:

ui-library/
├── foundation/
│   ├── _variables.scss
│   ├── _colors.scss
│   └── _index.scss
├── components/
│   ├── _button.scss
│   ├── _card.scss
│   └── _index.scss
└── index.scss

配置入口文件:

// ui-library/foundation/_index.scss
@forward "variables";
@forward "colors";
@forward "typography";

// ui-library/components/_index.scss
@forward "button" show button, button-variants;
@forward "card" show card;
// 隐藏私有成员
@forward "modal" hide _private-styles;

// ui-library/index.scss
@forward "foundation";
@forward "components";

// 业务代码中使用
@use "ui-library" as ui;

.custom-button {
  @extend ui.button;
  background-color: ui.$primary-color;
}

常见陷阱和解决方案

陷阱1:命名空间冲突

// ❌ 错误:相同的命名空间
@use "module1" as utils;
@use "module2" as utils; // 错误:命名空间 "utils" 重复

// ✅ 正确:使用不同的命名空间
@use "module1" as utils1;
@use "module2" as utils2;

陷阱2:@use 和 @forward 顺序错误

// ❌ 错误:@forward 必须在 @use 之前
@use "sass:color";
@forward "sass:math"; // 错误!

// ✅ 正确:正确的顺序
@forward "sass:math"; // 先转发
@use "sass:color";    // 后使用

陷阱3:忽略除法运算迁移

// ⚠️ 警告:传统除法将弃用
$ratio: 16/9; // 警告:Using / for division is deprecated

// ✅ 正确:使用 math.div()
@use "sass:math";
$ratio: math.div(16, 9);

陷阱4:在 @forward 文件中直接使用转发的成员

// utils/_index.scss
@forward "math-tools";

// ❌ 错误:不能在转发文件中直接使用转发的成员
$value: math.div(100, 2); // 错误!math 不可用

// ✅ 正确:需要单独 @use
@use "sass:math" as math;
$value: math.div(100, 2);
@forward "math-tools";

自动化迁移工具

Sass 官方提供了强大的迁移工具:

# 安装迁移工具
npm install -g sass-migrator

# 1. 迁移 @import 到 @use
sass-migrator import-to-use **/*.scss

# 2. 迁移除法运算
sass-migrator division **/*.scss

# 3. 同时处理多种文件类型
sass-migrator import-to-use --recursive "**/*.{scss,sass,vue}"

# 4. 带参数的迁移
sass-migrator import-to-use --namespace=lib "src/**/*.scss"

最佳实践总结

1. 命名策略

// 基础变量 → 通配符导入(方便使用)
@use "variables" as *;

// 工具函数 → 命名空间导入(避免冲突)
@use "utils/math" as math;

// 第三方库 → 使用短命名空间
@use "element-plus" as ep;

2. 文件组织

// 库/框架:使用 @forward 构建清晰的API
// _index.scss
@forward "foundation" show $colors, $typography;
@forward "components" hide _private-*;
@forward "utilities" as utils-*;

// 业务代码:使用 @use 明确依赖
@use "ui-library" as ui;
@use "project/utils" as utils;

3. 处理依赖关系

// 依赖图:A → B → C
// c.scss
$value: red;

// b.scss
@use "c" as *;
$color: $value;

// a.scss
@use "b" as *;
.element { color: $color; }

性能优化建议

  1. 减少重复计算:模块只计算一次,即使被多次 @use
  2. 合理使用缓存:构建工具通常会缓存编译结果
  3. 避免深层嵌套:过深的 @forward 链可能影响性能
  4. 按需导入:使用 show/hide 只导入需要的成员

版本兼容性

// package.json 版本建议
{
  "devDependencies": {
    "sass": "^1.58.0",     // 支持完整模块系统
    "sass-loader": "^13.2.0"
  }
}

写在最后

迁移到新的 Sass 模块系统看起来有些挑战,但带来的好处是实实在在的:

🎯 代码更清晰:明确的依赖关系和命名空间
🔧 维护更容易:模块化的结构便于重构
性能更好:智能的缓存和编译优化
🚀 面向未来:符合现代前端开发的最佳实践

迁移不是一次性的痛苦,而是一次性的投资。现在花时间迁移,未来将节省大量的调试和维护时间。

记住这个简单的决策流程:

  1. 构建库/框架 → 优先使用 @forward
  2. 编写业务代码 → 主要使用 @use
  3. 基础变量/配置 → 考虑 @use ... as *
  4. 工具函数 → 使用命名空间避免冲突

行动起来吧! 从今天开始,逐步将你的项目迁移到新的模块系统。你的未来代码库会感谢你现在做出的努力!


你的项目开始迁移了吗? 在迁移过程中遇到了什么有趣的问题或挑战?欢迎在评论区分享你的经验!

❌