普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月20日技术

Three.js 实战:使用 DOM/CSS 打造高性能 3D 文字

作者 烛阴
2026年1月20日 22:21

Three.js 实战:使用 DOM/CSS 打造高性能 3D 文字

在 Three.js 中渲染文字有多种方案,本文介绍一种高性能且灵活的方案:CSS2DRenderer。它能将 DOM 元素无缝嵌入 3D 场景,同时保留 CSS 的全部能力。

为什么选择 DOM/CSS 方案?

方案 优点 缺点
TextGeometry 真正的 3D 几何体 性能开销大,需加载字体文件
CSS2DRenderer 清晰锐利、CSS 全特性、高性能 无法被 3D 物体遮挡

CSS2DRenderer 的核心优势:

  • 文字永远清晰:浏览器原生渲染,不受 3D 缩放影响
  • CSS 全特性:阴影、渐变、动画、backdrop-filter 磨砂玻璃效果
  • 性能优异:DOM 渲染与 WebGL 渲染分离,互不干扰

核心实现

1. 初始化双渲染器

import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';

// WebGL 渲染器(渲染 3D 场景)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

// CSS2D 渲染器(渲染 DOM 标签)
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(innerWidth, innerHeight);
Object.assign(labelRenderer.domElement.style, {
  position: 'absolute',
  top: '0',
  pointerEvents: 'none' // 关键:让鼠标事件穿透
});
document.body.appendChild(labelRenderer.domElement);

关键点pointerEvents: 'none' 让鼠标事件穿透 DOM 层,否则无法拖拽 3D 场景。

2. 创建 CSS2D 标签

const createLabel = (text, position) => {
  const div = document.createElement('div');
  div.className = 'label';
  div.textContent = text;
  const label = new CSS2DObject(div);
  label.position.copy(position);
  return label;
};

// 将标签添加到 3D 物体上
earth.add(createLabel('Earth', new THREE.Vector3(0, 1.5, 0)));

标签添加到 earth 网格后,会自动跟随地球的旋转和位移。

3. 双渲染器同步渲染

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);      // 渲染 3D 场景
  labelRenderer.render(scene, camera); // 渲染 DOM 标签
}

4. CSS 样式

.label {
  color: #FFF;
  font-family: 'Helvetica Neue', sans-serif;
  font-weight: bold;
  padding: 5px 10px;
  background: rgba(0, 0, 0, 0.6);
  border-radius: 4px;
  backdrop-filter: blur(4px); /* 磨砂玻璃效果 */
}

backdrop-filter: blur() 实现的磨砂玻璃效果,在纯 WebGL 中需要复杂的后处理才能实现,而 CSS 一行代码搞定。

完整代码

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
import { CSS2DRenderer, CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';

// 场景、相机
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, innerWidth / innerHeight, 0.1, 1000);
camera.position.z = 5;

// WebGL 渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(devicePixelRatio);
document.body.appendChild(renderer.domElement);

// CSS2D 渲染器
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(innerWidth, innerHeight);
Object.assign(labelRenderer.domElement.style, {
  position: 'absolute',
  top: '0',
  pointerEvents: 'none'
});
document.body.appendChild(labelRenderer.domElement);

// 轨道控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;

// 创建地球
const earth = new THREE.Mesh(
  new THREE.SphereGeometry(1, 32, 32),
  new THREE.MeshStandardMaterial({ color: 0x2233ff, roughness: 0.5 })
);
scene.add(earth);

// 生成地球纹理
const canvas = Object.assign(document.createElement('canvas'), { width: 512, height: 512 });
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e90ff';
ctx.fillRect(0, 0, 512, 512);
ctx.fillStyle = '#228b22';
for (let i = 0; i < 20; i++) {
  ctx.beginPath();
  ctx.arc(Math.random() * 512, Math.random() * 512, Math.random() * 50 + 20, 0, Math.PI * 2);
  ctx.fill();
}
earth.material.map = new THREE.CanvasTexture(canvas);

// 创建标签工厂函数
const createLabel = (text, position) => {
  const div = document.createElement('div');
  div.className = 'label';
  div.textContent = text;
  const label = new CSS2DObject(div);
  label.position.copy(position);
  return label;
};

earth.add(createLabel('Earth', new THREE.Vector3(0, 1.5, 0)));

// 光源
scene.add(new THREE.AmbientLight(0xffffff, 0.5));
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
dirLight.position.set(5, 3, 5);
scene.add(dirLight);

// 动画循环
(function animate() {
  requestAnimationFrame(animate);
  earth.rotation.y += 0.005;
  controls.update();
  renderer.render(scene, camera);
  labelRenderer.render(scene, camera);
})();

// 响应窗口变化
addEventListener('resize', () => {
  camera.aspect = innerWidth / innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(innerWidth, innerHeight);
  labelRenderer.setSize(innerWidth, innerHeight);
});

📂 核心代码与完整示例:     my-three-app

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多Three.js开发干货

2026 年,值得前端全栈尝试的 NestJS 技术栈组合 😍😍😍

作者 Moment
2026年1月20日 22:08

我正在开发 DocFlow,它是一个完整的 AI 全栈协同文档平台。该项目融合了多个技术栈,包括基于 Tiptap 的富文本编辑器、NestJs 后端服务、AI 集成功能和实时协作。在开发过程中,我积累了丰富的实战经验,涵盖了 Tiptap 的深度定制、性能优化和协作功能的实现等核心难点。

如果你对 AI 全栈开发、Tiptap 富文本编辑器定制或 DocFlow 项目的完整技术方案感兴趣,欢迎加我微信 yunmz777 进行私聊咨询,获取详细的技术分享和最佳实践。

对很多想从前端转向全栈的同学来说,NestJS 是一个非常友好的选择:语法风格接近前端熟悉的 TypeScript,又借鉴了后端常见的模块化与依赖注入模式,可以在保留前端开发舒适度的同时,比较轻松地搭起一套“像样的后端服务”。

如果目标不仅是写几个简单接口,而是要扛起鉴权、实时协同、AI 能力,还要自己搭建和维护数据库、缓存、搜索、对象存储、监控这些基础设施,那么就需要一套偏“自托管、自运维”的 NestJS 技术栈组合。

这里推荐的技术栈选择标准主要有三点:

  1. NestJS 生态高度契合,有成熟的官方或社区集成;
  2. 能够支撑中大型文档、知识类应用的性能和复杂度,比如协同编辑、全文检索、RAG、任务队列等;
  3. 个人或小团队也能在合理成本内自行部署和维护,比如使用 Prisma + MySqlRedisElasticsearchMinIO 这类开源组件。

接下来就按照从框架、运行时到数据层、搜索、队列、AI 的顺序,分享一套适合前端转全栈使用的 NestJS 核心技术栈,代码也尽量贴近实战,方便直接改造复用。

NestJSTypeScript

NestJS 是整个后端的“框架壳子”,负责模块划分、依赖注入、装饰器等基础能力;TypeScript 则是地基,把很多原本要靠经验避免的错误提前到编译期发现,例如 Controller 入参、Service 返回值、配置对象等。

实际项目中,一般会开启严格模式,再结合全局的 ValidationPipeclass-transformer,把请求里的原始 JSON 自动转换成带类型的 DTO 实例,从而减少 any 的使用、避免脏数据流入业务层。

基本使用案例:

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
    }),
  );
  await app.listen(3000);
}

bootstrap();

Fastify@nestjs/platform-fastify

Fastify 是一个高性能 HTTP 引擎,相比 Express 更轻量、更适合高并发场景;@nestjs/platform-fastify 负责把 Fastify 接进 Nest,让你在业务层依然只写标准的 Controller、Guard、Interceptor 等。

搭配 @fastify/helmet@fastify/rate-limit@fastify/cookie@fastify/secure-session@fastify/multipart@fastify/static,可以在统一框架里完成安全头、限流、会话、上传和静态资源托管。

基本使用案例:

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import {
  FastifyAdapter,
  NestFastifyApplication,
} from '@nestjs/platform-fastify';

async function bootstrap() {
  const app = await NestFactory.create<NestFastifyApplication>(
    AppModule,
    new FastifyAdapter(),
  );
  await app.listen(3000, '0.0.0.0');
}

bootstrap();

@nestjs/config

@nestjs/config 是 Nest 官方的配置模块,用来统一管理多环境配置。思路很简单:所有数据库地址、第三方秘钥、开关配置等,都通过 ConfigService 读取,而不是在代码里到处散落 process.env

推荐的做法是为不同领域写独立的配置工厂,然后在对应模块中注入使用。

基本使用案例:

// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';

@Module({
  imports: [
    ConfigModule.forRoot({
      isGlobal: true,
      envFilePath: '.env',
    }),
  ],
})
export class AppModule {}
// some.service.ts
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

@Injectable()
export class SomeService {
  constructor(private readonly configService: ConfigService) {}

  getDbUrl() {
    return this.configService.get<string>('DATABASE_URL');
  }
}

PassportJWT

@nestjs/passport@nestjs/jwt 提供了一整套认证基础设施:Passport 统一管理各种认证策略(本地、JWT、OAuth 等),JWT 提供无状态令牌,让前后端分离、多端访问更容易管理。

常见流程是:通过本地策略验证用户名密码,登录成功后签发 JWT,后续请求通过 AuthGuard('jwt') 验证;接入第三方登录(如 GitHub)时,仅需增加新的 Passport 策略。

基本使用案例:

// auth.module.ts
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [
    PassportModule.register({ defaultStrategy: 'jwt' }),
    JwtModule.register({
      secret: 'secret', // 实际项目请从 ConfigService 读取
    }),
  ],
  providers: [JwtStrategy],
  exports: [PassportModule, JwtModule],
})
export class AuthModule {}
// some.controller.ts
import { Controller, Get, UseGuards } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Controller('profile')
export class ProfileController {
  @UseGuards(AuthGuard('jwt'))
  @Get()
  getProfile() {
    return { ok: true };
  }
}

WebSocketSocket.IO

当系统需要即时通知、在线状态、协同编辑时,可以使用 @nestjs/websockets 搭配 @nestjs/platform-socket.iosocket.ioGateway 充当“长连接入口”,负责管理连接、房间和事件。

更高级的协同场景中,还可以引入 hocuspocusyjsy-prosemirror 来负责文档协同算法,Nest 只需要负责连接管理和权限校验。

基本使用案例:

// chat.gateway.ts
import {
  WebSocketGateway,
  WebSocketServer,
  SubscribeMessage,
  MessageBody,
} from '@nestjs/websockets';
import { Server } from 'socket.io';

@WebSocketGateway()
export class ChatGateway {
  @WebSocketServer()
  server: Server;

  @SubscribeMessage('message')
  handleMessage(@MessageBody() data: string) {
    this.server.emit('message', data);
  }
}

Prisma

Prisma 通过独立的 schema.prisma 文件定义模型,并生成强类型的 PrismaClient,非常适合在 Nest 的 Service 层中使用。它把数据库迁移、数据建模、类型安全绑在一起,能明显降低 SQL 错误和字段拼写错误的概率。

在 Service 中,直接注入封装好的 PrismaService,就可以用类型安全的方式进行增删改查。

基本使用案例:

// user.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '@/common/prisma/prisma.service';

@Injectable()
export class UserService {
  constructor(private readonly prisma: PrismaService) {}

  findAll() {
    return this.prisma.user.findMany();
  }
}

Redisioredis

Redis 是常见的缓存与中间层,而 ioredis 是稳定好用的 Node 客户端组合。它通常用于三个方向:缓存(加速读取)、分布式协调(锁、限流、防重复)、短期数据存储(会话、任务状态等)。

在 Nest 中一般会封装一个 RedisService,对外暴露 getsetincr 等方法,避免直接在业务里使用底层客户端。

基本使用案例:

// redis.service.ts
import { Injectable, OnModuleInit } from '@nestjs/common';
import { Redis } from 'ioredis';

@Injectable()
export class RedisService implements OnModuleInit {
  private client: Redis;

  onModuleInit() {
    this.client = new Redis('redis://localhost:6379');
  }

  async get(key: string) {
    return this.client.get(key);
  }

  async set(key: string, value: string, ttlSeconds?: number) {
    if (ttlSeconds) {
      await this.client.set(key, value, 'EX', ttlSeconds);
    } else {
      await this.client.set(key, value);
    }
  }
}

BullMQ

BullMQ 是基于 Redis 的任务队列,@nestjs/bullmq 让你可以用装饰器方式定义队列和消费者。它适合承载各种耗时任务,例如大文件解析、批量导入导出、调用外部 AI 接口等。

这样可以把重任务从 HTTP 请求中剥离,避免接口超时,用户只需拿到一个“任务已受理”的 ID。

基本使用案例:

消费者(处理任务):

// task.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';

@Processor('tasks')
export class TaskProcessor extends WorkerHost {
  async process(job: Job) {
    // 这里处理耗时任务
    console.log('processing job', job.id, job.data);
  }
}

生产者(投递任务):

// task.service.ts
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';

@Injectable()
export class TaskService {
  constructor(@InjectQueue('tasks') private readonly queue: Queue) {}

  async createTask(payload: any) {
    const job = await this.queue.add('process', payload);
    return { jobId: job.id };
  }
}

Elasticsearch

Elasticsearch 在文档和知识类系统中通常用作结构化搜索与日志索引引擎。通过 @elastic/elasticsearch 客户端可以在 Nest 的 Service 里封装搜索接口,对外统一暴露“搜索文档”“搜索日志”等能力。

相对数据库原生查询,Elasticsearch 更擅长复杂查询、聚合统计、模糊搜索与搜索结果排序,是提升搜索体验的关键组件。

基本使用案例:

封装搜索 Service:

// search.service.ts
import { Injectable } from '@nestjs/common';
import { Client } from '@elastic/elasticsearch';

@Injectable()
export class SearchService {
  private client = new Client({ node: 'http://localhost:9200' });

  async searchDocs(keyword: string) {
    const res = await this.client.search({
      index: 'documents',
      query: {
        multi_match: {
          query: keyword,
          fields: ['title', 'content'],
        },
      },
    });
    return res.hits.hits;
  }
}

对象存储(MinIO

当系统有大量文件(如 PDFWord、图片、音频)时,本地磁盘很快就会吃不消,这时可以用自建的 MinIO 集群来做对象存储。它负责长期保存大文件,后端只需要关心对象名和访问地址,不必再直接管理磁盘。

在 Nest 中通常会封装一个存储 Service,对上层暴露“上传文件”“生成下载地址”等方法;同时配合 imagekitsharpexiftool-vendoredpdf-parsemammoth 等,对文件做压缩、预览、元信息与文本提取等处理。

基本使用案例:

// storage.service.ts
import { Injectable } from '@nestjs/common';
import { Client } from 'minio';

@Injectable()
export class StorageService {
  private client = new Client({
    endPoint: 'localhost',
    port: 9000,
    useSSL: false,
    accessKey: 'minio',
    secretKey: 'minio123',
  });

  async upload(bucket: string, objectName: string, buffer: Buffer) {
    await this.client.putObject(bucket, objectName, buffer);
    return { bucket, objectName };
  }
}

@nestjs/swagger

@nestjs/swagger 负责从 Controller 和 DTO 的装饰器中生成 OpenAPI 文档,让接口定义和代码实现保持同步,不再需要单独维护一份容易过期的接口文档。

在前后端分离项目中,Swagger 文档可以同时服务前端、测试与产品:前端对齐请求和响应结构,测试做接口验证,产品了解后端已有能力。

基本使用案例:

// main.ts 片段
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const config = new DocumentBuilder()
    .setTitle('API 文档')
    .setDescription('服务接口说明')
    .setVersion('1.0')
    .build();

  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('docs', app, document);

  await app.listen(3000);
}

class-validatorclass-transformer

class-validator 通过在 DTO 类属性上添加装饰器(如 IsStringIsIntIsOptional 等)定义字段的合法规则,class-transformer 负责把原始请求 JSON 转换成 DTO 实例。配合全局 ValidationPipe,可以保证进入 Controller 的数据已经过校验和转换。

这一体系大大减少了手写 if 校验的重复劳动,同时确保错误请求在统一入口被拦截并抛出合适的 HTTP 异常。

基本使用案例:

// create-user.dto.ts
import { IsEmail, IsString, MinLength } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  @MinLength(6)
  password: string;
}
// user.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';

@Controller('users')
export class UserController {
  @Post()
  create(@Body() dto: CreateUserDto) {
    return dto;
  }
}

