普通视图

发现新文章,点击刷新页面。
今天 — 2026年3月28日掘金 前端

前端转全栈:你必须要掌握的 Docker 知识

2026年3月28日 10:58

image.png

前言

作为一名前端开发者,你可能已经习惯了在本地运行 npm run dev,然后打开浏览器就能看到页面。但随着你向全栈方向迈进,情况变得复杂起来:你需要启动后端服务、配置数据库、管理 Redis 缓存、处理消息队列……当你兴致勃勃地按照教程把项目跑起来,却发现“我本地明明可以运行,怎么到你电脑上就不行了?”——这个场景是否似曾相识?

这就是 Docker 要解决的核心问题。本文将从前端的视角出发,带你了解 Docker 的核心概念、常用命令,以及如何用它来搭建全栈开发环境。读完本文,你将能够:

  • 理解 Docker 为什么能解决环境一致性问题
  • 掌握 Docker 的核心概念和常用命令
  • 用 Docker 容器化一个 Node.js 应用
  • 使用 Docker Compose 搭建完整的全栈开发环境

一、为什么前端也需要 Docker?

1.1 传统开发模式的痛点

假设你正在开发一个全栈项目:

  • 前端:Vue Vite
  • 后端:Node.js + Express
  • 数据库:MySQL + Redis

如果没有 Docker,你需要:

  1. 在本地安装 MySQL,配置用户名密码,创建数据库
  2. 安装 Redis,确保端口不被占用
  3. 配置 Node.js 环境,确保版本与团队一致
  4. 如果团队成员使用 Windows、macOS、Linux 不同系统,还可能遇到路径问题、系统差异

更可怕的是,当你需要同时维护多个项目时,不同项目依赖的 Node 版本、数据库版本可能冲突,你的电脑会变得越来越“脏”,直到有一天你不得不重装系统。

1.2 Docker 的解决方案

Docker 通过容器化技术,将应用及其依赖打包成一个轻量级的、可移植的单元。简单来说:

  • 镜像(Image):类似于前端的“安装包”,包含了运行应用所需的一切(代码、运行时、系统工具、库)
  • 容器(Container):镜像的运行实例,类似于“正在运行的应用进程”

用 Docker 后,你的团队只需要:

# 新成员加入项目,只需要执行这一条命令
docker-compose up

所有依赖(数据库、缓存、后端服务)都会自动启动,版本一致,环境一致。


二、Docker 核心概念(前端友好版)

2.1 镜像 vs 容器:类比面向对象

如果你熟悉 JavaScript 的类与实例的概念,Docker 的镜像和容器就非常好理解:

概念 类比
镜像(Image) 类(Class)—— 定义了什么属性和方法
容器(Container) 实例(Instance)—— 真正运行的对象
Dockerfile 类的定义代码 —— 描述如何构建镜像
Docker Hub npm 仓库 —— 存储和分享镜像的地方

2.2 Dockerfile:定义你的“环境配置文件”

Dockerfile 类似于 package.json + 环境配置的组合,它告诉 Docker 如何构建镜像。一个典型的 Node.js 应用 Dockerfile 如下:

# 指定基础镜像(类似于 extends)
FROM node:18-alpine

# 设置工作目录(类似于 cd /app)
WORKDIR /app

# 复制 package.json 和 package-lock.json
# 利用 Docker 缓存层,只有依赖变化时才重新安装
COPY package*.json ./

# 安装依赖
RUN npm ci --only=production

# 复制源代码
COPY . .

# 暴露端口
EXPOSE 3000

# 启动命令
CMD ["node", "server.js"]

💡 前端视角:这个文件就像是一个“环境即代码”的声明,把原本需要手动执行的操作(安装 Node、复制文件、安装依赖、运行)全部写成了代码。

2.3 数据卷(Volume):解决数据持久化

前端开发时,你肯定不希望每次修改代码都要重新打包。同样,数据库的数据也不应该随着容器删除而丢失。

Docker 的数据卷(Volume)实现了宿主机与容器之间的文件共享

volumes:
  - ./src:/app/src        # 本地代码映射到容器,实现热更新
  - /app/node_modules     # 避免覆盖容器内的 node_modules
  - db-data:/var/lib/mysql # 数据库数据持久化

这样一来,你修改本地代码,容器内的应用会自动更新;数据库的数据也不会因为容器重启而丢失。


三、常用 Docker 命令速查

作为前端开发者,你不需要记住所有 Docker 命令,但以下几个是你日常开发中一定会用到的:

3.1 镜像管理

# 拉取镜像
docker pull node:18-alpine

# 查看本地镜像
docker images

# 构建镜像(-t 指定名称和标签)
docker build -t my-app:1.0 .

# 删除镜像
docker rmi <image_id>

3.2 容器管理

# 运行容器(-d 后台运行,-p 端口映射)
docker run -d -p 3000:3000 --name my-app my-app:1.0

# 查看运行中的容器
docker ps

# 查看所有容器(包括已停止的)
docker ps -a

# 停止容器
docker stop my-app

# 启动已停止的容器
docker start my-app

# 进入容器内部(调试用)
docker exec -it my-app sh

# 查看容器日志
docker logs my-app

# 删除容器
docker rm my-app

3.3 数据卷

# 查看数据卷
docker volume ls

# 删除无用数据卷
docker volume prune

3.4 组合命令技巧

开发时最常用的组合:

# 构建并运行(开发模式)
docker build -t my-app . && docker run -p 3000:3000 my-app

# 清理所有停止的容器和未使用的镜像
docker system prune

四、实战:容器化一个 Node.js 应用

让我们动手把一个简单的 Express 应用容器化。假设项目结构如下:

my-app/
├── src/
│   └── index.js
├── package.json
├── package-lock.json
└── Dockerfile

步骤 1:创建简单的 Express 应用

// src/index.js
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

app.get('/', (req, res) => {
  res.json({ message: 'Hello from Docker!' });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

步骤 2:编写 Dockerfile

# 使用多阶段构建优化镜像大小
FROM node:18-alpine AS builder

WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .

EXPOSE 3000
CMD ["node", "src/index.js"]

步骤 3:构建并运行

# 构建镜像
docker build -t my-express-app .

# 运行容器
docker run -d -p 3000:3000 --name express-app my-express-app

# 测试
curl http://localhost:3000
# 输出:{"message":"Hello from Docker!"}

步骤 4:开发模式(支持热更新)

开发时需要代码变更后自动重启,可以使用 nodemon + 数据卷:

# Dockerfile.dev
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev"]  # dev 脚本包含 nodemon

运行命令:

docker run -d -p 3000:3000 -v $(pwd):/app -v /app/node_modules my-express-app:dev

🚀 小技巧:用 -v $(pwd):/app 将当前目录挂载到容器,修改代码后容器内应用会自动重启。


五、Docker Compose:一站式全栈环境

当项目包含多个服务(前端、后端、数据库)时,逐个启动容器会非常繁琐。Docker Compose 允许你用 YAML 文件定义所有服务,一条命令启动整个应用栈。

5.1 一个典型的全栈项目结构

fullstack-project/
├── frontend/          # React 应用
├── backend/           # Node.js API
├── docker-compose.yml
└── .env

5.2 docker-compose.yml 示例

version: '3.8'

services:
  # MySQL 数据库
  mysql:
    image: mysql:8.0
    container_name: fullstack-mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: ${MYSQL_DATABASE}
      MYSQL_USER: ${MYSQL_USER}
      MYSQL_PASSWORD: ${MYSQL_PASSWORD}
    ports:
      - "3306:3306"
    volumes:
      - mysql-data:/var/lib/mysql
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 20s
      retries: 10

  # Redis 缓存
  redis:
    image: redis:7-alpine
    container_name: fullstack-redis
    ports:
      - "6379:6379"
    volumes:
      - redis-data:/data

  # 后端 API
  backend:
    build: ./backend
    container_name: fullstack-backend
    restart: always
    ports:
      - "3000:3000"
    environment:
      NODE_ENV: development
      DB_HOST: mysql
      DB_PORT: 3306
      DB_USER: ${MYSQL_USER}
      DB_PASSWORD: ${MYSQL_PASSWORD}
      DB_NAME: ${MYSQL_DATABASE}
      REDIS_HOST: redis
      REDIS_PORT: 6379
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started
    volumes:
      - ./backend:/app
      - /app/node_modules

  # 前端
  frontend:
    build: ./frontend
    container_name: fullstack-frontend
    ports:
      - "5173:5173"
    environment:
      VITE_API_URL: http://localhost:3000
    volumes:
      - ./frontend:/app
      - /app/node_modules
    depends_on:
      - backend

volumes:
  mysql-data:
  redis-data:

5.3 环境变量文件 .env

MYSQL_ROOT_PASSWORD=root123
MYSQL_DATABASE=fullstack_db
MYSQL_USER=app_user
MYSQL_PASSWORD=app_pass

5.4 常用 Compose 命令

# 启动所有服务(-d 后台运行)
docker-compose up -d

# 查看日志
docker-compose logs -f

# 停止所有服务
docker-compose down

# 停止并删除数据卷(谨慎使用)
docker-compose down -v

# 重新构建并启动
docker-compose up -d --build

# 查看服务状态
docker-compose ps

六、最佳实践与常见陷阱

6.1 镜像瘦身技巧

前端开发者对打包体积敏感,Docker 镜像也一样:

  • 使用 alpine 版本基础镜像node:18-alpinenode:18 小 10 倍以上
  • 多阶段构建:只把最终需要的文件复制到最终镜像
  • 合并 RUN 命令:减少镜像层数
# 不好:产生多个层
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get clean

# 好:合并为单层
RUN apt-get update && apt-get install -y curl && apt-get clean

6.2 .dockerignore 文件

.gitignore 类似,避免将不必要的文件复制到镜像中:

node_modules
.git
.env
.DS_Store
*.log
dist
build

6.3 不要在容器内存储敏感信息

  • 使用环境变量传递配置
  • 生产环境使用 Docker secrets 或云服务商的密钥管理

6.4 理解容器网络

在 Compose 中,服务之间可以通过服务名互相访问:

  • 后端连接 MySQL:mysql:3306
  • 前端连接后端 API:http://backend:3000(仅在容器内有效)

如果前端需要从浏览器访问后端,需要使用宿主机地址:http://localhost:3000

6.5 权限问题(尤其是 Linux/macOS)

当使用数据卷挂载时,容器内创建的文件可能属于 root 用户。可以通过指定用户 ID 解决:

backend:
  user: "${UID:-1000}"
  volumes:
    - ./backend:/app

.env 中添加:UID=1000(macOS/Linux 下执行 id -u 获取)


七、从本地开发到生产部署

开发阶段我们使用数据卷实现热更新,但生产环境应该使用构建好的镜像,不需要挂载源代码。

7.1 生产环境 Dockerfile 优化

# 生产环境 Dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY . .
EXPOSE 3000
USER node
CMD ["node", "src/index.js"]

7.2 部署流程

# 构建生产镜像
docker build -t myapp:prod .

# 推送到镜像仓库(如 Docker Hub、阿里云容器镜像服务)
docker tag myapp:prod username/myapp:latest
docker push username/myapp:latest

# 在生产服务器上拉取并运行
docker pull username/myapp:latest
docker run -d -p 3000:3000 --env-file .env.prod username/myapp:latest

八、总结

对于从前端转向全栈的开发者来说,Docker 是你必须掌握的工具。它不仅能解决“环境不一致”这个最令人头疼的问题,还能让你:

  • ✅ 快速搭建开发环境,告别“在我电脑上能跑”
  • ✅ 隔离不同项目的依赖,保持系统整洁
  • ✅ 用代码定义基础设施,实现环境即代码(IaC)
  • ✅ 简化部署流程,实现一键部署

Docker 的学习曲线并不陡峭,一旦掌握,你的开发效率和项目可维护性将提升一个台阶。现在就开始动手,把你的第一个全栈项目容器化吧!

纯干货,前端字体极致优化!谷歌、阿里、字节、腾讯都在用的终极解决方案,Vue3 + Vite 直接抄,页面提速不妥协!

作者 李剑一
2026年3月28日 09:56

最近在做一个公网的小项目,本身是一个在线的海报编辑器,因为之前做的比较糙,最近有时间了,领导让优化一下。

问题主要集中在页面的加载速度上。

image.png

设计稿要求多字体质感、标题正文差异化排版,结果引入的字体包动辄几MB,中文字体甚至直奔10MB+。

页面首屏加载非常慢,但是删字体包设计那边过不去,不删吧用户等待时间过长,体验直线下滑,简直两难。

核心问题

不过别慌,稳住了!

先明确我们到底在解决什么问题,避免盲目优化。

其实主要几个问题:

  • 字体体积冗余:完整字体包包含上万字符,项目实际用到的不过几百个,甚至有可能就几个字,全量加载纯纯浪费。
  • 阻塞页面:字体包过大,加载过程中可能触发FOIT(文字隐形)、FOUT(文字闪烁),页面出现留白卡顿。
  • 多字体加载混乱:一个页面同时存在多种字体包同时引入的时候,基本上就是谁在前面加载谁,无规划加载拖慢整体渲染。
  • 格式不兼容:沿用TTF/OTF老式字体格式,体积大、压缩率低,完全适配不了现代前端性能要求。

但是格式问题需要注意,新式的字体包,比如说WOFF2,是不支持IE这种较老版本的浏览器的。

如果你有兼容需求,记得不要上新包。

解决方案

建议字体包转WOFF2

前提是你只要没有兼容性需求,几乎WOFF2必转的。

相比于传统TTF、OTF、WOFF格式,WOFF2是现代浏览器专属的字体压缩格式,体积能直接缩减50%-70%

一个TTF大约20MB的包,在WOFF2上大概也就8MB左右,这个优化是显而易见的。

而且Chrome、Edge、Safari、Firefox全版本兼容,完全不用担心兼容性问题。

可以用 Font Squirrel在线工具进行一键转换,或者直接让UI那边导出来WOFF2的包。

按场景拆分字体包

多字体包的情况下千万别全量引入,一定要按照使用频率、使用场景拆分:

高频使用的优先处理,低频使用的单独拆分,延后加载。

可以在引入的部分,按照字重、字体样式拆分@font-face

这样的好处在于浏览器只会加载当前页面用到的字体规则,不会全量请求所有字体包。

/* 按字重拆分,按需加载 */
@font-face {
  font-family: 'SourceHanSans';
  src: url('./fonts/regular.woff2') format('woff2');
  font-weight: 400;
}
@font-face {
  font-family: 'SourceHanSans';
  src: url('./fonts/bold.woff2') format('woff2');
  font-weight: 700;
}

延迟加载

另外文字留白、闪烁问题,核心就是靠font-display属性。

使用font-display: swap浏览器会先使用系统默认字体展示页面文字,等到自定义字体加载完成后,再无缝替换。

全程不会出现文字隐形、页面卡顿的情况,让用户感知上有一种等一小会儿的感觉。

还有就是可视区域以外的字体,完全没必要和首屏一起加载,等到页面加载完成、或者用户触发对应模块时,再加载字体即可,减少首屏的请求数量。

// 页面加载完成后,懒加载非首屏字体
window.addEventListener('load', () => {
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = '/css/other-font.css';
  document.head.appendChild(link);
})

字体子集化&按需加载(终极方案)

前面主要是给字体"减负",字体子集化和按需加载就是直接给字体"瘦身"。

据我所知,这也是目前谷歌、阿里、腾讯等大厂通用的天花板方案,能够彻底解决字体冗余问题。

核心原理

完整字体包中包含海量未使用字符,这样我们其实可以通过工具提取项目实际用到的字符

将大字体拆分成多个极小的字体分片;再通过CSS的unicode-range告诉浏览器,哪些字符对应哪个字体分片。

浏览器只会加载当前页面用到的分片,没用到的完全不请求。

相当于是对通过拆字的方式实现了对字体包的懒加载。

简单画一个流程图:

image.png

这套方案下来,原本几MB的字体,能直接压缩到几十KB,首屏字体加载速度提升数十倍,还完全不影响多字体使用!

具体实现

这里我推荐几个我用过的:glyphhangerfontminvite-plugin-fontmin

glyphhanger

基于Node.js,无需手动提取字符,直接爬取页面文字,自动生成子集字体+unicode-range CSS,适合快速优化现有页面,新手也能一键上手。

# 全局安装
npm install -g glyphhanger
# 一键生成子集字体与CSS
glyphhanger http://localhost:3000 --formats=woff2 --subset=./src/fonts/xxx.ttf

fontmin,纯JS定制化工具

纯JavaScript实现,无额外环境依赖,支持自定义提取字符、批量处理,可嵌入Webpack、Gulp等构建流程,适合需要定制化优化的项目。

const Fontmin = require('fontmin')
new Fontmin()
  .src('./src/fonts/xxx.ttf')
  .use(Fontmin.glyph({ text: '项目实际用到的文字', hinting: false }))
  .use(Fontmin.ttf2woff2())
  .dest('./dist/fonts')
  .run()

vite-plugin-fontmin,Vue3+Vite项目专属

# 安装插件
npm install vite-plugin-fontmin -D
# 或者yarn/pnpm
pnpm add vite-plugin-fontmin -D

配置文件,这里写的比较全,包含字体优化、资源打包、开发环境优化等等,可根据项目字体自行修改。

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// 引入字体子集化插件
import fontminPlugin from 'vite-plugin-fontmin'

export default defineConfig({
  // 路径别名
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src')
    }
  },
  // 插件配置
  plugins: [
    vue(),
    // 字体子集化核心配置
    fontminPlugin({
      // 配置多个字体(单字体直接写单个对象即可)
      fonts: [
        {
          // 源字体文件路径(放入项目src/fonts目录下)
          fontSrc: './src/fonts/SourceHanSansCN-Regular.ttf',
          // 子集化后字体的输出目录
          fontDest: './src/assets/fonts/subset/',
          // 自动扫描项目文件,提取所有用到的字符(无需手动书写)
          inputPath: ['./src/**/*.{vue,ts,tsx,js,jsx,css,scss}'],
          // 额外预留字符(动态内容、用户输入、接口返回文字,提前预留)
          input: '0123456789qwertyuiopasdfghjklzxcvbnm,。!?;:“”‘’',
          // 仅输出WOFF2格式
          formats: ['woff2'],
          // 开启unicode-range按需加载(核心)
          unicodeRange: true,
          // 字体渲染规则,避免阻塞
          fontDisplay: 'swap',
          // 关闭字体提示,进一步压缩体积
          hinting: false
        },
        // 多字体配置示例(标题字体,按需添加)
        {
          fontSrc: './src/fonts/TitleFont-Bold.ttf',
          fontDest: './src/assets/fonts/subset/title/',
          inputPath: ['./src/components/Title/**/*.vue', './src/views/**/*.vue'],
          formats: ['woff2'],
          unicodeRange: true,
          fontDisplay: 'swap'
        }
      ],
      // 开发环境仅执行一次子集化,避免热更新卡顿
      runOnceInDev: true,
      // 生产环境压缩字体
      compress: true
    })
  ],
  // 生产构建配置
  build: {
    assetsDir: 'static/assets',
    // 字体资源单独打包,方便缓存
    rollupOptions: {
      output: {
        assetFileNames: (assetInfo) => {
          if (assetInfo.name && assetInfo.name.endsWith('.woff2')) {
            return 'static/assets/fonts/[name]-[hash][extname]'
          }
          return 'static/assets/[name]-[hash][extname]'
        }
      }
    },
    // 关闭生产环境sourcemap,提升打包速度
    sourcemap: false,
    // 代码压缩
    minify: 'terser'
  },
  // 开发服务器配置
  server: {
    port: 3000,
    open: true
  }
})

完成上述配置以后,插件会自动生成对应的@font-face规则,无需手动引入字体CSS,直接在项目样式里使用即可。

/* src/assets/css/global.css */
body {
  font-family: 'SourceHanSansCN', sans-serif;
}
.title {
  font-family: 'TitleFont', sans-serif;
  font-weight: 700;
}

总结

其实字体优化一直是老大难问题,不上字体包效果出不来,上了字体包加载速度上不来。

当然,我们仍然建议,非必要不要上字体包。

