阅读视图

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

每日一题-距离最小相等元素查询🟡

给你一个 环形 数组 nums 和一个数组 queries 。

对于每个查询 i ,你需要找到以下内容:

  • 数组 nums 中下标 queries[i] 处的元素与 任意 其他下标 j(满足 nums[j] == nums[queries[i]])之间的 最小 距离。如果不存在这样的下标 j,则该查询的结果为 -1

返回一个数组 answer,其大小与 queries 相同,其中 answer[i] 表示查询i的结果。

 

示例 1:

输入: nums = [1,3,1,4,1,3,2], queries = [0,3,5]

输出: [2,-1,3]

解释:

  • 查询 0:下标 queries[0] = 0 处的元素为 nums[0] = 1 。最近的相同值下标为 2,距离为 2。
  • 查询 1:下标 queries[1] = 3 处的元素为 nums[3] = 4 。不存在其他包含值 4 的下标,因此结果为 -1。
  • 查询 2:下标 queries[2] = 5 处的元素为 nums[5] = 3 。最近的相同值下标为 1,距离为 3(沿着循环路径:5 -> 6 -> 0 -> 1)。

示例 2:

输入: nums = [1,2,3,4], queries = [0,1,2,3]

输出: [-1,-1,-1,-1]

解释:

数组 nums 中的每个值都是唯一的,因此没有下标与查询的元素值相同。所有查询的结果均为 -1。

 

提示:

  • 1 <= queries.length <= nums.length <= 105
  • 1 <= nums[i] <= 106
  • 0 <= queries[i] < nums.length

整洁架构三连问:是什么,怎么做,为什么要用

整洁架构问答

Q1:什么是整洁架构?

由 Robert C. Martin(Uncle Bob)提出,核心思想:业务逻辑独立于框架、UI、数据库等外部细节,依赖关系只能从外层指向内层。

四层结构

┌─────────────────────────────────┐
   Frameworks & Drivers             Web数据库UI(最易变)
   ┌─────────────────────────┐   
     Interface Adapters           控制器网关Presenter
     ┌───────────────────┐     
       Application              用例(Use Cases)
       Business Rules        
       ┌─────────────┐      
        Enterprise           实体(Entities)最稳定
        Business          
        Rules             
       └─────────────┘      
     └───────────────────┘     
   └─────────────────────────┘   
└─────────────────────────────────┘
  1. 实体层:核心业务规则,零依赖
  2. 用例层:编排实体完成应用逻辑
  3. 接口适配层:数据转换,连接内外
  4. 框架与驱动层:具体技术实现

关键收益

  • 可测试:业务逻辑不依赖外部,独立单元测试
  • 可替换:换数据库/框架不影响核心逻辑
  • 可维护:修改外层不影响内层

一句话:让业务逻辑成为核心,让数据库、框架等成为可插拔的细节。


Q2:整洁架构和普通架构有什么区别?

普通架构按技术类型分层,整洁架构按依赖方向分层。

直观对比

普通架构(传统分层)
src/
├── components/    ← UI 组件
├── api/           ← 接口调用
├── store/         ← 状态管理
├── utils/         ← 工具函数
└── views/         ← 页面

依赖方向:随意交叉

组件 ←→ store ←→ api
 ↕        ↕       ↕
utils ←─ views ←─ components
整洁架构
src/
├── domain/           ← 纯业务(零依赖)
├── application/      ← 用例编排
├── infrastructure/   ← 技术实现
└── ui/               ← 界面渲染

依赖方向:只从外向内

ui → application → domain
infrastructure → application → domain
         ↘          ↓          ↗
          任何外层都可以依赖内层
          内层永远不知道外层存在

具体差异对比

维度 普通架构 整洁架构
分层依据 按技术类型(组件、API、工具) 按依赖方向(外层依赖内层)
业务逻辑位置 散落在组件、store、utils 集中在 domain 层
框架耦合 业务逻辑依赖 Vue/React domain 层零框架依赖
改数据库 改 api/ + store/ + 组件 只改 infrastructure/
换框架 几乎重写 只重写 ui/ 层
单元测试 需要 mock 框架、mount 组件 直接测纯 TS 函数
API 字段变了 改多处 只改 Repository 转换逻辑

代码对比:同一个需求

普通架构写法
// store/cart.ts
import axios from 'axios'

export const useCartStore = defineStore('cart', () => {
  const items = ref([])

  async function addItem(productId: string) {
    // 业务规则散落在 store 里
    if (items.value.length >= 20) {
      throw new Error('购物车已满')
    }
    // 直接耦合 axios
    const res = await axios.post('/api/cart', { productId })
    // 直接操作原始 JSON
    items.value.push(res.data)
  }

  return { items, addItem }
})
<!-- CartPage.vue -->
<script setup>
import { useCartStore } from '@/store/cart'

const cartStore = useCartStore()

// 组件里也有业务逻辑
const canAdd = computed(() => cartStore.items.length < 20)
</script>

问题:业务规则在 store 和组件里都有,换框架全废,换 axios 要改 store。

整洁架构写法
// domain/aggregates/Cart.ts — 纯 TS,零依赖
export class Cart {
  private items: CartItem[] = []

  addItem(item: CartItem): void { /* 规则在这里 */ }
  canAddMore(): boolean { return this.items.length < 20 }
}
// application/usecases/AddToCartUseCase.ts — 编排
export class AddToCartUseCase {
  constructor(private repo: CartRepository) {}
  async execute(id: string) {
    const cart = await this.repo.getCart()
    cart.addItem(item)           // 调领域方法
    await this.repo.save(cart)   // 持久化
  }
}
// infrastructure/repositories/ApiCartRepository.ts — 适配
export class ApiCartRepository implements CartRepository {
  async getCart() { /* axios → 领域对象 */ }
  async save(cart) { /* 领域对象 → axios */ }
}
<!-- ui/pages/CartPage.vue — 只渲染 -->
<script setup>
const { addItem } = useCart()  // composable 调用用例
</script>

好处:业务规则只在 Cart.ts,换框架/换 axios 都不影响它。

什么时候用哪个

场景 建议
简单 CRUD、几个页面 普通架构够用,别过度设计
中等复杂度、有业务规则 提取 domain 层,用 composable 做应用层
复杂业务(交易、审批、计费) 完整整洁架构 + DDD

一句话总结:普通架构按技术分文件夹,整洁架构按依赖方向分层。前者简单但业务逻辑散落各处,后者前期成本高但业务逻辑内聚、可替换、可测试。


Q3:目前 Vue CLI 是不是都是整洁架构?

不是。 Vue CLI 生成的项目默认不符合整洁架构

Vue CLI 默认结构

src/
├── components/    ← 组件(UI + 业务逻辑混在一起)
├── views/         ← 页面(UI + 业务逻辑混在一起)
├── router/        ← 路由
├── store/         ← Vuex/Pinia(状态管理)
├── api/           ← API 调用
├── utils/         ← 工具函数
└── App.vue

为什么不是整洁架构

整洁架构要求 Vue CLI 默认 问题
依赖从外指向内 各层互相 import,方向混乱 无依赖规则约束
领域层纯 TS,零框架依赖 业务逻辑写在 .vue 领域层耦合了 Vue
用例层编排领域对象 没有 use case 层 业务逻辑散落在组件和 store
Repository 接口在领域层定义 直接在组件里调 axios 数据获取和 UI 耦合

典型 Vue 组件的问题

<script setup>
// ❌ UI、业务规则、API 调用全混在一起
import axios from 'axios'

const cart = ref([])

async function addItem(id) {
  if (cart.value.length >= 20) {       // 业务规则在组件里
    alert('购物车已满')
    return
  }
  const res = await axios.post('/api/cart', { id })  // 直接调 API
  cart.value.push(res.data)
}
</script>

Vue CLI 能改成整洁架构吗

能,但需要手动改造,Vue CLI 不会自动帮你分层:

src/
├── domain/           ← 手动添加:纯 TS 领域层
│   ├── value-objects/
│   ├── entities/
│   ├── aggregates/
│   └── repositories/    ← 接口定义
├── application/      ← 手动添加:用例层
│   ├── usecases/
│   └── composables/
├── infrastructure/   ← 手动添加:适配层
│   ├── repositories/    ← Repository 实现
│   └── api/
├── ui/               ← 原 components/views,只负责渲染
│   ├── components/
│   └── pages/
├── router/
└── App.vue

一句话总结

Vue CLI 是脚手架工具,只管项目初始化和构建配置,不管架构分层。默认生成的项目是传统分层(按技术类型分),不是整洁架构(按依赖方向分)。整洁架构需要开发者自己设计和实施。

前端整洁架构详解

前端整洁架构详解

以电商购物车系统为例,逐层讲解整洁架构在前端中的落地方式。


架构分层总览

┌──────────────────────────────────┐
│  UI 框架层(Vue/React 组件)      │  ← 最外层:页面、组件
│  ┌────────────────────────────┐  │
│  │  接口适配层                 │  │  ← Store/Composable、API 适配、Repository 实现
│  │  ┌────────────────────┐    │  │
│  │  │  应用层             │    │  │  ← 用例:编排领域逻辑
│  │  │  ┌────────────┐    │    │  │
│  │  │  │  领域层     │    │    │  │  ← 纯 JS/TS:实体、值对象、规则
│  │  │  └────────────┘    │    │  │
│  │  └────────────────────┘    │  │
│  └────────────────────────────┘  │
└──────────────────────────────────┘

依赖规则:依赖关系只能从外层指向内层,内层完全不知道外层的存在。


1. 领域层(纯 JS/TS,零依赖)

最内层,不引入任何框架,不 import Vue/React,不 import axios,纯业务逻辑。

1.1 值对象 — 封装校验和不可变概念

// domain/value-objects/Money.ts
export class Money {
  constructor(private amount: number, private currency: string = 'CNY') {
    if (amount < 0) throw new Error('金额不能为负数');
  }

  add(other: Money): Money {
    if (this.currency !== other.currency) throw new Error('币种不同');
    return new Money(this.amount + other.amount, this.currency);
  }

  multiply(factor: number): Money {
    return new Money(this.amount * factor, this.currency);
  }

  get value(): number { return this.amount; }
  toString(): string { return `${this.currency} ${this.amount.toFixed(2)}`; }
}
// domain/value-objects/Email.ts
export class Email {
  constructor(private value: string) {
    if (!/^[^\s@]+@[^\s@]+.[^\s@]+$/.test(value)) {
      throw new Error('邮箱格式不合法');
    }
  }

  get domain(): string { return this.value.split('@')[1]; }
  toString(): string { return this.value; }
}

为什么用值对象? 校验逻辑内聚,不会散落在各个组件里。任何地方拿到一个 Money,就保证它是合法的。

1.2 实体 — 有标识,有生命周期

// domain/entities/CartItem.ts
import { Money } from '../value-objects/Money';

export class CartItem {
  constructor(
    public readonly productId: string,  // 唯一标识
    public readonly name: string,
    private unitPrice: Money,
    private quantity: number,
  ) {
    if (quantity <= 0) throw new Error('数量必须大于0');
  }

  get totalPrice(): Money {
    return this.unitPrice.multiply(this.quantity);
  }

  changeQuantity(newQty: number): CartItem {
    return new CartItem(this.productId, this.name, this.unitPrice, newQty);
  }
}

1.3 聚合根 — 保证一致性边界

// domain/aggregates/Cart.ts
import { Money } from '../value-objects/Money';
import { CartItem } from '../entities/CartItem';

export class Cart {
  private items: CartItem[] = [];

  addItem(item: CartItem): void {
    const existing = this.items.find(i => i.productId === item.productId);
    if (existing) {
      // 已存在则合并数量
      this.items = this.items.map(i =>
        i.productId === item.productId
          ? i.changeQuantity(i.quantity + item.quantity)
          : i
      );
    } else {
      this.items.push(item);
    }
  }

  removeItem(productId: string): void {
    this.items = this.items.filter(i => i.productId !== productId);
  }

  get total(): Money {
    return this.items.reduce(
      (sum, item) => sum.add(item.totalPrice),
      new Money(0),
    );
  }

  get itemCount(): number {
    return this.items.length;
  }

  // 业务规则:购物车最多 20 件商品
  canAddMore(): boolean {
    return this.items.length < 20;
  }
}

关键点:所有业务规则都在这里——最多 20 件、合并同类商品、金额计算。组件不需要知道这些规则。

1.4 Repository 接口(领域层定义)

// domain/repositories/CartRepository.ts
import { Cart } from '../aggregates/Cart';

export interface CartRepository {
  getCart(): Promise<Cart>;
  save(cart: Cart): Promise<void>;
  clear(): Promise<void>;
}

接口在领域层定义,实现在适配层——这是依赖反转的核心,让领域层不依赖具体的数据获取方式。


2. 应用层(用例 — 编排领域对象)

这一层只做编排,不包含业务规则本身。它协调领域对象完成一个完整操作。

2.1 添加商品到购物车

// application/usecases/AddToCartUseCase.ts
import { Cart } from '../../domain/aggregates/Cart';
import { CartItem } from '../../domain/entities/CartItem';
import { Money } from '../../domain/value-objects/Money';
import { CartRepository } from '../../domain/repositories/CartRepository';

export class AddToCartUseCase {
  constructor(private cartRepo: CartRepository) {}

  async execute(productId: string, name: string, price: number, qty: number): Promise<Cart> {
    // 1. 从仓库获取当前购物车
    const cart = await this.cartRepo.getCart();

    // 2. 业务规则检查(规则在领域对象里,用例只调用)
    if (!cart.canAddMore()) {
      throw new Error('购物车已满,最多 20 件商品');
    }

    // 3. 创建领域对象
    const item = new CartItem(productId, name, new Money(price), qty);

    // 4. 执行领域操作
    cart.addItem(item);

    // 5. 持久化
    await this.cartRepo.save(cart);

    return cart;
  }
}

2.2 结算下单

// application/usecases/CheckoutUseCase.ts
import { CartRepository } from '../../domain/repositories/CartRepository';
import { OrderRepository } from '../../domain/repositories/OrderRepository';
import { Order } from '../../domain/aggregates/Order';

export class CheckoutUseCase {
  constructor(
    private cartRepo: CartRepository,
    private orderRepo: OrderRepository,
  ) {}

  async execute(userId: string): Promise<Order> {
    const cart = await this.cartRepo.getCart();

    if (cart.itemCount === 0) {
      throw new Error('购物车为空,无法下单');
    }

    // 领域逻辑:创建订单
    const order = Order.createFromCart(cart, userId);

    await this.orderRepo.save(order);
    await this.cartRepo.clear();

    return order;
  }
}

用例层的特点:读数据 → 调领域方法 → 存数据。像导演一样编排,但不自己写业务规则。


3. 接口适配层(Store/Composable + API 适配)

3.1 Repository 实现(JSON ↔ 领域对象转换)

// infrastructure/repositories/ApiCartRepository.ts
import { CartRepository } from '../../domain/repositories/CartRepository';
import { Cart } from '../../domain/aggregates/Cart';
import { CartItem } from '../../domain/entities/CartItem';
import { Money } from '../../domain/value-objects/Money';
import { cartApi } from '../api/CartApi';

export class ApiCartRepository implements CartRepository {
  async getCart(): Promise<Cart> {
    // API 返回的是原始 JSON,需要转换成领域对象
    const raw = await cartApi.fetchCart();
    const cart = new Cart();
    for (const item of raw.items) {
      cart.addItem(new CartItem(item.productId, item.name, new Money(item.price), item.qty));
    }
    return cart;
  }

  async save(cart: Cart): Promise<void> {
    // 领域对象 → 原始 JSON,给 API
    const payload = {
      items: cart.items.map(i => ({
        productId: i.productId,
        name: i.name,
        price: i.unitPrice.value,
        qty: i.quantity,
      })),
    };
    await cartApi.updateCart(payload);
  }

  async clear(): Promise<void> {
    await cartApi.clearCart();
  }
}

关键:API 返回的 JSON → 领域对象的转换在这里完成。领域层永远不碰原始 JSON。

3.2 API 层(最外层细节)

// infrastructure/api/CartApi.ts
import axios from 'axios';

export const cartApi = {
  fetchCart: () => axios.get('/api/cart').then(r => r.data),
  updateCart: (data: any) => axios.put('/api/cart', data).then(r => r.data),
  clearCart: () => axios.delete('/api/cart').then(r => r.data),
};

3.3 Composable(连接用例和 UI)

// application/composables/useCart.ts
import { ref } from 'vue';
import { AddToCartUseCase } from '../usecases/AddToCartUseCase';
import { ApiCartRepository } from '../../infrastructure/repositories/ApiCartRepository';

const cartRepo = new ApiCartRepository();
const addToCartUseCase = new AddToCartUseCase(cartRepo);

export function useCart() {
  const cart = ref<Cart | null>(null);
  const loading = ref(false);
  const error = ref<string | null>(null);

  async function addItem(productId: string, name: string, price: number, qty: number) {
    loading.value = true;
    error.value = null;
    try {
      cart.value = await addToCartUseCase.execute(productId, name, price, qty);
    } catch (e) {
      error.value = e.message;
    } finally {
      loading.value = false;
    }
  }

  return { cart, loading, error, addItem };
}

4. UI 框架层(组件 — 只负责渲染)

<!-- ui/components/ProductCard.vue -->
<script setup lang="ts">
import { useCart } from '@/application/composables/useCart';

const { addItem, loading, error } = useCart();

function handleAdd() {
  addItem(product.id, product.name, product.price, 1);
}
</script>

<template>
  <div class="product-card">
    <h3>{{ product.name }}</h3>
    <p>¥{{ product.price }}</p>
    <button :disabled="loading" @click="handleAdd">
      加入购物车
    </button>
    <p v-if="error" class="error">{{ error }}</p>
  </div>
</template>

组件只做三件事:展示数据、捕获用户操作、调用用例。零业务逻辑。


依赖关系总览

ProductCard.vue          ← 只渲染,零业务逻辑
  ↓ 调用
useCart()                ← 状态管理 + 调用用例
  ↓ 调用
AddToCartUseCase         ← 编排领域对象
  ↓ 使用
Cart / CartItem / Money  ← 纯业务规则
  ↓ 通过接口
CartRepository (接口)    ← 领域层定义接口
  ↓ 实现
ApiCartRepository        ← 适配层实现,转换 JSON ↔ 领域对象
  ↓ 调用
cartApi (axios)          ← 最外层:HTTP 请求细节

箭头方向 = 依赖方向,全部从外指向内。内层完全不知道外层存在。


项目目录结构

src/
├── domain/                          # 领域层(纯 TS,零框架依赖)
│   ├── value-objects/
│   │   ├── Money.ts
│   │   └── Email.ts
│   ├── entities/
│   │   └── CartItem.ts
│   ├── aggregates/
│   │   ├── Cart.ts
│   │   └── Order.ts
│   └── repositories/
│       ├── CartRepository.ts        # 接口定义
│       └── OrderRepository.ts
│
├── application/                     # 应用层(用例编排)
│   ├── usecases/
│   │   ├── AddToCartUseCase.ts
│   │   └── CheckoutUseCase.ts
│   └── composables/
│       └── useCart.ts
│
├── infrastructure/                  # 接口适配层
│   ├── repositories/
│   │   └── ApiCartRepository.ts     # Repository 实现
│   └── api/
│       └── CartApi.ts               # axios 调用
│
├── ui/                              # UI 框架层
│   ├── components/
│   │   └── ProductCard.vue
│   └── pages/
│       └── CartPage.vue

这样做的好处

场景 传统做法 整洁架构
换框架 Vue→React 重写所有业务逻辑 只重写 UI 层,领域层直接复用
API 字段名变了 改几十个组件 只改 ApiCartRepository 的转换逻辑
加新业务规则 在组件里到处加 if 在领域对象里加,组件无感知
单元测试 mock axios、mount 组件 直接测 Cart.addItem(),纯函数测试
后端复用 不可能 领域层是纯 TS,Node 端可直接引用

实际项目中的权衡

不是所有前端项目都需要完整分层

项目规模 建议做法
简单页面/CRUD 分离 API 调用和 UI 即可,不必过度设计
中等复杂度 提取领域层(纯 TS),用 Composable 做应用层
复杂业务系统 完整分层 + DDD 建模(如交易系统、审批流)

核心原则

领域层必须是纯 JS/TS,不依赖任何框架。 这样你的业务逻辑可以:

  • 在 Vue 和 React 之间迁移
  • 在 Node 后端复用(同构)
  • 独立写单元测试,不需要 mock 组件

框架是细节,业务逻辑才是核心。

我花一天时间Vibe Coding的开源AI工具,一键检测你的电脑能跑哪些AI大模型

最近一直在深耕 AI Agent 与大模型应用,比如 JitKnow AI 知识库、JitWord协同AI文档、Pxcharts 超级表格,同时也持续在给大家分享 GitHub 上真正能落地、能解决实际问题的优质AI开源项目。

图片

今天和大家分享一款我花了一天时间做的开源工具——ai-detector。

它是一款完全运行在浏览器端的免费硬件检测工具。能自动读取你电脑的内存、CPU、GPU 信息,智能匹配 21+ 主流开源 AI 大模型的兼容性,并给出本地运行速度预估,帮你在 5 秒内找到最适合自己电脑的 AI 模型。

无需安装、无需登录、无需上传任何数据,全程 100% 本地运行。

ps:最近小龙虾很火,但是又担心自己电脑配置不够的朋友,可以使用这款线上工具检测一下电脑适合哪些模型,告别AI恐惧啦~老规矩,先上开源地址。

github:github.com/MrXujiang/a…

演示地址:jitword.com/ai-detector

下面就和大家详细分享一下这款 AI Coding 出来的开源项目。

先上一个基础的功能演示比如我想在我的电脑里部署一个本地AI模型,但是又担心我的电脑配置不够,那么直接运行这个项目:

图片

点击开始检测, 不到5s,就会给出自己电脑的性能和适合运行哪些模型的详细报告:

图片

分析的非常准确,可能是我电脑年久失修,只给出了28分。。。ai-detector 还会为我们推荐基于当前电脑,适合运行的模型推荐:

图片

不仅如此,它还会对目前主流的数十个开源模型,对当前电脑进行分析评测,分析出部署这些大模型的性能,风险等信息,如下:

图片

对于比较吃电脑性能的模型,它会给我们全面的分析:

图片

最后会基于我们的硬件配置,估算各模型生成速度(tokens/秒),并输出可视化的分析报表:

图片

核心能力总结

下面和大家总结一下 ai-detector 的核心能力和亮点。

  1. 硬件自动检测
  • 系统内存通过 navigator.deviceMemory 读取 RAM 大小
  • CPU 核心数通过 navigator.hardwareConcurrency 读取逻辑核数
  • GPU 型号通过 WebGL WEBGL_debug_renderer_info 扩展识别显卡
  • 综合跑分基于内存与 CPU 计算 0-100 综合评分,直观了解你的 AI 能力等级

2. 21+ 大模型兼容性分析

覆盖当前最主流的开源大模型系列,一键发现哪些能跑、哪些跑不动:

系列 代表模型 参数规模
🦙 Llama TinyLlama、Llama 3.2、Llama 3.1 1.1B ~ 70B
🌐 Qwen Qwen2.5 3B/7B/14B/32B/72B 3B ~ 72B
💎 Phi Phi-3 Mini、Phi-3 Medium 3.8B ~ 14B
🌬️ Mistral Mistral 7B 7B
🧠 DeepSeek DeepSeek-R1、DeepSeek Coder 7B ~ 70B
👁️ 多模态 LLaVA 7B、MiniCPM-V 8B 7B ~ 8B
💫 Gemma Gemma 2 2B 2B
  1. 支持兼容性三级分类
  • 😊 流畅运行内存充裕,可稳定高速推理
  • ⚠️ 勉强运行:内存刚好满足,速度偏慢
  • ❌ 内存不足:当前配置无法加载该模型
  1. 运行速度排行

检测完成后自动生成可运行模型的速度排行榜,按 tokens/秒从高到低排列,帮你优先选出响应最快的模型。

  1. 量化模式切换

图片

  • Q4 量化内存占用更低,普通设备首选
  • Q8 量化精度更高,内存需求约为 Q4 的 2 倍
  • 一键切换,实时刷新所有模型兼容状态
  1. 个性化模型推荐基于我们的硬件配置,自动从模型库中精选 最均衡速度最快能力最强 三款推荐,省去选择烦恼。

  2. 一键复制检测报告

图片

生成包含硬件配置、综合评分、可运行模型的文本报告,方便分享或咨询。

完整使用流程总结

为了让大家轻松上手使用,我总结了一下7步使用法,大家可以参考一下:

  1. 打开页面 → 点击「开始硬件检测」按钮
  2. 等待扫描(约 3~5 秒)→ 自动完成内存、CPU、GPU 检测
  3. 查看结果 → 获得综合跑分与设备等级评价
  4. 浏览推荐 → 查看「为你推荐」区块,获取最适合你的 3 款模型
  5. 筛选模型 → 在「模型列表」中按兼容状态、类型筛选,支持关键词搜索
  6. 切换量化 → 尝试切换 Q4 / Q8 量化,查看内存需求变化
  7. 复制报告 → 一键复制检测结果,方便保存或分享

为什么要做这个开源项目这里分析一张图,大家就知道了:

特性 AI -Detector 其他工具
需要安装 ❌ 无需安装 ✅ 通常需要
需要登录 ❌ 无需注册 ✅ 通常需要
数据上传 ❌ 完全不上传 ⚠️ 部分上传
模型覆盖 ✅ 21+ 主流模型 ⚠️ 覆盖有限
速度预估 ✅ tokens/s 量化估算 ⚠️ 通常无此功能
升级建议 ✅ 智能提示内存升级方案 ❌ 无
量化切换 ✅ Q4 / Q8 实时切换 ❌ 无
开源免费 ✅ MIT 协议 ⚠️ 多数收费

主要是为了让任何没有技术基础的人,也能轻松拥有专业级AI模型选型能力,告别AI焦虑。

目前已开源,大家可以免费使用:

github:github.com/MrXujiang/a…

DEMO演示地址:jitword.com/ai-detector

贪心

解法:贪心

如果不是环形序列,为了让距离差尽量小,每个元素只要考虑上一个和下一个相同元素即可。

环形序列怎么做呢?设序列长度为 $n$,我们把序列复制一遍加在原序列后面,然后按普通序列的方法计算这个长度为 $2n$ 的序列的答案。设新序列中下标为 $i$ 的元素答案为 $v_i$,则原序列中下标为 $i$ 的元素答案为 $\min(v_i, v_{i + n})$。

复杂度 $\mathcal{O}(n)$。

参考代码(c++)

class Solution {
public:
    vector<int> solveQueries(vector<int>& nums, vector<int>& queries) {
        int n = nums.size();
        // 把序列复制一遍
        for (int i = 0; i < n; i++) nums.push_back(nums[i]);

        int ans[n * 2];
        for (int i = 0; i < n * 2; i++) ans[i] = 1e9;

        // 计算每个元素距离上一个相同元素多远
        unordered_map<int, vector<int>> mp;
        for (int i = 0; i < n * 2; i++) {
            auto &vec = mp[nums[i]];
            if (!vec.empty()) ans[i] = min(ans[i], i - vec.back());
            vec.push_back(i);
        }

        // 计算每个元素距离下一个相同元素多远
        mp.clear();
        for (int i = n * 2 - 1; i >= 0; i--) {
            auto &vec = mp[nums[i]];
            if (!vec.empty()) ans[i] = min(ans[i], vec.back() - i);
            vec.push_back(i);
        }

        vector<int> ret;
        for (int x : queries) {
            int t = min(ans[x], ans[x + n]);
            if (t < n) ret.push_back(t);
            else ret.push_back(-1);
        }
        return ret;
    }
};

两种方法:二分查找 / 预处理(Python/Java/C++/Go)

方法一:二分查找

看示例 1,所有 $1$ 的下标列表是 $p=[0,2,4]$。

我们在 $p$ 中二分查找下标 $i$,设二分返回值为 $j$,那么:

  • $p[j-1]$ 就是在 $i$ 左边的最近位置。
  • $p[j+1]$ 就是在 $i$ 右边的最近位置。

两个距离取最小值,答案为

$$
\min(i-p[j-1], p[j+1]-i)
$$