PrometheusTerminus

@willsoto/nestjs-prometheus 搭配 prom-client 可以方便地暴露各种指标端点,例如 HTTP 延迟、错误率、队列堆积情况等;@nestjs/terminus 则专注于健康检查,通过多种 HealthIndicator 检查数据库、RedisElasticsearchQdrant 等依赖服务是否可用。

在生产环境下,这两者为“可观测性”打基础,使运维和开发可以快速感知和定位问题。

基本使用案例:

// health.controller.ts
import { Controller, Get } from '@nestjs/common';
import {
  HealthCheck,
  HealthCheckService,
  TypeOrmHealthIndicator,
} from '@nestjs/terminus';

@Controller('health')
export class HealthController {
  constructor(
    private health: HealthCheckService,
    private db: TypeOrmHealthIndicator,
  ) {}

  @Get()
  @HealthCheck()
  check() {
    return this.health.check([
      () => this.db.pingCheck('database'),
    ]);
  }
}

OpenAILangChain

openai 提供与大模型交互的基础接口,而 langchain@langchain/core@langchain/community@langchain/openai@langchain/textsplitters 则把模型调用、提示模板、工具调用、长文档切片等复杂逻辑抽象成可组合的模块。对于文档工作流类项目,这一层就是从“普通文档系统”升级为“智能文档系统”的关键。

在 Nest 中,通常会拆出一个 AI 模块,把“向量检索 + RAG + 模型调用”封装在 Service 里,再通过少量 HTTP 接口暴露出问答、总结、润色等能力。

基本使用案例:

// ai.service.ts
import { Injectable } from '@nestjs/common';
import OpenAI from 'openai';

@Injectable()
export class AiService {
  private client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

  async chat(prompt: string) {
    const res = await this.client.chat.completions.create({
      model: 'gpt-4.1-mini',
      messages: [{ role: 'user', content: prompt }],
    });
    return res.choices[0]?.message?.content ?? '';
  }
}

总结

如果只是想把接口“跑起来”,也许只要 NestJS 加上一两个库就够用;但一旦要扛起鉴权、实时协同、AI、文件与搜索这些完整能力,就很容易演变成这篇文章前面列出来的这一整套技术栈:NestJS + TypeScript 打基础,Fastify 提供高性能 HTTP 入口,Prisma + MySql 管数据,Redis + BullMQ 管缓存和队列,Elasticsearch 管搜索,MinIO 管文件,再加上 @nestjs/swaggerclass-validatorPrometheusTerminusOpenAILangChain 这些周边,让项目从“能跑”变成“好用、可维护、可扩展”。

对前端转全栈来说,这套组合有两个现实的好处:一是语法和思路都围绕 TypeScript 展开,上手成本可控;二是每一个环节都有成熟的 Nest 集成(模块、装饰器、示例代码),可以按需逐步引入,而不必一口气吃下全部。你可以先用 NestJS + Prisma + Redis 起一个简单项目,再慢慢把队列、搜索、对象存储和 AI 能力补上,最终搭出一套适合自己长期维护的后端脚手架。

让 AI 学会"问一嘴":assistant-ui 前端工具的人机交互实践

作者 红尘散仙
2026年1月20日 22:02

让 AI 学会"问一嘴":assistant-ui 前端工具的人机交互实践

🤖 当 AI 助手要帮用户执行敏感操作时,总不能闷头就干吧?得先问问人家确不确认啊!

背景:一个"莽撞"的 AI 助手

我在做一个工单管理系统,里面有个 AI 助手功能。用户可以说"帮我创建一个工单,数量 50",AI 就会调用工具创建记录。

听起来很美好,但问题来了:

AI 太"自信"了。

它收到指令就直接执行,万一用户说错了呢?万一 AI 理解错了呢?50 变成 500,那可就麻烦了。

所以我需要一个机制:AI 调用工具前,先让用户确认一下。

用户: 帮我创建一个工单,数量 50

AI: 好的,我来帮你创建记录
    ┌─────────────────────────┐
    │ 确认创建记录             │
    │ 名称: A 款               │
    │ 数量: 50                 │
    │ [确认]  [取消]           │
    └─────────────────────────┘

用户: *点击确认*

AI: ✅ 已创建记录:Ax 50

这就是所谓的 Human-in-the-Loop(人机协作)模式。

技术选型

  • 前端:React + @assistant-ui/react
  • 后端:Rust + Axum + Rig(AI 框架)
  • AI:OpenAI GPT-4

assistant-ui 是一个专门为 AI 聊天界面设计的 React 组件库,它有个很棒的特性:前端工具(Frontend Tools)

架构概览

flowchart TB
    subgraph Frontend["前端 (React)"]
        Runtime[useChatRuntime]
        Tool[CreateRecordTool<br/>前端工具]
        UI[确认 UI]
        Runtime --> Tool --> UI
        UI -->|resume| Tool
        Tool -->|sendAutomatically| Runtime
    end

    subgraph Backend["后端 (Rust)"]
        Handler[Chat Handler]
        FTool[FrontendTool<br/>工具定义转发]
        AI[AI Model]
        Handler --> FTool --> AI
    end

    Runtime <-->|HTTP| Handler

核心概念:前端工具 vs 后端工具

前端工具 后端工具
执行位置 浏览器 服务器
能否与用户交互 ✅ 可以 ❌ 不行
适用场景 需要确认的操作 查询、计算

前端工具的精髓在于:工具定义发给 AI,但执行在前端

AI 知道有这个工具可以用,当它决定调用时,前端拦截执行,可以弹个确认框、让用户填个表单,用户操作完再把结果告诉 AI。

实现步骤

1. 定义工具参数

// schema.ts
import { z } from "zod";

export const CreateRecordSchema = z.object({
  name: z.string().describe("名称"),
  amount: z.number().describe("数量"),
});

2. 创建前端工具

这是最关键的部分,使用 makeAssistantTool

import { makeAssistantTool } from "@assistant-ui/react";

export const CreateRecordTool = makeAssistantTool({
  toolName: "create-record",
  type: "frontend",  // 🔑 关键:标记为前端工具
  parameters: CreateRecordSchema,
  description: "创建记录",

  // execute 在前端执行
  execute: async (args, ctx) => {
    const { human } = ctx;

    // human() 会暂停执行,等待用户确认
    const response = await human("请确认创建记录");

    if (response === "confirmed") {
      // 调用实际 API
      await api.createRecord(args);
      return { success: true };
    }
    return { success: false };
  },

  // render 渲染确认 UI
  render: ({ args, status, result, resume }) => {
    // 等待确认状态
    if (status.type === "requires-action") {
      return (
        <div className="rounded-lg border p-4">
          <div>确认创建记录</div>
          <div>名称: {args.name} | 数量: {args.amount}</div>
          <button onClick={() => resume("confirmed")}>确认</button>
          <button onClick={() => resume("cancelled")}>取消</button>
        </div>
      );
    }

    // 完成状态
    return <div>{result?.success ? "✅" : "❌"} 已处理</div>;
  },
});

核心 API 解释:

  • human(message): 暂停执行,等待用户操作
  • resume(value): 用户操作后恢复执行,value 会作为 human() 的返回值

3. 注册工具