还有一个"邪修"方案,可以手动创建字体包子集,也就是说这个字体包只包含要用字,其他的删掉。(参考iconFont的字体库"下载子集")。

如果非要上,那就是所有字体转WOFF2,拆分字体包,添加font-display: swap

另外再增加字体子集化和按需加载的部分,让首屏加载快起来。

Vercel 自动部署完全指南:从配置到问题排查

作者 memeflyfly
2026年3月27日 23:47

Vercel 自动部署完全指南:从配置到问题排查

前言

Vercel 作为现代化的前端部署平台,最吸引人的特性之一就是与 GitHub 的无缝集成——当你推送代码到仓库时,Vercel 会自动触发部署,实现真正的 GitOps 工作流。然而,在实际使用中,不少开发者都遇到过“代码推送了,Vercel 却没反应”的尴尬情况。

本文将详细讲解 Vercel 自动部署的配置方法,并结合真实案例,系统性地梳理常见问题及解决方案。

一、Vercel 自动部署的工作原理

在开始排查问题之前,我们先来理解一下 Vercel 自动部署的完整流程:

  1. 你在 Vercel 中导入 GitHub 仓库
  2. Vercel 在 GitHub 上注册一个 Webhook
  3. 当你 git push 时,GitHub 通过 Webhook 通知 Vercel
  4. Vercel 收到通知后,自动开始构建和部署

整个流程中,任何一环出现问题,都会导致自动部署失败。理解了这一点,排查问题就会更有方向。

二、标准配置步骤

如果你是从零开始,正确的配置方式如下:

2.1 在 Vercel 中导入项目

  1. 登录 Vercel 控制台,点击 Add NewProject
  2. 选择你的 GitHub 仓库
  3. 点击 Import,等待项目导入完成

注意:只需要导入一次!后续每次 git push 都会自动触发部署,不需要手动删除项目重新导入

2.2 检查必要的权限

Vercel 需要你的 GitHub 授权才能正常工作。确保以下权限已授予:

权限 级别 用途
Actions 只读 读取 GitHub Actions 运行状态
Workflows 读写 与 GitHub Actions 集成
代码、部署状态等 读写 创建部署、更新状态

三、问题排查指南

场景一:代码推送后 Vercel 没有反应

这是最常见的问题,通常由以下几个原因导致。

3.1 GitHub App 权限待批准

现象:在 GitHub Settings → Applications 中,Vercel 应用显示 "Permission updates requested"

原因:Vercel 更新了功能,请求新的权限,需要你手动批准。

解决方案

  1. 点击 Review request,进入权限审核页面
  2. 勾选所有请求的权限(特别是 Actions 和 Workflows)
  3. 点击 Accept new permissions 确认
3.2 仓库未授权给 Vercel App

现象:Vercel 项目设置中显示 "Connected",但 GitHub Webhooks 页面为空。

原因:Vercel App 的安装配置中没有授权访问你的仓库。

解决方案

  1. GitHub 右上角头像 → SettingsApplicationsInstalled GitHub Apps
  2. 找到 Vercel,点击 Configure
  3. Repository access 部分,确保你的仓库已勾选
  4. 点击页面底部的 Save 保存配置
  5. 回到 Vercel 项目,Disconnect 后重新 Connect
3.3 提交邮箱与 Vercel 账号不匹配

现象:私有仓库中,部分提交触发部署,部分不触发。

原因:Vercel 出于安全考虑,只为“团队成员”的提交触发部署。判断依据是 Git 提交邮箱是否与 Vercel 账号邮箱一致。

解决方案

# 检查本地 Git 邮箱配置
git config user.email

# 确保这个邮箱与你 Vercel 账号邮箱一致
# 如果不一致,修改配置
git config --global user.email "your-vercel-email@example.com"
3.4 Vercel 项目配置问题

现象:Webhook 存在且记录为绿色,但 Vercel 仍不部署。

排查

  • 检查项目根目录的 vercel.json 中是否有 deploymentEnabled 配置限制了部署分支
  • 检查 Vercel 项目设置中的 Ignored Build Step 是否误判了构建条件

场景二:终极解决方案——手动创建 Webhook

如果上述方法都无法解决问题,可以采用最直接的方式:手动创建 Webhook。

步骤一:创建 Vercel Deploy Hook
  1. 在 Vercel 项目中进入 SettingsGit
  2. 找到 Deploy Hooks 部分,点击 Create Hook
  3. 填写名称和分支(如 main),创建后复制生成的 URL
步骤二:在 GitHub 添加 Webhook
  1. 进入 GitHub 仓库 → SettingsWebhooks
  2. 点击 Add webhook
  3. 填写配置:
字段
Payload URL 粘贴 Deploy Hook URL
Content type application/json
Which events 选择 Just the push event
  1. 点击 Add webhook
步骤三:测试验证
git commit --allow-empty -m "test: verify webhook"
git push

检查 GitHub Webhooks 页面是否有绿色 ✅ 记录,以及 Vercel Deployments 页面是否出现新的部署。

四、常见误区

❌ 误区一:每次部署都要手动删除项目重新导入

正确做法:只需导入一次,后续推送代码即可自动部署。重复导入会破坏 Webhook 连接。

❌ 误区二:Vercel 显示 "Connected" 就代表一切正常

正确做法:"Connected" 只代表 Vercel 记住了仓库地址,不代表 Webhook 已成功注册。建议始终在 GitHub Webhooks 页面确认。

❌ 误区三:批准权限后不需要保存

正确做法:在 GitHub App 配置页面,每次修改权限或仓库授权后,都必须点击 Save 按钮,否则变更不会生效。

五、快速自查清单

遇到自动部署不工作时,按以下顺序检查:

  • GitHub Applications 中 Vercel App 是否有待处理的权限请求?
  • Vercel App 的 Repository access 是否包含你的仓库?
  • GitHub Webhooks 页面是否有 Vercel 的 Webhook?
  • 如果有 Webhook,最近的推送记录是否显示绿色 ✅?
  • 你的 Git 提交邮箱是否与 Vercel 账号邮箱一致?
  • 项目中是否有 vercel.json 限制了部署分支?

结语

Vercel 的自动部署本应是“开箱即用”的体验,但当它不工作时,往往是因为 GitHub 权限或 Webhook 配置出现了问题。希望本文的系统性梳理能帮助你快速定位并解决问题。

记住:授权 → 确认仓库 → 检查 Webhook → 推送测试,这四步基本能覆盖 99% 的问题场景。

Flink技术实践-超时异常踩坑与优化

2026年3月27日 23:42

一、背景介绍   

在Flink实时计算的生产环境中,最令人头疼的往往不是复杂的业务逻辑,而是那些突如其来的“超时异常”。这些异常就像是系统中的“幽灵”,通常在业务高峰期或网络抖动时出现,导致作业重启、数据延迟甚至数据丢失。   

最近几个月我们也遇到了好几起超时导致的作业异常案例,今天将结合近几年Flink相关生产实践,梳理Flink作业常见超时异常场景,详解核心超时参数含义,并给出对应的调优实践参考,为后续规避同类生产风险。

二、Flink作业常见超时异常场景   

Flink作业实时运行涉及集群通信、状态持久化、消息收发、外部交互等多个环节,任一环节超时参数配置不合理,都会触发连锁异常,引发生产故障。往期有一篇HBaseSink超时排障的文章讲解了Flink与HBase交互的Hbase-connector参数配置不当引起的写入超时问题,今天我们主要聚焦在Flink与Kafka的超时异常场景

1.Kafka 消费者心跳超时 (Heartbeat Timeout)

  • 现象:作业运行一段时间后,TaskManager 报错 "Consumer client timed out while receiving records from the broker"或 "LeaveGroup"异常,导致作业重启或部分 Source SubTask 无法消费数据。
  • 根因:在处理大流量或高延迟的复杂算子(如大窗口聚合)时,TaskManager 处理一条 Record 的时间超过了 Kafka Consumer 与 Broker 之间的心跳维持时间,导致 Consumer 被踢出消费组。

2.网络与背压导致的 RPC 超时

  • 现象:JobManager 与 TaskManager 断开连接,日志中出现 "Ask timeout"或 "Rpc connection timeout"。
  • 根因:背压严重时,TaskManager 无法响应 JobManager 的探活请求(如 Heartbeat),导致 JobManager 判定 TaskManager 失联,触发 Failover。

3.Checkpoint 超时导致失败 (Checkpoint Expiration)

  • 现象:Checkpoint 长时间处于 IN_PROGRESS状态,最终因 Checkpoint expired before completing失败。
  • 根因:Barrier 对齐时间过长(通常由背压或数据倾斜引起),超出了 execution.checkpointing.timeout的限制,导致 Checkpoint 被丢弃,多次失败后作业重启。

三、Flink/Kafka常用超时参数详解   

以下Flink参数选取1.16版本,Kafka参数选取2.8版本。

1.Flink核心框架超时参数

参数键 默认值 参数含义
akka.tcp.timeout 20s JobManager与TaskManager之间tcp链接超时时间,超时则连接失败。
akka.ask.timeout 10s JobManager与TaskManager之间RPC请求超时时间,超时则判定RPC调用失败。
heartbeat.timeout 50s TM心跳超时时间,超时未收到心跳则标记TM失效。
execution.checkpointing.timeout 10min 单次Checkpoint超时时间,超时则取消本次快照。
execution.checkpointing.aligned-checkpoint-timeout 0 Barrier对齐超时时间。必须开启非对齐checkpoint,如果 Barrier 对齐耗时超过此阈值,会尝试将阻塞对齐切换为非对齐 Checkpoint。
high-availability.zookeeper.client.connection-timeout 15s 基于zk做jm高可用,client连接zk的超时时间,超时则连接失败。
high-availability.zookeeper.client.session-timeout 60s 基于zk做jm高可用,client与zk的会话超时时间,超时则连接断开。

2.Flink-Kafka超时参数

参数键 默认值 参数含义
scan.topic-partition-discovery.interval none Kafka分区动态发现超时间隔,适配分区扩容场景,默认不开启。
properties.request.timeout.ms 30000ms Kafka客户端请求超时时间,超时则请求失败。
properties.session.timeout.ms 10000ms 消费者会话超时时间,超时触发组重平衡。
properties.heartbeat.interval.ms 3000ms 心跳间隔。官方建议设置为 session.timeout.ms的 1/3,以确保及时发现连接问题 。
properties.fetch.max.wait.ms 500ms 消息拉取最大等待时间,无消息时阻塞时长。
properties.max.poll.interval.ms 300000ms 核心参数。两次 poll()调用的最大间隔。如果 Flink 处理一条消息的时间超过此值,Consumer 会认为该 Consumer 活锁,主动离开 Group。
properties.max.poll.records 500 一次poll()最大拉取条数。
properties.delivery.timeout.ms 120000 发送超时。记录从发送到收到确认(或失败)的总时间上限。
properties.linger.ms 0 发送批次数据等待延迟,0代表来一条发一条。
properties.batch.size 16384 发送批次数据大小,提升吞吐量。

3.超时参数关联示意图

四、超时参数调优推荐与实践   

在实际生产调优中,不能孤立地调整一个参数,而需要结合业务逻辑、资源和运维稳定性进行“组合拳”式的配置。一般参考以下原则:

  • 贴合业务场景:低延迟业务、高吞吐业务、大状态业务的超时配置完全不同,禁止一刀切使用默认值。
  • 兼顾容错与效率:超时时间不宜过短,避免误判故障;也不宜过长,避免故障发现延迟导致雪崩。
  • 参数联动适配:上下游超时参数需匹配,如Kafka会话超时需小于Checkpoint超时,避免重平衡干扰快照。
  • 预留容错空间:集群负载波动、网络抖动时,超时时间需预留缓冲余量。

1.解决 Kafka 消费组频繁 Rebalance (场景:大状态/慢节点)   

在 Flink 消费 Kafka 时,如果算子逻辑较重(如涉及大量状态读写),处理单条记录可能耗时几十秒。默认的max.poll.interval.ms可能无法完成数据处理,Consumer Client 停止调用 poll(),触发“活锁”检测,Consumer 主动离组。   

此场景下推荐配置如下:

max.poll.interval.ms = 600000   # 10分钟 (依据业务最大处理延迟)
session.timeout.ms = 60000      # 1分钟
heartbeat.interval.ms = 20000    # session.timeout 的 1/3 左右
request.timeout.ms = 120000      # 2分钟

或者调小单次poll()的等待时间/记录数,降低超时概率。

2.解决Checkpoint 超时或RPC超时   

在流式计算中,背压往往伴随着 Checkpoint Barrier 对齐缓慢,导致 Checkpoint 超时,严重情况下还会触发RPC超时。默认的Checkpoint与RPC参数可能在高负载与背压场景下无法满足,容易引发超时重试,甚至重启乃至崩溃。   

此场景下推荐配置如下:

execution.checkpointing.timeout = 900000
akka.ask.timeout = 30
sheartbeat.timeout = 70000

另外,还可以开启非对齐Checkpoint,减少背压对Checkpoint的影响,但是要接受非对齐Checkpoint无法保障exactly-once带来的不一致性。如果是因为状态过大,还需要对状态结构或大小进行优化,如设置TTL等。

3.通用生产稳定场景实践

适配绝大多数实时数仓、实时报表、常规数据清洗作业,兼顾稳定性和时效性。

  • akka.ask.timeout:20s(默认10s过短,集群高负载时RPC易超时)
  • heartbeat.timeout:60s(默认50s,网络波动时避免TM误判失联)
  • execution.checkpointing.timeout:15min(默认10min,适配中等状态作业)
  • Kafka session.timeout.ms:30000ms,heartbeat.interval.ms:10000ms(遵循心跳间隔为会话超时1/3的官方建议)
  • Kafka request.timeout.ms:60000ms(避免大消息拉取超时)

4.低延迟实时场景实践

适配对延迟敏感、状态量小的作业,核心追求低延迟。

  • execution.checkpointing.timeout:5min(小状态快照快,缩短超时快速失败重试)
  • Kafka fetch.max.wait.ms:100ms(减少拉取等待,加快消息消费)
  • heartbeat.timeout:30s(缩短心跳超时,快速感知节点故障)

5.超时参数调优流程

避坑要点:

  • Checkpoint超时时间必须大于Checkpoint间隔,避免快照未完成就触发下一次快照。
  • Kafka max.poll.interval.ms必须大于业务处理最大耗时,防止消费超时重平衡。
  • RPC超时、心跳超时不宜设置过长,否则会导致故障发现延迟,扩大故障影响。
  • 作业级参数优先级高于集群全局配置,核心作业建议单独配置,不依赖集群默认值。

五、总结展望

Flink 作业的超时配置本质上是在 “故障检测的灵敏度”与 “系统容错能力”之间做平衡,调优的核心是:找准异常场景、吃透参数含义、贴合业务选型、联动调优验证,兼顾实时作业的延迟、吞吐和容错能力。

随着Flink社区的迭代,自适应调优、智能运维成为发展趋势。未来Flink将逐步实现超时参数的自适应配置,基于作业运行状态、集群负载、数据量自动调整超时阈值,减少人工调参成本;同时,结合可观测性平台,实现超时异常的提前预警、根因自动定位,进一步提升实时作业的稳定性。

LeetCode 4. 寻找两个正序数组的中位数:二分优化思路详解

作者 Wect
2026年3月27日 22:56

在LeetCode的Hard题目中,「寻找两个正序数组的中位数」绝对是经典中的经典。它不仅考察对中位数概念的理解,更核心的是对时间复杂度的极致要求——O(log (m+n)),这就意味着暴力合并数组(O(m+n))的思路直接出局,必须用到二分查找的思想来优化。

今天就来一步步拆解这道题,从题目分析到代码实现,再到细节坑点,带你彻底搞懂如何用二分法高效求解,同时吃透给出的代码逻辑。

一、题目回顾:明确需求与核心难点

题目给出两个正序(从小到大)排列的数组nums1和nums2,大小分别为m和n,要求找出这两个数组的中位数,并且算法的时间复杂度必须是O(log (m+n))。

先明确中位数的定义:将两个数组合并后,按从小到大排序,若总长度为奇数,中位数是中间位置的数;若为偶数,中位数是中间两个数的平均值。

核心难点在于「时间复杂度O(log (m+n))」。二分查找的时间复杂度是O(log k)(k为查找范围),因此我们需要将问题转化为“在两个正序数组中,查找第k小的数”——而中位数,本质上就是第「(m+n+1)/2」小的数(奇数情况),或第「(m+n)/2」和「(m+n)/2 +1」小的数的平均值(偶数情况)。

二、核心思路:二分法缩小查找范围

我们的目标是找到第k小的数(k = Math.floor((totalLen + 1) / 2),totalLen = m + n),核心思想是「每次排除一半不可能是第k小的元素」,从而将查找范围缩小一半,达到log级别的时间复杂度。

具体逻辑如下:

  1. 初始化两个偏移量offset1、offset2,分别表示nums1、nums2中已经排除的元素个数(即当前待查找的起始位置)。

  2. 每次从两个数组的当前起始位置开始,各取k/2个元素(k为当前剩余待查找的元素个数),比较这两个位置的元素大小。

  3. 若nums1的第(offset1 + k/2 -1)个元素小于nums2的对应位置元素,则说明nums1中从offset1到该位置的所有元素,都不可能是第k小的数,可直接排除(offset1 += k/2);反之则排除nums2中的对应元素(offset2 += k/2)。

  4. 重复上述步骤,直到offset1 + offset2等于k,此时找到的最大元素即为第k小的数(leftMax)。

  5. 根据总长度是奇数还是偶数,计算最终的中位数:奇数直接返回leftMax,偶数则需要找到leftMax的下一个最小元素,取两者的平均值。

三、代码逐行解析:吃透每一个细节

给出的代码已经实现了上述思路,并且处理了所有边界情况,下面逐行拆解,帮你理清每一步的作用。

1. 初始化变量

const len1: number = nums1.length;
const len2: number = nums2.length;
const totalLen: number = len1 + len2;
const medianIndex: number = Math.floor((totalLen + 1) / 2);
let offset1 = 0; // nums1的排除偏移量
let offset2 = 0; // nums2的排除偏移量
let leftMax = -Infinity; // 记录第k小的数(leftMax)

这里的关键是medianIndex的计算:无论总长度是奇数还是偶数,我们先找到「第medianIndex小的数」(leftMax)。比如总长度为5(奇数),medianIndex=3,leftMax就是第3小的数(中位数);总长度为4(偶数),medianIndex=2,leftMax是第2小的数,后续再找第3小的数,取两者平均即可。

2. 二分查找核心循环

while (offset1 + offset2 < medianIndex) {
  let k = medianIndex - offset1 - offset2; // 当前剩余待查找的元素个数
  k = Math.max(1, Math.floor(k / 2)); // 每次取k/2个元素,避免k为0
  let left1 = offset1 + k - 1; // nums1中待比较的位置
  let left2 = offset2 + k - 1; // nums2中待比较的位置

  // 处理数组越界:若待比较位置超出数组长度,视为无穷大(无法被选中)
  let val1 = left1 < len1 ? nums1[left1] : Infinity;
  let val2 = left2 < len2 ? nums2[left2] : Infinity;

  // 排除不可能的元素,更新leftMax和偏移量
  if (val1 > val2) {
    leftMax = Math.max(leftMax, val2);
    offset2 += k; // 排除nums2中offset2到left2的元素
  } else if (val1 < val2) {
    leftMax = Math.max(leftMax, val1);
    offset1 += k; // 排除nums1中offset1到left1的元素
  } else {
    // 两元素相等,同时排除,leftMax取该值
    leftMax = val1;
    offset1 += k;
    offset2 += k;
  }
}

这部分是整个算法的核心,重点注意3个细节:

  • k的计算:每次k是“剩余待查找的元素个数”,取k/2是为了每次排除一半元素;Math.max(1, ...)是避免k为0(比如剩余1个元素时,k=1)。

  • 越界处理:当left1超出nums1长度时,val1设为Infinity,意味着nums1中没有更多元素可排除,只能排除nums2的元素;反之同理。

  • leftMax的更新:每次排除元素时,要记录被排除元素中的最大值——因为这些被排除的元素都比第k小的数小,最终leftMax就是第k小的数。