如果 $j-1$ 或者 $j+1$ 下标越界怎么办?需要写一堆 $\texttt{if-else}$ 吗?

在示例 1 中,由于 $\textit{nums}$ 是循环数组:

  • 在下标列表前面添加 $4-n=-3$,相当于认为在 $-3$ 下标处也有一个 $1$。
  • 在下标列表末尾添加 $0+n=7$,相当于认为在 $7$ 下标处也有一个 $1$。

修改后的下标列表为 $p=[-3,0,2,4,7]$。

特别地,如果 $\textit{nums}[i]$ 在 $\textit{nums}$ 中只出现了一次,那么答案为 $-1$。

代码实现时,可以直接把答案记录在 $\textit{queries}$ 数组中。

具体请看 视频讲解,欢迎点赞关注~

###py

class Solution:
    def solveQueries(self, nums: List[int], queries: List[int]) -> List[int]:
        indices = defaultdict(list)
        for i, x in enumerate(nums):
            indices[x].append(i)

        n = len(nums)
        for p in indices.values():
            # 前后各加一个哨兵
            i0 = p[0]
            p.insert(0, p[-1] - n)
            p.append(i0 + n)

        for qi, i in enumerate(queries):
            p = indices[nums[i]]
            if len(p) == 3:
                queries[qi] = -1
            else:
                j = bisect_left(p, i)
                queries[qi] = min(i - p[j - 1], p[j + 1] - i)
        return queries

###java