function ChatPage() {
  const processedToolCalls = useRef(new Set<string>());

  const runtime = useChatRuntime({
    transport: new AssistantChatTransport({
      api: "/api/chat",
    }),
    // 工具完成后自动发送结果给后端
    sendAutomaticallyWhen: (options) => {
      if (!lastAssistantMessageIsCompleteWithToolCalls(options)) {
        return false;
      }
      const lastMsg = options.messages.at(-1);
      const toolPart = lastMsg?.parts.find(
        (p) => p.type === "tool-create-record" && p.state === "output-available"
      ) as { toolCallId: string } | undefined;

      if (toolPart && !processedToolCalls.current.has(toolPart.toolCallId)) {
        processedToolCalls.current.add(toolPart.toolCallId);
        return true;
      }
      return false;
    },
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      <Thread />
      <CreateRecordTool />
    </AssistantRuntimeProvider>
  );
}

4. 后端接收工具定义

前端会把工具定义发给后端,后端需要转发给 AI:

// Rust 后端
pub struct FrontendTool {
    pub name: String,
    pub description: String,
    pub parameters: Value,
}

impl ToolDyn for FrontendTool {
    fn name(&self) -> String { self.name.clone() }

    fn definition(&self, _: String) -> ToolDefinition {
        ToolDefinition {
            name: self.name.clone(),
            description: self.description.clone(),
            parameters: self.parameters.clone(),
        }
    }

    fn call(&self, _: String) -> Result<String, ToolError> {
        // 前端工具不在后端执行!
        Err(ToolError::ToolCallError("Frontend tool".into()))
    }
}

完整流程

sequenceDiagram
    participant 用户
    participant 前端
    participant 后端
    participant AI

    用户->>前端: "创建工单,数量 50"
    前端->>后端: 发送消息 + 工具定义
    后端->>AI: 转发
    AI->>后端: 调用 create-record
    后端-->>前端: 返回工具调用
    前端->>前端: execute() → human()
    前端->>用户: 显示确认框
    用户->>前端: 点击确认
    前端->>前端: resume("confirmed")
    前端->>后端: 发送工具结果
    后端->>AI: 转发结果
    AI->>后端: "好的,已记录"
    后端-->>前端: 返回回复
    前端->>用户: 显示完成

踩坑记录

坑 1:Tool call is not waiting for human input

原因:没在 execute 里调用 human(),或者在错误状态下调用了 resume()

解决:确保 execute 里有 await human(),且只在 status.type === "requires-action" 时调用 resume()

坑 2:工具完成后消息无限发送

原因sendAutomaticallyWhen 对同一个工具调用重复返回 true

解决:用 useRef 记录已处理的 toolCallId

const processedToolCalls = useRef(new Set<string>());

sendAutomaticallyWhen: (options) => {
  // ... 找到完成的工具调用
  if (!processedToolCalls.current.has(toolCallId)) {
    processedToolCalls.current.add(toolCallId);
    return true;
  }
  return false;
}

坑 3:用错了 makeAssistantToolUI

makeAssistantToolUI 只渲染 UI,不会把工具定义发给后端。如果需要 AI 能调用,必须用 makeAssistantTool

总结

通过 assistant-ui 的前端工具机制,我们实现了:

  1. AI 能力不打折:AI 仍然可以决定何时调用工具
  2. 用户有控制权:敏感操作必须经过用户确认
  3. 体验很自然:确认框嵌入在对话流中,不突兀

这套方案已经在生产环境稳定运行,用户再也不用担心 AI "手滑"了 😄


技术栈:React + Rust + Tauri + assistant-ui

如果你也在做 AI 应用,需要人机协作的场景,希望这篇文章对你有帮助!

欢迎评论区交流 👇

回首 jQuery 20 年:从辉煌到没落

作者 冴羽
2026年1月20日 18:53

2006 年 1 月 14 日,John Resig 发布了名为 jQuery 的 JavaScript 库。

至今已经过去了 20 年!

20 周年之际,jQuery 4.0 正式发布了!

是的,就是那个被无数人宣布“已死”的 jQuery,经过 10 年的等待后迎来了重大更新。

更让人意想不到的是,根据 W3Techs 的数据,jQuery 仍然被全球 78% 的网站使用

这个数字意味着什么?

在 React、Vue、Angular 等现代框架横行的今天,那个曾经被我们嫌弃“老掉牙”的 jQuery,依然在互联网的角落里默默发光发热。

从 2006 年 John Resig 在 BarCampNYC 大会上首次发布,到今天 4.0 版本的现代化重生,jQuery 走过了整整 20 年。

它不仅是一个 JavaScript 库,更是一个时代的缩影,见证了前端技术从混沌到繁荣的完整历程。

本篇让我们一起回顾 jQuery 的 20 年,见证它的辉煌与没落。

1. 混沌时代

回望 2006 年,彼时正值第一次浏览器战争的尾声,微软 IE 与网景 Navigator 刚刚打完仗,但遗留下来的兼容性问题却让无数前端开发者头疼不已。

当时开发者需要面对各种浏览器的“奇技淫巧”,光是一个事件绑定就要写一大串兼容代码。

来看看这段早期的 jQuery 源码:

// 如果使用Mozilla
if (jQuery.browser == "mozilla" || jQuery.browser == "opera") {
    jQuery.event.add(document, "DOMContentLoaded", jQuery.ready);
}
// 如果使用IE
else if (jQuery.browser == "msie") {
    document.write("<scr" +="" "ipt="" id="__ie_init" defer="true" "="" "src="javascript:void(0)"><\/script>");
    var script = document.getElementById("__ie_init");
    script.onreadystatechange = function() {
        if (this.readyState == "complete") jQuery.ready();
    };
}
// 如果使用Safari
else if (jQuery.browser == "safari") {
    jQuery.safariTimer = setInterval(function(){
        if (document.readyState == "loaded" || document.readyState == "complete") {
            clearInterval(jQuery.safariTimer);
            jQuery.ready();
        }
    }, 10);
}
</scr">

看到没?仅仅是处理页面加载事件就要写这么多兼容代码!这在今天是难以想象的。

2. 横空出世

就在这时,jQuery 横空出世,彻底改变了游戏规则。

John Resig 提出了一个简单而优雅的理念:

Write Less,Do More

jQuery 通过精简常见的重复性任务,去除所有不必要的标记,使代码简洁、高效且易于理解,从而实现这一目标。

jQuery 带来了两大革命性改变:

  1. 强大的选择器引擎:不再局限于简单的 ID 和类选择,可以进行复杂的关系选择
  2. 优雅的 API 设计:链式操作让代码既简洁又易读

看看这个对比:

// 传统DOM操作
var elements = document.getElementById("contacts").getElementsByTagName("ul")[0].getElementsByClassName("people");
for (var i = 0; i < elements.length; i++) {
  var items = elements[i].getElementsByTagName("li");
  for (var j = 0; j < items.length; j++) {
    // 操作每个item
  }
}

// jQuery方式
$("#contacts ul.people li").each(function () {
  // 操作每个item
});

差距一目了然!

jQuery 的出现让前端开发变得如此优雅,以至于迅速在开发者群体中传播开来。

3. 辉煌岁月

随着 jQuery 的普及,一个庞大的插件生态迅速建立起来。

从日期选择器到轮播图,从表单验证到动画效果,几乎你能想到的功能都有对应的 jQuery 插件。

那时候前端开发的标准流程是:

  1. 下载 jQuery 核心库
  2. 搜索并下载所需的 jQuery 插件
  3. 组合这些插件完成项目

同时,jQuery 的管理也变得正式。

2011 年,jQuery 团队正式成立了 jQuery 理事会。2012 年,jQuery 理事会成立了 jQuery 基金会。

4. 影响深远

jQuery 的影响力远远超出了技术本身,它推动了整个前端行业的发展:

  • **大幅降低了前端开发的门槛:**让更多的开发者能够参与到前端开发中来
  • 提升了前端工程师的社会地位:让前端开发变得更加专业和重要
  • 促进了浏览器厂商的标准化:jQuery 的成功证明了统一 API 的重要性
  • 催生了现代前端工具链:为后来的模块化、构建工具奠定了基础

甚至连 jQuery 的选择器引擎 Sizzle 后来都被提取出来,影响了整个选择器标准的发展。

5. 价值动摇

jQuery 之所以能够快速普及,很大程度上是因为浏览器的“不争气”。

而当浏览器厂商开始认真对待标准化问题时,jQuery 的核心价值就开始动摇了。

2009 年后,浏览器标准化进程大幅加速:

  • querySelectorquerySelectorAll的出现
  • classList API 的普及
  • fetch API 替代 Ajax 需求
  • CSS3 动画替代 JavaScript 动画

现代浏览器 API 的完善,让很多 jQuery 功能都有了原生替代品:

// jQuery方式
$("#btn").on("click", () => $("#box").addClass("active"));

// 原生方式
document.querySelector("#btn").addEventListener("click", () => {
  document.querySelector("#box").classList.add("active");
});

你可以发现,差距已经不再那么明显!

6. 框架打击

2010 年,React、Angular、Vue 等现代框架相继登场,带来了革命性的变化:

  1. 组件化思维:从 DOM 操作转向组件构建
  2. 声明式编程:描述“什么”而不是“如何”
  3. 状态管理:解决了复杂应用的维护问题
  4. 工具链完善:从构建到部署的完整解决方案

这些框架从架构层面解决了 jQuery 时代的问题,就像从手工制作转向了工业化生产。

7. 惨遭背叛

2018 年,GitHub 公开宣布从其前端移除 jQuery,这个标志性事件被广泛解读为“jQuery 时代的终结”。

GitHub 在博客中详细说明了迁移的理由:现代浏览器 API 已经足够完善,React 的组件化模式更适合大型应用的维护。

这个“背叛”对 jQuery 的声誉造成了重大打击,也加速了它在新技术栈中的衰落。

8. 瘦死骆驼

尽管在技术前沿领域失势,但 jQuery 在存量市场中的地位依然稳固:

  • 78% 的顶级网站仍在使用 jQuery
  • WordPress 等 CMS 系统大量依赖 jQuery
  • 企业级应用中 jQuery 代码基数庞大

为什么企业不直接抛弃 jQuery?

因为现实远比理想复杂:

  1. 业务逻辑与 DOM 深度耦合:重构成本巨大
  2. 第三方插件依赖:很多插件没有现代替代方案
  3. 迁移风险:新功能开发受阻,影响营收
  4. 技能断层:团队对旧技术熟悉,对新技术陌生

比如一个电商网站如果要重构支付流程的 jQuery 代码,任何 bug 都可能导致直接的经济损失。这种风险评估让很多公司望而却步。

此外,WordPress 支撑着全球 43% 的网站,它的核心仍然依赖 jQuery。这个庞大的生态系统意味着:

  • 数十万主题和插件依赖 jQuery
  • 内容管理系统对稳定性的要求远超先进性
  • 托管服务商倾向于保持现有技术栈

所以即使所有前端开发者都不再使用 jQuery,仅 WordPress 生态系统就能让它继续存在很多年。

9. 拥抱现代

2026 年 1 月 17 日,jQuery 4.0 正式发布,在这次发布中:

  • 移除对 IE11 以下版本的支持:摆脱历史包袱
  • 迁移到 ES 模块:与现代构建工具兼容
  • 增加 Trusted Types 支持:提升安全性
  • 移除已弃用 API:清理技术债务

这次更新像是 jQuery 面向现代 Web 的断舍离。

10. 结语:一个时代的完结

jQuery 20 年的发展史,就是一部前端技术的缩影。

它从解决现实问题出发,推动了整个行业的发展,最终也随着时代的变化而淡出主流。

这并不意味着 jQuery 是失败的。恰恰相反,它超额完成了自己的历史使命

  • 它让无数人学会了前端开发
  • 它推动了浏览器厂商的标准化
  • 它催生了现代前端生态
  • 它证明了开源协作的力量

正如那句经典的台词:“并不是英雄迟暮,而是时代需要新的英雄。”

jQuery 4.0 的发布不是回光返照,它告诉我们:技术没有绝对的对错,只有是否适合那个时代的需求

今天,当我们在 React、Vue 的组件化世界中忙碌时,偶尔回望一下 jQuery 的简单优雅,也许能获得一些关于技术本质的思考:

好的工具应该让人更专注于创造价值,而不是被技术本身所困扰。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

MCP、Agent、大模型应用架构解读

作者 sorryhc
2026年1月20日 17:58

前言

随着大语言模型(LLM)的快速发展,如何让 AI 能够有效地与外部世界交互,已成为 AI 应用开发的核心课题。Anthropic 推出的 MCP(Model Context Protocol)、智能代理(Agent)和大模型应用三者的结合,形成了一套完整的 AI 系统架构。

接下来,我们深入解读这三个核心概念及其相互关系。


一、3个核心概念的定义

1.1 大模型应用(AI Application)

大模型应用是整个系统的最外层容器。它包括:

  • 应用程序框架和生命周期管理
  • 用户交互界面(CLI、Web、API等)
  • 系统配置和资源管理
  • 外部集成(数据库、监控等)
大模型应用
  ├─ 启动应用
  ├─ 管理配置
  ├─ 处理用户输入
  ├─ 返回处理结果
  └─ 关闭应用

1.2 Agent(智能代理)

Agent 是大模型应用的大脑和执行引擎。它的职责是:

  • 理解用户意图(通过大模型)
  • 规划执行步骤
  • 决定调用什么工具
  • 处理工具执行结果
  • 持续优化和迭代

Agent 的核心价值在于将大模型的推理能力与外部工具执行能力结合。

1.3 MCP(Model Context Protocol)

MCP 是一个开放的通信协议规范。它定义了:

  • 工具的统一调用接口
  • 消息的标准格式(JSON-RPC 2.0)
  • 服务的发现和注册机制
  • 错误处理规范

MCP 的核心价值在于解耦工具调用的复杂性,实现工具即插即用。


二、三者的包含关系

┌──────────────────────────────────────────────────┐
│                 大模型应用                        │
│                                                  │
│  ┌────────────────────────────────────────────┐ │
│  │              Agent                         │ │
│  │                                            │ │
│  │  ├─ 初始化 MCP (建立连接、获取工具)       │ │
│  │  ├─ 与大模型交互 (发送提示词、接收响应)  │ │
│  │  ├─ 解析大模型输出 (识别工具调用)        │ │
│  │  ├─ 通过 MCP 调用工具 (执行具体任务)     │ │
│  │  ├─ 处理工具结果 (反馈给大模型)          │ │
│  │  └─ 循环迭代 (直到任务完成)              │ │
│  │                                            │ │
│  │         ◄──────────────────────►          │ │
│  │            MCP (工具协议)                  │ │
│  │         ◄──────────────────────►          │ │
│  │                                            │ │
│  └────────────────────────────────────────────┘ │
│                                                  │
│  用户输入  ──►  应用处理  ──►  用户输出        │
│                                                  │
└──────────────────────────────────────────────────┘

三、工作流程详解

3.1 初始化阶段

第一步:读取配置文件(mcp.json)
  ├─ 检查有哪些 MCP Server
  ├─ 验证配置的合法性
  └─ 记录工具来源信息

第二步:连接所有 MCP Server
  ├─ 为每个 Server 创建 MCP Client
  ├─ 建立传输连接(stdio/HTTP/WebSocket)
  ├─ 发送 initialize 信息握手
  └─ 获取 Server 能力信息

第三步:获取所有工具列表
  ├─ 从每个 Server 调用 listTools()
  ├─ 收集返回的工具定义
  ├─ 合并工具列表并检查冲突
  └─ 标记每个工具来自哪个 Server

第四步:准备就绪
  └─ Agent 获得完整的工具清单,可以开始工作

代码示例:

class AIApplication {
  private agent: Agent
  
  async initialize() {
    // Agent 初始化
    this.agent = new Agent("mcp.json")
    await this.agent.initialize()
    
    console.log("✓ 应用初始化完成")
    console.log(`✓ 可用工具数: ${this.agent.toolCount}`)
  }
}

3.2 处理请求阶段

当用户输入一个请求时,完整的处理流程如下:

用户输入: "帮我计算 (10 + 5) * 2 的结果"
  │
  ▼
┌─────────────────────────────────────────┐
│  Agent 第一步:准备提示词                 │
│  ├─ 获取当前的工具列表                   │
│  ├─ 组织成 Claude 能理解的格式          │
│  └─ 加入用户的原始请求                   │
└──────────────┬──────────────────────────┘
               │
  ┌────────────▼─────────────┐
  │  Claude API              │
  │  (处理用户请求)          │
  │  ├─ 理解用户意图         │
  │  ├─ 规划执行步骤         │
  │  └─ 决定调用哪些工具     │
  │                          │
  │  Claude 响应:            │
  │  "我需要先调用 add(10,5)"│
  └────────────┬─────────────┘
               │
  ┌────────────▼──────────────────────┐
  │  Agent 第二步:处理工具调用请求     │
  │  ├─ 解析 Claude 的响应             │
  │  ├─ 识别出要调用 "add" 工具        │
  │  ├─ 找到 "add" 来自哪个 Server    │
  │  └─ 获取该 Server 的 MCP Client    │
  └────────────┬──────────────────────┘
               │
  ┌────────────▼──────────────────────┐
  │  Agent 第三步:通过 MCP 调用工具    │
  │  ├─ 构建标准化的 RPC 请求          │
  │  ├─ 调用: client.callTool("add",   │
  │  │         {a: 10, b: 5})         │
  │  └─ 等待工具执行完毕               │
  └────────────┬──────────────────────┘
               │
  ┌────────────▼──────────────────────┐
  │  MCP Server (实际执行工具)         │
  │  ├─ 接收 RPC 请求                  │
  │  ├─ 执行: 10 + 5 = 15             │
  │  └─ 返回结果: {result: 15}        │
  └────────────┬──────────────────────┘
               │
  ┌────────────▼──────────────────────┐
  │  Agent 第四步:反馈给 Claude        │
  │  ├─ 把结果添加到对话历史           │
  │  ├─ "add(10, 5) 的结果是 15"      │
  │  └─ 重新调用 Claude               │
  └────────────┬──────────────────────┘
               │
  ┌────────────▼──────────────────────┐
  │  Claude 继续推理                  │
  │  ├─ 看到了第一步的结果             │
  │  ├─ 继续规划下一步                 │
  │  └─ "现在我需要调用 multiply(15,2)"│
  └────────────┬──────────────────────┘
               │
  (重复步骤 2-4 直到 Claude 说完成)
               │
  ┌────────────▼──────────────────────┐
  │  Claude 最终响应                   │
  │  ├─ stop_reason = "end_turn"      │
  │  ├─ content = "答案是 30"         │
  │  └─ Agent 停止循环                 │
  └────────────┬──────────────────────┘
               │
               ▼
        返回用户: "答案是 30"

3.3 循环机制的关键

Agent 的循环处理是理解整个架构的关键:

async process(userInput: string): Promise<string> {
  let messages = [{ role: "user", content: userInput }]
  
  for (let iteration = 0; iteration < maxIterations; iteration++) {
    // 1. 调用 Claude
    const response = await claude.messages.create({
      messages,
      tools: this.tools  // 传递所有可用工具
    })
    
    // 2. 添加 Claude 的响应到历史
    messages.push({ role: "assistant", content: response.content })
    
    // 3. 检查 Claude 是否完成
    if (response.stop_reason === "end_turn") {
      // Claude 完成了,返回最终答案
      const textBlock = response.content.find(b => b.type === "text")
      return textBlock.text
    }
    
    // 4. Claude 要求调用工具
    if (response.stop_reason === "tool_use") {

      const toolResults = [ ]

      
      for (const block of response.content) {
        if (block.type === "tool_use") {
          // 通过 MCP 调用工具
          const result = await this.callToolViaMCP(
            block.name,
            block.input
          )
          
          toolResults.push({
            type: "tool_result",
            tool_use_id: block.id,
            content: JSON.stringify(result)
          })
        }
      }
      
      // 5. 把工具结果添加到历史(关键!Claude 需要看到结果)
      messages.push({
        role: "user",
        content: toolResults
      })
      
      // 循环回第 1 步,Claude 基于工具结果继续推理
    }
  }
}

关键点:

  • messages 数组是"记忆",不断积累
  • 每次调用 Claude 时,都传递完整的历史
  • Claude 基于之前的工具执行结果进行下一步决策

四、MCP 的泛化调用设计

4.1 为什么需要泛化?

不泛化的方式(混乱):

// 需要为每个工具写特定代码
if (toolName === "add") {
  result = calculator.add(args.a, args.b)
} else if (toolName === "query") {
  result = database.query(args.sql)
} else if (toolName === "analyzeCode") {
  result = codeAnalyzer.analyze(args.code)
}
// ... 100+ 个 else if ...

// 问题:新增工具时要改应用代码

泛化的方式(MCP):

// 一个函数搞定所有工具
const result = await this.callToolViaMCP(toolName, args)

// 问题解决:新增工具时只需改配置

4.2 泛化的实现原理

┌──────────────────────────────────────────┐
│   统一的工具调用接口                      │
│   callTool(name: string, args: any)      │
└──────────────┬───────────────────────────┘
               │
      ┌────────┴────────┐
      │                 │
      ▼                 ▼
  ┌────────┐      ┌──────────┐
  │ Server │      │ Server   │
  │ A      │      │ B        │
  │        │      │          │
  │ add    │      │ query    │
  │ sub    │      │ insert   │
  └────────┘      └──────────┘

所有 Server 遵守相同的 MCP 规范:
  ├─ 都支持 listTools() 方法
  ├─ 都支持 callTool(name, args) 调用
  ├─ 都返回标准格式的结果
  └─ 应用无需关心 Server 差异

4.3 MCP 规范的约束

MCP 定义了统一的消息格式:

// 工具列表请求
{
  "jsonrpc": "2.0",
  "id": "1",
  "method": "tools/list"
}

// 工具列表响应
{
  "jsonrpc": "2.0",
  "id": "1",
  "result": {
    "tools": [
      {
        "name": "add",
        "description": "Add two numbers",
        "inputSchema": {
          "type": "object",
          "properties": {
            "a": {"type": "number"},
            "b": {"type": "number"}
          },
          "required": ["a", "b"]
        }
      }
    ]
  }
}

// 工具调用请求
{
  "jsonrpc": "2.0",
  "id": "2",
  "method": "tools/call",
  "params": {
    "name": "add",
    "arguments": {"a": 5, "b": 3}
  }
}

// 工具调用响应
{
  "jsonrpc": "2.0",
  "id": "2",
  "result": {
    "content": [
      {
        "type": "text",
        "text": "5 + 3 = 8"
      }
    ]
  }
}

四、结尾

因此有了对这三者的核心概念的了解,其实对大模型应用开发也有了比较深入的认识了。

评论区欢迎讨论。

用 Intersection Observer 打造丝滑的级联滚动动画

作者 阿明Drift
2026年1月20日 17:49

无需任何动画库,仅用原生 Web API 实现滚动时丝滑的淡入滑入效果,兼顾性能与体验。

你是否见过这样的交互动效:

  • 用户滚动页面时,一组卡片像被“唤醒”一样,依次从下方滑入并淡入;

滚动触发动画示例

  • 如果这些元素在页面加载时已在视口内,它们也会自动按顺序浮现。

初始加载动画示例

这种效果不仅视觉流畅,还能有效引导用户注意力,提升内容层次感。更重要的是——它不依赖 GSAP、AOS 等第三方库,仅靠 Intersection Observer + CSS 动画 + 少量 JavaScript,就能实现高性能、可访问、且高度可控的滚动触发型级联动画。

今天,我们就来一步步拆解这个经典动效,并给出一套可直接复用的轻量级方案


🔧 核心原理概览

整个动画系统依赖三个关键技术点:

技术 作用
IntersectionObserver 监听元素是否进入视口,避免频繁 scroll 事件
CSS @keyframes 定义滑入 + 淡入动画
--animation-order 自定义属性 通过 calc() 动态设置 animation-delay,实现“逐个延迟”的级联感

最关键的设计哲学是:动画只在用户能看到它的时候才执行,既节省性能,又避免“闪现”。


🧱 HTML 结构(简化版)

为便于理解,我们剥离业务逻辑,只保留动效核心:

<div class="container">
    <ul class="card-list">
        <li class="card scroll-trigger animate--slide-in" data-cascade style="--animation-order: 1;"
            >Card 1</li
        >
        <li class="card scroll-trigger animate--slide-in" data-cascade style="--animation-order: 2;"
            >Card 2</li
        >
        <li class="card scroll-trigger animate--slide-in" data-cascade style="--animation-order: 3;"
            >Card 3</li
        >
        <!-- 更多卡片... -->
    </ul>
</div>

💡 类名与属性说明

  • .scroll-trigger:表示该元素需要被滚动监听;
  • .animate--slide-in:启用滑入动画;
  • data-cascade:JS 识别“需设置动画顺序”的标志;
  • --animation-order:CSS 自定义属性,用于计算延迟时间(如第 2 个元素延迟 150ms)。

🎨 CSS 动画定义

:root {
    --duration-extra-long: 600ms;
    --ease-out-slow: cubic-bezier(0, 0, 0.3, 1);
}

/* 仅在用户未开启“减少运动”时启用动画(晕动症用户友好) */
@media (prefers-reduced-motion: no-preference) {
    .scroll-trigger:not(.scroll-trigger--offscreen).animate--slide-in {
        animation: slideIn var(--duration-extra-long) var(--ease-out-slow) forwards;
        animation-delay: calc(var(--animation-order) * 75ms);
    }

    @keyframes slideIn {
        from {
            transform: translateY(2rem);
            opacity: 0.01;
        }
        to {
            transform: translateY(0);
            opacity: 1;
        }
    }
}

✨ 参数说明

属性 作用
transform translateY(2rem) → 0 由下往上滑入
opacity 0.01 → 1 淡入(避免完全透明导致布局跳动)
animation-delay n × 75ms 第1个延迟75ms,第2个150ms……形成级联
animation-fill-mode forwards 动画结束后保持最终状态

无障碍提示:通过 @media (prefers-reduced-motion) 尊重用户偏好,对晕动症用户更友好。


🕵️ JavaScript:Intersection Observer 监听逻辑

为什么不用 scroll 事件?

传统方式:

// ❌ 性能差,频繁触发
window.addEventListener('scroll', checkVisibility);

现代方案:

// ✅ 高性能,浏览器底层优化
const observer = new IntersectionObserver(callback, options);

完整监听逻辑

const SCROLL_ANIMATION_TRIGGER_CLASSNAME = 'scroll-trigger';
const SCROLL_ANIMATION_OFFSCREEN_CLASSNAME = 'scroll-trigger--offscreen';

function onIntersection(entries, observer) {
    entries.forEach((entry, index) => {
        const el = entry.target;

        if (entry.isIntersecting) {
            // 进入视口:移除 offscreen 类,允许动画播放
            el.classList.remove(SCROLL_ANIMATION_OFFSCREEN_CLASSNAME);

            // 若为级联元素,动态设置顺序(兜底)
            if (el.hasAttribute('data-cascade')) {
                el.style.setProperty('--animation-order', index + 1);
            }

            // 只触发一次,停止监听
            observer.unobserve(el);
        } else {
            // 离开视口:加上 offscreen 类,禁用动画
            el.classList.add(SCROLL_ANIMATION_OFFSCREEN_CLASSNAME);
        }
    });
}

function initScrollAnimations(root = document) {
    const triggers = root.querySelectorAll(`.${SCROLL_ANIMATION_TRIGGER_CLASSNAME}`);
    if (!triggers.length) return;

    const observer = new IntersectionObserver(onIntersection, {
        rootMargin: '0px 0px -50px 0px', // 元素进入视口 50px 后才触发
        threshold: [0, 0.25, 0.5, 0.75, 1.0],
    });

    triggers.forEach((el) => observer.observe(el));
}

// 页面加载完成后启动
document.addEventListener('DOMContentLoaded', () => {
    initScrollAnimations();
});

🎯 关键设计细节

  • rootMargin: '0px 0px -50px 0px':确保元素完全进入用户视野后再触发动画,避免“刚看到就结束”;
  • 初始所有 .scroll-trigger 元素默认带有 .scroll-trigger--offscreen 类,阻止 CSS 动画生效;
  • unobserve:动画只播放一次,避免重复触发,节省资源。

📊 两种场景下的行为对比

场景 初始状态 触发时机 动画表现
卡片已在视口内 --offscreen 页面加载后立即 依次淡入(基于 --animation-order
卡片在视口外 --offscreen 滚动到视口(超过 50px) 滚动时依次淡入

这正是你感受到的“丝滑感”来源:无论用户如何进入页面,动画总是在最合适的时机出现


💡 总结:这套方案的优势

能力 说明
高性能 使用 IntersectionObserver 替代 scroll 事件,避免频繁计算
精准控制 通过 rootMarginthreshold 灵活调整触发时机
无障碍友好 尊重 prefers-reduced-motion 用户偏好
轻量可复用 无依赖,仅 50 行 JS + 简洁 CSS,适合嵌入任何项目
懒加载兼容 可扩展用于图片懒加载、广告曝光统计等场景

完整 Demo 已上传 CodePen:
👉 codepen.io/AMingDrift/…

如果你正在开发电商、博客、SaaS 产品页等内容密集型网站,不妨将这套方案集成进去,给用户带来更优雅的浏览体验!


学习优秀作品,是提升技术的最佳路径。本文既是我的学习笔记,也希望对你有所启发。

CSS 动效进阶:从“能动就行”到“性能优化”,一个呼吸球背后的 3 个思考

作者 Flinton
2026年1月20日 17:31

大家好,我叫【小奇腾】,今天我们聊一个场景题:“同心圆呼吸动画”,很多同学 5 分钟就能写出来。

但是代码能跑,就OK了吗?

当出题人问你:“为什么你的动画看起来卡卡的,或者你是怎么确定缩放比例的?”,这时候就不是考察你会不会CSS语法了,而是考察你对浏览器渲染原理工程化思维的理解。 今天让我们分3步来进行思考,从能动就行极致性能

本期详细的视频教程bilibili:# CSS 动效进阶:从“能动就行”到“性能优化”,一个呼吸球背后的 3 个思考

01. 场景复现

题目: 场景复现
利用所学的盒子模型和动画,考虑如何实现如下图的同心圆。该同心圆会放大缩小的运动轨迹:
1. 定义:目前两圈的大小为常规大小
2. 正常运动轨迹:
外圈向外扩大 10px (2000ms)
外圈向内回归正常大小 (2000ms)
内圈向内缩小 12px (2500ms)
内圈放大至常规大小 (2500ms)
循环

思考一:布局的健壮性 —— 为什么不推荐 margin?

拿到题目,第一步是布局。很多初学者习惯用 margin 来控制位置,比如:

/* ❌ 脆弱的布局写法 */
.circle {
    width: 100px;
    margin: 100px auto; /* 依赖外部容器高度,不够灵活 */
}

或者用 calc 配合 margin-left 负值来居中。这些写法在静态页面没问题,但在动画场景下,一旦圆的尺寸发生变化(比如宽高改变),中心点很容易偏移。

这里有三个理由: 第一,脱离文档流,防止动画放大时撑开页面; 第二,自适应垂直居中,不用人肉计算 margin-top; 第三,锚定圆心,确保多层圆圈在缩放时,圆心永远重合,不会跑偏。 这就是为什么在做动效组件时,绝对定位永远是首选。”

✅ 推荐方案:绝对定位 + 变换

.center-abs {
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
}

解析:

  • top/left: 50%:将元素的左上角推到容器中心。
  • translate(-50%, -50%):将元素自身往回拉一半。
  • 优势: 这种居中方式完全不依赖元素的具体宽高。无论你怎么缩放,它的几何中心永远锚定在父容器的正中心。

思考二:渲染性能 —— 为什么宁愿算 Scale 也不改 Width?

这里是区分“初级”和“高级”的分水岭

需求是:外圈从 100px 变大到 110px。 直觉告诉我们,直接改 width 最方便:

❌ 性能较差的写法 */
@keyframes expand {
    0% { width: 100px; height: 100px; }
    50% { width: 110px; height: 110px; }
    100% { width: 100px; height: 100px; }
}

为什么说它性能差? 这涉及到了浏览器的渲染流水线(Rendering Pipeline)

  • Layout (重排/回流): 当你改变 widthheightmargin 时,浏览器会惊恐地发现:“天呐,元素的大小变了!它会不会挤到旁边的字?我是不是要重新计算整个文档的布局?”这个过程非常消耗 CPU。

  • Paint (重绘): 布局变了,颜色可能也要重画。

  • Composite (合成): 最后合成图层。

✅ 优化方案:使用 Transform: Scale

如果我们使用 transform: scale,浏览器会意识到:“哦,你只是想视觉上放大它,不需要改变实际占据的空间。”

此时,浏览器会跳过 Layout 和 Paint,直接进行 Composite。这个过程通常由 GPU(硬件加速) 处理,动画会丝般顺滑。

数学计算: 既然不能直接写 110px,我们需要算出缩放比例:

  • 外圈: 目标 110px / 原始 100px = 1.1 倍
  • 内圈: 目标 48px / 原始 60px = 0.8 倍
/* ✅ 性能优化的写法 */
@keyframes outer-move {
    0%, 100% { transform: translate(-50%, -50%) scale(1); }
    50%      { transform: translate(-50%, -50%) scale(1.1); }
}

注意: 这里的 transform 必须包含 translate(-50%, -50%),否则动画播放时,元素会因为覆盖了原有的 transform 而瞬间跳回非居中状态。

思考三:交互体验 —— 让动画“活”过来

解决了性能,最后一步是体验。 很多工程师写出来的动画像机器人,机械且生硬。通常是因为忽略了两个参数:Timing Function(时间曲线)Stagger(交错感)

  1. 拒绝 Linear: 呼吸是自然的生理过程,有吸气的急促和呼气的平缓。使用 linear(匀速)会显得非常怪异。推荐使用 ease-in-out(慢进慢出)。
  2. 制造时间差: 如果外圈和内圈完全同步(比如都是 2s 一圈),画面会显得单调。 我们可以让外圈慢一点(4s),内圈快一点(5s)。
  • 外圈周期:4s (2s 变大,2s 变小)
  • 内圈周期:5s (2.5s 变小,2.5s 变大)

由于 4 和 5 的最小公倍数是 20,这意味着观众要看 20秒 才能看到一次完全重复的画面,这种错落感会让动画显得更有生命力。

最终代码清单

结合以上三个思考,我们得到了这份既优雅又高效的答卷:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>css动画同心圆</title>
    <style>
        .box {
            width: 200px; height: 300px;
            background-color: #000;
            position: relative; /* 相对定位基准 */
            overflow: hidden; /* 防止未来动画增大倍数“溢出”事故 */
        }

        /* 思考一:健壮的居中 */
        .circle-outer, .circle-inner {
            position: absolute;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            border-radius: 50%;
        }

        .circle-outer {
            width: 100px; height: 100px;
            border: 4px solid #ccc;

            /* 思考三:错开时间节奏 */
            animation: outer-move 4s ease-in-out infinite;
        }

        .circle-inner {
            width: 60px; height: 60px;
            border: 4px solid #fff;

            /* 思考三:错开时间节奏 */
            animation: inner-move 5s ease-in-out infinite;
        }

        .text {
            position: absolute;
            bottom: 40px;
            color: #fff;
            font-size: 20px;
            text-align: center;
            width: 100%;
        }

        /* 思考二:Scale 优化性能 */
        @keyframes outer-move {
            0%,100%  { transform: translate(-50%, -50%) scale(1) }
            50% { transform: translate(-50%, -50%) scale(1.1) } /* 100px -> 110px */
        }

        @keyframes inner-move {
            0%,100% { transform: translate(-50%, -50%) scale(1) } 
            50% { transform: translate(-50%, -50%) scale(0.8) } /* 60px -> 48px */
        }
    </style>
</head>

<body>
    <div class="box">
        <div class="circle-outer "></div>
        <div class="circle-inner "></div>
        <div class="text">Hi</div>
    </div>
</body>

</html>

总结

当我们谈论“CSS 进阶”时,往往不是指记住了多少偏门的属性,而是指在简单的场景下,能否做出最优的技术选择

通过这道题,我们知道了:

  1. translate 居中的原理。
  2. Reflow(重排)Composite(合成) 对性能的影响。
  3. 动画曲线与节奏对用户体验的微调。

希望你在职业生涯中遇到 CSS 动画题,你也能自信地对面试官说:“为了性能,我选择用 Scale。” 感谢大家的支持和鼓励,一起加油!

Bipes项目二次开发/扩展积木功能(八)

2026年1月20日 17:22

Bipes项目二次开发/扩展积木功能(八)

新年第一篇文章,这一篇开发扩展积木功能。先看一段VCR。 广告:需要二开Bipes,Scratch,blockly可以找我。 项目地址:maxuecan.github.io/Bipes/index…

VCR

[video(video-CjWu9kdf-1768899636737)(type-csdn)(url-live.csdn.net/v/embed/510…)]

第一:模式选择

在这里插入图片描述 在三种模式中,暂时对海龟编程加了扩展积木功能,点击选择海龟编程,就可以看到积木列表多了个添加按钮。其它模式下不会显示

第二:积木扩展

在这里插入图片描述

点击扩展按钮,会弹窗一个扩展积木弹窗,接着点击卡片,会显示确认添加按钮,最后点击确认添加,就能动态添加扩展积木。

第三:代码解析

ui/components/extensions-btn.js(扩展积木按钮)

import EventEmitterController from '../utils/event-emitter-controller'
import { resetPostion } from '../utils/utils'

export default class extensionsBtn {
    constructor(props) {
        this.settings = props.settings
        this.resetPostion = resetPostion
        if (document.getElementById('content_blocks')) {
            $('#content_blocks').append(this.render())
            this.initEvent()
        }

        // 根据模式,控制扩展按钮的显示
        setTimeout(() => {
            let { mode } = this.settings
            resetPostion()
            $('#extensions-btn').css('display', mode === 'turtle' ? 'block' : 'none')
        }, 1000);
    }
    // 初始化事件
    initEvent() {
        window.addEventListener('resize', (e) => {
            this.resetPostion()
        })

        $('#extensions-btn').on('click', () => {
            EventEmitterController.emit('open-extensions-dialog')
        })
    }

    render() {
        return `
            <div id="extensions-btn">
                <div class="extensions-add"></div>
            </div>
        `
    }
}
ui/components/extensions-dialog.js(扩展积木弹窗)

import ExtensionsList from '../config/extensions-blocks.js'
import { resetPostion } from '../utils/utils'

export default class extensionsDialog {
    constructor() {
        this._xml = undefined
        this._show = false
        this.list = ExtensionsList
        this.use = []
        this.after_extensions = [] // 记录已经添加过的扩展积木
    }
    // 初始化事件
    initEvent() {
        $('.extensions-modal-close').on('click', this.close.bind(this))
        $('.extensions-modal-confirm').on('click', this.confirm.bind(this))
        $('.extensions-modal-list').on('click', this.select.bind(this))
    }
    // 销毁事件
    removeEvent() {
        $('.extensions-modal-close').off('click', this.close.bind(this))
        $('.extensions-modal-confirm').off('click', this.confirm.bind(this))
        $('.extensions-modal-list').off('click', this.select.bind(this))
    }
    // 显示隐藏弹窗
    show() {
        if (this._show) {
            $('.extensions-dialog').remove()
            this.removeEvent()
        } else {
            $('body').append(this.render())
            this.initEvent()
            this.createList()
        }

        this._show = !this._show
    }
    // 创建扩展列表
    createList() {
        $('.extensions-list').empty()
        for (let i in this.list) {
            let li = $('<li>')
                    .attr('key', this.list[i]['type'])
                    .css({
                        background: `url(${this.list[i]['image']}) center/cover no-repeat`,
                    })
            let box = $('<div>')
                    .addClass('extensions-list-image')
                    .attr('key', this.list[i]['type'])
            let detail = $('<div>')
                .addClass('extensions-list-detail')
                .attr('key', this.list[i]['type'])

            let name = $('<h4>').text(this.list[i]['name']).attr('key', this.list[i]['type'])
            let remark = $('<span>').text(this.list[i]['remark']).attr('key', this.list[i]['type'])
            detail.append(name).append(remark)
            $('.extensions-modal-list').append(li.append(box).append(detail))
        }
    }
    // 选择列表
    select(e) {
        let key = e.target.getAttribute('key')
        if (key !== null) {
            let index = this.use.indexOf(key)
            let type = undefined
            if (index !== -1) {
                this.use.splice(index, 1)
                type = 'delete'
            } else {
                this.use.push(key)
                type = 'add'
            }
            this.highlightList(type, key)
            this.showConfirm()
        }
    }
    // 高亮列表项
    highlightList(action, key) {
        $('.extensions-modal-list li').each(function(index) {
            let c_key = $(this).attr('key')
            if (key === c_key) {
                if (action === 'add') {
                    $(this).addClass('extensions-modal-list-act')
                } else if (action === 'delete') {
                    $(this).removeClass('extensions-modal-list-act')
                }
            }
        })
    }
    // 显示确认按钮
    showConfirm() {
        if (this.use.length > 0) {
            $('.extensions-modal-footer').css('display', 'block')
        } else {
            $('.extensions-modal-footer').css('display', 'none')
        }
    }
    // 关闭
    close() {
        this.show()
    }
    // 确认操作
    confirm() {
        let str = ''
        this.use.forEach(item => {
            let index = this.after_extensions.indexOf(item)
            if (index === -1) {
                this.after_extensions.push(item)
                str += this.getExtendsionsXML(item)
            }
        })

        if (str) {
            if (!this._xml) this._xml = window._xml.cloneNode(true)
            let toolbox = this._xml
            toolbox.children[0].innerHTML += str
            Code.reloadToolbox(toolbox)
        }

        this.show()
        resetPostion()
    }
    /* 获取扩展积木的XML */
    getExtendsionsXML(type) {
        let item = ExtensionsList.filter(itm => itm.type === type)
        return item[0].xml
    }
    // 重置toolbox
    resetToolbox() {
        return new Promise((resolve) => {
            this._xml = window._xml.cloneNode(true)
            Code.reloadToolbox(this._xml)
            this.use = []
            this.after_extensions = []
            setTimeout(resolve(true), 200)
        })
    }

    render() {
        return `
            <div class="extensions-dialog">
                <div class="extensions-modal">
                    <div class="extensions-modal-header">
                        <h4></h4>
                        <ul class="extensions-modal-nav">
                            <li class="extensions-modal-nav-act" key="basic">
                                <span key="basic">扩展积木</span>
                            </li>
                        </ul>
                        <div class="extensions-modal-close"></div>
                    </div>

                    <div class="extensions-modal-content">
                        <ul class="extensions-modal-list"></ul>
                    </div>

                    <div class="extensions-modal-footer">
                        <button class="extensions-modal-confirm">确认添加</button>
                    </div>
                </div>
            </div>
        `
    }
}
ui/config/extensions-blocks.js(扩展积木配置)

let turtle = require('./turtle.png')

module.exports = [
  {
    type: 'turtle',
    name: '海龟函数',
    image: turtle,
    remark: '可以调用海龟编辑器中对应Python函数。',
    xml: `
            <category name="海龟" colour="%{BKY_TURTLE_HUE}">
                <block type="variables_set" id="fg004w+XJ=maCm$V7?3T" x="238" y="138">
                    <field name="VAR" id="dfa$SFe(HK(10)Y+T-bS">海龟</field>
                    <value name="VALUE">
                        <block type="turtle_create" id="Hv^2jr?;yxhA=%oCs1=d"></block>
                    </value>
                </block>
                <block type="turtle_create"></block>
                <block type="turtle_move">
                    <value name="VALUE">
                        <block type="variables_get">
                            <field name="VAR">{turtleVariable}</field>
                        </block>
                    </value>
                    <value name="distance">
                        <shadow type="math_number">
                            <field name="NUM">50</field>
                        </shadow>
                    </value>
                </block>
                <block type="turtle_rotate">
                    <value name="VALUE">
                        <block type="variables_get">
                            <field name="VAR">{turtleVariable}</field>
                        </block>
                    </value>
                    <value name="angle">
                        <shadow type="math_number">
                            <field name="NUM">90</field>
                        </shadow>
                    </value>
                </block>
            <block type="turtle_move_xy">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="x">
                    <shadow type="math_number">
                        <field name="NUM">50</field>
                    </shadow>
                </value>
                <value name="y">
                    <shadow type="math_number">
                        <field name="NUM">50</field>
                    </shadow>
                </value>
            </block>
            <block type="turtle_set_position">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="position">
                    <shadow type="math_number">
                        <field name="NUM">50</field>
                    </shadow>
                </value>
            </block>
            <block type="turtle_draw_circle">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="radius">
                    <shadow type="math_number">
                        <field name="NUM">50</field>
                    </shadow>
                </value>
                <value name="extent">
                    <shadow type="math_number">
                        <field name="NUM">50</field>
                    </shadow>
                </value>
                <value name="steps">
                    <shadow type="math_number">
                        <field name="NUM">50</field>
                    </shadow>
                </value>
            </block>
            <block type="turtle_draw_polygon">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="num_sides">
                    <shadow type="math_number">
                        <field name="NUM">5</field>
                    </shadow>
                </value>
                <value name="radius">
                    <shadow type="math_number">
                        <field name="NUM">30</field>
                    </shadow>
                </value>
            </block>
            <block type="turtle_draw_point">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="diameter">
                    <shadow type="math_number">
                        <field name="NUM">50</field>
                    </shadow>
                </value>
            </block>
            <block type="turtle_write">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="text">
                    <shadow type="text">
                        <field name="TEXT">Hello</field>
                    </shadow>
                </value>
            </block>
            <block type="turtle_set_heading">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="angle">
                    <shadow type="math_number">
                        <field name="NUM">90</field>
                    </shadow>
                </value>
            </block>
            <block type="turtle_pendown">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
            </block>
            <block type="turtle_set_pensize">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="size">
                    <shadow type="math_number">
                        <field name="NUM">5</field>
                    </shadow>
                </value>
            </block>
            <block type="turtle_set_speed">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="speed">
                    <shadow type="math_number">
                        <field name="NUM">5</field>
                    </shadow>
                </value>
            </block>
            <block type="turtle_get_position">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
            </block>
            <block type="turtle_show_hide">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
            </block>
            <block type="turtle_clear">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
            </block>
            <block type="turtle_stop">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
            </block>
            <block type="turtle_set_bgcolor">
                <value name="COLOUR">
                    <block type="colour_picker"></block>
                </value>
            </block>
            <block type="turtle_set_pencolor">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="COLOUR">
                    <block type="colour_picker"></block>
                </value>
            </block>
            <block type="turtle_set_fillcolor">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="COLOUR">
                    <block type="colour_picker"></block>
                </value>
            </block>

            <block type="turtle_set_colormode">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="COLOUR">
                    <shadow type="math_number">
                        <field name="NUM">255</field>
                    </shadow>
                </value>
            </block>
            <block type="turtle_set_fill">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
            </block>
            <block type="turtle_set_color">
                <value name="VALUE">
                    <block type="variables_get">
                        <field name="VAR">{turtleVariable}</field>
                    </block>
                </value>
                <value name="COLOUR">
                    <block type="colour_picker"></block>
                </value>
            </block>
        </category>
        `,
  },
]

总结

扩展积木功能改动挺多的,功能也时不断的完善,讲解可能比较粗糙,也在尽量写注解,有需要可以看下提交日志,信息会比较全。

详解TypedArray的内存机制——从backing store到 Native Heap 与 JS Heap

2026年1月20日 17:12

前言:TypedArray与普通数组的比较

在js中创建一个200万元素的二维数组,

  const array = [];

  const step = 0.01;
  let x = 0;
  while (x < 20000) {
    const y = Math.sin(x);
    array.push([x, y]);
    x = Number((x + step).toFixed(2));
  }

该数组对象占用内存如下 image.png

但如果改为Float64Array:

 const array = new Float64Array(4000000);
  const step = 0.01;
  let x = 0;
  let i = 0;
  while (x < 20000) {
    const y = Math.sin(x);
    array[i] = x;
    array[i + 1] = y;
    x = Number((x + step).toFixed(2));
    i += 2;
  }

占用内存如下: image.png

Float64Array内存只有数组的30%。其中最明显的差距是浅层大小,也就是该对象本身占用的内存。该数组对象浅层大小11738kB是因为数组容器本身具有开销,而Float64Array对象的0.1kB则只存储了该对象本身的元信息,其真正内容是存储于backing_store——这就是我们今天要介绍的主角。


一、什么是 Backing Store?

Backing Store(底层存储区) 是指:

在 V8 中,用于支撑(back)某些 JavaScript 对象(如 ArrayBufferTypedArraySharedArrayBufferWebAssembly.Memory)实际数据的 底层原生内存区域

可以理解为:

JS 层的对象只是“壳”或“视图”,而 backing store 是真正存放数据的地方


二、为什么需要 Backing Store?

JavaScript 是一种 托管语言 —— 内存由 GC 自动管理;
ArrayBufferTypedArray 等对象需要存放大规模、结构化、可直接访问的二进制数据

如果这些数据都放在 JS Heap(GC 管理区),会导致:

  • GC 扫描时间急剧上升;
  • 无法保证内存连续性;
  • 与 C/C++/WebAssembly 交互效率低。

因此,V8 设计了 backing store 机制 ——
通过在 Native Heap(C++ 层) 中分配一块连续的内存块来存放这些数据。


三、结构示意(简化)

┌────────────────────────────────────────────┐
│               JS Heap (GC 管理)           │
│ ┌──────────────────────────────┐          │
│ │  ArrayBuffer Object          │          │
│ │  ├─ byteLength: 1048576      │          │
│ │  ├─ pointer → backing store ─┼──────────┼─►
│ │  └─ internal fields          │          │
│ └──────────────────────────────┘          │
└────────────────────────────────────────────┘
                       │
                       ▼
┌────────────────────────────────────────────┐
│           Native Heap (C++ 管理)           │
│ ┌──────────────────────────────────────┐   │
│ │  Backing Store (1MB binary buffer)   │   │
│ │  [00 FF 12 8A ...]                  │   │
│ └──────────────────────────────────────┘   │
└────────────────────────────────────────────┘

四、V8 中 Backing Store 的生命周期

1️. 创建阶段

当你在 JS 层执行:

const buf = new ArrayBuffer(1024 * 1024);

V8 会在内部:

  • 调用 C++ 层函数,分配一块 1MB 原生内存;
  • 把该地址封装为一个 BackingStore 对象;
  • JS 对象 ArrayBuffer 只持有指针(引用)到这块内存。

2. 使用阶段

访问 TypedArray 时,例如:

const arr = new Uint8Array(buf);
arr[0] = 255;
  • JS 层通过 arr 的引用,直接映射到 backing store;
  • 写入的数据会直接修改底层的原生内存;
  • 无需拷贝、无 GC 干预;
  • 性能接近原生内存访问。

3️. 销毁阶段

当 JS 层的对象(ArrayBufferTypedArray)不再被引用:

  • GC 会回收它们;
  • GC 通知 C++ 层释放对应的 backing store;
  • 对应的原生内存被 freemunmap 回收。

五、JS Heap 和 Native Heap

V8 内存总体结构

在 V8(Chrome 和 Node.js 的 JavaScript 引擎)中,内存主要可以分为两大块:

  1. JS Heap(JavaScript 堆)

    • 用于存放 JavaScript 层面可见的对象、闭包、字符串、数组等。
    • 由 V8 自己的垃圾回收器(GC)管理。
    • 典型 GC 算法:分代垃圾回收(Generational GC) ,包括新生代(New Space)和老生代(Old Space)。
  2. Native Heap(原生堆)

    • 用于存放 V8 引擎内部的 C++ 对象、编译后的代码、内建结构,以及 JS 对象引用的底层资源(例如 ArrayBuffer 的底层内存)。
    • 由操作系统或 C++ 层通过 malloc / new 分配。
    • 不由 V8 的 GC 直接回收,但 V8 会间接追踪引用关系。

Native Heap 与 JS Heap 的关系

  • JS 对象(如 ArrayBuffer)可能在 JS Heap 中只保存一个 指针或句柄
  • 实际的二进制数据存在 Native Heap
  • GC 负责追踪 JS 层对象的引用,当 JS 对象不可达时,会 触发 C++ 层的释放钩子(如 Finalizer 或 WeakRef)。

例子:

const buffer = new ArrayBuffer(1024 * 1024); // 1MB

此时:

  • JS Heap 中只有一个轻量对象 ArrayBuffer
  • 实际的 1MB 数据分配在 Native Heap
  • buffer 被 GC 回收时,底层的 native 内存也会被释放。

六、ArrayBuffer到底占不占用JS Heap

基于上述结论,ArrayBuffer存储的内容不占JS Heap。但如果拿这个问题去问ai,有些会回答占,有些回答不占。

如果在node中测试如下test.js

function formatMB(bytes) {
  return (bytes / 1024 / 1024).toFixed(2) + " MB";
}

function logMemory(label) {
  const m = process.memoryUsage();
  console.log(`\n[${label}]`);
  console.log("  rss:", formatMB(m.rss));
  console.log("  heapTotal:", formatMB(m.heapTotal));
  console.log("  heapUsed:", formatMB(m.heapUsed));
  console.log("  external:", formatMB(m.external));
  console.log("  arrayBuffers:", formatMB(m.arrayBuffers));
}

async function run() {
  logMemory("Before allocation");

  // 分配 100MB ArrayBuffer
  const buf = new ArrayBuffer(1024 * 1024 * 100);
  logMemory("After allocation");

  // 等待一会儿,确保 GC 没回收
  await new Promise((r) => setTimeout(r, 5000));

  // 释放引用并手动触发 GC(需要 node 启动参数 --expose-gc)
  global.gc();
  // 等待一会儿,确保 GC 没回收
  await new Promise((r) => setTimeout(r, 5000));
  logMemory("After GC");

  console.log(
    "\n external 增加约 100MB,而 heapUsed 基本不变,说明 backing store 在 Native Heap。",
  );
}

run();


执行node --expose-gc .\test.js输出如下:

image.png 可以得到结论:ArrayBuffer使用Native Heap。

但如果在浏览器中执行,可能会得到相反的结论:

  const before = window.performance.memory.usedJSHeapSize;
  const buffer = new ArrayBuffer(1024 * 1024 * 100); // 100MB
  setTimeout(() => {
    const after = window.performance.memory.usedJSHeapSize;
    console.log(
      "backing_store 占用:",
      (after - before) / 1024 / 1024,
      "MB",
    ); // 约 100MB
  }, 5000);

这个现象gpt给的解释我觉得挺有道理,但没找到出处,如果有人找到了可以踢我下。

Chrome 团队在 2021 年左右做过一次调整:

在 DevTools 与 performance.memory 中,usedJSHeapSize 将反映 “对开发者而言可达的内存使用总量”。

具体参见 Chromium bug 1203451 讨论:

“Expose externalized ArrayBuffer memory in JSHeapSize metrics to match DevTools heap snapshot.”

也就是说:

  • 从内存管理角度:backing store 属于 Native Heap;
  • 从统计视角(performance.memory) :它被加进了 usedJSHeapSize,为了让前端开发者能直观看到分配代价。

runtime chunk 到底是什么?

作者 Soler
2026年1月20日 17:12

runtime chunk(运行时代码块)是 Webpack 生成的一小段核心代码,它不包含你的业务逻辑,而是负责:

  • 管理模块之间的依赖关系(比如哪个模块对应哪个文件);
  • 加载和执行打包后的模块(比如异步加载 chunk);
  • 维护模块的缓存和版本映射。

实操案例:从零看 runtime chunk 的生成

1. 准备极简项目结构

plaintext

├── src
│   ├── index.js       # 主入口
│   └── utils.js       # 工具模块
├── package.json
└── webpack.config.js

2. 编写业务代码

javascript

运行

// src/utils.js
export const add = (a, b) => a + b;

javascript

运行

// src/index.js
// 同步引入 + 异步引入,触发Webpack的模块管理逻辑
import { add } from './utils.js';
console.log('同步调用:', add(1, 2));

// 异步引入(关键:会让runtime逻辑更明显)
setTimeout(() => {
  import('./async-module.js').then(({ sayHello }) => {
    sayHello();
  });
}, 1000);

javascript

运行

// src/async-module.js
export const sayHello = () => console.log('异步模块加载成功!');

3. Webpack 配置(默认开启 runtime chunk)

javascript

运行

// webpack.config.js
const path = require('path');

module.exports = {
  mode: 'development',
  entry: './src/index.js',
  output: {
    filename: '[name].bundle.js', // 主bundle
    chunkFilename: '[name].chunk.js', // 异步chunk
    path: path.resolve(__dirname, 'dist'),
  },
  optimization: {
    // 默认值为'single':将runtime提取为单独的chunk
    runtimeChunk: 'single', 
    splitChunks: {
      chunks: 'all', // 分割同步/异步chunk
    },
  },
};

4. 执行打包 & 查看输出

运行 npx webpack 后,dist 目录会生成 3 个文件:

plaintext

dist/
├── runtime.bundle.js   # runtime chunk(核心!)
├── main.bundle.js      # 主业务chunk(index + utils)
└── async-module.chunk.js # 异步chunk

5. 核心:runtime.bundle.js 里有什么?

打开 runtime.bundle.js,核心内容(简化后)如下:

javascript

运行

// runtime的核心逻辑:模块映射 + 加载器
(() => {
  // 1. 模块ID和文件路径的映射表(关键)
  const moduleMap = {
    "./src/async-module.js": () => import("./async-module.chunk.js"),
  };

  // 2. 模块加载器:处理异步import的核心逻辑
  window.__webpack_require__.e = (chunkId) => {
    // 加载对应的chunk文件(比如async-module.chunk.js)
    // 处理模块缓存、依赖解析
  };

  // 3. 模块缓存管理:避免重复加载
  const installedModules = {};
  window.__webpack_require__.c = installedModules;
})();

对比:禁用 runtime chunk 的效果

修改 Webpack 配置,将 runtimeChunk: false,重新打包后:

  • dist 目录只有 2 个文件:main.bundle.jsasync-module.chunk.js
  • main.bundle.js 里会包含原本 runtime.bundle.js 的所有逻辑(模块映射、加载器等);
  • 此时 main.bundle.js = 业务代码 + runtime 代码(即多个 chunk 合并到一个 bundle,对应上一轮你问的场景)。

为什么要单独提取 runtime chunk?

这是最关键的实战价值,用一个场景说明:假设你只修改了 utils.js 里的 add 函数(比如改成 a + b + 1),重新打包后:

  • main.bundle.js 的内容变了 → hash 值会变;
  • async-module.chunk.js 没改 → hash 值不变;
  • runtime.bundle.js 里的模块映射表没改 → hash 值不变;

用户浏览器缓存中:

  • 只会重新加载 main.bundle.jsruntime.bundle.jsasync-module.chunk.js 会复用缓存;

如果不提取 runtime chunk,main.bundle.js 包含 runtime 逻辑,哪怕只改一行业务代码,整个 main.bundle.js 的 hash 都变,用户需要重新加载全部内容 → 缓存失效,性能变差。


总结

  1. runtime chunk 本质:Webpack 的 “模块调度器”,包含模块映射、加载逻辑、缓存管理,无业务代码;
  2. 核心作用:管理打包后模块的加载和依赖,单独提取可提升缓存复用率;
  3. 表现形式:默认会生成单独的 runtime.bundle.js,禁用则合并到主 bundle 中(多 chunk→单 bundle)。

告别笨重的 Prometheus,这款 5 分钟部署的 Nginx 监控工具凭什么刷屏 GitHub?

2026年1月20日 16:49

前言

作为后端开发者,Nginx 几乎是我们每天都要打交道的“基础设施”。但说实话,Nginx 的运维体验一直很割裂:

  • 原生监控太简陋:stub_status 只能看个连接数,想看接口响应耗时?想看 502 错误分布?对不起,请去翻几 GB 的 Access Log。
  • 传统方案太重:为了监控几台机器,要搭一套 Prometheus + Grafana + Exporter “全家桶”。对于中小团队或个人项目来说,运维这套监控系统的时间,甚至比写业务代码还长。

最近,我在 GitHub 发现了一个名为 nginxpulse 的开源项目。它上线仅一周 star 就突破了 1k,彻底解决了“轻量级 Nginx 监控”这个老大难问题。今天和大家聊聊,这款“黑马”工具到底香在哪?


一、 痛点直击:为什么我们讨厌传统的 Nginx 监控?

在深入 nginxpulse 之前,先看看我们平时的痛点:

  1. 部署成本极高:传统方案涉及多个组件的联动配置,学习成本和资源成本双高。
  2. 监控与业务脱节:改完 Nginx 配置,不知道对性能有没有影响;报了 404/502,非得等用户反馈了才去查日志。
  3. 非侵入性差:很多工具需要编译特定的 Nginx 模块,这在生产环境简直是灾难。

nginxpulse 的核心逻辑很简单:用最轻量的方式,给 Nginx 装上“上帝视角”。


二、 架构设计:极简,但不简单

nginxpulse 并没有走“大而全”的路子,它采用了 Agent + Web UI + 数据存储 的极简架构:

  • nginxpulse-agent:基于 Go 编写的轻量级采集端,CPU 占用极低(<5%)。最惊艳的是,它无需重启 Nginx,通过 include 一行配置即可实现无侵入采集。
  • 可视化控制台:基于 Vue3 + Element Plus 开发,界面清爽,支持 1 秒粒度的实时刷新。
  • 灵活存储:小规模用本地文件,大规模支持 Redis 接入。

三、 杀手锏功能:不止是“能看”,更是“好用”

1. 深度异常分析(不再盲目翻日志)

以往查错需要 tail -f 盯着屏幕看,NP 内置了强大的分析指令。你只需运行一行命令,它就能告诉你谁是“罪魁祸首”:

codeBash

# 自动分析 Nginx 日志并生成 TOP 20 错误报告
nginxpulse analyze error --nginx-log /var/log/nginx/access.log --top 20

它会直接输出一份直观的报告,包含 4xx/5xx 分布、高频异常 URL、甚至后端 Upstream 的故障节点。

2. YAML 驱动的自动化告警

NP 彻底解决了“发现晚”的问题。你可以通过 YAML 灵活配置告警规则,支持钉钉、企业微信、邮件等主流渠道:

codeYaml

# 告警规则示例:响应时间过长即刻推送
alert_rules:
  - name: "API_Response_Slow"
    type: "response"
    condition: "p99_response_time > 800ms"
    duration: 60s  # 持续1分钟触发
    severity: "warning"
    targets:
      - type: "dingtalk"
        url: "https://oapi.dingtalk.com/robot/send?access_token=your_token"

3. 运维辅助:配置校验与安全重载

改完配置手抖?NP 提供了配置语法一键校验,避免因为一个分号导致整台服务器崩溃。

codeBash

# 安全校验配置
nginxpulse config check --conf /etc/nginx/nginx.conf

四、 实战体验:5 分钟完成部署

NP 的上手门槛极低,这也是它能快速传播的原因。我最推荐使用 Docker 部署,真正做到开箱即用:

codeBash

# 1. 拉取镜像
docker pull likaia/nginxpulse:latest

# 2. 启动全功能容器(Agent + UI)
docker run -d \
  --name nginxpulse \
  -p 9090:9090 \  # Agent 端口
  -p 8080:8080 \  # 控制台端口
  -v /var/log/nginx:/var/log/nginx \
  -v /etc/nginx:/etc/nginx \
  likaia/nginxpulse:latest

启动后,访问 http://localhost:8080,你会发现整个 Nginx 的运行状态、流量趋势、错误分布已经整整齐齐地摆在面前了。


五、 深度思考:好的开源项目长什么样?

nginxpulse 的走红再次印证了一个道理:开源项目的价值在于解决真实世界的“小痛点”。

它没有追求花哨的技术栈,而是聚焦在:

  • 低损耗: agent 占用内存不到 20MB。
  • 零门槛:运维新手也能看懂图表。
  • 场景化:针对 404 扫描、502 穿透等真实运维场景做了深度适配。

六、 结语

如果你正在被 Nginx 监控难、配置乱、排查慢的问题困扰,或者不想折腾笨重的 Prometheus 体系,nginxpulse 绝对是一个值得尝试的替代方案。

目前该项目还在快速迭代中,不仅完全开源(MIT 协议),社区响应也极快。

你平时是如何监控 Nginx 的?欢迎在评论区分享你的避坑指南!


本文纯技术分享,欢迎点赞、收藏。如果觉得有帮助,也欢迎去给开源作者点个 Star 鼓励一下。

Nuxt 3 vs Next.js:新手选型指南与项目实战对比

2026年1月20日 16:44

在现代Web开发中,两大全栈框架Nuxt 3和Next.js占据着服务端渲染(SSR)领域的主导地位。它们都提供了文件系统路由、自动代码分割、SEO优化等现代Web应用所需的核心功能,但技术选型背后的技术栈差异设计哲学却大不相同。

本文将通过对比分析,帮助前端新手理解这两大框架的区别,并提供实际的项目创建示例。


01 核心差异:Vue与React的技术栈选择

Nuxt 3与Next.js最根本的区别在于其底层技术栈

  • Nuxt 3:基于Vue 3生态系统,采用组合式API和响应式系统
  • Next.js:基于React生态系统,支持最新的React特性

这种核心差异决定了你的开发体验、学习曲线以及可用的第三方库生态。

学习曲线对比

对于完全没有前端经验的新手来说,Vue通常被认为比React学习曲线更平缓。Vue的模板语法更接近传统HTML,而React的JSX则需要适应将HTML与JavaScript混合编写的模式。

框架特性 Nuxt 3 Next.js
基础框架 Vue 3 React
路由系统 文件系统路由(pages/目录) 文件系统路由(app/目录)
数据获取 useAsyncData, useFetch 服务端组件、fetch API
状态管理 Pinia (推荐) Zustand, Redux等
样式方案 多种选择(CSS模块、Tailwind等) 多种选择(CSS模块、Tailwind等)
部署平台 Vercel、Netlify、Node服务器等 Vercel(官方)、Netlify等

生态圈对比

Next.js拥有更庞大的社区和更丰富的第三方库,这得益于React本身的普及度。Nuxt 3虽然社区规模较小,但其官方模块质量很高,且与Vue生态无缝集成。


02 快速入门:创建你的第一个应用

Nuxt 3入门示例

项目初始化

# 创建Nuxt 3项目
npx nuxi@latest init my-nuxt-app
cd my-nuxt-app
npm install
npm run dev

创建页面和组件

  1. pages/index.vue中创建主页:
<template>
  <div class="container">
    <h1>欢迎使用Nuxt 3</h1>
    <p>当前时间:{{ currentTime }}</p>
    <button @click="refreshTime">刷新时间</button>
  </div>
</template>

<script setup>
// 使用组合式API
const currentTime = ref('')

// 获取服务器时间
onMounted(async () => {
  const { data } = await useFetch('/api/time')
  currentTime.value = data.value
})

// 客户端交互
const refreshTime = () => {
  currentTime.value = new Date().toLocaleString()
}
</script>
  1. 创建API端点server/api/time.get.ts
export default defineEventHandler(() => {
  return new Date().toISOString()
})

Next.js入门示例

项目初始化

# 创建Next.js项目(使用App Router)
npx create-next-app@latest my-next-app
cd my-next-app
npm install
npm run dev

创建页面和组件

  1. app/page.tsx中创建主页:
export default function HomePage() {
  return (
    <div className="container">
      <h1>欢迎使用Next.js</h1>
      <TimeDisplay />
    </div>
  )
}

// 服务端组件:自动在服务器上运行
async function TimeDisplay() {
  // 在服务端获取数据
  const response = await fetch('http://worldtimeapi.org/api/timezone/Asia/Shanghai')
  const data = await response.json()
  
  return (
    <div>
      <p>当前时间:{data.datetime}</p>
      <ClientComponent />
    </div>
  )
}

// 客户端组件:需要"use client"指令
'use client'
function ClientComponent() {
  const [count, setCount] = useState(0)
  
  return (
    <button onClick={() => setCount(count + 1)}>
      点击次数:{count}
    </button>
  )
}

03 特性深度对比:数据获取与渲染策略

数据获取方式对比

Nuxt 3的数据获取

<template>
  <div>
    <h2>文章列表</h2>
    <div v-if="pending">加载中...</div>
    <ul v-else>
      <li v-for="post in posts" :key="post.id">
        {{ post.title }}
      </li>
    </ul>
  </div>
</template>

<script setup>
// useAsyncData用于服务端获取数据
const { data: posts, pending } = await useAsyncData(
  'posts',
  () => $fetch('https://api.example.com/posts')
)

// useFetch是useAsyncData的简写
const { data: user } = await useFetch('/api/user')
</script>

Next.js的数据获取

// 在App Router中,页面组件默认为服务端组件
export default async function PostsPage() {
  // 直接使用fetch API,Next.js会自动优化
  const response = await fetch('https://api.example.com/posts', {
    next: { revalidate: 60 } // 每60秒重新验证
  })
  const posts = await response.json()
  
  return (
    <div>
      <h2>文章列表</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <LikeButton postId={posts[0].id} />
    </div>
  )
}

// 客户端交互组件需要"use client"指令
'use client'
function LikeButton({ postId }) {
  const [likes, setLikes] = useState(0)
  
  return (
    <button onClick={() => setLikes(likes + 1)}>
      点赞 ({likes})
    </button>
  )
}

渲染策略对比

两个框架都支持多种渲染策略,但实现方式不同:

渲染模式 Nuxt 3实现 Next.js实现
静态生成(SSG) nuxt generate output: 'static'
服务端渲染(SSR) 默认启用 默认启用(服务端组件)
客户端渲染(CSR) <ClientOnly>组件 "use client"指令
增量静态再生(ISR) 通过模块实现 原生支持(fetch选项)

04 实际应用场景分析

何时选择Nuxt 3?

  1. Vue技术栈项目:团队已熟悉Vue生态
  2. 快速原型开发:需要快速搭建MVP产品
  3. 内容型网站:博客、文档、营销页面
  4. 项目结构清晰:喜欢"约定优于配置"的理念

Nuxt 3优势场景示例

<!-- 快速创建SEO友好的内容页面 -->
<template>
  <div>
    <Head>
      <Title>产品介绍 - 我的网站</Title>
      <Meta name="description" :content="product.description" />
    </Head>
    
    <article>
      <h1>{{ product.title }}</h1>
      <!-- 内容自动渲染 -->
      <ContentRenderer :value="product" />
    </article>
  </div>
</template>

<script setup>
// 自动根据文件路径获取内容
const { data: product } = await useAsyncData('product', () => 
  queryContent('/products').findOne()
)
</script>

何时选择Next.js?

  1. React技术栈项目:团队已熟悉React生态
  2. 大型复杂应用:需要React丰富生态支持
  3. 需要最新特性:希望使用React最新功能
  4. Vercel平台部署:计划使用Vercel的完整能力

Next.js优势场景示例

// 复杂的动态仪表板应用
export default async function DashboardPage() {
  // 并行获取多个数据源
  const [sales, users, analytics] = await Promise.all([
    fetchSalesData(),
    fetchUserData(),
    fetchAnalyticsData(),
  ])
  
  return (
    <div className="dashboard">
      <SalesChart data={sales} />
      <UserTable users={users} />
      <AnalyticsOverview data={analytics} />
      {/* 实时更新的客户端组件 */}
      <LiveNotifications />
    </div>
  )
}

// 使用React Server Components实现部分渲染
'use client'
function LiveNotifications() {
  const [notifications, setNotifications] = useState([])
  
  useEffect(() => {
    // 建立WebSocket连接获取实时数据
    const ws = new WebSocket('wss://api.example.com/notifications')
    // ... 处理实时数据
  }, [])
  
  return <NotificationList items={notifications} />
}

05 开发体验与工具链对比

Nuxt 3的开发体验

  1. 零配置起步:大多数功能开箱即用
  2. 模块系统:官方和社区模块质量高
  3. TypeScript支持:一流的TypeScript体验
  4. 开发工具:Nuxt DevTools提供强大调试能力
# Nuxt 3的典型工作流
npx nuxi@latest init my-project  # 创建项目
npm install                       # 安装依赖
npm run dev                       # 开发模式
npm run build                     # 生产构建
npm run preview                   # 预览生产版本

Next.js的开发体验

  1. 灵活的配置:可根据需要深度定制
  2. TurboPack:极快的构建和刷新速度
  3. 完善的文档:官方文档质量极高
  4. Vercel集成:无缝部署和预览体验
# Next.js的典型工作流
npx create-next-app@latest my-app  # 创建项目
npm install                        # 安装依赖
npm run dev                        # 开发模式
npm run build                      # 生产构建
npm run start                      # 启动生产服务器

06 性能与优化对比

性能特征

  1. 首次加载性能:两者都优秀,Nuxt 3在小型项目上可能略快
  2. 开发服务器速度:Next.js的Turbopack在大型项目上优势明显
  3. 构建速度:取决于项目大小,两者都提供增量构建

优化技巧对比

Nuxt 3优化示例

<!-- 组件懒加载和图片优化 -->
<template>
  <div>
    <!-- 延迟加载重型组件 -->
    <LazyMyHeavyComponent v-if="showComponent" />
    
    <!-- 自动优化的图片 -->
    <NuxtImg
      src="/images/hero.jpg"
      width="1200"
      height="600"
      loading="lazy"
      format="webp"
    />
  </div>
</template>

Next.js优化示例

// 使用Next.js内置优化功能
import Image from 'next/image'
import dynamic from 'next/dynamic'

// 动态导入重型组件
const HeavyComponent = dynamic(() => import('./HeavyComponent'))

export default function OptimizedPage() {
  return (
    <>
      {/* 自动优化的图片组件 */}
      <Image
        src="/hero.jpg"
        alt="Hero image"
        width={1200}
        height={600}
        priority={false} // 非关键图片延迟加载
      />
      
      {/* 条件加载重型组件 */}
      <HeavyComponent />
    </>
  )
}

07 新手选择建议

根据背景选择

  1. 完全零基础

    • 如果喜欢更直观的模板语法 → 选择Nuxt 3
    • 如果看重就业市场需求 → 选择Next.js
  2. 有前端基础

    • 熟悉HTML/CSS/JS → 都可尝试,根据偏好选择
    • 有React经验 → 选择Next.js
    • 有Vue经验 → 选择Nuxt 3

根据项目类型选择

项目类型 推荐框架 理由
个人博客/作品集 Nuxt 3 快速搭建,SEO优秀
企业官网/营销页 Nuxt 3 开发效率高,维护简单
SaaS/管理后台 Next.js React生态丰富,组件库多
电商平台 Next.js 性能优化完善,生态成熟
实时应用 均可 根据团队技术栈选择

无论选择哪个框架,最重要的是开始构建。真正的经验来自于项目实践,而不是框架比较。

🗳️ 互动时间:你的选择是?

读完全文,相信你对 Nuxt 3 和 Next.js 有了更清晰的认识。技术选型没有标准答案,真实项目中的经验才是最宝贵的参考。

欢迎在评论区分享你的观点:

  1. 投票选择:你目前更倾向于或正在使用哪个框架?

    • A. Nuxt 3 (Vue阵营)
    • B. Next.js (React阵营)
    • C. 两个都在用/观望中
  2. 经验分享:在实际项目中,你使用 Nuxt 3 或 Next.js 时,遇到的最大挑战或最惊喜的体验是什么? 你的分享对其他开发者会非常有帮助!


关注我的公众号" 大前端历险记",掌握更多前端开发干货姿势!

学术界最大的室内运动捕捉设施为世界领先的无人机研究提供支持

作者 爱迪斯通
2026年1月20日 16:42

亚利桑那州立大学跟踪体积为 230,000 立方英尺的无人机工作室是世界上学术机构中最大的室内无人机研究动捕设施。该设施前身是一个篮球馆,经过五年多的建造,由亚利桑那州立大学机器人研究员和副教授Panagiotis Artemiadis博士设计,为跨学科研究的合作空间提供支持。

ScreenShot_2026-01-20_153410_194.png

该无人机研究设施的跟踪系统拥有 23 英尺高的天花板和 104 个广角 OptiTrack Prime 17W 摄像机,每秒能够同时捕获多达 250 个机器人的 360 次测量数据,每架无人机的运动跟踪精度在 0.5 毫米以内,使该空间非常适合研究无人机群的快速动态和适用于大型平台的机器人等应用。

Artemiadis博士在 2016 年凭借其“大脑活动驱动的无人机控制系统”的创新成果引起了轰动,亚利桑那州立大学人机控制 (HORC) 实验室的研究人员,与亚利桑那州立大学计算、信息学和决策系统工程学院的助理教授Stephanie Gil一起主导了无人机工作室的落成。

除了支持空中机器人系统的研究和开发外,无人机工作室还支持地面机器人,使研究人员能够检查和协调具有不同运动方式的机器人之间的相互作用。此类研究的现实意义可以帮助新兴技术的发展,例如无人驾驶搜索和救援车辆、自动驾驶车辆和无人机送货。

ScreenShot_2026-01-20_153437_707.png

Artemiadis博士在亚利桑那州立大学富尔顿工程学院杂志 Convergence 上介绍无人机工作室时表示:“这个先进的仪器空间使多学科的教师团队能够解决以前无法解决的研究问题,例如代理数百个机器人的协调和控制以及与人类群体的互动等。”

Gil指出,“我希望无人机工作室能够激励研究人员尝试新的实验类别,利用大空间工作与实时跟踪功能的结合,揭示对如何最好地协调、控制和建模大型机器人系统的新理解。”

Element Plus SCSS 变量覆盖用法

作者 码农张3
2026年1月20日 16:13

安装依赖

pnpm i sass -D

样式文件

element-plus-vars.scss

// 覆盖变量
@forward "element-plus/theme-chalk/src/common/var.scss" with (
  $colors: (
    "primary": (
      "base": #4080ff,
    ),
    "success": (
      "base": #23c343,
    ),
    "warning": (
      "base": #ff9a2e,
    ),
    "danger": (
      "base": #f76560,
    ),
    "info": (
      "base": #a9aeb8,
    ),
  ),

  $bg-color: (
    "page": #f5f8fd,
  )
);
// 引入 Element Plus 样式(必须在覆盖变量后)
@use "element-plus/theme-chalk/src/index.scss";

element-plus.scss

/**
 * element-plus 组件样式覆盖
 */
// 变量覆盖(必须在最前面)
@use "./element-plus-vars";
// 引入 Element Plus 样式(必须在覆盖变量后)
@use "element-plus/theme-chalk/src/index.scss";
...

导入样式

index.scss

// 重置样式
@use "./reset";

// element-plus
@use "./element-plus";
...

main.js

...
// ===== 样式导入 =====
import "@/assets/styles/index.scss";
...

vite.config.js

...
// 自动导入
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
...
plugins: [
    vue(),
    // 自动导入 Element Plus 组件和函数,无需手动 import
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    // 自动注册 Element Plus 组件,可在模板中直接使用,采用sass样式配色
    Components({
      resolvers: [ElementPlusResolver({ importStyle: "sass" })],
    }),
  ],  
...

大白话解释Vue响应式三要素

作者 cj8140
2026年1月20日 16:00

好的!咱们用最接地气的大白话聊聊 Vue 响应式系统的三个核心角色:Observer(观察者)Watcher(监听者)Dep(依赖收集器)。想象一下它们仨是怎么配合工作的:


🎯 场景比喻:班级通知系统

想象一个班级:

  • 数据 (data):班级里某个同学(比如小明)的作业本状态(是否写完)。
  • 模板 (template):班级公告栏(显示“小明作业:未完成”)。
  • 视图 (view):你眼睛看到的公告栏内容。

👤 1. Observer (观察者) - “数据管家”

  • 职责:把普通数据变成“聪明”数据,让数据自己知道谁在关心它。
  • 工作方式
    • 当你创建 Vue 实例时,Observer 会像管家一样,拿着小本本(Dep),去遍历你写在 data 里的所有属性(比如 homeworkStatus: '未完成')。
    • 它会给每个属性(homeworkStatus装上一个“监听器”Object.definePropertygetter/setter)。
    • getter (获取值时):属性被访问时(比如公告栏要显示状态),Observer 会悄悄在本子上记一笔:“谁(谁)在关心我(homeworkStatus)?”(这就是依赖收集)。
    • setter (设置值时):属性被修改时(比如你把 homeworkStatus 改成 '已完成'),Observer 会立刻大喊一声:“喂!我(homeworkStatus)变了!快通知所有关心我的人!”(这就是派发更新)。
  • 大白话总结Observer 就是个数据管家,给数据装上“耳朵”(getter)和“喇叭”(setter),让数据能“被听到”和“能广播”。

👂 2. Watcher (监听者) - “关心者”

  • 职责:谁关心数据?就是它!它负责在数据变化时执行动作(比如更新视图)。
  • 工作方式
    • 有两种主要类型的 Watcher
      • 渲染 Watcher (Render Watcher):最核心的“关心者”!它负责渲染整个组件的模板(也就是更新公告栏)。它一启动,就会去访问组件里用到的所有数据(比如 homeworkStatus),触发它们的 getter,从而在本子(Dep)上登记:“我是渲染 Watcher,我关心 homeworkStatus!”。
      • 用户 Watcher (User Watcher):你在代码里写的 watch 选项或者 this.$watch() 创建的监听器。比如你专门写代码监听 homeworkStatus 变化时弹个窗。它启动时也会去访问 homeworkStatus,触发 getter,登记:“我是用户 Watcher,我也关心 homeworkStatus!”。
    • 当数据变化时(setter 广播),Watcher 收到通知,就执行它该做的事:
      • 渲染 Watcher:重新执行渲染函数,更新公告栏内容。
      • 用户 Watcher:执行你写的回调函数(比如弹窗)。
  • 大白话总结Watcher 就是数据的粉丝!它主动去“追星”(访问数据),在数据“本子”(Dep)上留下联系方式。数据一“发新动态”(变化),粉丝(Watcher)立刻收到通知,按约定做动作(更新视图/执行回调)。

📒 3. Dep (Dependency - 依赖收集器) - “通讯录本子”

  • 职责:记录数据(属性)和关心它的 Watcher 之间的“依赖关系”。它是 ObserverWatcher 之间的桥梁
  • 工作方式
    • 每个被 Observer 装上“监听器”的数据属性(比如 homeworkStatus),都会拥有一个专属的 Dep 实例。这个 Dep 就像一个小本本。
    • Watcher(粉丝)去访问这个数据(触发 getter)时:
      • Observer 会把当前正在“关心”的 Watcher(比如渲染 Watcher)添加到这个数据属性专属的 Dep 本子里。登记:“粉丝 Watcher A 关心我!”。
    • 当数据变化(触发 setter)时:
      • Observer 会找到这个数据属性专属的 Dep 本子。
      • 它会遍历本子上记录的所有 Watcher(粉丝),挨个给他们打电话(调用 Watcherupdate 方法):“喂!数据变了!快更新!”。
  • 大白话总结Dep 就是数据的“粉丝通讯录”!它记录了“谁(Watcher)关心我(数据)”。数据变化时,它就翻通讯录,挨个通知粉丝(Watcher)。

🔄 三者协作流程(大白话版)

  1. 初始化 (装喇叭 & 翻通讯录)

    • Observer 管家:给 data 里的每个属性(如 homeworkStatus)装上“喇叭”(setter)和“耳朵”(getter)。每个属性都带一个空“通讯录”(Dep)。
    • 渲染 Watcher(公告栏):启动!为了显示内容,它必须访问 homeworkStatus(触发 getter)。
    • Observer 听到访问:立刻在 homeworkStatus 的“通讯录”(Dep)上记一笔:“公告栏 Watcher 关心你!”。
  2. 数据变化 (广播通知)

    • 你修改数据:this.homeworkStatus = '已完成'
    • Observer 管家:检测到 homeworkStatussetter 被调用!立刻大喊:“homeworkStatus 变了!”
    • Observer 管家:找到 homeworkStatus 的“通讯录”(Dep),翻开来看:“哦,只有公告栏 Watcher 关心你。”
    • Observer 管家:拿起电话,拨打“公告栏 Watcher”的号码:“喂!homeworkStatus 变成 '已完成' 了!快更新公告栏!”
  3. 更新视图 (粉丝行动)

    • 公告栏 Watcher(粉丝)接到电话:立刻行动!重新执行渲染函数。
    • 渲染函数访问 homeworkStatus(再次触发 getter,但这次主要是为了获取新值)。
    • 公告栏显示更新为:“小明作业:已完成”。

🎉 总结一句话

Observer 给数据装上“耳朵”和“喇叭”;Watcher 去听数据(触发耳朵),登记在数据的“通讯录”(Dep)上;数据变化时(喇叭响),Dep 翻通讯录通知所有 Watcher 去更新视图!

这样理解是不是清晰多了?它们仨就像一个高效的班级通知系统,确保数据一变,关心它的地方(视图、用户监听器)能立刻知道并做出反应!

执行了 git commit 然后 git pull,现在想要回退到 pull 之前的状态

作者 新晨437
2026年1月20日 15:45

如果你已经执行了 git commit 然后 git pull,现在想要回退到 pull 之前的状态,这里有几个解决方案:

解决方案

1. 使用 git reset(最常用)

# 查看提交历史,找到你想要回退到的commit
git log --oneline

# 回退到指定的commit(保留工作区更改)
git reset --soft <commit-hash>

# 或者回退并丢弃更改
git reset --hard <commit-hash>

2. 回退到 pull 之前的状态

# 使用 reflog 查看操作历史
git reflog

# 找到 pull 之前的 HEAD 位置,然后重置
git reset --hard HEAD@{n}  # n 是 reflog 中的编号

3. 如果你想要完全撤销 pull 和 commit

# 查看最近的操作,找到 pull 之前的提交
git log --oneline

# 重置到 pull 之前的提交
git reset --hard HEAD~2  # 回退2个提交(假设你commit了1次,pull了1次)

具体操作步骤建议:

  1. 先查看当前状态
git status
git log --oneline
  1. 安全方案(保留更改)
# 先保存当前工作状态(如果有未提交的更改)
git stash

# 回退到 pull 之前的commit
git reset --hard HEAD~1

# 如果需要,恢复stash的更改
git stash pop
  1. 如果你只是想修改 commit 信息
# 修改最后一次commit
git commit --amend

注意事项:

  • 如果已经 push 到远程仓库,需要谨慎操作,可能需要 git push --force
  • 如果是多人协作的项目,最好先和团队沟通
  • 使用 git reset --hard 会丢失所有未提交的更改,请确保已备份

你具体遇到了什么情况?我可以提供更针对性的建议。

Vue组件变量值更新过程记录

作者 cj8140
2026年1月20日 15:45

从 Vue 2.x 源码角度分析将组件变量 a 从空值修改为 1 的完整调用栈如下:


1. 组件初始化阶段

在组件创建时,Vue 会初始化响应式数据:

// 调用栈:
Vue.prototype._init (init.js)
  └── initState (state.js)
      └── initData (state.js)
          └── observe (observer/index.js)
              └── new Observer (observer/index.js)
                  └── walk (observer/index.js)
                      └── defineReactive (observer/index.js) // 为属性 `a` 创建响应式

关键步骤

  • defineReactivea 创建 getter/setter
    • 初始化 dep 实例(依赖收集器)。
    • 通过 Object.defineProperty 重写 a 的访问器:
      Object.defineProperty(obj, key, {
        get() { /* 依赖收集 */ },
        set(newVal) { /* 触发更新 */ }
      })
      

2. 修改 a 的值

执行 this.a = 1 时触发 setter

// 调用栈:
this.a = 1
  └── a 的 setter (defineReactive 内部)
      └── dep.notify() (observer/dep.js)
          └── subs[i].update() (observer/watcher.js)
              └── queueWatcher (scheduler.js)
                  └── nextTick (scheduler.js)
                      └── flushSchedulerQueue (scheduler.js)
                          └── watcher.run (observer/watcher.js)
                              └── watcher.get (observer/watcher.js)
                                  └── 组件重新渲染 (render 函数)

关键步骤详解

  1. setter 触发

    • 检查新值 1 是否与旧值不同(newVal !== oldVal)。
    • 若不同,调用 dep.notify() 通知所有依赖。
  2. dep.notify()

    • 遍历 dep.subs(存储所有订阅该属性的 Watcher)。
    • 调用每个 watcher.update()
  3. watcher.update()

    • 将 Watcher 加入异步队列(queueWatcher)。
    • 通过 nextTick 异步执行更新。
  4. flushSchedulerQueue

    • 遍历队列中的 Watcher,调用 watcher.run()
    • watcher.run()watcher.get() → 重新执行组件的 render 函数。
  5. 重新渲染

    • render 函数执行时访问 a,触发 getter 重新收集依赖。
    • 生成新的虚拟 DOM,对比差异后更新真实 DOM。

3. 依赖收集机制

在首次渲染和后续更新时,getter 负责收集依赖:

// getter 调用栈:
组件访问 a (render 函数)
  └── a 的 getter (defineReactive 内部)
      └── Dep.target (全局唯一 Watcher)
          └── dep.depend() (observer/dep.js)
              └── 将当前 Watcher 添加到 dep.subs

关键点

  • Dep.target:全局唯一变量,指向当前正在执行的 Watcher(如渲染 Watcher)。
  • dep.depend():将当前 Watcher 加入 dep.subs,建立 属性 → Watcher 的依赖关系。

4. 异步更新队列

Vue 使用异步队列合并更新:

// nextTick 流程:
queueWatcher (scheduler.js)
  └── nextTick (util/next-tick.js)
      └── 异步任务 (Promise/MutationObserver/setTimeout)
          └── flushSchedulerQueue (scheduler.js)

优化逻辑

  • 多次修改 a 会被合并为一次更新(避免重复渲染)。
  • 通过 nextTick 确保在 DOM 更新后执行回调。

Vue 3 Proxy 版本的差异

若使用 Vue 3(基于 Proxy):

  1. 初始化:通过 reactive 创建响应式代理。
  2. 修改值:直接触发 Proxy.set 拦截器,后续流程类似(依赖收集、异步更新)。
  3. 核心差异
    • 无需 Object.defineProperty,支持动态属性。
    • 依赖收集通过 Track 操作,更新通过 Trigger 操作。

总结

阶段 核心操作 关键函数/类
初始化 a 创建响应式 getter/setter defineReactiveDep
修改值 触发 setter → 通知依赖 dep.notify()
依赖更新 异步队列合并更新 queueWatchernextTick
重新渲染 执行 render 函数 Watcher.run()

整个流程体现了 Vue 响应式系统的核心:依赖收集getter)和 派发更新setter),通过 异步队列 优化性能。