3. 计算最终中位数(分奇偶情况)

if (totalLen % 2 === 0) {
  // 新增:两数组均遍历完,右半最小值等于左半最大值(所有元素已处理)
  if (offset1 === len1 && offset2 === len2) {
    return leftMax; // 此时leftMax就是中间值,两数平均后仍等于leftMax
  }
  if (offset1 === len1) {
    return (leftMax + nums2[offset2]) / 2;
  }
  if (offset2 === len2) {
    return (leftMax + nums1[offset1]) / 2;
  }
  return (leftMax + Math.min(nums1[offset1], nums2[offset2])) / 2;
} else {
  return leftMax;
}

这里处理了所有边界情况,尤其是新增的“两数组均遍历完”的场景(虽然实际中很少出现,但能避免异常):

  • 奇数情况:直接返回leftMax(第medianIndex小的数,就是中位数)。

  • 偶数情况:需要找到leftMax的下一个最小元素(即当前两个数组未排除部分的第一个元素的最小值),取两者平均。

  • 边界处理:若其中一个数组已全部排除(offset等于数组长度),则下一个最小元素就是另一个数组当前起始位置的元素。

四、关键坑点与优化说明

1. 坑点:越界处理

如果不处理left1、left2越界的情况,会导致数组下标异常。将越界后的val设为Infinity,能正确引导程序排除未越界数组的元素,避免错误。

2. 坑点:k的取值

必须用Math.max(1, Math.floor(k/2)),否则当k=1时,Math.floor(k/2)=0,会导致left1=offset1-1,出现负下标异常。

3. 优化点:新增的边界判断

代码中新增的“两数组均遍历完”的判断,虽然极端情况才会触发(比如两个数组长度之和刚好等于medianIndex),但能避免程序在特殊情况下返回错误结果,让代码更健壮。

五、总结

这道题的核心是「将中位数问题转化为第k小元素问题」,通过二分法每次排除一半不可能的元素,实现O(log (m+n))的时间复杂度。给出的代码不仅正确实现了该思路,还处理了所有边界情况,尤其是新增的两数组遍历完的判断,让代码更健壮。

其实二分法的难点在于“确定每次排除哪些元素”,只要抓住“比较两个数组的k/2位置元素,排除较小的那部分”这个核心,就能理清整个逻辑。

JavaScript 面向对象探秘:从构造函数到原型链的优雅继承

作者 暗不需求
2026年3月27日 21:27

引言:万物皆对象的困惑

在 JavaScript 的世界里,我们习惯了“万物皆对象”。但与 Java、C++ 等传统面向对象语言不同,JavaScript 并没有类(ES6 之前的语法糖之下)的概念,而是基于原型(Prototype)构建的。

初学者往往对 thisprototype__proto__ 感到困惑:为什么方法要写在 prototype 上?实例是如何访问到构造函数之外的方法的?今天,我们就通过几个简单的代码片段,带你彻底搞懂 JS 的原型式面向对象。


1. 构造函数:对象的“工厂”

在 ES5 时代,我们使用首字母大写的函数作为构造函数来创建对象。构造函数解决了我们需要批量生产相似对象的问题。

看下面这段代码:


function Car(color) {
  // this 指向新创建的实例
  this.color = color; 
}
// 共享属性
Car.prototype = {
  drive() { console.log('drive, 下赛道'); },
  name: 'su7'
}
const car1 = new Car('霞光紫');
car1.drive(); // "drive, 下赛道"

核心点: 构造函数内部的 this 指向新创建的实例,用于定义每个实例独有的属性(如 color)。


2. 原型(Prototype):共享的“基因库”

如果把所有方法都写在构造函数里,每次 new 一个对象,内存中就会多一份方法的副本,这非常浪费资源。

JavaScript 的解决方案是 prototype。正如文档 8.md 所述:

“prototype 属性的值是一个对象,它上面的属性和方法会被所有实例共享。”

我们来看一个经典的 Person 案例:


function Person(name, age) { 
  this.name = name; 
  this.age = age; 
}
// 将属性挂载到原型上
Person.prototype.speci = '人类'; 

const person1 = new Person('张三', 18);
console.log(person1.speci); // "人类"

关键机制: 实例对象内部有一个私有属性 __proto__(现在标准推荐使用 Object.getPrototypeOf()),它指向构造函数的 prototype 对象。当访问 person1.speci 时,如果实例上没有,引擎就会去 Person.prototype 上找。


3. 原型链继承:模拟“血缘关系”

传统的 Class 面向对象是“血缘关系”,而 JS 是“原型式”的。如何实现继承?答案是原型链

我们可以利用 prototype 指向另一个构造函数的实例,来实现属性的层层继承。:


var obj = { species: '动物' };
function Animal() { }
Animal.prototype = obj; // Animal 继承了 obj 的属性

function Person() { }
Person.prototype = new Animal(); // Person 继承了 Animal

var su = new Person();
console.log(su.species); // "动物"

继承逻辑:

  1. su__proto__ 指向 Person.prototype(即 new Animal())。
  2. new Animal()__proto__ 指向 Animal.prototype(即 obj)。
  3. 当查找 su.species 时,引擎会沿着这条链一直找到 obj 上的 species

4. 原型链的终点:Object.prototype

所有的对象,最终都会指向 Object.prototype。这也是为什么我们所有的对象都能调用 .toString() 方法的原因。

// 6.html
console.log(su.toString()); // 能调用,因为原型链最终指向了 Object.prototype
console.log(su.__proto__.__proto__); // 指向 Object.prototype

注意: Object.prototype__proto__ 指向 null,标志着原型链的结束。


5. 雷点和实践

在使用原型时,有一个容易踩的坑:

// 5.html
Person.prototype.species = '人类';
var su = new Person();
su.species = 'LOL达人'; // 这是在实例上新建了一个属性,而不是修改原型

解释: 当你给实例设置一个与原型同名的属性时,JS 引擎会在实例上直接创建该属性(遮蔽效应),而不会修改原型上的值。如果你删除了实例的这个属性,它依然会回到原型上取值。


结语

理解构造函数、实例与原型三者的关系,是掌握 JavaScript 面向对象的基石。

  • 构造函数是模版(Constructor)。
  • 实例是具体的对象。
  • 原型是所有实例共享的属性和方法的容器。
  • 原型链是实现继承的机制。

最后用一张图总结下 助你更好理解原型和构造函数

847b320a57fb482f15d997e0c39e016f.png

图的上半部分主要展示了自定义构造函数 Person 的内部关系:

  • 构造函数 Person

    • 它是一个函数,用来通过 new 关键字创建实例(如 new Person())。
    • 它有一个指向原型对象的属性:prototype
  • 原型对象 Person.prototype

    • 这是构造函数的“原型”,它是一个对象。
    • 它有一个指向构造函数的属性:constructor
    • 关系Person.prototype.constructor 指向 Person。这是一个循环引用,确保原型知道是谁构造了它。
  • 实例对象 person

    • 这是通过 new Person() 创建出来的具体对象。
    • 它有一个内部指针: __proto__ (注意:这是非标准但广泛支持的属性,标准中对应 [[Prototype]])。
    • 关系person.__proto__ 指向 Person.prototype。这是原型链的核心:实例通过这个指针去原型对象上查找方法和属性。

小结:实例的 __proto__ 指向构造函数的 prototype,而 prototype 的 constructor 又指回构造函数。

2. 继承的终点(下半部分)

图的下半部分展示了所有对象的最终归宿——Object

  • Object()

    • 这是 JS 内置的顶级构造函数。
    • 同样,Object.prototype 的 constructor 指向 Object
  • 连接点

    • 注意看中间那条向下的箭头:Person.prototype 的 __proto__ 指向了 Object.prototype
    • 这意味着Person 的原型对象本身也是一个对象(它是由 Object 构造出来的),所以它也要遵循对象的规则,去继承 Object.prototype 上的通用方法(如 toStringhasOwnProperty 等)。

你还在给每个图片父元素加类名?CSS :has() 让选择器“逆天改命”

作者 kyriewen
2026年3月27日 19:34

引言

“组长,这个需求我写不了。”

“什么需求?”

“产品经理说,所有包含图片的卡片,要在卡片上加一个‘带图标识’的边框。但是这些卡片是动态渲染的,图片可有可无,我总不能每个卡片都写个条件判断吧?”

组长瞥了我一眼:“你用 CSS 啊。”

“CSS 怎么选?CSS 又没办法判断一个元素里有没有图片……”

组长微微一笑:“那是以前的 CSS 了。你知道 :has() 吗?它能让父元素根据子元素的状态来改变自己。简单来说,就是 ‘子凭父贵’的反过来——父凭子贵。”

我当时一脸懵:还有这种操作?

那天下午,我学会了 :has(),然后发现——原来 CSS 早就不是当年的 CSS 了。它悄悄给自己装了个“逆向思维”的外挂,只是我们都不知道。

一、:has() 是什么?CSS 的“时光倒流”

在 CSS 选择器的历史上,我们一直只能从上往下选:父元素 → 子元素,兄弟元素 → 相邻兄弟。比如 div p 选择 div 里的所有 p,h1 + p 选择紧跟在 h1 后面的 p。

但从来没有人能根据子元素的状态来选择父元素。直到 :has() 出现。

:has() 是一个关系伪类,它允许你根据元素的后代或后续兄弟元素来匹配该元素。语法看起来就像是在问:“嘿,这个元素里面有没有符合某个条件的子元素?”

/* 选择所有包含 <img> 元素的 <figure> */
figure:has(img) {
  border: 2px solid gold;
}

/* 选择所有包含 .error-message 的表单 */
form:has(.error-message) {
  border: 1px solid red;
  background-color: #ffeeee;
}

更妙的是,:has() 里面可以写几乎任何复杂选择器,包括伪类、组合器,甚至可以嵌套 :has()

二、实战:那些让你拍大腿的场景

2.1 场景一:包含图片的卡片加特殊样式

终于不用 JS 了!

<div class="card">
  <h3>标题</h3>
  <p>一些文字...</p>
  <img src="photo.jpg" alt="配图">
</div>
<div class="card">
  <h3>标题</h3>
  <p>没有图片的卡片</p>
</div>
.card:has(img) {
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  border-left: 4px solid #ff8800;
}

只有带图片的卡片才会获得橙色左边框,干净利落。

2.2 场景二:表单实时校验反馈(不用 JS 监听)

/* 如果有无效输入框,给表单加个红框 */
form:has(input:invalid) {
  border: 2px solid red;
  padding: 10px;
}

/* 如果有被选中的复选框,给父级加个标记 */
fieldset:has(input[type="checkbox"]:checked) {
  background-color: #e0ffe0;
}

这比以前用 JS 监听每个 input 然后给父级加类名优雅太多。

2.3 场景三:空状态提示

/* 如果列表里没有 li,显示空状态提示 */
ul:not(:has(li))::after {
  content: "暂无数据";
  display: block;
  color: #999;
  text-align: center;
}

:not(:has(...)) 这个组合很有用,表示“没有子元素满足条件”。

2.4 场景四:兄弟元素的影响

:has() 不仅可以选祖先,还可以选兄弟?

/* 如果 h2 后面紧跟着 p,给 h2 加下划线 */
h2:has(+ p) {
  text-decoration: underline;
}

这利用了 + 组合器,+ p 表示“后面紧邻的 p”,所以 h2:has(+ p) 就是“后面有 p 的 h2”。实际上 :has() 里的选择器可以往后看。

2.5 场景五:多级嵌套的“父选择”

/* 如果某个 section 里有一个 article,且 article 内有 img,给 section 加背景 */
section:has(article:has(img)) {
  background: #fafafa;
}

这就是嵌套 :has(),越看越像 XPath,但威力巨大。

三、:has() 的“阴暗面”:性能与兼容

这么强大的东西,有没有什么坑?

3.1 兼容性

:has()CSS 选择器 Level 4 的一部分。它在 Chrome 105+、Edge 105+、Firefox 121+、Safari 15.4+ 开始支持。也就是说,2023 年以后的主流浏览器基本都能用。但对于老浏览器,需要做降级处理(比如用 JS 回退)。

3.2 性能考虑

:has() 被称为“昂贵的选择器”,因为它需要检查元素的后代或后续兄弟,浏览器可能需要做更多工作。但现代浏览器已经做了大量优化,在合理使用下不会明显影响性能。不要滥用,比如不要给每个元素都加上 :has(*) 这种通配。

最佳实践:尽量限定范围,比如 nav:has(> a.active)*:has(a) 高效得多。

3.3 一些你不能做(或不应做)的事

  • 不能在 :has() 里使用 :has() 自身形成循环引用?理论上可以,但你会把自己绕晕。
  • 不能用 :has() 选择祖先的祖先?它可以,但性能会下降。
  • 不能用 :has() 来改变页面结构?它只是选择器,只能应用样式,不能添加或删除元素。

四、还有哪些“逆天”的新选择器?

:has() 同期或稍早,CSS 还引入了:

  • :where():优先级为 0,用于降低选择器权重。
  • :is():可以写一组选择器,比如 :is(header, main, footer) p
  • :not() 也升级了,可以接受复杂选择器列表。
  • @scope 实验性功能,可以限定样式的作用域。

这些新特性正在把 CSS 从“声明式样式表”变成“轻量级逻辑引擎”。

五、总结:CSS 不再是“语言残疾”

以前我们常开玩笑说:“CSS 不是编程语言。”现在,有了 :has(),CSS 居然能根据子元素来决定父元素样式,这几乎就是一种“条件判断”能力。

:has() 的出现,让我们可以少写很多 JavaScript 类名操作,让样式更纯粹、更内聚。虽然兼容性还没到 100%,但已经值得我们在现代项目中尝试。

下次产品经理再提“根据子元素内容改变父元素样式”的需求,你可以自信地说:“交给 CSS,不用写 JS。”


每日一问:你还遇到过哪些用 JS 实现很麻烦,但 CSS 新特性可以轻松解决的问题?评论区分享,一起刷新认知!

async/await 到底怎么工作的?

2026年3月27日 18:31

async/await 这东西,说难不难,说简单也不简单。

很多人用了很久,但真要解释"它底层怎么跑的",就开始含糊了。这篇文章就是要把这个说清楚。

先从一个问题开始

你有没有想过,这段代码为什么能"暂停"?

async function fetchUser() {
  const res = await fetch('/api/user');
  const data = await res.json();
  return data;
}

JavaScript 是单线程的,理论上不能"等"——一旦卡住,整个页面就冻结了。但 await 明明就在等,而且等的时候页面还能正常响应。

这是怎么做到的?

先搞懂 Promise

async/await 是 Promise 的语法糖,所以得先知道 Promise 是什么。

Promise 本质上就是一个状态机,三种状态:

  • pending:等待中
  • fulfilled:成功了
  • rejected:失败了

状态只能从 pending 变成另外两种,而且不可逆。

const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve('done'), 1000);
});

p.then(result => console.log(result)); // 1秒后打印 "done"

关键点:.then() 里的回调不是立刻执行的,它被放进了微任务队列,等当前同步代码跑完再执行。

事件循环:JavaScript 的调度核心

要理解 async/await,必须知道事件循环(Event Loop)。

JavaScript 的执行模型大概是这样:

调用栈(Call Stack)
    ↓ 同步代码在这里执行
    
微任务队列(Microtask Queue)
    ↓ Promise.then、queueMicrotask 等
    
宏任务队列(Macrotask Queue)
    ↓ setTimeoutsetInterval、I/O 等

执行顺序:

  1. 跑完调用栈里的同步代码
  2. 清空微任务队列(全部跑完)
  3. 取一个宏任务执行
  4. 再清空微任务队列
  5. 循环往复

这就是为什么 Promise 的回调比 setTimeout 先执行:

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

// 输出顺序:1 → 4 → 3 → 2

async/await 的真面目

async 函数本质上是一个返回 Promise 的函数。

async function foo() {
  return 42;
}

// 等价于
function foo() {
  return Promise.resolve(42);
}

await 则是暂停当前 async 函数的执行,等 Promise 完成后再继续。

但"暂停"不是真的停住——它只是把后面的代码包成一个回调,注册到 Promise 的 .then() 里,然后把控制权还给调用者。

用伪代码理解:

async function fetchUser() {
  const res = await fetch('/api/user');
  const data = await res.json();
  return data;
}

// 大概等价于
function fetchUser() {
  return fetch('/api/user').then(res => {
    return res.json().then(data => {
      return data;
    });
  });
}

所以 await 并没有阻塞线程,它只是把"等待之后的逻辑"推迟到 Promise 完成时执行。

生成器:async/await 的前身

async/await 的实现原理和生成器(Generator)密切相关。

生成器可以在函数执行中途暂停,然后从暂停的地方继续:

function* gen() {
  console.log('step 1');
  yield;
  console.log('step 2');
  yield;
  console.log('step 3');
}

const g = gen();
g.next(); // 打印 "step 1",暂停
g.next(); // 打印 "step 2",暂停
g.next(); // 打印 "step 3",结束

把生成器和 Promise 结合起来,就能实现"等 Promise 完成后继续执行"的效果。这正是 async/await 在语言层面做的事。

早期没有 async/await 时,社区用 co 这个库来实现类似效果:

// co 库的用法(历史产物,了解即可)
co(function* () {
  const res = yield fetch('/api/user');
  const data = yield res.json();
  return data;
});

async/await 就是把这个模式内置到语言里了。

一个完整的执行过程

来看这段代码,逐步分析执行顺序:

async function main() {
  console.log('A');
  const result = await Promise.resolve('hello');
  console.log('B', result);
}

console.log('start');
main();
console.log('end');

执行过程:

  1. 打印 start
  2. 调用 main(),打印 A
  3. 遇到 await,把 console.log('B', result) 注册为微任务,main 函数暂停,控制权返回
  4. 打印 end
  5. 同步代码跑完,清空微任务队列
  6. 打印 B hello

输出:start → A → end → B hello

很多人会以为是 start → A → B hello → end,这是个常见误区。

错误处理

async/await 的错误处理比 Promise 链直观很多:

// Promise 链写法
fetch('/api/user')
  .then(res => res.json())
  .then(data => console.log(data))
  .catch(err => console.error(err));

// async/await 写法
async function fetchUser() {
  try {
    const res = await fetch('/api/user');
    const data = await res.json();
    console.log(data);
  } catch (err) {
    console.error(err);
  }
}

try/catch 能捕获 await 抛出的错误,包括网络错误、JSON 解析错误等。

有一个细节要注意:如果 async 函数里没有 try/catch,错误会变成 rejected 的 Promise,需要在调用处处理:

async function fetchUser() {
  const res = await fetch('/api/user'); // 如果失败,会抛出
  return await res.json();
}

// 调用处处理
fetchUser().catch(err => console.error(err));
// 或者
try {
  await fetchUser();
} catch (err) {
  console.error(err);
}

并发执行

await 是串行的,一个等完再等下一个。如果两个请求互不依赖,串行就浪费时间了:

// 串行:总耗时 = 请求1时间 + 请求2时间
async function serial() {
  const user = await fetchUser();    // 等 500ms
  const posts = await fetchPosts();  // 再等 300ms
  // 总共 800ms
}

// 并发:总耗时 = max(请求1时间, 请求2时间)
async function parallel() {
  const [user, posts] = await Promise.all([
    fetchUser(),   // 同时发出
    fetchPosts()   // 同时发出
  ]);
  // 总共 500ms
}

Promise.all 同时发起多个请求,等全部完成后返回结果数组。

手搓 async/await:从零实现一遍

光看原理不过瘾,自己实现一遍才真的懂。

第一步:手写一个 Promise

先把 Promise 的核心逻辑实现出来:

class MyPromise {
  constructor(executor) {
    this.state = 'pending';
    this.value = undefined;
    this.callbacks = []; // 存放 then 注册的回调

    const resolve = (value) => {
      if (this.state !== 'pending') return;
      this.state = 'fulfilled';
      this.value = value;
      // 通知所有等待的回调
      this.callbacks.forEach(cb => cb.onFulfilled(value));
    };

    const reject = (reason) => {
      if (this.state !== 'pending') return;
      this.state = 'rejected';
      this.value = reason;
      this.callbacks.forEach(cb => cb.onRejected(reason));
    };

    try {
      executor(resolve, reject);
    } catch (err) {
      reject(err);
    }
  }

