阅读视图

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

使用 Node.js 批量导入多语言标签到 Strapi

在多语言网站开发中,我们常常需要在 Strapi 中维护大量的标签(Tags),比如文章标签、产品分类标签等。如果手动在后台创建上百条标签,会非常耗时且容易出错。本文将介绍如何使用 Node.js 脚本批量导入标签,并支持多语言(英文 / 德语 / 法语)与自动生成 slug。


一、项目背景

假设我们有一个 Next.js + Strapi 项目,Strapi 作为内容管理系统(CMS),我们希望:

  • 批量导入 1000+ 标签
  • 支持多语言(en / de / fr)
  • 自动生成 URL slug
  • 避免重复创建

为了实现这些目标,我们可以写一个 Node.js 脚本,调用 Strapi 的 REST API 来批量创建标签。


二、准备工作

  1. 获取 Strapi API Token 在 Strapi 后台创建一个 API Token,选择 Full Access 或者至少有 Tags CRUD 权限。 在项目根目录创建 .env 文件:

    STRAPI_API_URL=https://your-strapi-domain.com
    STRAPI_API_TOKEN=YOUR_API_TOKEN
    
  2. 安装依赖

    npm install node-fetch@2 dotenv
    
  3. 准备标签数据 我们将标签写成 tags.json 文件,示例:

    {
      "tags": [
        {
          "title_en": "Economy",
          "title_de": "Wirtschaft",
          "title_fr": "Économie",
          "slug_en": "economy",
          "slug_de": "wirtschaft",
          "slug_fr": "economie"
        },
        {
          "title_en": "Technology",
          "title_de": "Technologie",
          "title_fr": "Technologie",
          "slug_en": "technology",
          "slug_de": "technologie",
          "slug_fr": "technologie"
        }
      ]
    }
    

三、核心脚本解析

以下是 import_tags_to_strapi.js 的核心实现:

const fs = require('fs');
const fetch = require('node-fetch'); // npm install node-fetch@2
require('dotenv').config();

const STRAPI_URL = process.env.STRAPI_API_URL || 'http://localhost:1337';
const TOKEN = process.env.STRAPI_API_TOKEN;

const raw = fs.readFileSync('tags.json', 'utf8');
const { tags } = JSON.parse(raw);

// helper: sleep
const sleep = (ms) => new Promise((res) => setTimeout(res, ms));

1. 创建英文标签

const enBody = {
  data: {
    title: tagObj.title_en,
    slug: tagObj.slug_en
  }
};
await fetch(`${STRAPI_URL}/api/tags?populate=none`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify(enBody)
});

2. 创建德语和法语本地化

const deBody = {
  data: { title: tagObj.title_de, slug: tagObj.slug_de },
  locale: 'de'
};
await fetch(`${STRAPI_URL}/api/tags/${createdId}/localizations`, { ... });

const frBody = {
  data: { title: tagObj.title_fr, slug: tagObj.slug_fr },
  locale: 'fr'
};
await fetch(`${STRAPI_URL}/api/tags/${createdId}/localizations`, { ... });

这里使用了 Strapi Localizations API,保证不同语言之间的标签关联。

3. 批量处理与防刷限流

for (let i = 0; i < tags.length; i++) {
  await createTag(tags[i], i + 1);
  await sleep(200); // 避免 API 请求过快
}

四、运行脚本

node import_tags_to_strapi.js

执行后,你会看到:

1 created EN id= 15
1 created DE localization
1 created FR localization
2 created EN id= 16
...
done import

五、注意事项

  1. API Token 权限:确保 Token 有 Tag 的读写权限。
  2. slug 唯一性:Strapi 对 slug 有唯一性要求,建议提前生成或使用 slugify
  3. 请求频率:一次导入大量标签时,增加 sleep 时间可避免 Strapi 报 429。
  4. 多语言管理:Localizations API 保证标签在多语言之间关联,便于前端展示。

六、总结

通过 Node.js 脚本批量导入 Strapi 标签可以大幅提高效率,并且可以:

  • 支持上千条标签
  • 自动生成 slug
  • 支持多语言
  • 可在 CI/CD 或部署脚本中重复执行

这种方式特别适合新闻网站、博客、产品目录、跨语言项目等。

Monorepo 架构以及工具选型、搭建

Monorepo(Monolithic Repository,单体仓库)是一种代码管理策略,核心是将一个项目的所有相关代码(包括多个应用、库、工具链等)集中存储在单个代码仓库中,而非按模块拆分到多个独立仓库(Multirepo)。

📑 目录


快速参考

工具选型速查表

工具类型 推荐工具 适用场景 备选方案
包管理器 pnpm workspace 磁盘效率高、安装速度快 npm workspace、yarn workspace
任务调度 Turbo 增量构建、并行任务、缓存 Nx(企业级)、Rush(大型项目)
版本管理 Changeset monorepo 友好的版本管理 release-it(单包)、Lerna(传统)

快速开始

# 1. 初始化项目
mkdir your-project && cd your-project
pnpm init -y

# 2. 安装 Turbo
pnpm add turbo -D -w

# 3. 配置工作区
# 创建 pnpm-workspace.yaml

# 4. 配置 Turbo
# 创建 turbo.json

# 5. 创建子包
mkdir -p packages/core docs examples/basic

什么是 Monorepo

简单类比

  • Multirepo:像多个独立的文件夹,每个项目 / 库单独存放(比如 reactreact-domreact-router 各一个仓库)
  • Monorepo:像一个大文件夹,里面按功能分类存放所有相关项目(比如 Facebook 的 facebook/react 仓库,包含 React 核心、文档、示例、相关工具等所有代码)

常用结构

monorepo-root/
├── packages/          # 所有可复用包(库、工具)
│   ├── utils/         # 通用工具库
│   ├── components/     # UI 组件库
│   └── cli/           # 命令行工具
├── apps/              # 可部署应用
│   ├── web/           # 网页应用
│   └── admin/         # 管理后台
├── scripts/           # 全局构建/测试脚本
├── package.json       # 根项目配置(依赖、脚本)
└── pnpm-workspace.yaml # 工作区配置(pnpm 为例)

Monorepo vs Multirepo

对比维度 Multirepo(多仓库) Monorepo(单仓库)
依赖管理 重复安装,版本不一致,易冲突 共享依赖,版本统一,减少冗余
跨项目引用 需发布 npm / 用相对路径,同步修改繁琐 本地直接引用,修改实时生效,无需发布
工程化规范 各仓库独立配置,维护成本高 根目录统一配置,所有子项目继承
代码复用 复制粘贴或发布私有包,复用成本高 仓库内直接复用,抽离库更便捷
版本管理与发布 手动协调多包版本(如 A 依赖 B,B 升级后 A 需手动更新) 工具自动管理版本依赖(如 Changeset),批量发布
协作效率 跨仓库 PR 联动复杂,代码审查分散 所有代码在一个仓库,PR 集中,协作更高效

Monorepo 的优缺点

优点

  • 高效协作:所有代码集中管理,跨项目修改无需切换仓库,PR 集中审查
  • 规范统一:工程化配置(lint、测试、构建)全局统一,降低维护成本
  • 依赖优化:共享依赖减少安装体积,版本统一避免冲突
  • 代码复用:子包间直接引用,无需发布,迭代速度快

缺点

  • 仓库体积增大:随着项目增多,仓库体积会变大,但现代 Git(如 Git LFS)可缓解
  • 构建速度:大型 Monorepo 全量构建较慢,需借助 Turborepo 等工具实现增量构建和缓存
  • 权限控制:难以对单个子包进行精细化权限控制(如需控制,可结合 Git 子模块或企业级工具如 GitLab Enterprise)

经典案例

前端 Monorepo 经典案例:

  • Reactfacebook/react 仓库包含 React 核心、React DOM、React Server Components 等所有相关代码
  • Vuevuejs/core 仓库包含 Vue 3 核心、编译器、运行时等
  • Vitevitejs/vite 仓库包含 Vite 核心、官方插件(如 @vitejs/plugin-react)等
  • Tailwind CSStailwindlabs/tailwindcss 仓库包含核心库、CLI、插件等

何时使用 Monorepo

当你的项目满足以下 2 个及以上条件时,优先选择 Monorepo:

  • ✅ 需拆分独立模块(核心包 + 文档 + 示例是典型场景)
  • ✅ 模块间有依赖关系(如示例依赖核心包、文档引用核心包 API)
  • ✅ 需统一构建、测试、发布流程
  • ✅ 追求高效开发(增量构建、并行任务)

何时不使用 Monorepo

  • ❌ 单一核心包 + 简单 README 文档(单包架构足够)
  • ❌ 子包之间完全独立,无依赖关系
  • ❌ 团队规模小,维护成本高

工具选型

工作区管理工具

负责管理多包的依赖安装、路径映射、脚本执行,主流选择:

工具 磁盘效率 安装速度 monorepo 支持 适用场景
pnpm workspace ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 推荐,生态最优
npm workspace ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐⭐ npm 7+ 原生支持
yarn workspace ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ Yarn 1.x 传统方案
Lerna ⭐⭐⭐ ⭐⭐⭐ ⭐⭐⭐ 早期方案,已过时

选型建议

  • 推荐:pnpm workspace(磁盘效率高、安装速度快、生态完善)
  • 备选:npm workspace(npm 7+ 原生支持,无需额外配置)

核心特性

  • pnpm workspace:轻量、快速,原生支持 Monorepo,通过 pnpm-workspace.yaml 配置子项目路径,自动处理包之间的软链接,安装依赖时复用缓存,效率极高
  • Yarn Workspaces:与 pnpm 功能类似,支持 workspace:* 语法声明内部依赖
  • Lerna:早期流行的 Monorepo 工具,可搭配 npm/yarn 使用,擅长版本管理和发布,但依赖安装效率不如 pnpm

任务调度工具

需支持「多包构建」「增量构建」「依赖顺序构建」,避免每次全量构建:

工具 增量构建 并行任务 缓存机制 配置复杂度 适用场景
Turbo ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 中小型项目(推荐)
Nx ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐ 大型企业级项目
Rush ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐⭐ 超大型项目

选型建议

  • 中小型项目:Turbo(推荐,配置简单,性能优秀)
  • 大型企业级项目:Nx(功能强大,但配置复杂)
  • 超大型项目:Rush(微软开源,适合超大型 monorepo)

核心特性

  • Turbo:高性能构建系统,可缓存构建结果,并行执行任务(构建、测试、lint),大幅提升大型 Monorepo 的构建速度
  • tsup:支持多入口、增量构建,适配 TypeScript 项目,可快速构建多个子包
  • Rollup/Vite:适合构建库或应用,支持 Tree-shaking,Vite 还能提供开发时热更新

版本管理工具

解决多包版本联动、CHANGELOG 自动生成、npm 发布等问题:

工具 monorepo 支持 多包版本同步 自动化程度 配置复杂度 适用场景
Changeset ⭐⭐⭐⭐⭐ ✅ 自动同步 ⭐⭐⭐ ⭐⭐⭐ 多包项目(推荐)
release-it ⭐⭐⭐ ❌ 需手动 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ 单包项目、简单场景
Lerna ⭐⭐⭐ ✅ 支持 ⭐⭐⭐ ⭐⭐ 传统方案

选型建议

  • 多包项目、需要版本同步:Changeset(推荐)
  • 单包项目、追求自动化:release-it
  • 简单场景、快速发布:release-it

核心特性

  • Changeset:轻量、易用,支持按子包提交变更记录,自动计算版本号(语义化版本),生成 CHANGELOG,批量发布子包
  • release-it:可搭配 Changeset 使用,提供交互式发布流程,支持 GitHub 标签、发布说明等

代码质量工具

统一管理 lint、测试、格式化:

工具类型 推荐工具 核心功能
代码规范 ESLint + Prettier 根目录配置,所有子项目共享规则,可通过 eslint-config-xxx 抽离自定义规则
测试框架 Vitest / Jest 统一测试框架,支持跨包测试,可在根目录运行所有子项目的测试用例
Git Hooks Husky + lint-staged 提交代码前自动执行 lint 和测试,保障代码质量

实战搭建

以下是最简搭建流程,基于 pnpm(生态最优)+ Turbo + tsup(核心包打包)+ VitePress(文档)+ Vitest(测试)

1. 初始化基础环境

# 创建项目根目录
mkdir your-project && cd your-project

# 初始化根目录 package.json
pnpm init -y

# 安装 Turbo(任务调度)
pnpm add turbo -D -w  # -w 表示安装到根目录(workspace-root)

2. 配置 pnpm 工作区

创建 pnpm-workspace.yaml

# pnpm-workspace.yaml
packages:
  - 'packages/*' # 核心包(可发布)
  - 'docs' # 文档站点(不发布)
  - 'examples/*' # 示例项目(不发布)

3. 配置 Turbo

创建 turbo.json

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["package.json", "turbo.json"],
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**", "build/**", ".vitepress/dist/**"]
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "test": {
      "dependsOn": ["build"],
      "outputs": ["coverage/**"]
    },
    "lint": {
      "outputs": []
    },
    "clean": {
      "cache": false
    }
  }
}

配置说明

  • dependsOn: ["^build"]:构建前先构建依赖的子包
  • outputs:指定构建输出目录,用于缓存判断
  • cache: false:开发模式不缓存,避免热更新问题
  • persistent: true:开发模式持续运行(如 watch 模式)

4. 根目录 package.json 配置

{
  "name": "your-project-monorepo",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "test": "turbo run test",
    "lint": "turbo run lint",
    "clean": "turbo run clean && rm -rf node_modules",
    "format": "prettier --write \"**/*.{ts,tsx,md,json}\""
  },
  "devDependencies": {
    "turbo": "^2.0.0",
    "prettier": "^3.0.0"
  },
  "packageManager": "pnpm@9.0.0",
  "engines": {
    "node": ">=18.0.0",
    "pnpm": ">=9.0.0"
  }
}

5. 搭建核心包

# 创建核心包目录并初始化
mkdir -p packages/core && cd packages/core
pnpm init -y

# 安装核心依赖(共享依赖安装到根目录)
pnpm add -D tsup typescript vitest @types/node -w

核心包 package.json

{
  "name": "@your-org/core",
  "version": "1.0.0",
  "description": "核心功能包",
  "type": "module",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    }
  },
  "files": ["dist"],
  "scripts": {
    "build": "tsup",
    "dev": "tsup --watch",
    "test": "vitest run",
    "test:watch": "vitest",
    "lint": "eslint src/**/*.ts",
    "clean": "rm -rf dist coverage"
  },
  "keywords": ["core", "utils"],
  "license": "MIT"
}

核心包 tsup.config.ts

import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  clean: true,
  sourcemap: true,
  minify: true,
  splitting: false,
});

核心包 src/index.ts

export const greet = (name: string) => {
  return `Hello, ${name}!`;
};

export const add = (a: number, b: number) => {
  return a + b;
};

6. 搭建文档站点

# 创建文档目录并初始化
mkdir docs && cd docs
pnpm init -y

# 安装文档依赖
pnpm add -D vitepress @vitepress/theme-default -w

# 初始化 VitePress 文档
npx vitepress init

文档 package.json

{
  "name": "@your-org/docs",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "vitepress dev",
    "build": "vitepress build",
    "preview": "vitepress preview",
    "lint": "eslint . --ext .md,.ts",
    "clean": "rm -rf .vitepress/dist"
  },
  "dependencies": {
    "@your-org/core": "workspace:*"
  }
}

在 Markdown 中可直接导入核心包,用于示例演示:

# 快速使用

```ts
import { greet } from '@your-org/core';

console.log(greet('World')); // Hello, World!
```

7. 搭建示例项目

# 创建示例目录并初始化
mkdir -p examples/basic && cd examples/basic
pnpm init -y

# 安装依赖(使用工作区协议引用核心包)
pnpm add @your-org/core@workspace:* -w
pnpm add -D vite @vitejs/plugin-react -w

示例 package.json

{
  "name": "@your-org/example-basic",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview",
    "clean": "rm -rf dist"
  },
  "dependencies": {
    "@your-org/core": "workspace:*"
  }
}

8. 核心命令使用

命令 作用
pnpm run dev 同时启动核心包监听、文档热更新、示例热更新
pnpm run build 一键构建核心包(ESM/CJS + 类型)、文档静态资源、示例
pnpm run test 运行所有子包的测试(核心包单元测试、示例冒烟测试)
pnpm run lint 统一校验所有子包的代码规范
pnpm run clean 清理所有子包的构建产物和缓存

增量构建效果示例

  • 首次执行 pnpm run build:构建所有子包(core + docs + examples)
  • 修改核心包代码后再次执行 pnpm run build:仅重建 core 和依赖它的 examples,docs 未变更则直接复用缓存,构建速度提升 50%+

过滤特定子包执行任务

# 只构建核心包
pnpm run build --filter=@your-org/core

# 只构建文档和示例
pnpm run build --filter=@your-org/docs --filter=@your-org/example-basic

# 构建核心包及其依赖者
pnpm run build --filter=@your-org/core...

9. 版本管理与发布

使用 Changeset(推荐,适合多包版本同步)

Changeset 完全支持 Turbo monorepo,可仅发布核心包(packages/core),文档和示例不发布,并支持多包版本同步:

优势

  • ✅ 专为 monorepo 设计,支持多包版本同步
  • ✅ 自动更新依赖包的版本号
  • ✅ 变更记录清晰,便于追溯

劣势

  • ❌ 需要手动记录变更(npx changeset
  • ❌ 流程相对复杂

安装与配置

# 安装 Changeset 并初始化
pnpm add @changesets/cli -D -w
npx changeset init

修改 Changeset 配置(.changeset/config.json)

{
  "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": ["@your-org/docs", "@your-org/example-basic"]
}

发布流程

# 1. 记录核心包变更(仅选择 @your-org/core)
npx changeset

# 2. 升级版本 + 生成 CHANGELOG
npx changeset version

# 3. 构建核心包
pnpm run build --filter=@your-org/core

# 4. 发布核心包
pnpm publish --filter=@your-org/core --access public

使用 release-it(适合单包发布或简单场景)

release-it 也可以用于 monorepo,但主要用于单包发布场景。

优势

  • ✅ 自动化程度高,一条命令完成发布
  • ✅ 配置灵活,支持自定义发布流程
  • ✅ 支持 GitHub Release、npm 发布等

劣势

  • ❌ 不支持多包版本同步(需要手动处理)
  • ❌ 需要为每个包单独配置或使用脚本

方式一:在子包中单独配置(推荐)

在需要发布的子包中配置 release-it:

# 在核心包目录下
cd packages/core
pnpm add release-it @release-it/conventional-changelog -D

核心包 release-it.config.js

module.exports = {
  git: {
    commitMessage: 'chore: release @your-org/core@${version}',
    tagName: '@your-org/core@${version}',
    requireCleanWorkingDir: false,
    requireBranch: 'main',
    requireCommits: true,
  },
  github: {
    release: true,
    releaseName: '@your-org/core@${version}',
  },
  npm: {
    publish: true,
    publishPath: './',
  },
  hooks: {
    'before:init': ['pnpm run test'],
    'after:bump': ['pnpm run build'],
    'after:release': 'echo "Release @your-org/core@${version} completed!"',
  },
  plugins: {
    '@release-it/conventional-changelog': {
      preset: 'angular',
      infile: 'CHANGELOG.md',
    },
  },
};

核心包 package.json scripts

{
  "scripts": {
    "release": "release-it",
    "release:patch": "release-it patch",
    "release:minor": "release-it minor",
    "release:major": "release-it major"
  }
}

发布流程

# 在核心包目录下
cd packages/core
pnpm run release

方式二:在根目录统一配置(适合单包发布)

在根目录配置,配合 pnpm filter 使用:

# 在根目录安装
pnpm add release-it @release-it/conventional-changelog -D -w

根目录 release-it.config.js

module.exports = {
  git: {
    commitMessage: 'chore: release v${version}',
    tagName: 'v${version}',
    requireCleanWorkingDir: false,
    requireBranch: 'main',
  },
  hooks: {
    'before:init': ['pnpm run test --filter=@your-org/core'],
    'after:bump': ['pnpm run build --filter=@your-org/core'],
  },
  plugins: {
    '@release-it/conventional-changelog': {
      preset: 'angular',
      infile: 'CHANGELOG.md',
    },
  },
};

根目录 package.json scripts

{
  "scripts": {
    "release": "release-it",
    "release:core": "cd packages/core && pnpm run release"
  }
}

Changeset vs release-it 对比

特性 Changeset release-it
monorepo 支持 ⭐⭐⭐⭐⭐(专为 monorepo 设计) ⭐⭐⭐(需手动配置)
多包版本同步 ✅ 自动同步 ❌ 需手动处理
自动化程度 ⭐⭐⭐(需手动记录变更) ⭐⭐⭐⭐⭐(一条命令)
配置复杂度 ⭐⭐⭐ ⭐⭐⭐⭐
适用场景 多包项目、版本同步需求 单包项目、简单场景

选型建议

  • 多包项目、需要版本同步:Changeset(推荐)
  • 单包项目、追求自动化:release-it
  • 简单场景、快速发布:release-it

高级配置

Turbo 缓存优化

1. 配置远程缓存(可选)

Turbo 支持远程缓存,团队共享构建缓存:

# 安装 Turbo 远程缓存客户端
pnpm add turbo -D -w

# 登录 Vercel(免费提供远程缓存)
npx turbo login

# 链接项目
npx turbo link

2. 优化缓存配置

{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "env": ["NODE_ENV"], // 环境变量变化时重新构建
      "inputs": ["src/**/*.ts", "tsup.config.ts"] // 指定输入文件
    }
  }
}

CI/CD 集成

GitHub Actions 示例

创建 .github/workflows/ci.yml

name: CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 9

      - uses: actions/setup-node@v4
        with:
          node-version: 18
          cache: 'pnpm'

      - name: Install dependencies
        run: pnpm install

      - name: Build
        run: pnpm run build

      - name: Test
        run: pnpm run test

      - name: Lint
        run: pnpm run lint

依赖管理策略

1. 公共依赖提升到根目录

# 安装公共依赖到根目录
pnpm add -D typescript eslint prettier -w

# 子包无需重复安装,直接使用

2. 子包间依赖使用工作区协议

{
  "dependencies": {
    "@your-org/core": "workspace:*" // ✅ 正确
    // "@your-org/core": "1.0.0"     // ❌ 错误,无法实时同步
  }
}

3. 版本统一管理

使用 .npmrc 统一配置:

# .npmrc
shamefully-hoist=true
strict-peer-dependencies=false

最佳实践

  1. 子包命名规范:使用 scope(如 @your-org/core),避免命名冲突
  2. 依赖管理:公共依赖提升到根目录,子包间使用 workspace:* 协议
  3. 任务配置:在 turbo.json 中准确配置 outputs,确保缓存生效
  4. 版本管理:使用 Changeset 管理版本,仅发布需要发布的包
  5. 目录结构:按功能拆分(packages、docs、examples),保持清晰
  6. 避免过度拆分:子包数量控制在 5 个以内,过多会增加配置复杂度
  7. 开发体验:使用 pnpm run dev 同时启动所有子包的开发模式

常见问题

工作区相关问题

Q: 子包间依赖如何引用?

A: 使用工作区协议 workspace:*

{
  "dependencies": {
    "@your-org/core": "workspace:*"
  }
}

Q: 如何安装依赖到特定子包?