rxjs基本语法

作者 米诺zuo
2026年1月20日 15:38

RxJS (Reactive Extensions for JavaScript) 是 Angular 中处理异步编程的核心库。 它通过使用 Observable(可观察对象) 序列来编写异步和基于回调的代码。


一、 核心概念

在 RxJS 中,一切基于数据流。

  • Observable (被观察者): 数据的源头,发出数据。
  • Observer (观察者): 数据的消费者,接收数据。
  • Subscription (订阅): 连接 Observable 和 Observer 的桥梁。注意:必须取消订阅,否则会内存泄漏。
  • Operators (操作符): 纯函数,用来处理、转换数据流(如 map, filter)。
  • Subject (主题): 既是 Observable 又是 Observer,可以多播数据(常用于组件通信)。

二、 基础写法

1. 创建 Observable 和 订阅

import { Observable } from 'rxjs';
// 1. 创建 Observable
const observable$ = new Observable(subscriber => {
  subscriber.next(1); // 发出数据
  subscriber.next(2);
  subscriber.next(3);
  subscriber.complete(); // 结束
  // subscriber.error('出错了'); // 抛出异常
});
// 2. 订阅
const subscription = observable$.subscribe({
  next: (x) => console.log('收到数据:', x),
  error: (err) => console.error('错误:', err),
  complete: () => console.log('流结束')
});
// 3. 取消订阅 (非常重要)
subscription.unsubscribe();