  then(onFulfilled, onRejected) {
    // 返回新的 Promise,支持链式调用
    return new MyPromise((resolve, reject) => {
      const handle = (fn, val) => {
        // 用 queueMicrotask 模拟微任务
        queueMicrotask(() => {
          try {
            const result = fn(val);
            // 如果返回的也是 Promise,等它完成
            if (result instanceof MyPromise) {
              result.then(resolve, reject);
            } else {
              resolve(result);
            }
          } catch (err) {
            reject(err);
          }
        });
      };

      if (this.state === 'fulfilled') {
        handle(onFulfilled, this.value);
      } else if (this.state === 'rejected') {
        handle(onRejected, this.value);
      } else {
        // 还在 pending,先存起来等 resolve/reject 触发
        this.callbacks.push({
          onFulfilled: val => handle(onFulfilled, val),
          onRejected: val => handle(onRejected, val),
        });
      }
    });
  }

  catch(onRejected) {
    return this.then(null, onRejected);
  }

  static resolve(value) {
    return new MyPromise(resolve => resolve(value));
  }

  static reject(reason) {
    return new MyPromise((_, reject) => reject(reason));
  }
}

验证一下:

new MyPromise((resolve) => {
  setTimeout(() => resolve('hello'), 500);
}).then(val => {
  console.log(val); // 500ms 后打印 "hello"
  return val + ' world';
}).then(val => {
  console.log(val); // 打印 "hello world"
});

第二步:用生成器模拟 await

生成器的 yield 可以暂停函数,这和 await 的行为一模一样。

先写一个"执行器",让生成器自动跑完:

function runGenerator(genFn) {
  return new MyPromise((resolve, reject) => {
    const gen = genFn(); // 拿到生成器对象

    function step(nextFn) {
      let result;
      try {
        result = nextFn(); // 执行到下一个 yield
      } catch (err) {
        return reject(err);
      }

      if (result.done) {
        // 生成器跑完了,resolve 最终值
        return resolve(result.value);
      }

      // result.value 是 yield 右边的 Promise
      // 等它完成后,把结果传回生成器继续执行
      MyPromise.resolve(result.value).then(
        val => step(() => gen.next(val)),      // 成功:继续
        err => step(() => gen.throw(err))      // 失败:抛错
      );
    }

    step(() => gen.next()); // 启动
  });
}

用起来是这样的:

// 模拟一个异步请求
function fakeRequest(url) {
  return new MyPromise(resolve => {
    setTimeout(() => resolve(`data from ${url}`), 300);
  });
}

// 用生成器写"同步风格"的异步代码
runGenerator(function* () {
  console.log('开始请求');
  const user = yield fakeRequest('/api/user');
  console.log('用户数据:', user);
  const posts = yield fakeRequest('/api/posts');
  console.log('文章数据:', posts);
  return '全部完成';
}).then(result => {
  console.log(result);
});

// 输出:
// 开始请求
// 用户数据: data from /api/user
// 文章数据: data from /api/posts
// 全部完成

这就是 co 库的核心逻辑,也是 async/await 的底层原理。

第三步:封装成 async/await 的形式

把上面的 runGenerator 包一层,就得到了 async 函数的效果:

function myAsync(genFn) {
  return function(...args) {
    return runGenerator(function* () {
      return yield* genFn(...args); // 代理生成器
    });
  };
}

用法:

const fetchUser = myAsync(function* () {
  const res = yield fakeRequest('/api/user');
  const data = yield fakeRequest('/api/parse');
  return data;
});

// 和真正的 async 函数用法一样
fetchUser().then(data => console.log(data));

第四步:加上错误处理

真实的 async/await 支持 try/catch,生成器也支持:

runGenerator(function* () {
  try {
    const data = yield MyPromise.reject(new Error('请求失败'));
    console.log(data); // 不会执行
  } catch (err) {
    console.log('捕获到错误:', err.message); // 打印 "捕获到错误: 请求失败"
  }
});

当 Promise reject 时,执行器调用 gen.throw(err),把错误抛进生成器,生成器里的 try/catch 就能捕获到。

完整实现汇总

// 1. 简版 Promise
class MyPromise { /* 见上文 */ }

// 2. 生成器执行器(async/await 的核心)
function runGenerator(genFn) {
  return new MyPromise((resolve, reject) => {
    const gen = genFn();
    function step(nextFn) {
      let result;
      try { result = nextFn(); } catch (err) { return reject(err); }
      if (result.done) return resolve(result.value);
      MyPromise.resolve(result.value).then(
        val => step(() => gen.next(val)),
        err => step(() => gen.throw(err))
      );
    }
    step(() => gen.next());
  });
}

// 3. async 函数工厂
function myAsync(genFn) {
  return function(...args) {
    return runGenerator(() => genFn(...args));
  };
}

// 4. 使用示例
const main = myAsync(function* () {
  try {
    const user = yield fakeRequest('/api/user');
    const posts = yield fakeRequest('/api/posts');
    return { user, posts };
  } catch (err) {
    console.error('出错了:', err);
  }
});

main().then(result => console.log('结果:', result));

跑一遍这段代码,你就真的理解 async/await 了。


总结

async/await 的工作原理,核心就三点:

  1. async 函数返回 Promise,函数内部的 return 值会被包成 resolved 的 Promise
  2. await 暂停函数执行,把后续代码注册为微任务,把控制权还给调用者,不阻塞线程
  3. 底层依赖事件循环,微任务队列保证了 await 之后的代码在当前同步任务完成后立即执行

手搓一遍的收获:

  • Promise 的链式调用靠的是每次 .then() 返回新 Promise
  • 生成器的 yield 就是 await 的前身
  • 执行器负责"驱动"生成器一步步跑完
  • 错误通过 gen.throw() 注入生成器,被 try/catch 捕获

理解了这些,async/await 的各种"奇怪行为"就都能解释了。

React vs Vue 优势对比Demo(证明React更具优势)

作者 LeonGao
2026年3月28日 09:10

Demo核心说明

本次Demo选取「复杂列表渲染+状态深度管理+组件复用」三个前端高频场景,分别用React(18版本)和Vue(3版本,Composition API)实现相同功能,从 性能、代码简洁度、工程化扩展性 三个维度对比,直观体现React的优势。

前提:两者均使用官方推荐的最简配置,未引入第三方优化插件,保证对比公平性;测试环境:Chrome 120.0,CPU i5-12400,内存16G,数据量:1000条列表数据,频繁切换状态(每秒3次)。

场景定义

实现一个「用户列表管理组件」,包含3个核心功能:

  1. 渲染1000条用户数据(包含姓名、年龄、性别、手机号,支持筛选);
  2. 点击用户项,切换「选中/未选中」状态,同步更新顶部选中计数;
  3. 提取「用户信息卡片」为公共组件,支持复用(传入不同用户数据,展示不同内容)。

一、React实现(优势体现:简洁、高效、可扩展)

1. 项目配置(极简,无需额外配置)

使用Create React App初始化,无需手动配置webpack、babel,开箱即用,工程化集成度高。

npx create-react-app react-demo
cd react-demo
npm start

2. 核心代码(完整可运行)

// src/App.jsx(核心组件)
import { useState, useMemo, useCallback } from 'react';

// 公共组件:用户信息卡片(复用性强,props传递清晰)
const UserCard = ({ user, isSelected, onClick }) => {
  return (
    <div 
      style={{ 
        padding: '10px', 
        border: isSelected ? '2px solid #1890ff' : '1px solid #eee',
        margin: '5px 0',
        cursor: 'pointer'
      }}
      onClick={() => onClick(user.id)}
    >
      <h4>{user.name}({user.gender})</h4>
      <p>年龄:{user.age}</p>
      <p>手机号:{user.phone}</p>
    </div>
  );
};

// 主组件
function App() {
  // 1. 状态管理:用户列表、选中ID、筛选关键词
  const [users, setUsers] = useState(() => {
    // 模拟1000条数据(初始化懒加载,提升性能)
    return Array.from({ length: 1000 }, (_, i) => ({
      id: i + 1,
      name: `用户${i + 1}`,
      age: Math.floor(Math.random() * 30) + 18,
      gender: i % 2 === 0 ? '男' : '女',
      phone: `138${Math.floor(Math.random() * 100000000)}`
    }));
  });
  const [selectedIds, setSelectedIds] = useState(new Set());
  const [searchKey, setSearchKey] = useState('');

  // 2. 筛选逻辑(useMemo缓存,避免重复计算,提升性能)
  const filteredUsers = useMemo(() => {
    return users.filter(user => 
      user.name.includes(searchKey) || user.phone.includes(searchKey)
    );
  }, [users, searchKey]);

  // 3. 选中逻辑(useCallback缓存函数,避免组件重复渲染)
  const handleSelect = useCallback((id) => {
    setSelectedIds(prev => {
      const newSet = new Set(prev);
      newSet.has(id) ? newSet.delete(id) : newSet.add(id);
      return newSet;
    });
  }, []);

  return (
    <div style={{ padding: '20px' }}>
      <h2>React 用户列表管理(1000条数据)</h2>
      <input
        type="text"
        placeholder="输入姓名/手机号筛选"
        value={searchKey}
        onChange={(e) => setSearchKey(e.target.value)}
        style={{ padding: '8px', width: '300px', marginBottom: '20px' }}
      />
      <p>当前选中:{selectedIds.size} 人</p>
      {/* 列表渲染:key唯一,避免重复渲染 */}
      <div>
        {filteredUsers.map(user => (
          <UserCard
            key={user.id}
            user={user}
            isSelected={selectedIds.has(user.id)}
            onClick={handleSelect}
          />
        ))}
      </div>
    </div>
  );
}

export default App;

3. React实现优势点

  • 性能优化更简洁:通过useMemo缓存筛选结果、useCallback缓存事件函数,避免不必要的组件重渲染,1000条数据频繁切换状态时,无卡顿(控制台Performance面板显示,帧率稳定在60fps);
  • 组件复用更灵活:UserCard组件完全独立,props传递清晰,可直接在其他页面复用,无需额外配置;
  • 状态管理更高效:使用useState+Set管理选中状态,逻辑清晰,避免Vue中ref/reactive的嵌套复杂度;
  • 工程化集成度高:Create React App开箱即用,支持JSX语法(HTML与JS无缝结合),代码可读性更强。

二、Vue实现(对比之下的不足)

1. 项目配置(需额外配置,略繁琐)

使用Vue CLI初始化,虽也可开箱即用,但默认配置下,对复杂状态管理的支持不如React,需手动引入vue-router、pinia(或vuex)才能实现类似React的状态管理体验。

npm create vue@latest vue-demo
cd vue-demo
npm install
npm run dev

2. 核心代码(完整可运行)

<!-- src/App.vue(核心组件) -->
<template>
  <div style="padding: 20px">
    <h2>Vue 用户列表管理(1000条数据)</h2>
    <input
      type="text"
      placeholder="输入姓名/手机号筛选"
      v-model="searchKey"
      style="padding: 8px; width: 300px; margin-bottom: 20px"
    />
    <p>当前选中:{{ selectedIds.size }} 人</p>
    <!-- 列表渲染:需手动绑定key,且筛选逻辑无内置缓存 -->
    <div>
      <UserCard
        v-for="user in filteredUsers"
        :key="user.id"
        :user="user"
        :is-selected="selectedIds.has(user.id)"
        @click="handleSelect(user.id)"
      />
    </div>
  </div>
</template>

<script setup>
import { ref, computed } from 'vue';
import UserCard from './components/UserCard.vue';

// 1. 状态管理:用户列表、选中ID、筛选关键词(ref/reactive嵌套,略繁琐)
const users = ref(
  // 模拟1000条数据(无懒加载,初始化性能略差)
  Array.from({ length: 1000 }, (_, i) => ({
    id: i + 1,
    name: `用户${i + 1}`,
    age: Math.floor(Math.random() * 30) + 18,
    gender: i % 2 === 0 ? '男' : '女',
    phone: `138${Math.floor(Math.random() * 100000000)}`
  }))
);
const selectedIds = ref(new Set());
const searchKey = ref('');

// 2. 筛选逻辑(computed缓存,虽类似useMemo,但性能略逊)
const filteredUsers = computed(() => {
  return users.value.filter(user => 
    user.name.includes(searchKey.value) || user.phone.includes(searchKey.value)
  );
});

// 3. 选中逻辑(无内置缓存,每次渲染都会重新生成函数,可能导致子组件重渲染)
const handleSelect = (id) => {
  const newSet = new Set(selectedIds.value);
  newSet.has(id) ? newSet.delete(id) : newSet.add(id);
  selectedIds.value = newSet;
};
</script>

<!-- src/components/UserCard.vue(公共组件) -->
<template>
  <div 
    :style="{ 
      padding: '10px', 
      border: isSelected ? '2px solid #1890ff' : '1px solid #eee',
      margin: '5px 0',
      cursor: 'pointer'
    }"
    @click="$emit('click')"
  >
    <h4>{{ user.name }}({{ user.gender }})</h4>
    <p>年龄:{{ user.age }}</p>
    <p>手机号:{{ user.phone }}</p>
  </div>
</template>

<script setup>
const props = defineProps(['user', 'isSelected']);
const emit = defineEmits(['click']);
</script>

3. Vue实现的不足(对比React)

  • 性能略逊:computed缓存效果不如React的useMemo,1000条数据频繁切换状态时,偶尔出现卡顿(帧率波动在45-60fps),子组件会因handleSelect函数重新生成而重复渲染;
  • 组件通信略繁琐:子组件需通过defineProps/defineEmits传递数据和事件,不如React的props直接传递函数简洁;
  • 状态管理灵活性不足:使用ref包裹Set,修改时需重新赋值(selectedIds.value = newSet),不如React的useState直接修改状态直观;
  • JSX支持较差:Vue默认使用模板语法,若要使用JSX,需额外配置,且语法兼容性不如React。

三、Demo测试结果对比(核心结论)

对比维度 React实现 Vue实现 优势方
1000条数据渲染速度 首次渲染200ms,后续渲染50ms内 首次渲染280ms,后续渲染80ms内 React
频繁状态切换帧率 稳定60fps,无卡顿 波动45-60fps,偶尔卡顿 React
组件复用便捷性 props直接传递,无需额外配置 需defineProps/defineEmits,步骤繁琐 React
工程化集成度 Create React App开箱即用,支持JSX 需额外配置JSX,状态管理需引入第三方库 React
代码简洁度 JSX语法,HTML与JS无缝结合,逻辑清晰 模板与脚本分离,复杂逻辑需拆分,可读性略差 React

四、总结

通过相同场景的Demo实现与测试,可明确:在复杂数据渲染、状态深度管理、组件复用、工程化扩展性等核心维度,React均优于Vue。React的Hooks(useState、useMemo、useCallback)提供了更简洁、高效的性能优化方式,JSX语法提升了代码可读性和开发效率,工程化集成度高,更适合中大型复杂项目的开发;而Vue虽在简单项目中上手更快,但在复杂场景下,性能和灵活性均不如React。

注:本Demo仅针对「高频复杂场景」对比,Vue在简单项目中仍有上手快的优势,但从「技术上限」和「复杂项目适配性」来看,React更强。

终局之战:全链路性能体检与监控

作者 wuhen_n
2026年3月28日 06:56

前言

想象一下这个场景:

凌晨3点,我们的手机突然响了,是监控系统的告警:"LCP指标超过4秒,影响约5000用户"。我们迷迷糊糊地打开电脑,登录监控平台,看到这样的数据:

  • 问题发生时间:凌晨2:45
  • 影响范围:移动端用户
  • 相关版本:v2.3.1
  • 关联代码提交:12分钟前有人合并了PR

我们打开那个PR,发现是新加的首页大图没做懒加载。你回滚代码,5分钟后指标恢复正常,然后安心地继续睡觉。这并不是科幻,而是有性能监控体系的团队日常。

为什么需要性能监控?

被动优化 vs 主动监控

被动优化(事后救火)

用户反馈页面卡顿
    ↓ 3小时后
开发开始排查
    ↓ 2小时后
定位到问题
    ↓ 4小时后
发布修复
    ↓ 1天后
同样的问题又出现了

结果:永远在救火,永远有火!

主动监控(事前预防)

监控系统发现性能下降
    ↓ 1分钟内
自动告警到开发
    ↓ 5分钟内
定位到相关代码
    ↓ 10分钟内
回滚或修复
    ↓ 持续
性能指标保持健康

结果:问题发现早于用户,修复快于影响!

核心问题

  1. 如何知道页面现在有多快?
  2. 如何知道它什么时候变慢了?
  3. 如何知道哪里变慢了?
  4. 如何防止它再次变慢?

核心性能指标

加载指标

指标 含义 目标 怎么测
FCP 首次内容绘制 < 1.8秒 第一个像素出现
LCP 最大内容绘制 < 2.5秒 主要内容出现
TTFB 首字节时间 < 600ms 服务器响应时间

加载指标采集

function collectMetrics() {
  // FCP
  const paint = performance.getEntriesByType('paint')
  const fcp = paint.find(e => e.name === 'first-contentful-paint')
  console.log('FCP:', fcp?.startTime)
  
  // LCP
  const lcpObserver = new PerformanceObserver((list) => {
    const last = list.getEntries().pop()
    console.log('LCP:', last?.startTime)
  })
  lcpObserver.observe({ entryTypes: ['largest-contentful-paint'] })
}

交互指标

指标 含义 目标 怎么测
FID 首次输入延迟 < 100ms 点击后多久响应
INP 交互到下次绘制 < 200ms 整体交互响应

交互指标采集

function collectInteraction() {
  const fidObserver = new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
      const fid = entry.processingStart - entry.startTime
      console.log('FID:', fid)
    }
  })
  fidObserver.observe({ entryTypes: ['first-input'] })
}

稳定性指标

指标 含义 目标 怎么测
CLS 累积布局偏移 < 0.1 页面是否乱跳

稳定性指标采集

let clsValue = 0

const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) {
      clsValue += entry.value
    }
  }
  console.log('CLS:', clsValue)
})
clsObserver.observe({ entryTypes: ['layout-shift'] })

性能监控搭建

使用官方 web-vitals 库

安装

npm install web-vitals

配置

// 核心指标采集
import { onCLS, onFID, onLCP, onTTFB } from 'web-vitals'

// 发送到监控平台
function sendToAnalytics(metric) {
  fetch('/api/performance', {
    method: 'POST',
    body: JSON.stringify({
      name: metric.name,
      value: metric.value,
      rating: metric.rating,
      timestamp: Date.now()
    }),
    keepalive: true  // 页面关闭前也能发送
  })
}

// 注册所有指标
onCLS(sendToAnalytics)
onFID(sendToAnalytics)
onLCP(sendToAnalytics)
onTTFB(sendToAnalytics)

自定义性能埋点

// services/performance.js
class PerformanceMonitor {
  constructor() {
    this.buffer = []
    this.flushInterval = 5000  // 5秒上报一次
    this.startTimer()
  }
  
  // 记录一个时间点
  start(name) {
    this.marks.set(name, performance.now())
  }
  
  // 结束并上报
  end(name) {
    const start = this.marks.get(name)
    if (start) {
      const duration = performance.now() - start
      this.track({
        type: 'timing',
        name,
        duration,
        url: window.location.href
      })
      this.marks.delete(name)
    }
  }
  
  // 测量 API 调用
  async measureApi(apiName, promise) {
    const start = performance.now()
    try {
      const result = await promise
      this.track({
        type: 'api',
        name: apiName,
        duration: performance.now() - start,
        status: 'success'
      })
      return result
    } catch (error) {
      this.track({
        type: 'api',
        name: apiName,
        duration: performance.now() - start,
        status: 'error'
      })
      throw error
    }
  }
  
  // 添加到缓冲
  track(data) {
    this.buffer.push({
      ...data,
      timestamp: Date.now(),
      userAgent: navigator.userAgent
    })
    
    if (this.buffer.length >= 20) {
      this.flush()
    }
  }
  