class Solution {
    public List<Integer> solveQueries(int[] nums, int[] queries) {
        Map<Integer, List<Integer>> indices = new HashMap<>();
        for (int i = 0; i < nums.length; i++) {
            indices.computeIfAbsent(nums[i], k -> new ArrayList<>()).add(i);
        }

        int n = nums.length;
        for (List<Integer> p : indices.values()) {
            // 前后各加一个哨兵
            int i0 = p.get(0);
            p.add(0, p.get(p.size() - 1) - n);
            p.add(i0 + n);
        }

        List<Integer> ans = new ArrayList<>(queries.length); // 预分配空间
        for (int i : queries) {
            List<Integer> p = indices.get(nums[i]);
            if (p.size() == 3) {
                ans.add(-1);
            } else {
                int j = Collections.binarySearch(p, i);
                ans.add(Math.min(i - p.get(j - 1), p.get(j + 1) - i));
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    vector<int> solveQueries(vector<int>& nums, vector<int>& queries) {
        unordered_map<int, vector<int>> indices;
        for (int i = 0; i < nums.size(); i++) {
            indices[nums[i]].push_back(i);
        }

        int n = nums.size();
        for (auto& [_, p] : indices) {
            // 前后各加一个哨兵
            int i0 = p[0];
            p.insert(p.begin(), p.back() - n);
            p.push_back(i0 + n);
        }

        for (int& i : queries) { // 注意这里是引用
            auto& p = indices[nums[i]];
            if (p.size() == 3) {
                i = -1;
            } else {
                int j = ranges::lower_bound(p, i) - p.begin();
                i = min(i - p[j - 1], p[j + 1] - i);
            }
        }
        return queries;
    }
};

###go

func solveQueries(nums []int, queries []int) []int {
indices := map[int][]int{}
for i, x := range nums {
indices[x] = append(indices[x], i)
}

n := len(nums)
for x, p := range indices {
// 前后各加一个哨兵
i0 := p[0]
p = slices.Insert(p, 0, p[len(p)-1]-n)
indices[x] = append(p, i0+n)
}

for qi, i := range queries {
p := indices[nums[i]]
if len(p) == 3 {
queries[qi] = -1
} else {
j := sort.SearchInts(p, i)
queries[qi] = min(i-p[j-1], p[j+1]-i)
}
}
return queries
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n + q\log n)$,其中 $n$ 是 $\textit{nums}$ 的长度,$q$ 是 $\textit{queries}$ 的长度。每次二分需要 $\mathcal{O}(\log n)$ 的时间。
  • 空间复杂度:$\mathcal{O}(n)$。返回值不计入。

方法二:预处理左右最近相同元素的下标

定义 $\textit{left}[i]$ 表示在 $i$ 左边的等于 $\textit{nums}[i]$ 的最近元素下标。注意数组是循环数组,我们可以像方法一那样,用 $-1$ 表示最后一个数的下标,$-2$ 表示倒数第二个数的下标,依此类推。

定义 $\textit{right}[i]$ 表示在 $i$ 右边的等于 $\textit{nums}[i]$ 的最近元素下标。注意数组是循环数组,我们可以像方法一那样,用 $n$ 表示第一个数的下标,$n+1$ 表示第二个数的下标,依此类推。

计算方式:

  • 从 $-n$ 循环到 $n-1$,同时用一个哈希表记录每个数的最新位置。当 $i\ge 0$ 时,$\textit{left}[i]$ 就是哈希中记录的 $\textit{nums}[i]$ 的位置。注意先更新 $\textit{left}[i]$ 再更新哈希表。
  • 从 $2n-1$ 循环到 $0$,同时用一个哈希表记录每个数的最新位置。当 $i < n$ 时,$\textit{right}[i]$ 就是哈希中记录的 $\textit{nums}[i]$ 的位置。注意先更新 $\textit{right}[i]$ 再更新哈希表。

答案为:

$$
\min(i-\textit{left}[i], \textit{right}[i]-i)
$$

如果上式等于 $n$,说明只有一个 $\textit{nums}[i]$,答案为 $-1$。

优化前

###py

class Solution:
    def solveQueries(self, nums: List[int], queries: List[int]) -> List[int]:
        n = len(nums)
        left = [0] * n
        pos = {}
        for i in range(-n, n):
            if i >= 0:
                left[i] = pos[nums[i]]
            pos[nums[i]] = i

        right = [0] * n
        pos.clear()
        for i in range(n * 2 - 1, -1, -1):
            if i < n:
                right[i] = pos[nums[i]]
            pos[nums[i % n]] = i

        for qi, i in enumerate(queries):
            l = left[i]
            queries[qi] = -1 if i - l == n else min(i - l, right[i] - i)
        return queries

###java

class Solution {
    public List<Integer> solveQueries(int[] nums, int[] queries) {
        int n = nums.length;
        int[] left = new int[n];
        Map<Integer, Integer> pos = new HashMap<>();
        for (int i = -n; i < n; i++) {
            if (i >= 0) {
                left[i] = pos.get(nums[i]);
            }
            pos.put(nums[(i + n) % n], i);
        }

        int[] right = new int[n];
        pos.clear();
        for (int i = n * 2 - 1; i >= 0; i--) {
            if (i < n) {
                right[i] = pos.get(nums[i]);
            }
            pos.put(nums[i % n], i);
        }

        List<Integer> ans = new ArrayList<>(queries.length);
        for (int i : queries) {
            int l = left[i];
            ans.add(i - l == n ? -1 : Math.min(i - l, right[i] - i));
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    vector<int> solveQueries(vector<int>& nums, vector<int>& queries) {
        int n = nums.size();
        vector<int> left(n), right(n);
        unordered_map<int, int> pos;
        for (int i = -n; i < n; i++) {
            if (i >= 0) {
                left[i] = pos[nums[i]];
            }
            pos[nums[(i + n) % n]] = i;
        }

        pos.clear();
        for (int i = n * 2 - 1; i >= 0; i--) {
            if (i < n) {
                right[i] = pos[nums[i]];
            }
            pos[nums[i % n]] = i;
        }

        for (int& i : queries) {
            int l = left[i];
            i = i - l == n ? -1 : min(i - l, right[i] - i);
        }
        return queries;
    }
};

###go

func solveQueries(nums []int, queries []int) []int {
n := len(nums)
left := make([]int, n)
pos := map[int]int{}
for i := -n; i < n; i++ {
if i >= 0 {
left[i] = pos[nums[i]]
}
pos[nums[(i+n)%n]] = i
}

right := make([]int, n)
clear(pos)
for i := n*2 - 1; i >= 0; i-- {
if i < n {
right[i] = pos[nums[i]]
}
pos[nums[i%n]] = i
}

for qi, i := range queries {
l := left[i]
if i-l == n {
queries[qi] = -1
} else {
queries[qi] = min(i-l, right[i]-i)
}
}
return queries
}

优化(一)

一次遍历同时计算 $\textit{left}$ 和 $\textit{right}$。(类似单调栈)

###py

class Solution:
    def solveQueries(self, nums: List[int], queries: List[int]) -> List[int]:
        n = len(nums)
        left = [0] * n
        right = [0] * n
        pos = {}
        for i in range(-n, n):
            x = nums[i]
            if i >= 0:
                j = pos[x]
                left[i] = j
                # 对于左边的 j 来说,它的 right 就是 i
                if j >= 0:
                    right[j] = i
                else:
                    right[j + n] = i + n
            pos[x] = i

        for qi, i in enumerate(queries):
            l = left[i]
            queries[qi] = -1 if i - l == n else min(i - l, right[i] - i)
        return queries

###java

class Solution {
    public List<Integer> solveQueries(int[] nums, int[] queries) {
        int n = nums.length;
        int[] left = new int[n];
        int[] right = new int[n];
        Map<Integer, Integer> pos = new HashMap<>();
        for (int i = -n; i < n; i++) {
            if (i >= 0) {
                int j = pos.get(nums[i]);
                left[i] = j;
                // 对于左边的 j 来说,它的 right 就是 i
                if (j >= 0) {
                    right[j] = i;
                } else {
                    right[j + n] = i + n;
                }
            }
            pos.put(nums[(i + n) % n], i);
        }

        List<Integer> ans = new ArrayList<>(queries.length);
        for (int i : queries) {
            int l = left[i];
            ans.add(i - l == n ? -1 : Math.min(i - l, right[i] - i));
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    vector<int> solveQueries(vector<int>& nums, vector<int>& queries) {
        int n = nums.size();
        vector<int> left(n), right(n);
        unordered_map<int, int> pos;
        for (int i = -n; i < n; i++) {
            if (i >= 0) {
                int j = pos[nums[i]];
                left[i] = j;
                // 对于左边的 j 来说,它的 right 就是 i
                if (j >= 0) {
                    right[j] = i;
                } else {
                    right[j + n] = i + n;
                }
            }
            pos[nums[(i + n) % n]] = i;
        }

        for (int& i : queries) {
            int l = left[i];
            i = i - l == n ? -1 : min(i - l, right[i] - i);
        }
        return queries;
    }
};

###go

func solveQueries(nums []int, queries []int) []int {
n := len(nums)
left := make([]int, n)
right := make([]int, n)
pos := map[int]int{}
for i := -n; i < n; i++ {
if i >= 0 {
j := pos[nums[i]]
left[i] = j
// 对于左边的 j 来说,它的 right 就是 i
if j >= 0 {
right[j] = i
} else {
right[j+n] = i + n
}
}
pos[nums[(i+n)%n]] = i
}

for qi, i := range queries {
l := left[i]
if i-l == n {
queries[qi] = -1
} else {
queries[qi] = min(i-l, right[i]-i)
}
}
return queries
}

优化(二)

上面的写法并不是真正的一次遍历,因为遍历了 $\textit{nums}$ 两次。

要做到真正的一次遍历,需要记录每个元素首次出现的位置和最后一次出现的位置。

###py

class Solution:
    def solveQueries(self, nums: List[int], queries: List[int]) -> List[int]:
        n = len(nums)
        left = [0] * n
        right = [0] * n
        first = {}  # 记录首次出现的位置
        last = {}  # 记录最后一次出现的位置
        for i, x in enumerate(nums):
            left[i] = j = last.get(x, -1)
            if j >= 0:
                right[j] = i
            if x not in first:
                first[x] = i
            last[x] = i

        for qi, i in enumerate(queries):
            l = left[i] if left[i] >= 0 else last[nums[i]] - n
            if i - l == n:
                queries[qi] = -1
            else:
                r = right[i] or first[nums[i]] + n
                queries[qi] = min(i - l, r - i)
        return queries

###java

class Solution {
    public List<Integer> solveQueries(int[] nums, int[] queries) {
        int n = nums.length;
        int[] left = new int[n];
        int[] right = new int[n];
        Map<Integer, Integer> first = new HashMap<>(); // 记录首次出现的位置
        Map<Integer, Integer> last = new HashMap<>(); // 记录最后一次出现的位置
        for (int i = 0; i < n; i++) {
            int x = nums[i];
            left[i] = last.getOrDefault(x, -1);
            if (left[i] >= 0) {
                right[left[i]] = i;
            }
            first.putIfAbsent(x, i);
            last.put(x, i);
        }

        List<Integer> ans = new ArrayList<>(queries.length);
        for (int i : queries) {
            int l = left[i] >= 0 ? left[i] : last.get(nums[i]) - n;
            if (i - l == n) {
                ans.add(-1);
            } else {
                int r = right[i] > 0 ? right[i] : first.get(nums[i]) + n;
                ans.add(Math.min(i - l, r - i));
            }
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    vector<int> solveQueries(vector<int>& nums, vector<int>& queries) {
        int n = nums.size();
        vector<int> left(n), right(n);
        unordered_map<int, int> first, last; // 记录首次出现和最后一次出现的位置
        for (int i = 0; i < n; i++) {
            int x = nums[i];
            left[i] = last.contains(x) ? last[x] : -1;
            if (left[i] >= 0) {
                right[left[i]] = i;
            }
            if (!first.contains(x)) {
                first[x] = i;
            }
            last[x] = i;
        }

        for (int& i : queries) {
            int l = left[i] >= 0 ? left[i] : last[nums[i]] - n;
            if (i - l == n) {
                i = -1;
            } else {
                int r = right[i] ? right[i] : first[nums[i]] + n;
                i = min(i - l, r - i);
            }
        }
        return queries;
    }
};

###go

func solveQueries(nums []int, queries []int) []int {
n := len(nums)
left := make([]int, n)
right := make([]int, n)
first := map[int]int{} // 首次出现的位置
last := map[int]int{}  // 最后一次出现的位置
for i, x := range nums {
j, ok := last[nums[i]]
if ok {
left[i] = j
right[j] = i
} else {
left[i] = -1
}
if _, ok := first[x]; !ok {
first[x] = i
}
last[x] = i
}

for qi, i := range queries {
l := left[i]
if l < 0 {
l = last[nums[i]] - n
}
if i-l == n {
queries[qi] = -1
} else {
r := right[i]
if r == 0 {
r = first[nums[i]] + n
}
queries[qi] = min(i-l, r-i)
}
}
return queries
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n + q)$,其中 $n$ 是 $\textit{nums}$ 的长度,$q$ 是 $\textit{queries}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。返回值不计入。

相似题目

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 【本题相关】二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/最短路/最小生成树/二分图/基环树/欧拉路径)
  7. 动态规划(入门/背包/状态机/划分/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、二叉树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA/一般树)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

大人工智能时代下前端界面全新开发模式的思考(三)

第三章:范式的跃迁——从组件驱动到意图驱动

工具的变革只是表象,更深层的变革发生在开发范式层面。前端开发正在经历从"组件驱动"到"意图驱动"的范式跃迁,这不仅是技术的变化,更是思维方式、能力模型和职业价值的根本性重构。

这一章我们将深入探讨这场范式转变的内涵、影响和实践路径。


3.1 代码范式的对比:两种世界观的碰撞

让我们通过具体的代码示例,来感受组件驱动和意图驱动这两种范式的根本差异。

3.1.1 场景:实现一个用户管理功能

需求描述

  • 展示用户列表
  • 支持搜索(按姓名或邮箱)
  • 支持按角色筛选
  • 支持分页
  • 支持行内编辑
  • 响应式布局
  • 加载状态和空状态处理

组件驱动模式(传统方式)

// UserManagement.tsx - 约150行代码
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { 
  Card, 
  CardHeader, 
  CardTitle, 
  CardContent,
  CardFooter 
} from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Pagination,
  PaginationContent,
  PaginationEllipsis,
  PaginationItem,
  PaginationLink,
  PaginationNext,
  PaginationPrevious,
} from "@/components/ui/pagination";
import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/components/ui/use-toast';
import { Search, Edit2, Save, X } from 'lucide-react';
import { debounce } from 'lodash';

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';
  status: 'active' | 'inactive';
  createdAt: string;
}

interface Filters {
  search: string;
  role: string;
  page: number;
  pageSize: number;
}

export function UserManagement() {
  // 状态管理
  const [filters, setFilters] = useState<Filters>({
    search: '',
    role: 'all',
    page: 1,
    pageSize: 10
  });
  
  const [editingId, setEditingId] = useState<string | null>(null);
  const [editForm, setEditForm] = useState<Partial<User>>({});
  
  const queryClient = useQueryClient();
  
  // 数据获取
  const { data, isLoading, error } = useQuery({
    queryKey: ['users', filters],
    queryFn: async () => {
      const params = new URLSearchParams();
      if (filters.search) params.append('search', filters.search);
      if (filters.role !== 'all') params.append('role', filters.role);
      params.append('page', String(filters.page));
      params.append('pageSize', String(filters.pageSize));
      
      const response = await fetch(`/api/users?${params}`);
      if (!response.ok) throw new Error('Failed to fetch users');
      return response.json();
    }
  });
  
  // 更新用户mutation
  const updateUser = useMutation({
    mutationFn: async (user: Partial<User> & { id: string }) => {
      const response = await fetch(`/api/users/${user.id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(user)
      });
      if (!response.ok) throw new Error('Failed to update user');
      return response.json();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] });
      toast({ title: 'User updated successfully' });
      setEditingId(null);
    },
    onError: (error) => {
      toast({ 
        title: 'Failed to update user', 
        variant: 'destructive',
        description: error.message
      });
    }
  });
  
  // 防抖搜索
  const debouncedSearch = useMemo(
    () => debounce((value: string) => {
      setFilters(prev => ({ ...prev, search: value, page: 1 }));
    }, 300),
    []
  );
  
  // 事件处理
  const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    debouncedSearch(e.target.value);
  };
  
  const handleRoleChange = (value: string) => {
    setFilters(prev => ({ ...prev, role: value, page: 1 }));
  };
  
  const handlePageChange = (page: number) => {
    setFilters(prev => ({ ...prev, page }));
  };
  
  const handleEdit = (user: User) => {
    setEditingId(user.id);
    setEditForm(user);
  };
  
  const handleSave = () => {
    if (editingId && editForm) {
      updateUser.mutate({ id: editingId, ...editForm });
    }
  };
  
  const handleCancel = () => {
    setEditingId(null);
    setEditForm({});
  };
  
  // 计算分页
  const totalPages = Math.ceil((data?.total || 0) / filters.pageSize);
  
  if (error) {
    return (
      <Card className="w-full">
        <CardContent className="pt-6">
          <div className="text-center text-red-600">
            <p className="text-lg font-semibold">Error loading users</p>
            <p className="text-sm">{error.message}</p>
            <Button 
              onClick={() => queryClient.invalidateQueries({ queryKey: ['users'] })}
              className="mt-4"
            >
              Retry
            </Button>
          </div>
        </CardContent>
      </Card>
    );
  }
  
  return (
    <Card className="w-full">
      <CardHeader>
        <CardTitle className="text-2xl font-bold">User Management</CardTitle>
      </CardHeader>
      
      <CardContent className="space-y-6">
        {/* 过滤器 */}
        <div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
          <div className="relative w-full sm:w-64">
            <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
            <Input
              placeholder="Search users..."
              onChange={handleSearchChange}
              className="pl-10"
            />
          </div>
          
          <Select value={filters.role} onValueChange={handleRoleChange}>
            <SelectTrigger className="w-full sm:w-40">
              <SelectValue placeholder="Filter by role" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="all">All Roles</SelectItem>
              <SelectItem value="admin">Admin</SelectItem>
              <SelectItem value="editor">Editor</SelectItem>
              <SelectItem value="viewer">Viewer</SelectItem>
            </SelectContent>
          </Select>
        </div>
        
        {/* 表格 */}
        <div className="border rounded-lg overflow-hidden">
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead>Name</TableHead>
                <TableHead>Email</TableHead>
                <TableHead>Role</TableHead>
                <TableHead>Status</TableHead>
                <TableHead className="text-right">Actions</TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {isLoading ? (
                // 加载状态
                Array.from({ length: 5 }).map((_, i) => (
                  <TableRow key={i}>
                    <TableCell><Skeleton className="h-4 w-32" /></TableCell>
                    <TableCell><Skeleton className="h-4 w-48" /></TableCell>
                    <TableCell><Skeleton className="h-4 w-20" /></TableCell>
                    <TableCell><Skeleton className="h-4 w-16" /></TableCell>
                    <TableCell><Skeleton className="h-8 w-20 ml-auto" /></TableCell>
                  </TableRow>
                ))
              ) : data?.users.length === 0 ? (
                // 空状态
                <TableRow>
                  <TableCell colSpan={5} className="text-center py-8 text-gray-500">
                    No users found
                  </TableCell>
                </TableRow>
              ) : (
                // 数据展示
                data?.users.map((user: User) => (
                  <TableRow key={user.id}>
                    <TableCell>
                      {editingId === user.id ? (
                        <Input
                          value={editForm.name || ''}
                          onChange={(e) => setEditForm(prev => ({ ...prev, name: e.target.value }))}
                          className="w-40"
                        />
                      ) : (
                        <span className="font-medium">{user.name}</span>
                      )}
                    </TableCell>
                    <TableCell>
                      {editingId === user.id ? (
                        <Input
                          value={editForm.email || ''}
                          onChange={(e) => setEditForm(prev => ({ ...prev, email: e.target.value }))}
                          className="w-56"
                        />
                      ) : (
                        user.email
                      )}
                    </TableCell>
                    <TableCell>
                      <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
                        user.role === 'admin' ? 'bg-purple-100 text-purple-800' :
                        user.role === 'editor' ? 'bg-blue-100 text-blue-800' :
                        'bg-gray-100 text-gray-800'
                      }`}>
                        {user.role}
                      </span>
                    </TableCell>
                    <TableCell>
                      <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
                        user.status === 'active' 
                          ? 'bg-green-100 text-green-800' 
                          : 'bg-red-100 text-red-800'
                      }`}>
                        {user.status}
                      </span>
                    </TableCell>
                    <TableCell className="text-right">
                      {editingId === user.id ? (
                        <div className="flex justify-end gap-2">
                          <Button 
                            size="sm" 
                            onClick={handleSave}
                            disabled={updateUser.isPending}
                          >
                            <Save className="w-4 h-4 mr-1" />
                            Save
                          </Button>
                          <Button 
                            size="sm" 
                            variant="outline"
                            onClick={handleCancel}
                          >
                            <X className="w-4 h-4 mr-1" />
                            Cancel
                          </Button>
                        </div>
                      ) : (
                        <Button 
                          size="sm" 
                          variant="ghost"
                          onClick={() => handleEdit(user)}
                        >
                          <Edit2 className="w-4 h-4 mr-1" />
                          Edit
                        </Button>
                      )}
                    </TableCell>
                  </TableRow>
                ))
              )}
            </TableBody>
          </Table>
        </div>
        
        {/* 分页 */}
        {totalPages > 1 && (
          <Pagination>
            <PaginationContent>
              <PaginationItem>
                <PaginationPrevious 
                  onClick={() => handlePageChange(Math.max(1, filters.page - 1))}
                  className={filters.page === 1 ? 'pointer-events-none opacity-50' : ''}
                />
              </PaginationItem>
              
              {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
                <PaginationItem key={page}>
                  <PaginationLink
                    onClick={() => handlePageChange(page)}
                    isActive={page === filters.page}
                  >
                    {page}
                  </PaginationLink>
                </PaginationItem>
              ))}
              
              <PaginationItem>
                <PaginationNext 
                  onClick={() => handlePageChange(Math.min(totalPages, filters.page + 1))}
                  className={filters.page === totalPages ? 'pointer-events-none opacity-50' : ''}
                />
              </PaginationItem>
            </PaginationContent>
          </Pagination>
        )}
      </CardContent>
    </Card>
  );
}

传统方式的特点

  • 代码量大:约150行,还不算类型定义和样式
  • 关注点分散:需要同时处理UI、状态、数据获取、错误处理、加载状态
  • 依赖众多:需要熟悉React Query、UI组件库、Lodash等多个库
  • 调试复杂:状态流转复杂,bug定位困难
  • 但是:完全可控,每一行代码都理解其作用

意图驱动模式(AI生成)

提示词:
"创建一个用户管理表格组件,要求:
1. 从 /api/users 获取数据,使用React Query
2. 支持按姓名或邮箱搜索(防抖300ms)
3. 支持按角色筛选(admin/editor/viewer)
4. 分页功能,每页10条
5. 行内编辑功能,可修改姓名和邮箱
6. 加载状态显示骨架屏
7. 空状态提示
8. 错误处理,显示重试按钮
9. 使用Tailwind CSS和shadcn/ui组件
10. 响应式布局,移动端友好
11. 添加适当的类型定义"

→ AI生成完整实现(约150行,与手写相当)

意图驱动方式的特点

  • 代码量相当:AI生成的代码也是约150行
  • 关注点集中:开发者只需要关注"要什么",不需要关注"怎么实现"
  • 实现细节黑盒:搜索防抖、分页逻辑、状态管理都由AI处理
  • 快速迭代:需要修改时,修改提示词重新生成,而非修改代码
  • 但是:不完全理解实现细节,调试困难,可维护性存疑

3.1.2 关键差异分析

维度 组件驱动 意图驱动
关注点 如何组装组件、管理状态、处理副作用 需要实现什么功能、满足什么需求
代码所有权 精心编写、深度理解、长期维护 一次性使用、黑盒理解、按需重新生成
调试方式 阅读代码、理解逻辑、定位问题 与AI对话、重新生成、试错迭代
学习曲线 陡峭(需要掌握语法、框架、模式) 平缓(需要学会与AI沟通)
代码质量 依赖开发者水平,质量可控 依赖AI能力和Prompt质量,波动较大
维护成本 高(需要持续维护代码) 低(可以重新生成),但长期可能更高
创新性 高(完全自定义,可实现任何想法) 中(受限于AI的理解和能力)

3.1.3 范式转变的本质

这两种模式的差异,本质上是"控制"与"委托"的权衡:

  • 组件驱动:开发者完全控制实现细节,但需要投入大量时间和精力
  • 意图驱动:开发者委托AI处理实现细节,但需要接受一定的不可控性

这不是非此即彼的选择,而是一个连续谱。实际开发中,我们往往在两者之间找到平衡点:

高控制 ←─────────────────────────────→ 高委托
        组件驱动    混合模式    意图驱动
        (手动编写)  (AI辅助)   (AI主导)
        
适用场景:
- 核心功能 → 手动编写
- 工具函数 → AI生成+审查
- 样板代码 → AI生成
- 原型验证 → AI主导

3.2 架构层面的三大转变

从组件驱动到意图驱动的转变,不仅仅是编码方式的变化,更是架构层面的根本性重构。

3.2.1 从"声明式UI"到"生成式UI"

声明式UI(传统)

开发者声明UI应该是什么样,框架负责将其渲染到DOM。

// 声明式:我声明这个div应该是什么样
function App() {
  const [count, setCount] = useState(0);
  
  return (
    <div className="p-4 bg-blue-500 text-white rounded hover:bg-blue-600">
      <p>Count: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>
        Increment
      </button>
    </div>
  );
}

开发者明确声明:

  • 这是一个div
  • padding是1rem(p-4)
  • 背景是蓝色(bg-blue-500)
  • 文字是白色(text-white)
  • 圆角(rounded)
  • 悬停时背景变深(hover:bg-blue-600)

生成式UI(AI驱动)

开发者描述意图,AI生成UI。

提示词:"创建一个计数器组件,蓝色主题,有悬停效果"

→ AI生成代码(可能不完全符合预期,需要迭代)

关键区别

维度 声明式UI 生成式UI
确定性 高(代码即UI) 低(AI可能生成不同结果)
可预测性 高(相同输入,相同输出) 中(相同提示词,可能不同结果)
控制精度 像素级控制 意图级控制
开发速度 慢(需要手动编写每一行) 快(AI批量生成)
调试难度 中(理解代码即可) 高(需要理解AI的"思维")

实践建议

生产环境中,建议采用"混合模式":

// 核心UI手动声明(确保精确控制)
function CoreLayout() {
  return (
    <div className="min-h-screen flex">
      <Sidebar />
      <main className="flex-1 p-6">
        <AIContent /> {/* AI生成的内容区域 */}
      </main>
    </div>
  );
}

// AI生成内容(非关键路径)
function AIContent() {
  const { content } = useAI({
    prompt: "根据当前页面上下文生成合适的内容"
  });
  
  return <div dangerouslySetInnerHTML={{ __html: content }} />;
}

3.2.2 从"状态驱动"到"对话驱动"

状态驱动(传统)

前端架构的核心是状态管理。

数据流向:
用户操作 → Action → Dispatcher → Reducer → State → UI重新渲染

示例:
点击按钮 → dispatch({ type: 'INCREMENT' }) → 
Reducer处理 → State.count++ → UI显示新数值

React的useState、Redux、Vuex,都是围绕"状态"设计的。

对话驱动(AI应用)

状态依然存在,但不再是架构的核心。**对话历史(Conversation History)**成为新的状态载体。

// Vercel AI SDK的useChat管理的是消息历史
function ChatComponent() {
  const { messages, input, handleSubmit } = useChat();
  
  // messages就是新的"状态",它驱动UI的展示
  return (
    <div>
      {messages.map(m => (
        <Message key={m.id} role={m.role} content={m.content} />
      ))}
      
      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="输入消息..."
        />
        <button type="submit">发送</button>
      </form>
    </div>
  );
}

对话驱动的特点

  1. 上下文保持:AI记住之前的对话,可以基于上下文理解用户意图
  2. 多轮交互:不是一次性操作,而是通过多轮对话逐步完成任务
  3. 不确定性:同样的输入,可能因为上下文不同而产生不同输出
  4. 流式响应:AI的响应是流式的,UI需要支持渐进式更新

架构变化

传统应用架构:
用户操作 → 状态更新 → UI渲染

AI应用架构:
用户输入 → AI理解 → 生成响应 → 流式展示 → 用户反馈 → 下一轮...
            ↑_________↓
              上下文循环

3.2.3 从"静态组件"到"智能组件"

静态组件(传统)

给定相同的props,永远渲染相同的UI。

// 静态组件:纯函数,确定性输出
function Button({ children, onClick, variant }: ButtonProps) {
  return (
    <button 
      className={`btn btn-${variant}`}
      onClick={onClick}
    >
      {children}
    </button>
  );
}

// 相同输入,相同输出
<Button variant="primary">Click me</Button> // 总是渲染相同的按钮

智能组件(AI驱动)

组件具备AI能力,能根据上下文自动调整行为。

// 智能组件概念示例
function AdaptiveButton({ intent, context }) {
  // 组件理解上下文,自动调整行为
  const { variant, size, icon, label, confirmation } = useAI({
    prompt: `根据意图"${intent}"和上下文${JSON.stringify(context)},
             生成最合适的按钮配置`,
    constraints: {
      variants: ['primary', 'secondary', 'danger', 'ghost'],
      sizes: ['sm', 'md', 'lg', 'xl'],
      requireConfirmation: ['delete', 'irreversible']
    }
  });
  
  const handleClick = () => {
    if (confirmation) {
      showConfirmationDialog(confirmation.message, executeAction);
    } else {
      executeAction();
    }
  };
  
  return (
    <Button variant={variant} size={size} onClick={handleClick}>
      {icon && <Icon name={icon} />}
      {label}
    </Button>
  );
}

// 使用:组件自动根据场景调整
<AdaptiveButton 
  intent="删除用户账户"
  context={{ userRole: 'admin', targetUser: 'VIP客户', irreversible: true }}
/>
// AI理解这是危险且不可逆的操作
// 自动选择danger变体,添加确认对话框,显示警告信息

智能组件的特征

  1. 自适应:根据用户行为、设备环境、网络状况自动调整
  2. 自优化:根据使用数据自动优化性能(如自动代码分割、懒加载)
  3. 自解释:能够解释自己的行为,帮助用户理解和调试
  4. 个性化:根据用户偏好和历史行为提供个性化体验

3.3 Prompt工程的新角色

在AI驱动的前端开发中,Prompt Engineering(提示工程)扮演着越来越重要的角色。它不再是一个"技巧",而是一个核心技能。

3.3.1 Prompt即接口(Prompt as Interface)

在传统开发中,我们定义函数接口:

// 传统接口定义
interface CreateUserParams {
  name: string;
  email: string;
  role: 'admin' | 'user' | 'guest';
}

function createUser(params: CreateUserParams): Promise<User> {
  // 实现...
}

在AI驱动开发中,Prompt成为新的"接口":

// Prompt即接口
interface ComponentGenerationPrompt {
  role: "前端开发专家";
  task: {
    componentName: string;
    requirements: string[];     // 功能需求列表
    techStack: {
      framework: 'React' | 'Vue' | 'Angular';
      styling: 'Tailwind' | 'CSS Modules' | 'Styled';
      typescript: boolean;
    };
    designSpec: DesignTokens;   // 设计规范
    context: {
      existingHooks: string[];  // 已有Hooks
      uiLibrary: string;        // UI组件库
      conventions: string[];    // 代码规范
    };
  };
  output: {
    code: string;              // 完整组件代码
    tests: string;             // 测试用例
    examples: string;          // 使用示例
    docs: string;              // Props文档
  };
}

// 用这个"接口"生成组件
const prompt: ComponentGenerationPrompt = {
  role: "前端开发专家",
  task: {
    componentName: "UserProfileCard",
    requirements: [
      "展示用户头像、姓名、职位",
      "悬停显示更多详情",
      "支持点击跳转到用户详情页",
      "响应式布局"
    ],
    techStack: {
      framework: "React",
      styling: "Tailwind",
      typescript: true
    },
    designSpec: designSystem.tokens,
    context: {
      existingHooks: ["useUser", "useRouter"],
      uiLibrary: "shadcn/ui",
      conventions: ["使用函数组件", "Props类型使用interface"]
    }
  }
};

const component = await generateComponent(prompt);

3.3.2 Prompt资产化管理

随着Prompt越来越多,团队需要建立Prompt资产库:

prompts/
├── README.md                    # 使用指南
├── guidelines/
│   └── writing-effective-prompts.md  # Prompt编写规范
├── templates/
│   ├── code-generation/         # 代码生成模板
│   │   ├── react-component.md
│   │   ├── vue-component.md
│   │   ├── utility-function.md
│   │   ├── custom-hook.md
│   │   └── api-client.md
│   ├── code-review/             # 代码审查模板
│   │   ├── security-check.md
│   │   ├── performance-review.md
│   │   ├── accessibility-check.md
│   │   └── style-guide-check.md
│   ├── debugging/               # 调试排错模板
│   │   ├── error-analysis.md
│   │   ├── performance-debug.md
│   │   └── memory-leak-debug.md
│   ├── documentation/           # 文档生成模板
│   │   ├── component-docs.md
│   │   ├── api-docs.md
│   │   └── readme-generator.md
│   └── architecture/            # 架构设计模板
│       ├── system-design.md
│       ├── data-modeling.md
│       └── api-design.md
├── examples/                    # 示例Prompt
│   ├── good-examples/           # 优秀案例
│   └── bad-examples/            # 反面教材
└── snippets/                    # 可复用的Prompt片段
    ├── tech-stack-definitions.md
    ├── code-conventions.md
    └── design-system-tokens.md

Prompt模板示例

<!-- prompts/templates/code-generation/react-component.md -->

# React组件生成模板

## 角色
你是资深前端工程师,精通React、TypeScript和现代前端工程化。

## 任务
根据以下要求生成高质量的React组件代码。

## 输入
- 组件名称:{{componentName}}
- 功能需求:{{requirements}}
- 技术栈:{{techStack}}
- 设计规范:{{designSpec}}
- 上下文:{{context}}

## 输出要求
1. 使用函数组件和TypeScript
2. 完整的Props类型定义
3. 包含JSDoc注释
4. 处理加载状态和错误状态
5. 遵循{{techStack.conventions}}代码规范
6. 使用{{techStack.uiLibrary}}组件库
7. 可访问性支持(aria属性、键盘导航)

## 代码结构

import React from 'react';
// 导入语句

// Props类型定义
interface {{componentName}}Props {
  // ...
}

/**
 * {{componentName}}组件
 * @description {{description}}
 */
export function {{componentName}}(props: {{componentName}}Props) {
  // 实现代码
}

## 示例
{{examples}}

3.3.3 Prompt工程最佳实践

1. 结构化Prompt

好的Prompt应该结构清晰、信息完整:

❌ 不好的Prompt:
"写一个用户表单"

✅ 好的Prompt:
"创建一个用户注册表单组件

角色:前端开发专家
技术栈:React + TypeScript + Tailwind CSS + shadcn/ui

功能要求:
1. 表单字段:用户名(必填,3-20字符)、邮箱(必填,有效格式)、密码(必填,8+字符,包含大小写和数字)
2. 实时验证:失去焦点时验证,显示错误信息
3. 提交处理:调用/api/register,显示加载状态
4. 成功处理:清空表单,显示成功消息
5. 错误处理:显示服务器返回的错误信息

UI要求:
1. 使用Card布局,最大宽度480px,居中
2. 输入框使用shadcn/ui的Input组件
3. 错误信息使用红色文字,显示在输入框下方
4. 提交按钮显示加载Spinner

可访问性:
1. 所有输入框关联label
2. 错误信息使用aria-describedby关联
3. 支持键盘导航"

2. 渐进式细化策略

与AI协作的最佳实践是"渐进式细化":

Round 1: 生成骨架
"创建一个用户管理页面,包含表格和基本CRUD操作"
→ AI生成基础结构

Round 2: 添加功能
"在表格上方添加搜索框和筛选器,支持按姓名和角色筛选"
→ AI添加筛选功能

Round 3: 优化细节
"搜索框添加防抖处理,筛选器使用下拉菜单,表格添加分页"
→ AI优化交互细节

Round 4: 完善体验
"添加加载状态、空状态、错误处理,优化移动端显示"
→ AI完善用户体验

3. 示例驱动(Few-Shot Learning)

提供示例可以帮助AI理解预期输出:

"创建一个格式化日期函数,要求:
1. 输入:Date对象或时间戳
2. 输出:'YYYY年MM月DD日 HH:mm'格式
3. 处理无效输入

示例:
输入:new Date('2024-03-15 14:30:00')
输出:'2024031514:30'

输入:null
输出:'无效日期'

请实现这个函数:"

4. 约束和边界

明确指定约束条件,避免AI生成不符合要求的代码:

"实现一个节流函数,约束条件:
1. 使用TypeScript,完整类型定义
2. 支持leading和trailing选项
3. 使用requestAnimationFrame优化性能
4. 不要使用lodash或其他库
5. 包含单元测试"

3.4 新抽象层的出现:意图层(Intent Layer)

AI的引入,在前端架构中增加了一个新的抽象层。

3.4.1 传统架构 vs AI增强架构

传统前端架构

用户操作 → 事件处理 → 状态更新 → 组件重新渲染
    ↑________________________________↓
              循环

开发者直接控制每一个环节。

AI增强架构

用户意图 → AI理解 → 决策/生成 → 状态更新 → 组件重新渲染
    ↑________________________↓
           反馈循环

在"用户意图"和"实现代码"之间,增加了AI处理层。

3.4.2 意图层带来的变化

1. 更高的抽象级别

开发者描述意图,AI处理实现细节。

传统方式:
"我需要创建一个div,className是p-4 bg-blue-500..."

AI方式:
"创建一个蓝色卡片组件"

2. 更好的用户体验

AI可以根据上下文提供智能化建议。

// AI可以根据用户角色自动调整界面
function Dashboard() {
  const { user } = useAuth();
  
  // AI根据用户角色和历史行为,生成个性化的仪表板布局
  const { layout, widgets } = useAI({
    prompt: `为${user.role}生成个性化的仪表板布局`,
    context: {
      userRole: user.role,
      permissions: user.permissions,
      frequentlyUsed: user.metrics.frequentlyUsedFeatures,
      recentActivity: user.metrics.recentActivity
    }
  });
  
  return <AdaptiveLayout layout={layout} widgets={widgets} />;
}

3. 更大的不确定性

AI的输出不是完全确定的,需要处理各种边界情况。

function AIGeneratedComponent({ prompt }) {
  const [result, setResult] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);
  
  useEffect(() => {
    generateCode(prompt)
      .then(code => {
        // 验证生成的代码
        if (!isValidCode(code)) {
          throw new Error('Generated code is invalid');
        }
        setResult(code);
      })
      .catch(err => {
        setError(err);
        // 记录错误,用于改进AI模型
        logError(prompt, err);
      })
      .finally(() => setLoading(false));
  }, [prompt]);
  
  if (loading) return <LoadingState />;
  if (error) return <ErrorState error={error} onRetry={() => window.location.reload()} />;
  
  return <RenderedComponent code={result} />;
}

3.4.3 意图层的边界和风险

何时使用意图层?

适合使用AI

  • 样板代码生成
  • 快速原型验证
  • 探索性开发
  • 文档生成
  • 测试用例生成

不适合使用AI

  • 核心算法实现
  • 安全敏感代码
  • 性能关键路径
  • 需要严格合规的代码
  • 创新性设计

风险控制

AI代码进入生产环境的门禁:

1. 自动检查层
   ├─ 语法检查(ESLint/TypeScript)
   ├─ 安全检查(SAST扫描)
   ├─ 性能检查(Bundle分析)
   └─ 可访问性检查(axe-core)

2. 人工审查层(必须)
   ├─ 逻辑正确性审查
   ├─ 安全漏洞审查
   ├─ 性能影响评估
   └─ 可维护性评估

3. 测试验证层
   ├─ 单元测试通过率>80%
   ├─ 集成测试通过
   ├─ 端到端测试通过
   └─ 视觉回归测试通过

4. 灰度发布层
   ├─ 5%流量验证
   ├─ 监控错误率
   ├─ 监控性能指标
   └─ 全量发布

3.5 小结:拥抱范式转变

从组件驱动到意图驱动的转变,是前端开发范式的一次重大跃迁。这不仅仅是工具的升级,更是思维方式的重构。

关键转变总结

维度 传统模式 新模式 应对策略
关注点 如何组装组件 如何描述意图 学习Prompt工程
代码所有权 精心维护 按需生成 建立质量门禁
调试方式 理解代码逻辑 与AI对话迭代 保留核心能力
技能重点 框架和API 需求拆解和沟通 培养软技能
架构思维 状态管理 意图管理和AI编排 学习AI架构

未来的前端工程师

将是一个混合角色

  • 50%的架构师:设计系统、把控质量、做出关键决策
  • 30%的Prompt工程师:与AI高效沟通,生成高质量代码
  • 20%的产品设计师:理解用户需求,创造优秀体验

这个转变不会一夜之间完成,而是一个渐进的过程。现在开始学习和适应,才能在未来保持竞争力。


下章预告

第四章《锋利的双刃剑——批判性审视AI生成代码》将深入探讨:

  • AI生成代码的可访问性危机及解决方案
  • 性能陷阱和技术债的累积模式
  • 安全漏洞的隐蔽性和防护措施
  • 工程师能力退化的风险及防范
  • 真实案例分析:过度依赖AI的教训

工具指南24-在线CSS Box Shadow生成器

打开任何一个现代 Web 应用,你几乎找不到一个不用 box-shadow 的页面。卡片悬浮、按钮点击反馈、模态框层级、导航栏分隔——阴影是建立视觉层次的基础手段。但 box-shadow 的参数组合极其复杂:水平偏移、垂直偏移、模糊半径、扩展半径、颜色、inset……一个自然的阴影通常需要 2-3 层叠加,每层 5 个参数,手写意味着 15 个数值的排列组合。在编辑器里盲调效率极低,每次修改都要切到浏览器确认效果。

可视化工具能把这个过程从"猜参数"变成"拖滑块"。这篇文章介绍一个在线 Box Shadow 生成器,同时深入讲解 CSS 阴影的技术细节,帮你理解每个参数的作用,写出性能更好的阴影代码。

工具介绍

CSS Box Shadow 生成器 提供了一个可视化的阴影编辑界面,核心功能包括:

  • 多层阴影叠加:添加多个阴影层,分别调整参数,实现复杂的光影效果
  • 实时预览:拖动滑块即刻看到效果变化,不用反复刷新浏览器
  • Inset 阴影:支持内阴影模式,用于凹陷效果
  • 一键复制:生成的 CSS 代码可直接粘贴到项目中

操作很直观:调整各个参数的滑块,观察预览区域的阴影变化,满意后复制 CSS 代码。支持同时编辑多层阴影,这是手写代码最难调试的部分。

box-shadow 语法详解

先搞清楚语法结构,才能真正理解工具里每个滑块的含义。

基本语法

box-shadow: [inset] <offset-x> <offset-y> [blur-radius] [spread-radius] [color];

五个参数各自的作用:

box-shadow: 
  /* offset-x: 水平偏移,正值向右,负值向左 */
  /* offset-y: 垂直偏移,正值向下,负值向上 */
  /* blur-radius: 模糊半径,值越大阴影越柔和,默认 0(硬边缘)*/
  /* spread-radius: 扩展半径,正值阴影扩大,负值收缩,默认 0 */
  /* color: 阴影颜色,通常用 rgba 控制透明度 */
  4px 8px 16px -2px rgba(0, 0, 0, 0.15);

参数对视觉效果的影响

每个参数单独调整时的效果:

/* 只有偏移,没有模糊 —— 硬阴影,像剪纸效果 */
box-shadow: 4px 4px 0 0 #000;

/* 加模糊,去偏移 —— 均匀发光效果 */
box-shadow: 0 0 20px 0 rgba(59, 130, 246, 0.5);

/* 负扩展 —— 阴影比元素小,只在底部可见 */
box-shadow: 0 4px 8px -4px rgba(0, 0, 0, 0.3);

/* inset —— 内阴影,凹陷效果 */
box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.1);

理解这些基础后,在工具里调参就不是盲目操作了,而是有目的地调整。

多层阴影

自然界中的阴影不是单层的。一个物体在光源下会产生多层不同浓度的阴影——靠近物体的部分浓且清晰,远离的部分淡且模糊。CSS 通过逗号分隔多层阴影来模拟这个效果:

/* 经典的双层自然阴影 */
.card {
  box-shadow: 
    0 1px 3px 0 rgba(0, 0, 0, 0.1),   /* 近处:小偏移,低模糊,较浓 */
    0 1px 2px -1px rgba(0, 0, 0, 0.1); /* 补充层:略有扩展收缩 */
}

/* 三层阴影:Google Material Design 风格 */
.elevated-card {
  box-shadow: 
    0 1px 2px 0 rgba(0, 0, 0, 0.05),   /* 底层:微弱的基础阴影 */
    0 4px 6px -1px rgba(0, 0, 0, 0.1),  /* 中层:主阴影 */
    0 10px 15px -3px rgba(0, 0, 0, 0.1); /* 远层:大范围的柔和阴影 */
}

多层阴影的调试是最需要可视化工具的场景。在生成器里逐层调整,比在代码里改数字再刷新高效得多。

实战设计模式

光知道语法不够,还得知道什么场景用什么阴影。下面是几个常见的设计模式和对应的 CSS 实现。

卡片悬浮效果

卡片组件几乎是 box-shadow 最高频的使用场景。好的卡片阴影需要在"存在感"和"不抢戏"之间找平衡:

/* 默认状态:轻柔的阴影,暗示可交互 */
.card {
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
  transition: box-shadow 0.2s ease;
}

/* hover 状态:阴影加深加大,暗示"抬起" */
.card:hover {
  box-shadow: 
    0 10px 15px -3px rgba(0, 0, 0, 0.1),
    0 4px 6px -4px rgba(0, 0, 0, 0.1);
}

关键技巧:hover 时增大 offset-yblur-radius,模拟元素离页面"抬起"的效果。配合 transition 让阴影变化有动画过渡。

按钮点击反馈

按钮的阴影变化可以传达"按下"的物理反馈:

.button {
  box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.15);
  transition: all 0.15s ease;
}

.button:active {
  box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.1);
  transform: translateY(1px);
}

按下时缩小阴影 + 向下位移,两者配合才有真实的按压感。只改阴影不改位置,效果会很奇怪。

聚焦环(Focus Ring)

用 box-shadow 替代 outline 做聚焦指示,可以跟随圆角且支持颜色自定义:

.input:focus {
  outline: none;
  box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.4);
}

/* 多层:内边框 + 外发光 */
.input:focus-visible {
  outline: none;
  box-shadow: 
    0 0 0 1px #3b82f6,              /* 内层:实色边框 */
    0 0 0 4px rgba(59, 130, 246, 0.2); /* 外层:柔和光晕 */
}

这里用了 spread-radius 配合零偏移零模糊,让阴影变成一个等宽的"边框"。这个技巧在 Tailwind CSS 的 ring 工具类中被广泛使用。

Neumorphism(新拟态)

新拟态设计依赖两层方向相反的阴影,模拟凸起或凹陷效果:

/* 凸起效果 */
.neumorphic {
  background: #e0e0e0;
  box-shadow: 
    6px 6px 12px #bebebe,   /* 右下深色阴影 */
    -6px -6px 12px #ffffff;  /* 左上亮色高光 */
}

/* 凹陷效果(inset)*/
.neumorphic-inset {
  background: #e0e0e0;
  box-shadow: 
    inset 6px 6px 12px #bebebe,
    inset -6px -6px 12px #ffffff;
}

新拟态对背景色有严格要求——必须是中性灰色系,否则两层阴影的对比度不够,效果会消失。在生成器里调试这类效果比手写快得多,因为你需要同时调整背景色和两层阴影的颜色来找到平衡点。

性能注意事项

box-shadow 不是"免费"的。浏览器渲染阴影需要额外计算,在某些场景下可能导致性能问题。

模糊半径与渲染成本

模糊半径越大,浏览器需要采样的像素范围越广,渲染成本越高。根据实际渲染测试的经验值:

  • blur-radius: 4px → 影响很小,几乎无性能开销
  • blur-radius: 20px → 中等开销,大量元素同时渲染时注意
  • blur-radius: 50px+ → 高开销,避免在动画中使用

动画阴影的正确姿势

直接动画 box-shadow 属性会触发重绘(repaint),在列表中对大量元素同时做阴影动画会造成卡顿。更好的做法是用伪元素 + opacity:

.card {
  position: relative;
}

/* 把"hover 后的阴影"放在伪元素上 */
.card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

.card:hover::after {
  opacity: 1;
}

这样浏览器只需要改变 opacity(走合成层优化),不用每帧重新计算阴影的模糊像素,性能好很多。这是 CSS 动画优化的通用技巧:能用 opacity/transform 做的动画,就不要用其他属性

will-change 的使用

如果确实需要动画 box-shadow,可以提前告诉浏览器:

.card {
  will-change: box-shadow;
}

但不要滥用——will-change 会让浏览器提前分配 GPU 资源,对内存有额外开销。更好的做法是通过 JavaScript 在动画开始前动态添加这个属性,动画结束后移除,而不是一直写在 CSS 中。只在确认存在性能问题的元素上使用。

设计系统中的阴影规范

成熟的设计系统会定义一套标准化的阴影层级,而不是让每个组件自己写阴影值。

Tailwind CSS 的阴影层级

Tailwind 定义了 6 个层级的阴影,覆盖了绝大多数场景:

/* shadow-sm: 微弱阴影,表单输入框等 */
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);

/* shadow: 默认阴影,卡片等 */
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);

/* shadow-md: 中等阴影,下拉菜单等 */
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);

/* shadow-lg: 较深阴影,弹出层等 */
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);

/* shadow-xl: 深阴影,模态框等 */
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);

/* shadow-2xl: 最深阴影,全屏浮层等 */
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);

注意规律:层级越高,offset-y 越大,blur-radius 越大,spread-radius 用负值控制阴影不要过度扩散。这套设计背后的逻辑是模拟"距离页面越远,阴影越大越柔和"。

自定义阴影 Token

如果你在做自己的设计系统,建议用 CSS 自定义属性管理阴影:

:root {
  --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
  --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
  --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
}

.card { box-shadow: var(--shadow-sm); }
.dropdown { box-shadow: var(--shadow-md); }
.modal { box-shadow: var(--shadow-lg); }

用生成器调出满意的阴影效果后,把值存到 Token 里统一管理,项目中所有组件引用 Token 而不是写死数值。改阴影风格时只需要改 Token 定义,不用逐个组件修改。

暗色模式下的阴影处理

暗色模式是容易踩坑的地方。在浅色背景上好看的阴影,切到暗色背景可能完全看不见。

:root {
  --shadow-color: rgba(0, 0, 0, 0.1);
}

/* 暗色模式:加大阴影不透明度,或者换用更深的颜色 */
@media (prefers-color-scheme: dark) {
  :root {
    --shadow-color: rgba(0, 0, 0, 0.4);
  }
}

.card {
  box-shadow: 0 4px 6px -1px var(--shadow-color);
}

另一种做法是在暗色模式下用"发光"代替阴影,用浅色半透明值模拟光源效果:

@media (prefers-color-scheme: dark) {
  .card {
    box-shadow: 0 0 15px rgba(255, 255, 255, 0.05);
  }
}

这种处理在生成器里来回切换预览背景色就能快速对比效果。

常见问题

box-shadow 和 filter: drop-shadow 的区别

两者看起来效果类似,但有本质差异:

  • box-shadow 作用于元素的盒模型矩形,无论元素形状如何,阴影都是矩形(或跟随 border-radius 的圆角矩形)
  • filter: drop-shadow() 作用于元素的 alpha 通道轮廓,会跟随元素的实际形状(包括透明区域)
/* box-shadow:矩形阴影,不跟随 PNG 透明区域 */
img { box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); }

/* drop-shadow:跟随图片实际形状 */
img { filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.2)); }

对于普通的 div、按钮、卡片,两者效果基本一样。但对于不规则形状(SVG 图标、透明 PNG、clip-path 裁剪的元素),只有 drop-shadow 能产生正确的阴影。

阴影颜色的选择

新手常犯的错误是用纯黑色 #000 做阴影。自然界中的阴影不是纯黑的,它会带有环境色的倾向。更自然的做法:

/* 偏冷色的阴影(适合蓝色系界面)*/
box-shadow: 0 4px 12px rgba(0, 0, 40, 0.12);

/* 偏暖色的阴影(适合暖色系界面)*/
box-shadow: 0 4px 12px rgba(40, 20, 0, 0.1);

/* 彩色阴影(用元素自身颜色做阴影)*/
.blue-button {
  background: #3b82f6;
  box-shadow: 0 4px 14px rgba(59, 130, 246, 0.4);
}

彩色阴影是近年来 UI 设计的趋势之一,能让按钮和卡片看起来更有"质感"。在生成器里把阴影颜色设成和元素背景相近的色调,透明度调到 30%-50%,就能得到不错的效果。

总结

box-shadow 参数多、层数多、场景多,是最适合用可视化工具辅助的 CSS 属性之一。用 在线 Box Shadow 生成器 快速调试出想要的效果,再把生成的代码整理成设计 Token 统一管理,是效率最高的工作流。

核心要点:

  • 自然阴影通常需要 2-3 层叠加,用工具逐层调整比手写高效
  • 动画阴影优先用伪元素 + opacity,避免直接动画 box-shadow
  • 建立阴影层级体系(xs/sm/md/lg),用 CSS 变量管理
  • 暗色模式需要单独处理阴影参数
  • 不规则形状用 filter: drop-shadow() 而非 box-shadow

本系列其他文章


原文发布于 陈广亮的技术博客,欢迎关注获取更多前端与 AI 开发内容。

智能体与工作流:从「想做一个应用」到「能跑通一条链」

智能体与工作流:从「想做一个应用」到「能跑通一条链」

这篇博客用睡前故事串起两件事:概念上分清智能体与工作流;操作上Coze 搭画布(大模型 / 循环 / 插件)、发布工作流,再 创建对话智能体 把链挂上去并用预览验收;最后对比 Coze 与 Node.js 自研。配图已上传 OSS(与本地 assets/agent-workflow/*.png 同源文件名,便于你用 img 脚本覆盖更新)。

本文结构(按需跳读)

部分 内容
概念与需求 智能体、工作流、为何不能一次调模型、串行链、mermaid
设计四步 只在脑子里/文档里「生成」草图,不涉及 Coze 点击路径
实践一 Coze 工作流画布:节点类型、从「开始」到「结束」的配置与截图
实践二 发布工作流创建智能体 → 编排里 挂工作流 → 预览与 发布智能体(截图)
收尾 Coze 优缺点、与自研关系、全文小结

智能体是什么:不止「和大模型聊一句」

智能体(Agent)在经典定义里,是能感知环境做决策再行动的系统。落到今天的大模型应用上,可以把它理解成:

以模型为「大脑」之一,再叠上检索、工具调用、业务规则、多模态输出等能力,按固定或可变策略运转,最终对用户给出一个完整结果单元(而不只是一段即时回复)的那一层产品形态。

用户输入、系统提示词、中间调用的搜索与 TTS 等,都是「环境」与「指导信息」;智能体要做的,是在这些约束下把多步事情办完。


工作流是什么:把能力排成一条(或多条)流水线

智能体里真正承担业务骨架的,是 工作流(Workflow):把「分析意图 → 查资料 → 写稿 → 润色 → 念出来」这类步骤,变成可执行、可观测、可迭代的节点图。

  • 串行工作流:上一步输出是下一步输入,适合故事生成这类主线清晰的任务。
  • 并行与分支:实际业务里常有「同时查多个源」「某步失败则降级」等,图会变复杂;入门阶段先把一条串行链画清楚,价值最大。

一句话:智能体是「产品视角」的说法,工作流是「工程视角」的实现方式。


用「6~8 岁睡前故事」理解:为什么不能只调一次文本模型

假设产品需求是:

  1. 用户给一个故事主题;尽量讲经典民间故事,没有经典则围绕主题创作
  2. 内容与语言要符合 6~8 岁认知,不「超龄」。
  3. 最后用亲切的语音把故事念出来。

若只做 chat.completions 一次调用,模型既可能胡编典故,又无法引用可靠原文,更没有声音。因此这个应用本质上需要多能力组合:

能力 作用
搜索 / 检索 找到故事原文或参考资料(常与 RAG / 检索增强生成 一起讨论)
写作与润色 在检索结果上写草稿,再按儿童口吻改写
语音合成(TTS) 把定稿文本变成可播放音频

这些能力不会自动长在一起,要靠你在产品里编排顺序、约定每步输入输出。这就是工作流要解决的问题。


核心工作流长什么样(串行示例)

把上面的需求压成一条链,可以是:

输入主题 → 生成检索 query → 搜索并整理材料 → 撰写草稿 → 语言与风格润色 → 语音合成 → 输出(文本 + 音频)

用流程图表示更直观:

flowchart LR
  A[用户主题] --> B[生成搜索 query]
  B --> C[搜索 / 整理]
  C --> D[写草稿]
  D --> E[儿童向润色]
  E --> F[TTS]
  F --> G[文本 + 语音]

实现顺序上的建议:先在纸上或文档里画出这条链,标清每一步的输入输出数据结构;再决定用 Coze 拖拽,还是用代码(例如 Node.js)写「调度器」。顺序对了,换工具只是换壳。


设计阶段的四步清单(还不打开 Coze 也能做)

你可以把下面四步当作任意业务的模板,先在文档或白板完成;它们回答的是「做什么」,而不是「在 Coze 里点哪个菜单」:

  1. 定义成功态:用户最终拿到什么?(一段 JSON、一篇带出处的文章、一条语音……)
  2. 拆能力:需要模型、搜索、数据库、支付、TTS 中的哪几项?哪些可以合并成一步?
  3. 定依赖与顺序:哪一步必须等上一步结束?哪一步可以并行?失败时是否重试或降级?
  4. 选承载:原型期用 Coze 等低代码快速验证;上线前再评估是否迁到 自研编排(数据隐私、细粒度调试、成本结构)。

做到这里,你已经「生成」了智能体的设计稿。接下来两节是落地:先在 Coze 里把 工作流画布 跑通,再 发布挂到智能体


实践一:在 Coze 里搭「睡前故事」工作流(画布)

扣子 Coze 提供了工作流编排:用节点把大模型、循环、插件等连成图,适合快速验证「这条链跑不跑得通」。下面配图来自同一套「睡前故事」示例画布,界面以你当前 Coze 版本为准;若菜单文案略有差异,对照节点职责即可。

节点类型与添加路径(和本文截图一致)

在 Coze 工作流画布上点 「添加节点」 时,可按下面方式选类型(不同版本菜单层级可能微调,核心是节点类型要对):

画布上的职责 添加节点时的选择 说明
开始 / 结束 无需添加 新建工作流后画布默认自带;只需配置入参、出参。
生成 query、撰写草稿、润色 大模型 三处都是「大模型」节点,分别改节点标题与提示词、输入输出即可。
搜索并整理内容(外层) 循环 先加循环节点,再在循环体内部加搜索用的插件节点。
循环体内的搜索 插件 → 必应搜索 每次迭代用当前 query 调必应,把结果汇总给后续大模型。
语音合成 插件 → 搜索文本转语音 将润色后的正文交给插件生成音频(插件名以控制台为准)。

下文按数据从左到右的顺序讲配置要点;你在菜单里选对的节点类型,就和「一步步实现」对上了。

画布总览:一条从主题到「文本 + 语音」的链

整体从左到右大致是:开始(默认)→ 生成 query(大模型)→ 搜索并整理内容(循环,循环体内必应搜索)→ 撰写草稿(大模型)→ 润色(大模型)→ 语音合成(插件:搜索文本转语音)→ 结束(默认)。多模态输出在「结束」节点里一次性返回给上层 Bot 或 API。

Coze 工作流画布总览:睡前故事智能体

1. 新建工作流

进入 Coze 控制台 → 工作空间资源库 → 新建 工作流,例如命名为 bedtime_story,描述写清「给 6~8 岁孩子讲睡前故事」。进入画布后,「开始」与「结束」是默认节点,不必在「添加节点」里再选一次;后面所有节点都是从「开始」往后串、最后收进「结束」。

「开始」节点:声明工作流对外的入参。示例里只暴露一个字符串 input(故事主题),后续大模型节点通过模板变量 {{input}} 引用。

开始节点:配置入参 input

2. 第一个大模型节点:从主题到「检索 query」

添加节点 → 大模型,将节点标题改为「生成 query」(名称可自定)。用于:根据用户输入分析意图,并输出一组搜索用 query(后续由循环消费)。

系统提示词可围绕「目标 + 分析方法 + 任务」来写,例如(节选思路):

  • 若主题是常见民间故事名,则生成便于检索原文的 query;
  • 否则结合文化背景生成能搜到参考资料的 query;
  • 明确输出格式要求(如字符串数组)。

用户提示词里使用 Coze 的模板变量,把「开始」节点的输入接进来,例如:

{{input}}

双花括号中的名字需与开始节点里定义的输入字段名一致(默认常为 input)。

输出变量建议配置两个(示例命名):

输出名 类型 含义
querys 字符串数组 多条检索 query
intent 字符串 对用户意图的简短概括

这里 intent 未必被后续节点消费,但让模型多输出一个「对自己有用」的字段,往往能起到链式思考(chain-of-thought)外显的效果,有助于提高 querys 质量——这是很多工作流里的小技巧。

联调小技巧:开发时可以把该节点输出直接连到 结束 节点,在结束节点里配置要暴露的变量,先验证「query 生成」是否稳定,再往下接搜索与写作。

下图可见:模型选用「豆包·2.0·pro」等;输入绑定「开始 → input」;输出解析为 JSON 字段(如 intentquerys 数组),供下一节点消费。

生成 query 节点:系统提示词、用户侧 {{input}}、JSON 输出 intent / querys

3. 循环 + 必应搜索:对多条 query 逐个检索

因为 querys 是数组,在「生成 query」后面 添加节点 → 循环;外层循环节点标题可写成「搜索并整理内容」一类,便于读图。

  • 循环类型:选「使用数组循环」;循环数组绑定上一大模型节点的 querys
  • 循环体内部:再点 添加节点 → 插件 → 必应搜索(或你工作区里可用的等价联网搜索插件)。每次迭代把当前元素映射为搜索的 querycount 控制条数;输出里的 data 等字段供循环汇总。
  • 输出映射:界面上常有经验顺序——先在循环体里把「必应搜索」节点接好、跑通,再回来配置循环节点对外的输出数组(否则没有可引用的中间结果)。

循环节点:数组绑定「生成 query → querys」,输出汇总检索结果

循环体内插件「必应搜索」:query 来自循环、count 控制返回条数

若不需要循环内的临时中间变量,可在 Coze 里按界面提示精简变量,避免图越来越乱。

4. 撰写草稿 → 润色(大模型)→ 语音合成(插件)→ 结束(默认)

在循环之后,把整理后的检索结果交给两个连续的大模型节点做「写稿 + 润色」,最后用插件出音。

撰写草稿添加节点 → 大模型。输入侧接入「搜索并整理内容」汇总后的材料;系统提示词约束「6~8 岁、经典尽量忠于原文」等;用户提示词用模板 参考资料:{{input}} 把变量喂进模型;输出 output(及可选 reasoning_content)供下一步使用。

撰写草稿节点:大模型,输入检索整理结果

润色:同样 添加节点 → 大模型。输入接 撰写草稿 → output;系统提示词切换为「温柔大姐姐给妹妹讲睡前故事」等人设;用户侧 故事材料:{{input}};输出仍为字符串 output

润色节点:大模型,承接草稿 output

语音合成添加节点 → 插件 → 搜索文本转语音(若控制台插件名称有细微差别,以实际列表为准)。将正文字段绑定 润色 → output;并按插件面板填写音色、cluster(如 volcano_tts)、app_id / app_token 等;输出里常见 link 指向生成音频 URL。

语音合成节点:插件「搜索文本转语音」,文本来自润色 output

结束:使用画布默认的「结束」节点即可;在配置里选 「返回变量」:例如 text 映射润色后的正文,audio 映射语音合成返回的 link(或平台等价字段),这样上层一次拿到「可读文本 + 可播音频」。

结束节点:返回变量 text(润色)与 audio(语音 link)

每一段的输入输出变量名要与前后节点对齐;逻辑顺序应与上文「核心工作流」示意图一致——你在 Coze 里是在「画图实现」同一张设计稿。


实践二:发布工作流,并挂到「对话智能体」

画布上的 工作流 解决「一条链怎么跑」;智能体(Bot) 解决「用户怎么对话触发这条链」。建议顺序:试运行并发布工作流 → 在资源库 创建智能体编排 → 技能 → 工作流 里添加已发布的工作流 → 预览与调试 验证触发与入参 → 发布智能体

试运行与发布工作流

bedtime_story(或你的工作流名)编辑页里先 试运行,确认「开始 → … → 结束」整条链无报错后,使用平台提供的 发布(或「上线」类)能力,把工作流变为 已发布 状态。只有发布后,智能体侧「添加工作流」列表里才容易稳定搜到它(具体按钮名称以 Coze 当前版本为准)。

在资源库创建智能体

进入 工作空间 → 资源库,右上角 「+ 创建」,选择 「创建智能体」(适用于对话式智能体)。

资源库中创建智能体入口

填写名片并确认

在弹窗里选 标准创建(或你需要的创建方式),填写 智能体名称功能介绍工作空间图标 等。示例中与睡前故事一致:儿童睡前故事 / 给 6-8 岁儿童讲睡前故事 等。点 确认 进入编排页。

创建智能体:名称、介绍、空间与图标

编排里挂载工作流技能

打开 编排,在 技能 区域找到 工作流 一行,点击右侧 「+」(提示为 添加工作流)。在列表中选中已发布的 bedtime_story,点 添加,把它挂到当前智能体上。这样用户发一句自然语言时,智能体才会按策略去 调用 你刚编排好的那条链。

编排页:技能 → 工作流 → 添加工作流

添加工作流弹窗:选择已发布的 bedtime_story

预览调试并发布智能体

右侧 预览与调试 里直接输入用户会说的主题(例如 「狼来了」)。若编排正确,应能看到 正在调用 bedtime_story 一类状态,并走完整条工作流(含你结束的 text / audio 等返回)。确认满意后,再在平台里 发布智能体,对外分享或接入渠道。

预览与调试:用户输入触发 bedtime_story 工作流

与「实践一」的关系:工作流 = 可复用的业务链;智能体 = 对话壳 + 默认模型 + 挂载的一条或多条工作流。先发布链,再把链挂到 Bot 上,用预览验证「用户一句话 → 工作流入参 input」是否对齐。


Coze 工作流的优缺点:适合当「哪一级」

优点

  • :复杂分支、流式输出、插件生态都能较快搭出可演示版本。
  • 省成本:适合创业者、团队做原型验证与需求对齐。
  • 可视化:非纯研发也能参与讨论「第几步该干什么」。

局限

  • 平台绑定:流程与数据多在 Coze 侧,对强隐私、强合规、专有部署的场景要慎重。
  • 节点内部偏黑盒:要做极致的数据结构优化、细粒度耗时分析时,不如代码透明。

因此常见节奏是:Coze 验证工作流是否合理 → 定型后用 Node.js(或其它后端) 把同一条 DAG 写成可维护的服务(与本仓库 server.js 编排多厂商 API 的思路一致)。理解「工作流原理」之后,换承载并不神秘。


小结:从草图到可对话的一条龙

  1. 先定义成功态与边界(对应上文「设计四步」前两条):用户拿什么结果、年龄与体裁等约束。
  2. 再画工作流(设计四步后两条 + mermaid):拆能力、定顺序、选承载。
  3. 实践一:Coze 画布:「大模型 → 循环 + 必应 → 大模型 ×2 → 搜索文本转语音」,开始/结束用默认节点。
  4. 实践二:上架:发布工作流 → 创建智能体 → 编排 → 技能 → 工作流 挂载 → 预览 → 发布智能体。
  5. 再决定要不要自研:原型通过后,用 Node.js 等复刻同一条 DAG(见本仓库 server.js 一类编排)。

智能体不是「多调几次模型」的代名词,而是**「多步能力 + 清晰编排」**的产物;设计稿 → 工作流画布 → 对话壳挂载 走完,就是从想法到可演示产品的完整一程。

React 19 正式发布:这一次,表单和服务器组件终于"原生"了

React 19 正式发布:这一次,表单和服务器组件终于"原生"了

2024 年 12 月,React 19 正式登陆 npm。这是一次真正意义上的全栈升级——不仅有给 UI 开发者的新 Hook 和表单能力,还有面向框架作者的 React Server Components 稳定支持。

本文不堆砌升级指南,只聚焦一个核心问题:React 19 到底带来了什么值得你花时间学的新特性?


一、Actions:表单提交终于不用自己写 pending 了

React 19 最核心的改动是引入了 Actions 这个概念。

它的背景很真实:用户提交一个表单 → 发 API 请求 → 等待响应 → 处理错误或跳转。过去这一切都需要开发者手动写 isPendingsetErrorsetIsPending 三件套。

// React 18:手动管理 pending 和错误
function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState(null);
  const [isPending, setIsPending] = useState(false);

  const handleSubmit = async () => {
    setIsPending(true);  // 手动设置 pending
    const error = await updateName(name);
    setIsPending(false); // 手动重置
    if (error) { setError(error); return; }
    redirect("/path");
  };

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>Update</button>
      {error && <p>{error}</p>}
    </div>
  );
}

Actions 的做法:把异步逻辑包进 startTransition,React 自动帮你处理 pending 状态。

// React 19:Actions 自动处理 pending + 错误
function UpdateName() {
  const [name, setName] = useState("");
  const [error, setError] = useState(null);
  const [isPending, startTransition] = useTransition();

  const handleSubmit = () => {
    startTransition(async () => {
      const error = await updateName(name);
      if (error) { setError(error); return; }
      redirect("/path");
    });
  };

  return (
    <div>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      <button onClick={handleSubmit} disabled={isPending}>Update</button>
      {error && <p>{error}</p>}
    </div>
  );
}

isPending 会自动跟随 transition 的状态变化,不需要手动 setIsPending


二、useActionState:连错误处理都包装好了

useActionState 是 Actions 的进一步封装,把最常见的"提交 → 等待 → 结果"模式再简化一层:

const [error, submitAction, isPending] = useActionState(
  async (previousState, formData) => {
    const error = await updateName(formData.get("name"));
    if (error) return error;  // 返回 error
    redirect("/path");
    return null;              // 成功返回 null
  },
  null,  // 初始状态
);

用法非常直觉:submitAction 就是你要调用的函数,error 是上次调用的结果,isPending 是自动管理的加载状态。


三、useOptimistic:乐观更新终于有官方方案了

"乐观更新"指的是:用户操作后立即显示预期结果,不用等服务器返回。比如点赞、发帖、修改用户名,都适合用这个模式。

function ChangeName({ currentName, onUpdateName }) {
  // 乐观 name:立即渲染,请求完成前显示用户预期的值
  const [optimisticName, setOptimisticName] = useOptimistic(currentName);

  const submitAction = async (formData) => {
    const newName = formData.get("name");
    setOptimisticName(newName);  // 立即更新 UI
    const updatedName = await updateName(newName);
    onUpdateName(updatedName);   // 服务器返回后自动切回真实值
  };

  return (
    <form action={submitAction}>
      <p>你的名字是:{optimisticName}</p>
      <input type="text" name="name" disabled={currentName !== optimisticName} />
    </form>
  );
}

如果请求失败超时,React 会自动回滚到 currentName,不需要手动处理。


四、<form> Actions:表单提交进入"声明式"时代

React 19 的 react-dom 新增了对 <form> 的原生支持,可以直接传一个函数给 action

function App() {
  const submitAction = async (formData) => {
    "use server";  // 这个函数会在服务器端执行
    await saveName(formData.get("name"));
  };

  return (
    <form action={submitAction}>
      <input type="text" name="name" />
      <button type="submit">提交</button>
    </form>
  );
}

提交成功后,React 会自动重置表单。如果需要手动重置,调用 requestFormReset() API 即可。


五、use:比 useContext 更灵活的资源读取方式

React 19 引入了全新 API use,它可以有条件地读取 Promise 和 Context:

读取 Promise(配合 Suspense)

import { use, Suspense } from 'react';

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);  // 挂起直到 resolved
  return comments.map(c => <p key={c.id}>{c.text}</p>);
}

function Page({ commentsPromise }) {
  return (
    <Suspense fallback={<div>加载中...</div>}>
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

读取 Context(支持 early return)

useContext 的痛点是不能在条件语句后调用use 解决了这个问题:

import { use } from 'react';
import ThemeContext from './ThemeContext';

function Heading({ children }) {
  if (children == null) return null;  // early return 了?
  const theme = use(ThemeContext);     // 照样能用 use
  return <h1 style={{ color: theme.color }}>{children}</h1>;
}

注意:use 不支持在 render 内部直接创建 Promise(会导致哨兵错误),需要从 Suspense 友好的框架或库获取已缓存的 Promise。


六、React Server Components:稳定支持,生产可用

React 19 包含了完整的 React Server Components(RSC) 能力,意味着:

  • 组件可以在服务器端运行一次(CI 构建时或每次请求时)
  • 服务器组件有零 bundle 体积(不会打到客户端 JS 里)
  • 可以直接在组件里 await 数据获取
// Server Component(服务器组件,默认支持)
async function ArticleList() {
  const articles = await db.query('SELECT * FROM articles');
  return (
    <ul>
      {articles.map(a => <li key={a.id}>{a.title}</li>)}
    </ul>
  );
}

配合 Server Actions(用 "use server" 标记的异步函数),可以做到从 Client Component 调用服务器端逻辑:

// 客户端调用服务器端函数,全程类型安全
async function updateName(name: string) {
  "use server";
  await db.update({ name });
}

七、其他值得注意的改进

改进 说明
ref 作为 prop 新版函数组件可以直接接收 ref 作为 prop,不再需要 forwardRef,未来会废弃 forwardRef
<Context> 作为 provider <Context.Provider> 可以直接写成 <Context value={...}>,旧的写法未来废弃
ref 回调支持清理函数 ref 回调可以返回清理函数,元素从 DOM 移除时自动调用
Hydration 错误更详细 报错信息直接显示服务器和客户端的 diff,不用再猜哪行出了问题
新的静态生成 API prerender / prerenderToNodeStream 改进静态站点生成,SSR 性能更好

八、React 19.2(2025年10月)更新速览

React 19.2 在 19 基础上新增了两个实验性功能:

  • Activity:类似 React 的"活动状态"追踪,适合实时协作类应用
  • React Performance Tracks:新的性能监控 API,帮助开发者精确分析组件渲染性能

这两个功能目前仍处于实验阶段,正式稳定支持尚需时间。


总结:React 19 解决了什么问题

React 19 的核心改进可以归结为两条线:

客户端:让表单、乐观更新、pending 状态这些常见模式开箱即用,不再需要 Copy/Paste 一堆样板代码。

全栈:Server Components + Server Actions 让前后端边界更清晰,同一个函数可以运行在服务器,返回结果给客户端,全程类型安全。

如果你的团队正在用 Next.js 14+ 或其他支持 RSC 的框架,React 19 的价值会非常明显。如果是纯客户端 React App,Actions + useOptimistic 也足以让表单开发体验提升一个档次。


参考来源

  • React 官方博客:react.dev/blog(2024/12/05)
  • React 19.2 Release Notes(2025/10/01)
  • React Conf 2025 Recap(2025/10/16)

【monorepo架构】前端 pnpm workspace详解

公众号:AI小揭秘

前端 pnpm workspace 架构详解

一篇帮你搞懂 pnpm workspace 的实战向教程,从「为啥要用」到「怎么配」全给你捋清楚;每个知识点都会讲清是什么、为什么、怎么用、注意啥,方便你系统学习、随时查阅、直接落地。


一、先聊聊:我们到底遇到了啥问题?

做前端久了,多包、monorepo、组件库联调这些事一多,就会踩到一堆具体又磨人的坑。下面把这些痛点拆开说:具体表现 → 典型场景 → 对你有啥影响。搞清楚这些,后面再看 pnpm workspace 解决啥就一目了然。

1.1 node_modules 膨胀,磁盘和时间都遭殃

具体表现:用 npm 搞 monorepo 时,根目录一个 node_modules,每个子包再来一个;或者多个独立项目各自一份。每个 node_modules 里,npm 会做扁平化:把子依赖提升到顶层,同一份包可能在不同项目的 node_modules 里各存一份,重复拷贝

典型场景:比如你有一个 monorepo,里面 5 个 app、3 个共享库,都用 React、lodash、一堆 Babel/Webpack 相关包。单项目 node_modules 可能就 400~600MB,monorepo 里再乘上包数量、加上提升带来的重复,轻松破 2GB。npm install 第一次全量装要几分钟,以后每次 npm ci 或清缓存重装,体感也很慢。

影响:占磁盘、拉代码慢、CI 缓存大、流水线耗时增加;本机多开几个项目,node_modules 动不动几十 GB。

1.2 依赖版本乱成一锅粥:幽灵依赖与冲突

幽灵依赖的定义:某个包没有在你自己的 package.jsondependencies / devDependencies 里声明,你却能在代码里 importrequire 到它。常见原因就是 npm 的扁平化:你装了 A,A 依赖 B,B 被提升到了项目根 node_modules,于是你的代码「意外」地能直接用 B。

典型场景:你习惯性 import _ from 'lodash',但从没在 package.json 里加过 lodash,因为它是某个依赖的子依赖,被提升上来了。后来你升级了那个依赖,人家不再依赖 lodash,或者换了版本,你这边没改一行业务代码就报错:找不到 lodash。更坑的是「本地能跑、CI 挂」:本地可能还有别的路径残留或缓存,CI 干净安装就炸。同理,删了某个你以为没用的依赖,结果别的地方一直隐式用着,一删就挂。

版本冲突:A 包要 React 18,B 包要 React 17,扁平化之后只能满足一边,另一边可能用了「不对」的版本,运行时才暴露问题,调试成本很高。

1.3 本地包联调贼麻烦:npm link 的坑

典型场景:你维护一个业务组件库,要在另一个前端项目里联调。通常做法是 npm link:在组件库目录 npm link,在业务项目里 npm link your-components。但经常会遇到:

  • 双实例问题:React、Vue 等对「单实例」有要求,link 过去可能出现两个版本,引发诡异 bug。
  • bin 路径:某些 CLI 或工具通过 node_modules/.bin 找可执行文件,link 后路径解析不对,跑不起来。
  • 不同 Node 版本 / 环境: link 的是「当时本机」的构建结果,换机器、换 Node、改点配置,行为可能不一致。

总之,改一下组件库就要反复 link、unlink、重装,体验很差,也容易忘步骤导致联调结果不可靠。

1.4 CI 又慢又占空间

典型场景:每次 CI 全量 npm install,没有跨项目或跨 job 的 store 复用;缓存 key 设计不当(例如只按 package.json 不按 lockfile),导致缓存命中率低,每次都几乎全量装。加上前面说的 node_modules 巨大,流水线耗时长、占用空间大,体验和成本都不好。


上面这些,本质都可以归为两类问题:一是多包怎么组织、怎么一起开发、怎么发布(项目结构 + 工作流);二是依赖怎么存、怎么解析、怎么隔离(存储与解析策略)。pnpm 的 workspace 就是在这两方面同时发力的方案之一:多包管理 + 更合理的依赖存储与解析。下面先把你可能最关心的——pnpm 底层是怎么干的——讲清楚,再回头看 workspace 具体解决了啥。


二、pnpm 底层原理:为啥能省空间、装得快、依赖还干净?

很多人只记住结论:「pnpm 省磁盘、快、没幽灵依赖」,但不知道它到底咋做到的。这一节把存储模型node_modules 结构说透,你后面看配置、看优缺点都会更有数。

2.1 全局 store:content-addressable + 硬链接

pnpm 有一个全局 store,所有安装过的包都会先放进这里,再通过硬链接挂到各个项目的 node_modules 里。

  • 存哪儿

    • Linux:默认 ~/.local/share/pnpm/store
    • macOS:默认 ~/Library/pnpm/store
    • Windows:默认 %LOCALAPPDATA%\pnpm\store(即 C:\Users\<你>\AppData\Local\pnpm\store
      若设置了 $XDG_DATA_HOME,Linux/macOS 会改用 $XDG_DATA_HOME/pnpm/store。可通过 .npmrcstore-dir 覆盖,例如 store-dir=D:\pnpm-store
  • content-addressable(按内容寻址)
    包在 store 里按内容哈希存,同一版本、同一份包只存一份。不同项目、不同 monorepo 子包,只要依赖的版本相同,都用这一份,去重、跨项目复用

  • 硬链接
    硬链接可以理解为「同一份文件的多个路径入口」,改一处全体生效,但不额外占磁盘。pnpm 从 store 把包硬链接到项目里的 node_modules/.pnpm/...,所以看起来每个项目都有一份,实际磁盘只存 store 里那一份。
    复制的区别:不占多余空间。和符号链接的区别:符号链接是「指向另一个路径」的小文件,硬链接是文件系统层面的多路径同一 inode,更省空间、也更稳定(删掉一个链接不会影响 store 里的那份,只要还有别的链接在)。

结果:同 monorepo、同样依赖,用 pnpm 时磁盘占用往往只有 npm 的一半左右(常见 benchmark 结论),二次安装时大量命中 store,pnpm install 明显更快。

2.2 node_modules 的真实结构:非扁平 + 严格依赖

npm 会把依赖扁平化提升到顶层,所以你能「意外」用到子依赖;pnpm 不这么做,结构是非扁平的。

目录结构示意(精简版):

  • 项目根目录的 node_modules/

    • 只放你直接声明的依赖(dependencies / devDependencies 里的包)。
    • 这些「包名」多数是符号链接,指向 node_modules/.pnpm/<pkg>@<version>/node_modules/<pkg>
  • node_modules/.pnpm/

    • 里面才是实际内容(或链到 store)。
    • 每个 package@version 一个目录,且每个包有自己的 node_modules,里面只装它自己的依赖
    • 子依赖不会提升到项目根 node_modules,所以你没法在业务代码里 require('某个未声明的子依赖')

严格依赖就是这样实现的:
只有package.json 里显式声明的包,才会出现在你项目的 node_modules 顶层(或子包自己的 node_modules 里)。未声明的包根本不在你可访问的路径下,require / import 会直接报错,从根上杜绝幽灵依赖

有些老旧工具会假设「所有依赖都在根 node_modules 扁平展开」,在 pnpm 默认结构下会找不到包。这时可以用 public-hoist-patternnode-linker=hoisted有限提升,相当于在「兼容旧工具」和「严格依赖」之间做权衡;提升多了,幽灵依赖风险又回来了,所以能窄就窄。

2.3 workspace 包怎么被链接进来?

当你在 package.json 里写 "@my/ui": "workspace:*" 时,pnpm 会:

  1. pnpm-workspace.yaml 定义的目录里找到对应包(如 packages/ui);
  2. 该包所在目录(源码目录)链接到 node_modules 里对应位置,不拷贝、不先打包

所以,你改 packages/ui 的源码,消费方(例如 apps/web立即可见,不用 npm link,也没有双实例、路径错乱那些破事。这就是 workspace 协议 带来的「本地包即源码」的联调体验。

2.4 和 npm / Yarn 的存储对比(简要)

  • npm:扁平化 + 每项目各自拷贝,多项目多份;易幽灵依赖;安装速度、磁盘占用都一般。
  • Yarn:经典模式类似 npm;Plug'n'Play 可选,但生态兼容性要看工具。
  • pnpm:全局 store + 硬链接 + 非扁平 node_modules,省空间、安装快、默认严格依赖。

差异主要在存储与解析策略,而不是「有没有 workspace」这个概念。


三、pnpm workspace 解决了什么问题?(深化版)

有了第二节的原理打底,这里直接说 workspace 在「多包管理」场景下,具体帮你解决了啥;每个点都往「能用、能查」上靠。

3.1 磁盘与安装

  • store + 硬链接:全 workspace 共享同一 store,同版本依赖只存一份;子包、apps 装依赖都是链过去,磁盘占用明显低于 npm 同规模 monorepo(约一半量级的说法很常见)。
  • workspace 包不占 store:像 @my/utils@my/ui 这种本地包,pnpm 只做链接到源码目录,不往 store 里塞,也不拷贝,改完即生效。
  • 安装速度pnpm install 在 monorepo 里通常比 npm install 快不少,尤其二次安装、CI 命中 store 时。

3.2 依赖隔离与一致性

  • 幽灵依赖
    pnpm 默认严格依赖,未声明就不能用。你刻意避免隐式依赖,配合 code review,能从根本上消灭「删了某依赖突然挂」「本地有 CI 没有」这类问题。
    若必须兼容旧工具,再考虑 public-hoist-pattern 有限提升,并清楚这会带来隐性依赖风险。

  • 版本统一

    • 单一 lockfile:整个 workspace 只有一个 pnpm-lock.yaml 在根目录,所有子包、所有环境的依赖解析都以它为准,版本全仓库一致,复现性高。
    • catalog(pnpm 9+):在 pnpm-workspace.yaml 里定义 catalog,给常用依赖约定版本(如 react: ^18.3.1),子包用 catalog: 引用,升级时只改一处,避免各包各自为政。
    • overrides:根 package.json 里可配 pnpm.overrides,强制某依赖在全 workspace 解析成指定版本,适合解决传递依赖冲突、安全修复等。

3.3 多包协作与发布

  • 统一装依赖、统一跑脚本:根目录一次 pnpm install,所有 workspace 包依赖都装好;用 pnpm -r run buildpnpm --filter ... 批量或定向跑脚本,配合根 package.jsonscripts,协作流程清晰。
  • 按需发布pnpm publish -r 可递归发布,结合 --filter 只发布改动的包;配合 changesets 做 version + changelog + publish,适合多包独立发版。
  • 权限与发包:可以按包名、按目录做 access 控制,和现有 npm registry 权限模型配合使用。

四、pnpm workspace 架构长什么样?

4.1 目录树与职责

下面是一个常见的 pnpm workspace 根目录结构,以及各部分的职责。

项目根目录
├── pnpm-workspace.yaml    # 声明哪些目录是 workspace 包(唯一、仅根目录)
├── package.json           # 根包:公共 devDependencies、批量脚本、overrides 等
├── pnpm-lock.yaml         # 全 workspace 唯一 lockfile,所有人、CI 共用一个
├── .npmrc                 # 可选:store-dir、node-linker、hoist 等
├── packages/
│   ├── ui/                # 如:组件库
│   ├── utils/             # 公共工具
│   ├── config-eslint/     # 共享 ESLint 配置
│   └── ...
└── apps/
    ├── web/               # 前端应用
    ├── docs/              # 文档站
    └── ...
  • package.json

    • 全仓库共用的 devDependencies(如 TypeScript、ESLint、Vitest、Prettier)。
    • 定义 scripts,用 pnpm -r--filter 批量或定向执行子包的 build、dev、test。
    • 根包通常 "private": true,不发布;可加 packageManagerpnpm.overrides 等。
  • pnpm-workspace.yaml

    • 唯一,只能放在根目录。
    • 通过 packages 数组声明哪些目录算 workspace 包(如 packages/*apps/*),只有这些才能被 workspace:* 引用。
    • pnpm 官方推荐用这个文件,而不是 package.jsonworkspaces 字段。
  • pnpm-lock.yaml

    • 全 workspace 共用一个,在根目录。
    • 锁死所有依赖(含 workspace 包解析结果),保证任意环境 pnpm install 结果一致。
  • packages/*

    • 一般放可复用库:组件库、工具库、配置包等。
    • 各自有 package.json,通过 workspace:* 相互依赖或被 apps/* 依赖。
  • apps/*

    • 一般放应用:前端项目、文档站、Demo 等。
    • 依赖 packages/* 时用 workspace:*,改库即生效。

有的项目还会加 tools/* 放脚本、CLI 等,本质上一样:在 pnpm-workspace.yaml 里写上对应 glob 即可。

4.2 命名与布局约定

  • packages:可复用、可能发布到 npm 的库;apps:入口应用、不发布或只发构建产物。
  • 何时拆 apps?当你明确有「多个应用 + 共享 packages」时,拆开更清晰;只有一两个 app 时,全放 packages 也没问题,按团队习惯来。
  • 依赖方向
    • 子包互相依赖、app 依赖子包,一律用 workspace:*
    • 禁止循环依赖(A 依赖 B,B 又依赖 A),否则安装、构建都会出问题。
    • 根包通常作为业务依赖,只提供脚本和公共 devDependencies。

4.3 workspace 包的解析与匹配机制

靠啥匹配?
pnpm 解析 workspace:* 时,只看 package.json 里的 name,和目录名、路径都无关。你写 "@my/ui": "workspace:*",pnpm 就会在 pnpm-workspace.yaml 声明的那堆目录里,找 name@my/ui 的包;找到就把该包所在目录链进 node_modules,找不到就直接报错,不会悄悄去 npm 装一个。

具体流程

  1. pnpm-workspace.yaml,收集所有匹配 packages 的目录(如 packages/*apps/*);
  2. 逐个读这些目录下的 package.json,拿到 name,建成一张 「name → 目录」 的映射;
  3. 解析依赖时,遇到 workspace:*workspace:^ 等,用依赖里的包名去这张表里查;
  4. 查到了 → 用该包所在目录做链接目标,链到当前包的 node_modules 里;
  5. 查不到 → 报错(例如 ERR_PNPM_NO_MATCHING_PACKAGE),安装中止。

所以:包名必须和依赖里写的一模一样packages/uiname 要是 @my/ui,别的地方才能 "@my/ui": "workspace:*";写成 @my/components 就匹配不上。

几种写法

  • workspace:*:匹配 workspace 里同名包的任意版本,并链到源码目录;开发联调最常用。
  • workspace:^workspace:~:按 semver 匹配 workspace 内版本;发布时会被替换成具体版本号(如 1.0.0),发布出去的 package.json 里不会还带着 workspace:
  • workspace:../packages/utils(相对路径):明确指向某个目录,不靠 name 匹配;适合临时调试或路径敏感的布局。

别名
可以用 "别名": "workspace:真实包名@*" 把 workspace 包挂到另一个名字下,例如 "react": "workspace:my-react@*"。发布时同样会替换成普通依赖形式。

找不到会怎样?
只会报错,不会回退到 npm 装。这样你才能确定:用的一定是本地的 workspace 包,没有误用远端的。

4.4 依赖图与构建顺序

workspace 里包和包之间的依赖关系,会形成一张有向图:谁依赖谁,一目了然。pnpm 跑 pnpm -r run build 这类递归命令时,默认按这张图的拓扑顺序执行:先跑被依赖的,再跑依赖别人的,避免「还没 build 完就被别人 require」的坑。

拓扑顺序是啥?
简单说:若 A 依赖 B,则一定执行 B 的 build执行 A 的 build。例如 utilsuiweb,顺序就是 utilsuiweb。同一层之间(比如多个 app 互不依赖)谁先谁后不保证,但层级不会乱。

默认行为

  • pnpm -r run build(以及 pnpm -r run <script>):按依赖图拓扑排序,再依次执行;没有 -r 时则只跑当前包。
  • pnpm -r --parallel run build不管顺序,所有包并行跑;跑 devtest 时常用 --parallel,但 build 一般要保证顺序,所以慎用 --parallel

怎么知道谁依赖谁?

  • 看各包 package.jsondependencies / devDependencies 里对 workspace 包、普通包的引用;
  • pnpm why <pkg> 看某包被谁依赖;pnpm list -r 看全 workspace 的依赖树(注意 list 默认不按拓扑序,按字母序);
  • 有些团队会接 Turborepo、Nx 等,用它们画依赖图、跑拓扑并行 build(同一层并行,层与层之间仍按依赖顺序)。

循环依赖
若出现 A → B → C → A,依赖图成环,拓扑排序搞不定,pnpm 会报错;安装、-r 执行都可能挂。所以必须保证 workspace 内无环,设计时就要避免「包互相依赖」。

4.5 安装与打包:workspace 如何工作

安装(pnpm install
根目录执行 pnpm install 时,大致会做这几步:

  1. 读 workspace 定义:解析 pnpm-workspace.yaml,得到所有 workspace 包目录(如 packages/*apps/*)。
  2. 收集包信息:逐个读这些目录下的 package.json,建 name → 目录 映射,并算出整棵依赖树(含对 npm 包的依赖)。
  3. 解析 workspace:*:遇到 workspace:* 等,按 4.3 的规则匹配到本地包目录,从 registry 拉包。
  4. 链接 workspace 包:把匹配到的本地包目录链到各包的 node_modules 里(符号链接或 junction),不拷贝、不往 store 塞;改源码立即生效。
  5. 装外部依赖:对 npm 上的包,按平时那套来:store + 硬链接,装到 node_modules/.pnpm 等位置。
  6. 写 lockfile:把所有依赖(含 workspace:* 的解析结果)写入根目录的 pnpm-lock.yaml

所以:workspace 包只做链接,不占 store;占磁盘、耗时的主要是外部依赖,而它们仍走 store 复用。

打包 / 构建(pnpm -r run build
构建改依赖安装方式,只是按依赖图顺序跑各包的 build 脚本:

  1. 算依赖图:根据各包 package.json 的依赖关系,得到有向图。
  2. 拓扑排序:排出「被依赖的在前、依赖别人的在后」的顺序(pnpm 内部用类似 graph-sequencer 的方式处理)。
  3. 依次执行:按该顺序对每个 workspace 包执行 pnpm run build(或你配的其它 script)。
  4. 若某包没有 build 脚本,pnpm 会报错或跳过该包,视配置而定。

因此:先装依赖,再构建;装依赖保证 node_modules 里 workspace 包、npm 包都就位,构建则按依赖顺序生成各包产物。
若用 --parallel,pnpm 会忽略拓扑顺序,所有包一起跑;适合 devtest 等不严格要求「被依赖的先跑」的场景,但 build 一般别开 --parallel,否则可能用到尚未 build 的依赖。

和 Turborepo / Nx 的关系
pnpm 只负责依赖安装 + 按拓扑序跑 script缓存、增量构建、远程缓存等,可交给 Turborepo、Nx。通常做法是:pnpm 管 install 和 workspace 链接,Turbo/Nx 管 build / test 的调度与缓存,两者一起用没问题。


五、优缺点一览(够直白版)+ 逐条详解

5.1 优点总览

说明
省磁盘、安装快 全局 store + 硬链接,避免重复存包;workspace 包用链接,不复制。
依赖干净 严格依赖,无幽灵依赖;lockfile 唯一,版本一致。
本地联调友好 workspace:* 直接链到源码,改即生效,无需 npm link
monorepo 友好 内建 workspace 支持,-r--filter 过滤、并行跑脚本很方便。
易于做权限与发布 配合 pnpm publish -r、changesets 做按包发布、权限控制。

详细说明

  • 省磁盘、安装快:原理即第二节的 store + 硬链接;workspace 包不进 store,只做链接。典型收益是 monorepo 磁盘占用和 pnpm install 耗时明显下降。
  • 依赖干净:严格依赖 + 单一 lockfile,少很多「删了某包就挂」「本地有 CI 没有」的玄学问题;注意若用了 public-hoist-pattern 等,要控制范围,否则又引入隐性依赖。
  • 本地联调:改 packages/ui 立刻在 apps/web 里生效,无需 link;注意跑 dev 的终端要在根目录或对应 app 目录,且已执行过根目录的 pnpm install
  • monorepo 友好pnpm -r--filter 能力足,再配合 Turborepo/Nx 做任务编排、缓存,体验更好。
  • 发布:按包发布、changesets 管理版本与 changelog,和现有 registry 流程兼容。

5.2 缺点 / 注意点总览

说明
和 npm 不完全兼容 部分工具假设「所有依赖扁平在根 node_modules」,可能报错,需适配。
学习与迁移成本 团队要搞懂 workspace、workspace:*pnpm-workspace.yaml--filter 等。
部分旧工具兼容性 极端老旧的构建/调试工具对 pnpm 的 node_modules 结构可能不友好。
需统一包管理 全 repo 必须用 pnpm,不能混用 npm/yarn,否则 lockfile、链接会乱。

详细说明

  • 和 npm 不完全兼容
    有些 Webpack 插件、老版 Babel、个别 CLI 会直接去根 node_modules 找包,pnpm 默认非扁平就可能找不到。处理办法:

    • node-linker=hoisted.npmrc)切回类 npm 扁平结构,会牺牲严格依赖;
    • 或只用 public-hoist-pattern 把有问题的包提升上来,尽量窄配。
  • 学习与迁移成本
    团队至少要会:workspace 概念、pnpm-workspace.yamlworkspace:* 协议、根目录 pnpm install--filter-r 的用法。可以抽半小时过一遍本文 + 官方文档,再在试点项目跑一遍。

  • 旧工具兼容性
    建议先小范围试点,遇到具体工具再查 pnpm 兼容性 或社区 issue;大多数现代前端工具已支持。

  • 统一包管理
    全仓库只用 pnpm,禁止 npm install / yarn。用 packageManager 锁版本,CI 里 corepack enable && pnpm install,避免有人用错包管理器导致 lockfile 或链接关系错乱。

适合:中大型前端项目、组件库 + 多应用、多包复用的 monorepo。
不大适合:单应用、没有多包复用需求的小项目;用 pnpm 单仓也能受益,但 workspace 收益有限。


六、应用场景(什么时候上 workspace?)

下面按场景拆:谁用、解决啥问题、推荐结构、关键配置、日常工作流。你对照自己项目,能直接套用或微调。

6.1 UI 组件库 + 多个业务项目

场景:你们有一个业务组件库,要同时支撑 2~3 个前端项目;组件库频繁迭代,需要在各项目里即时验证,而不是先发 npm 再装。

推荐结构

packages/
  ui/           # 组件库
apps/
  web-admin/
  web-h5/
  web-docs/     # 组件文档

web-adminweb-h5web-docs 都依赖 @my/ui,用 workspace:*

关键配置

  • pnpm-workspace.yamlpackages: ['packages/*', 'apps/*']
  • 各 app 的 package.json"@my/ui": "workspace:*"
  • scripts:如 "dev:docs": "pnpm --filter web-docs run dev""build:ui": "pnpm --filter @my/ui run build"

工作流
packages/ui → 在 apps/web-docs 或任意 app 里直接看效果;要发版时用 changesets 给 @my/ui 打 version、写 changelog、publish,各 app 再决定何时把 workspace:* 换成固定版本(若你们发 npm 的话)。

6.2 多应用 + 公共 utils / config

场景:多条产品线、多个前端应用,共享 utilsapi-clienteslint-config 等,希望统一版本、统一升级

推荐结构

packages/
  utils/
  api-client/
  config-eslint/
apps/
  app-a/
  app-b/

apps 按需依赖 @my/utils@my/api-clientconfig-eslint 被各 app 的 devDependencies 引用。

关键配置

  • pnpm-workspace.yaml:同上。
  • 各包用 workspace:* 互引;根 package.json 可放公共 devDependencies,或用 catalog 统一 React、TypeScript 等版本。
  • 根脚本:"build": "pnpm -r --filter './apps/*' run build",只构建 apps。

工作流
公共逻辑在 packages/* 改,各 app 自动用到;发版用 changesets 按包发布,各 app 通过 workspace:* 或固定版本消费。

6.3 文档站 + 组件库

场景:组件库配套一个文档站(如 VitePress、Docusaurus),文档站要直接引用源码里的组件做 Demo,而不是已发布的 npm 包。

推荐结构

packages/
  ui/
apps/
  docs/

docs 依赖 @my/uiworkspace:*

关键配置

  • 同上,packages + appsdocs"@my/ui": "workspace:*"
  • 文档站构建配置里保证能解析 packages/ui 的源码(通常 workspace 链接后没问题)。

工作流
改组件 → 跑 docs 的 dev,文档里实时看效果;发版时先发 @my/ui,再更新文档站里对版本的说明(若文档站自己也要发)。

6.4 全栈 monorepo(前后端同仓)

场景:前端 + Node 服务同仓,共享类型、常量或少量 utils,用同一套依赖管理。

推荐结构

packages/
  types/
  shared-utils/
apps/
  web/
  api/          # Node 服务

apiweb 都依赖 @my/types@my/shared-utilsworkspace:*

关键配置

  • pnpm-workspace.yaml 包含 packages/*apps/*
  • package.jsonscripts 里分别 --filter web--filter api 跑 dev/build。

工作流
typesshared-utils,前后端同时生效;各自部署时只构建对应 app,公共逻辑通过 workspace 链进去。


只要你存在「多个包 + 互相依赖 + 要一起开发」的需求,workspace 就很值得上;上面四种可以组合,比如「组件库 + 多应用 + 文档站」一起做。


七、详细教程:从零搭一个 pnpm workspace

下面按步骤做一遍,每步会写操作、预期结果、常见报错与排查。路径、包名和上文保持一致,你照抄就能跑通。

7.1 环境准备

  • 安装 pnpm

    npm install -g pnpm
    

    或用 Corepack(Node 16.9+):

    corepack enable
    corepack prepare pnpm@latest --activate
    

    建议用 pnpm 8.x 或 9.x,Node 18+ 更省心。

  • 校验

    pnpm -v
    node -v
    

    看到版本号即成功。

7.2 初始化根项目

mkdir my-workspace && cd my-workspace
pnpm init

会生成根目录 package.json。编辑成类似:

{
  "name": "my-workspace",
  "version": "1.0.0",
  "private": true,
  "packageManager": "pnpm@9.0.0"
}
  • private: true:根包不会被 pnpm publish 发出去,避免误发。
  • packageManager:锁死 pnpm 版本,配合 corepack enable 使用;可选但推荐。

7.3 配置 pnpm-workspace.yaml

项目根目录新建 pnpm-workspace.yaml

packages:
  - 'packages/*'
  - 'apps/*'
  • packages/*packages/ 下每个子目录(如 packages/uipackages/utils)都算一个 workspace 包。
  • apps/*:同理。
  • 只有被列出来的目录才会被 pnpm 当成 workspace 成员,才能被 workspace:* 引用。

预期:保存后暂无输出;之后 pnpm install 时 pnpm 会扫描这些目录。

7.4 创建子包目录并初始化

mkdir -p packages/ui packages/utils apps/web

然后逐个初始化(Windows 用户可用 PowerShell,mkdir -p 若不可用就分步 mkdir):

cd packages/utils && pnpm init && cd ../..
cd packages/ui   && pnpm init && cd ../..
cd apps/web      && pnpm init && cd ../..

Windows:若 mkdir -p 报错,可改为 mkdir packages\uimkdir packages\utilsmkdir apps\web 等分步创建;cd ../.. 在 PowerShell 中同样适用。)

每个子包会多一个 package.json。接下来改包名、入口、exports

packages/utils/package.json

{
  "name": "@my/utils",
  "version": "0.0.1",
  "main": "index.js",
  "exports": {
    ".": "./index.js"
  }
}

packages/ui/package.json

{
  "name": "@my/ui",
  "version": "0.0.1",
  "main": "index.js",
  "exports": {
    ".": "./index.js"
  }
}

apps/web/package.json

{
  "name": "web",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "dev": "echo \"dev placeholder\"",
    "build": "echo \"build placeholder\""
  }
}
  • exports:现代 Node 和打包器都认,用来明确入口,避免多余文件被引用;对 ESM、TS 等更友好。
  • webdev/build 先占位,后面验证完 workspace 再换成真实命令。

7.5 用 workspace:* 做包间依赖

packages/ui/package.json 里加依赖 @my/utils

{
  "name": "@my/ui",
  "version": "0.0.1",
  "main": "index.js",
  "exports": { ".": "./index.js" },
  "dependencies": {
    "@my/utils": "workspace:*"
  }
}

apps/web/package.json 里加依赖 @my/ui

{
  "name": "web",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "dev": "echo \"dev placeholder\"",
    "build": "echo \"build placeholder\""
  },
  "dependencies": {
    "@my/ui": "workspace:*"
  }
}

workspace:* 表示「用当前 workspace 里的同名包,追踪源码」;装完依赖后会链接到对应包目录,改代码即时生效。

7.6 根目录执行 pnpm install

务必在根目录执行(若不在根目录,先 cd 到项目根):

pnpm install

预期

  • 根目录出现 node_modules/pnpm-lock.yaml
  • packages/uiapps/webnode_modules 里会有 @my/utils@my/ui 的链接;
  • lockfile 里能看到对 workspace: 的解析,例如:
packages:
  '@my/utils@workspace:*':
    resolution: { directory: packages/utils, type: directory }
  '@my/ui@workspace:*':
    resolution: { directory: packages/ui, type: directory }

(省略其他字段;实际 lockfile 还有 nameversion 等。)

若报 ERR_PNPM_NO_MATCHING_PACKAGE:检查 pnpm-workspace.yamlpackages 是否包含对应目录,以及子包 name 是否和依赖里写的一致。

7.7 根 package.json 里加批量脚本

根目录 package.json 增加:

{
  "name": "my-workspace",
  "version": "1.0.0",
  "private": true,
  "packageManager": "pnpm@9.0.0",
  "scripts": {
    "dev": "pnpm -r --parallel run dev",
    "build": "pnpm -r run build",
    "build:web": "pnpm --filter web run build"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}
  • pnpm -r:递归在所有 workspace 包里执行同名 script。
  • pnpm -r --parallel:并行跑,适合 dev
  • pnpm --filter web run build:只对 web 包执行 build

Windows:若使用 PowerShell,scripts 里的双引号、&& 等和 Unix 略有差异,一般上述写法没问题;若遇解析错误,可改为 node 跑一小段脚本封装命令。

7.8 验证 workspace 链路

  • packages/utils/index.js 写:
module.exports = { add: (a, b) => a + b };
  • packages/ui/index.js 写:
const { add } = require('@my/utils');
module.exports = { add, hello: 'from ui' };
  • apps/web 里加个临时脚本验证。给 apps/web/package.jsonscripts 增加一行 "run:check",例如:
"scripts": {
  "dev": "echo \"dev placeholder\"",
  "build": "echo \"build placeholder\"",
  "run:check": "node -e \"const x=require('@my/ui'); console.log(x.add(1,2), x.hello)\""
}

保存后,在根目录执行:

pnpm --filter web run run:check

预期输出3 'from ui'
Cannot find module '@my/ui'

  • 确认在根目录执行过 pnpm install
  • 确认 apps/webdependencies 里有 "@my/ui": "workspace:*"
  • 看看 apps/web/node_modules/@my 下是否有 ui 的链接。

ENOENT 等路径类错误:

  • 检查 packages/utilspackages/ui 是否有 index.js,以及 package.jsonmain / exports 是否指向它。

验证通过后,可以把 webdev / build 换成真实命令(如 Vite、Next 等),继续开发。


八、配置说明(可查阅手册)

这一节把 pnpm workspace 相关配置 拆开讲:每项是啥、怎么配、适用场景、注意点。方便你以后查。

8.1 pnpm-workspace.yaml

  • 唯一性:整个仓库只放一个在根目录;pnpm 只认根目录这份。
  • packages
    • 字符串数组,每个元素是一个 glob 或具体路径。
    • 例:'packages/*''apps/*''tools/*',或 'packages/ui''packages/utils'
    • 只有匹配到的目录且其中包含 package.json,才会被当作 workspace 包。
  • 排除:部分版本支持 ! 排除,如 !'packages/legacy/*',以你用的 pnpm 文档为准。
  • package.jsonworkspaces:pnpm 官方推荐用 pnpm-workspace.yaml 定义 workspace,不用 workspaces 字段;若同时存在,以 pnpm-workspace.yaml 为准。

示例

packages:
  - 'packages/*'
  - 'apps/*'
  - 'tools/*'

8.2 根目录 package.json

  • private: true:根包不发布,避免误 pnpm publish
  • packageManager:如 "pnpm@9.0.0",锁包管理器 + 版本;需 corepack enable
  • scripts:结合 pnpm -r--filter 做批量或定向执行(见 8.6)。
  • pnpm.overrides:强制某依赖在全 workspace 解析成指定版本。
    {
      "pnpm": {
        "overrides": {
          "lodash": "4.17.21"
        }
      }
    }
    
    装依赖时 pnpm 会按 overrides 解析,并反映在 lockfile;适合修安全漏洞、解决传递依赖冲突。
  • catalog(pnpm 9+):在 pnpm-workspace.yaml 里定义(不是 package.json),子包用 catalog: 引用;见下方示例。

catalog 示例pnpm-workspace.yaml):

packages:
  - 'packages/*'
  - 'apps/*'

catalog:
  react: ^18.3.1
  react-dom: ^18.3.1

子包 package.json

{
  "dependencies": {
    "react": "catalog:",
    "react-dom": "catalog:"
  }
}

升级时只改 catalog 即可,所有用 catalog: 的包一起变。

8.3 workspace: 协议

  • workspace:*:用当前 workspace 里同名包任意版本,并链接到源码目录。开发联调默认用这个。
  • workspace:^workspace:~:按 semver 匹配 workspace 内版本;发布时 pnpm 会把它替换成实际版本号(如 1.0.0),所以发布到 npm 的包不会还带着 workspace:
  • 锁文件里的表现
    '@my/ui@workspace:*':
      resolution: { directory: packages/ui, type: directory }
    
    表示解析为本地 packages/ui 目录。

日常开发 workspace:* 就够用;若你们有严格的 semver 约束再考虑 ^ / ~

8.4 pnpm-lock.yaml

  • 唯一:整份 workspace 共用一个 lockfile,放在根目录。
  • 内容:锁住所有依赖(含 workspace 解析结果)的版本、完整性校验等。
  • 维护:用 pnpm installpnpm add 等变更依赖,不要手改
  • CI:务必把 pnpm-lock.yaml 纳入 git;CI 里 pnpm install --frozen-lockfile 可保证和 lockfile 完全一致,复现构建。

8.5 .npmrc(项目级)

放在项目根目录,只影响当前仓库。

常见项:

配置项 含义 示例
store-dir 全局 store 路径 store-dir=D:\pnpm-store
node-linker 链接方式 isolated(默认)/ hoisted
hoisted 已废弃,用 node-linker
public-hoist-pattern 哪些包提升到根 node_modules public-hoist-pattern[]=*eslint*
shamefully-hoist 全部提升,类似 npm true,易幽灵依赖,慎用
auto-install-peers 自动装 peerDependencies true
strict-peer-dependencies peer 未满足时报错 true
  • node-linker=hoisted:切回类 npm 扁平结构;兼容性好,但失去严格依赖。
  • public-hoist-pattern:只把匹配的包提升,例如 ESLint、Prettier 等工具常见需求;能窄就窄,减少幽灵依赖。
  • resolution-mode:依赖解析策略(如 lowest-direct);lockfile-include-tty 等可按需查文档。

示例(只提升部分工具):

public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*

8.6 --filter 完整语法

--filter 用来限定要对哪些 workspace 包执行命令,常与 pnpm -rpnpm add 等一起用。

写法 含义 示例
--filter <pkg> 指定包(按 name 或路径) pnpm --filter web run build
--filter <pkg>... pkg 以及依赖了 pkg 的所有包(dependents) pnpm -r --filter '@my/ui...' run build
--filter ...<pkg> pkg 以及 pkg 依赖的所有包(dependencies) pnpm -r --filter '...web' run build
--filter ...^<pkg> 依赖了 pkg 的包,不含 pkg 自身 pnpm -r --filter '...^@my/ui' run test
@scope/* 通配,所有 @scope 下包 pnpm -r --filter '@my/*' run build

示例

# 只给 web 装 lodash
pnpm add lodash --filter web

# 只给名字匹配 @my/* 的包跑 build
pnpm -r --filter '@my/*' run build

# 只给依赖了 @my/ui 的包跑 test(不含 @my/ui 自身,例如 web、docs)
pnpm -r --filter '...^@my/ui' run test

# 只给 web 及其依赖的 workspace 包跑 build(含 web 自身)
pnpm -r --filter '...web' run build

多 filter 可组合,例如 --filter '@my/ui...' --filter web 表示满足任一条件的包。仅要「依赖了某包」的包且排除该包本身时,用 ...^<pkg>

8.7 依赖提升(hoisting)

  • 默认:pnpm 不提升,依赖装在各自包的 node_modules.pnpm 下,严格隔离。
  • public-hoist-pattern:把匹配的包额外提升到根 node_modules,方便某些工具查找;提升范围越大,幽灵依赖风险越高。
  • shamefully-hoist:几乎全部提升,和 npm 类似;不推荐,除非你只是临时兼容旧工具。

对比

  • 不提升:根 node_modules 只有直接依赖,子依赖在 .pnpm 里,严格。
  • 提升后:根 node_modules 会出现被提升的包,未声明也可能被引用,所以要想清楚再开。

8.8 只用 pnpm / 锁包管理

  • 全仓库统一用 pnpm,禁止 npmyarn,否则 lockfile 和链接会乱。
  • package.jsonpackageManager,如 "pnpm@9.0.0"
  • 启用 Corepackcorepack enable;CI 里先 corepack enablepnpm install,保证版本一致。

九、和 npm / Yarn workspace 的简单对比

能力 npm workspaces Yarn workspace pnpm workspace
磁盘占用 高,多份拷贝 一般 低,store+硬链接
安装速度 一般 较快
node_modules 结构 扁平 扁平或 PnP 非扁平,.pnpm
幽灵依赖 易出现 默认严格,无
lockfile 格式 package-lock.json yarn.lock pnpm-lock.yaml
workspace 协议 workspace:* workspace:* workspace:*
配置方式 package.json workspaces package.json workspaces pnpm-workspace.yaml
filter/scripts 无内置 filter 有 workspaces 脚本 -r--filter
CI 缓存友好度 一般 较好 好(store 可复用)

何时选 pnpm workspace

  • 你打算认真搞 monorepo、多包复用,且关注磁盘、安装速度、依赖干净。
  • 愿意统一用 pnpm,并接受一点学习与迁移成本。

何时继续用 npm / Yarn

  • 现有 npm/Yarn 脚本、CI 已经很成熟,团队不想动。
  • 单仓库、包很少,workspace 收益有限,用 pnpm 单仓也不错,不必非上 workspace。

pnpm 的差异主要来自存储与解析策略,而不是「有没有 workspace」本身。


十、进阶与延伸

10.1 发版:按包发布 + changesets

  • pnpm publish -r:递归发布所有 未 private 的 workspace 包;可加 --filter 只发改动的,例如先 pnpm -r --filter '@my/ui...' run buildpnpm publish -r --filter '@my/ui'
  • changesets
    • changeset 管理 version bumpchangelog
    • 流程大致:改代码 → pnpm changeset 选包、选版本类型、写 changelog → pnpm changeset version 更新版本号 → pnpm publish -r 发布。
      这样多包独立发版、可追溯,很常见。

10.2 任务编排:Turborepo / Nx

  • package.jsonbuilddev 等可以交给 TurboNx 跑:他们按依赖图做拓扑排序,只跑该跑的,且能做远程/本地缓存,加速 CI 和本地构建。
  • pnpm workspace 只负责依赖安装与链接;Turborepo/Nx 负责任务调度,两者配合良好。

10.3 参考


十一、小结与 FAQ

11.1 小结

  • 问题:多包重复安装、幽灵依赖、本地联调麻烦、CI 又慢又占空间 → 本质是多包管理 + 依赖存储/解析没做好;pnpm workspace 针对这两点设计。
  • 原理:全局 store + 硬链接省空间、提速;非扁平 node_modules + 严格依赖防幽灵依赖;workspace 包链到源码,改即生效。
  • 架构:根 pnpm-workspace.yaml + 根 package.json + 唯一 pnpm-lock.yaml + packages/* / apps/*;子包用 workspace:* 互引,禁止循环依赖。
  • 配置:弄清 pnpm-workspace.yaml、根 package.jsonworkspace: 协议、.npmrc 常用项、--filter 用法即可上手。
  • 建议:按第七节亲手搭一遍,再在一个小项目里拆一个 utils 包用 workspace:* 引用,跑几天 dev/build,体感会很明显;后续再接 changesets、Turborepo 等。

11.2 FAQ

Q:子包的依赖装到根还是装到各自包?
A:各自 package.json 里声明,各自装;pnpm 会把实体放在 store、在对应包的 node_modules/.pnpm 下链接。根 package.json 只放全仓库共用的 devDependencies(如 TS、ESLint)和脚本。

Q:workspace:* 发布到 npm 前要改吗?
A:不用pnpm publish 时会把 workspace:* 等替换成实际版本号再发布,发布出去的 package.json 里是普通版本范围。

Q:Windows 下路径或脚本有问题怎么办?
A:

  • 路径尽量别带中文、空格;store-dir 等用正斜杠或系统可识别的形式。
  • 若在 PowerShell 里 scripts 报错,可试着用 node 写一个小脚本封装 pnpm -r / --filter 等命令,再在 scripts 里调该脚本。
  • 全局 pnpm、Node 建议用官方安装包或 nvm-windows,避免权限、路径异常。

如果你有具体的目录结构或 package.json 想优化,可以贴出来,按你现在的项目一步步改也行。

【Node】操作磁盘文件底层原理:从「点外卖」到「厨房流水线」

公众号:AI小揭秘

Node.js 操作磁盘文件底层原理:从「点外卖」到「厨房流水线」

你以为 fs.readFile 是让 Node 帮你「拿一下文件」?不,其实是:你下单 → 前台记单 → 后厨线程池做菜 → 做好了再叫你。这篇文章带你看看这份「外卖」是怎么从磁盘端到你手里的。


一、先别急着写代码:为什么你要关心「底层」?

很多人学 Node.js 的 fs 模块,会背两句口诀就收工:「用异步别用同步」「大文件用 Stream」
背完发现:

  • 为什么我 readFile 读个 10GB 的日志直接 OOM?
  • 为什么说 Node 是单线程,但一堆文件操作时还是会「卡」?
  • fs.promisesfs.readFile 回调版,底层是不是同一套?

(补充一句:文件 I/O 本身是等磁盘,不会占满 CPU;你觉得「卡」多半是线程池被占满,新任务在排队。)

要回答这些,就得知道:你的 JS 代码 → Node 的 C++ 绑定 → libuv → 操作系统 → 磁盘,这条链上每一环在干什么。
知道之后,你选 API、调参数、排查性能问题,都会心里有数——而不是靠「玄学调参」。

所以这篇东西的目标很简单:用尽量人话 + 一点幽默,把 Node.js 操作磁盘文件的底层原理讲清楚,顺便带上能跑的示例。


二、从你敲下 fs.readFile 开始:调用链长什么样?

你写的可能是:

const fs = require('fs');
fs.readFile('/tmp/hello.txt', (err, data) => {
  if (err) throw err;
  console.log(data.toString());
});

在底层,大概发生了这些事(简化版):

  1. JavaScript 层
    fs.readFile 是 Node 内置模块 fs 上的方法,实现里会做路径解析、编码处理、以及「把回调塞进某个流程里」。

  2. C++ 绑定层(Node 的 node_file.cc 等)
    JS 调用的其实是 C++ 里封装好的函数。这里会:

    • 把路径、回调、选项等转成 C++ 能用的东西;
    • 调 libuv 的 API,发起「异步文件读请求」。
  3. libuv 层
    libuv 是 Node 用来抽象「异步 I/O」的 C 库,跨平台(Windows / Linux / macOS 都靠它)。
    文件 I/O,它一般不会用 epoll/kqueue 这种「纯事件」机制,而是:
    把实际读文件的工作丢进「线程池」,在池里某条线程里做阻塞式的 read。
    所以:你以为的单线程,只是 JS 执行单线程;文件读写是在别的线程里阻塞地干的。

  4. 操作系统 → 磁盘
    线程池里的线程调的就是 OS 的 read(或类似)系统调用,由内核去和磁盘驱动、块设备打交道,把数据从磁盘读到内核缓冲区,再拷到用户态(Node 的 Buffer)。

  5. 回到 JS
    读完后,libuv 在某个时机(下一次事件循环的 I/O 阶段)把结果和你的回调塞回主线程,于是你的 (err, data) => { ... } 被调到了,data 就是那个 Buffer。

一句话:fs.readFile = 你在 JS 里下单 → Node 通过 libuv 把「读文件」这个任务派给线程池 → 线程池里的线程阻塞地读磁盘 → 读完再通过事件循环把结果回传给 JS。
所以「Node 单线程」指的是 JS 只在一个线程跑,磁盘 I/O 并不在主线程上阻塞,而是在线程池里。


三、事件循环与 libuv:谁在真正「干活」?

Node 的事件循环(event loop)是由 libuv 实现的。
和文件相关的部分可以粗分为:

  • Poll 阶段:等 I/O(网络、部分原生异步 API 等)。
  • 线程池完成回调:文件 I/O 在池里做完后,会在合适的阶段把「完成」事件插回事件循环,从而执行你传的 callback 或 resolve Promise。

所以:

  • 主线程(跑 JS 的那条):只负责执行你的 JS、跑定时器、处理已完成 I/O 的回调,不直接去读磁盘
  • 真正摸磁盘的:是 libuv 的线程池里那几条 worker 线程(默认 4 个,可配 UV_THREADPOOL_SIZE)。

这就是为什么:

  • 你写 fs.readFileSync 时,主线程会阻塞(因为同步 API 就是在主线程上直接调系统调用读文件);
  • fs.readFile 不会阻塞主线程,因为读是在线程池里做的。

四、线程池:别被「单线程」三个字骗了

默认情况下,libuv 的线程池大小是 4(和你的 CPU 核数无关,就是个固定值)。
所以:

  • 同时发 10 个 fs.readFile,只有 4 个在「真·读磁盘」,剩下 6 个在排队。
  • 线程池既管文件 I/O,也管部分 crypto、部分 DNS 等,所以文件多的时候你会感觉「怎么慢下来了」——因为池子被占满了。

可以通过环境变量把池子调大(建议不超过 CPU 数太多,否则上下文切换会变多):

# 例如把线程池改成 8
set UV_THREADPOOL_SIZE=8   # Windows
export UV_THREADPOOL_SIZE=8  # Linux/macOS
// 你可以自己试:同时读多个文件,看完成顺序
const fs = require('fs');
const files = ['file1.txt', 'file2.txt', 'file3.txt', 'file4.txt', 'file5.txt'];

files.forEach((f, i) => {
  fs.readFile(f, () => console.log(`第 ${i + 1} 个完成: ${f}`));
});
// 前 4 个往往先完成(线程池只有 4),第 5 个要等池里有空位

五、Buffer:内存里那块「黑板」

Buffer 是 Node 里表示「一块二进制数据」的类型,本质是 V8 外的一块连续内存(不经过 V8 堆的 GC,由 Node 自己管理)。
文件读进来、网络收来的裸字节,在 JS 里最常见的就是用 Buffer 拿着。

  • fs.readFiledata 就是 Buffer。
  • data.toString() 是把这块内存按指定编码(默认 UTF-8)解码成字符串。
  • 大文件一次性 readFile,就是一次性在内存里开一块和文件一样大的 Buffer——所以 10GB 文件会直接 OOM,和「底层」没关系,就是设计如此。

所以:大文件不要用 readFile,用 Stream 或 read(fd, buffer, offset, length, position) 分段读。

const fs = require('fs');

// 小文件没问题(记得先判断 err,否则文件不存在时 buf 为 undefined)
fs.readFile('small.txt', (err, buf) => {
  if (err) return console.error(err);
  console.log(Buffer.isBuffer(buf)); // true
  console.log(buf.length);            // 字节数
});

// 大文件:别这么干,用 createReadStream
// fs.readFile('huge.log', ...);  // 可能 OOM

六、文件描述符:操作系统给你的「取餐号」

文件描述符(file descriptor, fd) 是操作系统里「打开的文件」的整数句柄。
open 一个文件,内核给你一个 fd(比如 3、4、5),后续 read/write 都用这个数字来指代「哪个打开的文件」。

Node 里:

  • fs.open(path, flags, callback) 会得到 (err, fd)
  • fs.read(fd, buffer, offset, length, position, callback) 表示:从 fd 对应的文件里,从 position 开始,读 length 字节,放进 bufferoffset 位置,读完再回调。
  • 用完后要 fs.close(fd),否则会占用内核资源(可打开 fd 数量有限制)。

用 fd + read 可以自己实现「分段读大文件」:

const fs = require('fs');

function readInChunks(filePath, chunkSize = 64 * 1024) {
  const buffer = Buffer.alloc(chunkSize);
  let position = 0;

  fs.open(filePath, 'r', (err, fd) => {
    if (err) return console.error(err);
    function readNext() {
      fs.read(fd, buffer, 0, chunkSize, position, (err, bytesRead) => {
        if (err) return fs.close(fd, () => console.error(err));
        if (bytesRead === 0) return fs.close(fd, () => console.log('读完了'));
        console.log(`读到 ${bytesRead} 字节,position=${position}`);
        position += bytesRead;
        readNext();
      });
    }
    readNext();
  });
}

readInChunks('./some-big-file.log');

这里就是「底层」用法:自己控 Buffer、position、每次读多少,不依赖 readFile 一次性装进内存。


七、Stream:别一口吞,一口一口吃

Stream(流) 是「一块一块处理数据」的抽象:不要求一次性把整个文件读进内存,而是读一块、处理一块、再读下一块。

  • fs.createReadStream(path) 会打开文件,并返回一个 Readable 流
  • 底层一般也是用 fd + 多次 read,每次读满一块 Buffer(默认 64KB,可配),通过 data 事件或 read() 推给你。
  • 流内部有 highWaterMark:内部缓冲超过这个值就暂停从底层拉数据,避免内存爆掉。

所以:大文件用 ReadStream + 管道或逐 chunk 处理,就不会 OOM。

const fs = require('fs');

// 大文件拷贝:流式,内存占用稳定
function copyBigFile(src, dest) {
  const readStream = fs.createReadStream(src, { highWaterMark: 64 * 1024 });
  const writeStream = fs.createWriteStream(dest, { highWaterMark: 64 * 1024 });
  readStream.pipe(writeStream);
  writeStream.on('finish', () => console.log('拷贝完成'));
}

// 边读边处理:例如数行数
let lines = 0;
fs.createReadStream('huge.log')
  .on('data', (chunk) => {
    for (let i = 0; i < chunk.length; i++) if (chunk[i] === 10) lines++;
  })
  .on('end', () => console.log('总行数:', lines));

八、同步 vs 异步:什么时候该用谁?

方式 谁在干活 阻塞主线程? 适用场景
fs.readFile 线程池 小文件、配置等
fs.readFileSync 主线程 启动时读配置、脚本
createReadStream 线程池 + 事件 大文件、日志
fs.read(fd, ...) 线程池 需要精细控制位置/块

原则:

  • 能异步就异步,避免阻塞事件循环。
  • 只有在「进程刚启动、必须立刻拿到结果才能往下跑」的场景,才考虑用 Sync(例如读一个 config.json 再启动服务)。

九、新特性与最新知识点(Promise、FileHandle、io_uring)

1. fs.promises 与 async/await

Node 内置了基于 Promise 的 fs API,不用自己包一层:

const fs = require('fs').promises;

async function main() {
  try {
    const data = await fs.readFile('config.json', 'utf8');
    const config = JSON.parse(data);
    console.log(config);
  } catch (e) {
    console.error(e);
  }
}
main();

底层和回调版是同一套:都是走 libuv 线程池,只是把 callback 换成了 Promise 的 resolve/reject。

2. FileHandle:长期持有 fd 的「句柄」

fs.promises.open() 返回的是 FileHandle,可以多次读/写再关闭,适合「同一个文件反复读」:

const fsp = require('fs').promises;

async function readHeadAndTail(path, headBytes = 100, tailBytes = 100) {
  const handle = await fsp.open(path, 'r');
  const stat = await handle.stat();
  const head = Buffer.alloc(headBytes);
  const tail = Buffer.alloc(tailBytes);
  await handle.read(head, 0, headBytes, 0);
  if (stat.size > tailBytes) {
    await handle.read(tail, 0, tailBytes, stat.size - tailBytes);
  }
  await handle.close();
  return { head: head.toString(), tail: tail.toString() };
}

3. Linux 上的 io_uring(了解即可)

从 libuv 1.45 起,Linux 上部分文件 I/O 曾尝试用 io_uring 做更高性能的异步磁盘 I/O;后来默认又改回线程池。若要用 io_uring,需要在创建 event loop 时显式开启(如 UV_LOOP_USE_IO_URING_SQPOLL)。
对写业务代码的我们来说:知道「文件 I/O 主要走线程池」就够了,除非你在做极致性能调优。


十、综合示例:一个「带流式读 + 行解析」的日志处理器

下面这段把「底层」和「实用」串起来:用 ReadStream 读大日志,按行切分、逐行处理(不会把整个文件载入内存)。

const fs = require('fs');
const readline = require('readline');

async function processLargeLog(filePath, onLine) {
  const stream = fs.createReadStream(filePath, {
    highWaterMark: 256 * 1024, // 256KB 一块
  });
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
  for await (const line of rl) {
    await onLine(line); // 你可以在这里做解析、写库、发 MQ 等
  }
}

// 使用示例:只打印包含 "ERROR" 的行
processLargeLog('./app.log', async (line) => {
  if (line.includes('ERROR')) console.log(line);
}).then(() => console.log('处理完毕'));

这里用到的就是:fs 的 ReadStream(底层 fd + 分块 read)+ readline 按行消费,既不会 OOM,又符合「流式」的思维方式。


十一、小结:一张「外卖流程图」收尾

  • 你调 fs.readFile / createReadStream 等 → Node fs 模块(JS)
  • C++ 绑定libuv
  • libuv 把文件 I/O 丢给 线程池(默认 4 个 worker)
  • 线程池里 阻塞式 read内核 → 磁盘
  • 读到的数据放进 Buffer,完成后通过 事件循环 把回调/Promise 推回 主线程
  • 若是 Stream,则是多次「读一块 → 推一块」,由 highWaterMark 等控制背压

记住这几件事:

  1. 单线程指的是 JS,文件 I/O 在 libuv 线程池里。
  2. 大文件用 Stream 或 fd + read,别用 readFile 一把梭。
  3. Buffer 是那块「装字节」的内存;fd 是操作系统给你的「取餐号」。
  4. 新代码优先用 fs.promisesFileHandle,逻辑更清晰;底层和回调版一致。

如果你愿意再往深挖,可以看:

这样,下次有人问「Node 读文件到底是同步还是异步」「为什么我读大文件会崩」,你就能从事件循环讲到线程池、从 Buffer 讲到 Stream,顺便用「外卖下单 → 后厨线程池 → 取餐号 fd」的比喻把对方讲懂。
祝写 Node 少踩坑,磁盘 I/O 稳如狗。

别再手搓 Skill 了,用这个工具 5 分钟搞定

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。


说实话,第一次看到 "Skill" 这个词,我也有点懵。是不是又要写很多代码?是不是只有程序员能玩?

后来自己上手做了几个才发现——完全不是那么回事。Skill 更像是:把一件你本来就会做的事,写成一套能反复执行、不会乱跑的步骤

就说做饭吧。第一次做某道菜,你得一边看菜谱一边试探;做多了流程就固定了——先备料,再处理,最后出锅。这时候让别人帮你做,你大概会说"就按这个步骤来,别自己发挥"。Skill 干的就是这事,只是对象从「人」变成了「模型」——让大模型也照着这个步骤来,别自己发挥。

一个 Skill 到底长啥样?

先记住一句话:一个 Skill,本质上就是一个文件夹。

最简单的结构:

my-skill/
├── SKILL.md        # 说明:什么时候用、输入输出是什么
├── scripts/        # 执行:真正跑的逻辑
└── references/     # 参考:示例输入输出

SKILL.md 是最核心的——告诉模型"这件事干嘛用的、什么时候用、输出长啥样"。

你可以把它当成一份「使用说明书 + 注意事项」。

Skill 能解决什么问题?

举个例子,之前我开放了一个去水印下载鸭工具,同时写了份接口文档。但说实话,调用接口的步骤是固定的——传参数、发请求、解析返回。就这几步,每次都要重复。

调用步骤:

  1. 传参:token、url
  2. 发请求:
curl -X GET "https://nologo.code24.top/api/open/parse?url=https%3A%2F%2Fv.douyin.com%2Fxxxxx" \
  -H "Authorization: your-token"
  1. 解析返回数据

就这三步,每次都要重复一遍,挺烦的。 图片1.png

写成 Skill 之后,这些步骤直接固化下来,AI 碰到要调去水印接口的场景,自己就跑完了,不用你再手动复制粘贴。

其中 token、url 怎么获取,直接在 SKILL.md 里写清楚,或者丢个文档链接就行。

image.png

这个去水印解析的 Skill 已经开源了:github.com/CatsAndMice…,名字叫 nologo-open-api

效果测试成功:

image 1.png

但说实话,手搓还是有点麻烦

概念不难理解,但真要自己从头写一个 Skill,还是得折腾——要想触发条件,要写 SKILL.md,要调试……

有没有更省事的方法?

有。用 skill-creator。

skill-creator 是什么?

Skill-Creator是一款专为开发者设计的Skill创建向导,旨在简化开发流程。其便捷性和实用性已得到广泛验证,在SkillHub上的下载量已突破7.5万。

地址:www.skillhub.cn/skills/skil…

image 2.png

说到 SkillHub,这事儿还有点意思:

安装 Skill-Creator 特别简单——直接跟它聊天就行,它会一步步指导 AI 帮你创建技能

举个例子:

你:"用 skill-creator 帮我创建一个提取小红书链接的 Skill"

它:"好的,我来帮你创建。先告诉我:这个 Skill 要处理什么类型的链接?"

你:"抖音、小红书都行,主要提取无水印的视频地址"

它:"明白了。输出格式要不要加上 metadata?我给你两个选项……"

就这么一来一回,你描述需求,它帮你搞定剩下的——写 SKILL.md、搭目录结构、甚至调试代码。

说白了就是:你出想法,它帮你落地。

总结

Skill 的核心是把一件事的"固定流程"写成可反复执行的步骤,让模型按规程稳定产出,避免每次都从零复制粘贴、手动操作。像调用去水印接口这类标准化流程,尤其适合沉淀成 Skill 直接复用;而如果你不想从触发条件、SKILL.md 到目录结构都自己手搓,skill-creator 这种对话式向导能把创建与落地成本降到最低。

【pnpm 】pnpm 执行 xxx 的 底层原理

公众号:AI小揭秘。

pnpm install / pnpm run dev / pnpm run build 底层原理

讲清楚 pnpm ipnpm run devpnpm run build 在底层做了什么:执行步骤、数据流、以及它们如何与 lockfile、store、node_modulespackage.json scripts 配合。附流程图方便对照。


一、总览:三条命令分别干啥

命令 缩写 主要职责
pnpm install pnpm i 解析依赖 → 拉包/复用 store → 算目录结构 → 链接到 node_modules
pnpm run dev 执行 package.jsondev 脚本(及 predev / postdev),通常跑开发服务器
pnpm run build 执行 build 脚本(及 prebuild / postbuild),通常做生产构建

pnpm run 的底层逻辑对 devbuild完全一致,只是脚本名不同;差异来自你在 scripts 里写的具体命令(如 vitenext build)。


二、pnpm installpnpm i)底层原理

2.1 核心目标

  • 根据 package.json(及 workspace 的 pnpm-workspace.yaml)确定要装哪些包、哪些版本。
  • pnpm-lock.yaml 锁定解析结果,保证可复现。
  • 把包实体放进全局 store,再通过硬链接 + 符号链接挂到项目的 node_modules,避免重复拷贝。

2.2 整体执行流程(高层)

flowchart TD
    Start[pnpm install] --> ReadLock{存在 pnpm-lock.yaml?}
    ReadLock -->|否| Resolve[依赖解析,读 package.json 等]
    ReadLock -->|是| ParseLock[解析 lockfile]
    Resolve --> Fetch[从 registry 拉元数据与 tarball]
    Fetch --> BuildTree[构建依赖树]
    BuildTree --> WriteLock[写入/更新 pnpm-lock.yaml]
    ParseLock --> LockOK[锁内容与 package.json 一致?]
    LockOK -->|否且非 frozen| Resolve
    LockOK -->|是或 frozen 通过| CalcStruct[计算 node_modules 目录结构]
    WriteLock --> CalcStruct
    CalcStruct --> Store[包入 store 或复用已有]
    Store --> Link[硬链接到 .pnpm, 符号链接到 node_modules]
    Link --> Done[安装完成]

2.3 分阶段说明

阶段一:Lockfile 与依赖解析
  1. 读 lockfile

    • 若存在 pnpm-lock.yaml,先解析;得到「包名 → 解析后版本、integrity、resolved」等映射。
  2. package.json 对齐

    • 对比 package.json(及 workspace 子包)的 dependenciesdevDependencies 与 lockfile。
    • --frozen-lockfile:若不一致直接失败,不改 lockfile、不写 node_modules
    • 未 frozen:若不一致则重新解析,再更新 lockfile。
  3. 依赖解析(无 lockfile 或需要更新时)

    • registry(默认 npm)拉取元数据,按 semver 解析版本;workspace 内 workspace:* 等解析为本地包。
    • 递归处理传递依赖,得到整棵依赖树
    • 若有 overridescatalog 等,在此阶段应用。
  4. 写回 lockfile

    • 将解析结果写回 pnpm-lock.yaml--lockfile-only 时只做这一步,不进行后续链接)。
阶段二:目录结构计算
  1. 计算 node_modules 布局
    • 确定哪些包放在 node_modules(直接依赖)、哪些只在 .pnpm 下、以及 符号链接 的指向。
    • 满足 非扁平、严格依赖:未声明的包不会出现在项目可访问路径下。
阶段三:Store 与链接
  1. Store 存取

    • 实体存到 全局 store(默认 ~/.local/share/pnpm/store 等,可 store-dir 配置)。
    • 内容寻址:同版本、同 integrity 只存一份;缺少则从 registry 下载 tarball 写入 store。
  2. 硬链接到 .pnpm

    • node_modules/.pnpm 下按 package@version 建目录,包内文件以硬链接从 store 链出。
    • 每个 package@version 有自己的 node_modules,里面只放它自己的依赖的符号链接。
  3. 符号链接到「使用方」的 node_modules

    • node_modules:项目直接依赖的包,符号链接到 .pnpm/<pkg>@<version>/node_modules/<pkg>
    • workspace 包workspace:* 解析出的本地包,链接到源码目录,不占 store。

2.4 pnpm install 流程简图(按阶段)

flowchart LR
    subgraph Phase1 [阶段一 解析]
        A1[读 package.json] --> A2[读/解析 lockfile]
        A2 --> A3{一致?}
        A3 -->|否| A4[解析 + 拉 registry]
        A4 --> A5[写 lockfile]
        A3 -->|是| A5
    end

    subgraph Phase2 [阶段二 结构]
        B1[算依赖树] --> B2[算 node_modules 布局]
    end

    subgraph Phase3 [阶段三 存储与链接]
        C1[store 取/存包] --> C2[硬链接到 .pnpm]
        C2 --> C3[符号链接到 node_modules]
    end

    Phase1 --> Phase2 --> Phase3

2.5 小结

  • pnpm i = 解析(含 lockfile)→ 算结构 → store + 硬链接 + 符号链接。
  • Workspace 下会多一步:解析 pnpm-workspace.yaml、处理 workspace:*,再统一算布局、链接。

三、node_modules 目录结构与执行相关文件

本节把 pnpm install 完成后 node_modules 里有哪些目录和文件、pnpm run dev / pnpm run build 执行时又会用到其中哪些,按「目录 → 文件 → 执行逻辑」列清楚,并详细列出与执行相关的文件清单

3.1 node_modules 顶层目录一览

以单包项目、依赖了 vitelodash 为例,项目根目录下的 node_modules 大致长这样:

<项目根>/node_modules/
├── .bin/                    # 可执行命令的入口(见 3.3)
│   ├── vite                 # Unix 下执行 vite 时实际跑的文件
│   ├── vite.cmd             # Windows CMD
│   ├── vite.ps1             # Windows PowerShell
│   ├── tsc
│   ├── tsc.cmd
│   └── ...
├── .modules.yaml            # pnpm 元数据(store 路径、layout 版本等)
├── .pnpm/                   # 所有包实体所在处(硬链接到 store,见 3.2)
├── vite                     # 符号链接 → .pnpm/vite@x.x.x/node_modules/vite
├── lodash                   # 符号链接 → .pnpm/lodash@x.x.x/node_modules/lodash
└── ...
  • 直接依赖(如 vitelodash):在顶层以包名出现,实际是符号链接,指向 .pnpm/<包名>@<版本>/node_modules/<包名>
  • .bin:下面是对应各包 bin 字段的可执行入口(脚本或符号链接),pnpm run dev / pnpm run build 时 PATH 里会带上这个目录。
  • .modules.yaml:pnpm 自己用的元数据,记录 store 路径、layout 版本等,run 不读它,install 会写。

3.1.1 与「执行」相关的 node_modules 内文件详细清单

下表按路径列出 pnpm run dev / pnpm run build 执行链路中会读、会执行的 node_modules 内文件与目录;「执行逻辑」一列说明该文件在运行时的作用。

路径 类型 谁创建 执行时作用 / 执行逻辑
node_modules/.bin/ 目录 pnpm install 被加入 PATH 前面;shell 解析 vitetsc 等命令时在此目录查找可执行文件。
node_modules/.bin/vite 文件(脚本或符号链接) pnpm install(根据 vite 的 bin 字段) Unix/macOS:被 shell 执行。若为脚本,首行 shebang 调 node,正文调包内入口;若为符号链接,指向 .pnpm/vite@x.x.x/node_modules/vite/dist/node/cli.js 等,由 node 执行。
node_modules/.bin/vite.cmd 文件(批处理) pnpm install Windows CMD:执行时用 node "%~dp0\..\vite\dist\node\cli.js" 等形式调包内入口(%~dp0 为 .cmd 所在目录)。
node_modules/.bin/vite.ps1 文件(PowerShell) pnpm install Windows PowerShell:脚本内用 node $PSScriptRoot\..\vite\dist\node\cli.js 等调包内入口。
node_modules/.bin/tsc 文件 pnpm install(typescript 的 bin) 同上逻辑,最终执行 node .../typescript/bin/tsc 或 tsc.js。
node_modules/.bin/tsc.cmd / .ps1 文件 pnpm install Windows 下执行 tsc 时命中的 wrapper。
node_modules/.pnpm/ 目录 pnpm install 存所有包实体;run 不直接遍历,而是通过 .bin 里的 wrapper 间接执行到其下某包的 bin 入口文件
node_modules/.pnpm/vite@5.4.0/node_modules/vite/ 目录(硬链接到 store) pnpm install 包本体;.bin/vite 的 wrapper 最终会 node 这个目录下 package.json#bin 指定的入口(如 dist/node/cli.js)。
node_modules/.pnpm/vite@5.4.0/node_modules/vite/package.json 文件 包自带 定义 bin 入口路径;pnpm 安装时据此在 .bin 下生成 wrapper;运行时由 wrapper 或 node 间接读到入口路径。
node_modules/.pnpm/vite@5.4.0/node_modules/vite/dist/node/cli.js 文件 包自带 vite 的 CLI 入口;.bin/vite(或 .cmd/.ps1)最终执行 node .../cli.js,即此文件。
node_modules/.pnpm/typescript@x.x.x/node_modules/typescript/bin/tsctsc.js 文件 包自带 tsc 命令的真实入口;.bin/tsc 最终执行此文件。
node_modules/vite 符号链接 pnpm install 指向 .pnpm/vite@x.x.x/node_modules/viterun 时若脚本里用 node 的 require/import 解析 vite,会走到此链接再到 .pnpm 下包本体。
node_modules/.modules.yaml 文件 pnpm install 记录 store 路径、layout 版本;仅 install 使用run 不读。

目录小结

  • 执行 dev/build 直接用到package.json(scripts)、node_modules/.bin/*(wrapper)、.pnpm/<包>@<版本>/node_modules/<包>/bin 入口文件
  • 间接用到:顶层 node_modules/<包名> 符号链接(Node 解析 require('vite') 等时)、.pnpm 下各依赖的 node_modules(运行时模块解析)。

3.2 .pnpm 目录结构(包实体与依赖链)

.pnpm 里才是「包的真实内容」所在位置(内容来自 store 的硬链接)。每个 package@version 一个目录,且每个包有自己的 node_modules,只放自己声明的依赖的符号链接。

示例(项目依赖 vitevite 又依赖 esbuild 等):

node_modules/.pnpm/
├── vite@5.4.0
│   └── node_modules/
│       ├── vite          # 指向 store 的硬链接(包本体)
│       ├── esbuild       # 符号链接 → ../../esbuild@x.x.x/node_modules/esbuild
│       ├── rollup        # 符号链接 → ...
│       └── ...
├── esbuild@0.19.x
│   └── node_modules/
│       └── esbuild       # 指向 store
├── lodash@4.17.21
│   └── node_modules/
│       └── lodash
└── ...
  • <包名>@<版本>/node_modules/<包名>:包本体(目录或硬链接到 store 的目录)。
  • <包名>@<版本>/node_modules/<依赖名>:该包的依赖,以符号链接指到 ../../<依赖名>@<版本>/node_modules/<依赖名>
  • 根目录的 node_modules/vite:符号链接到 .pnpm/vite@5.4.0/node_modules/vite,所以你在代码里 import 'vite' 时,Node 解析到的就是 .pnpm 里这一份。

执行逻辑

  • pnpm install 只写 storenode_modules(含 .pnpm 与顶层符号链接、.bin);不执行任何业务脚本。
  • pnpm run dev / pnpm run build 不会去「遍历 .pnpm」;它们只是执行 package.json 里配置的命令,命令里若写 vite,就会通过 PATH 找到 node_modules/.bin/vite,再由该文件间接执行到 .pnpm 里对应包的入口

3.3 .bin 目录:有哪些文件、怎么被执行

.bin 下的文件来自各依赖包 package.jsonbin 字段。pnpm 在 install 阶段会为每个 bin 项在 node_modules/.bin 下生成可执行入口,名字即 bin 的 key(如 vitetsc)。

平台 / 类型 文件名示例 说明
Unix / Linux / macOS vitetsc(无后缀) 一般为脚本(shebang 调用 node)或符号链接到包内 bin 文件。
Windows CMD vite.cmdtsc.cmd 批处理,内部通常用 node "%~dp0\..\vite\dist\cli.js" 等形式调包内入口。
Windows PowerShell vite.ps1tsc.ps1 PowerShell 脚本,同样会去调包内对应 js。

.bin 下典型文件内容示例(执行逻辑)

  • Unix:node_modules/.bin/vite(脚本形式时)
    内容通常类似:

    #!/usr/bin/env node
    require('../vite/dist/node/cli.js')
    

    或直接为符号链接,指向 .pnpm/vite@5.4.0/node_modules/vite/dist/node/cli.js。执行时:shell 调起该文件 → 若为脚本则 #!/usr/bin/env node 导致用 node 执行本文件,进而 require 包内 cli.js;若为符号链接则 node 执行链接目标(即 cli.js)。

  • Windows CMD:node_modules/.bin/vite.cmd
    内容通常类似:

    @echo off
    node "%~dp0..\vite\dist\node\cli.js" %*
    

    执行逻辑%~dp0 为当前 .cmd 所在目录(即 node_modules/.bin),..\vite 为顶层符号链接 vite(在 pnpm 下会解析到 .pnpm 里对应包),最终用 node 执行 cli.js%* 把命令行参数原样传给 cli.js。

  • Windows PowerShell:node_modules/.bin/vite.ps1
    逻辑类似,用 $PSScriptRoot 定位到 .bin,再 node 执行上一级 vite 下的入口 js。

执行逻辑(以 pnpm run dev 且 scripts.dev 为 vite 为例)

  1. pnpm 在当前包package.json 里读到 scripts.dev = "vite"
  2. pnpm 把 <包目录>/node_modules/.bin(及 workspace 根同路径)加到 PATH 前面,再在子 shell 里执行 vite
  3. 系统在 PATH 里找到第一个名为 vite 的可执行文件:
    • Unix:即 node_modules/.bin/vite(无后缀),可能是脚本或符号链接;
    • Windows CMD:会找 vite.cmd;PowerShell 可能用 vite.ps1
  4. 执行该文件:
    • 若是脚本,内容通常类似 #!/usr/bin/env node + 调 node <包内入口>,或直接 node path/to/vite/dist/node/cli.js
    • 若是符号链接,会指向 .pnpm/vite@x.x.x/node_modules/vite 下的 bin 入口(如 dist/node/cli.js),再由 node 执行该 js。
  5. 最终实际运行的是 node + .pnpm/vite@x.x.x/node_modules/vite 里声明的 bin 入口文件

因此:执行链路 = package.json#scripts.dev → shell 执行 vite → PATH 解析到 node_modules/.bin/vite(或 .cmd/.ps1)→ 该文件内部执行 node + .pnpm 里 vite 的 bin 入口

执行时文件与目录关系(示意)

flowchart LR
    A[package.json] -->|读 scripts.dev/build| B[命令字符串 vite 等]
    B --> C[PATH 含 node_modules/.bin]
    C --> D[node_modules/.bin/vite]
    D --> E[.pnpm/vite@x.x.x/node_modules/vite 下 bin 入口]
    E --> F[node 执行 cli.js]

3.4 执行 dev / build 时涉及的文件与目录(按顺序)

步骤 类型 路径 / 文件 作用
1 <包目录>/package.json 确定当前包、查 scripts.dev / scripts.build 等。
2 scripts.predev / scripts.dev / scripts.build 得到要执行的命令字符串(如 vitevite build)。
3 环境 <包目录>/node_modules/.bin<workspace根>/node_modules/.bin 被 pnpm 追加到 PATH 前面。
4 执行 node_modules/.bin/vite(或 vite.cmd / vite.ps1 shell 解析 vite 时命中的可执行文件。
5 执行 node_modules/.pnpm/vite@x.x.x/node_modules/vite/dist/node/cli.js(以 vite 为例) .bin 里的 wrapper 最终用 node 执行的真实入口
6 该包及其依赖下的 package.jsonnode_modules/... Node / Vite 等运行时按模块解析规则继续读,与 pnpm 无直接关系。

3.4.1 按「是否参与执行」区分的 node_modules 目录一览

下面用一棵更完整的目录树,标出执行 dev/build 时直接参与的目录/文件(✅)与仅 install 使用、run 不读的(○):

<项目根>/node_modules/
├── .bin/                          ✅ run 时 PATH 包含此目录,执行 vite/tsc 等命中此处
│   ├── vite                       ✅ Unix 下执行 vite 时运行
│   ├── vite.cmd                   ✅ Windows CMD 下执行 vite 时运行
│   ├── vite.ps1                   ✅ Windows PowerShell 下执行 vite 时运行
│   ├── tsc / tsc.cmd / tsc.ps1    ✅ 同上,tsc 命令
│   └── ...
├── .modules.yaml                  ○ 仅 pnpm install 读写,run 不读
├── .pnpm/                         ✅ run 时通过 .bin wrapper 间接执行到其下包的 bin 入口
│   ├── vite@5.4.0/
│   │   └── node_modules/
│   │       ├── vite/              ✅ 包本体,.bin/vite 最终 node 其下 bin 入口
│   │       │   ├── package.json   ✅ 定义 bin 入口路径
│   │       │   └── dist/node/cli.js  ✅ vite 命令的真实执行文件
│   │       ├── esbuild            ○ run 时由 vite 等按 require 解析
│   │       └── ...
│   ├── typescript@5.x.x/
│   │   └── node_modules/
│   │       └── typescript/bin/tsc ✅ tsc 命令的真实执行文件
│   └── ...
├── vite                           ✅ 符号链接;Node require('vite') 等会解析到此
├── lodash                         ✅ 同上
└── ...

执行链路小结
scripts.dev 字符串(如 vite)→ shell 在 PATH 里找到 node_modules/.bin/vite(或 .cmd/.ps1)→ 该文件内部执行 node + .pnpm 下 vite 的 bin 入口(如 cli.js)→ 之后由 Vite/Node 按模块解析规则读 .pnpm 下各依赖,与 pnpm 无直接关系。

目录小结

  • install 生成并维护:node_modules/node_modules/.pnpm/node_modules/.bin/node_modules/.modules.yaml,以及顶层包名符号链接。
  • run 直接用到的是:package.json(读 scripts)、node_modules/.bin/*(执行入口),间接用到 .pnpm 里对应包的 bin 入口文件。

3.5 小结

  • node_modules 顶层:.bin(可执行入口)、.pnpm(包实体与依赖链)、.modules.yaml(pnpm 元数据)、以及直接依赖的符号链接。
  • .pnpm:按 包名@版本 存包本体(硬链接到 store),每个包有自己的 node_modules,里面是该包依赖的符号链接。
  • .bin:由 install 根据各包 bin 生成;run 时 PATH 包含 .bin,执行 vite 等会先走到 .bin 再转到 .pnpm 里对应包的入口文件。
  • 执行 dev/build:先读 package.json 的 scripts,再在子 shell 里执行命令字符串,通过 .bin 找到并执行对应包的 bin 入口。

四、pnpm run(含 dev / build)底层原理

4.1 核心目标

  • 当前包package.jsonscripts 里找到对应脚本(如 devbuild)。
  • pre / 本体 / post 顺序执行(若有)。
  • 执行时把 node_modules/.bin(及 workspace 根 node_modules/.bin)加入 PATH,以便直接跑本地安装的 CLI。

4.2 整体执行流程

flowchart TD
    Start[pnpm run scriptName] --> ResolvePkg[解析当前包,读 package.json]
    ResolvePkg --> FindScript{scripts.scriptName 存在?}
    FindScript -->|否| Err[报错 Missing script]
    FindScript -->|是| Pre{存在 pre scriptName?}
    Pre -->|是| RunPre[执行 pre scriptName]
    Pre -->|否| RunMain
    RunPre --> RunMain[执行 scriptName]
    RunMain --> Post{存在 post scriptName?}
    Post -->|是| RunPost[执行 post scriptName]
    Post -->|否| Done[结束]
    RunPost --> Done

4.3 分步骤说明

1. 确定「当前包」与 script
  • 当前目录 若不是 workspace 根,pnpm 会向上找 包含 package.json 的目录,当作当前包
  • Workspace:若在子包目录执行 pnpm run dev,则用该子包package.json;在根目录则用根包的。
2. 查找 script
  • package.jsonscripts 里找 scriptName(如 devbuild)。
  • 没有则报 Missing script: "dev" 等错误。
3. Pre / 本体 / Post 顺序
  • 若存在 prescriptName,先执行 pnpm run prescriptName(递归,同样有 pre/post)。
  • 再执行 scriptName 对应的命令。
  • 若存在 postscriptName,再执行 pnpm run postscriptName
  • 例如 pnpm run build → 有 prebuild 则先 prebuild,再 build,再 postbuild(若有)。
4. 准备执行环境(PATH 等)
  • <包目录>/node_modules/.bin 加入 PATH 前端。
  • Workspace:还会把 <workspace 根>/node_modules/.bin 加入 PATH,因此根目录装的 CLI(如 vitetsc)在子包里也能直接调用。
5. 执行命令
  • 子 shell 中执行 scripts[scriptName] 里的字符串(如 vitenext dev)。
  • 通常通过 nodenode_modules/.bin 下对应平台的 wrapper(如 vitevite.jsvite.cmd),再 node vite.js;具体由 npm lifecycles / run-script 等底层处理。

PATH 与 .bin 的关系
pnpm 先把 node_modules/.bin(及 workspace 根同路径)塞进 PATH 前面,再启子进程跑脚本。因此脚本里写的 vitetsc 等会解析到 node_modules/.bin 里的 wrapper,而 .bin 里的文件是 pnpm install 阶段根据各包 bin 字段创建的符号链接或脚本。流程关系如下:

flowchart LR
    subgraph Install [pnpm install]
        I1[解析依赖] --> I2[链接包到 node_modules]
        I2 --> I3[根据 bin 字段生成 .bin 下可执行文件]
    end

    subgraph Run [pnpm run dev 或 build]
        R1[查找 scripts.dev 或 scripts.build] --> R2[PATH 前追加 node_modules/.bin]
        R2 --> R3[子 shell 执行脚本命令]
        R3 --> R4[解析 vite 等到 .bin 对应 wrapper]
    end

    I3 -.->|install 完成后 .bin 就绪| R4

4.4 pnpm run 流程简图(环境 + 生命周期)

flowchart TD
    subgraph Env [环境准备]
        E1[确定当前包] --> E2[找 scripts.scriptName]
        E2 --> E3[PATH += node_modules/.bin]
        E3 --> E4[Workspace 时 PATH += 根 node_modules/.bin]
    end

    subgraph Lifecycle [生命周期]
        L1[pre scriptName] --> L2[scriptName]
        L2 --> L3[post scriptName]
    end

    Env --> Lifecycle
    Lifecycle --> Spawn[在子 shell 中执行命令]
    Spawn --> Exit[退出码决定 pnpm run 成功/失败]

4.5 devbuild 在「run」层面的区别

  • 执行机制完全相同:都是 pnpm run <script>,只是 <script> 名字不同。
  • 差异来自你在 scripts 里写的命令,例如:
    • dev:常为 vitenext devwebpack serve长期进程
    • build:常为 vite buildnext buildtsc一次性构建
  • Pre/post:若你配置了 predev / postdevprebuild / postbuild,会按顺序跑;没配则只跑本体。

4.6 小结

  • pnpm run dev / pnpm run build = 找 script → 可能 pre → 本体 → 可能 post;执行前把 node_modules/.bin 等加入 PATH,在子 shell 中跑对应命令。
  • node_modules/.bin 里的可执行文件由 依赖包bin 字段生成,pnpm 在 install 阶段已经链好;run 只负责 查 script、改 PATH、调起这些 bin

五、三者之间的关系

flowchart LR
    subgraph Install [pnpm install]
        I1[解析依赖] --> I2[store 与链接]
        I2 --> I3[node_modules 就绪]
        I3 --> I4[.bin 可执行文件就绪]
    end

    subgraph Run [pnpm run dev / build]
        R1[读 scripts] --> R2[PATH += .bin]
        R2 --> R3[pre / 本体 / post]
        R3 --> R4[执行 vite / next 等]
    end

    I4 --> R1
  • pnpm install 准备好 node_modulesnode_modules/.binpnpm run dev / pnpm run build 才能正确找到 vitenext 等命令。
  • 未安装就 run,通常会报 找不到命令Cannot find module

六、常用 flag 与行为

6.1 pnpm install

Flag 作用
--frozen-lockfile 不更新 lockfile;若与 package.json 不一致则失败
--lockfile-only 只更新 pnpm-lock.yaml,不写 node_modules
--prefer-offline 尽量用 store,缺的再拉
--offline 只用 store,不访问 registry

6.2 pnpm run

Flag 作用
--silent 少打日志
--prefix <path> 以指定目录为包根(找 package.json

dev / build 本身没有专属 flag;传参会透传给脚本,例如:

pnpm run build -- --mode production

-- 后面的 --mode production 会交给 scripts.build 对应的命令。


七、参考

用 codex AI 更新了下之前写的浏览器云书签标签页扩展

之前的文章

juejin.cn/post/749309…

开源地址

github.com/wumingluren…

image.png

image.png

image.png

最近重新做了 ui 还支持了换肤功能,复刻了一份 Omni 功能。

主要是玩玩 codex 的各种 skill。

之前还有配套的导航站,还没有升级 ui ,下一步就准备升级一下。

下面内容由 AI 生成。

这半个月,我们把无名云书签往前推了一大步

这段时间,无名云书签做了几次很关键的更新。

如果用一句话来概括,就是我们不再只满足于“把书签存起来”,而是开始认真把它做成一个真正顺手、能每天打开就用的浏览器工具。

最近,这个项目主要往前走了四步:重做了新标签页、做出了 Omni 命令面板的第一个可用版本、统一了开发工具链,也顺手解决了一个很影响体验的样式污染问题。

新标签页,不想再只是一个“列表页”

最先动刀的是新标签页。

以前的新标签页更偏功能导向,能用,但谈不上舒服。打开之后,你能看到推荐书签、能搜内容,但整体更像一个功能页,而不是一个你愿意长期停留的导航页。

这次改版,我们把它从“一个能看书签的页面”,往“一个真正能承接日常访问入口的首页”推进了一步。

新的版本里,页面结构被重新梳理了。搜索、推荐内容、反馈状态都被放进了更统一的视觉层级里,信息密度更高,但阅读负担反而更低。空状态、骨架屏、错误提示这些以前容易被忽略的细节,这次也都补上了。你在加载、搜索、无结果这些场景下,终于能明确知道系统现在在做什么。

另一个比较明显的变化,是换肤。

这次新标签页内置了 5 套主题风格,支持直接切换,并且会保存你的选择。我们希望它不是一个“只能用默认样式”的工具页,而是一个你可以按自己的习惯留在浏览器里的空间。无论你喜欢偏清爽、偏冷静,还是更适合夜间使用的风格,现在都有了一个更自然的落点。

对项目内部来说,这次改版也不只是改了外观。像 BookmarkItemThemeSwitcheruseNewTabThemethemes 这些模块被单独拆出来之后,后面不管是继续补主题、加模块,还是调整布局,都会轻松很多。

Omni 命令面板,终于有了第一个能打的版本

如果说新标签页解决的是“打开浏览器之后”的体验,那 Omni 命令面板解决的,就是“在任何页面里,怎么更快完成操作”。

最近,我们把 Omni 命令面板的 MVP 做出来了。

现在你可以直接通过 Cmd/Ctrl + Shift + K 呼出这个面板,不需要先切到某个固定页面,也不用绕到设置页或者侧边栏里找入口。它更像一个悬浮在浏览器里的统一操作台。

这个版本最核心的能力,是把原本分散的东西聚合到了一起。

你可以在同一个输入框里同时搜:

  • 飞书云书签
  • 当前浏览器标签页
  • 浏览器书签
  • 浏览历史
  • 常用快捷动作
  • 最近使用记录

这件事听起来简单,但它实际改变的是操作路径。以前你可能需要先想“我要去哪里找这个东西”,现在是先输入,再从结果里选。这个心智负担小很多,尤其在书签变多、标签页变多之后,差别会特别明显。

为了让搜索更顺手,这次也加了命令前缀能力。比如你想只查标签页,可以直接输入 /tabs;只查云书签,可以用 /feishu;只看历史记录、动作、浏览器书签,也都可以快速切换。它不是一个复杂的命令系统,但已经足够把“全局搜索”变成“可控搜索”。

除了搜,Omni 现在也能直接做事。

这个版本已经支持一组比较高频的快捷动作,比如打开新标签页、打开扩展设置、打开侧边栏、把当前页面存到飞书、存到浏览器书签、关闭当前标签页、关闭其他标签页、关闭右侧标签页、固定标签页、静音标签页,以及把当前输入直接交给默认搜索引擎。

为了避免误操作,危险动作还加了二次确认。设置页里也补了对应的 Omni 配置项,可以控制开关、搜索来源、每组显示结果数量,以及是否保留危险动作确认。

这一步对无名云书签来说挺重要。因为从这里开始,它不只是一个“存书签”的扩展,而是在往“浏览器里的个人信息入口”走。

修掉一个很小,但很烦的问题

最近,我们修了一个看起来不大、但实际很影响观感的问题:内容脚本的样式会污染宿主页面。

这个问题的本质是,扩展注入页面时使用的样式里,有一些通用类名,比如 .hidden.flex 这类工具类。如果它们被直接挂到宿主页面环境里,就有可能影响原网站自己的样式表现。

这类问题往往不容易第一时间被发现,因为它不一定是“页面直接坏掉”,很多时候只是某些站点会变得怪怪的。但一旦用户碰到,体感会很差,而且锅最后还是会落到扩展头上。

这次的处理方式,是不再通过 manifest 直接把内容脚本样式注入宿主页面,而是改成在运行时加载到 Shadow DOM 里。这样扩展自己的界面还能保留原来的样式能力,但不会继续往外泄漏。

同时,这次也补了一条测试,专门保证这个行为不会再被后续改动带回来。

这种改动不一定会出现在截图里,也不一定会成为“新功能”被感知到,但它会直接决定扩展是不是一个足够克制、足够可靠的工具。

顺手把开发链路也理顺了

除了功能本身,这轮还有一件更偏工程化的事情一起做了:项目里的命令和文档,统一切到了 pnpm

这包括 README 里的安装、开发、构建、打包说明,也包括项目脚本本身的调用方式。package-lock.json 被移除,pnpm-lock.yaml 成为了当前唯一的锁文件。

这个变化对普通使用者几乎没有感知,但对后续维护很有帮助。至少从现在开始,这个项目在“文档怎么写”和“实际怎么跑”这件事上是一致的,新同学接手时也不会一上来就踩到工具链不统一的问题。

这轮更新之后,无名云书签更像什么了

如果说以前的无名云书签,核心价值是“把书签放进飞书里”,那这轮更新之后,它开始更像一个围绕书签展开的浏览器工作台。

你可以在新标签页里更舒服地浏览和进入内容,也可以在任何网页里直接拉起命令面板,搜索、跳转、保存、整理。当这些入口被串起来之后,书签管理就不再只是“存档”,而更接近“随时可用的个人知识入口”。

这也是接下来这个项目更值得继续做下去的地方。

后面应该还会继续补 Omni 的能力、磨新标签页的细节,也把一些现在已经能用但还不够顺的部分继续打磨下去。至少从这半个月来看,无名云书签已经不再停留在“能用”的阶段,而是在慢慢往“好用、愿意一直用”靠近。

我从瑞幸咖啡小程序里,拆出了一套 22 个组件的开源 UI 库

我从瑞幸咖啡小程序里,拆出了一套 22 个组件的开源 UI 库

把它的设计语言完整提炼出来,做成了一个可以直接 npm install 的微信小程序组件库。

效果截图

Snipaste_2026-04-15_16-25-37.jpg

Snipaste_2026-04-15_16-27-47.jpg

Snipaste_2026-04-15_16-27-15.jpg

Snipaste_2026-04-15_16-26-37.jpg

Snipaste_2026-04-15_16-26-20.jpg

Snipaste_2026-04-15_16-26-00.jpg

克制的双色系统(蓝+橙),无阴影的卡片层次,菜单页那个从 + 按钮展开到数量步进器的丝滑交互,会员卡页面方案选择器的信息架构……这些细节放在一起,构成了一套非常完整且高辨识度的设计语言。

项目叫 LKCN UI,22 个组件,纯原生微信小程序自定义组件,零依赖,原生 / Taro / uni-app 项目都能直接用。

GitHub: https://github.com/user/lkcn-ui

色彩系统

瑞幸全局只用 两个强调色

色值 用途 使用场景
#1A6EFF Brand Blue 交互元素 TabBar 激活态、按钮、加购圆钮、链接
#FF6B35 Accent Orange 促销与价格 价格数字、CTA 按钮、Badge、优惠券

辅助色包括会员金 #C8A26E、即享绿 #2B7D5B、咖啡棕 #3D2D1F

一个重要发现:瑞幸的卡片没有阴影。整个 App 的层次感完全靠圆角 + 间距 + 背景色差来实现,这使得渲染性能非常好,也让整体视觉特别干净。

字体体系

价格是瑞幸 UI 最有辨识度的元素。它把价格拆成了三段不同大小的文字:

¥(小号加粗) 9(大号加粗) .9(小号加粗)  ¥32(小号灰色删除线)

这种「符号小、整数大、小数小」的层次处理让价格数字极具视觉冲击力,同时原价的删除线灰色处理制造了强烈的价差感知。我在 lkcn-price 组件里完整还原了这个效果。

间距与圆角

间距体系是标准的 8px 递增:4 / 8 / 12 / 16 / 24 / 32(rpx 翻倍)。

圆角有 5 级:4px(标签)→ 8px(按钮、输入框)→ 12px(卡片)→ 20px(弹窗)→ 999px(胶囊)。

所有 Token 都通过 CSS 变量注入,覆盖变量即可全局换肤:

page {
  --lkcn-blue: #1A6EFF;
  --lkcn-orange: #FF6B35;
  --lkcn-radius-md: 24rpx;
  /* ... 60+ 个变量 */
}

22 个组件一览

全部组件从瑞幸小程序的真实页面中提取,不是凭空设计的:

基础组件: Button(6 种类型 × 3 尺寸)、Tag(4 类型 × 4 颜色)、Price(整数/小数自动拆分)、Badge、Avatar

布局容器: Card、Grid(3/4/5 列自适应)、Swiper(胶囊形指示点)、CouponScroll、PromoCard

导航: TabBar(safe-area 适配)、Tabs(滑动下划线)、SegmentControl、SearchBar、CategorySidebar、LocationBar

业务组件: ProductCard(菜单列表项)、Stepper(折叠→展开态)、LevelCard(会员等级)、MembershipPlan(订阅方案选择)、NoticeBar、FloatingButton

1. Stepper:瑞幸的加购交互

瑞幸菜单页的加购交互是我见过最优雅的——数量为 0 时只显示一个蓝色 + 圆钮,点击后展开为 [-] [数字] [+] 三段式控件。

<!-- 使用方式 -->
<lkcn-stepper value="{{count}}" bind:change="onChange" />

组件内部的关键判断:

<!-- value <= min 时只显示 + 按钮 -->
<view wx:if="{{value <= min}}" class="lkcn-stepper__add lkcn-stepper__add--solo">
  <text class="lkcn-stepper__icon">+</text>
</view>
<!-- 否则展开完整控件 -->
<view wx:else class="lkcn-stepper__controls">
  <!-- [-] [count] [+] -->
</view>

加购按钮的 scale(0.88) + cubic-bezier(0.34, 1.56, 0.64, 1) 弹性回弹动画让点击手感特别好。

2. Price:三段式价格渲染

<lkcn-price value="9.9" original="32" prefix="预估到手" />

组件自动将 9.9 拆分为整数 9 和小数 .9,分别用不同字号渲染,currency symbol ¥ 用小号加粗。这种处理在电商类小程序里非常实用,直接拿去用就行。

3. CategorySidebar:菜单页左侧导航

这个组件还原了菜单页左侧的完整细节——激活态的白色背景、左侧橙色指示条、分类标签(新品产地季苦瓜轻体),以及新品小红点。

<lkcn-category-sidebar
  categories="{{categories}}"
  active="{{catActive}}"
  height="100vh"
  bind:change="onCatChange"
/>

数据结构支持纯文字和对象两种格式:

categories: [
  '人气Top',                           // 纯文字
  { text: '周边NEW', tag: '周边NEW', tagColor: 'blue' },  // 带标签
  { text: '果C美式', tag: '苦瓜轻体', tagColor: 'green', dot: true },
]

4. MembershipPlan:会员方案选择器

会员卡页面底部那个方案选择 + 订阅 CTA + 协议勾选的完整流程,一个组件搞定:

<lkcn-membership-plan
  plans="{{plans}}"
  active="{{planActive}}"
  agreement="开通会员代表接受"
  agreement-links="{{[{text:'《服务协议》'}, {text:'《续费说明》'}]}}"
  bind:subscribe="onSubscribe"
/>

为什么选原生而不是 Taro / uni-app

这是我在开发前做的一个关键决策。核心理由就一个——受众最大化

原生微信小程序自定义组件能被所有技术栈引入:

原生组件 → 原生项目 ✅、uni-app 项目 ✅、Taro 项目 ✅
uni-app 组件 → 只有 uni-app 能用 ❌
Taro 组件 → 只有 Taro 能用 ❌

uni-app 引入原生组件只需要放到 wxcomponents/ 目录,在 pages.json 注册即可。Taro 也类似。写一份代码三个生态都能吃到,这是 Vant Weapp 走过的路。

快速上手

npm install lkcn-ui

在微信开发者工具中构建 npm,然后注册组件:

{
  "usingComponents": {
    "lkcn-button": "lkcn-ui/button/index",
    "lkcn-price": "lkcn-ui/price/index",
    "lkcn-product-card": "lkcn-ui/product-card/index"
  }
}

直接使用:

<lkcn-button type="primary" round>立即下单</lkcn-button>

<lkcn-product-card
  image="/images/coconut-latte.png"
  title="生椰拿铁(首创)"
  tags="{{['全球销量第一', 'IIAC金奖']}}"
  price="9.9"
  original-price="32"
  bind:add="onAddToCart"
/>

也可以不用 npm,直接把 packages/ 下需要的组件目录复制到你的项目里。

换肤

所有视觉变量都通过 CSS 变量控制,覆盖即可适配你自己的品牌:

page {
  --lkcn-blue: #7C3AED;    /* 换成你的品牌紫 */
  --lkcn-orange: #F59E0B;  /* 换成你的品牌黄 */
  --lkcn-radius-md: 32rpx; /* 更大的圆角 */
}

不需要改任何组件源码,Design Token 体系的优势就在这里。

项目数据

  • 22 个组件,全部完成
  • 143 个源文件
  • 0 外部依赖
  • 每个组件 4 件套(wxml / wxss / js / json)
  • 60+ Design Token CSS 变量
  • 11 个可交互 demo 页面
  • 包体积 < 90KB(未压缩)

后续计划

  • 组件 TypeScript .d.ts 类型声明
  • VitePress 文档站
  • 暗色模式适配
  • GitHub Actions CI 自动发布

如果你也觉得有用,欢迎 Star:

GitHub: https://github.com/user/lkcn-ui

单例模式渐进式学习指南

单例模式渐进式学习指南

面向前端开发者,从“看懂概念”到“能写能辨别”,一步步掌握设计模式中的单例模式。


目录

  1. 什么是单例模式?
  2. 为什么前端里需要单例?
  3. 先从最小例子理解“唯一实例”
  4. 单例模式的标准结构
  5. 前端中常见的单例场景
  6. 几种常见实现方式
  7. 单例模式的优点与缺点
  8. 使用单例时的常见误区
  9. 面试中怎么回答单例模式
  10. 练习题与思考题
  11. 学习总结

一、什么是单例模式?

单例模式(Singleton Pattern)是一种创建型设计模式

它的核心目标只有一句话:

保证一个类、一个对象工厂、或一个功能模块在系统中只有一个实例,并提供一个全局访问点。

你可以把它理解成:

  • 系统里这个对象只能创建一次
  • 后面再获取时,拿到的都是同一个对象
  • 大家共用它,而不是每次都 new 一个新的

生活类比

可以把单例想象成:

  • 浏览器里的 window
  • 页面中的全局配置中心
  • 整个项目里唯一的消息提示组件管理器
  • 唯一的缓存中心

这些东西通常不需要来一个人就建一个新的,否则系统会乱套。

单例的两个关键词

关键词 含义
唯一实例 无论调用多少次,都只有一个对象
全局访问 任何需要它的地方都能拿到同一个对象

二、为什么前端里需要单例?

很多初学者会有个疑问:

前端不就是写页面吗?为什么还要学设计模式?

其实前端项目一旦变大,就会出现很多“全局唯一资源”的问题。

常见需求

  • 全局只有一个登录弹窗
  • 全局只有一个消息通知容器
  • 全局只有一个请求管理器
  • 全局只有一个事件总线实例
  • 全局只有一个缓存对象
  • 全局只有一个状态管理容器入口

如果每次使用都重新创建:

  • 会造成资源浪费
  • 会引发状态不一致
  • 会让调试复杂度上升
  • 甚至会出现界面重复渲染、重复请求等问题

一个典型问题

比如你写一个全局弹窗:

function createModal() {
  return {
    show() {
      console.log('弹窗打开')
    },
  }
}

const modal1 = createModal()
const modal2 = createModal()

console.log(modal1 === modal2) // false

这里 modal1modal2 不是同一个对象。

这意味着:

  • 你可能创建了多个弹窗实例
  • 每个实例的状态互不相通
  • 页面上可能冒出多个重复弹窗

这时候,单例模式就登场了。


三、先从最小例子理解“唯一实例”

普通写法:每次都创建新对象

function createUserStore() {
  return {
    name: 'frontend-store',
  }
}

const store1 = createUserStore()
const store2 = createUserStore()

console.log(store1 === store2) // false

单例写法:始终返回同一个对象

function createSingleUserStore() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        name: 'frontend-store',
      }
    }

    return instance
  }
}

const getStore = createSingleUserStore()

const store1 = getStore()
const store2 = getStore()

console.log(store1 === store2) // true

这段代码发生了什么?

核心在这里:

let instance = null

它会把第一次创建出来的对象缓存起来。

后续再调用时:

  • 如果 instance 不存在,就创建
  • 如果 instance 已存在,就直接返回

于是无论调用多少次,拿到的都是同一个对象。

一句话:单例不是“不让你调用”,而是“让你重复调用时仍然拿到同一个实例”。


四、单例模式的标准结构

虽然前端里未必真的写“类”,但你最好知道它的标准思想。

结构拆解

一个典型的单例通常包含 3 个部分:

  1. 私有实例缓存:记录是否已经创建过对象
  2. 创建逻辑:第一次使用时创建对象
  3. 访问入口:外部通过统一方法获取实例

用类的方式理解

class Singleton {
  constructor() {
    if (Singleton.instance) {
      return Singleton.instance
    }

    this.data = '唯一实例'
    Singleton.instance = this
  }
}

const s1 = new Singleton()
const s2 = new Singleton()

console.log(s1 === s2) // true

更推荐前端中理解成“模块级唯一对象”

在现代前端中,很多单例并不是通过 class 写出来的,而是通过 模块缓存机制 自然形成的。

// config.js
const config = {
  apiBaseUrl: 'https://api.example.com',
  timeout: 5000,
}

export default config
// a.js
import config from './config.js'

// b.js
import config from './config.js'

因为 ES Module 会缓存模块实例,所以多个文件导入同一个模块时,通常拿到的是同一份模块对象。

这也是前端里最常见、最自然的“单例感”来源。


五、前端中常见的单例场景

这一部分最重要,因为真正写业务时,你不是为了“背定义”而用单例,而是为了解决全局唯一资源管理问题

1. 全局消息提示(Message / Toast)

很多 UI 库里的全局提示本质就是单例。

class Message {
  constructor() {
    this.queue = []
  }

  show(text) {
    this.queue.push(text)
    console.log('消息:', text)
  }
}

let messageInstance = null

export function getMessageInstance() {
  if (!messageInstance) {
    messageInstance = new Message()
  }

  return messageInstance
}

使用时:

const message1 = getMessageInstance()
const message2 = getMessageInstance()

message1.show('保存成功')
console.log(message1 === message2) // true

2. 全局弹窗管理器

如果每点击一次按钮都创建一个弹窗管理器,页面就可能出现多个重复节点。

单例的好处是:

  • 整个应用只维护一个弹窗容器
  • 状态统一管理
  • DOM 节点不会重复创建

3. 请求管理器 / API 客户端

比如你封装了一个请求实例:

class RequestService {
  constructor(baseURL) {
    this.baseURL = baseURL
  }

  get(url) {
    console.log(`GET: ${this.baseURL}${url}`)
  }
}

let requestInstance = null

export function getRequestService() {
  if (!requestInstance) {
    requestInstance = new RequestService('/api')
  }

  return requestInstance
}

这样做可以统一:

  • baseURL
  • 请求拦截器
  • token 注入
  • 错误处理策略

4. 缓存中心

const cache = {
  data: new Map(),
  set(key, value) {
    this.data.set(key, value)
  },
  get(key) {
    return this.data.get(key)
  },
}

export default cache

这本质上也是一个单例对象。

5. 状态共享对象

某些轻量项目不用 Pinia / Redux,也会自己写一个全局 store。

const store = {
  state: {
    userInfo: null,
  },
  setUser(user) {
    this.state.userInfo = user
  },
}

export default store

所有页面共享同一份 store,这就是一种模块单例。


六、几种常见实现方式

方式一:闭包实现单例(最适合入门)

function createSingleton() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        id: Date.now(),
      }
    }
    return instance
  }
}

const getInstance = createSingleton()
const obj1 = getInstance()
const obj2 = getInstance()

console.log(obj1 === obj2) // true
优点
  • 容易理解
  • 不依赖 class
  • 很适合讲清“缓存实例”的本质
缺点
  • 如果逻辑复杂,代码可维护性一般

方式二:类 + 静态属性

class LoginDialog {
  static instance = null

  constructor() {
    if (LoginDialog.instance) {
      return LoginDialog.instance
    }

    this.visible = false
    LoginDialog.instance = this
  }

  open() {
    this.visible = true
    console.log('登录弹窗打开')
  }
}

const dialog1 = new LoginDialog()
const dialog2 = new LoginDialog()

console.log(dialog1 === dialog2) // true
优点
  • 结构清晰
  • 更贴近传统设计模式写法
  • 适合面试表达
缺点
  • 对前端业务代码来说有时略显“重”

方式三:模块单例(现代前端最常见)

// auth-store.js
const authStore = {
  token: '',
  setToken(token) {
    this.token = token
  },
}

export default authStore
import authStore from './auth-store.js'
为什么它是单例?

因为模块只会初始化一次,后续导入拿到的是同一个模块实例引用。

优点
  • 写法最自然
  • 非常适合工程化项目
  • 不需要显式写 getInstance
缺点
  • 初学者可能“用了单例却没意识到自己在用单例”

方式四:惰性单例(Lazy Singleton)

惰性单例指的是:

不在一开始就创建实例,而是在第一次真正需要时才创建。

function getModal() {
  if (!getModal.instance) {
    getModal.instance = {
      createdAt: Date.now(),
      show() {
        console.log('显示 modal')
      },
    }
  }

  return getModal.instance
}

这种方式很常见,因为很多全局对象并不一定在页面加载时就需要。

惰性单例的意义

  • 减少初始加载开销
  • 按需创建资源
  • 更适合弹窗、通知、复杂组件容器

七、单例模式的优点与缺点

任何设计模式都不是“银弹”,单例也一样。

优点

1. 节省资源

只创建一次对象,避免重复初始化。

2. 统一状态管理

所有地方访问的都是同一份实例,状态天然一致。

3. 便于全局协调

适合处理:

  • 全局配置
  • 全局弹窗
  • 全局缓存
  • 全局事件中心
4. 减少重复代码

不必每次都手动创建和管理相同对象。

缺点

1. 全局状态过多会让系统变复杂

一旦所有东西都做成单例,项目就会慢慢变成“全局变量乐园”。这可不是什么嘉年华。

2. 测试不友好

单例在测试中容易产生状态污染。

比如:

  • 上一个测试改了实例状态
  • 下一个测试拿到的还是同一个实例
  • 测试之间互相影响
3. 模块耦合增强

很多模块都依赖某个全局单例时,重构会变困难。

4. 容易被滥用

不是“全局都能访问”就该用单例,只有确实应该全局唯一时才适合。


八、使用单例时的常见误区

误区 1:把普通工具函数也做成单例

比如一个纯函数工具库:

function formatDate(date) {
  return String(date)
}

这种函数没有状态,不需要单例。

没有状态、没有初始化成本、没有唯一资源约束的对象,通常没必要单例化。

误区 2:把“全局可访问”误认为“必须单例”

全局可访问 ≠ 必须只有一个实例。

比如:

  • 表单校验器可能每个表单都应该有独立实例
  • 图表对象可能每个图表容器都应该各自创建

误区 3:忽略实例重置能力

在测试或热更新环境中,有些单例需要支持重置,否则状态会残留。

let instance = null

export function getInstance() {
  if (!instance) {
    instance = { count: 0 }
  }
  return instance
}

export function resetInstance() {
  instance = null
}

误区 4:把单例当作“解决一切共享问题”的万能方案

如果共享状态越来越复杂,应该考虑:

  • 状态管理库(Pinia / Redux / Zustand)
  • 依赖注入
  • 组合式函数(composables)
  • 上下文容器

单例是工具,不是宗教。


九、面试中怎么回答单例模式

如果面试官问:

你怎么理解单例模式?前端中有哪些应用?

你可以这样回答:

标准回答模板

单例模式是一种创建型设计模式,核心是保证某个对象在系统中只有一个实例,并提供统一的访问入口。

在前端开发中,它常用于管理全局唯一资源,比如:

  • 全局弹窗
  • 消息提示组件
  • 请求实例
  • 缓存对象
  • 全局配置对象

实现方式通常有:

  • 闭包缓存实例
  • 类的静态属性保存实例
  • ES Module 天然单例

它的优点是节省资源、统一状态;缺点是容易带来全局耦合、测试困难,因此要谨慎使用,避免滥用。

如果面试官继续追问:ES Module 算不算单例?

你可以回答:

在工程实践里,很多模块导出的对象会因为模块缓存机制而表现出单例特征,所以它是一种非常常见的“模块级单例”实现方式。

如果继续追问:单例和全局变量有什么区别?

你可以回答:

  • 全局变量只是“所有地方都能访问”
  • 单例模式强调“唯一实例 + 可控访问入口 + 创建时机管理”

所以单例比裸露的全局变量更有结构,也更便于维护。


十、练习题与思考题

练习 1:实现一个单例缓存对象

要求:

  • 只能创建一个缓存实例
  • 提供 setget 方法

你可以自己先暂停 5 分钟写一下,再参考下面思路:

function createCacheSingleton() {
  let instance = null

  return function () {
    if (!instance) {
      instance = {
        data: new Map(),
        set(key, value) {
          this.data.set(key, value)
        },
        get(key) {
          return this.data.get(key)
        },
      }
    }

    return instance
  }
}

练习 2:实现一个全局登录弹窗管理器

要求:

  • 整个应用中只能有一个登录弹窗实例
  • 支持 open()close()

练习 3:思考哪些场景不适合单例

请判断以下对象是否适合单例,并说明理由:

  • 每个页面一个轮播图实例
  • 全局埋点上报管理器
  • 每个表格一个筛选器对象
  • 全局请求客户端
  • 每个图表一个图表实例

参考答案方向

对象 是否适合单例 原因
每个页面一个轮播图实例 不适合 每个轮播图通常是独立的
全局埋点上报管理器 适合 全局统一上报规则和缓存队列
每个表格一个筛选器对象 不适合 每个表格状态独立
全局请求客户端 适合 请求配置、拦截器应统一
每个图表一个图表实例 不适合 每个容器对应独立实例

十一、学习总结

你应该记住的 4 句话

  1. 单例模式的核心是:一个实例、全局访问。
  2. 前端中凡是“全局唯一资源”,都值得考虑单例。
  3. 现代前端里最常见的单例形式,其实是模块单例。
  4. 不要滥用单例,能局部化的状态就不要硬塞成全局。

一张速记表

问题 结论
单例模式是什么? 保证对象只有一个实例
适合什么场景? 全局配置、消息提示、请求实例、缓存中心
常见实现方式? 闭包、类静态属性、ES Module
最大风险是什么? 全局耦合、状态污染、测试困难
判断标准是什么? 这个对象是否真的应该全局唯一

git cherry-pick Command: Apply Commits from Another Branch

Sometimes the change you need is already written, just on the wrong branch. A hotfix may land on main when it also needs to go to a maintenance branch, or a useful commit may be buried in a feature branch that you do not want to merge wholesale. In that situation, git cherry-pick lets you copy the effect of a specific commit onto your current branch.

This guide explains how git cherry-pick works, how to apply one or more commits safely, and how to handle the conflicts that can appear along the way.

Syntax

The general syntax for git cherry-pick is:

txt
git cherry-pick [OPTIONS] COMMIT...
  • OPTIONS - Flags that change how Git applies the commit.
  • COMMIT - One or more commit hashes, branch references, or commit ranges.

git cherry-pick replays the changes introduced by the selected commit on top of your current branch. Git creates a new commit, so the result has a different commit hash even when the file changes are the same.

Cherry-Picking a Single Commit

Start by finding the commit you want to copy. This example lists the recent commits on a feature branch:

Terminal
git log --oneline feature/auth
output
a3f1c92 Fix null pointer in auth handler
d8b22e1 Add login form validation
7c4e003 Refactor session logic

The output gives you the abbreviated commit hashes. In this case, a3f1c92 is the fix we want to move.

Switch to the target branch before running git cherry-pick:

Terminal
git switch main
git cherry-pick a3f1c92
output
[main 9b2d4f1] Fix null pointer in auth handler
Date: Tue Apr 14 10:42:00 2026 +0200
1 file changed, 2 insertions(+)

Git applies the change from a3f1c92 to main and creates a new commit, 9b2d4f1. The subject line is the same, but the commit hash is different because the parent commit is different.

Cherry-Picking Multiple Commits

If you need more than one non-consecutive commit, pass each hash in the order you want Git to apply them:

Terminal
git cherry-pick a3f1c92 d8b22e1

Git creates a separate new commit for each one. This works well when you need a few targeted fixes but do not want the rest of the source branch.

For a range of consecutive commits, use the range notation:

Terminal
git cherry-pick a3f1c92^..7c4e003

This tells Git to include a3f1c92 and every commit after it up to 7c4e003. If you omit the caret, the starting commit itself is excluded:

Terminal
git cherry-pick a3f1c92..7c4e003

That form applies every commit after a3f1c92 through 7c4e003.

Applying Changes Without Committing

Sometimes you want the changes from a commit, but not an automatic commit for each one. Use --no-commit (or -n) to apply the changes to your working tree and staging area without creating the commit yet:

Terminal
git cherry-pick --no-commit a3f1c92

This is useful when you want to combine several small fixes into one commit on the target branch, or when you need to edit the files before committing.

After reviewing the result, create the commit yourself:

Terminal
git status
git commit -m "Backport auth null-check fix"

This gives you more control over the final commit message and lets you group related backports together.

Recording Where the Commit Came From

For maintenance branches and backports, it is often helpful to keep a reference to the original commit. Use -x to append the source commit hash to the new commit message:

Terminal
git cherry-pick -x a3f1c92

Git adds a line like this to the new commit message:

output
(cherry picked from commit a3f1c92...)

That extra line makes future audits easier, especially when you need to prove that a fix on a release branch came from a reviewed change on another branch.

Cherry-Picking a Merge Commit

Cherry-picking a regular commit is straightforward, but merge commits need one extra option. Git must know which parent to treat as the main line:

Terminal
git cherry-pick -m 1 MERGE_COMMIT_HASH
  • -m 1 - Use the first parent as the base, which is usually the branch that received the merge.
  • -m 2 - Use the second parent instead.

If you are not sure which parent is which, inspect the history first:

Terminal
git log --oneline --graph

Cherry-picking merge commits is more advanced and easier to get wrong. If the goal is to bring over an entire merged feature, a normal merge is often clearer than cherry-picking the merge commit itself.

Resolving Conflicts

If the target branch has changed in the same area of code, Git may stop and ask you to resolve a conflict:

output
error: could not apply a3f1c92... Fix null pointer in auth handler
hint: After resolving the conflicts, mark them with
hint: "git add/rm <pathspec>", then run
hint: "git cherry-pick --continue".

Open the conflicted file and look for the conflict markers:

output
<<<<<<< HEAD
return session.getUser();
=======
if (session == null) return null;
return session.getUser();
>>>>>>> a3f1c92 (Fix null pointer in auth handler)

Edit the file to keep the final version you want, then stage it and continue:

Terminal
git add src/auth.js
git cherry-pick --continue

If you decide the commit is not worth applying after all, abort the operation:

Terminal
git cherry-pick --abort

git cherry-pick --abort puts the branch back where it was before the cherry-pick started.

A Safe Backport Workflow

When you cherry-pick onto a release or maintenance branch, slow down and make the source obvious. A simple workflow looks like this:

Terminal
git switch release/1.4
git pull --ff-only
git cherry-pick -x a3f1c92
git status

The important part is the sequence. Start from the branch that needs the fix, make sure it is up to date, cherry-pick with -x, then review and test the branch before pushing it. This avoids the common mistake of copying a fix into an outdated branch and shipping an untested backport.

If the picked commit depends on earlier refactors or new APIs that are not present on the target branch, stop there. In that case, either copy the prerequisite commits too or recreate the fix manually.

When to Use git cherry-pick

git cherry-pick is a good fit when you need a precise change without the rest of the branch:

  • Backporting a bug fix from main to a release branch
  • Recovering one useful commit from an abandoned feature branch
  • Moving a small fix that was committed on the wrong branch
  • Pulling a reviewed change into a hotfix branch without merging unrelated work

Avoid it when the target branch needs the full context of the source branch. If the commit depends on earlier commits, shared refactors, or schema changes, a merge or rebase is usually the cleaner option.

Troubleshooting

error: could not apply ... during cherry-pick
The target branch has conflicting changes. Resolve the files Git marks as conflicted, stage them with git add, then run git cherry-pick --continue.

Cherry-pick created duplicate-looking history
That is normal. Cherry-pick copies the effect of a commit, not the original object. The new commit has a different hash because it has a different parent.

The picked commit does not build on the target branch
The commit likely depends on earlier work that is missing from the target branch. Inspect the source branch history with git log and either cherry-pick the prerequisites too or reimplement the change manually.

You picked the wrong commit
If the cherry-pick already completed, use git revert on the new commit. If the operation is still in progress, use git cherry-pick --abort.

Quick Reference

For a printable quick reference, see the Git cheatsheet .

Task Command
Pick one commit git cherry-pick COMMIT
Pick several specific commits git cherry-pick C1 C2 C3
Pick a consecutive range including the first commit git cherry-pick A^..B
Apply changes without committing git cherry-pick --no-commit COMMIT
Record the source commit in the message git cherry-pick -x COMMIT
Continue after resolving a conflict git cherry-pick --continue
Skip the current commit in a sequence git cherry-pick --skip
Abort the operation git cherry-pick --abort
Pick a merge commit git cherry-pick -m 1 MERGE_COMMIT

FAQ

What is the difference between git cherry-pick and git merge?
git cherry-pick copies the effect of selected commits onto your current branch. git merge joins two branch histories and brings over all commits that are missing from the target branch.

Does cherry-pick change the original commit?
No. The source commit stays exactly where it is. Git creates a new commit on your current branch with the same file changes.

Should I use -x every time?
Not always, but it is a good habit for backports and maintenance branches. It gives you a clear link back to the original commit.

Can I cherry-pick commits from another remote branch?
Yes. Fetch the remote branch first, then cherry-pick the commit hash you want. You can inspect the incoming history with git log or review the file changes with git diff before applying anything.

Conclusion

git cherry-pick is the tool to reach for when one commit matters more than the branch it came from. Use it for targeted fixes, keep -x in mind for backports, and fall back to merge or rebase when the change depends on broader branch context.

❌