2. 简写订阅 (只关心 next)

observable$.subscribe(data => console.log(data));

三、 常用创建操作符

用于生成数据流。

import { of, from, interval, fromEvent, throwError } from 'rxjs';
// 1. of: 依次发出参数
of(1, 2, 3).subscribe(console.log); // 输出: 1, 2, 3
// 2. from: 将数组/Promise 转为 Observable
from([10, 20, 30]).subscribe(console.log); // 输出: 10, 20, 30
// 3. interval: 周期性发出数字 (每1秒发一个)
interval(1000).subscribe(n => console.log(n)); // 0, 1, 2...
// 4. fromEvent: 监听 DOM 事件
fromEvent(document.querySelector('button')!, 'click')
  .subscribe(() => console.log('按钮被点击'));
// 5. throwError: 创建一个只报错的流
// throwError(() => new Error('哎呀出错了')).subscribe();

四、 常用转换操作符

这是 RxJS 最强大的部分,管道 语法是 Angular 18+ 的标准写法。

import { map, filter, pluck } from 'rxjs/operators';
of(1, 2, 3, 4, 5).pipe(
  // 1. map: 转换数据 (类似数组的 map)
  map(x => x * 10), 
  
  // 2. filter: 过滤数据 (只有 true 才会通过)
  filter(x => x > 20)
).subscribe(console.log); 
// 输出: 30, 40, 50
// 3. pluck: 提取对象属性 (已废弃,推荐用 map)
// 旧写法: source$.pipe(pluck('user', 'name'))
// 新写法:
interface User { name: string; age: number; }
const user$: Observable<User> = of({ name: 'Tom', age: 18 });
user$.pipe(map(user => user.name)).subscribe(console.log);