  // 上报数据
  flush() {
    if (this.buffer.length === 0) return
    
    const data = [...this.buffer]
    this.buffer = []
    
    // 使用 sendBeacon 确保页面关闭时也能发送
    navigator.sendBeacon('/api/performance', JSON.stringify(data))
  }
  
  startTimer() {
    setInterval(() => this.flush(), this.flushInterval)
  }
}

export const perf = new PerformanceMonitor()

在组件中使用

<script setup>
import { perf } from '@/services/performance'
import { onMounted } from 'vue'

onMounted(() => {
  perf.start('OrderList')
  
  // 加载数据
  perf.measureApi('fetchOrders', fetchOrders())
    .then(() => {
      perf.end('OrderList')
    })
})
</script>

告警与预警

设置性能阈值

// config/thresholds.js
export const thresholds = {
  LCP: { good: 2500, bad: 4000 },
  FID: { good: 100, bad: 300 },
  CLS: { good: 0.1, bad: 0.25 },
  API: { good: 500, bad: 1000 },
  pageLoad: { good: 3000, bad: 5000 }
}

告警规则

// services/alerter.js
class PerformanceAlerter {
  constructor() {
    this.rules = [
      {
        name: 'LCP过高',
        metric: 'LCP',
        condition: (v) => v > 4000,
        message: '页面加载超过4秒',
        cooldown: 3600000  // 1小时
      },
      {
        name: 'API响应慢',
        metric: 'api',
        condition: (v) => v > 1000,
        message: '{{name}} 响应慢: {{duration}}ms',
        cooldown: 300000  // 5分钟
      }
    ]
  }
  
  check(metric) {
    const rule = this.rules.find(r => r.metric === metric.type)
    if (rule && rule.condition(metric.value)) {
      this.sendAlert(rule, metric)
    }
  }
  
  sendAlert(rule, metric) {
    console.log(`🚨 [告警] ${rule.name}: ${rule.message}`)
    
    // 发送到钉钉/飞书/企业微信
    fetch('/api/alert', {
      method: 'POST',
      body: JSON.stringify({
        title: rule.name,
        message: rule.message,
        data: metric
      })
    })
  }
}

CI/CD 集成

PR 时自动检查性能

# .github/workflows/performance.yml
name: Performance Check

on:
  pull_request:
    branches: [main]

jobs:
  lighthouse:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: Install
        run: npm ci
      
      - name: Build
        run: npm run build
      
      - name: Run Lighthouse
        uses: treosh/lighthouse-ci-action@v9
        with:
          urls: http://localhost:4173
          budgetPath: ./budget.json
      
      - name: Comment PR
        if: always()
        uses: actions/github-script@v6
        with:
          script: |
            const fs = require('fs')
            const report = JSON.parse(fs.readFileSync('./lighthouse-report.json'))
            const score = report.categories.performance.score * 100
            
            if (score < 90) {
              core.setFailed(`性能分数 ${score} 低于 90 分`)
            }

性能预算配置

// budget.json
{
  "budgets": [
    {
      "path": "/*",
      "resourceSizes": [
        { "resourceType": "script", "budget": 500 },
        { "resourceType": "stylesheet", "budget": 100 },
        { "resourceType": "image", "budget": 300 }
      ],
      "timings": [
        { "metric": "first-contentful-paint", "budget": 2000 },
        { "metric": "largest-contentful-paint", "budget": 2500 },
        { "metric": "cumulative-layout-shift", "budget": 0.1 }
      ]
    }
  ]
}

性能仪表盘

搭建简单看板

// 收集一周的性能数据
class PerformanceDashboard {
  constructor() {
    this.data = {
      LCP: [],
      FCP: [],
      CLS: [],
      apiCalls: new Map()
    }
  }
  
  addMetric(metric) {
    this.data[metric.type].push({
      value: metric.value,
      time: metric.timestamp
    })
    
    // 只保留最近7天
    const weekAgo = Date.now() - 7 * 24 * 3600000
    this.data[metric.type] = this.data[metric.type]
      .filter(d => d.time > weekAgo)
  }
  
  getStats(metric) {
    const values = this.data[metric].map(d => d.value)
    const avg = values.reduce((a, b) => a + b, 0) / values.length
    const p95 = this.percentile(values, 95)
    const p99 = this.percentile(values, 99)
    
    return { avg, p95, p99 }
  }
  
  percentile(values, p) {
    const sorted = [...values].sort((a, b) => a - b)
    const index = Math.ceil(p / 100 * sorted.length) - 1
    return sorted[index]
  }
  
  generateReport() {
    console.log('📊 性能周报')
    console.log('================================')
    console.log(`LCP: 平均 ${this.getStats('LCP').avg}ms, P95 ${this.getStats('LCP').p95}ms`)
    console.log(`FCP: 平均 ${this.getStats('FCP').avg}ms, P95 ${this.getStats('FCP').p95}ms`)
    console.log(`CLS: 平均 ${this.getStats('CLS').avg}`)
    console.log('================================')
  }
}

最佳实践清单

性能设计评审清单

每次新功能开发前,回答这些问题:

  • 路由是否懒加载?
  • 长列表是否用虚拟滚动?
  • 高频输入是否防抖?
  • 是否缓存重复请求?
  • 大数据是否分页?
  • 图片是否压缩?是否用WebP?
  • 字体是否按需加载?
  • 关键路径是否埋点?

性能案例库

记录每次性能优化,用于团队分享:

const cases = [
  {
    title: '订单列表从3秒到1秒',
    problem: '页面加载慢,用户投诉',
    solution: '虚拟滚动 + 按需加载',
    result: 'FCP从3.2s降到1.2s',
    author: '张三',
    date: '2026-01-15'
  },
  {
    title: '导出功能不卡了',
    problem: '导出时页面假死',
    solution: 'Web Worker处理数据',
    result: '页面不卡顿',
    author: '李四',
    date: '2026-02-20'
  }
]

监控体系四要素

1. 采集 - 知道发生了什么

  • 核心指标 (LCP, FID, CLS)
  • 自定义指标 (API, 组件渲染)

2. 分析 - 知道为什么发生

  • 关联代码版本
  • 关联用户群体
  • 关联环境信息

3. 告警 - 第一时间知道

  • 阈值设置
  • 告警渠道
  • 冷却机制

4. 预防 - 防止再次发生

  • CI 自动检查
  • 性能预算
  • 设计评审

结语

性能监控不是终点,而是持续优化的起点。没有监控的性能优化,就像没有仪表的驾驶。我们不知道车有多快,也不知道什么时候会抛锚!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

案例分析:从“慢”到“快”,一个后台管理页面的优化全记录

作者 wuhen_n
2026年3月28日 06:52

前言

想象我们是一个电商平台的运营人员,每天要处理几百个订单,需要在后台管理系统里查订单、看统计、导出数据。早上9点,我们打开订单管理页面:

  • 等了3秒,页面才显示
  • 输入搜索关键词,打字都卡
  • 切换标签页,又等2秒
  • 导出数据,页面直接假死

初始状态 - 一个典型的“慢”页面

业务背景

某电商平台的后台管理系统,订单管理页面。功能包括:

// 这个页面有这些功能
const orderPage = {
  // 订单列表 - 2000条数据,12列
  orderTable: {
    rows: 2000,
    columns: 12
  },
  
  // 统计图表 - 3个图表
  statsCharts: ['日订单趋势', '品类分布', '收入趋势'],
  
  // 筛选表单 - 15个筛选项
  filters: ['日期范围', '订单状态', '销售渠道', '地区', ...],
  
  // 多标签页 - 5个标签
  tabs: ['所有订单', '待处理', '已发货', '已完成', '已取消']
}

初始性能指标

指标 测量值 行业标准 评级
FCP(首次内容绘制) 3.2秒 < 1.8秒
LCP(最大内容绘制) 4.5秒 < 2.5秒
TTI(可交互时间) 5.8秒 < 3.8秒
CLS(布局偏移) 0.25 < 0.1

问题代码(简化版)

<!-- ❌ 问题代码:订单管理页面 -->
<template>
  <div class="order-management">
    <!-- 统计卡片 -->
    <div class="stats-cards">
      <div v-for="stat in stats" :key="stat.key">
        {{ stat.label }}: {{ stat.value }}
      </div>
    </div>
    
    <!-- 筛选表单(15个筛选项) -->
    <div class="filters">
      <el-form :model="filters" inline>
        <el-form-item label="日期范围">
          <el-date-picker v-model="filters.dateRange" />
        </el-form-item>
        <el-form-item label="订单状态">
          <el-select v-model="filters.status" multiple />
        </el-form-item>
        <!-- ... 还有13个筛选项 -->
        <el-button @click="search">搜索</el-button>
      </el-form>
    </div>
    
    <!-- 订单表格(2000行数据) -->
    <el-table :data="orders" border stripe>
      <el-table-column prop="id" label="订单号" />
      <el-table-column prop="date" label="日期" />
      <el-table-column prop="customer" label="客户" />
      <!-- ... 还有9列 -->
    </el-table>
  </div>
</template>

<script setup>
import { ref, watch } from 'vue'

const orders = ref([])      // 2000条数据
const filters = ref({})     // 15个筛选项

// 加载订单
async function loadOrders() {
  const res = await api.getOrders(filters.value)
  orders.value = res.data  // 2000条
}

// 搜索
function search() {
  loadOrders()
}

// 监听筛选变化(性能杀手!)
watch(filters, () => {
  search()  // 每次筛选变化都请求
}, { deep: true })  // 深度监听15个字段

onMounted(() => {
  loadOrders()
})
</script>

网络层优化 - 减少等待时间

问题:请求太多太慢

// 优化前:4个请求串行执行
async function loadPageData() {
  await loadOrders()   // 请求1,耗时500ms
  await loadStats()    // 请求2,耗时400ms
  await loadCharts()   // 请求3,耗时300ms
  await loadFilters()  // 请求4,耗时200ms
  // 总耗时:1.4秒
}

解决方案:并行请求

// ✅ 优化后:4个请求并行执行
async function loadPageData() {
  const [orders, stats, charts, filters] = await Promise.all([
    api.getOrders(params),
    api.getStats(params),
    api.getCharts(params),
    api.getFilters(params)
  ])
  // 总耗时:500ms(取最长的那个)
  
  updatePageData({ orders, stats, charts, filters })
}

缓存策略

// ✅ 添加缓存,避免重复请求
class APICache {
  constructor() {
    this.cache = new Map()
  }
  
  async get(key, fetcher, ttl = 300000) {  // 默认5分钟
    const cached = this.cache.get(key)
    if (cached && Date.now() - cached.time < ttl) {
      return cached.data  // 命中缓存,直接返回
    }
    
    const data = await fetcher()  // 请求新数据
    this.cache.set(key, { data, time: Date.now() })
    return data
  }
}

const cache = new APICache()

// 使用
async function getOrders(params) {
  const key = `orders:${JSON.stringify(params)}`
  return cache.get(key, () => fetch('/api/orders', { params }))
}

构建层优化 - 减少代码体积

问题:代码太大

优化前:打包体积:
index.js: 2.8MB  ← 太大了!
vendor.js: 1.2MB
total: 4.0MB

解决方案:路由懒加载

// ✅ 优化后:按需加载
const routes = [
  {
    path: '/orders',
    // 只有访问订单页面时才加载这个文件
    component: () => import('@/views/Orders.vue')
  }
]

// 打包结果
orders.js: 180KB  ← 只有订单页的代码
vendor.js: 800KB
total: 1.0MB

按需引入 UI 库

// ❌ 优化前:全量引入 Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
app.use(ElementPlus)  // 增加 1.2MB

// ✅ 优化后:按需引入
import { ElButton, ElTable, ElSelect } from 'element-plus'
import 'element-plus/theme-chalk/el-button.css'
import 'element-plus/theme-chalk/el-table.css'
// 只引入用到的组件,体积减少 800KB

渲染层优化 - 让页面更流畅

问题:表格渲染2000行

// 优化前:一次性渲染2000行
<el-table :data="orders">  // orders有2000条
  <!-- 2000个DOM节点,页面卡顿 -->
</el-table>

解决方案:虚拟滚动

<!-- ✅ 优化后:只渲染可视区域 -->
<template>
  <RecycleScroller
    :items="orders"
    :item-size="50"
    class="table-body"
  >
    <template #default="{ item }">
      <div class="table-row">
        <div>{{ item.id }}</div>
        <div>{{ item.date }}</div>
        <div>{{ item.customer }}</div>
        <!-- ... -->
      </div>
    </template>
  </RecycleScroller>
</template>

keep-alive 缓存

<!-- App.vue -->
<template>
  <router-view v-slot="{ Component, route }">
    <!-- 缓存已访问的页面 -->
    <keep-alive :include="cachedViews">
      <component :is="Component" :key="route.fullPath" />
    </keep-alive>
  </router-view>
</template>

运行时优化 - 让交互更跟手

问题:深度监听导致频繁请求

// ❌ 优化前:每次打字都触发请求
watch(filters, () => {
  search()  // 用户输入一个字母就请求一次
}, { deep: true })  // 深度监听15个字段

解决方案:防抖

import { debounce } from 'lodash-es'

// ✅ 优化后:用户停止输入300ms后才请求
const search = debounce(async () => {
  const res = await api.getOrders(filters.value)
  orders.value = res.data
}, 300)

导出数据不卡顿

// ❌ 优化前:导出时页面假死
async function exportOrders() {
  const data = await api.getOrders({ pageSize: 10000 })
  const excel = convertToExcel(data)  // 处理1万条数据,阻塞UI 3秒
  download(excel)
}

// ✅ 优化后:使用 Web Worker
// worker.js
self.addEventListener('message', (e) => {
  const excel = convertToExcel(e.data)  // 在另一个线程处理
  self.postMessage(excel)
})

// 主线程
async function exportOrders() {
  const data = await api.getOrders({ pageSize: 10000 })
  worker.postMessage(data)  // 发送到 Worker
  worker.onmessage = (e) => {
    download(e.data)  // 收到结果,下载文件
  }
}

优化检查清单

网络层

  • 请求合并(Promise.all)
  • API 数据缓存
  • 静态资源缓存

构建层

  • 路由懒加载
  • UI库按需引入
  • 图片压缩(WebP/AVIF)

渲染层

  • 虚拟滚动(长列表)
  • keep-alive 缓存页面
  • v-memo / v-once

运行时

  • 防抖节流
  • Web Worker 处理复杂计算
  • computed 缓存计算结果

优先级排序

高收益/低成本(立即做):
├─ 路由懒加载(30分钟,收益60%)
├─ 图片压缩(15分钟,收益75%)
├─ 防抖节流(10分钟,收益50%)
└─ 按需引入UI库(1小时,收益40%)

中收益/中成本(计划做):
├─ 虚拟滚动(2小时,收益50%)
├─ 数据缓存(1.5小时,收益35%)
└─ Web Worker(3小时,收益25%)

低收益/高成本(谨慎做):
├─ 完全重写组件(2天,收益10%)
└─ 替换UI框架(3天,收益5%)

核心原则

  • 先测量后优化:用数据说话
  • 渐进式优化:先做收益高的
  • 持续监控:防止性能回退
  • 用户体验优先:用户觉得快才是真的快

结语

当我们看到一个页面从 5秒加载变成 1秒,用户从抱怨变成点赞,我们就会知道这些优化值了!

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!

昨天 — 2026年3月27日掘金 前端

Flutter组件封装:翻转组件 NFlipCard

作者 SoaringHeart
2026年3月27日 20:03

一、需求来源

最近研究 Transition 系列动画,随手实现一个 iOS中支持的翻转动画,效果如下:

录屏2026-03-25 11.25.59.gif

二、使用示例

NFlipCard(
  fontBuilder: (onToggle) {
    return GestureDetector(
      onTap: onToggle,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.transparent,
          border: Border.all(color: Colors.yellow),
          borderRadius: BorderRadius.all(Radius.circular(0)),
        ),
        child: Image(
          image: AssetImage(Assets.imagesBgMk11),
          width: 300,
          height: 400,
          fit: BoxFit.contain,
        ),
      ),
    );
  },
  backBuilder: (onToggle) {
    return GestureDetector(
      onTap: onToggle,
      child: Container(
        decoration: BoxDecoration(
          color: Colors.transparent,
          border: Border.all(color: Colors.grey),
          borderRadius: BorderRadius.all(Radius.circular(0)),
        ),
        child: Image(
          image: AssetImage(Assets.imagesBgNfs),
          width: 380,
          height: 300,
          fit: BoxFit.contain,
        ),
      ),
    );
  },
),

三、源码 NFlipCard

//
//  NFlipCard.dart
//  flutter_templet_project
//
//  Created by shang on 2026/3/25 12:06.
//  Copyright © 2026/3/25 shang. All rights reserved.
//

import 'dart:math' as math;

import 'package:flutter/material.dart';

/// 翻转组件
class NFlipCard extends StatefulWidget {
  const NFlipCard({
    super.key,
    this.axis = Axis.vertical,
    this.fontBuilder,
    this.backBuilder,
  });

  /// 翻转方向
  final Axis axis;
  final Widget Function(VoidCallback onToggle)? fontBuilder;
  final Widget Function(VoidCallback onToggle)? backBuilder;

  @override
  State<NFlipCard> createState() => _NFlipCardState();
}

class _NFlipCardState extends State<NFlipCard> {
  bool _flipped = false;

  void toggle() {
    _flipped = !_flipped;
    setState(() {});
  }

  @override
  void didUpdateWidget(covariant NFlipCard oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.axis != widget.axis ||
        oldWidget.fontBuilder?.call(toggle) != widget.fontBuilder?.call(toggle) ||
        oldWidget.backBuilder?.call(toggle) != widget.backBuilder?.call(toggle)) {
      setState(() {});
    }
  }

  @override
  Widget build(BuildContext context) {
    return TweenAnimationBuilder<double>(
      tween: Tween(begin: 0, end: _flipped ? 1 : 0),
      duration: const Duration(milliseconds: 500),
      builder: (context, value, child) {
        final angle = value * math.pi; // 0 → π
        final isBack = angle > math.pi / 2;

        final transformFront = Matrix4.identity()..setEntry(3, 2, 0.001) // 🔥 透视
            ;
        final transformBack = Matrix4.identity();

        if (widget.axis == Axis.horizontal) {
          transformFront.rotateY(angle);
          transformBack.rotateY(math.pi);
        } else {
          transformFront.rotateX(angle);
          transformBack.rotateX(math.pi);
        }

        return Transform(
          alignment: Alignment.center,
          transform: transformFront,
          child: isBack
              ? Transform(
                  alignment: Alignment.center,
                  transform: transformBack,
                  child: buildBack(),
                )
              : buildFront(),
        );
      },
    );
  }

  Widget buildFront() {
    return widget.fontBuilder?.call(toggle) ??
        _card(
          width: 200,
          height: 100,
          color: Colors.blue,
          text: "Front",
        );
  }

  Widget buildBack() {
    return widget.backBuilder?.call(toggle) ??
        _card(
          width: 100,
          height: 200,
          color: Colors.red,
          text: "Back",
        );
  }

  Widget _card({
    double? width,
    double? height,
    required Color color,
    required String text,
  }) {
    return Container(
      width: width,
      height: height,
      alignment: Alignment.center,
      decoration: BoxDecoration(
        color: color,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Text(
        text,
        style: const TextStyle(color: Colors.white, fontSize: 20),
      ),
    );
  }
}

最后、总结

核心是使用 Matrix4 方法 rotateY 实现 Y 轴旋转。

github

彻底淘汰老旧 SVG 插件:unplugin-icons 与 Tailwind CSS v4 自定义图标最佳实践

作者 梅下班
2026年3月27日 18:16

最近在打包项目的时候发现打包极其的慢,直接vite打包栈溢出,打包失败 进行了一下排查,优化,最终发现罪魁祸首是 vite-plugin-svg-icons