A:

# 安装到根目录
pnpm add -D typescript -w

# 安装到特定子包
pnpm add -D vite --filter @your-org/example-basic

# 安装到所有子包
pnpm add -D eslint --filter "./packages/*"

Q: 如何查看所有子包?

A:

pnpm list -r --depth=0

Turbo 相关问题

Q: Turbo 缓存不生效怎么办?

A:

  1. 检查 turbo.json 中的 outputs 配置是否正确
  2. 检查构建输出目录是否在 outputs 中声明
  3. 清理缓存:pnpm run clean && rm -rf .turbo

Q: 如何只构建变更的子包?

A: Turbo 默认就是增量构建,只需执行 pnpm run build,Turbo 会自动判断哪些子包需要重新构建。

Q: 如何跳过缓存强制构建?

A:

pnpm run build --force

Q: 如何查看构建依赖关系?

A:

# 查看任务依赖图
npx turbo run build --graph

版本管理相关问题

Q: Changeset 如何只发布特定包?

A: 在 .changeset/config.json 中配置 ignore 字段,或在执行 npx changeset 时只选择需要发布的包。

Q: 如何自动化发布流程?

A: 使用 GitHub Actions + Changeset,参考 Changeset 文档

Q: release-it 能否用于 monorepo?

A: 可以,但主要用于单包发布场景。如果项目只有一个包需要发布,release-it 更简单;如果需要多包版本同步,建议使用 Changeset。

Q: release-it 如何发布 monorepo 中的特定包?

A:

# 方式一:在子包目录下执行
cd packages/core
pnpm run release

# 方式二:使用 pnpm filter
pnpm --filter @your-org/core run release

性能优化相关问题

Q: 如何提升构建速度?

A:

  1. 配置 Turbo 远程缓存(团队共享)
  2. 优化 turbo.jsonoutputs 配置
  3. 使用 dependsOn 合理配置任务依赖
  4. 避免不必要的任务依赖

Q: 如何减少 node_modules 体积?

A:

  1. 使用 pnpm(默认使用符号链接,节省磁盘空间)
  2. 公共依赖提升到根目录
  3. 使用 .npmrc 配置 shamefully-hoist=false

参考资源


文档版本:v2.0
最后更新:2024 年

MCP理论和实战,然后做个MCP脚手架吧

引言: 本文介绍了目前MCP Server的开发方式和原理,包括streamable HTTP和STDIO两种。并提供了一个npm脚手架工具帮你创建项目,每个模板项目都是可运行的。

streamable HTTP

原理分析

抓包「握手」

MCP Client总共发了三次请求,MCP Server响应2次。实际的握手流程是4次握手,第5次请求是为了通知后续的信息(比如进度,日志等。 目前规范实现来看,第5次握手不影响正常功能)

使用wiresshark抓包结果如下:

image.png

image.png

从官网的「initialization」流程来看,也就是4次(第5次未来应该会被普遍实现)

image.png

第1次 Post请求,initialize 方法

{
  "jsonrpc": "2.0",
  "id": 0,
  "method": "initialize",
  "params": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "sampling": {},
      "elicitation": {},
      "roots": {
        "listChanged": true
      }
    },
    "clientInfo": {
      "name": "inspector-client",
      "version": "0.17.2"
    }
  }
}

第2次 :200 OK,响应体如下

{
  "result": {
    "protocolVersion": "2025-06-18",
    "capabilities": {
      "tools": {
        "listChanged": true
      }
    },
    "serverInfo": {
      "name": "weather",
      "version": "0.0.1"
    }
  },
  "jsonrpc": "2.0",
  "id": 0
}

第3次 :Post请求,notifications/initialized方法

{"jsonrpc":"2.0","method":"notifications/initialized"}

第4次 :202 Accepted,无响应体

第5次 :Get请求,此时要求服务端一定是SSE传输了-accept: text/event-stream

GET /mcp HTTP/1.1
accept: text/event-stream

总结「握手」流程

  1. POST /mcp (initialize)

    • 客户端:你好,我是 Inspector Client,我想初始化。
    • 服务器:收到,这是我的能力列表(200 OK)。
    • 状态:JSON-RPC 会话开始。
  2. POST /mcp (notifications/initialized)

    • 客户端:我已经收到你的能力了,初始化完成。
    • 服务器:收到 (202 Accepted)。
    • 状态:逻辑握手完成。
  3. GET /mcp (Header: accept: text/event-stream)

    • 目的:客户端现在试图建立长连接通道,以便在未来能收到服务器发来的通知(比如 notifications/message 或 roots/listChanged)。如果没有这个通道,服务器就变成了“哑巴”,无法主动联系客户端。

后续通信

tools/list (列出工具)

client->server 请求

请求头:

POST /mcp HTTP/1.1
accept: application/json, text/event-stream
accept-encoding: gzip, deflate, br
content-length: 85
content-type: application/json
mcp-protocol-version: 2025-06-18
user-agent: node-fetch
Host: localhost:3000
Connection: keep-alive

请求数据:

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/list",
  "params": {
    "_meta": {
      "progressToken": 1
    }
  }
}

P.S. params中的progressToken是可以用于后续的进度通知的(通过SSE)

server->client 响应

响应头:

HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: application/json
Date: Thu, 27 Nov 2025 11:52:31 GMT
Connection: keep-alive
Keep-Alive: timeout=5
Transfer-Encoding: chunked

响应体:

{
  "result": {
    "tools": [
      {
        "name": "get_weather_now",
        "title": "Get Weather Now",
        "description": "Get current weather for a location (city name)",
        "inputSchema": {
          "$schema": "http://json-schema.org/draft-07/schema#",
          "type": "object",
          "properties": {
            "location": {
              "description": "Location name or city (e.g. beijing, shanghai, new york, tokyo)",
              "type": "string"
            }
          },
          "required": [
            "location"
          ]
        }
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 1
}

这里列出一个工具:

  • get_weather_now,我们自己定义/注册的工具。我们可以拿到它的titledescriptioninputSchema,这些语义信息可以帮助LLM理解这个工具。
tools/call (调用tool)

这里通过 mcp inspector 工具调用了get_weather_now,请求体如下:

{
  "jsonrpc": "2.0",
  "id": 2,
  "method": "tools/call",
  "params": {
    "_meta": {
      "progressToken": 2
    },
    "name": "get_weather_now",
    "arguments": {
      "location": "北京"
    }
  }
}

响应体:

{
  "result": {
    "content": [
      {
        "type": "text",
        "text": "Weather for 北京, CN:\nCondition: 晴\nTemperature: 3°C\nLast Update: 2025-11-27T19:50:14+08:00"
      }
    ]
  },
  "jsonrpc": "2.0",
  "id": 2
}
方法小总结

上面我们列出了两种常见的方法

  • tools/list。MCP Client在向LLM发请求携带列出的tool,LLM会告诉客户端调用的tool name,然后由MCP client来触发tool调用。
  • tools/call。MCP Client告诉MCP Server 调用哪个tool。

可以结合官网的这张示意图,调用tool就是一次request/response。如果是长任务,可以通过_meta.progressToken作为关联,通过SSE持续通知进度(还记得「握手」流程的第5次握手吗)

image.png

代码实战 - 天气工具

准备天气API

这里我使用了心知天气的API,然后自己封装一个node API。 src/core/seniverse.ts

import * as crypto from 'node:crypto';
import * as querystring from 'node:querystring';
/**
 * 查询天气接口
 */
const API_URL = 'https://api.seniverse.com/v3/';
export class SeniverseApi {
    publicKey;
    secretKey;
    constructor(publicKey, secretKey) {
        this.publicKey = publicKey;
        this.secretKey = secretKey;
    }
    async getWeatherNow(location) {
        const params = {
            ts: Math.floor(Date.now() / 1000), // Current timestamp (seconds)
            ttl: 300, // Expiration time
            public_key: this.publicKey,
            location: location
        };
        // Step 2: Sort keys and construct the string for signature
        // "key=value" joined by "&", sorted by key
        const sortedKeys = Object.keys(params).sort();
        const str = sortedKeys.map(key => `${key}=${params[key]}`).join('&');
        // Step 3: HMAC-SHA1 signature
        const signature = crypto
            .createHmac('sha1', this.secretKey)
            .update(str)
            .digest('base64');
        // Step 4 & 5: Add sig to params and encode for URL
        // querystring.encode will handle URL encoding of the signature and other params
        params.sig = signature;
        const queryString = querystring.encode(params);
        const url = `${API_URL}weather/now.json?${queryString}`;
        try {
            const response = await fetch(url);
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            return await response.json();
        }
        catch (error) {
            console.error("Error making Seniverse request:", error);
            return null;
        }
    }
}

src/core/index.ts

import { SeniverseApi } from './seniverse.js';

export const seniverseApi = new SeniverseApi(
  process.env.SENIVERSE_PUBLIC_KEY || '',
  process.env.SENIVERSE_SECRET_KEY || '',
);

搭建streamable HTTP类型的MCP

1.使用express提供后端服务,然后设置/mcp endpoint(一般来说MCP client默认就是访问这个endpoint). 2.在MCP协议中,握手/工具调用等都是通过这个一个endpoint来完成的。

3.封装逻辑 封装了一个MyServer

  • run方法启动HTTP服务
  • init方法注册工具

4.核心是McpServerStreamableHTTPServerTransport两个API

  • McpServer: 负责注册tool.
  • StreamableHTTPServerTransport: 接管了/mcp endpoint的通信逻辑
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
import { z } from "zod";
import "dotenv/config";
import { seniverseApi } from "./core/index.js";

export class MyServer {
  private mcpServer: McpServer;
  private app: express.Express
  constructor() {
    this.mcpServer = new McpServer({
      name: "weather",
      version: "0.0.1",
    });

    // Set up Express and HTTP transport
    this.app = express();
    this.app.use(express.json());

    this.app.use('/mcp', async (req: express.Request, res: express.Response) => {
        // Create a new transport for each request to prevent request ID collisions
        const transport = new StreamableHTTPServerTransport({
            sessionIdGenerator: undefined,
            enableJsonResponse: true
        });

        res.on('close', () => {
            transport.close();
        });

        await this.mcpServer.connect(transport);
        await transport.handleRequest(req, res, req.body);
    });

  }

  /**
   * 在端口运行Server, 通过HTTP stream传输数据
   */
  async run(): Promise<void> {
    const port = parseInt(process.env.PORT || '3000');
    this.app.listen(port, () => {
        console.log(`Demo MCP Server running on http://localhost:${port}/mcp`);
    }).on('error', error => {
        console.error('Server error:', error);
        process.exit(1);
    });
    
  }

  /**
   * 初始化,注册工具
   */
  async init(): Promise<void> {
    // Register weather tool
    this.mcpServer.registerTool(
      "get_weather_now",
      {
        title: "Get Weather Now",
        description: "Get current weather for a location (city name)",
        inputSchema: {
          location: z.string().describe("Location name or city (e.g. beijing, shanghai, new york, tokyo)")
        }
      },
      async ({ location }) => {
        
        const weatherData = await seniverseApi.getWeatherNow(location);
        if (!weatherData || !weatherData.results || weatherData.results.length === 0) {
          return {
            content: [
              {
                type: "text",
                text: `Failed to retrieve weather data for location: ${location}. Please check the location name and try again.`,
              },
            ],
          };
        }

        const result = weatherData.results[0];
        const weatherText = `Weather for ${result.location.name}, ${result.location.country}:\n` +
                            `Condition: ${result.now.text}\n` +
                            `Temperature: ${result.now.temperature}°C\n` +
                            `Last Update: ${result.last_update}`;
        return {
          content: [
            {
              type: "text",
              text: weatherText,
            },
          ],
        };
      },
    );
  }
}

效果如下:

image.png

注意左侧侧边栏:

  • Transport Type选择Streamable HTTP
  • URL 填写你的express 服务地址和endpoint。

stdio

原理分析

我在项目中,通过监听process.stdin,查看通信Message

// 监听 stdin 输入,可以在inspector面板的"notifications/message"中看到(作为debug用)
    process.stdin.on("data", async (data) => {
      const input = data.toString().trim();
      console.error(input);
    });

通过mcp-inspector工具就可以观察到通信信息了,往下看👁

tools/list

image.png

tools/call

image.png

结合官网的stdio通信原理图

image.png

可以总结如下:

  • 连接一个stdio MCP服务,不同于streamable HTTP MCP服务需要进行「握手」,只要开启一个子进程(subprocess),就表示连接成功。
  • 后续的通信的信息格式遵循json-rpc:2.0,通过读写process.stdinprocess.stdout完成通信。

代码实战 - 统计文件数

比较简单,可以参考我的这篇博客 Node写MCP入门教程,基于StdioServerTransport实现的统计目录下文件夹的MCP Server,并且介绍了mcp inspector的调试和Trae安装使用。

创建MCP项目的脚手架

每次写个新MCP Server都要搭建项目模板,这种重复的工作当然该做成工具辣! 我自己写了一个create-mcp脚手架 Githubcreate-mcp cli工具已经发布在npm上了,可以npm安装使用。

cli 原理

1.脚手架原理,首先准备两个模板项目

  • template-stdio 模板
  • template-streamable 模板

2.然后用Node写一个cli工具,使用了以下依赖,通过命令行交互的方式创建项目

pnpm i minimist prompts fs-extra chalk

3.根据你选择的项目名称和模板,帮你拷贝模板,修改为你的「项目名称」

觉得这个cli项目不错的话,给个免费的star吧~ 👉 Github

使用 cli

使用@caikengren-cli/create-mcp创建项目

npx @caikengren-cli/create-mcp

image.png

然后依次分别运行下面两个命令

# 编译ts/运行node
pnpm dev

# 打开 mcp-inspector工具调试
pnpm inspect

参考

mcp官网
mcp中文网
mcp: typescript-sdk

CLI 工具开发的常用包对比和介绍

04-CLI 工具开发

CLI 工具开发涉及命令行交互、终端美化、文件操作和模板生成等核心功能。

📑 目录


快速参考

工具选型速查表

工具类型 推荐工具 适用场景 备选方案
命令行解析 commander 功能丰富、API 友好 yargs(灵活)、meow(轻量)
交互式输入 inquirer 功能全面、生态丰富 prompts(轻量)、enquirer
终端美化 chalk 功能丰富、API 友好 picocolors(极轻量)、kleur
加载动画 ora 简单易用 -
进度条 cli-progress 文件上传/下载进度 -
文件操作 fs-extra Promise API、功能增强 -
文件匹配 glob 通配符匹配 -
模板引擎 handlebars 轻量、逻辑少 ejs、mustache

快速开始

# 1. 安装核心工具
pnpm add commander inquirer chalk ora fs-extra glob handlebars

# 2. 创建 CLI 入口文件
# src/cli.ts

# 3. 配置 package.json bin 字段

命令行交互

commander(推荐)

commander 是一款 Node.js 命令行解析工具,核心用途是解析命令行参数,让 CLI 工具的命令行交互更友好、专业。

优势

  • ✅ API 友好,链式调用
  • ✅ 功能丰富,支持子命令、选项、帮助信息
  • ✅ 生态完善,文档详细
  • ✅ 自动生成帮助信息

劣势

  • ❌ 体积较大(相比 meow)
安装
pnpm add commander
pnpm add @types/commander -D
基础用法
// src/cli.ts
import { program } from 'commander';

program
  .version('1.0.0', '-v, --version')
  .description('一个基于 commander + inquirer 的 CLI 工具示例');

// 定义无参数命令
program
  .command('init')
  .description('初始化项目')
  .action(() => {
    console.log('开始初始化项目...');
  });

// 定义带选项的命令
program
  .command('build')
  .description('打包项目')
  .option('-e, --env <env>', '打包环境', 'development')
  .option('-o, --outDir <dir>', '输出目录', 'dist')
  .action((options) => {
    console.log('开始打包...');
    console.log('打包环境:', options.env);
    console.log('输出目录:', options.outDir);
  });

program.parse(process.argv);
选项配置
选项格式 说明 示例
.option('-s, --single') 布尔型选项(无参数,存在即 true) your-cli --single{ single: true }
.option('-n, --name <name>') 必填参数选项 your-cli --name test{ name: 'test' }
.option('-a, --age [age]') 可选参数选项 your-cli --age 25{ age: 25 };不传则为 undefined
.option('--env <env>', '描述', 'dev') 带默认值的选项 不传 --env 时,默认 { env: 'dev' }
高级用法
// 子命令
program
  .command('create <name>')
  .description('创建新项目')
  .option('-t, --template <template>', '模板类型', 'default')
  .action((name, options) => {
    console.log(`创建项目 ${name},使用模板 ${options.template}`);
  });

// 必需选项
program.requiredOption('-c, --config <path>', '配置文件路径').parse();

// 自定义帮助信息
program.addHelpText('after', '\n示例:\n  $ my-cli init\n  $ my-cli build --env production');

yargs

yargs 是功能强大的命令行解析工具,支持位置参数、命令补全等高级功能。

优势

  • ✅ 功能强大,支持位置参数
  • ✅ 灵活的配置方式
  • ✅ 支持命令补全

劣势

  • ❌ API 相对复杂
  • ❌ 学习曲线较陡
安装
pnpm add yargs
pnpm add @types/yargs -D
基础用法
import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';

const argv = yargs(hideBin(process.argv))
  .option('name', {
    alias: 'n',
    type: 'string',
    description: '项目名称',
    demandOption: true,
  })
  .option('template', {
    alias: 't',
    type: 'string',
    default: 'default',
    description: '模板类型',
  })
  .command('init <name>', '初始化项目', (yargs) => {
    return yargs.positional('name', {
      describe: '项目名称',
      type: 'string',
    });
  })
  .parseSync();

console.log(argv);

meow

meow 是轻量级的命令行解析工具,适合简单场景。

优势

  • ✅ 轻量级,体积小
  • ✅ 配置简单
  • ✅ 自动处理帮助信息

劣势

  • ❌ 功能相对简单
  • ❌ 不支持复杂命令结构
安装
pnpm add meow
基础用法
import meow from 'meow';

const cli = meow(
  `
  用法
    $ my-cli <input>

  选项
    --name, -n  项目名称
    --template, -t  模板类型

  示例
    $ my-cli init --name my-project
`,
  {
    importMeta: import.meta,
    flags: {
      name: {
        type: 'string',
        alias: 'n',
      },
      template: {
        type: 'string',
        alias: 't',
        default: 'default',
      },
    },
  },
);

console.log(cli.input[0]); // 命令参数
console.log(cli.flags); // 选项

命令行解析工具对比

工具 体积 配置复杂度 功能丰富度 适用场景
commander 较大 ⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 功能丰富的 CLI 工具
yargs 较大 ⭐⭐ ⭐⭐⭐⭐⭐ 需要位置参数的场景
meow 轻量级 ⭐⭐⭐⭐⭐ ⭐⭐⭐ 简单 CLI 工具

选型建议

  • 功能丰富的 CLI 工具:commander(推荐)
  • 需要位置参数:yargs
  • 简单工具:meow

交互式输入

inquirer(推荐)

当命令行参数无法满足需求(如让用户选择框架、输入密码、确认操作)时,inquirer 提供「交互式输入」。

优势

  • ✅ 功能全面,支持多种交互类型
  • ✅ 生态丰富,插件多
  • ✅ 文档完善

劣势

  • ❌ 体积较大
  • ❌ 配置相对复杂
安装
pnpm add inquirer
pnpm add @types/inquirer -D
基础用法
import { program } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';

program
  .command('init')
  .description('初始化项目')
  .action(async () => {
    console.log(chalk.blue('📦 开始初始化项目...'));

    const answers = await inquirer.prompt([
      {
        type: 'input',
        name: 'projectName',
        message: '请输入项目名称:',
        default: 'my-project',
        validate: (value) => {
          if (!value.trim()) return '项目名称不能为空!';
          return true;
        },
      },
      {
        type: 'list',
        name: 'framework',
        message: '请选择项目框架:',
        choices: [
          { name: 'React + TypeScript', value: 'react-ts' },
          { name: 'Vue + TypeScript', value: 'vue-ts' },
          { name: 'Vanilla JS', value: 'vanilla' },
        ],
        default: 'react-ts',
      },
      {
        type: 'checkbox',
        name: 'modules',
        message: '请选择需要的功能模块:',
        choices: ['路由', '状态管理', 'UI 组件库', 'ESLint/Prettier'],
        default: ['路由', 'ESLint/Prettier'],
      },
      {
        type: 'confirm',
        name: 'initGit',
        message: '是否初始化 Git 仓库?',
        default: true,
      },
    ]);

    console.log(chalk.green('\n✅ 项目配置如下:'));
    console.log('项目名称:', answers.projectName);
    console.log('框架:', answers.framework);
    console.log('功能模块:', answers.modules.join(', '));
    console.log('初始化 Git:', answers.initGit ? '是' : '否');
  });

program.parse(process.argv);
核心交互类型
类型 用途 关键配置
input 普通文本输入(如项目名称、邮箱) message、default、validate
password 密码输入(输入内容隐藏) 同 input,自动隐藏输入
list 单选(如框架选择、环境选择) choices(选项数组)、default
checkbox 多选(如功能模块、依赖选择) choices、default(默认选中项)
confirm 二选一确认(是 / 否) message、default(true/false)
rawlist 带编号的单选(按数字选择) 同 list,选项前显示编号
autocomplete 带自动补全的输入(如文件路径) 需配合 inquirer-autocomplete-prompt 插件

prompts

prompts 是轻量级的交互式输入工具,API 简洁。

优势

  • ✅ 轻量级,体积小
  • ✅ API 简洁
  • ✅ 支持取消操作(Ctrl+C)

劣势

  • ❌ 功能相对简单
  • ❌ 生态较小
安装
pnpm add prompts
基础用法
import prompts from 'prompts';

const response = await prompts([
  {
    type: 'text',
    name: 'projectName',
    message: '项目名称',
    initial: 'my-project',
    validate: (value) => (value.trim() ? true : '项目名称不能为空'),
  },
  {
    type: 'select',
    name: 'framework',
    message: '选择框架',
    choices: [
      { title: 'React', value: 'react' },
      { title: 'Vue', value: 'vue' },
    ],
  },
]);

console.log(response);

enquirer

enquirer 是现代化的交互式输入工具,支持自定义提示符。

优势

  • ✅ 现代化设计
  • ✅ 支持自定义提示符
  • ✅ API 灵活

劣势

  • ❌ 文档相对较少
  • ❌ 生态较小
安装
pnpm add enquirer
基础用法
import { prompt } from 'enquirer';

const response = await prompt({
  type: 'input',
  name: 'projectName',
  message: '项目名称',
  initial: 'my-project',
});

console.log(response);

交互式输入工具对比

工具 体积 配置复杂度 功能丰富度 生态 适用场景
inquirer 较大 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐⭐ 功能全面的 CLI 工具
prompts 轻量级 ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 简单交互场景
enquirer 中等 ⭐⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐ 需要自定义提示符

选型建议

  • 功能全面的 CLI 工具:inquirer(推荐)
  • 简单交互场景:prompts
  • 需要自定义提示符:enquirer

终端美化

chalk(推荐)

chalk 是 Node.js 终端日志彩色打印工具,核心用途是给终端输出的文字添加颜色、背景色、加粗 / 下划线等样式。

优势

  • ✅ 功能丰富,API 友好
  • ✅ 支持链式调用
  • ✅ 生态完善

劣势

  • ❌ 体积较大(相比 picocolors)
安装
pnpm add chalk
封装日志函数
// src/utils/logger.ts
import chalk from 'chalk';

export enum LogType {
  SUCCESS = 'success',
  ERROR = 'error',
  WARN = 'warn',
  INFO = 'info',
}

export const log = (message: string, type: LogType = LogType.INFO) => {
  const prefixMap = {
    [LogType.SUCCESS]: chalk.green('✅'),
    [LogType.ERROR]: chalk.bold.red('❌'),
    [LogType.WARN]: chalk.yellow('⚠️'),
    [LogType.INFO]: chalk.blue('ℹ️'),
  };

  const colorMap = {
    [LogType.SUCCESS]: chalk.green,
    [LogType.ERROR]: chalk.red,
    [LogType.WARN]: chalk.yellow,
    [LogType.INFO]: chalk.blue,
  };

  const prefix = prefixMap[type];
  const color = colorMap[type];
  console.log(`${prefix} ${color(message)}`);
};

export const logSuccess = (message: string) => log(message, LogType.SUCCESS);
export const logError = (message: string) => log(message, LogType.ERROR);
export const logWarn = (message: string) => log(message, LogType.WARN);
export const logInfo = (message: string) => log(message, LogType.INFO);
使用
import { logSuccess, logError, logWarn, logInfo } from './utils/logger';

logInfo('正在初始化项目...');
logWarn('当前 Node 版本低于推荐版本');
logSuccess('项目初始化完成!');
logError('配置文件缺失,请检查 config.json');

picocolors

picocolors 是极轻量的终端颜色库,体积仅 0.5KB。

优势

  • ✅ 极轻量(0.5KB)
  • ✅ API 简洁
  • ✅ 性能好

劣势

  • ❌ 功能相对简单
  • ❌ 不支持链式调用
安装
pnpm add picocolors
基础用法
import pc from 'picocolors';

console.log(pc.green('成功'));
console.log(pc.red('错误'));
console.log(pc.bold(pc.blue('加粗蓝色')));

kleur

kleur 是轻量级的终端颜色库,API 类似 chalk。

优势

  • ✅ 轻量级(体积小)
  • ✅ API 类似 chalk,迁移成本低
  • ✅ 支持链式调用

劣势

  • ❌ 功能相对简单
安装
pnpm add kleur
基础用法
import kleur from 'kleur';

console.log(kleur.green('成功'));
console.log(kleur.red('错误'));
console.log(kleur.bold().blue('加粗蓝色'));

ora(展示加载动画)

ora 是一款 Node.js 终端加载动画工具,核心用途是在耗时操作时显示「加载中」动画 + 提示文字。

安装
pnpm add ora
封装加载动画函数
// src/utils/loader.ts
import ora from 'ora';
import chalk from 'chalk';

export const withLoader = async <T>(message: string, asyncFn: () => Promise<T>): Promise<T> => {
  const spinner = ora(chalk.bold.blue(message)).start();
  try {
    const result = await asyncFn();
    spinner.succeed(chalk.green('✅ 操作完成!'));
    return result;
  } catch (error) {
    spinner.fail(chalk.bold.red(`❌ 操作失败:${(error as Error).message}`));
    throw error;
  }
};
使用示例
import { withLoader } from './utils/loader';

await withLoader('正在请求接口数据...', async () => {
  await new Promise((resolve) => setTimeout(resolve, 1500));
});
高级用法
import ora from 'ora';

const spinner = ora('加载中...').start();

// 更新文本
spinner.text = '处理中...';

// 成功
spinner.succeed('完成!');

// 失败
spinner.fail('失败!');

// 警告
spinner.warn('警告!');

// 信息
spinner.info('信息');

cli-progress(进度条)

cli-progress 用于显示文件上传/下载、批量处理等操作的进度。

安装
pnpm add cli-progress
基础用法
import cliProgress from 'cli-progress';

const bar = new cliProgress.SingleBar({
  format: '进度 |{bar}| {percentage}% | {value}/{total}',
  barCompleteChar: '\u2588',
  barIncompleteChar: '\u2591',
  hideCursor: true,
});

bar.start(100, 0);

// 模拟进度
for (let i = 0; i <= 100; i++) {
  await new Promise((resolve) => setTimeout(resolve, 50));
  bar.update(i);
}

bar.stop();

boxen(边框框)

boxen 用于在终端中创建带边框的文本框,适合显示重要信息。

安装
pnpm add boxen
基础用法
import boxen from 'boxen';
import chalk from 'chalk';

const message = boxen(chalk.green('✅ 项目初始化完成!'), {
  padding: 1,
  margin: 1,
  borderStyle: 'round',
  borderColor: 'green',
});

console.log(message);

cli-table3(表格展示)

cli-table3 用于在终端中展示表格数据,适合显示配置信息、对比数据等。

安装
pnpm add cli-table3
pnpm add @types/cli-table3 -D
基础用法
import Table from 'cli-table3';

const table = new Table({
  head: ['工具', '用途', '推荐度'],
  colWidths: [20, 30, 10],
});

table.push(
  ['commander', '命令行解析', '⭐⭐⭐⭐⭐'],
  ['inquirer', '交互式输入', '⭐⭐⭐⭐⭐'],
  ['chalk', '终端美化', '⭐⭐⭐⭐⭐'],
);

console.log(table.toString());

终端美化工具对比

工具 体积 功能 适用场景
chalk 较大 颜色、样式 功能丰富的 CLI 工具
picocolors 极轻量 基础颜色 对体积敏感的项目
kleur 轻量级 颜色、样式 轻量级 CLI 工具
ora 中等 加载动画 耗时操作提示
cli-progress 中等 进度条 文件处理进度
boxen 轻量级 边框框 重要信息展示
cli-table3 中等 表格 数据展示

选型建议

  • 功能丰富的 CLI 工具:chalk(推荐)
  • 对体积敏感:picocolors
  • 需要进度条:cli-progress
  • 需要表格展示:cli-table3

文件操作

fs-extra(操作文件系统)

fs-extra 在 Node.js 原生 fs 模块基础上做了增强,核心优势:

  • 完全兼容原生 fs 模块(可直接替换 fs 使用)
  • 所有 API 支持 Promise(无需手动封装 util.promisify)
  • 新增高频实用功能(递归创建目录、递归删除目录、复制文件 / 目录等)
安装
pnpm add fs-extra
pnpm add @types/fs-extra -D
核心用法
import fs from 'fs-extra';
import path from 'path';

// 递归创建目录
await fs.ensureDir(path.resolve(__dirname, 'a/b/c'));

// 递归删除目录
await fs.remove(path.resolve(__dirname, 'a'));

// 复制文件/目录
await fs.copy(src, dest);

// 写入 JSON 文件
await fs.writeJson(jsonPath, { name: 'test', version: '1.0.0' }, { spaces: 2 });

// 读取 JSON 文件
const config = await fs.readJson(jsonPath);

// 判断文件/目录是否存在
const exists = await fs.pathExists(path);
常用 API
API 名称 核心用途 优势对比(vs 原生 fs)
fs.ensureDir(path) 递归创建目录(不存在则创建,存在则忽略) 原生需手动递归,fs-extra 一键实现
fs.remove(path) 递归删除文件 / 目录(支持任意层级) 原生需先遍历目录,fs-extra 一键删除
fs.copy(src, dest) 复制文件 / 目录(自动创建目标目录) 原生需区分文件 / 目录,fs-extra 自动适配
fs.writeJson(path, data) 写入 JSON 文件(自动 stringify) 原生需手动 JSON.stringify,fs-extra 简化步骤
fs.readJson(path) 读取 JSON 文件(自动 parse) 原生需手动 JSON.parse,fs-extra 简化步骤
fs.pathExists(path) 判断文件 / 目录是否存在(返回 boolean) 原生需用 fs.access 捕获错误,fs-extra 直接返回

glob(匹配文件)

glob 解决「按规则批量查找文件」的需求,支持用通配符(如 ***?)匹配文件路径。

安装
pnpm add glob
pnpm add @types/glob -D
核心用法
import glob from 'glob';
import path from 'path';

// 同步匹配
const files = glob.sync('src/**/*.ts', {
  cwd: process.cwd(),
  ignore: ['src/test/**/*'],
});

// 异步匹配(推荐)
const files = await glob.promise('src/**/*.{ts,js}', {
  cwd: process.cwd(),
  dot: true,
});

// 流式匹配(适合大量文件)
const stream = glob.stream('src/**/*.ts');
stream.on('data', (filePath) => {
  console.log('匹配到文件:', filePath);
});
常见通配符规则
通配符 含义 示例 匹配结果
* 匹配当前目录下的任意字符(不含子目录) src/*.ts src/index.tssrc/utils.ts
** 匹配任意层级的目录(递归) src/**/*.ts src/index.tssrc/a/b/utils.ts
? 匹配单个字符 src/file?.ts src/file1.tssrc/file2.ts
[] 匹配括号内的任意一个字符 src/[ab].ts src/a.tssrc/b.ts
! 排除匹配的文件 src/**/*.ts + !src/test.ts 所有 .ts 文件,排除 src/test.ts

模板生成

handlebars(生成模板文件)

handlebars 是一款逻辑少、轻量型的模板引擎,核心用途是「将数据与模板结合,动态生成文本内容」。

安装
pnpm add handlebars
pnpm add @types/handlebars -D
基础用法
import handlebars from 'handlebars';

// 1. 定义模板
const template = `{
  "name": "{{ projectName }}",
  "version": "{{ version }}",
  "description": "{{ description }}"
}`;

// 2. 编译模板
const compiledTemplate = handlebars.compile(template);

// 3. 传入数据渲染
const data = {
  projectName: 'my-ts-pkg',
  version: '1.0.0',
  description: '基于 TS + 双模式的 npm 包',
};

const result = compiledTemplate(data);
console.log(result);
核心语法
  • 变量占位符{{ 变量名 }}(支持嵌套对象)
  • 条件判断{{#if 条件}}...{{else}}...{{/if}}
  • 循环遍历{{#each 数组}}...{{/each}}
  • 注释{{! 注释内容 }}
高级用法
// 注册辅助函数
handlebars.registerHelper('uppercase', (str) => {
  return str.toUpperCase();
});

// 使用辅助函数
const template = `{{ uppercase name }}`;

完整示例

结合所有工具,实现一个完整的 CLI 工具:

import { program } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import ora from 'ora';
import fs from 'fs-extra';
import glob from 'glob';
import handlebars from 'handlebars';
import path from 'path';
import boxen from 'boxen';

program.version('1.0.0', '-v, --version').description('CLI 工具示例');

program
  .command('init [projectName]')
  .description('初始化项目')
  .option('-t, --template <template>', '模板类型', 'default')
  .option('-y, --yes', '跳过交互式询问', false)
  .action(async (projectName, options) => {
    console.log(chalk.blue('📦 开始初始化项目...'));

    let answers: any = {};

    // 如果提供了项目名称且使用了 --yes,跳过交互
    if (projectName && options.yes) {
      answers = {
        projectName,
        framework: 'react-ts',
        modules: ['路由', 'ESLint/Prettier'],
        initGit: true,
      };
    } else {
      // 交互式询问
      answers = await inquirer.prompt([
        {
          type: 'input',
          name: 'projectName',
          message: '项目名称',
          default: projectName || 'my-project',
          validate: (value) => {
            if (!value.trim()) return '项目名称不能为空!';
            if (!/^[a-z0-9-]+$/.test(value)) {
              return '项目名称只能包含小写字母、数字和连字符!';
            }
            return true;
          },
        },
        {
          type: 'list',
          name: 'framework',
          message: '选择框架',
          choices: [
            { name: 'React + TypeScript', value: 'react-ts' },
            { name: 'Vue + TypeScript', value: 'vue-ts' },
            { name: 'Vanilla JS', value: 'vanilla' },
          ],
          default: 'react-ts',
        },
        {
          type: 'checkbox',
          name: 'modules',
          message: '选择功能模块',
          choices: ['路由', '状态管理', 'UI 组件库', 'ESLint/Prettier'],
          default: ['路由', 'ESLint/Prettier'],
        },
        {
          type: 'confirm',
          name: 'initGit',
          message: '是否初始化 Git 仓库?',
          default: true,
        },
      ]);
    }

    const spinner = ora(chalk.blue('正在生成项目文件...')).start();

    try {
      // 1. 检查目录是否存在
      const targetDir = path.resolve(process.cwd(), answers.projectName);
      if (await fs.pathExists(targetDir)) {
        spinner.fail(chalk.red(`目录 ${answers.projectName} 已存在!`));
        process.exit(1);
      }

      // 2. 创建项目目录
      await fs.ensureDir(targetDir);

      // 3. 读取模板文件
      const templateDir = path.resolve(__dirname, '../templates', options.template);
      const templateFiles = await glob.promise('**/*.hbs', {
        cwd: templateDir,
        dot: true,
      });

      // 4. 渲染模板并写入文件
      for (const templateFile of templateFiles) {
        const templatePath = path.resolve(templateDir, templateFile);
        const templateContent = await fs.readFile(templatePath, 'utf8');

        const compiled = handlebars.compile(templateContent);
        const renderedContent = compiled(answers);

        const targetFile = templateFile.replace(/\.hbs$/, '');
        const targetPath = path.resolve(targetDir, targetFile);

        await fs.ensureDir(path.dirname(targetPath));
        await fs.writeFile(targetPath, renderedContent, 'utf8');
      }

      // 5. 初始化 Git(如果选择)
      if (answers.initGit) {
        spinner.text = '正在初始化 Git 仓库...';
        // 这里可以调用 git 命令
      }

      spinner.succeed(chalk.green('项目初始化完成!'));

      // 6. 显示成功信息
      const successMessage = boxen(
        chalk.green(`✅ 项目 ${answers.projectName} 创建成功!\n\n`) +
          chalk.cyan(`cd ${answers.projectName}\n`) +
          chalk.cyan('npm install\n') +
          chalk.cyan('npm run dev'),
        {
          padding: 1,
          margin: 1,
          borderStyle: 'round',
          borderColor: 'green',
        },
      );

      console.log(successMessage);
    } catch (error) {
      spinner.fail(chalk.red(`初始化失败:${(error as Error).message}`));
      process.exit(1);
    }
  });

program
  .command('build')
  .description('构建项目')
  .option('-e, --env <env>', '构建环境', 'production')
  .option('-o, --outDir <dir>', '输出目录', 'dist')
  .action(async (options) => {
    const spinner = ora(chalk.blue('正在构建项目...')).start();

    try {
      // 模拟构建过程
      await new Promise((resolve) => setTimeout(resolve, 2000));

      spinner.succeed(chalk.green(`构建完成!输出目录:${options.outDir}`));
    } catch (error) {
      spinner.fail(chalk.red(`构建失败:${(error as Error).message}`));
      process.exit(1);
    }
  });

program.parse(process.argv);

最佳实践

  1. 错误处理:使用 try-catch 捕获错误,提供友好的错误提示
  2. 用户体验:使用 ora 显示加载状态,使用 chalk 美化输出
  3. 参数验证:在 inquirer 中使用 validate 验证用户输入
  4. 文件操作:使用 fs-extra 的 Promise API,避免回调地狱
  5. 模板管理:将模板文件放在独立目录,使用 glob 批量处理
  6. 命令结构:使用 commander 组织命令,保持清晰的命令层次
  7. 帮助信息:为每个命令添加清晰的描述和示例

常见问题

命令行解析相关问题

Q: commander 和 yargs 如何选择?

A:

  • commander:API 友好,适合大多数场景(推荐)
  • yargs:需要位置参数或复杂参数解析时使用

Q: 如何获取未定义的选项?

A:

// commander
program.parse();
const unknownOptions = program.opts();

// yargs
const argv = yargs.parse();
const unknown = argv._; // 未定义的参数

交互式输入相关问题

Q: inquirer 和 prompts 如何选择?

A:

  • inquirer:功能全面,生态丰富(推荐)
  • prompts:轻量级,简单场景使用

Q: 如何中断交互式输入?

A:

// inquirer 会自动处理 Ctrl+C
// prompts 需要手动处理
const response = await prompts({
  type: 'text',
  name: 'value',
  message: '输入值',
  onCancel: () => {
    console.log('已取消');
    process.exit(0);
  },
});

终端美化相关问题

Q: chalk 和 picocolors 如何选择?

A:

  • chalk:功能丰富,适合大多数场景(推荐)
  • picocolors:对体积敏感的项目使用

Q: 如何检测终端是否支持颜色?

A:

import chalk from 'chalk';

// chalk 会自动检测,不支持时自动禁用颜色
// 手动检测
const supportsColor = chalk.supportsColor;

文件操作相关问题

Q: fs-extra 和原生 fs 的区别?

A:

  • fs-extra:Promise API、递归操作、JSON 操作更便捷
  • 原生 fs:需要手动封装 Promise、手动递归

Q: glob 如何排除多个文件?

A:

const files = await glob.promise('src/**/*.ts', {
  ignore: ['src/test/**/*', 'src/**/*.test.ts'],
});

模板生成相关问题

Q: handlebars 和其他模板引擎的区别?

A:

  • handlebars:逻辑少、轻量级(推荐)
  • ejs:支持 JavaScript 代码,功能强大但体积大
  • mustache:无逻辑模板,但功能较少

Q: 如何在模板中使用辅助函数?

A:

handlebars.registerHelper('eq', (a, b) => a === b);

// 模板中使用
// {{#if (eq value "test")}}...{{/if}}

参考资源

被国外IP持续DDOS怎么办?

logo.png

最近持续被东南亚的几个IP持续攻击,我用的是阿里云的免费应用防火墙,一直没什么好办法。今天看到了几个免费的IP库,一下子就想到了如果我直接屏蔽这些来源国家的访问不就好了。

在网站运营、网络安全或流量管控场景中,按国家/地区限制IP访问(如仅允许国内IP访问、屏蔽特定国家IP)是常见需求。实现这一需求的核心步骤是:获取目标国家的IP段 → 将IP段配置到 Nginx 中生效。本文将详细讲解如何通过Node.js或Shell脚本获取国家IP段,并结合 Nginx 的geo模块完成配置。

一、选择 IP 数据源

获取国家 IP 段的前提是选择可靠的数据源,目前主流免费/商用数据源包括:

1. MaxMind GeoLite2

MaxMind 是全球知名的 IP 地理信息服务商,提供免费的 GeoLite2 数据库(需注册账号),包含 IPv4/IPv6 的国家 / 地区映射,数据精度高且更新及时(每月更新)。

2. apnic

apnic提供免费可直接获取的IP信息,直接下载就能获取最新的IP数据信息。

3. 其他

  • ip2region:国内开源的 IP 定位库,支持国家 / 城市级映射,数据文件小(约 10MB),查询速度快。
  • 17monip(纯真 IP):国内常用的 IP 库,需定期下载更新包。
  • IP2Location LITE:类似 GeoLite2 的免费数据库,提供 CSV/MMDB 格式。

本文以apnic为例讲解(兼容性和易用性最优)。

二、获取国家 IP 段的方法

方法 1:使用bash脚本获取并处理

这种方式适合linux系统下的简单使用,兼容centos、ubuntu等系统。

步骤一:获取数据

可以使用wget或者curl这种命令直接从远程下载脚本,脚本地址取最新数据http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest,脚本内容类似:

wget -c -O /tmp http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest

curl -C - -o /tmp http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest

步骤二:处理数据

官方提供的数据通常都是txt格式的,逐行进行排列的数据。我们要使用还需要对数据进行解析,把我们需要的数据单独获取出来。可以使用脚本的awk命令进行处理。

awk的命令格式:

awk [参数] [处理内容] [操作对象]

完整脚本

我们假设要把数据处理成“起始IP|数量/前缀长度|注册机构|国家代码”的格式,按照这个逻辑,再加上错误处理,我们输出以下脚本:

#!/bin/bash
set -eu  # 移除 pipefail,兼容低版本 Bash,保留关键错误检查

# 脚本配置
URL="http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest"
DOWNLOAD_DIR="/root/apnic"
DOWNLOAD_FILENAME="delegated-apnic-latest"
IPV4_OUTPUT_FILENAME="apnic_ipv4.txt"
IPV6_OUTPUT_FILENAME="apnic_ipv6.txt"

# 帮助信息
show_help() {
    echo "用法:$0 --download-dir <下载目录> [--temp-dir <临时目录>]"
    echo "选项:"
    echo "  --download-dir  可选,文件下载保存目录"
    echo "  -h/--help       显示帮助信息"
}

# 解析命令行参数
parse_args() {

    while [[ $# -gt 0 ]]; do
        case "$1" in
            --download-dir)
                DOWNLOAD_DIR="$2"
                shift 2
                ;;
            -h|--help)
                show_help
                exit 0
                ;;
            *)
                echo "错误:未知参数 $1"
                show_help
                exit 1
                ;;
        esac
    done
}

# 检查依赖工具(wget 或 curl)
check_dependencies() {
    if command -v wget &> /dev/null; then
        DOWNLOAD_TOOL="wget"
    elif command -v curl &> /dev/null; then
        DOWNLOAD_TOOL="curl"
    else
        echo "错误:未找到 wget 或 curl,请先安装其中一个工具"
        exit 1
    fi
}

# 下载文件 + 校验文件有效性
download_file() {
    local download_path="${DOWNLOAD_DIR}/${DOWNLOAD_FILENAME}"
    echo "=== 开始下载文件 ==="
    echo "URL: $URL"
    echo "保存路径: $download_path"

    # 检查下载目录是否存在
    if [[ ! -d "$DOWNLOAD_DIR" ]]; then
        mkdir -p "$DOWNLOAD_DIR" || {
            echo "错误:无法创建保持目录 $DOWNLOAD_DIR"
            exit 1
        }
    fi

    # 下载文件(支持断点续传)
    if [[ "$DOWNLOAD_TOOL" == "wget" ]]; then
        wget -c -O "$download_path" "$URL" || {
            echo "错误:wget 下载失败(可能是网络问题或服务器异常)"
            exit 1
        }
    else
        curl -C - -o "$download_path" "$URL" || {
            echo "错误:curl 下载失败(可能是网络问题或服务器异常)"
            exit 1
        }
    fi

    # 校验下载文件是否为空
    if [[ ! -s "$download_path" ]]; then
        echo "错误:下载的文件为空,请检查 URL 或网络连接"
        rm -f "$download_path"  # 删除空文件
        exit 1
    fi

    echo "=== 文件下载完成(大小:$(du -sh "$download_path" | awk '{print $1}'))==="
    echo "下载文件路径: $download_path"
    echo
    return 0
}

# 解析并筛选 IPv4/IPv6(容错增强)
parse_and_filter_ip() {
    local download_path="${DOWNLOAD_DIR}/${DOWNLOAD_FILENAME}"
    local ipv4_output="${DOWNLOAD_DIR}/${IPV4_OUTPUT_FILENAME}"
    local ipv6_output="${DOWNLOAD_DIR}/${IPV6_OUTPUT_FILENAME}"

    echo "=== 开始解析文件 ==="
    echo "源文件: $download_path"
    echo "解析目录: $DOWNLOAD_DIR"

    # 清空输出文件(避免残留旧数据)
    > "$ipv4_output"
    > "$ipv6_output"

    # 筛选规则:
    # 1. 跳过注释行(以 # 开头)和空行
    # 2. 第 3 列(type)为 ipv4/ipv6 的行
    # 3. 输出格式:起始IP|数量/前缀长度|注册机构|国家代码(字段 4|5|1|2)
    # 用 awk 处理,即使部分行格式异常也不中断
    awk -F '|' '
        !/^#/ && NF >= 6 {  # 只处理非注释行且字段数≥6的行
            if ($3 == "ipv4") {
                print $4 "|" $5 "|" $1 "|" $2 >> "'"$ipv4_output"'"
            } else if ($3 == "ipv6") {
                print $4 "|" $5 "|" $1 "|" $2 >> "'"$ipv6_output"'"
            }
        }
    ' "$download_path" || {
        echo "警告:部分行格式异常,已跳过"
    }

    # 检查筛选结果(允许其中一种IP类型为空,避免误判)
    if [[ -s "$ipv4_output" ]]; then
        echo "IPv4 筛选完成,记录数:$(wc -l < "$ipv4_output") 行"
    else
        echo "警告:未筛选到 IPv4 数据(可能文件格式变更或无相关记录)"
    fi

    if [[ -s "$ipv6_output" ]]; then
        echo "IPv6 筛选完成,记录数:$(wc -l < "$ipv6_output") 行"
    else
        echo "警告:未筛选到 IPv6 数据(可能文件格式变更或无相关记录)"
    fi

    echo "=== 解析筛选完成 ==="
    echo "IPv4 文件路径: $ipv4_output"
    echo "IPv6 文件路径: $ipv6_output"
    echo
    return 0
}

# 主流程
main() {
    parse_args "$@"
    check_dependencies
    download_file
    parse_and_filter_ip

    echo "=== 任务全部完成 ==="
}

# 启动脚本
main "$@"

脚本首先设置默认变量,也就是默认的存储位置是/root/apnic,然后再把最新数据下载到文件里,通过命令筛选我们需要的数据分别存到对应的文件中。

方法 2:Node.js处理数据

使用nodejs整体流程设计上就不需要临时存储文件了,我们直接每次获取之后在内存中就全部处理完成。整体处理如下:

const axios = require('axios');
// 要允许通过的国家
const ALLowList = ['CN', 'HK', 'MO', 'TW'];

// 获取远程数据
async function getApnicTxt() {
    const res = await axios.get('http://ftp.apnic.net/apnic/stats/apnic/delegated-apnic-latest');
    return res.data;
}

// apnic|BD|ipv4|103.132.248.0|1024|20190116|allocated
// apnic|ID|ipv6|2001:df0:f180::|48|20190718|assigned
function createIpRecord(line, dot = '24') {
    const parts = line.split('|');
    if (ALLowList.includes(parts[1])) {
        return `allow ${parts[3]}/${dot}; #${parts[1]}`;
    }
    return `deny ${parts[3]}/${dot}; #${parts[1]}`;
}

async function main() {
    const txts = await getApnicTxt();
    const list = txts.split('\n');
    const ipv4list = [];
    const ipv6list = [];
    for (let index = 0; index < list.length; index++) {
        const txt = list[index];
        if (txt.includes('|ipv4|')) {
            ipv4list.push(createIpRecord(txt));
        }
        if (txt.includes('|ipv6|')) {
            ipv6list.push(createIpRecord(txt, '32'));
        }
    }
    console.log('ipv4list', ipv4list);
    console.log('ipv4list', ipv6list);
}

main();

脚本中同样是先获取最新的数据,然后直接交给处理函数进行分批处理。这里我们假设处理的结果是给nginx使用的,所以我们直接在结构上增加allowdeny前缀,方便在nginx中直接使用。

三、配置nginx文件

方法1:使用allow进行管理

这种配置方式兼容新老版本的nginx,只需要在nginx中提前引入配置文件,每次更新配置文件内容之后重启nginx就能达到自动允许和拒绝来源国家的访问地址。

server {
    listen 80;
    listen [::]:80;
    server_name your-domain.com;

    # 引入允许的IP白名单
    include /etc/nginx/conf.d/china-ipv4.conf;
    include /etc/nginx/conf.d/china-ipv6.conf;

    # 可选:放行本地回环(避免自己被拦)
    allow 127.0.0.1;
    allow ::1;

    # 拒绝所有未匹配的请求
    deny all;

    location / {
        root /var/www/html;
        index index.html;
        # 你的其他配置...
    }
}

通过以上配置就可以达到只允许我们需要的访问,其他国家访问一律禁止。

方法2:使用geo模块

Nginx 通过geo模块(默认编译)实现 IP 段的快速匹配,该模块将 IP 段加载到内存中,查询效率极高(不受 IP 段数量影响)。

http {
    # 定义IPv4的国家匹配变量($is_cn_v4:1=中国IP,0=非中国IP)
    geo $is_cn_v4 {
        default 0;
        include /etc/nginx/conf.d/cn-ipv4.conf;  # 引入中国IPv4段文件
    }

    # 定义IPv6的国家匹配变量(如需支持IPv6)
    geo $is_cn_v6 {
        default 0;
        include /etc/nginx/conf.d/cn-ipv6.conf;  # 引入中国IPv6段文件
    }

    # 合并IPv4/IPv6的匹配结果
    map $scheme $is_cn {
        http $is_cn_v4;
        https $is_cn_v4;
        httpv6 $is_cn_v6;
        httpsv6 $is_cn_v6;
    }

    server {
      listen 80;
      listen [::]:80;
      server_name yourdomain.com;

      # 非中国IP返回403
      if ($is_cn != 1) {
          return 403;
      }

      # 正常业务配置...
      location / {
          root /var/www/html;
          index index.html;
      }
  }
}

通过使用nginx的geo模块可以非常快速的识别到ip对应的国家,然后在具体的路由中通过自动一判断来返回自己想要返回的内容。同样的,配置文件更新之后要重新启动nginx服务。

其他命令

检查nginx配置文件是否正确。

nginx -t

重新启动nginx。

systemctl restart nginx

严重IP匹配的效果。

curl -H "X-Forwarded-For: 8.8.8.8" http://yourdomain.com  # 8.8.8.8为美国IP

四、注意事项

在具体的使用过程中还有一些是需要特别注意的:

  1. geo和allow的配置文件格式是不一样的。
  2. 如果使用云服务做转发,来源ip是云服务的地址,需要额外增加IP透传的功能。
  3. 远程地址的更新没那么快,可以设置每隔一周来定时更新一次。持续访问可能会被官方封禁哦。

深入 Nestjs 底层概念(1):依赖注入和面向切面编程 AOP

前言

本文保证绝对大白话!简单易懂!

最近跟一个好友讨论,他说想学习 node.js 后端框架 nest.js,但对于 nest.js 的下面用法一头雾水,为什么要这么用呢?什么 Contoller, Provider,怎么跟一般的 javascript 代码的用法完全不一样呢?如下的 @Controller,@Injectable 你理解是什么意思吗?为什么要用这样的方式组织代码呢?

import { Controller, Get } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { Cat } from './interfaces/cat.interface';


@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}

@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];

  create(cat: Cat) {
    this.cats.push(cat);
  }

  findAll(): Cat[] {
    return this.cats;
  }
}

基于此,我写了这篇文章,帮助大家从 0 开始,层层递进到所有这些让你陌生的 nest.js 的概念!

从编程范式说起

编程范式在之前面试经常被问到了,什么 FP(函数式编程),RP(响应式编程),FRP(函数式响应式编程),还有我们熟知的大兄弟 面相对象编程,现在 nest.js 又来个 AOP(面相切面编程),属实让人蒙圈了。。。。太多概念了。

别急,我们一个一个来!

函数式编程 - FP

简单来说就是要求你使用纯函数,什么是纯函数,就是输入一定的时候输出一定,就是传入相同的参数得到相同的结果。就这么简单,例如

function add(a, b) {
    sum = a + b;
    return sum;
}

常见有几种情况会影响纯函数的纯净,最常见就是:

  • 修改传参的值,或者引用,或者全局状态,改 DOM,例如
function add(a, b, c) { 
     // 假设 c 传入一个对象,c 的 xx 属性被修改
     c.xx = a + b; // 修改传参的值
     window.name = a; // 修改全局变量
    sum = a + b;
    return sum;
}
  • 做一些 i/o 操作,例如 console.log, 写文件。好了接下里说说 RP(响应式编程)

响应式编程 - RP

也能很好理解,我们举个例子:

就像“Excel 表格”:
你改了 A1,B1 = A1 + 10 会自动更新,这就是典型的 RP。

在 vue 和 react 都非常常见,跟踪一个值变化,然后引发另一些值变化。

它强调:

  • 数据是“流”(stream)
  • 数据变化会自动传播到依赖它的逻辑(自动更新)
  • 你不需要手动触发

函数式响应式编程 - FRP

FRP 是“函数式编程 + 响应式编程”的结合。比较典型的就是 Rxjs 的风格:

// 监听 DOM 的 input 事件
const input$ = fromEvent(searchInput, 'input'); // 输入是一个“流”

const results$ = input$.pipe(
  map(event => event.target.value),  // 函数式转换
);

results$.subscribe(renderResults);

简单理解就是当触发 DOMinput 事件触发的时候,也就是一个值改变的时候,触发 results$ 值的改变,这就是 RP(响应式编程),然后在 input 事件触发值改变的过程中,还执行了 map 方法,这是一个纯函数,所以也算 FP 函数式编程。

OOP - 面向对象编程

顾名思义,面相对象就是以对象为对象为单位,例如

class Dog {
  name: string;

  constructor(name: string) {
    this.name = name;
  }

  bark() {
    console.log(`${this.name} is barking!`);
  }
}

使用到的数据,最好都封装为对象,如上面,使用的时候需要 new Dog() 返回我们新创造出来的实例,也就是 class 仅仅是个模板。对象是由模板创造出来的。

然后就是面向对象的特点,也就是最熟悉的三件套,封装,继承,多态。靠这三个,面向对象编程的思维模式认为可以模拟现实世界。

封装:很简单,例如 Dog 这个类,把跟 Dog 相关的属性和方法都放在一个类里就是封装,例如有 name 属性,狗的名字,你如果想加其它参数,继续拓展就行了,例如性别,品种。当然封装还有一些细节,例如封装私有属性什么的,这些细枝末节不用太在意。我们主要是简单介绍 OOP 的思想。

继承:可以将别的类的东西继承过来,例如, 很多动物都可以继承Animal 这个类:

class Animal {
  constructor(public name: string) {}

  makeSound() {
    console.log("Some sound...");
  }
}

class Cat extends Animal {
  makeSound() {
    console.log(`${this.name} says meow~`);
  }
}

class Dog extends Animal {
  makeSound() {
    console.log(`${this.name} says woof!`);
  }
}

const cat = new Cat("Mimi");
const dog = new Dog("Coco");

cat.makeSound(); // Mimi says meow~
dog.makeSound(); // Coco says woof!

主要是为我们抽象代码用的,例如公共部分放到一个类中,子类单独实现。思想是挺好的,就是实现起来有时候会比较麻烦,因为刚开始设计的时候,不太容易一开始就能预知未来需要抽象哪些。

多态:简单来说就是一个接口,不同实现,Javascript 中没有多态,更多在 typescript 中出现,例如用重载实现多态。如下:

class Calculator {
  add(a: number, b: number): number;
  add(a: string, b: string): string;
  add(a: any, b: any): any {
    return a + b;
  }
}

const calc = new Calculator();

console.log(calc.add(5, 10));       // 15
console.log(calc.add("foo", "bar")) // foobar

面相切面编程 - AOP

为我们提供了一种将代码注入现有函数或对象的方法,而无需修改目标逻辑。这句话大家看看就行了,很官方,我们简单理解就是:

把横跨多个功能的“通用逻辑”抽出来,统一管理,而不是在每个函数里重复写。

常见实现的方式,就是在函数前后,注册一个钩子,这样达到不修改当前函数逻辑的目的。例如:

function logBefore(fn) {
  return function (...args) {
    console.log("Calling:", fn.name, "args:", args);
    return fn.apply(this, args);
  }
}

以上方法是一个 切面 ,也就是可以包装到另一个函数上,在这个函数调用之前打印日志,是一个独立的功能。例如:

const getUser = logBefore(function getUser(id) {
  // 在数据库搜索数据返回
  return db.query(`SELECT * FROM users WHERE id = ${id}`);
});

这样其它函数都可以共享这个 logBefore 的逻辑。

当然实际场景,例如鉴权,在调用一个方法前,看看有没有权限,就是一个很好的使用 AOP 的场景。

AOP 跟 nest.js 关系

nest.js 后面我们会讲一个很关键的概念,pipeline,我们这里先简单介绍一下,后面详细说。pipeline 就是当客户端收到请求到返回的过程。

首先 nest.js 需要注册一个 Controller 来处理请求,如下

import { Controller, Get } from '@nestjs/common';

@Controller('cats')
export class CatsController {
  @Get()
  findAll(): string {
    return 'This action returns all cats';
  }
}

一般情况下,get 方法访问路径 /cats 的时候,返回 'This action returns all cats'。

但实际上到 Controller 处理数据之前,还有许多步骤。

例如在 nest.js 中有 Middleware(中间件) 的概念,也有 Guard(守卫的概念) 等等概念,我们这里不细说,后面文章会有,这里只需要记住:请求来了之后,先到 Middleware 然后到 Guard。。。兜兜转转好几回才能到 Controller

之前我们提到,AOP 中的面向切面,一个 切面 ,也就是可以包装到另一个函数上,在这个函数调用之前打印日志,是一个独立的功能。

nest.js 中的 Middleware 可以打印日志,是不是在不直接影响 Controller 逻辑的前提下,实现了一个可以复用的独立的功能,因为所有路由接收的请求都会经过 Middleware

从这个意义上说 MiddlewareGuard 这些概念本质就是 “切面”。

如果你对node.js ,组件库,前端动画感兴趣,欢迎进群交流。这是我组件库教程官网。感谢star,再次感谢:

依赖注入和控制反转

这两个概念看起来挺唬人的,比较高大上。我们简单解释一下:

我们从一个简单例子说起:

假设有一个类叫老王,专指那些住在美女隔壁的靓仔们。他们喜欢助人为乐,送大帽子。能力上擅长逃跑。

class Hobby {
    constructor(gender){
        return [gender, '助人为乐']
    }
}
class Skill {
    constructor(){
        return ['送帽子', '跑路']
    }
}
class Person {
    hobby: Hobby
    skill: Skill
    constructor(){
        this.hobby = new Hobby('女');
        this.skill = new Skill();
    }
}
console.log(new Person()); // { hobby: ['女', '助人为乐'], skill: ['送帽子', '跑路'] }

好了,这个Person类,我们看看有啥缺点:

  • 每次创建Person类的实例,都要传入Hobby类和Skill类,也就是对这两个类都产生了依赖,假如有一天我们想创建不同的老王,比如有的老王喜欢小鲜肉,有的老王喜欢老腊肉,这个Person类写死了,没法定制

有同学马上就会说,这个简单啊,Hobby和Skill当做参数传入不就行了,是的,这个方法确实能解决问题,如下:

class Hobby {
    constructor(gender){
        return [gender, '助人为乐']
    }
}
class Skill {
    constructor(){
        return ['送帽子', '跑路']
    }
}
class Person {
    hobby: Hobby
    skill: Skill
    constructor(hobby, skill){
        this.hobby = hobby;
        this.skill = skill;
    }
}

// { hobby: [’男', '助人为乐'], skill: ['送帽子', '跑路'] }
console.log(new Person(new Hobby('男'), new Skill()); 
  • 但有没有更好的办法,也就是这个类我们不用自己去new,像下面这样,系统帮我们自动导入呢?

class Person {
    constructor(hobby: Hobby, skill: Skill){
  
    }
    hello(){
        这里直接就可以用this.hobby了
    }
}

也就是说,在你new Person的时候,hobby和skill参数,自动帮你实例化导入,而且你还需要new Person,能不能不new Person 都是自动调用并把参数导入?

  • 这就引申出第一个概念就控制反转,以前我们都要自己主动去new,从而创建实例,现在是把创建实例的任务交给了一个容器(后面会实现,你就明白了,相当于一个掌控者,或者说造物主,专门来创建实例的,并管理依赖关系),所以控制权是不是就反转了,你主动的创建实例和控制依赖,反转为容器创建实例和控制依赖。
  • 对于控制反转,最常用件的方式叫依赖注入,意思是容器动态的将依赖关系注入到组件中。
  • 控制反转可以通过依赖注入实现,所以本质上是一回事。

到这里,其实大家也没觉得依赖注入有啥毛用吧!下面的讲解一言以蔽之就是,虽然我们js可以把函数或者类当参数传入另一个类里,这确实解决了之前讲的写死代码的问题,也就是说我们不用依赖注入也能解决代码耦合的问题,所以看起来我们并不是那么需要依赖注入。

小结

这里第一篇 nest.js 内容完毕,接下来会详细解释上文提到的 pipline,也就是一个请求经过 nest.js 的全过程,以及这些经过的过程中,碰到的核心概念!

涨见识了,Error.cause 让 JavaScript 错误调试更轻松

1. 前言

在 JavaScript 中,抛出错误很容易,但追溯原因却有些麻烦,这就是 cause属性的用武之地。

这是我们传统的处理错误的做法:

try {
  JSON.parse("{ bad json }");
} catch (err) {
  throw new Error("Something went wrong: " + err.message);
}

虽然包装了错误,但已经丢失了原始的堆栈信息和错误类型。

当问题发生时,你只能看到最顶层的错误信息,却不知道根本原因是什么。

你好,我是冴羽。前端资讯、前端干货,欢迎关注公众号:冴羽

2. 引入 Error.cause

ES2022 引入了 Error.cause 属性,可以保留原始错误信息:

try {
  try {
    JSON.parse("{ bad json }");
  } catch (err) {
    throw new Error("Something went wrong", { cause: err });
  }
} catch (err) {
  console.error(err.stack);
  console.error("Caused by:", err.cause.stack);
}

此时你可以看到完整的错误链:

Error: Something went wrong
    at ...
Caused by: SyntaxError: Unexpected token b in JSON at position 2
    at JSON.parse (<anonymous>)
    at ...

现在,你既保留了原始错误,又能提供清晰的顶层错误信息。

3. 实际应用示例

让我们看一个更实际的例子:

function fetchUserData() {
  try {
    JSON.parse("{ broken: true }"); // ← 这里会失败
  } catch (parseError) {
    throw new Error("Failed to fetch user data", { cause: parseError });
  }
}

try {
  fetchUserData();
} catch (err) {
  console.error(err.message); // "Failed to fetch user data"
  console.error(err.cause); // [SyntaxError: Unexpected token b in JSON]
  console.error(err.cause instanceof SyntaxError); // true
}

可以看到代码非常清晰直观。

而且 cause 属性被定义为不可枚举,因此它不会污染日志或 for...in 循环,除非你显式访问它。

4. 自定义错误类

你可以在自定义错误类中使用 cause 属性:

class DatabaseError extends Error {
  constructor(message, { cause } = {}) {
    super(message, { cause });
    this.name = "DatabaseError";
  }
}

如果你的运行环境是 ES2022+,这已经足够了:super(message, { cause }) 会自动处理一切。

对于 TypeScript 用户,确保 tsconfig.json 配置了:

{
  "compilerOptions": {
    "target": "es2022",
    "lib": ["es2022"]
  }
}

否则,在将 { cause } 传递给 Error 构造函数时可能会看到类型错误。

5. 更好的测试断言

假设你的服务抛出了一个 UserCreationError,这是由一个 ValidationError 引发的。

你可以这样写断言:

expect(err.cause).toBeInstanceOf(ValidationError);

这样测试会更清晰、更健壮。

6. 注意事项

默认情况下,console.error(err) 只会打印顶层错误。cause链不会自动显示,因此需要手动打印:

console.error(err);
console.error("Caused by:", err.cause);

尽管 cause 很好,但也不要滥用。每个小错误都包装可能更乱,因此只在真正需要上下文的时候使用。

7. 递归打印完整错误链

这是一个安全遍历错误链的工具函数:

function logErrorChain(err, level = 0) {
  if (!err) return;
  console.error(" ".repeat(level * 2) + `${err.name}: ${err.message}`);

  if (err.cause instanceof Error) {
    logErrorChain(err.cause, level + 1);
  } else if (err.cause) {
    console.error(" ".repeat((level + 1) * 2) + String(err.cause));
  }
}

如果需要完整堆栈信息:

function logFullErrorChain(err) {
  let current = err;
  while (current) {
    console.error(current.stack);
    current = current.cause instanceof Error ? current.cause : null;
  }
}

对于结构复杂、可能在不同层级出现多种故障的系统来说,这非常有用。

8. 跨层错误链示例

假设调用流程如下:

  1. 数据库连接失败,抛出 ConnectionTimeoutError
  2. 捕获后包装成 DatabaseError
  3. 再次捕获并包装成 ServiceUnavailableError
class ConnectionTimeoutError extends Error {}
class DatabaseError extends Error {}
class ServiceUnavailableError extends Error {}

try {
  try {
    try {
      throw new ConnectionTimeoutError("DB connection timed out");
    } catch (networkErr) {
      throw new DatabaseError("Failed to connect to database", { cause: networkErr });
    }
  } catch (dbErr) {
    throw new ServiceUnavailableError("Unable to save user data", { cause: dbErr });
  }
} catch (finalErr) {
  logErrorChain(finalErr);
}

控制台输出:

ServiceUnavailableError: Unable to save user data
  DatabaseError: Failed to connect to database
    ConnectionTimeoutError: DB connection timed out

可以看到,错误链提供了一个清晰的视图,告诉你发生了什么以及在哪里发生的。

9. 支持度

.cause 参数在所有现代环境中都支持:

  • ✅ Chrome 93+、Firefox 91+、Safari 15+、Edge 93+
  • ✅ Node.js 16.9+
  • ✅ Bun 和 Deno(当前版本)

需要注意的是,开发者工具可能不会自动显示 cause。

所以需要显式记录它(console.error('Caused by:', err.cause))。

还要注意:如果使用 Babel 或 TypeScript 进行转译,此功能不会被 polyfill。

10. 异步操作中的错误处理

Error.cause 同样适用于异步操作。结合 async/await 可以这样使用:

async function fetchData() {
  try {
    const response = await fetch("/api/data");
    if (!response.ok) {
      throw new Error(`HTTP error! Status: ${response.status}`);
    }
    return await response.json();
  } catch (error) {
    console.error("数据获取失败:", error);
    throw new Error("Failed to fetch data", { cause: error });
  }
}

这种方式让异步代码的错误处理逻辑看起来与同步代码无异,大大提升了可读性和可维护性。

11. 总结

总结一下,现代错误链处理的最佳实践

  • 使用 new Error(message, { cause }) 保留上下文
  • 适用于内置错误类和自定义错误类
  • 所有现代运行时环境都支持(浏览器、Node.js、Deno、Bun)
  • 可以改善日志、调试和测试断言
  • 注意 TypeScript:设置 "target": "es2022""lib": ["es2022"]
  • 注意记录 err.cause 或手动遍历错误链

从而实现更清晰的堆栈跟踪、更好的上下文、更愉快的调试体验。

Error.cause 就是你错误处理中缺少的那一环。

12. 参考链接

  1. allthingssmitty.com/2025/11/10/…
  2. developer.mozilla.org/zh-CN/docs/…
❌