五、 工具操作符 (面试高频)

用于处理流的逻辑,如限流、防抖、错误处理。

import { delay, tap, catchError, takeUntil, debounceTime } from 'rxjs/operators';
import { of, Subject, throwError } from 'rxjs';
// 1. tap: 副作用操作 (不修改数据,通常用于打印日志、存 LocalStorage)
of('Hello').pipe(
  tap(val => console.log('处理前:', val)), 
  delay(1000) // 延迟1秒发射
).subscribe(val => console.log('处理后:', val));
// 2. catchError: 错误捕获 (让流不中断)
throwError(() => new Error('网络错误')).pipe(
  catchError(err => {
    console.error(err);
    // 捕获错误后,返回一个新的 Observable 给下游,防止程序崩溃
    return of('默认数据'); 
  })
).subscribe(console.log); // 输出: 默认数据
// 3. debounceTime: 防抖 (用户停止输入 300ms 后才发送请求)
fromEvent(document.querySelector('input')!, 'input').pipe(
  debounceTime(300)
).subscribe((event: any) => console.log(event.target.value));
// 4. takeUntil: 立即取消订阅 (在 Angular 组件销毁时最常用)
const destroy$ = new Subject<void>();
interval(1000).pipe(
  takeUntil(destroy$) // 当 destroy$ 发出值时,上面的流自动停止
).subscribe(console.log);
// 模拟组件销毁
setTimeout(() => {
  destroy$.next(); // 停止上面的 interval
  destroy$.complete();
}, 5000);