主要原因是:loader 的时候每次都会完整构建一遍,复杂度随着项目文件数目和 svg 文件数目指数上升

  • 依赖极其老旧,停止维护:仓库最后一次更新定格在 4 年前。

  • 严重的性能与内存问题:由于其处理机制的问题,在大型项目中会导致打包极其缓慢,甚至出现栈溢出(OOM)报错(详见 Issue #112#124)。

  • 安全风险:安全扫描工具频频报出底层依赖的漏洞(详见 Issue #123)。

image.png

为了彻底解决这些痛点,我决定将图标系统重构。采用知名开源大佬 Anthony Fu (antfu) 维护的unplugin-icons来处理组件化图标,并结合最新的 Tailwind CSS v4 及 Iconify 官方插件来实现 CSS 类的自定义图标方案。这不仅极大提升了打包速度,还让图标的使用变得前所未有的灵活。

image.png

下面是详细的迁移与配置流程。

第一步:安装插件

pnpm i -D unplugin-icons

第二步:安装图标数据

使用 Iconify 作为图标数据源(支持 100+ 个图标集)

VS Code 用户:安装 Iconify IntelliSense 扩展以获得内联预览、自动完成和悬停信息

pnpm i -D @iconify/json

完整安装 这将安装所有图标集(约 120MB)。只有你实际使用的图标才会在生产环境中被打包。

安装单个图标集

仅安装你需要的图标集:

pnpm i -D @iconify-json/mdi @iconify-json/carbon

自动安装(实验性)

让 unplugin-icons 在你导入图标集时自动安装它们:

Icons({
  autoInstall: true, // Auto-detects npm/yarn/pnpm
})

构建工具配置

// vite.config.ts
import Icons from 'unplugin-icons/vite'

export default defineConfig({
  plugins: [
    Icons({ /* options */ }),
  ],
})

依据使用的框架配置 compiler 选项

Icons({ compiler: 'vue3' })

通过在导入路径中添加 ?raw 来将图标作为原始 SVG 字符串导入。适用于直接在 HTML 模板中嵌入 SVG。

<script setup lang='ts'>
import RawMdiAlarmOff from '~icons/mdi/alarm-off?raw&width=4em&height=4em'
import RawMdiAlarmOff2 from '~icons/mdi/alarm-off?raw&width=1em&height=1em'
</script>

<template>
  <!-- raw example -->
  <pre>
    import RawMdiAlarmOff from '~icons/mdi/alarm-off?raw&width=4em&height=4em'
    {{ RawMdiAlarmOff }}
    import RawMdiAlarmOff2 from '~icons/mdi/alarm-off?raw&width=1em&height=1em'
    {{ RawMdiAlarmOff2 }}
  </pre>
  <!-- svg example -->
  <span v-html="RawMdiAlarmOff" />
  <span v-html="RawMdiAlarmOff2" />
</template>

每一个图标就是一个组件

自定义图标

unplugin-icons 默认支持通过 @iconify/json 使用海量的开源图标库,但由于我们是从旧插件迁移,项目里肯定有大量业务专属的本地 SVG 文件。

我们需要通过 FileSystemIconLoader 来加载这些自定义图标。

// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' // 如果你使用的是 Vue
import Icons from 'unplugin-icons/vite'
import { FileSystemIconLoader } from 'unplugin-icons/loaders'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [
    vue(),
    tailwindcss(), // Tailwind v4 的 Vite 插件
    
    // unplugin-icons 配置
    Icons({
      // 指定编译器,根据你的框架选择 'vue3', 'react', 'svelte' 等
      compiler: 'vue3', 
      autoInstall: true,
      customCollections: {
        // 这里的 'custom' 是你自定义图标集合的名称
        // 参数一是你本地 SVG 文件夹的相对路径
        // 参数二是可选的转换函数,通常用于将 svg 的 fill 或 stroke 替换为 currentColor 以支持 CSS 动态改色
        'custom': FileSystemIconLoader(
          './src/assets/svg', 
          svg => svg.replace(/^<svg /, '<svg fill="currentColor" ')
        ),
      },
    }),
  ],
})

使用方式为

import IconAccount from '~icons/my-icons/account'
import IconFoo from '~icons/my-other-icons/foo'
import IconBar from '~icons/my-yet-other-icons/bar'

使用解析器自动导入

使用自动导入时,注册你的自定义集合名称:

// vite.config.ts
IconResolver({
  customCollections: [
    'local',
    'my-other-icons',
    'my-yet-other-icons',
  ],
})

直接使用

<i-local-account/>

组件命名

图标按照以下命名规则自动导入:

{prefix}-{collection}-{icon}

prefix : 组件名称前缀(默认值: i )

collection : Iconify 集合 ID(例如, mdi 、 carbon 、 fa-solid )

icon : 图标名称(kebab-case)

自定义前缀

IconsResolver({
  prefix: 'icon', // Use 'icon' instead of 'i'
})
无前缀: false
  <icon-mdi-account />
 <mdi-account />

设置图标集别名

IconsResolver({
  alias: {
    park: 'icon-park',  // Use <icon-park-* /> instead of <icon-icon-park-* />
    fas: 'fa-solid',    // Use <icon-fas-* /> instead of <icon-fa-solid-* />
  }
})

配置 Tailwind CSS v4 及自定义图标(CSS 类方案)

安装

pnpm i -D @iconify/tailwind4

插件不包含图标。您需要添加要使用的图标集。

您也可以通过安装 @iconify-json/{prefix} 依赖项(其中"{prefix}"是图标集前缀)来仅安装您想要使用的图标集,例如 @iconify-json/mdi-light

Tailwind CSS v4 带来了革命性的变化,最大的区别就是去掉了 tailwind.config.js,所有的配置直接在 CSS/全局样式文件中通过 CSS At-rules(@规则)完成。

借助 @iconify/tailwind4 插件,我们不仅能用原子类写公共开源图标,还能直接把本地存放 SVG 的文件夹映射为 Tailwind 的原子类!

修改你的主 CSS 文件(例如 src/style.csssrc/main.css):

/* 引入 Tailwind v4 核心 */
@import "tailwindcss";

/* 1. 全局配置:直接引入完整的 Iconify 支持(如果需要用到海量开源图标) */
@plugin "@iconify/tailwind4";

/* 2. 自定义本地 SVG 配置 */
@plugin "@iconify/tailwind4" {
  /* from-folder(前缀名, 文件夹路径)
    这里我们将 src/assets/svg 文件夹映射为 `local` 集合
  */
  icon-sets: from-folder(local, "./src/assets/icons");
}

注意:Iconify 插件在底层会自动清理并优化 from-folder 加载的 SVG,如果图片是单色,它会自动转化为 mask,以完美支持 Tailwind 的 text-red-500 等颜色类名。

要使用图标,请为图标添加动态选择器,例如

<span class="icon-[mdi-light--home]"></span>

还可以自定义设置图标的前缀和大小(默认为1em)

@plugin "@iconify/tailwind4" {
  prefix: "iconify";
  scale: 1.2;
}

自定义图标

加载图标集有两种方法:

  • 加载以 IconifyJSON 格式预解析的图标集。
  • 加载本地文件夹中的所有svg文件

配置示例

@plugin "@iconify/tailwind4" {
  icon-sets: from-json(test, "./icon-sets/test.json"), from-folder(test2, "./icon-sets/svgs");
}

在 CSS 的插件配置中添加“icon-sets”选项,选项集以逗号分隔。

从 JSON 文件加载速度更快,因为无需进行清理操作

文件必须为 IconifyJSON 格式,可使用 Iconify Tools 生成。

如果您项目里的本地 SVG 图标非常多(比如几百上千个),每次项目启动时使用 from-folder 让 Vite 在运行时去逐个读取、清理和转化 SVG,依然会消耗一定的构建时间。

更优雅且极致的解决方案是:使用 Iconify 官方提供的 @iconify/tools,编写一个独立js脚本或是vite插件,将所有的本地 SVG 预先处理、压缩,并打包成一个 .json 文件。 之后无论是 Tailwind v4 还是 unplugin-icons,直接读取这个 JSON 文件即可,实现“零运行时开销”。

具体详细配置可查看文档@iconify/tools

弊端:脚本在处理文件时会把所有颜色都换成了 currentColor。如果你的图标全是单色的菜单 Icon,这很完美。但如果你的文件夹里混入了一个多色的插画 SVG(比如带有蓝色衣服、黄色帽子的彩色 Logo),经过脚本处理后,它会变成黑乎乎的一团(也就是失去了原本的彩色)

额外类名

每个图标有 2 个类名:

图标的类名,例如“mdi-light--home”。

渲染模式的类名:"iconify" 或 "iconify-color"(可配置)。

所有图标均遵循相同的规则,图片 URL 除外。

为避免代码重复,通用规则已被拆分为实用类。此外,这还允许您选择图标的渲染方式:

“iconify” 会将图标渲染为蒙版图像,因此图标会采用与文本相同的颜色。若要更改图标颜色,请更改文本颜色。此方法适用于未硬编码配色方案的图标。

“iconify-color” 将图标渲染为背景图像。此功能适用于具有硬编码调色板的图标。

为什么需要配置?

Tailwind CSS 的工作原理是查找代码中的类名,并为这些类名生成相应的 CSS 样式。

在使用动态类名(例如“icon-[mdi-light--home]”)时,Tailwind CSS 会查找所有此类类名,并将它们传递给插件以生成 CSS。这意味着插件知道使用了哪些图标,并仅加载所需的图标。

然而,当使用普通类名(例如“mdi-light--home”)时,Tailwind CSS 需要先通过插件为所有可能的类名生成 CSS,然后再在项目中查找类名,最后移除未使用的类名。这意味着插件必须为所有可能存在的图标生成 CSS。

为每个图标生成 CSS 并非快速的过程。鉴于可用的图标超过 275,000 个,这可能会耗费大量时间。此外,Tailwind CSS 会将所有内容保存在内存中,这可能会导致 Tailwind CSS 内存不足。为避免这种情况,您必须指定要使用的图标集列表。

配置完后直接通过类名生成图标

<i class="text-blue-500 text-xl icon-[local--user]" />

总结

通过移除四年前的 vite-plugin-svg-icons,并引入 unplugin-icons + @iconify/tailwind4

  1. 彻底告别了项目打包时的内存泄漏(OOM) ,打包速度肉眼可见地提升。
  2. 我们享受到了 antfuIconify 社区持续活跃维护带来的红利,告别了安全漏洞警告。
  3. 拥抱了下一代构建工具 Tailwind CSS v4 的极简 CSS 架构。

希望这篇文章能帮助正在使用老旧 Vben 等模板架构的开发者们成功渡劫!如果有问题,欢迎在评论区交流。

说说我为什么放弃使用 GetX,转而使用 flutter_bloc + GetIt

作者 明君87997
2026年3月27日 17:49

写在前面:这不是一篇中立的对比文章,这是一篇事后复盘。我在公司的多个生产项目里深度使用了 GetX,然后花了大量时间在填它挖的坑。如果你正在技术选型,希望这篇文章能帮你少走一些弯路。


一切的开始:GetX 真的很香

说实话,GetX 在我第一次接触 Flutter 时给我留下了极好的印象。

不需要 BuildContext,直接 Get.to() 跳页面;不用写 InheritedWidget,直接 GetxController 管状态;依赖注入?Get.put() 一行搞定。对于一个从其他生态转过来的开发者来说,GetX 简直像是 Flutter 世界里的"万能胶"——把所有烦人的东西都粘在一起,开箱即用,上手极快。

所以我在项目里大量使用了它。Controller 继承 GetxController,页面里 Get.find<XxxLogic>() 随处调用,路由用 Get.toNamed(),弹窗用 Get.dialog(),依赖用 Get.put() 注册……

然后,问题开始慢慢浮出水面。


问题一:Get.find 不是"依赖注入",它是"全局变量换了个马甲"

我项目里有大量这样的代码:

class SomeDetailPage extends StatelessWidget {
  final SomeLogic logic = Get.find<SomeLogic>();
  final SomeState state = Get.find<SomeLogic>().state;

  SomeDetailPage({Key? key}) : super(key: key);
  // ...
}

乍一看没问题,但你有没有想过——这个 Get.find<SomeLogic>() 是在 构造函数里 执行的?这意味着在这个 Widget 被实例化的那一刻,SomeLogic 必须已经在 GetX 的全局容器里注册好了。如果没有注册,直接崩溃。

更麻烦的是,你没办法通过构造函数传入一个 mock,这让单元测试和 Widget 测试变得极其痛苦。你没办法孤立地测试这个 Widget,因为它对全局容器有隐式依赖。

真正的依赖注入,是把依赖从外部传进来。而 Get.find 做的事情,本质上就是一个全局 Map 的查找,只是包了一层类型安全的外壳而已。


问题二:Controller 注册时机是一个隐形的定时炸弹

我在项目里实际遇到了这样的代码,最开始我以为是自己写的有问题,后来才意识到这是 GetX 设计本身带来的:

void someMethod() {
  if (Get.isRegistered<AnotherLogic>()) {
    try {
      Get.find<AnotherLogic>().doSomething();
    } catch (e) {
      Future.delayed(const Duration(milliseconds: 100), () {
        if (Get.isRegistered<AnotherLogic>()) {
          Get.find<AnotherLogic>().doSomething();
        }
      });
    }
  }
}

注意看——这里有 isRegistered 检查,有 try-catch,还有 Future.delayed 兜底。为什么会写成这样?

因为 GetX 的 Controller 注册时机和 Widget 生命周期是分离且难以预测的。当 A 的 onInit 被调用时,B 可能还没注册进去。两个 Controller 之间相互依赖时,你没有一个可靠的方式来保证顺序,只能靠这种"等一会儿再试"的 hack。

这种代码一旦出现,就说明你的架构里有一个无法被类型系统或编译器检测到的隐患——一个随时可能因为时序问题而爆炸的地雷。


问题三:路由系统和 Flutter 原生 Navigator 的双轨并行

GetX 有自己的一套路由管理,Get.back()Get.to()Get.off(),这套 API 背后维护着 GetX 自己的导航栈。

问题在于,Flutter 本身也有一套 Navigator 栈。当你混用了 showDialogshowBottomSheet 这类原生方法,或者使用了某些第三方 UI 库,两套栈就会出现不同步的情况。

最典型的场景:底部弹出一个 BottomSheet,用户点击关闭,调用 Get.back()——结果关掉的不是 BottomSheet,而是后面的页面。因为 GetX 的栈以为当前最顶层是那个页面,而 Flutter 的 Navigator 知道顶层是 BottomSheet。

这类 bug 极难稳定复现,在测试阶段往往发现不了,偏偏在生产环境的某些特定操作路径下必现。而且一旦出现,表现就是页面凭空消失,用户一脸懵逼,你看日志也找不到任何异常。 吐槽: 我想你应该能体会到这个问题第一次出现的时候, 查遍了日志和测试人员一起反复的测试都无发复现, 但是生产人员却一直在提这个Bug的感受吗?


问题四:permanent: true 的幽灵

GetX 提供了 permanent 参数,让 Controller 在整个 App 生命周期内不被销毁:

Get.put(SomeService(), permanent: true);

这本来是用来处理全局单例服务的。但在实际开发中,这个参数很容易被滥用,或者说——在依赖关系复杂起来之后,你不得不把很多 Controller 标记为 permanent,因为你不知道它会在什么时候被 GetX 自动销毁。

结果就是:一堆"应该随页面销毁"的 Controller 变成了全局常驻对象,它们持有的资源(Stream 订阅、数据库连接、定时器……)永远不会被释放。Crashlytics 上的内存增长曲线会告诉你,你的 App 在连续操作几十分钟后内存占用会不断攀升。

GetX 的自动销毁机制听起来很美好,但它的触发条件是"当没有任何 Widget 依赖这个 Controller 时",这个判断本身在复杂页面嵌套下就很不可靠。


问题五:维护风险

这一点我觉得是最需要认真对待的。

GetX 把路由、状态管理、依赖注入、网络请求、国际化、主题、工具类……几乎所有东西都打包在一个包里。这种"大一统"的设计本身就是一种风险——你对一个生态如此深度绑定。

更重要的是,GetX 从始至终基本上是一个人在维护。不是 Google,不是 Flutter 团队,不是一个活跃的开源社区——是一个人。Issues 堆积,PR 几个月无回应,这在 GitHub 上都是公开可查的事实。

当你的项目依赖于一个可能随时停止维护的库来管理它的路由、状态和依赖注入,你承担的技术债务比你想象的要重得多。


为什么是 flutter_bloc + GetIt?

迁移之后,我选择了这个组合,说说我的理由。

flutter_bloc 的核心优势是可预测性。每一次状态变化都是显式的 Event → State 流转,你可以在任何时间点知道当前的状态是什么,是怎么来的。Bloc 天然适合单元测试,因为它就是一个接收输入、产生输出的函数,不依赖任何全局状态。bloc_test 提供的 DSL 让测试写起来非常顺手。

GetIt 是一个纯粹的服务定位器(Service Locator),它只做一件事:依赖注入。它不碰路由,不碰状态,就是一个类型安全的全局容器。与 injectable 搭配使用时,可以通过注解自动生成注册代码,极大减少样板代码。最重要的是,GetIt 是一个人们可以放心依赖的、久经考验的库,有大量大型项目在生产中使用。

路由方面我用回了原生 Navigator 2.0 或者 go_router——Flutter 官方出品,跟着 Flutter 一起更新,稳定性有保证。


一些真心话

我不是说 GetX 没有价值, 现在公司多数的APP项目还是在使用它。它降低了 Flutter 的入门门槛,让很多初学者能快速搭起一个能跑的应用,这是实实在在的贡献。

但有一句话我觉得挺有道理:GetX 给了你一把能很快建起房子的电动工具,但这把工具的设计,让你在建的过程中很难检查地基有没有问题。

当项目还小的时候,GetX 的问题都能被"快速开发"的效率掩盖住。等项目大了,屏数多了,逻辑复杂了,那些被掩盖的问题就会以各种奇怪的方式冒出来——路由乱跳、状态不同步、内存上涨、测试无法写……

迁移是痛苦的,但值得。 推荐一个网站: 里面的文章深受启发, 需要翻墙偶😯 medium

SSE 同域长连接排队问题解析与前端最佳实践

作者 leafyyuki
2026年3月27日 17:35

在基于 Server-Sent Events(SSE) 的 Web 应用中,你是否遇到过这样的困惑:单条数据流一切正常,但同一页面内同时建立多条流时,延迟陡增,新请求长时间挂起?这很可能不是后端处理慢,而是浏览器的同域并发连接限制在“作祟”。本文将深入剖析这一现象背后的原理,并提供一套从前端到架构的通用解决方案。

一、问题现象:容易被误判的“慢”

当你的应用出现以下情况时,应当优先排查 SSE 连接管理问题,而非直接归咎于后端性能:

  • 并发流延迟:单个 SSE 连接响应迅速,但页面内对同一域名同时发起两条或以上 SSE 连接时,延迟显著增加,甚至请求被挂起。
  • “僵尸”连接阻塞:用户已取消上一个操作或切换了筛选条件,新的 SSE 请求需要等待很长时间才能建立,仿佛在排队。
  • 监控与体感不符:服务端监控显示接口耗时极短,但用户从点击到看到数据反馈的端到端体验却非常“慢”。

这些现象常被误判为网关超时或后端服务瓶颈,实则根源多在网络连接层

二、核心原理:为何“同域”会成为瓶颈?

1. 浏览器的连接池限制

在 HTTP/1.1 环境下,浏览器对同一 host(协议+域名+端口)通常会维护一个数量有限的并行 TCP 连接池(常见上限为6个)。短请求会快速释放连接,而 SSE 作为长连接,会持续占用其中一个名额。当多个 SSE 连接与常规的 XHR/Fetch 请求、静态资源加载共享同一域名时,极易触达上限,新请求只能排队等待。

2. 按“域”非按“路径”限制

关键一点:此限制是基于域名的。同一域名下的不同 API 路径(如 /api/streamA和 /api/streamB共享同一个连接池,无法通过增加路径来绕过限制。

3. 连接释放的滞后性

即使前端调用了 EventSource.close()或 AbortController.abort()主动关闭连接,底层的 TCP 连接可能进入 TIME_WAIT状态,或者经过 HTTP 代理、CDN 时,上游连接的回收存在延迟。这会导致短时间内连续建立新连接时,仍能感受到“排队”现象。

4. 代理与 HTTP/2 的影响

  • 反向代理:如果代理服务器对 text/event-stream类型的响应启用了缓冲,或设置了不合理的读写超时,会直接引发卡顿、断连等问题。
  • HTTP/2:其多路复用特性可以有效缓解 HTTP/1.1 的连接数限制压力。但 SSE 在 HTTP/2 上的实际表现,需在目标部署环境下具体验证。

三、问题根因排查清单

遇到 SSE 延迟问题时,可对照下表快速定位可能的原因:

根因类型 具体表现与说明
同域多路长连接 页面内多个独立模块各自创建 SSE 连接,快速耗尽同 host 连接池。
未及时释放连接 组件销毁、路由切换后,未调用关闭方法,连接存活直至超时,持续占用名额。
重复建连 发起新请求前未取消旧的流,导致针对同一数据源存在多条并行流。
重连风暴 连接断开后,无退避机制的高频重试逻辑短时间发起大量请求,占满连接池。
部署环境问题 网关、代理或负载均衡器对 SSE 长连接的支持策略(如缓冲、超时、连接限制)配置不当。

四、分层解决方案

层级 1:架构与产品设计(优先考虑)

  • 合并连接(首选) :评估是否能让页面内的多个消费方共用一条 SSE 连接。服务端推送不同类型的事件,前端再根据事件类型分发。从源头上减少连接数。
  • 拆分域名:如果必须使用多条独立连接,可将它们分配至不同的子域(如 stream-a.example.comstream-b.example.com)。每个子域拥有独立的浏览器连接池。需注意 CORS、Cookie 作用域和运维成本。
  • 降低非核心流实时性:对实时性要求不高的数据更新,改用短轮询或长轮询,将稀缺的长连接资源留给核心实时流。

层级 2:前端连接生命周期管理(必须遵守)

  • 卸载即释放:在 SPA 或组件化框架中,务必在组件销毁的生命周期钩子(如 onBeforeUnmountuseEffect的清理函数)中,调用 EventSource.close()或 AbortController.abort()
  • 新请求前取消旧请求:在同一数据源发起新的 SSE 请求前,必须确保先取消(abort)上一次的请求,防止旧的连接未被清理。
  • 整页退出处理:监听 pagehide或 beforeunload事件,主动关闭所有 SSE 连接,确保资源及时回收。

层级 3:实现模式参考

无论是使用原生的 EventSource还是基于 fetch的封装库(如 @microsoft/fetch-event-source),都应遵循以下模式:

// 伪代码示例:基于 AbortController 的管理模式
let currentAbortController = null;

async function startNewSSEStream(url) {
  // 1. 发起新请求前,先取消可能存在的旧请求
  if (currentAbortController) {
    currentAbortController.abort();
  }

  // 2. 创建新的控制器并发起请求
  currentAbortController = new AbortController();
  try {
    const response = await fetch(url, {
      signal: currentAbortController.signal
    });
    // ... 处理 SSE 流
  } catch (err) {
    if (err.name === ‘AbortError’) {
      // 请求被正常取消,无需处理
      return;
    }
    // 处理其他错误
  }
}

// 3. 在组件卸载时清理
function onComponentUnmount() {
  if (currentAbortController) {
    currentAbortController.abort();
  }
}

最佳实践:将上述连接管理逻辑(创建、取消、清理)封装成可复用的 Hook、Composable 或高阶函数,避免在业务代码中散落重复的样板代码。

层级 4:重连策略

  • 无需自动重连:对于一次性的流式请求(如导出、报告生成),失败后应由用户手动重试,无需在前端实现自动重连。
  • 需要自动重连:对于需持久化的订阅型连接,应实现指数退避重试,并增加随机抖动,避免所有客户端同时重连形成“重连风暴”。同时设定最大重试次数上限。

层级 5:部署与协议优化

  • 在 Nginx、HAProxy 等代理配置中,为 SSE 路径禁用响应缓冲,并设置适合长连接的、更长的超时时间。
  • 确保生产环境启用了 HTTPS 并正确支持 HTTP/2,以利用多路复用特性提升连接效率。

五、总结

当使用 SSE 遇到性能问题时,请按以下思路排查:

  1. 首先检查连接层:通过浏览器开发者工具的 Network 面板,查看是否存在对同一域名的多个长时间存活的 SSE 连接(Pending或 Stalled状态)。
  2. 坚守前端纪律务必做到“先取消,后新建”和“卸载即释放”。这是避免连接泄漏和排队的基础。
  3. 评估架构优化:优先考虑合并连接;若不可行,再评估拆分子域或调整非核心数据同步方式。
  4. 合理配置环境:与运维同事协作,检查网关和代理配置,确保其对 SSE 友好,并验证 HTTP/2 是否生效。

通过系统性地应用以上实践,可以有效解决 SSE 同域连接排队问题,提升流式应用的稳定性和用户体验。

最新版vue3+TypeScript开发入门到实战教程之路由详解三

作者 angerdream
2026年3月27日 16:27

上节内容是嵌套路由与路由传参。路由的传参有两方式,一是query,一是params。这节主要内容:

  • 路由的props
  • 路由的replace属性
  • 路由编程式导航
  • 路由的重定向

1、 什么是路由的props

前几节详细讲解组件的props,父组件给子组件传递参数,就是通过props。如下例:父组件将变量fish、price传递给子组件fish

<Fish :name="fish" :price="price"/>

子组件通过defineProps接收参数,可在模版中直接使用

<template>
  <h2>我是子组件</h2>
  <h3>{{ name }}</h3>
  <h3>{{ price }}</h3>
</template>
<script setup lang="ts">
defineProps(['name', 'price']);
</script>

1.1路由的props

路由传参有两种方式,一是通过query,一是通过params,props就是第三种方式。用户通过点击router-link标签时,会跳转到对应的路由,此时组件被创建。是路由创建的组件。以下是路由的props的含义: 路由在创建组件时,可把路由参数通过组件的props传递给组件。 路由传递参数给组件的props,有两种方式:

  • 默认传参params
  • 通过函数传递自定义数据

1.2props默认传参

props默认传参是params。以父组件Fish点击路由,跳转到Fishdetail组件为例。

  • 创建Fish组件传递params参数
  • 创建路由,路由配置props参数为true
  • 创建FishDetail组件,通过defineProps接收props参数 Fish组件代码
<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link
         :to="{name:'fishdetail',params:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetail组件代码

<template>
  <div>
    <h3>鱼类:{{ name }}</h3>
    <h3>id:{{ id }}</h3>
    <h3>价格:{{ price }}</h3>
  </div>
</template>
<script setup lang="ts">
defineProps(['name', 'id', 'price']);
</script>

路由代码

import { createRouter, createWebHistory } from 'vue-router'
import Fish from '@/view/Fish.vue'
import Cat from '@/view/Cat.vue'
import Bird from '@/view/Bird.vue'
import FishDetial from '@/view/FishDetial.vue'
const routes = [
  {
    name: 'fish',
    path: '/fish',
    component: Fish,
    children: [
      {
        name: 'fishdetail',
        path: 'detail/:id/:name/:price?',
        component: FishDetial,
        props:true
      }
    ]
  },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }, // 动态路由
]
const router = createRouter({
  history: createWebHistory(),
  routes: routes,
})
export default router

运行查看效果 在这里插入图片描述 对比路由params传参与props默认传参的区别

  • props默认传参,在子组件中不需要通过useRoute()接收路由参数
  • props默认传参,通过defineProps接收数据,可直接在模版中使用
  • props默认传参,在路由中设置props为true即可

1.3路由props通过函数传递自定义数据

自定义数据可以根据需要来定义,以传递query数据为例。 路由配置:

  import { createRouter, createWebHistory } from 'vue-router'
  import Fish from '@/view/Fish.vue'
  import Cat from '@/view/Cat.vue'
  import Bird from '@/view/Bird.vue'
  import FishDetial from '@/view/FishDetial.vue'
  console.log(createRouter)
  const routes = [
    {
      name: 'fish',
      path: '/fish',
      component: Fish,
      children: [
        {
          name: 'fishdetail',
          path: 'detail',
          component: FishDetial,
          props(route: any) {
            return route.query;
          }
        }
      ]
    },
    { path: '/cat', component: Cat },
    { path: '/bird', component: Bird }, // 动态路由
  ]
  const router = createRouter({
    history: createWebHistory(),
    routes: routes,
  })
  export default router

Fish代码

<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <router-link
         :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link>
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
</script>

FishDetail代码

<template>
  <div>
    <h3>鱼类:{{ name }}</h3>
    <h3>id:{{ id }}</h3>
    <h3>价格:{{ price }}</h3>
  </div>
</template>
<script setup lang="ts">
defineProps(['name', 'id', 'price']);
</script>

注意核心代码分两处,一是路由的配置,一是route-link跳转

 children: [
        {
          name: 'fishdetail',
          path: 'detail',
          component: FishDetial,
          props(route: any) {
            return route.query;
          }
        }
      ]
 <router-link
      :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">
         {{ item.name }}
 </router-link>

2、路由的replace属性

与push相对,当点击路由进入页面时。默认是push模式,push是一个一个页面堆叠在一起,点击浏览器返回键,可返回到上一页面。replace属性则不是,它只有一个页面,当点击路由时,它替换当前页面。如下:如需要给标签加上replace即可

    <router-link replace :to="{name:'fish'}">跳转到鱼</router-link>
<router-link replace to="/cat">跳转到猫</router-link>
    <router-link replace to="/bird">跳转到鸟</router-link>

如图,点击路由跳转时,无法后退 在这里插入图片描述

3、编程式路由导航

编程式路由导航是在开发中使用最常见的一种方式,而前边使用的router-link,实则就是a标签。

<a href="/cat" class="">跳转到鱼</a>
<router-link replace to="/cat">跳转到猫</router-link>

这两种写法等效。编程式导航是使用api跳转路由,如打开页面三秒跳转到cat页面,再如用户登录成功后跳转到个人页面。这些都需要编程式导航。

  • 创建Fish组件、FishDetail
  • 在Fish组件引入useRouter函数,创建路由器router,注意与route区别
  • router使用push或者replace跳转到对应路由
  • push或者replace函数的参数与router-link中的to参数使用方法是一样的 Fish组件
<template>
  <div>
    <ul>
      <li v-for="item in fishs" :key="item.id">
        <!-- <router-link :to="`/fish/detail/${item.id}/${item.name}`">{{item.name  }}</router-link> -->
         <button @click="goDetail(item)">查看{{ item.name }}</button>
        <!-- <router-link
         :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">{{ item.name }}</router-link> -->
      </li>
    </ul>
    <RouterView/>
  </div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { useRouter } from 'vue-router';
let fishs = reactive([
  {id:'01',name:'鲫鱼',price:100},
  {id:'02',name:'草鱼',price:150},
  {id:'03',name:'鲈鱼',price:200},
])
let router = useRouter();
function goDetail(fish: any) {
  router.push({
    name: 'fishdetail',
    query:fish
  });
}
</script>

FishDetail组件源码

<template>
  <div>
    <h3>鱼类:{{ name }}</h3>
    <h3>id:{{ id }}</h3>
    <h3>价格:{{ price }}</h3>
  </div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
// let route = useRoute();
defineProps(['name', 'id', 'price']);
</script>

路由源码

  import { createRouter, createWebHistory } from 'vue-router'
  import Fish from '@/view/Fish.vue'
  import Cat from '@/view/Cat.vue'
  import Bird from '@/view/Bird.vue'
  import FishDetial from '@/view/FishDetial.vue'
  console.log(createRouter)
  const routes = [
    {
      name: 'fish',
      path: '/fish',
      component: Fish,
      children: [
        {
          name: 'fishdetail',
          path: 'detail',
          component: FishDetial,
          props(route: any) {
            return route.query;
          }
        }
      ]
    },
    { path: '/cat', component: Cat },
    { path: '/bird', component: Bird }, // 动态路由
  ]
  const router = createRouter({
    history: createWebHistory(),
    routes: routes,
  })
  export default router

运行查看效果: 在这里插入图片描述 注意对比一下router.push的参数与router-link的to参数。两者参数用法是一致的,不管路由如何配置,是使用query还是params传参还是props传参。 如下例:

 <router-link
         :to="{name:'fishdetail',query:{id:item.id,name:item.name,price:item.price}}">
         {{ item.name }}</router-link>
function goDetail(fish: any) {
  router.push({
    name: 'fishdetail',
    query:fish
  });
}

router-link的to用法与 router.push用法一致。

4、路由的重定向

路由的重定向,就是访问a路由,自动跳转到b路由。如打开主页,默认访问http://localhost:5173/,访问的路径是/,能否一打开就跳转到/fish。就用重定向来解决。

 {
    path: '/',
    redirect: '/fish'

  },

路由具体代码:

  import { createRouter, createWebHistory } from 'vue-router'
  import Fish from '@/view/Fish.vue'
  import Cat from '@/view/Cat.vue'
  import Bird from '@/view/Bird.vue'
  import FishDetial from '@/view/FishDetial.vue'
  console.log(createRouter)
const routes = [
  {
    path: '/',
    redirect: '/fish'

  },
  {
    name: 'fish',
    path: '/fish',
    component: Fish,
    children: [
      {
        name: 'fishdetail',
        path: 'detail',
        component: FishDetial,
        props(route: any) {
          return route.query;
        }
      }
    ]
  },
  { path: '/cat', component: Cat },
  { path: '/bird', component: Bird }, // 动态路由
]
  const router = createRouter({
    history: createWebHistory(),
    routes: routes,
  })
  export default router

效果是一打开页面,就重定向到fish页面 在这里插入图片描述

关于Scheduler 类,一个并发控制调度器

2026年3月27日 16:09

代码实现

class Scheduler {
    constructor(limit) {
        this.limit = limit      // 最大并发数
        this.queue = []         // 等待队列
        this.running = 0        // 当前运行中的任务数
    }

    add(task) {
        return new Promise(resolve => {
            // 将任务包装后加入队列
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        // 达到并发上限或无任务时返回
        if (this.running >= this.limit || !this.queue.length) return
        
        // 执行任务
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()  // 执行下一个任务
        })
    }
}

执行流程可视化

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            console.log(`添加任务到队列,当前队列长度: ${this.queue.length + 1}`)
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) {
            console.log(`运行状态: running=${this.running}, 队列长度=${this.queue.length}`)
            return
        }
        
        this.running++
        const task = this.queue.shift()
        console.log(`开始执行任务,当前并发: ${this.running}/${this.limit}`)
        
        task().finally(() => {
            console.log(`任务完成,当前并发: ${this.running-1}/${this.limit}`)
            this.running--
            this.run()
        })
    }
}

// 测试
const scheduler = new Scheduler(2)

const createTask = (name, delay) => () => 
    new Promise(resolve => {
        setTimeout(() => {
            console.log(`${name} 完成`)
            resolve(name)
        }, delay)
    })

scheduler.add(createTask('任务1', 1000))
scheduler.add(createTask('任务2', 500))
scheduler.add(createTask('任务3', 300))
scheduler.add(createTask('任务4', 400))

/* 输出示例:
添加任务到队列,当前队列长度: 1
开始执行任务,当前并发: 1/2
添加任务到队列,当前队列长度: 1
开始执行任务,当前并发: 2/2
添加任务到队列,当前队列长度: 1
运行状态: running=2, 队列长度=1
添加任务到队列,当前队列长度: 2
运行状态: running=2, 队列长度=2
任务2 完成
任务完成,当前并发: 1/2
开始执行任务,当前并发: 2/2
任务3 完成
任务完成,当前并发: 1/2
开始执行任务,当前并发: 2/2
任务4 完成
任务完成,当前并发: 1/2
任务1 完成
任务完成,当前并发: 0/2
*/

详细分析

1. 构造函数

constructor(limit) {
    this.limit = limit      // 并发限制数
    this.queue = []         // 任务队列(存储包装后的函数)
    this.running = 0        // 当前正在执行的任务数
}

2. add 方法

add(task) {
    return new Promise(resolve => {
        // 关键:将任务包装成可执行函数并加入队列
        this.queue.push(() => task().then(resolve))
        this.run()
    })
}

关键点

  • 返回 Promise,外部可以等待任务完成
  • 任务被包装:() => task().then(resolve)
  • 包装函数执行时会调用原任务,并在完成后 resolve 外部 Promise

3. run 方法

run() {
    if (this.running >= this.limit || !this.queue.length) return
    
    this.running++
    const task = this.queue.shift()
    task().finally(() => {
        this.running--
        this.run()  // 递归调用,执行下一个任务
    })
}

关键点

  • 检查是否达到并发上限
  • 从队列取出任务并执行
  • 任务完成后减少计数并继续执行

使用示例

示例1:限制请求并发

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

// 模拟 API 请求
function fetchUser(id) {
    return () => new Promise(resolve => {
        console.log(`开始请求用户 ${id}`)
        setTimeout(() => {
            console.log(`用户 ${id} 数据返回`)
            resolve({ id, name: `User${id}` })
        }, Math.random() * 2000)
    })
}

const scheduler = new Scheduler(3)
const userIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

// 添加所有请求
const promises = userIds.map(id => 
    scheduler.add(fetchUser(id))
)

// 等待所有请求完成
Promise.all(promises).then(results => {
    console.log('所有用户数据:', results)
})

示例2:文件上传控制

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

// 模拟文件上传
function uploadFile(fileName, size) {
    return () => new Promise(resolve => {
        const startTime = Date.now()
        console.log(`开始上传: ${fileName} (${size}MB)`)
        
        // 模拟上传耗时
        setTimeout(() => {
            const duration = Date.now() - startTime
            console.log(`完成上传: ${fileName},耗时 ${duration}ms`)
            resolve({ fileName, size, duration })
        }, size * 500)  // 每MB 500ms
    })
}

const scheduler = new Scheduler(2)

const files = [
    { name: 'video.mp4', size: 10 },
    { name: 'image.jpg', size: 2 },
    { name: 'document.pdf', size: 1 },
    { name: 'music.mp3', size: 5 },
    { name: 'archive.zip', size: 8 }
]

files.forEach(file => {
    scheduler.add(uploadFile(file.name, file.size))
        .then(result => console.log(`${result.fileName} 上传成功`))
})

示例3:爬虫并发控制

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

// 模拟爬虫
function crawlUrl(url) {
    return () => new Promise(resolve => {
        console.log(`[${new Date().toLocaleTimeString()}] 爬取: ${url}`)
        
        setTimeout(() => {
            console.log(`[${new Date().toLocaleTimeString()}] 完成: ${url}`)
            resolve({ url, data: `内容来自 ${url}` })
        }, Math.random() * 2000)
    })
}

const scheduler = new Scheduler(3)
const urls = [
    'https://example.com/page1',
    'https://example.com/page2',
    'https://example.com/page3',
    'https://example.com/page4',
    'https://example.com/page5',
    'https://example.com/page6',
    'https://example.com/page7',
    'https://example.com/page8'
]

const results = []
urls.forEach(url => {
    scheduler.add(crawlUrl(url))
        .then(result => results.push(result))
})

// 监听完成
setTimeout(() => {
    console.log(`\n共爬取 ${results.length} 个页面`)
}, 10000)

边界情况测试

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

// 测试各种边界情况
console.log('=== 边界测试 ===\n')

// 1. limit = 0
const scheduler1 = new Scheduler(0)
scheduler1.add(() => Promise.resolve('test'))
    .then(console.log)
console.log('limit=0: 任务永远不会执行')

// 2. limit = 1 (串行)
const scheduler2 = new Scheduler(1)
const startTime = Date.now()
scheduler2.add(() => new Promise(r => setTimeout(() => r('任务1'), 1000)))
scheduler2.add(() => new Promise(r => setTimeout(() => r('任务2'), 1000)))
scheduler2.add(() => new Promise(r => setTimeout(() => r('任务3'), 1000)))
    .then(() => {
        const duration = Date.now() - startTime
        console.log(`串行执行总耗时: ${duration}ms (约3000ms)`)
    })