六、 高阶操作符 (处理嵌套流)

当一个 Observable 发出的数据还是一个 Observable 时使用。

import { mergeMap, switchMap, concatMap, exhaustMap } from 'rxjs/operators';
// 场景:点击按钮 -> 发送 HTTP 请求
// 假设 click$ 是点击事件流, getData(id) 返回 Observable
// 1. mergeMap (并行): 点击一次发一次请求,不管上一个有没有完成。
// 适用:并发上传,互不干扰。
click$.pipe(
  mergeMap(() => this.http.get('/api/data'))
).subscribe();
// 2. switchMap (切换): **面试必考**。如果有新请求,取消旧请求。
// 适用:搜索框输入。
searchInput$.pipe(
  switchMap(keyword => this.http.search(keyword)) 
).subscribe();
// 3. concatMap (串行): 等前一个请求完成,再发下一个。
// 适用:必须按顺序执行的任务。
// 4. exhaustMap (排他): 如果有请求正在进行,忽略新的点击。
// 适用:防止重复提交表单。
submitBtn$.pipe(
  exhaustMap(() => this.http.submit())
).subscribe();

七、 Subject (多播)

普通的 Observable 是单播的;Subject 可以让多个订阅者共享同一个数据源。

import { Subject, BehaviorSubject, ReplaySubject } from 'rxjs';
// 1. Subject: 只有订阅后发出的数据才会收到。
const subject = new Subject<number>();
subject.subscribe(n => console.log('A:', n));
subject.next(1); // A 收到 1
subject.subscribe(n => console.log('B:', n));
subject.next(2); // A 收到 2, B 收到 2 (B 错过了 1)
// 2. BehaviorSubject: 必须有初始值,新订阅者会立即收到**最新**的值。
const bs = new BehaviorSubject<number>(0); // 初始值 0
bs.subscribe(n => console.log('C:', n)); // C 立即收到 0
bs.next(100);
// 3. ReplaySubject: 可以缓存最近的 N 个值,新订阅者会收到缓存的历史记录。
const rs = new ReplaySubject(2); // 缓存最近 2 个
rs.next(1);
rs.next(2);
rs.next(3);
rs.subscribe(n => console.log('D:', n)); // D 收到 2 和 3