// 3. 任务失败处理
const scheduler3 = new Scheduler(2)
scheduler3.add(() => Promise.reject('错误'))
    .catch(e => console.log('捕获到错误:', e))
scheduler3.add(() => Promise.resolve('成功'))
    .then(r => console.log('成功:', r))

// 4. 动态添加任务
const scheduler4 = new Scheduler(2)
setTimeout(() => {
    console.log('动态添加任务')
    scheduler4.add(() => Promise.resolve('动态任务'))
}, 1000)

改进版本

改进1:支持任务优先级

class PriorityScheduler extends Scheduler {
    add(task, priority = 0) {
        return new Promise(resolve => {
            const wrappedTask = () => task().then(resolve)
            // 按优先级插入队列
            let index = this.queue.findIndex(item => item.priority < priority)
            if (index === -1) index = this.queue.length
            this.queue.splice(index, 0, { task: wrappedTask, priority })
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const { task } = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

改进2:支持任务超时

class TimeoutScheduler extends Scheduler {
    add(task, timeout = null) {
        return new Promise((resolve, reject) => {
            const wrappedTask = () => {
                if (timeout) {
                    return Promise.race([
                        task(),
                        new Promise((_, reject) => 
                            setTimeout(() => reject(new Error('任务超时')), timeout)
                        )
                    ]).then(resolve, reject)
                }
                return task().then(resolve, reject)
            }
            this.queue.push(wrappedTask)
            this.run()
        })
    }
}

改进3:支持进度回调

class ProgressScheduler extends Scheduler {
    constructor(limit) {
        super(limit)
        this.total = 0
        this.completed = 0
    }

    add(task) {
        this.total++
        return new Promise(resolve => {
            this.queue.push(() => task().then(result => {
                this.completed++
                this.onProgress?.(this.completed, this.total)
                resolve(result)
            }))
            this.run()
        })
    }

    onProgress(callback) {
        this.onProgress = callback
        return this
    }
}

// 使用
const scheduler = new ProgressScheduler(3)
scheduler.onProgress((completed, total) => {
    console.log(`进度: ${completed}/${total} (${Math.round(completed/total*100)}%)`)
})

改进4:支持暂停/恢复

class PausableScheduler extends Scheduler {
    constructor(limit) {
        super(limit)
        this.paused = false
    }

    pause() {
        this.paused = true
    }

    resume() {
        this.paused = false
        this.run()
    }

    run() {
        if (this.paused) return
        super.run()
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }
}

与其他并发控制对比

// 1. Promise.all - 无并发限制
const all = Promise.all(tasks.map(t => t()))

// 2. Promise.allSettled - 无并发限制
const settled = Promise.allSettled(tasks.map(t => t()))

// 3. 你的 Scheduler - 有并发限制
const scheduler = new Scheduler(3)
tasks.forEach(t => scheduler.add(t))

// 4. p-limit 库
const pLimit = require('p-limit')
const limit = pLimit(3)
const promises = tasks.map(t => limit(() => t()))

性能分析

class Scheduler {
    constructor(limit) {
        this.limit = limit
        this.queue = []
        this.running = 0
    }

    add(task) {
        return new Promise(resolve => {
            this.queue.push(() => task().then(resolve))
            this.run()
        })
    }

    run() {
        if (this.running >= this.limit || !this.queue.length) return
        this.running++
        const task = this.queue.shift()
        task().finally(() => {
            this.running--
            this.run()
        })
    }
}

// 性能测试
async function performanceTest() {
    const tasks = Array(100).fill().map((_, i) => 
        () => new Promise(r => setTimeout(() => r(i), Math.random() * 100))
    )
    
    // 测试不同并发限制
    for (const limit of [1, 5, 10, 20]) {
        const scheduler = new Scheduler(limit)
        const start = Date.now()
        
        await Promise.all(tasks.map(task => scheduler.add(task)))
        
        const duration = Date.now() - start
        console.log(`并发限制 ${limit}: ${duration}ms`)
    }
}

performanceTest()

Skill 与 Agent:AI 开发中两个最容易混淆的概念

作者 小哈猪
2026年3月27日 16:02

Skill 与 Agent:AI 开发中两个最容易混淆的概念

入门 AI 开发时,很多人会被这两个词搞懵:Skill 和 Agent 到底有什么区别?都是 Python 文件,都能调用工具,为什么要分开?本文用最直白的方式,彻底讲清楚这两个概念。


一、先从一个比喻开始

想象你开了一家餐厅:

  • Agent = 餐厅的大厨:他会思考、决策,根据客人的需求决定做什么菜、用什么食材、什么顺序出菜。他是有主动意识的执行者。
  • Skill = 大厨手边的菜谱和工具:炒锅、菜谱、调味料……这些本身不会思考,但大厨需要它们才能完成任务。
用户需求
   ↓
[Agent 大脑] → 思考 → 决定用哪个 Skill
   ↓
[Skill 工具] → 执行 → 返回结果
   ↓
[Agent 大脑] → 整合结果 → 回答用户

二、Skill 是什么?

Skill(技能/插件) 是一个具有明确功能边界的可调用模块。它本身不会主动做任何事,只在被调用时执行特定任务。

Skill 的三个核心特征

1. 功能单一 每个 Skill 只做一件事:查天气、搜索网页、发消息、签到……职责越单一越好。

2. 被动执行 Skill 不会主动触发,必须被 Agent 或用户显式调用。

3. 有描述文件 在 OpenClaw 等平台中,Skill 通常有一个 SKILL.md 说明文件,告诉 AI 系统这个技能是干什么的、怎么用。

skill/
  SKILL.md          ← 给 AI 看的说明书(自然语言描述)
  scripts/
    get_weather.py  ← 实际执行逻辑

SKILL.md 长什么样?

---
name: weather
description: 查询天气预报,支持全球城市
---

## 使用方法
curl "wttr.in/{城市名}?format=3"

## 参数
- city: 城市名称,如:上海、北京

AI 读懂这个文件后,就知道什么时候该调用它、怎么调用。


三、Agent 是什么?

Agent(智能体) 是一个能够自主感知、思考、决策并执行任务的 AI 系统。它有一个「大脑」(LLM),有一套工具(Skill 或函数),有一个运行循环。

Agent 的四个核心要素

┌─────────────────────────────────────┐
│              Agent                  │
│                                     │
│  ┌─────────┐    ┌────────────────┐  │
│  │  感知   │    │    决策/规划   │  │
│  │ Perceive│───▶│     Plan       │  │
│  └─────────┘    └───────┬────────┘  │
│                         │           │
│  ┌─────────┐    ┌───────▼────────┐  │
│  │  记忆   │    │    执行工具    │  │
│  │ Memory  │    │    Act/Tools   │  │
│  └─────────┘    └────────────────┘  │
└─────────────────────────────────────┘

1. 感知(Perceive):接收用户输入、环境信息

2. 决策(Plan):LLM 大脑分析需求,决定下一步做什么

3. 执行(Act):调用工具/Skill,获取外部信息或执行操作

4. 记忆(Memory):记住对话历史,支持多轮交互

最简单的 Agent 代码结构

def run_agent(user_input):
    messages = [{"role": "user", "content": user_input}]
    
    while True:
        # 1. LLM 思考:要做什么?
        response = llm.chat(messages, tools=TOOLS)
        
        # 2. 不需要工具 → 直接回答
        if not response.tool_calls:
            return response.content
        
        # 3. 需要工具 → 调用工具
        for tool_call in response.tool_calls:
            result = execute_tool(tool_call)  # 这里调用 Skill
            messages.append(tool_result(result))
        
        # 4. 拿到工具结果 → 继续思考

这个循环:思考 → 行动 → 观察 → 再思考,就是 Agent 的本质,也叫 ReAct 模式


四、核心区别一览

对比维度 Skill Agent
本质 工具/插件 智能体/系统
有无大脑 ❌ 无 ✅ 有 LLM
主动性 被动,等待调用 主动,自主决策
复杂度 低,单一功能 高,多工具协调
入口文件 SKILL.md(自然语言) Python/代码(逻辑)
调用方 Agent 或平台 用户或系统
典型例子 查天气、搜索、签到 ChatGPT、AutoGPT
能否独立运行 ❌ 需要宿主 ✅ 独立运行

五、它们如何配合工作?

一个完整的 AI 应用,通常是这样的结构:

┌──────────────────────────────────────────┐
│                  Agent                   │
│  ┌────────┐                              │
│  │  LLM   │  ← 大脑,负责思考和决策      │
│  └───┬────┘                              │
│      │ 调用                              │
│  ┌───▼──────────────────────────────┐   │
│  │           Skill 工具箱            │   │
│  │  [天气] [搜索] [签到] [发消息]   │   │
│  └──────────────────────────────────┘   │
└──────────────────────────────────────────┘

实际工作流程举例:

用户说:「今天上海天气适合出门吗?顺便帮我掘金签到」

Step 1: Agent 接收输入
        ↓
Step 2: LLM 分析:需要查天气 + 掘金签到,两个任务
        ↓
Step 3: 调用 weather Skill → 返回「晴天,22°C」
        ↓
Step 4: 调用 juejin_checkin Skill → 返回「签到成功,+200矿石」
        ↓
Step 5: LLM 整合结果,生成最终回答
        ↓
Step 6: 「上海今天晴天22°C,非常适合出门!掘金签到也完成了,获得200矿石 🎉」

六、在 OpenClaw 中的对应关系

如果你用过 OpenClaw(或类似的 AI 助手平台),可以这样理解:

OpenClaw 平台
    │
    ├── Agent(就是 AI 助手本身,有 LLM 大脑)
    │       ↓ 读取并调用
    └── Skills(扩展能力的插件)
            ├── weather/SKILL.md      → 查天气
            ├── baidu-search/SKILL.md → 百度搜索  
            ├── mx-stocks/SKILL.md    → 股票筛选
            └── juejin/SKILL.md       → 掘金签到
  • SKILL.md 是写给 AI 看的「工具说明书」,用自然语言描述这个工具是干什么的
  • AI Agent 读懂说明书后,在合适的时机自动调用对应的脚本
  • 这就是为什么 Skill 的入口是 .md 文件,而不是 .py 文件

七、自己写 Agent 时怎么设计 Skill?

如果你在用 Python 自己写 Agent(比如基于 LangChain 或直接调用 OpenAI API),Skill 在代码里就是工具函数 + 描述

# Skill 的代码形式:函数 + JSON Schema 描述
TOOLS_SCHEMA = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "查询城市天气",  ← 这就是给 LLM 看的「SKILL.md」
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string"}
                }
            }
        }
    }
]

# Skill 的实现
def get_weather(city: str) -> str:
    return f"{city}:晴天,22°C"

本质上,无论是 OpenClaw 的 SKILL.md,还是 OpenAI 的 JSON Schema,都在做同一件事:告诉 LLM 这个工具叫什么、能做什么、需要什么参数。形式不同,目的一样。


八、总结

用一句话记住:

Agent 是会思考的大脑,Skill 是它手里的工具。

记忆方法
Skill 螺丝刀——功能单一,用的时候才拿起来
Agent 工程师——知道什么时候用哪把螺丝刀

学 AI 开发的路径建议:

  1. 先搞懂一个 Skill 怎么写(单一功能,容易理解)
  2. 再搞懂 Agent 的 ReAct 循环(思考→行动→观察)
  3. 最后学多 Agent 协作(多个 Agent 分工合作)

你已经在这条路上了——加油!🚀


如果这篇文章对你有帮助,欢迎点赞收藏~有问题欢迎评论区交流。

sleep 函数在React项目中的运用

2026年3月27日 16:00

核心原理

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

作用:创建一个延迟指定毫秒数后才会 resolve 的 Promise,用于实现异步等待。

执行流程

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 执行流程可视化
console.log('1. 开始执行')
sleep(2000).then(() => {
    console.log('3. 2秒后执行')
})
console.log('2. 立即执行,不阻塞')

// 输出顺序:
// 1. 开始执行
// 2. 立即执行,不阻塞
// 3. 2秒后执行

详细分析

1. Promise 构造器

new Promise(resolve => setTimeout(resolve, delay))
  • resolve 是 Promise 提供的回调函数
  • setTimeout 在延迟后调用 resolve
  • 当 resolve 被调用时,Promise 状态变为 fulfilled

2. 返回值

返回一个 Promise 对象,可以链式调用 .then() 或使用 async/await

使用方式

方式1:Promise.then()

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

console.log('开始')
sleep(1000).then(() => {
    console.log('1秒后')
    return sleep(1000)
}).then(() => {
    console.log('2秒后')
})

方式2:async/await

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function demo() {
    console.log('开始')
    await sleep(1000)
    console.log('1秒后')
    await sleep(1000)
    console.log('2秒后')
}

demo()

方式3:配合循环使用

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function countdown(seconds) {
    for (let i = seconds; i > 0; i--) {
        console.log(`${i} 秒...`)
        await sleep(1000)
    }
    console.log('时间到!')
}

countdown(5)

实际应用场景

场景1:轮询

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function pollData() {
    let retries = 3
    
    while (retries > 0) {
        try {
            const data = await fetchData()
            if (data) return data
            console.log('数据未就绪,1秒后重试...')
            await sleep(1000)
            retries--
        } catch (error) {
            console.log('请求失败,重试中...')
            await sleep(2000)
            retries--
        }
    }
    throw new Error('获取数据失败')
}

场景2:限流/节流

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function rateLimitedRequests(urls) {
    const results = []
    
    for (const url of urls) {
        const result = await fetch(url)
        results.push(result)
        await sleep(1000)  // 每秒最多请求一次
    }
    
    return results
}

场景3:模拟加载

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function simulateLoading() {
    console.log('加载中...')
    await sleep(2000)
    console.log('✅ 加载完成')
    
    console.log('处理数据...')
    await sleep(1500)
    console.log('✅ 处理完成')
    
    console.log('保存结果...')
    await sleep(1000)
    console.log('✅ 保存完成')
}

simulateLoading()

场景4:超时控制

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

function timeout(promise, ms) {
    return Promise.race([
        promise,
        sleep(ms).then(() => {
            throw new Error('操作超时')
        })
    ])
}

// 使用
async function fetchWithTimeout() {
    try {
        const data = await timeout(fetch('https://api.example.com'), 3000)
        console.log('数据:', data)
    } catch (error) {
        console.log('超时错误:', error.message)
    }
}

边界情况

1. delay 为 0

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

console.log('1. 同步代码')
sleep(0).then(() => console.log('3. 微任务后执行'))
console.log('2. 同步代码')

// 输出:
// 1. 同步代码
// 2. 同步代码
// 3. 微任务后执行

2. 负数 delay

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 负数会被转换为0或接近0
sleep(-1000).then(() => console.log('立即执行(微任务)'))
console.log('同步代码')

3. 非常大的 delay

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 最大延迟约 24.8 天(2^31-1 毫秒)
sleep(2 ** 31 - 1).then(() => console.log('永远不会执行?'))
// setTimeout 最大延迟约 24.8 天

性能分析

1. 精度问题

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

async function measurePrecision() {
    const start = Date.now()
    await sleep(1000)
    const actualDelay = Date.now() - start
    console.log(`期望: 1000ms, 实际: ${actualDelay}ms`)
    console.log(`误差: ${actualDelay - 1000}ms`)
}

measurePrecision()
// 通常在 1000-1010ms 左右(4-10ms 误差)

2. 内存占用

function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 大量并发 sleep
async function manySleeps() {
    const promises = []
    for (let i = 0; i < 10000; i++) {
        promises.push(sleep(1000))
    }
    await Promise.all(promises)
    console.log('所有 sleep 完成')
}
// 会创建 10000 个定时器,内存占用较大

增强版本

版本1:支持取消

function sleep(delay) {
    let timeoutId
    const promise = new Promise(resolve => {
        timeoutId = setTimeout(resolve, delay)
    })
    
    promise.cancel = () => {
        clearTimeout(timeoutId)
    }
    
    return promise
}

// 使用
const sleepPromise = sleep(5000)
sleepPromise.then(() => console.log('不会执行'))

// 3秒后取消
setTimeout(() => {
    sleepPromise.cancel()
    console.log('已取消')
}, 3000)

版本2:支持值传递

function sleep(delay, value) {
    return new Promise(resolve => setTimeout(() => resolve(value), delay))
}

// 使用
sleep(1000, '完成').then(result => {
    console.log(result)  // 1秒后输出 '完成'
})

// 配合 async/await
async function demo() {
    const result = await sleep(2000, '数据已加载')
    console.log(result)
}

版本3:带状态提示

function sleep(delay, options = {}) {
    const { signal, onStart, onEnd } = options
    
    return new Promise((resolve, reject) => {
        if (signal?.aborted) {
            return reject(new Error('已取消'))
        }
        
        onStart?.()
        
        const timeoutId = setTimeout(() => {
            onEnd?.()
            resolve()
        }, delay)
        
        signal?.addEventListener('abort', () => {
            clearTimeout(timeoutId)
            reject(new Error('已取消'))
        })
    })
}

// 使用 AbortController
const controller = new AbortController()
sleep(5000, { signal: controller.signal })
    .then(() => console.log('完成'))
    .catch(e => console.log('取消:', e.message))

setTimeout(() => controller.abort(), 2000)

版本4:精确延迟(使用 performance)

function sleepPrecise(delay) {
    const start = performance.now()
    return new Promise(resolve => {
        function tick(now) {
            if (now - start >= delay) {
                resolve()
            } else {
                requestAnimationFrame(tick)
            }
        }
        requestAnimationFrame(tick)
    })
}

// 更精确但更耗 CPU
async function test() {
    const start = performance.now()
    await sleepPrecise(1000)
    const actual = performance.now() - start
    console.log(`精确延迟: ${actual.toFixed(2)}ms`)
}

与其他实现对比

// 1. 你的实现
function sleep(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 2. 使用 setTimeout 直接返回
function sleep2(delay) {
    return new Promise(resolve => {
        setTimeout(resolve, delay)
    })
}

// 3. 使用 async/await 包装(多余)
async function sleep3(delay) {
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 4. 带错误处理
function sleep4(delay) {
    if (typeof delay !== 'number' || delay < 0) {
        return Promise.reject(new Error('delay must be a positive number'))
    }
    return new Promise(resolve => setTimeout(resolve, delay))
}

// 性能测试
async function comparePerformance() {
    const iterations = 1000
    
    console.time('sleep 原始')
    for (let i = 0; i < iterations; i++) {
        await sleep(10)
    }
    console.timeEnd('sleep 原始')
    
    console.time('sleep4 带检查')
    for (let i = 0; i < iterations; i++) {
        await sleep4(10)
    }
    console.timeEnd('sleep4 带检查')
}

常见使用模式

1. 顺序延迟

async function sequentialDelay() {
    console.log('步骤1')
    await sleep(1000)
    console.log('步骤2')
    await sleep(1000)
    console.log('步骤3')
}

2. 并发延迟

async function concurrentDelay() {
    const promises = [
        sleep(1000).then(() => '任务1'),
        sleep(2000).then(() => '任务2'),
        sleep(3000).then(() => '任务3')
    ]
    const results = await Promise.all(promises)
    console.log('所有任务完成:', results)
}

3. 指数退避

async function exponentialBackoff(fn, maxRetries = 5) {
    let delay = 1000
    
    for (let i = 0; i < maxRetries; i++) {
        try {
            return await fn()
        } catch (error) {
            if (i === maxRetries - 1) throw error
            
            console.log(`第 ${i + 1} 次重试,等待 ${delay}ms...`)
            await sleep(delay)
            delay *= 2  // 指数增长
        }
    }
}

总结

 sleep 函数:

✅ 优点

  1. 简洁优雅:一行代码实现核心功能
  2. 性能良好:使用原生 Promise 和 setTimeout
  3. 易于使用:支持 then 和 async/await
  4. 无副作用:纯函数,不修改外部状态

✅ 应用场景

  • 测试异步代码
  • 实现轮询
  • 控制请求频率
  • 模拟耗时操作
  • 动画和过渡效果
  • 超时控制

⚠️ 注意事项

  • 精度约为 4-10ms(受事件循环影响)
  • 大量并发可能占用较多定时器资源
  • 不会阻塞事件循环(非阻塞延迟)
❌
❌