八、 Angular 实战:AsyncPipe (语法糖)

在 Angular 中,你甚至不需要手动调用 .subscribe()

// 组件 TS
export class MyComponent {
  // 自动处理订阅、取消订阅、变化检测
  data$ = of([{ name: 'Tom' }, { name: 'Jerry' }]); 
}
// 组件 HTML
<div *ngFor="let item of data$ | async">
  {{ item.name }}
</div>

注意: 如果你需要拿到数据后在 TS 逻辑里做复杂处理,还是需要手动 subscribe 并配合 takeUntil 使用。

总结速查表

类别 操作符 作用
创建 of, from, interval 造数据
转换 map, filter 改数据
工具 tap, delay, debounceTime 辅助/拦截
组合 switchMap, mergeMap 处理嵌套流 (HTTP)
生命周期 takeUntil, first, take 管理订阅
错误 catchError, retry 异常处理
多播 Subject, BehaviorSubject 跨组件通信

开发者必看!TinyPro中后台系统最新Springboot上手指南~

2026年1月20日 15:32

本文由TinyPro中后台系统贡献者周泽龙原创。

在长达三个月的开发下,终于TinyPro的Springboot后端版本终于要问世了,在本期内容中我将带大家一步步去搭建整个后端的流程,也将带大家去探索对于最新版本的更改应该如何实现,以及如何使用本项目进行一个二次的开发和探索。 首先我们先要对于TinyPro项目进行一个整体的拉取,去到TinyPro的官方进行拉取,当我们获取到项目以后就可以进行开始今天的项目构建了。

接下来的流程就是对于前端i项目的搭建以及后端的springboot项目的搭建,最后再去介绍咱们新版本里面的一些特性和组件

1.前端部分的搭建

首先要确保咱们安装了Node.js、NPM、TinyCLI接下来就要正式初始化项目了首先我们进行初始化

(1)在命令行输入tiny init pro对项目进行一个初始化具体的流程可以看我的视频介绍

img_v3_02u4_d0333bf1-9980-431d-be2b-dbfe8a3e66cg.jpg
(2)接下来就让我们进入到我们的项目里面,tinyvue的前端代码里面我们首先进行一个项目的依赖的下载大家可以使用npm install进行项目依赖的下载。

(3)当我们项目依赖下载完成后就可以进入到一个启动流程了,使用npm start进行一个项目的启动启动后就会开启3031端口这样就可以看见项目的启动界面了!

img_v3_02u4_000ed57c-e850-4949-9f38-5309dd9e69ag.png

到目前为止我们的前端项目就算正式启动成功了,接下来让我们一起开始启动后端项目

2.后端项目的搭建

首先我们需要确保自己的本地环境里面有jdk17,maven,mysql,redis以及一个自己喜欢的开发软件可以idea或者vscode

好了准备工作做好以后接下来就让我们进入后端的开发和后端二次开发的一个介绍并且我也将带着大家去了解springboot里面的一些设计和里面的一些函数的内容接下来开始吧

项目结构的介绍: 当进入到项目里面的时候我们最直观的可以看见项目的一个整体结构

img_v3_02u4_fefceef2-2098-4f7d-8b32-9895a6f1faag.png
(1)先介绍一下项目的一个配置文件,对于所有的springboot项目上来第一件事就算看配置文件application.properties文件这个文件里面包含了所有项目需要的配置比如:mysql,redis,Springjpa,mybatis-plus(项目里面没有使用,但是基本的配置都配置好了,也就兼容了喜欢使用mybatis-plus的同学)大家可以更具自己的数据库信息和redis进行配置,需要自己填写好数据库的用户名,端口和驱动地址,还有redis的配置信息比如主机地址和端口号

到这里的同学,那就恭喜大家数据服务的配置我们就是做好了,接下来就是对项目的依赖的下载,这块主要涉及到maven的使用,如果还,没有下载maven的同学记得赶快去下载

(2)接下来开始项目依赖的初始化过程,在项目启动的时候,我们需要先对项目的依赖包去官方的仓库里面下载(这块给大家一个提醒,如果下载过慢的同学记得去配置一下maven的国内镜像源进行下载和配置),敲入命令 mvn install进行一个项目依赖的下载。

如果到这里都执行成功,大家就可以正式的启动项目,正式启动项目之前我希望大家可以去查看自己jdk的配置是否是17,因为接下来的必须要使用jdk17了

(3)进入到TinyProApplication文件里面进行启动项目,在这之前需要确保启动了redis和mysql的服务,并且配置好了密码,然后启动项目以后我们就会看到一个提示:

img_v3_02u4_fe85ca7b-734e-404e-87b6-88039cbcd7dg.png
这里就算证明项目的整体正式启动成功了,接下来就开始监听3000端口了。

项目启动成功以后就可以开始进行一个交互了,大家就可以进入到刚才启动的前端项目里面准备进行一个交互,账户和密码都是admin,这块是配置里面预先写好的,如果有人需要修改这个用户和角色名称,可以进到 DataInitializer文件里面找到user配置进行修改

3.二次开发

这个项目中支持二次开发的模块包括:权限管理拒绝策略,以及用户的登录校验初始化配置

img_v3_02u4_2f360650-8906-4132-92e6-72d89be6f99g.png

(1)首先就是项目的权限管理的问题大家可以看见代码里面首先需要权限校验的接口上面都会有一个

6.png
@PermissionAnnotation这个注解里面配置的就是当前接口需要用户所拥有的权限,然后这块里面底层的实现细节在aspect这个目录里面,然后里面就是对于apo的一个使用。如果大家需要给某一个接口增加新的权限大家就可以直接在接口的上面进行一个使用然后写入具体要限制的细节 比如可以写:

7.png
这块就是要求用户必须要有menu::query::list这个权限才能进入到这个接口里面进行查询操作如果大家想更进一步了解到权限管理的细节,可以去看aop的使用java里面的切面编程

(2)接下来可以看拒绝的策略,首先对于接口拒绝策略的具体控制在配置文件里面,大家可以看到

8.PNG\
这块就是一个拒绝策略的开关,如果大家想开始拒绝策略就可以直接输入true这个然后就会开启拒绝策略进行项目模式,目前是默认在演示模式里面

这个里面主要分为一个演示模式和一个项目模式,在项目模式里面大家可以自由的进行控制但是在演示模式里面,有很多的功能都被禁止了,所以大家要是不能使用的话就需要先查看是否是因为在演示模式里面导致的

(3)接下来就是用户的登录校验,大家首先要明白的一个流程就是用户首先要登录,只有登录成功以后才会将token放到redis里面,然后用户登录的校验就会先去redis里面进行查询,如果查询的到就会通过校验,如果redis里面没有当前用户人的信息就会进行一个拒绝的返回,然后就会跳转到前端的登录界面里面进行一个登录。具体就是拿一个拦截器进行拦截然后对每一个请求都进行校验只有登录过的才能进行项目的操作 (4)项目的初始化整个项目的初始化都在DataInitializer.java这个文件里面,如果后续需要进行一个项目的初始化调整,比如更改初始化的顺序以及在初始化的过程中想再加载一些资源都可以在这个文件里面进行增加

9.png

在这个run方法里面进行添加,这样项目在启动的时候就会先去加载项目里面的内容然后生成一个data文件夹的,这就标志着项目以及初始化过了,不需要再进行初始化,接下来每次的项目初始化都会先去看项目里面是否有data的目录如果存在就不走初始化的逻辑了

好了讲解完二次开发以后,接下来就要进入到docker的一个部署流程,在这个之前,大家可以更具的自己的情况去看是去买一个云服务器还是自己搭建一个虚拟机环境,然后进行配置,我在视频里面给搭建演示的就是在自己的虚拟机里面进行一个docker的部署和调用

4.docker部署

首先要了解在进行docker部署的时候,自己的容器文件里面的内容是否创建好了,以及对应的docker-compose.yml的一个配置

再检查完这些内容以后就要进入到我们的一个docker的部署流程环节,其实本质上也很简单就是进入到项目的文件夹目录里面,然后直接执行docker compose up -d这个命令以后,等待下载,但是下载的过程里面会有很多的问题比如下载过慢问题

(1)将项目的文件上传到服务器上面

10.png

然后进入当前目录大家可以看见,项目里面有两个文件一个是Dockerfile另一个是docker-compose.yml着两个文件是我们必须要的文件,进入进去看见

11.png

里面就是一些配置比如mysql的地址以及redis的地址,都是对应着我们即将启动的容器名称

(2)接下来就开始正式的启动docker-compose.yml文件,使用命令docker compose up -d启动成功以后就可以进行前端端口的配置映射到线上的docker地址,方便未来的开发
11.png

这个就是启动成功了,大家可以看映射的地址进行修改前端的配置了

5.本次参加开源之夏的感受和收获

在参加完这次的开源之夏以后,我最大的感受就是第一次有一个整齐的计划和老师还有别的学校的同学们可以一起开发一个软件,让我还没出社会的时候就已经拥有了独立开发的经验和经历。其次就是老师的辅导和社区的教导让我真的成长了很多,我特别感谢开源之夏和+OpenTiny社区对我的帮助,最后谢谢我的导师(真的很牛),他也很耐心的教我,特别感谢名字的话就不说了,不然以后有人烦他去了

谢谢大家我真的很珍惜这次机会,谢谢开源之夏,谢谢OpenTiny社区,谢谢导师,那我的这次开源之旅就结束,但是我相信只是暂时,我以后还会继续投身到开源里面,也希望可以帮助更多的人

关于OpenTiny

欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~

OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyPro 源码:github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~ 如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~

❌
❌