普通视图

发现新文章,点击刷新页面。
昨天 — 2026年1月29日掘金 前端

10分钟带你用Three.js手搓一个3D世界,代码少得离谱!

作者 烛阴
2026年1月29日 22:14

🎬 核心概念:上帝的“片场”

在 Three.js 的世界里,想要画面动起来,你只需要凑齐这“四大金刚”:

1. 场景 (Scene) —— 你的片场

想象你是一个导演,首先你得有个场地。在 Three.js 里,Scene 就是这个场地。它是一个容器,用来放置所有的物体、灯光和摄像机。

const scene = new THREE.Scene(); //(这就开辟了一个场地)

2. 摄像机 (Camera) —— 你的眼睛

片场有了,观众怎么看?得架摄像机。 Three.js 里最常用的是 透视摄像机 (PerspectiveCamera)。 这就好比人的眼睛,近大远小

  • 你需要告诉它:
    • 视角 (FOV):镜头是广角还是长焦?
    • 长宽比 (Aspect):电影是 16:9 还是 4:3?
    • 近剪切面 & 远剪切面:太近看不见,太远也看不见。

3. 渲染器 (Renderer) —— 你的放映机

场景布置好了,摄像机架好了,谁把画面画到屏幕(Canvas)上?这就是渲染器的工作。它负责计算每一帧画面,把 3D 的数据“拍扁”成 2D 的像素点显示在网页上。

4. 网格 (Mesh) —— 你的演员 🕺

这是最关键的部分!片场不能是空的,得有东西。在 Three.js 里,一个可见的物体通常被称为 Mesh (网格)。 一个 Mesh 由两部分组成(缺一不可):

  • 几何体 (Geometry)演员的身材。是方的?圆的?还是复杂的角色模型?(比如 BoxGeometry 就是个立方体骨架)。
  • 材质 (Material)演员的衣服。是金属质感?塑料质感?还是发光的?什么颜色?(比如 MeshPhongMaterial 就是一种这就好比给骨架穿上了皮肤)。

⚡️ 实战:3分钟手搓一个旋转立方体

别眨眼,核心代码真的少得离谱。我们来把上面的概念串起来:

第一步:搭建舞台(初始化)

// 1. 创建场景
const scene = new THREE.Scene();

// 2. 创建摄像机 (视角75度, 宽高比, 近距0.1, 远距1000)
const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
// 把摄像机往后拉一点,不然就在物体肚子里了
camera.position.z = 5;

// 3. 创建渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
// 把渲染出来的 canvas 塞到页面里
document.body.appendChild(renderer.domElement);

第二步:请演员入场(创建物体)

// 1. 骨架:一个 1x1x1 的立方体
const geometry = new THREE.BoxGeometry(1, 1, 1);

// 2. 皮肤:绿色的,对光照有反应的材质
const material = new THREE.MeshPhongMaterial({ color: 0x44aa88 });

// 3. 合体:创建网格
const cube = new THREE.Mesh(geometry, material);

// 4. 放到场景里!
scene.add(cube);

第三步:打光(Light)

如果你用的是 MeshPhongMaterial 这种高级材质,没有光就是漆黑一片。

// 创建一个平行光(类似太阳光)
const light = new THREE.DirectionalLight(0xFFFFFF, 1);
light.position.set(-1, 2, 4);
scene.add(light);

第四步:Action!(动画循环)

电影是每秒 24 帧的静态图,Three.js 也一样。我们需要一个循环,不停地让渲染器“拍照”。

function animate() {
    requestAnimationFrame(animate); // 浏览器下次重绘前调用我

    // 让立方体动起来
    cube.rotation.x += 0.01;
    cube.rotation.y += 0.01;

    // 咔嚓!渲染一帧
    renderer.render(scene, camera);
}

animate(); // 开始循环

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

总结

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

RBAC 权限系统实战(一):页面级访问控制全解析

作者 十五丶
2026年1月29日 18:56

前言

本篇文章主要讲解 RBAC 权限方案在中后台管理系统的实现

在公司内部写过好几个后台系统,都需要实现权限控制,在职时工作繁多,没有系统性的来总结一下相关经验,现在人已离职,就把自己的经验总结一下,希望能帮助到你

本文是《通俗易懂的中后台系统建设指南》系列的第九篇文章,该系列旨在告诉你如何来构建一个优秀的中后台管理系统

权限模型有哪些?

主流的权限模型主要分为以下五种:

  • ACL模型:访问控制列表
  • DAC模型:自主访问控制
  • MAC模型:强制访问控制
  • ABAC模型:基于属性的访问控制
  • RBAC模型:基于角色的权限访问控制

这里不介绍全部的权限模型,有兴趣你可以看看这篇文章:权限系统就该这么设计,yyds

如果你看过、用过市面上一些开源后台系统及权限设计,你会发现它们主要都是基于 RBAC 模型来实现的

为什么是 RBAC 权限模型?

好问题!我帮你问了下 AI

对比维度 ACL (访问控制列表) RBAC (基于角色) ABAC (基于属性)
核心逻辑 用户 ↔ 权限
直接点对点绑定,无中间层
用户 ↔ 角色 ↔ 权限
引入“角色”解耦,权限归于角色
属性 + 规则 = 权限
动态计算 (Who, When, Where)
优点 模型极简,开发速度快,适合初期 MVP 结构清晰,复用性高,符合企业组织架构,维护成本低 极度灵活,支持细粒度控制
(如:只能在工作日访问)
缺点 用户量大时维护工作呈指数级增长,极易出错 角色爆炸:若特例过多,可能导致定义成百上千个角色 开发复杂度极高,规则引擎难设计,有一定的性能消耗
适用场景 个人博客、小型内部工具 中大型后台系统、SaaS 平台 (行业标准) 银行风控、AWS IAM、国家安全级系统

总结来说,在后台系统的场景下,RBAC 模型在灵活性(对比ACL)和复杂性(对比ABAC)上取得了一个很好的平衡

RBAC 概念理解

RBAC 权限模型,全称 Role-Based Access Control,基于角色的权限访问控制

模型有三要素:

  • 用户(User):系统主体,即操作系统的具体人员或账号
  • 角色(Role):角色是一组权限的集合,代表了用户在组织中的职能或身份
  • 权限(Permission):用户可以对系统资源进行的访问或操作能力

RBAC 的设计是将角色绑定权限,用户绑定角色,从而实现权限控制

image.png

并且,它们之间的逻辑关系通常是多对多的:

用户 - 角色 (User-Role): 一个用户可以拥有多个角色(例如:某人既是“项目经理”又是“技术委员会成员”)

角色 - 权限(Role-Permission): 一个角色包含多个权限(例如:“人事经理”角色拥有“查看员工”、“编辑薪资”等权限)

主导权限控制的前端、后端方案

市面上这些开源 Admin 的权限控制中,存在两种主要的权限主导方案:前端主导的权限方案和后端主导的权限方案

前端主导的权限方案

前端主导的权限方案,一个主要的特征是菜单数据由前端维护,而不是存在数据库中

后端只需要在登录后给到用户信息,这个信息中会包含用户的角色,根据这个角色信息,前端可以筛选出具有权限的菜单、按钮

这种方案的主要逻辑放在前端,而不是后端数据库,所以安全性没保障,灵活性也较差,要更新权限,就需要改动前端代码并重新打包上线,无法支持“动态配置权限”

适合一些小型、简单系统

后端主导的权限方案

后端控制方案,即登录后在返回用户信息时,还会给到此用户对应的菜单数据和按钮权限码等

菜单数据、按钮权限码等都存在数据库,这样一来,安全性、灵活性更高,要更新权限数据或用户权限控制,提供相应接口即可修改

倒也不是说前端完全不用管菜单数据,而是前端只需要维护一些静态菜单数据,比如登录页、异常页(404、403...)

在企业级后台系统中,后端主导的权限方案是比较常用的,本文只介绍后端主导的权限方案

权限方案整体流程

在开始写代码之前,要清晰知道整体实现流程,我画了一张图来直观展示:

image.png

后台系统中的 RBAC 权限实战

权限菜单类型定义

首先,在前后端人员配合中,我们最好约定一套菜单数据的结构,比如:

import type { RouteMeta, RouteRecordRaw, RouteRecordRedirectOption } from 'vue-router';
import type { Component } from 'vue';
import type { DefineComponent } from 'vue';
import type { RouteType } from '#/type';

declare global {
  export interface CustomRouteRecordRaw extends Omit<RouteRecordRaw, 'meta'> {
    /**
     * 路由地址
     */
    path?: string;
    /**
     * 路由名称
     */
    name?: string;
    /**
     * 重定向路径
     */
    redirect?: RouteRecordRedirectOption;
    /**
     * 组件
     */
    component?: Component | DefineComponent | (() => Promise<unknown>);
    /**
     * 子路由信息
     */
    children?: CustomRouteRecordRaw[];
    /**
     * 路由类型
     */
    type?: RouteType;
    /**
     * 元信息
     */
    meta: {
      /**
       * 菜单标题
       */
      title: string;
      /**
       * 菜单图标
       */
      menuIcon?: string;
      /**
       * 排序
       */
      sort?: number;
      /**
       * 是否在侧边栏菜单中隐藏
       * @default false
       */
      hideMenu?: boolean;
      /**
       * 是否在面包屑中隐藏
       * @default false
       */
      hideBreadcrumb?: boolean;
      /**
       * 当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容
       * @default false
       */
      hideParentIfSingleChild?: boolean;
    };
  }

  /**
   * 后端返回的权限路由类型定义
   */
  export type PermissionRoute = Omit<CustomRouteRecordRaw, 'component' | 'children' | 'type'> & {
    /**
     * 路由ID
     */
    id?: number;
    /**
     * 路由父ID
     */
    parentId?: number;
    /**
     * 组件路径(后端返回时为字符串,前端处理后为组件)
     */
    component: string;
    /**
     * 子路由信息
     */
    children?: PermissionRoute[];
    /**
     * 路由类型
     */
    type: RouteType;
  };
}

router.d.ts 找到类型文件

以上面的类型定义为例,我们约定 PermissionRoute 类型是后端返回的权限路由类型:

我这里使用 ApiFox 来 Mock 权限路由数据,数据是这样的:

clean-admin ApiFox 文档在线地址

image.png

从登录页到路由守卫

权限方案的第一步,是登录并拿到用户信息

假设我们现在用 Element Plus 搭建起了一个登录页面,当用户点击登录时,我们需要做这几件事:

  1. 调用登录接口,将账号、密码发送给后端进行验证,验证通过则返回 JWT 信息
  2. 将返回的 JWT 信息保存到本地,后续每次请求都携带 Token 来识别用户身份并决定你能拿到的权限路由数据
  3. 触发路由守卫拦截

image.png

account-login.vue 找到全部代码

基本 Vue Router 配置

登录完成后,我们就可以触发路由守卫了,但在写路由守卫之前,我们先来配置一下基本的 Vue Router

在整个权限系统中,我们将路由数据分为两种:

  1. 静态路由:系统固定的路由,比如登录页、异常页(404、403...)
  2. 动态路由:由后端接口返回的用户角色对应的菜单路由数据

静态路由是直接由前端定义,不会从后端接口返回、不会根据用户角色动态变化,所以这部分路由我们直接写好然后注册到 Vue Router 中即可

Vue Router 配置:

import { createRouter, createWebHashHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
import type { App } from 'vue';
import type { ImportGlobRoutes } from './typing';
import { extractRoutes } from './helpers';
import { afterEachGuard, beforeEachGuard } from './guards';

/** 静态路由 */
const staticRoutes = extractRoutes(
  import.meta.glob<ImportGlobRoutes>(['./modules/constant-routes/**/*.ts'], {
    eager: true,
  }),
);

/** 系统路由 */
const systemRoutes = extractRoutes(
  import.meta.glob<ImportGlobRoutes>(['./modules/system-routes/**/*.ts'], {
    eager: true,
  }),
);

const router = createRouter({
  history: createWebHashHistory(),
  routes: [...staticRoutes, ...systemRoutes] as RouteRecordRaw[],
  strict: true,
  scrollBehavior: () => ({ left: 0, top: 0 }),
});

beforeEachGuard(router);
afterEachGuard(router);

/** 初始化路由 */
function initRouter(app: App<Element>) {
  app.use(router);
}

export { router, initRouter, staticRoutes };

图中的静态路由和系统路由是同一类路由数据,即静态路由

这个配置文件可以在 router/index.ts 找到

这个基本的 Vue Router 配置,做了这么几件事:

  1. 导入 modules 文件夹下的静态路由进行注册
  2. 路由初始化配置 initRouter ,在 main.ts 中调用
  3. 注册全局前置守卫 beforeEach、全局后置守卫 afterEach

我们实现动态路由注册的逻辑就写在 beforeEach

值得一提的是,使用了 import.meta.glob 来动态导入指定路径下的文件模块,这是 Vite 提供的一种导入方式,参考:Vite Glob 导入

路由守卫与动态注册

路由守卫是 Vue Router 提供的一种机制,主要用来通过跳转或取消的方式守卫导航:Vue Router 路由守卫

重头戏在全局前置守卫 router.beforeEach 中实现,来看看我们做哪些事:

import { ROUTE_NAMES } from '../config';
import type { RouteRecordNameGeneric, RouteRecordRaw, Router } from 'vue-router';
import { getLocalAccessToken } from '@/utils/permission';
import { userService } from '@/services/api';
import { nprogress } from './helpers';
import { storeToRefs } from 'pinia';

/** 登录认证页面:账号登录页、短信登录页、二维码登录页、忘记密码页、注册页... */
const authPages: RouteRecordNameGeneric[] = [
  ROUTE_NAMES.AUTH,
  ROUTE_NAMES.ACCOUNT_LOGIN,
  ROUTE_NAMES.SMS_LOGIN,
  ROUTE_NAMES.QR_LOGIN,
  ROUTE_NAMES.FORGOT_PASSWORD,
  ROUTE_NAMES.REGISTER,
];

/** 页面白名单:不需要登录也能访问的页面 */
const pageWhiteList: RouteRecordNameGeneric[] = [...authPages];

export function beforeEachGuard(router: Router) {
  router.beforeEach(async (to) => {
    /** 进度条:开始 */
    nprogress.start();

    const { name: RouteName } = to;

    const userStore = useUserStore();
    const { getAccessToken, getRoutesAddStatus, registerRoutes } = storeToRefs(userStore);
    const { setRoutesAddStatus, setUserInfo, logout } = userStore;

    /** 访问令牌 */
    const accessToken = getAccessToken.value || getLocalAccessToken();

    // 1.用户未登录(无 Token)
    if (!accessToken) {
      const isWhitePage = pageWhiteList.includes(RouteName);
      // 1.1 未登录,如果访问的是白名单中的页面,直接放行
      if (isWhitePage) return true;

      nprogress.done();

      // 1.2 未登录又不在白名单,则拦截并重定向到登录页
      return { name: ROUTE_NAMES.ACCOUNT_LOGIN };
    }

    // 如果已登录用户试图访问登录页,避免重复登录,要强制重定向到首页
    if (authPages.includes(RouteName)) {
      nprogress.done();
      return { name: ROUTE_NAMES.ROOT };
    }

    // 判断是否需要动态加载路由的操作
    if (!getRoutesAddStatus.value) {
      // isRoutesAdded 默认为 false(未持久化),在已经动态注册过时会设置为true,在页面刷新时会重置为 false
      try {
        // 1.拉取用户信息
        const userInfo = await userService.getUserInfo();

        // 2.将用户信息存入 Store
        setUserInfo(userInfo);

        // 3.动态注册路由,registerRoutes 是处理后的路由表
        registerRoutes.value.forEach((route) => {
          router.addRoute(route as unknown as RouteRecordRaw);
        });

        // 4.标记路由已添加
        setRoutesAddStatus(true);

        // 5.中断当前导航,重新进入守卫
        return { ...to, replace: true };
      } catch (error) {
        // 获取用户信息失败(如 Token 过期失效、网络异常)
        logout();
        nprogress.done();
        // 重定向回登录页,让用户重新登录
        return { name: ROUTE_NAMES.ACCOUNT_LOGIN };
      }
    }

    return true;
  });
}

before-each-guard.ts 找到全部代码

上面的代码已经给出了很详细的注释,从整体角度来讲,我们做了两件事:

  1. 处理一些情况,比如用户未登录、登录后访问登录页、白名单等情况
  2. 拉取用户信息,动态注册路由

image.png

在路由守卫中“拉取用户信息”,一般来说,除了返回用户本身的信息外,还会给到权限路由信息、权限码信息,这里的数据结构可以跟后端进行约定

image.png

比如在 vue-clean-admin 中,返回的数据结构是这样的:

在 ApiFox 文档可以找到用户接口说明:ApiFox 文档 - 用户信息

image.png

后端路由结构的转化

在通过“拉取用户信息”拿到路由数据后,并不是直接注册到 Vue Router,而是需要进行处理转化,才能符合 Vue Router 定义的路由表结构,registerRoutes 就是处理后的路由表,处理后的类型定义可以参考 CustomRouteRecordRaw

处理什么内容呢?

比如,接口拿到的路由数据字段 component 是一个字符串路径,这是一个映射路径,映射到前端项目下的真实组件路径

image.png

实现路由结构转换的代码,我写在了 router/helpers.ts,最主要逻辑是 generateRoutes 函数:

/**
 * 生成符合 Vue Router 定义的路由表
 * @param routes 未转化的路由数据
 * @returns 符合结构的路由表
 */
export function generateRoutes(routes: PermissionRoute[]): CustomRouteRecordRaw[] {
  if (!routes.length) return [];
  return routes.map((route) => {
    const { path, name, redirect, type, meta } = route;
    const baseRoute: Omit<CustomRouteRecordRaw, 'children'> = {
      path,
      name,
      redirect,
      type,
      component: loadComponent(route),
      meta: {
        ...meta,
        // 是否在侧边栏菜单中隐藏
        hideMenu: route.meta?.hideMenu || false,
        // 是否在面包屑中隐藏
        hideBreadcrumb: route.meta?.hideBreadcrumb || false,
        // 当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容
        hideParentIfSingleChild: route.meta?.hideParentIfSingleChild || false,
      },
    };

    // 是目录数据,设置重定向路径
    if (type === PermissionRouteTypeEnum.DIR) {
      baseRoute.redirect = redirect || getRedirectPath(route);
    }
    // 递归处理子路由
    const processedChildren =
      route.children && route.children.length ? generateRoutes(route.children) : undefined;

    return {
      ...baseRoute,
      ...(processedChildren ? { children: processedChildren } : {}),
    };
  });
}

经过 generateRoutes 处理的路由表,再 addRoute 到 Vue Router 中

侧边栏菜单的渲染

当路由守卫的逻辑走完后,就进入到首页,在首页中,我们会根据路由表(转换过的)来渲染侧边栏菜单

侧边栏菜单是拿 Element Plus 的 el-menu 组件来做的,我们封装了一个菜单组件,除了渲染路由数据外,也更方便自定义配置菜单属性(meta)来实现一些功能

封装不难,就是拿处理后的路由表循环渲染 menu-item,根据 meta 配置项来实现"是否隐藏菜单","当只有一个子菜单时,是否隐藏父级菜单直接显示子菜单内容"等

image.png

菜单组件的封装代码在 basic-menu 文件夹中

到这一步,已经实现了动态权限路由及侧边栏菜单的渲染,但还不算完

因为我们还不能自由定义菜单信息、角色信息、用户信息来实现权限控制,在下一篇文章来聊聊管理模块

了解更多

系列专栏地址:GitHub 博客 | 掘金专栏 | 思否专栏

实战项目:vue-clean-admin

交流讨论

文章如有错误或需要改进之处,欢迎指正

线程池的类型和原理

作者 Asmewill
2026年1月29日 18:22

参考文章:

Java线程池的四种创建方式 - 绝不妥协绝不低头 - 博客园 (cnblogs.com)

JAVA线程池原理详解一 - 冬瓜蔡 - 博客园 (cnblogs.com)

深度解读Java线程池

1.线程池创建之使用线程池工厂

1.1.定长线程池

Executors.newFixedThreadPool(2),核心线程和线程总数一样,使用LinkedBlockingQueue队列(链表,容量Integer.Max)

       //1.步骤一
       ExecutorService newFixedThreadPool=Executors.newFixedThreadPool(2);
        for(int j=0;j<4;j++){
            final  int index=j;
            newFixedThreadPool.execute(new MyRunnable(index));
        }
       //2.步骤二   LinkedBlockingQueue队列的容量为Integer.Max
       public static ExecutorService newFixedThreadPool(int nThreads) {
              return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
      }
     //3.步骤三 Integer.MAX_VALUE
     public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), defaultHandler);
    }
   //4.步骤四  defaultHandler实现了
    private static final RejectedExecutionHandler defaultHandler =  new AbortPolicy();
   public static class AbortPolicy implements RejectedExecutionHandler {
        /**
         * Creates an {@code AbortPolicy}.
         */
        public AbortPolicy() { }

        /**
         * Always throws RejectedExecutionException.
         *
         * @param r the runnable task requested to be executed
         * @param e the executor attempting to execute this task
         * @throws RejectedExecutionException always
         */
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            throw new RejectedExecutionException("Task " + r.toString() +
                                                 " rejected from " +
                                                 e.toString());
        }
    }
1.2.周期性程池,支持周期性或者定时任务。

Executors.newScheduleThreadPool 核心线程是固定的,线程总数是Integer.Max_value,使用DelayedWorkQueue队列(最小堆,容量16)

//1.步骤一
 ScheduledExecutorService newScheduleThreadPool= Executors.newScheduledThreadPool(2);
        for(int k=0;k<4;k++){
           final  int index=k;
            //执行结果:延迟三秒之后执行,除了延迟执行之外和newFixedThreadPool基本相同
            newScheduleThreadPool.schedule(new MyRunnable(index),3, TimeUnit.SECONDS);
        }
  // 2.步骤二
   public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }
  //3.步骤三
   public ScheduledThreadPoolExecutor(int corePoolSize) {
        super(corePoolSize, Integer.MAX_VALUE,
              DEFAULT_KEEPALIVE_MILLIS, MILLISECONDS,
              new DelayedWorkQueue());
    }
   //步骤四
    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), defaultHandler);
    }


1.3.可缓存线程池

Executors.newCachedThreadPool 核心线程数为0,线程总数为Integer.Max,使用SynchronousQueue同步队列(双向链表,容量0) 特点:SynchronousQueue没有容量,可以确保任务立即被处理,而不是排队等待。

   //1.步骤一
    ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
        for(int i=0;i<4;i++) {
            final int index=i;
            newCachedThreadPool.execute(new MyRunnable(index));
     }
 //2.步骤二
  public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }
//3.步骤三
    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), defaultHandler);
    }
  
1.4.单线程池

Executors.newSingleThreadExecutor 核心线程数和线程总数都是1,使用的LinkedBlockingQueue队列(链表实现,容量默认Integer.Max)

  //1.步骤一
  ExecutorService newSingleThreadExtutor=Executors.newSingleThreadExecutor();
        for(int l=0;l<4;l++){
            final int index=l;
            //执行结果:只存在一个线程,顺序执行
            newSingleThreadExtutor.execute(new MyRunnable(index));
  }
//2.步骤二
public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }
//步骤三
 public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), defaultHandler);
    }


2.线程池创建之使用ThreadPoolExecutor构造方法自定义线程池

  • int corePoolSize 核心线程数
  • int maximumPoolSize 线程总数
  • long keepAliveTime 非核心空闲线程存活时间
  • TimeUnit unit 非核心空闲线程存活时间单位
  • BlockingQueue workQueue 任务队列
  • ThreadFactory 线程工厂类 (工厂模式的方式创建线程)(接口,实现了newThread方法)【默认实现】
  • RejectedExecutionHandler 拒绝策略 (接口,实现了RejectedExecution方法) 【默认实现】 #####2.1.自定义线程池 注意:如果想要自定线程工程,或者自定义拒绝策略都可以.
LinkedBlockingQueue  queue=new LinkedBlockingQueue();
ThreadPoolExecutor   theadPool=new ThreadPoolExecutor(
2,4,10,
TimeUnit.SECONDS,queue,
Executors.defaultTheadFactory,
defaultHandler );

2.2.系统默认的线程工工厂和拒绝策略

//线程工厂,用于批量创建线程

public interface ThreadFactory {
    Thread newThread(Runnable r);
}
 public static ThreadFactory defaultThreadFactory() {
        return new DefaultThreadFactory();
}
//拒绝策略,默认为抛出异常
public interface RejectedExecutionHandler {
    void rejectedExecution(Runnable r, ThreadPoolExecutor executor);
}
 private static final RejectedExecutionHandler defaultHandler =new AbortPolicy();

2.3.默认的线程池工厂实现类
private static class DefaultThreadFactory implements ThreadFactory {
        private static final AtomicInteger poolNumber = new AtomicInteger(1);
        private final ThreadGroup group;
        private final AtomicInteger threadNumber = new AtomicInteger(1);
        private final String namePrefix;

        DefaultThreadFactory() {
            SecurityManager s = System.getSecurityManager();
            group = (s != null) ? s.getThreadGroup() :
                                  Thread.currentThread().getThreadGroup();
            namePrefix = "pool-" +
                          poolNumber.getAndIncrement() +
                         "-thread-";
        }

        public Thread newThread(Runnable r) {
            Thread t = new Thread(group, r,
                                  namePrefix + threadNumber.getAndIncrement(),
                                  0);
            if (t.isDaemon())
                t.setDaemon(false);
            if (t.getPriority() != Thread.NORM_PRIORITY)
                t.setPriority(Thread.NORM_PRIORITY);
            return t;
        }
    }
2.4.拒绝策略【A-C-D-D】 (在阻塞队列达到最大值,而且线程数也达到最大值了,线程池无法再处理新提交过来的任务,此时使用拒绝策略)
  • AbortPolicy 默认策略,抛出异常,终止任务
public static class AbortPolicy implements RejectedExecutionHandler {
        public AbortPolicy() { }
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
            throw new RejectedExecutionException("Task " + r.toString() +
                                                 " rejected from " +
                                                 e.toString());
        }
    }
  • CallerRunsPolicy 是使用当前提交任务的线程来执行任务
public static class CallerRunsPolicy implements RejectedExecutionHandler{
    public CallerRunsPolicy(){ }
    public void rejectedExecution(Runnable r , ThreadPoolExecutor e){
         if(!e.shutdown()){
             r.run();//当前提交任务的线程来执行任务
        }
    }
}
  • DiscardPolicy 什么都不做,直接丢弃任务
  public static class DiscardPolicy implements RejectExecutionHandler{
       public DiscardPolicy(){}
       public void rejectExecution(Runnable r, ThreadPoolExecutor e){ //do nothing
       }
  }
  • DiscardOldestPolicy 丢弃最先放入队列的任务,然后将当前任务提交到线程池
public static class DiscardOldestPolicy implements RejectExecutionHandler{
       public DiscardOldestPolicy(){}
       public void rejectExecution(Runnable r, ThreadPoolExecutor e){
                 if(!e.isShutdown()){
                     e.getQueue().poll();
                     e.execute(r);
                  }
       }
}

3.线程池源码解析

3.1.创建的线程池具体配置为:核心线程数量为5个;全部线程数量为10个;工作队列的长度为5。
3.2.我们通过queue.size()的方法来获取工作队列中的任务数。
3.3.线程池原理分析.

当向线程池提交一个任务时,首先判断当前正在工作的线程数是否>=核心线程数,如果小于核心线程数,那么创建新的线程,达到核心线程数量5个后,新的任务进来后不再创建新的线程,而是将任务加入工作队列,任务队列到达上线5个后,新的任务又会创建新的普通线程,直到达到线程池最大的线程数量10个,后面的任务则根据配置的饱和策略来处理。我们这里没有具体配置,使用的是默认的配置AbortPolicy:直接抛出异常。当然,为了达到我需要的效果,上述线程处理的任务都是利用休眠导致线程没有释放!!! #####3.4.线程池的核心线程会被回收吗? 默认情况下,线程池的核心线程是不会被回收的,即使他们处于空闲状态。这样可以避免频繁的创建线程,节省系统开销。当设置allowCoreThreadTimeCount(true),核心线程在空闲时超过keepAliveTime时,会被回收.

3.5.线程池源码分析
3.5.1.核心接口和类

image.png

Executor

Executor接口只是定义了一个基础的execute方法.

public interface Executor{
     void execute(Runnable);
}
ExecutorService

ExecutorService接口定义了线程池的一些常用操作.

public interface ExecutorService extends Executor{

     // 终止线程池,不再接受新任务,会将任务队列的任务执行完成
    void shutdown();
    //立即终止线程池,任务队列的任务不在执行,返回未执行任务集合.
    List<Runnable> shutdownNow();
   //判断线程池是否停掉,只要线程池不是RUNNING状态,都会返回true
   boolean isShutdown();
   //判断线程池是否完成终止,状态是TERMINATED
   boolean isTerminated();
  //提交Runnable任务,返回Future,Future的get方法返回值就是result参数.//get方法会阻塞当前线程
  <T> Future<T> submit(Runnable task,T result);
 //提交Rennable任务,返回Future,Future的get方法返回值就是null.//get方法会阻塞当前线程
  Future<?> submit(Runnable task);

}
AbstractExecutorService

AbstractExecutorService是一个抽象类,实现了接口的一些方法,未实现的方法继续留给子类实现.

public abstract class AbstractExecutorService implement  ExecutorService{
         //提交Runnalbe任务,返回Future,Future的get方法返回值是null//get方法会阻塞当前线程
        public Future<?> submit(Runnable task) {
                if (task == null) {
                    throw new NullPointerException();
               }
              RunnableFuture<Void> ftask = newTaskFor(task, null);
              execute(ftask);//真正执行任务的是子类实现的execute方法
              return ftask;
       }
       //提交Runnable任务,返回Future,Future的get方法返回值是result参数.//get方法会阻塞当前线程
      public <T> Future<T> submit(Runnable task, T result) {
           if (task == null) throw new NullPointerException();
           RunnableFuture<T> ftask = newTaskFor(task, result);
           execute(ftask);//真正执行任务的是子类实现的execute方法
           return ftask;
      }
 
}
任务执行
public class ThreadPoolExecutor extends AbstractExecutorService {
      public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
   
        int c = ctl.get();
      //1.判断工作线程数是否核心线程数
        if (workerCountOf(c) < corePoolSize) {
            //2.添加工作线程执行任务
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
         //3.线程池为Running状态且任务添加到阻塞队列成功
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);//添加工作线程执行任务
        }
        //4.队列满了,达到最大线程数之后,就会走拒绝策略,addWorker()返回值就是false
        else if (!addWorker(command, false))
            reject(command);
    }
}
添加工作线程
  • 第一阶段:设置ctl的线程数+1,主要是判断线程池状态以及线程数是否超限,然后对ctrl的线程数加1。
  • 第二阶段:创建一个线程并启动执行,将创建的工作线程类Worker放入线程池工作线程集合里并启动,另外,如果出现异常情况,就走finally{},移除工作线程Worker,并执行ctl的线程数减1.
 private final HashSet<Worker> workers = new HashSet<>(); //线程池工作线程集合
private boolean addWorker(Runnable firstTask, boolean core) {
        retry:
        for (int c = ctl.get();;) {
  
            for (;;) {
                //如果是核心线程 ,线程池中工作线程总数>=corePoolSize,返回false
              //如果是非核心线程 ,线程池中工作线程总数>=maximumPoolSize,返回false
                if (workerCountOf(c)>= ((core ? corePoolSize : maximumPoolSize) & COUNT_MASK))
                    return false;
                //设置ctl的线程数+1,跳出整个for循环
                if (compareAndIncrementWorkerCount(c))
                    break retry; //跳出retry  for循环
            }
        }
       //以上只是将ctl的线程数+1了,以下是真正的创建一个工作线程
        boolean workerStarted = false;
        boolean workerAdded = false;
        Worker w = null;
        try {
           //Worker构造方法中会使用ThreadFactory创建一个新线程
            w = new Worker(firstTask); 
            final Thread t = w.thread;
            if (t != null) {
                // 创建工作线程时,需要加锁,防止其他线程并发操作。
                final ReentrantLock mainLock = this.mainLock;
                mainLock.lock();
                try {
                    int c = ctl.get();
                    if (isRunning(c) ||
                        (runStateLessThan(c, STOP) && firstTask == null)) {
                        if (t.getState() != Thread.State.NEW)
                            throw new IllegalThreadStateException();
                        workers.add(w); //并将Worker放入工作线程集合里
                        workerAdded = true;
                        int s = workers.size();
                       // 这里就是标记线程池里创建线程的最大值,这个值最大也不会超过maximumPoolSize。
                        if (s > largestPoolSize)
                            largestPoolSize = s;
                    }
                } finally {
                    mainLock.unlock();//释放锁
                }
                if (workerAdded) {
                    t.start(); //启动工作线程>>Worker.run()>>runWorker()>>firstTask.run()
                    workerStarted = true;
                }
            }
        } finally {
            if (! workerStarted)
                addWorkerFailed(w);
        }
        return workerStarted;
 }

//如果添加子线程工作流程失败,
private void addWorkerFailed(Worker w) {
    final ReentrantLock mainLock = this.mainLock;
// 涉及工作线程的相关操作都需要加锁
    mainLock.lock();
    try {
        // 从工作线程集合里移除worker
        if (w != null)
            workers.remove(w);
        // cas操作ctl线程数减1
        decrementWorkerCount();
        // 判断是否需要终止线程池
        tryTerminate();
    } finally {
        mainLock.unlock();
    }
}
工作线程类Worker

Worker类是具体的工作线程类,持有一个Thread线程和一个Runnable任务实例,并实现了Runnable接口.

private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
   // Worker的Thread属性,其实干活的就是这个线程
    final Thread thread;
    // 任务
    Runnable firstTask;
    // 线程已经执行完成的任务总数
    volatile long completedTasks;

   // 构造方法
    Worker(Runnable firstTask) {
        setState(-1); // inhibit interrupts until runWorker
        this.firstTask = firstTask;
        // 线程执行时调用的就是Worker.run() >>runWorker()>>firstTask.run()
         //使用线程工程批量创建线程.
        this.thread = getThreadFactory().newThread(this);
    }
    // run方法执行任务,调用的是外部ThreadPoolExecutor的runWorker方法
    public void run() {
        runWorker(this);
    }
}
执行任务runWorker
  final void runWorker(Worker w) {
        Thread wt = Thread.currentThread();
        Runnable task = w.firstTask;
        w.firstTask = null;
        w.unlock(); // allow interrupts
        boolean completedAbruptly = true;
        try {
            while (task != null || (task = getTask()) != null) {
                w.lock();
                if ((runStateAtLeast(ctl.get(), STOP) ||
                     (Thread.interrupted() &&
                      runStateAtLeast(ctl.get(), STOP))) &&
                    !wt.isInterrupted())
                    wt.interrupt();
                try {
                    beforeExecute(wt, task);
                    try {
                        task.run(); //执行任务
                        afterExecute(task, null);
                    } catch (Throwable ex) {
                        afterExecute(task, ex);
                        throw ex;
                    }
                } finally {
                    task = null;
                    w.completedTasks++;
                    w.unlock();
                }
            }
            completedAbruptly = false;
        } finally {
            processWorkerExit(w, completedAbruptly);
        }
    }

浅谈常用的设计模式

作者 Asmewill
2026年1月29日 18:12

######参考文章:

单例模式
观察者模式 装饰模式

1.单例模式

(1):懒汉(线程不安全,在多线程中无法正常工作)

public class SingleTon {
  private SingleTon(){}
    private static SingleTon instance;
    public static SingleTon getInstance(){
        if(instance==null){
            instance=new SingleTon();
        }
        return instance;
    }
}

(2):懒汉(线程··安全 synchronized同步静态方法,实际锁住了class类,效率低下,无论instance有没有被实例化,每次调用该方法都要检查锁是否释放,耗费资源,所以(3)的这种双重校验锁的方式就是优化方式)

public class SingleTon {
    private SingleTon(){}
    private static SingleTon instance;
  //synchronized给静态方法加锁,实际上锁住的是SingleTon 这个class类,意味着其他现在在执行这个
// getInstance()静态方法时,必须等待class类锁被释放。
    public static synchronized SingleTon getInstance(){
        if(instance==null){ 
            instance=new SingleTon();
        }
        return instance;
    }
}

(3):懒汉(线程··安全 synchronized同步代码块,双重校验锁,synchronized关键字内外都加了一层 if 条件判断)

public class SingleTon {
    private SingleTon(){}
    private  volatile  static  SingleTon instance;
    public static  SingleTon getInstance(){
        if(instance==null){ 
            synchronized (SingleTon.class){ //和静态方法一样,也是锁住的class类
                if(instance==null){
                      //1.在堆上分配内存空间存储SingleTon对象>>2.实例化instance引用对象>>3.内存空间返回的地 
                    //址赋 值给instance引用.
                    instance=new SingleTon(); //volatile修饰Instance,禁止指令重排,防止报错
                }
            }
        }
        return instance;
    }
}

(4).懒汉(静态内部类,这种方式的好处是即使SingleTon被装载了,instance也不会立马实例化)

public class SingleTon {
    private SingleTon(){}
    private static class SingleTonHolder {
        private static SingleTon instance=new SingleTon();
    }
    public static  SingleTon getInstance(){
        return SingleTonHolder.instance;
    }
}

(5).饿汉(一旦SingleTon 被装载了,instance会立马实例化)

public class SingleTon {
    private SingleTon(){}
    private static SingleTon instance=new SingleTon();
    public static  SingleTon getInstance(){
        return instance;
    }
}

(6)饿汉(静态代码块)

public class SingleTonSeven {
    private SingleTonSeven() {
    }
    private static SingleTonSeven singleTonSeven;
    static {
        singleTonSeven=new SingleTonSeven();
    }
    public   static SingleTonSeven getInstance(){
        return  singleTonSeven;
    }
}

(7)枚举

// 单例
public enum  SingleTonSix {
    Instance;
    SingleTonSix() {
        System.out.println("init");
    }
    public void print(){
        System.out.println("ffffffffffff");
    }
}

//测试
public static void main(String[] args) {

      // 系统内存中该类只存在一个对象,节省了系统资源,对于一些需要频繁创建销毁的对象,使用单例模式可以提高系统能。
        SingleTonSix singleTonSix1=SingleTonSix.Instance;
        singleTonSix1.print();
        SingleTonSix singleTonSix2=SingleTonSix.Instance;
        singleTonSix2.print();
        SingleTonSix singleTonSix3=SingleTonSix.Instance;
        singleTonSix3.print();
        SingleTonSix singleTonSix4=SingleTonSix.Instance;
        singleTonSix4.print();
    }

执行效率上:
饿汉式没有加任何的锁,因此执行效率比较高。
懒汉式一般使用都会加同步锁,效率比饿汉式差。

性能上:
饿汉式在类加载的时候就初始化,不管你是否使用,它都实例化了,
所以会占据空间,浪费内存。
懒汉式什么时候需要什么时候实例化,相对来说不浪费内存。

2.工厂模式

(1).普通工厂模式

public interface Sender {
    public void send();
}

public class MailSender implements Sender{
    @Override
    public void send() {
        System.out.println("this is mail sender");
    }
}

public class SmsSender implements  Sender {
    @Override
    public void send() {
        System.out.println("this is sms sender");
    }
}

public class SendFactoryOne {
    public Sender produce(String type){
        if(type.equals("mail")){
            return  new MailSender();
        }else if(type.equals("sms")){
            return  new SmsSender();
        }else{
            return null;
        }
    }
}

(2).工厂方法模式

public class SendFactoryTwo {
    public  Sender produceMail(){
       return new MailSender();
    }
    public  Sender produceSms(){
        return new SmsSender();
    }
}

(3).静态工厂方法模式

public class SendFactoryThree {
    public static Sender produceMail(){
       return new MailSender();
    }
    public static Sender produceSms(){
        return new SmsSender();
    }
}

(4).抽象工程模式

注意:工程方法模式有个问题,类的创建和扩展必须修改工厂类,这违背了闭包原则,所有用到抽象工厂模式,创建多个工厂类,这样一来,直接增加工厂类就可以了,不需要修改之前的代码。
public interface Provider {
    public Sender produce();
}

public class SendMailFactory implements Provider {
    @Override
    public Sender produce() {
        return new MailSender();
    }
}
public class SendSmsFactory implements Provider {
    @Override
    public Sender produce() {
        return new SmsSender();
    }
}
public class MainTest {
    public static void main(String[] args){
        SendFactoryOne sendFactoryOne=new SendFactoryOne();
        sendFactoryOne.produce("mail");
        sendFactoryOne.produce("sms");

         SendFactoryTwo sendFactoryTwo=new SendFactoryTwo();
         sendFactoryTwo.produceMail();
         sendFactoryTwo.produceSms();

         SendFactoryThree.produceMail();
         SendFactoryThree.produceSms();
         
         Provider provider=new SendMailFactory();
         Sender sender=provider.produce();
         sender.send();

         Provider provider1=new SendSmsFactory();
         Sender sender1=provider1.produce();
         sender1.send();
    }
}

3.观察者模式

类似于邮件订阅,当我们浏览一些博客或者wiki时,当你订阅了改文章,如果后续有更新,会及时通知你,是一种一对多的关系。(www.cnblogs.com/luohanguo/p…

(1) 、定义一个抽象观察者接口

public interface Observer {
    public void update();
}

(2)、定义一个抽象被观察者接口

public interface Observerable {
    public void registerObserver(Observer observer);
    public void removeObserver(Observer observer);
    public void notifyObservers();
}

(3).定义被观察者,实现了Observerable接口,对Observerable接口的三个方法进行了具体实现,同时有一个List集合,用以保存注册的观察者,等需要通知观察者时,遍历该集合即可,通知新增一个operation()用于通知所有的观察者。

public class WeChatServer implements Observerable {

   List<Observer>  list=new ArrayList<>();//面向接口编程

    @Override
    public void registerObserver(Observer observer) {
        list.add(observer);
    }

    @Override
    public void removeObserver(Observer observer) {
        if(!list.isEmpty()){
            list.remove(observer);
        }
    }

    @Override
    public void notifyObservers() {
        for(int i=0;i<list.size();i++){
            list.get(i).update();
        }
    }

    public void operation() {
        notifyObservers();
    }
}

(4)、定义具体观察者,微信公众号的具体观察者为用户User1,User2

public class User1 implements Observer {
    @Override
    public void update() {
        System.out.println(User1.class.toString() +"has received the push message!");
    }
}
public class User2 implements Observer {
    @Override
    public void update() {
        System.out.println(User2.class.toString() +"has received the push message!");
    }
}

(5)、编写一个测试类

public class MainTest {
    public static void main(String[] args){
        WeChatServer server=new WeChatServer();
        User1 user1=new User1();
        User2 user2=new User2();
        server.registerObserver(user1);
        server.registerObserver(user2);
        server.operation();
    }
}

image.png

4.装饰模式

装饰模式就是给一个对象增加一些新的功能,而且是动态的,要求装饰对象和被装饰对象实现统一接口或者继承同一个父类,装饰对象持有被装饰对象的实例。

(1).Food类,让其他所有食物都来继承这个类

public class Food {
    private String food_name;
    public Food(){

    }
    public Food(String food_name) {
        this.food_name = food_name;
    }
    public String make(){
        return  food_name;
    }
}

(2).Bread类,Cream类,Vegetable类

public class Bread extends Food{
    private Food basic_food;
    public Bread(Food basic_food){
          this.basic_food=basic_food;
    }
    @Override
    public String make() {
        return basic_food.make()+"+面包";
    }
}

public class Cream extends Food {
    private  Food basic_food;

    public Cream(Food basic_food) {
        this.basic_food = basic_food;
    }

    @Override
    public String make() {
        return basic_food.make()+"+奶油";
    }
}

public class Vegetable extends Food {
    private Food basic_food;

    public Vegetable(Food basic_food) {
        this.basic_food = basic_food;
    }

    @Override
    public String make() {
        return basic_food.make()+"+蔬菜";
    }
}

(3).编写一个测试类

public class MainTest {

    public static void main(String[] args){
        Food food=new Food("香肠");
        Bread bread=new Bread(food);
        Cream cream=new Cream(bread);
        Vegetable vegetable=new Vegetable(cream);
        System.out.print("运行结果:"+vegetable.make()+"\n");
    }
}

image.png

5.适配器模式

(1).类适配器模式(通过继承来实现适配器功能)

我们以ps2与usb的转接为例: Ps2接口:

public interface Ps2 {
    void isPs2();
}

Usb接口:

public interface Usb {
    void isUsb();
}

Usb接口实现类:Usber

public class Usber implements Usb {
    @Override
    public void isUsb() {

    }
}

适配器:AdapterOne

public class AdapterOne extends Usber implements Ps2{
    @Override
    public void isPs2() {
           isUsb();
    }
}

测试方法:

 public static void main(String[] args){
        //1.类适配,通过继承类适配
        Ps2 p=new AdapterOne();
        p.isPs2();
    }
(2).对象适配器模式(通过组合来实现适配器的功能)

适配器:AdapterTwo

public class AdapterTwo implements Ps2{
    private Usber usber;
    public AdapterTwo(Usber usber) {
        this.usber = usber;
    }
    @Override
    public void isPs2() {
        usber.isUsb();
    }
}

测试方法:

public class MainTest {
    public static void main(String[] args){
        //2.对象适配,通过组合实现
        Ps2 p=new AdapterTwo(new Usber());
        p.isPs2();
    }
}
注意:类适配和对象适配模式中所有的adapter均需要实现Ps2接口
(3).接口适配器模式(通过抽象类来实现)

目标接口A:

public interface A {
    void a();
    void b();
    void c();
    void d();
    void e();
}

A的实现类:Adapter

public abstract class Adapter implements A {
    @Override
    public void a() {

    }

    @Override
    public void b() {

    }

    @Override
    public void c() {

    }

    @Override
    public void d() {

    }

    @Override
    public void e() {

    }
}

继承自Adapter的MyAdapter:

public class MyAdapter extends Adapter {

    @Override
    public void a() {
        super.a();
        System.out.println("实现A方法");
    }

    @Override
    public void b() {
        super.b();
        System.out.println("实现B方法");
    }
}

测试方法:

  public static void main(String[] args){
        //3.接口适配,通过抽象类实现
        A aRealize=new MyAdapter();
        aRealize.a();
        aRealize.b();

    }

6.策略模式(一个人走楼梯上楼或者走电梯上楼)

这里以加减算法为例: (1).定义抽象策略角色接口:Strategy

public interface Strategy {
     int calc(int num1,int num2);
}

(2).定义加法策略:AddStrategy

public class AddStrategy implements Strategy{
    @Override
    public int calc(int num1, int num2) {
        return num1+num2;
    }
}

(3).定义减法策略:SubStrategy

public class SubStrategy implements Strategy {
    @Override
    public int calc(int num1, int num2) {
        return num1-num2;
    }
}

(4).环境角色:Environment

public class Environment {
    private Strategy strategy;
    public Environment(Strategy strategy) {
        this.strategy = strategy;
    }

    public int calc(int a,int b){
        return strategy.calc(a,b);
    }
}

测试方法:

 public static void main(String[] args){
        Strategy addStrage=new AddStrategy();
        Environment environment=new Environment(addStrage);
        int sum1=environment.calc(10,10);
        System.out.println("Result1:"+sum1);
        Strategy subStrage=new SubStrategy();
        int sum2=subStrage.calc(20,10);
        System.out.println("Result2:"+sum2);
    }

7.Builder模式

public class Request {
    private String name;
    private String reason;
    private String days;
    private String groupLeaderInfo;
    private String managerInfo;
    private String departmentHeaderInfo;
    private String customInfo;
    public Request(Builder builder){
         // super();
          this.name=builder.name;
          this.reason=builder.reason;
          this.days=builder.days;
          this.groupLeaderInfo=builder.groupLeaderInfo;
          this.managerInfo=builder.managerInfo;
          this.departmentHeaderInfo=builder.departmentHeaderInfo;
          this.customInfo=builder.customInfo;

    }


    public static class Builder{
        private String name;
        private String reason;
        private String days;
        private String groupLeaderInfo;
        private String managerInfo;
        private String departmentHeaderInfo;
        private String customInfo;

        public Builder setName(String name) {
            this.name = name;
            return this;
        }

        public Builder setReason(String reason) {
            this.reason = reason;
            return this;
        }

        public Builder setDays(String days) {
            this.days = days;
            return this;
        }

        public Builder setGroupLeaderInfo(String groupLeaderInfo) {
            this.groupLeaderInfo = groupLeaderInfo;
            return this;
        }

        public Builder setManagerInfo(String managerInfo) {
            this.managerInfo = managerInfo;
            return this;
        }

        public Builder setDepartmentHeaderInfo(String departmentHeaderInfo) {
            this.departmentHeaderInfo = departmentHeaderInfo;
            return this;
        }

        public Builder setCustomInfo(String customInfo) {
            this.customInfo = customInfo;
            return this;
        }
        public Builder newRequest(Request request){
            this.name=request.name;
            this.days=request.days;
            this.reason=request.reason;
            if(request.getGroupLeaderInfo()!=null&&!request.getGroupLeaderInfo().equals("")){
                this.groupLeaderInfo=request.groupLeaderInfo;
            }
            if(request.getManagerInfo()!=null&&!request.getManagerInfo().equals("")){
                this.managerInfo=request.managerInfo;
            }
            if(request.getDepartmentHeaderInfo()!=null&&!request.getDepartmentHeaderInfo().equals("")){
                this.departmentHeaderInfo=request.getDepartmentHeaderInfo();
            }
            return this;
        }

        public Request build(){
            return new Request(this);
        }
    }

    public String getName() {
        return name;
    }

    public String getReason() {
        return reason;
    }

    public String getDays() {
        return days;
    }

    public String getGroupLeaderInfo() {
        return groupLeaderInfo;
    }

    public String getManagerInfo() {
        return managerInfo;
    }

    public String getDepartmentHeaderInfo() {
        return departmentHeaderInfo;
    }

    public String getCustomInfo() {
        return customInfo;
    }

    @Override
    public String toString() {
        return "Request{" +
                "name='" + name + '\'' +
                ", reason='" + reason + '\'' +
                ", days='" + days + '\'' +
                ", groupLeaderInfo='" + groupLeaderInfo + '\'' +
                ", managerInfo='" + managerInfo + '\'' +
                ", departmentHeaderInfo='" + departmentHeaderInfo + '\'' +
                ", customInfo='" + customInfo + '\'' +
                '}';
    }
}

测试方法:

  public static void main(String[] args){
       Request request=new Request.Builder()
               .setName("shuijian")
               .setReason("GoHome")
               .setDays("2days")
               .build();
       System.out.println(request.toString());
    }

8.责任链模式

实例场景     在公司内部员工请假一般情况是这样的:员工在OA系统中提交一封请假邮件,该邮件会自动转发到你的直接上级领导邮箱里,如果你的请假的情况特殊的话,该邮件也会转发到你上级的上级的邮箱,根据请假的情况天数多少,系统会自动转发相应的责任人的邮箱。我们就以这样一种场景为例完成一个责任链模式的代码。为了更清晰的描述这种场景我们规定如下:     ① GroupLeader(组长 ):他能批准的假期为2天,如果请假天数超过2天就将请假邮件自动转发到组长和经理邮箱。     ② Manager(经理):他能批准的假期为4天以内,如果请假天数大于4天将该邮件转发到自动转发到组长、经理和部门领导的邮箱。     ③ DepartmentHeader(部门领导):他能批准的假期为7天以内,如果大于7天就只批准7天。

(1).构造Request对象,如:Builder模式中的Request
(2).构造批准结果对象Result
public class Result {
    public boolean isRality;
    public String  info;

    public Result(boolean isRality, String info) {
        this.isRality = isRality;
        this.info = info;
    }

    public boolean isRality() {
        return isRality;
    }

    public void setRality(boolean rality) {
        isRality = rality;
    }

    public String getInfo() {
        return info;
    }

    public void setInfo(String info) {
        this.info = info;
    }

    @Override
    public String toString() {
        return "Result{" +
                "isRality=" + isRality +
                ", info='" + info + '\'' +
                '}';
    }
}
(3).定义一个接口,这个接口用于处理Request和获取请求结果Result
public interface Ratify {
    //处理请求
    public Result deal(Chain chain);
    //对request和result封装,用来转发
    interface Chain{
        //获取当前的request
        Request request();
        //转发Request
        Result proceed(Request request);
    }
}

看到上面的接口,可能会有人迷惑:在接口Ratify中为什么又定义一个Chain接口呢?其实这个接口是单独定义还是内部接口没有太大关系,但是考虑到Chain接口与Ratify接口的关系为提高内聚性就定义为内部接口了。定义Ratify接口是为了处理Request那为什么还要定义Chain接口呢?这正是责任链接口的精髓之处:转发功能及可动态扩展“责任人”,这个接口中定义了两个方法一个是request()就是为了获取request,如果当前Ratify的实现类获取到request之后发现自己不能处理或者说自己只能处理部分请求,那么他将自己的那部分能处理的就处理掉,然后重新构建一个或者直接转发Request给下一个责任人。可能这点说的不容易理解,我举个例子,在Android与后台交互中如果使用了Http协议,当然我们可能使用各种Http框架如HttpClient、OKHttp等,我们只需要发送要请求的参数就直接等待结果了,这个过程中你可能并没有构建请求头,那么框架帮你把这部分工作给做了,它做的工程中如果使用了责任链模式的话,它肯定会将Request进行包装(也就是添加请求头)成新的Request,我们姑且加他为Request1,如果你又希望Http做本地缓存,那么Request1又会被转发到并且重新进一步包装为Request2。总之Chain这个接口就是起到对Request进行重新包装的并将包装后的Request进行下一步转发的作用。如果还不是很明白也没关系,本实例会演示这一功能机制。

(4).实现Chain接口的的真正的包装Request和转发功能
public class RealChain implements Ratify.Chain {
    public Request request;
    public List<Ratify> ratifyList;
    public int index;
    /**
     * 构造方法
     *
     * @param ratifyList
     *            Ratify接口的实现类集合
     * @param request
     *            具体的请求Request实例
     * @param index
     *            已经处理过该request的责任人数量
     */
    public RealChain(List<Ratify> ratifyList, Request request,int index) {
        this.request = request;
        this.ratifyList = ratifyList;
        this.index = index;
    }
    /**
     * 方法描述:具体转发功能
     */
    @Override
    public Result proceed(Request request) {
        Result proceed=null;
        if(ratifyList.size()>index){
          RealChain realChain=new RealChain(ratifyList,request,index+1);
          Ratify ratify=ratifyList.get(index);
          proceed=ratify.deal(realChain);
        }
        return proceed;
    }

    /***
     * 方法描述:返回当前Request对象或者返回当前进行包装后的Request对象
     * @return
     */
    @Override
    public Request request() {
        return request;
    }
}
(5).GroupLeader、Manager和DepartmentHeader,并让他们实现Ratify接口
public class GroupLeader implements Ratify {
    @Override
    public Result deal(Chain chain) {
        Request request=chain.request();
        System.out.println("GroupLeader====>request:"+request.toString());
        if(Integer.parseInt(request.getDays())>1){
            //包装新的Request对象
            Request newRequest=new Request.Builder().newRequest(request).setGroupLeaderInfo(request.getName()+"平时表现不错,而且现在项目不忙").build();
            return chain.proceed(newRequest);
        }
        return new Result(true,"GroupLeader:早去早回");
    }
}
public class Manager implements Ratify {
    @Override
    public Result deal(Chain chain) {
        Request request=chain.request();
        System.out.println("Manager====>request:"+request.toString());
        if(Integer.parseInt(request.getDays())>3){
            //构建新的Request
            Request newRequest=new Request.Builder().newRequest(request).setManagerInfo(request.getName()+"每月的KPI考核还不错,可以批准").build();
            return chain.proceed(newRequest);

        }
        return new Result(true,"Manager:早点把事情办完,项目离不开你");
    }
}
public class DepartmentHeader implements Ratify {
    @Override
    public Result deal(Chain chain) {
        Request request=chain.request();
        System.out.println("DepartmentHeader=====>request:"+request.toString());
        if(Integer.parseInt(request.getDays())>7){
              return  new Result(false,"DepartmentHeader:你这个时间太长,不能批准");
        }
        return new Result(true,"DepartmentHeader:不要着急,把事情处理完在回来!");
    }
}
public class CustomInterceptor implements Ratify {
    @Override
    public Result deal(Chain chain) {
        Request request=chain.request();
        System.out.println("CustomInterceptor====>"+request.toString());
        String reason=request.getReason();
        if(reason!=null&&reason.equals("事假")){
              Request newRequest=new Request.Builder().newRequest(request).setCustomInfo(request.getName()+"请的是事假,而且很着急,请领导重视一下").build();
              System.out.println("CustomInterceptor====>转发请求");
              return chain.proceed(newRequest);
        }
        return new Result(true,"同意请假");
    }
}
(6).责任链模式工具类
public class ChainOfResponsibilityClient {
    private ArrayList<Ratify> ratifies;

    public ChainOfResponsibilityClient() {
        ratifies=new ArrayList<>();
    }

    /**
     * 为了展示“责任链模式”的真正的迷人之处(可扩展性),在这里构造该方法以便添加自定义的“责任人”
     * @param ratify
     */
    public void addRatifys(Ratify ratify){
        ratifies.add(ratify);
    }

    /***
     *
     * 方法描述:执行请求
     * @param request
     * @return
     */

    public Result execute(Request request){
        ArrayList<Ratify> arrayList=new ArrayList<>();
        arrayList.addAll(ratifies);
        arrayList.add(new GroupLeader());
        arrayList.add(new Manager());
        arrayList.add(new DepartmentHeader());
        RealChain realChain=new RealChain(arrayList,request,0);
        return realChain.proceed(request);
    }
}
(6).测试方法
 public static void main(String[] args){
        //写法一
        Request.Builder builder=new Request.Builder();//通过静态内部类构建builder对象
        builder.setName("zhangsan");
        builder.setDays("5");
        builder.setReason("事假");
        Request request=builder.build();//build方法返回request对象
        //写法二
        Request request1=new Request.Builder().setName("lisi").setDays("7").setReason("事假").build();
        //System.out.print("结果:"+request.toString());

        ChainOfResponsibilityClient client=new ChainOfResponsibilityClient();
        //添加自定义的拦截器到责任人列表顶端
        client.addRatifys(new CustomInterceptor());
        Result result=client.execute(request);
        System.out.println("结果:"+result.toString());

    }

9.享元模式

(1).定义一个接口作为享元角色

public interface IBike {
    void ride(int hours);
}

(2).实现IBike接口,作为具体的享元角色

public class ShareBike implements IBike{
    private int price=2 ;

    @Override
    public void ride(int hours) {
        int total=2*hours;
        System.out.println("ride bike total spend "+total+" RMB");

    }
}

(3).创建享元工厂

public class ShareBikeFactory {
    Map<String,IBike> pool=new HashMap<>();
    public  IBike getBike(String name){
        IBike iBike=null;
        if(!pool.containsKey(name)){
            iBike=new ShareBike();
            pool.put(name,iBike);
            System.out.println("交了199元押金,可以用车:"+name);
        }else{
            iBike=pool.get(name);
            System.out.println("押金已交,直接用车:"+name);
        }
        return iBike;
    }
}

(4).测试类

 public static void main(String[] args) {
        ShareBikeFactory shareBikeFactory=new ShareBikeFactory();
        //第一次骑ofo,交押金
        IBike ofo1=shareBikeFactory.getBike("ofo");
        ofo1.ride(2);
        //第一次骑mobike,交押金
        IBike mobike=shareBikeFactory.getBike("mobike");
        mobike.ride(3);
        //第二次骑mobike,不交押金
        IBike ofo2=shareBikeFactory.getBike("ofo");
        ofo2.ride(4);
    }

10.模板方法模式

(1).创建抽象类,定义算法框架

public abstract class Postman {

    public final void post(){//这里声明为final,不希望子类覆盖这个方法,防止更改流程
        prepare();
        call();
        if(isSign()){
            sign();
        }else{
            refuse();
        }
    }

    protected void refuse() {
    }

    protected void sign() {
        System.out.println("派送完毕,客户已经签收!");
    }

    protected boolean isSign() {
        return true;
    }

    protected abstract void call();

    protected void prepare() {
        System.out.println("快递已经到达,准备派送...");
    }
}

需要注意的是上面的抽象类(Postman)包含了三种类型的方法:抽象方法、具体方法和钩子方法。 抽象方法:需要子类去实现。如上面的call()。 具体方法:抽象父类中直接实现。如上面的prepare()和sign()。 钩子方法:有两种,第一种,它是一个空实现的方法,子类可以视情况来决定是否要覆盖它,如上面的refuse();第二种,它的返回类型通常是boolean类型的,一般用于对某个条件进行判断,如果条件满足则执行某一步骤,否则将不执行,如上面的isSign()。 (2).创建具体实现类,定义算法框架 PostmanA:

public  class PostmanA extends Postman{
    @Override
    protected void call() {
        System.out.println("联系收货人A,准备派送...");
    }
}

PostmanB:

public  class PostmanB extends Postman{
    @Override
    protected void call() {
        System.out.println("联系收货人B,准备派送...");
    }

    @Override
    protected boolean isSign() {
        return false;
    }

    @Override
    protected void refuse() {
        super.refuse();
        System.out.println("商品与实物不符,拒绝签收!");
    }
}

(3).测试类

 public static void main(String[] args){
        //A收货人正常签收
        Postman postmanA=new PostmanA();
        postmanA.post();
        //B收货人拒绝签收
        Postman postmanB=new PostmanB();
        postmanB.post();
    }

image.png

11.备忘录模式

以游戏存档为例: (1).创建发起人角色:Game

public class Game {
    private int mLevel=0;
    private int mIcon=0;

    /***
     * 开始游戏
     */
    public void paly(){
        System.out.print("升级了");
        mLevel++;
        System.out.println("当前等级为:"+mLevel);
        mIcon+=32;
        System.out.println("获得金币:"+mIcon);
        System.out.println("当前金币数量为:"+mIcon);
    }

    /***
     * 退出游戏
     */
    public void exit(){
        System.out.println("退出游戏,属性为:等级="+mLevel+",金币="+mIcon);
    }

    //创建备忘录
    public Memo createMemo(){
        Memo memo=new Memo();
        memo.setmLevel(mLevel);
        memo.setmIcon(mIcon);
        return memo;
    }

    public void setMemo(Memo memo){
        mLevel=memo.getmLevel();
        mIcon=memo.getmIcon();
    }

}

(2).创建备忘录角色:Memo

public class Memo {
    private int mLevel;//等级
    private int mIcon;//金币数量

    public int getmLevel() {
        return mLevel;
    }

    public void setmLevel(int mLevel) {
        this.mLevel = mLevel;
    }

    public int getmIcon() {
        return mIcon;
    }

    public void setmIcon(int mIcon) {
        this.mIcon = mIcon;
    }
}

(3).创建负责人角色:CreateMemo

public class CreateMemo {
    private Memo memo;

    public Memo getMemo() {
        return memo;
    }

    public void setMemo(Memo memo) {
        this.memo = memo;
    }
}

(4).测试类

public static void main(String[] args){
        Game game=new Game();
        game.paly();
        CreateMemo createMemo=new CreateMemo();
        createMemo.setMemo(game.createMemo());//游戏存档
        game.exit();
        //第二次进入游戏
        System.out.println("第二次进入游戏");
        Game secondGame=new Game();
        secondGame.setMemo(createMemo.getMemo());//取出之前备忘录中的数据
        secondGame.paly();
        secondGame.exit();
    }

12.原型模式

(1).创建具体原型类 实现Cloneable接口:

public class Card implements Cloneable {
    private int num;//卡号
    private Spec spec=new Spec();
    public Card(){
        System.out.println("Card 执行构造函数");
    }

    @Override
    protected Card clone() throws CloneNotSupportedException {
        System.out.println("clone时不执行构造函数");
        Card card= (Card) super.clone();
        card.spec=spec.clone();//对Spce对象也调用clone,实现深拷贝
        return card;
    }

    public int getNum() {
        return num;
    }

    public void setNum(int num) {
        this.num = num;
    }

    public Spec getSpec() {
        return spec;
    }

    public void setSpec(int width,int  length) {
        spec.width=width;
        spec.length=length;
    }

    public class Spec implements Cloneable{
        private int width;
        private int length;

        public int getWidth() {
            return width;
        }

        public void setWidth(int width) {
            this.width = width;
        }

        public int getLength() {
            return length;
        }

        public void setLength(int length) {
            this.length = length;
        }

        @Override
        protected Spec clone() throws CloneNotSupportedException {
            return (Spec) super.clone();
        }
    }

    @Override
    public String toString() {
        return "Card{" +
                "num=" + num +
                ", spec=" +"{width="+spec.getWidth()+",length="+spec.getLength()+
                '}';
    }
}

(2).测试类

 public static void main(String[] args) throws CloneNotSupportedException {
        Card card1=new Card();
        card1.setNum(111);
        card1.setSpec(66,67);
        System.out.println(card1.toString());
        System.out.println("---------------------");
        //拷贝
        Card card2=card1.clone();
        System.out.println(card2.toString());
        System.out.println("---------------------");
        //拷贝之后,card2对num进行重新赋值
        card2.setNum(222);
        System.out.println(card1.toString());
        System.out.println(card2.toString());
        System.out.println("---------------------");
        //拷贝之后,card2对Spec进行重新赋值之后,连card1也跟着改变了,这种就是浅拷贝
        card2.setSpec(76,77);
        System.out.println(card1.toString());
        System.out.println(card2.toString());
        System.out.println("---------------------");
    }

image.png

13.命令模式

(1).创建命令接口Command

public interface Command {
    void execute();
}

(2).创建命令接口实现类:ShutDownCommand

public class ShutDownCommand implements Command {
    Receiver receiver;

    public ShutDownCommand(Receiver receiver) {
        this.receiver = receiver;
    }

    @Override
    public void execute() {
        System.out.println("命令角色执行关机命令");
        receiver.action();
    }
}

(3).创建命令执行者Receiver

public class Receiver {
    public void action(){
        System.out.println("接收者执行具体的操作");
        System.out.println("开始执行关机命令");
        System.out.println("退出所有程序");
        System.out.println("关机...");
    }
}

(4).创建调用者Invoker

public class Invoker {
    private  Command command;
    public Invoker(Command command) {
        this.command = command;
    }
    public void action(){
        System.out.println("调用者执行命令");
        command.execute();
    }
}

测试方法:

public static void main(String[] args){
       Receiver receiver=new Receiver();//创建命令接收者
       Command command=new ShutDownCommand(receiver);//创建一个命令的具体实现对象,并指定命令接收者
       Invoker invoke=new Invoker(command);//创建一个命令调用者,并指定具体命令
       invoke.action();
    }
注意:此处调用者与接受者之间的解藕。易于扩展,扩展命令只需新增具体命令类即可,符合开放封闭原则。

image.png

14.代理模式

(1).静态代理 IBuy接口

public interface IBuy {
    void buy();
}

IBuy接口实现类:Home,OverSea

public class Home implements IBuy {
    @Override
    public void buy() {
        System.out.println("国内要买一个包");
    }
}
public class Oversea implements IBuy {
    IBuy buyer;
    public Oversea(IBuy buyer) {
        this.buyer=buyer;
    }

    @Override
    public void buy() {
        System.out.println("我是海外代购");
        buyer.buy();
    }
}

测试方法:

  public static void main(String[] args){
        //静态代理
        IBuy home=new Home();
        IBuy oversea=new Oversea(home);
        oversea.buy();
        System.out.println("----------------------------------------");
        
    }

(2).动态代理(代理类在程序运行时动态生成) 动态代理类:DynamicProxy

public class DynamicProxy implements InvocationHandler {
    private Object obj;//被代理的对象
    public DynamicProxy(Object obj) {
        this.obj=obj;
    }
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        System.out.println("海外动态代理调用方法:"+method.getName());
        Object result=method.invoke(obj,args);
        return result;
    }
}

测试方法:

  public static void main(String[] args){
        //动态代理
        IBuy home=new Home();//被代理类
        ClassLoader classLoader=demestics.getClass().getClassLoader();//获取classloader
        Class[] classes=new Class[]{IBuy.class};//接口类的class数组
        DynamicProxy dynamicProxy=new DynamicProxy(home);//创建动态代理
        IBuy oversea1= (IBuy) Proxy.newProxyInstance(classLoader,classes,dynamicProxy);
        oversea1.buy();//调用海外代购的buy,此处实际上是调用dynamicProxy.invoke()方法
        System.out.println("----------------------------------------");

    }

image.png

JAVA基础之集合框架详解

作者 Asmewill
2026年1月29日 18:03

参考文章:

www.cnblogs.com/xiaoxi/p/60…

www.importnew.com/16658.html

1.集合框架图

Java的集合类主要由两个接口派生而出:Collection和Map,Collection和Map是Java集合框架的根接口,这两个接口又包含了一些子接口或实现类。

image.png

2.Collection接口(Collection包含了List和Set两大分支。)

2.1 List接口的实现类(ArrayList,Vector,LinkedList,Stack)

(1).ArrayList

ArrayList是一个动态数组,它允许任何符合规则的元素插入甚至包括null,每一个ArrayList都有一个初始容量(10),随着容器中的元素不断增加,容器的大小也会随着增加。在每次向容器中增加元素的同时都会进行容量检查,当快溢出时,就会进行扩容操作。所以如果我们明确所插入元素的多少,最好指定一个初始容量值,避免过多的进行扩容操作而浪费时间、效率。

注意: ArrayList擅长于随机访问。同时ArrayList是非同步的。
(2).Vector

与ArrayList相似,但是Vector是同步的。所以说Vector是线程安全的动态数组。它的操作与ArrayList几乎一样。

(3).LinkedList

同样实现List接口的LinkedList与ArrayList不同,ArrayList是一个动态数组,而LinkedList是一个双向链表, 由于实现的方式不同,LinkedList不能随机访问,它所有的操作都是要按照双重链表的需要执行。在列表中索引的操作将从开头或结尾遍历列表(从靠近指定索引的一端)。这样做的好处就是可以通过较低的代价在List中进行插入和删除操作。 与ArrayList一样,LinkedList也是非同步的。如果多个线程同时访问一个List,则必须自己实现访问同步。

注意:创建List时构造一个同步的List:List list = Collections.synchronizedList(new LinkedList(...));

######(4).Stack Stack继承自Vector,实现一个后进先出的堆栈。Stack提供5个额外的方法使得Vector得以被当作堆栈使用。基本的push和pop 方法,还有peek方法得到栈顶的元素,empty方法测试堆栈是否为空,search方法检测一个元素在堆栈中的位置。Stack刚创建后是空栈。

   private static void testStack() {
        /***
         * 栈是一种只能在一端进行插入或删除操作的线性表
         * 特性:先进后出
         */
        Stack<String> stack=new Stack<>();
        //进栈push()
        stack.push("1");
        stack.push("2");
        stack.push("3");
        stack.push("4");
        System.out.println("statck data:"+stack.toString());
        // 取栈顶值(不出栈)
        System.out.println("stack top:"+stack.peek());
        //出栈
        //  stack.pop();
        // stack.pop();
        //stack.pop();
        System.out.println("stack data:"+stack.toString());
        System.out.println("stack is empty:"+stack.empty());
        int index=stack.search("3");//计数从顶部开始
        System.out.println("stack search index:"+index);
        System.out.println("stack search result:"+ stack.get(0));

        List<String> list=new ArrayList<>();
        list.add("1");
        list.add("2");
        list.add("3");
        list.add("4");
        System.out.println("list is empty:"+list.get(3));
        Iterator<String> iterator=list.iterator();
        while (iterator.hasNext()){
            System.out.println(iterator.next());
        }
    }

2.2 Set接口的实现类(HashSet,LinkedHashSet,TreeSet)

(1)HashSet

HashSet 是一个没有重复元素的集合。它是由HashMap实现的,不保证元素的顺序(这里所说的没有顺序是指:元素插入的顺序与输出的顺序不一致),而且HashSet允许使用null 元素。HashSet是非同步的,如果多个线程同时访问一个哈希set,而其中至少一个线程修改了该set,那么它必须保持外部同步。 HashSet按Hash算法来存储集合的元素,因此具有很好的存取和查找性能。

注意:HashSet是由HashMap实现,不保证元素的插入顺序,可以存放null值,仅仅能够存入一个null值。
 private static void testHashSet() {
        /****
         * 元素不重复
         */
        Set<String> hashSet=new HashSet<>();
        hashSet.add("javabbb");
        hashSet.add("java01");
        hashSet.add("java01");
        hashSet.add("java03");
        hashSet.add("java02");
        Set<String>  hashSet1=new HashSet<>();
        hashSet1.add("java05");
        hashSet1.add("java04");
        hashSet1.add("javaaaa");
        hashSet.add(null);//可以插入null
        hashSet.add(null);
        hashSet.addAll(hashSet1);
        boolean isEmpty=hashSet.isEmpty();
        //遍历
        Iterator<String> iterator= hashSet.iterator();
        while (iterator.hasNext()){
            System.out.println(iterator.next());
        }
    }

输出结果: image.png

(2)LinkedHashSet

LinkedHashSet继承自HashSet,其底层是基于LinkedHashMap来实现的,有序,非同步。LinkedHashSet集合同样是根据元素的hashCode值来决定元素的存储位置,但是它同时使用链表维护元素的次序。这样使得元素看起来像是以插入顺序保存的,也就是说,当遍历该集合时候,LinkedHashSet将会以元素的添加顺序访问集合的元素。

注意:LinkedHashSet底层是基于LinkedHashMap来实现
 private static void testLinkedHashSet() {
        /****
         * 因为是链表,所以有序输出
         * 元素不重复
         */
        Set<String> linkedHashSet=new LinkedHashSet<>();
        linkedHashSet.add("java01");
        linkedHashSet.add("java01");
        linkedHashSet.add("java02");
        linkedHashSet.add("java03");
        Set<String> linkedHashSet1=new LinkedHashSet<>();
        linkedHashSet1.add("java04");
        linkedHashSet1.add("java05");
        linkedHashSet1.add(null);
        linkedHashSet1.add(null);
        linkedHashSet.addAll(linkedHashSet1);
        //遍历
        Iterator<String> iterator= linkedHashSet.iterator();
        while (iterator.hasNext()){
            System.out.println(iterator.next());
        }

    }

输出结果: image.png

(3)TreeSet

TreeSet是一个有序集合,其底层是基于TreeMap实现的,非线程安全。TreeSet可以确保集合元素处于排序状态。TreeSet支持两种排序方式,自然排序和定制排序,其中自然排序为默认的排序方式。当我们构造TreeSet时,若使用不带参数的构造函数,则TreeSet的使用自然比较器;若用户需要使用自定义的比较器,则需要使用带比较器的参数。

注意:TreeSet底层是基于TreeMap来实现,Set集合都是非线程安全的

    private static void testIntegerSort() {
        System.out.println("Integer对象自然排序:");
        TreeSet<Integer> treeSetFirst = new TreeSet<>();
        treeSetFirst.add(2);
        treeSetFirst.add(1);
        treeSetFirst.add(4);
        treeSetFirst.add(3);
        treeSetFirst.add(5);
        Iterator<Integer> iterator=treeSetFirst.iterator();
        while (iterator.hasNext()){
                System.out.println(iterator.next());
        }
    }
    private static void testDictionarySort() {
        System.out.println("Dictionary对象自然排序:");
        TreeSet<String> treeSetFirst = new TreeSet<>();
        treeSetFirst.add("Baidu");
        treeSetFirst.add("Tecent");
        treeSetFirst.add("Ali");
        treeSetFirst.add("WanDa");
        treeSetFirst.add("HengDa");
        treeSetFirst.add("12");
        treeSetFirst.add("23a#");
        treeSetFirst.add("#");
        Iterator<String> iterator=treeSetFirst.iterator();
        while (iterator.hasNext()){
            System.out.println(iterator.next());
        }
    }


    private static void testCompatorSort() {
        Set<Student> treeSet=new TreeSet<>();
        treeSet.add(new Student("tecent",2));
        treeSet.add(new Student("JD",1));
        treeSet.add(new Student("wanda",3));
        treeSet.add(new Student("baidu",2));
        treeSet.add(new Student("ali",2));
        treeSet.add(new Student("tecent",2));//重复的元素被剔除了
        System.out.println(treeSet);
        Iterator itTSet = treeSet.iterator();//遍历输出
        while(itTSet.hasNext()){
            System.out.print(itTSet.next() + "\n");
        }
    }

    private static void testSubHeadTailSet() {
        TreeSet nums = new TreeSet();
        nums.add(5);
        nums.add(2);
        nums.add(3);
        nums.add(8);
        nums.add(8);
        //输出集合元素,可以看到集合元素已经处于排序状态,输出【2,3,5,8】
        System.out.println(nums);
        //输出排序后集合里的第一个元素2
        System.out.println(nums.first());
        //输出排序后集合里最后一个元素
        System.out.println(nums.last());
        //输出小于4的集合,不包含4,输出【2,3】
        System.out.println(nums.headSet(4));
        //输出大于5的集合,如果set集合中有5,子集中还应该有5,输出【5,8】
        System.out.println(nums.tailSet(5));
        //输出大于2,小于5的子集,包括2,不包括5,输出集合【2,3】
        System.out.println(nums.subSet(2, 5));
    }
    public static class Student implements Comparable {
        int num;
        String name;

        public Student( String name,int num) {
            this.num = num;
            this.name = name;
        }

        @Override
        public String toString() {
            return "StudentNo:" + num + " ,StudentName:" + name      ;
        }

        public int getNum() {
            return num;
        }

        public void setNum(int num) {
            this.num = num;
        }

        public String getName() {
            return name;
        }

        public void setName(String name) {
            this.name = name;
        }

        @Override
        public int compareTo(Object o) {
            Student student= (Student) o;
            if(num<student.getNum()){//升序排列
                return -1;
            }else if(num==student.getNum()){
                return name.compareTo(student.getName());
            }else{
                return 1;
            }

        }
    }

输出结果: image.png

3.Map接口

Map与List、Set接口不同,它是由一系列键值对组成的集合,提供了key到Value的映射。同时它也没有继承Collection。在Map中它保证了key与value之间的一一对应关系。也就是说一个key对应一个value,所以它不能存在相同的key值,当然value值可以相同。 ####(1).HashMap HashMap可以通过调用Collections的静态方法Collections.synchronizedMap(Map map)进行同步,最多只允许一条记录的键为Null,不支持线程的同步,无序

    private static void testHashMap() {
        /***
         * hashmap的key和value都可以为null
         */
        Map<String, String> map = new HashMap<>();
        for (int i = 0; i <= 3; i++) {
            map.put("key" + i, "value" + i);
        }
        map.put(null,"value4");
        map.put(null,"value5");
        map.put("key6",null);
        map.put("key7",null);
        map.get("key" + 5);
        for (String key : map.keySet()) {
            System.out.println(map.get(key));
        }
    }

输出结果: image.png

输出结果: image.png

(2).LinkedHashMap

LinkedHashMap是HashMap的一个子类,它保留插入的顺序,如果需要输出的顺序和输入时的相同,那么就选用LinkedHashMap。允许使用null值和null键

private static void testLinkHashMap() {
        //linkedhashmap  extends hashmap 比hashmap功能更强大
        Map<String, String> map = new LinkedHashMap<>();
        for (int i = 0; i <= 3; i++) {
            map.put("key" + i, "value" + i);
        }
        map.put(null,"value4");
        map.put(null,"value5");
        map.put("key6",null);
        map.put("key7",null);
        map.get("key" + 5);
        for (String key : map.keySet()) {
            System.out.println(map.get(key));
        }
    }

输出结果: image.png

(2).Hashtable

线程同步,同时key,value都不可以为null,无序的

private static void testHashtable() {
        /***
         * 线程同步的,同时key,value都不可以为null,无序的
         */
        Hashtable<String,Object> hashtable = new Hashtable();
        hashtable.put("baidu","101");
        hashtable.put("ali","102");
        hashtable.put("tencent","103");
        hashtable.put("wanda","105");
        hashtable.put("pingan","107");
        hashtable.put("hengda","106");
        hashtable.put("transsion","104");
        // hashtable.put(null,"2");//java.lang.NullPointerException
        //  hashtable.put("wanke",null);//java.lang.NullPointerException
        for(String key:hashtable.keySet()){
            System.out.println(key+"="+hashtable.get(key));
        }
    }

(4).ConCurrentHashMap

ConcurrentHashMap和HashTable都是线程安全的,无序的,key和value都不能为null,性能上要比Hashtable要强,是一个加强版本的Hashtable。

private static void testConcurrentHashMap() {
        /***
         * ConcurrentHashMap和HashTable都是线程安全的,可以在多线程中进行,
         * key和value都不能为null,性能上要比Hashtable要强
         * 线程同步的,同时key,value都不可以为null
         */
        ConcurrentHashMap<String,Object> concurrentHashMap = new ConcurrentHashMap();
        concurrentHashMap.put("baidu","101");
        concurrentHashMap.put("ali","102");
        concurrentHashMap.put("tencent","103");
        concurrentHashMap.put("wanda","105");
        concurrentHashMap.put("pingan","107");
        concurrentHashMap.put("hengda","106");
        concurrentHashMap.put("transsion","104");
        // concurrentHashMap.put(null,"2");//java.lang.NullPointerException
        // concurrentHashMap.put("wanke",null);//java.lang.NullPointerException
        for(String key:concurrentHashMap.keySet()){
            System.out.println(key+"="+concurrentHashMap.get(key));
        }
    }

输出结果: image.png

(5).TreeMap

TreeMap 是一个有序的key-value集合,非同步,基于红黑树(Red-Black tree)实现,每一个key-value节点作为红黑树的一个节点。TreeMap存储时会进行排序的,会根据key来对key-value键值对进行排序,其中排序方式也是分为两种,一种是自然排序,一种是定制排序,具体取决于使用的构造方法。 ######注意:key不能为null,value可以为null

 //自然排序顺序:
    public static void naturalSort(){
        //第一种情况:Integer对象
        System.out.println("Integer对象自然排序:");
        TreeMap<Integer,String> treeMapFirst = new TreeMap<Integer, String>();
        treeMapFirst.put(1,"jiaboyan");
        treeMapFirst.put(6,"jiaboyan");
        treeMapFirst.put(3,"jiaboyan");
        treeMapFirst.put(10,"jiaboyan");
        treeMapFirst.put(7,"jiaboyan");
        treeMapFirst.put(13,"jiaboyan");
        //treeMapFirst.put(null,"jiaboyan");java.lang.NullPointerException
        treeMapFirst.put(14,null);//可以运行
        System.out.println(treeMapFirst.toString());

        //第二种情况:SortedTest对象
        System.out.println("SortedTest对象排序一:");
        TreeMap<SortedTest,String> treeMapSecond = new TreeMap<SortedTest, String>();
        treeMapSecond.put(new SortedTest(10),"jiaboyan");
        treeMapSecond.put(new SortedTest(1),"jiaboyan");
        treeMapSecond.put(new SortedTest(13),"jiaboyan");
        treeMapSecond.put(new SortedTest(4),"jiaboyan");
        treeMapSecond.put(new SortedTest(0),"jiaboyan");
        treeMapSecond.put(new SortedTest(9),"jiaboyan");
        System.out.println(treeMapSecond.toString());
        //默认是根据key的自然排序来组织(比如integer的大小,String的字典排序)
        System.out.println("integer和字典对象排序二:");
        TreeMap<String,SortedTest> treeMapThree = new TreeMap<String,SortedTest >();
        treeMapThree.put("2key1",new SortedTest(10));
        treeMapThree.put("1key2",new SortedTest(1));
        treeMapThree.put("bey3",new SortedTest(13));
        treeMapThree.put("key6",new SortedTest(4));
        treeMapThree.put("key5",new SortedTest(0));
        treeMapThree.put("key4",new SortedTest(9));
        System.out.println(treeMapThree.toString());
    }

    public static class SortedTest implements Comparable<SortedTest> {
        private int age;
        public SortedTest(int age){
            this.age = age;
        }
        public int getAge() {
            return age;
        }
        public void setAge(int age) {
            this.age = age;
        }

        @Override
        public String toString() {
            return "age:"+age;
        }

        //自定义对象,实现compareTo(T o)方法:
        public int compareTo(SortedTest sortedTest) {
            int num = this.age - sortedTest.getAge();
            //为0时候,两者相同:
            if(num==0){
                return 0;
                //大于0时,传入的参数小:
            }else if(num>0){
                return 1;
                //小于0时,传入的参数大:
            }else{
                return -1;
            }
        }
    }

输出结果: image.png

【配置化 CRUD 01】搜索重置组件:封装与复用

作者 大杯咖啡
2026年1月29日 18:03

一:前言

在后台管理系统的配置化 CRUD 开发中,搜索+重置是高频组合场景,几乎所有列表页都需要通过搜索筛选数据、通过重置恢复初始查询状态等等...。基于此,本文将详细讲解「搜索重置组件」的封装思路及使用方法,该组件基于Vue3 + Element Plus 开发,支持配置化扩展、响应式联动,可直接集成到配置化 CRUD 体系中,提升开发效率与代码一致性。

二:解决问题

封装搜索重置组件主要为了解决以下几个问题:

1.代码冗余 : 每个列表页都要重复编写表单结构、搜索按钮、重置按钮,以及对应的点击事件、数据校验逻辑;

2.风格不统一:不同开发人员编写的搜索表单,在布局、按钮尺寸、标签宽度、间距等细节上可能存在差异;

3.维护成本高:当需要修改搜索表单的布局、按钮样式,需要逐个页面排查修改;

...

三:具体实现

在开始之前,请先阅读一下本专栏的第一篇文章,动态表单的实现是搜索重置组件的基础:

juejin.cn/post/757946…

image.png

接下来我们可以思考一下一个通用的搜索重置组件具备的基本功能

1.搜索项的展示

2.搜索项默认展示搜索初始值

3.搜素与重置按钮功能

...

扩展功能:

1.搜索表单项的联动

2.搜索表单项的校验

...

接下来我们一步一步来实现:

3.1 基础功能实现

先完成最简单的部分 : 展示搜索项和搜索重置按钮、以及基本样式统一处理

组件基础实现:


type SearchPrams = {
  schema?: FormOptions[] // 配置表
  search?: () => void  // 搜索回调
  reset?: () => void   // 重置回调
  labelWidth?: string,
  flex?: number
}
const props = withDefaults(defineProps<SearchPrams>(), {
  schema: {},
  labelWidth:'140px',
  flex:5
})

const codeFormRef = ref(null)

//搜索
const search = async () => {
  const data = await codeFormRef?.value?.getData()
  emits('search', data)
}

//重置
const reset = () => {
  codeFormRef?.value?.resetFields('')
  emits('reset', {})
}

 <div class="sea-box">
    <CodeForm
      class="form-box"
      :style="{ flex: props?.flex || 5 }"
      layoutType="cell"
      ref="codeFormRef"
      :schema="schema"
      :labelWidth="props.labelWidth"
    >
    </CodeForm>
    <div class="sea-btn-box">
      <div>
        <ElButton
          type="primary"
          :style="{ width: '80px' }"
          @click="search"
          >{{ $t('Search') }}</ElButton
        >
        <ElButton
          :style="{ width: '80px', marginLeft: '15px' }"
          @click="reset"
          >{{ $t('Reset') }}</ElButton
        >
      </div>
    </div>
  </div>
  
 <style scoped>
    .sea-btn-box {
      flex: 1;
      display: flex;
      justify-content: flex-end;
    }
    .form-box {
      flex: 5;
    }
    .sea-box {
      display: flex;
      padding: 20px;
      padding-bottom: 0;
      padding-top: 0;
    }
</style>

外部定义配置表:

  const searchColumn = [
      {
        label: '姓名',
        prop: 'name',
        component: 'Input',
      },
      {
        label: '年龄',
        prop: 'age',
        component: 'Input',
      },
      {
        label: '上学阶段',
        prop: 'jieduan',
        component: 'Select',
        componentProps: {
            options: [
              {
                label: '幼儿园',
                value: 1
              },
              {
                label: '其他阶段',
                value: 2
              }
            ]
          }
      },
]

引入组件使用:

 <Search
    :schema="allshema.searchcolumns"
    @search="(params) => console.log('点击查询:',{params})"
    @reset="() => setSearchParams({}, true, true)"
   >
</Search>

运行截图:

image.png

到这一步我们就已经实现了基本功能:展示表单、统一风格、查询重置

当然我们可能会想要某些表单项具有初始值,或者不展示重置按钮,只要组件内部稍加改造一下就行:


type SearchPrams = {
  showSearch?: boolean // 展示搜索按钮
  showReset?: boolean // 展示重置按钮
  schema?: any // 配置表
  search?: () => any
  reset?: () => any
  labelWidth?: string,
  flex?: number
}

 <div class="sea-btn-box">
      <div>
        <ElButton
          v-if="showSearch"
          type="primary"
          :style="{ width: '80px' }"
          @click="search"
          >{{ $t('Search') }}</ElButton
        >
        <ElButton
          v-if="showReset"
          :style="{ width: '80px', marginLeft: '15px' }"
          @click="reset"
          >{{ $t('Reset') }}</ElButton
        >
      </div>
 </div>

外部引入:

const searchColumn = [
      {
        label: '姓名',
        prop: 'name',
        initValue: '初始化名字',
        component: 'Input',
      },
      ...
  ]
  <Search
     :schema="searchColumn"
     @search="(params) => console.log('点击查询:',{params})"
     :showReset="false"
    >
 </Search>
 

运行截图:

image.png

这样就实现了按钮的展示与隐藏以及初始化默认值。

3.2 扩展功能实现

接下来我们继续实现一下扩展功能:

1.表单项的联动

利用动态表单组件内置的 setValues、setSchemas方法,

组件内部增加方法定义及暴露:


const setValues = (data: any) => {
  codeFormRef?.value?.setValues(data)
}

const setSchemas = (data: any) => {
  codeFormRef?.value?.setSchemas(data)
}

defineExpose({
  getData,
  setValues,
  setSchemas
})

外部增加搜索组件的ref引用:

const searchRef: any = ref(null)

const searchColumn = [
  {
    label: '姓名',
    prop: 'name',
    initValue: '初始化名字',
    component: 'Input',
    componentProps: {
      onInput: (e: any) => {
         console.log('姓名输入框输入事件', e)
         searchRef.value?.setSchemas([
             {
                prop: 'age',
                path: 'componentProps.placeholder',
                value: `请输入${e}的年龄`
            }
         ])
      }
    }
  },
  {
    label: '年龄',
    prop: 'age',
    component: 'Input',
  },
  ...
]

<Search
   ref="searchRef"
   :schema="allshema.searchcolumns"
   @search="setSearchParams"
   @reset="() => setSearchParams({}, true, true)"
  >
</Search>

运行截图:

image.png

这样就实现了搜索表单项之间的联动。

2.表单项的校验

组件内部改动:

type SearchPrams = {
  showSearch?: boolean // 展示搜索
  showReset?: boolean // 展示重置按钮
  isVaildSearch?: boolean // 是否校验搜索
  schema?: any // 配置表
  search?: () => any
  reset?: () => any
  labelWidth?: string,
  flex?: number
}

const props = withDefaults(defineProps<SearchPrams>(), {
  showSearch: true,
  showReset: true,
  isVaildSearch: false,
  schema: {}, // 表单配置
  labelWidth:'140px',
  flex:5
})

const search = async () => {
  if(props.isVaildSearch) {
    const valid = await codeFormRef?.value?.validate();
    if(!valid) return;
  }
  const data = await codeFormRef?.value?.getData()
  emits('search', data)
}

外部引入使用:

const searchColumn = [
    ...,
    {
       label: '年龄',
       prop: 'age',
       component: 'Input',
       formItemProps: {
         rules:[
             {
               required: true,
               message: '请输入年龄',
               trigger: 'blur'
             }
          ]
       }
    },
    ...
]

<Search
    ref="searchRef"
    :schema="searchColumn"
    @search="(params) => console.log('点击查询:',{params})"
    :showReset="false"
    :isVaildSearch="true"
   >
</Search>

运行截图:

image.png

这样就实现了搜索表单项的表单校验。

以上就是搜索重置组件的核心实现步骤~

专业指南:从核心概念到3D动效实现

作者 Lee川
2026年1月29日 18:03

CSS3 专业指南:从核心概念到3D动效实现

引言:CSS3的演进与现代化布局体系

CSS3不仅是CSS2.1的简单扩展,而是Web样式设计的一次革命性升级。自2011年开始逐步标准化,CSS3引入了模块化设计理念,将样式规范拆分为独立模块,每个模块可以独立演进。这种设计使得Flexbox、Grid、动画、变换等现代特性得以快速发展,彻底改变了前端开发者的工作方式。

一、CSS3核心模块架构

1.1 选择器模块:精准元素定位

/* 属性选择器 - 精准匹配 */
input[type="email"] {
  border-color: #3498db;
}

/* 结构伪类选择器 */
li:nth-child(2n) { /* 偶数项 */
  background-color: #f8f9fa;
}

li:nth-child(odd) { /* 奇数项 */
  background-color: #e9ecef;
}

/* 目标伪类 */
section:target {
  background-color: #fff3cd;
  animation: highlight 1s ease;
}

/* 否定伪类 */
div:not(.exclude) {
  opacity: 1;
  transition: opacity 0.3s;
}

/* 状态伪类 */
input:focus-within {
  box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.3);
}

1.2 盒模型增强:多列布局实践

/* 经典多列布局 */
.article-content {
  column-count: 3;
  column-gap: 2em;
  column-rule: 1px solid #dee2e6;
  column-width: 300px;
  
  /* 避免元素跨列断开 */
  break-inside: avoid;
}

/* 列间平衡优化 */
.balanced-columns {
  column-count: 3;
  column-fill: balance; /* 列高尽量平衡 */
}

/* 响应式多列 */
@media (max-width: 768px) {
  .article-content {
    column-count: 2;
  }
}

@media (max-width: 480px) {
  .article-content {
    column-count: 1;
  }
}

二、Flexbox:一维布局的革命

2.1 Flex容器与项目的基础配置

<div class="flex-container">
  <div class="flex-item">项目1</div>
  <div class="flex-item">项目2</div>
  <div class="flex-item">项目3</div>
</div>
.flex-container {
  display: flex;
  flex-direction: row; /* 主轴方向: row | row-reverse | column | column-reverse */
  flex-wrap: wrap; /* 换行: nowrap | wrap | wrap-reverse */
  justify-content: center; /* 主轴对齐 */
  align-items: center; /* 交叉轴对齐 */
  align-content: stretch; /* 多行对齐 */
  gap: 1rem; /* 项目间距 */
  min-height: 300px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  padding: 20px;
}

.flex-item {
  flex: 1 0 200px; /* grow | shrink | basis */
  min-height: 100px;
  background: rgba(255, 255, 255, 0.9);
  border-radius: 8px;
  display: flex;
  align-items: center;
  justify-content: center;
  font-weight: bold;
  transition: all 0.3s ease;
}

.flex-item:hover {
  transform: translateY(-5px);
  box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}

2.2 Flex项目的高级控制

/* 项目排序控制 */
.flex-item:nth-child(1) { order: 3; }
.flex-item:nth-child(2) { order: 1; }
.flex-item:nth-child(3) { order: 2; }

/* 项目对齐覆盖 */
.flex-item.special {
  align-self: flex-start; /* 覆盖容器align-items */
  flex-grow: 2; /* 比其他项目多占空间 */
}

/* 响应式Flex调整 */
@media (max-width: 768px) {
  .flex-container {
    flex-direction: column;
  }
  
  .flex-item {
    flex-basis: auto;
    width: 100%;
  }
}

三、CSS Grid:二维布局的终极解决方案

3.1 网格系统基础架构

.grid-container {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  grid-template-rows: auto;
  gap: 1.5rem;
  padding: 20px;
  background: #f8f9fa;
  min-height: 400px;
}

.grid-item {
  background: white;
  border-radius: 12px;
  padding: 1.5rem;
  box-shadow: 0 4px 6px rgba(0,0,0,0.1);
  transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}

/* 显式网格定位 */
.grid-item:nth-child(1) {
  grid-column: 1 / 3; /* 跨越两列 */
  grid-row: 1;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
}

.grid-item:nth-child(2) {
  grid-column: 3;
  grid-row: 1 / 3; /* 跨越两行 */
}

/* 隐式网格行为 */
.grid-container {
  grid-auto-flow: dense; /* 自动填充空白 */
  grid-auto-rows: minmax(150px, auto); /* 隐式行高 */
}

3.2 高级网格布局模式

/* 杂志式布局 */
.magazine-layout {
  display: grid;
  grid-template-areas:
    "header header header"
    "sidebar content ads"
    "footer footer footer";
  grid-template-columns: 200px 1fr 200px;
  grid-template-rows: auto 1fr auto;
  gap: 20px;
  height: 100vh;
}

.header { grid-area: header; background: #2c3e50; color: white; }
.sidebar { grid-area: sidebar; background: #ecf0f1; }
.content { grid-area: content; background: white; }
.ads { grid-area: ads; background: #f1c40f; }
.footer { grid-area: footer; background: #34495e; color: white; }

/* 响应式网格调整 */
@media (max-width: 1024px) {
  .magazine-layout {
    grid-template-areas:
      "header"
      "sidebar"
      "content"
      "ads"
      "footer";
    grid-template-columns: 1fr;
    grid-template-rows: auto;
  }
}

四、水平垂直居中:全方位解决方案

4.1 传统居中方案

/* 方案1:绝对定位 + transform */
.centered-1 {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  background: #3498db;
  color: white;
  padding: 2rem;
  border-radius: 8px;
}

/* 方案2:表格单元格 */
.parent-table {
  display: table;
  width: 100%;
  height: 300px;
  background: #ecf0f1;
}

.child-table {
  display: table-cell;
  vertical-align: middle;
  text-align: center;
}

4.2 现代居中方案

/* 方案3:Flexbox居中 */
.parent-flex {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 300px;
  background: linear-gradient(45deg, #ff9a9e, #fad0c4);
}

.child-flex {
  padding: 2rem;
  background: white;
  border-radius: 12px;
  box-shadow: 0 10px 40px rgba(0,0,0,0.1);
}

/* 方案4:Grid居中 */
.parent-grid {
  display: grid;
  place-items: center; /* 一行代码实现居中 */
  height: 300px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

/* 方案5:Margin auto (块级元素) */
.block-centered {
  width: 200px;
  height: 200px;
  margin: 50px auto;
  background: #2ecc71;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
}

4.3 文本居中与行内元素

/* 文本水平居中 */
.text-center {
  text-align: center;
}

/* 行内元素居中 */
.inline-parent {
  text-align: center;
  height: 100px;
  line-height: 100px; /* 单行文本垂直居中 */
  background: #f8f9fa;
}

/* 多行文本垂直居中 */
.multiline-center {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 200px;
  text-align: center;
  background: #fff3cd;
}

五、CSS3变换与过渡:交互动效基础

5.1 2D变换系统

.transform-demo {
  width: 200px;
  height: 200px;
  background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
  transition: all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-weight: bold;
}

/* 旋转变换 */
.transform-demo.rotate:hover {
  transform: rotate(45deg);
}

/* 缩放变换 */
.transform-demo.scale:hover {
  transform: scale(1.2);
}

/* 倾斜变换 */
.transform-demo.skew:hover {
  transform: skew(15deg, 15deg);
}

/* 多重变换 */
.transform-demo.multiple:hover {
  transform: rotate(15deg) scale(1.1) translateX(20px);
  box-shadow: 0 20px 40px rgba(0,0,0,0.2);
}

/* 变换原点控制 */
.transform-demo.origin {
  transform-origin: top left; /* 左上角为变换原点 */
}

5.2 过渡动画高级应用

/* 复杂过渡效果 */
.card {
  width: 300px;
  height: 400px;
  background: white;
  border-radius: 20px;
  overflow: hidden;
  position: relative;
  transition: all 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275);
  box-shadow: 0 15px 35px rgba(0,0,0,0.1);
}

.card:hover {
  transform: translateY(-20px) scale(1.05);
  box-shadow: 0 30px 60px rgba(0,0,0,0.2);
}

.card-content {
  padding: 2rem;
  opacity: 0;
  transform: translateY(50px);
  transition: all 0.5s ease 0.2s; /* 延迟触发 */
}

.card:hover .card-content {
  opacity: 1;
  transform: translateY(0);
}

/* 过渡性能优化 */
.optimized-transition {
  /* 使用transform和opacity以获得GPU加速 */
  transition: transform 0.3s ease, opacity 0.3s ease;
  
  /* 避免动画布局抖动 */
  will-change: transform, opacity;
}

六、CSS3 3D变换:深度与空间感

6.1 3D变换基础

<div class="scene">
  <div class="cube">
    <div class="face front">前面</div>
    <div class="face back">后面</div>
    <div class="face right">右面</div>
    <div class="face left">左面</div>
    <div class="face top">上面</div>
    <div class="face bottom">下面</div>
  </div>
</div>
/* 3D场景设置 */
.scene {
  width: 200px;
  height: 200px;
  perspective: 1000px; /* 透视距离,值越小3D效果越强 */
  margin: 100px auto;
}

/* 3D容器 */
.cube {
  width: 100%;
  height: 100%;
  position: relative;
  transform-style: preserve-3d; /* 保持3D空间 */
  transition: transform 1s ease-in-out;
  animation: rotateCube 10s infinite linear;
}

/* 立方体面 */
.face {
  position: absolute;
  width: 200px;
  height: 200px;
  background: rgba(52, 152, 219, 0.8);
  border: 2px solid white;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.5rem;
  color: white;
  font-weight: bold;
  backface-visibility: visible;
}

/* 各面定位 */
.front  { transform: rotateY(0deg) translateZ(100px); }
.back   { transform: rotateY(180deg) translateZ(100px); }
.right  { transform: rotateY(90deg) translateZ(100px); }
.left   { transform: rotateY(-90deg) translateZ(100px); }
.top    { transform: rotateX(90deg) translateZ(100px); }
.bottom { transform: rotateX(-90deg) translateZ(100px); }

/* 立方体旋转动画 */
@keyframes rotateCube {
  0% { transform: rotateX(0) rotateY(0) rotateZ(0); }
  25% { transform: rotateX(90deg) rotateY(180deg); }
  50% { transform: rotateX(180deg) rotateY(360deg); }
  75% { transform: rotateX(270deg) rotateY(540deg); }
  100% { transform: rotateX(360deg) rotateY(720deg); }
}

/* 交互式旋转 */
.cube:hover {
  animation-play-state: paused;
  transform: rotateX(45deg) rotateY(45deg);
}

6.2 3D卡片翻转效果

/* 3D卡片翻转 */
.flip-card {
  width: 300px;
  height: 400px;
  perspective: 1000px;
  cursor: pointer;
}

.flip-card-inner {
  position: relative;
  width: 100%;
  height: 100%;
  transition: transform 0.8s;
  transform-style: preserve-3d;
}

.flip-card:hover .flip-card-inner {
  transform: rotateY(180deg);
}

.flip-card-front,
.flip-card-back {
  position: absolute;
  width: 100%;
  height: 100%;
  backface-visibility: hidden;
  border-radius: 20px;
  overflow: hidden;
  box-shadow: 0 15px 35px rgba(0,0,0,0.1);
}

.flip-card-front {
  background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
  display: flex;
  align-items: center;
  justify-content: center;
  color: white;
  font-size: 2rem;
}

.flip-card-back {
  background: linear-gradient(45deg, #667eea, #764ba2);
  transform: rotateY(180deg);
  padding: 2rem;
  color: white;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

七、CSS动画系统:关键帧动画详解

7.1 复杂关键帧动画

/* 多阶段动画 */
@keyframes multiStep {
  0% {
    transform: translateX(0) scale(1);
    background-color: #ff6b6b;
  }
  25% {
    transform: translateX(100px) scale(1.2);
    background-color: #4ecdc4;
  }
  50% {
    transform: translateX(200px) scale(1);
    background-color: #45b7d1;
  }
  75% {
    transform: translateX(100px) scale(0.8);
    background-color: #96ceb4;
  }
  100% {
    transform: translateX(0) scale(1);
    background-color: #ff6b6b;
  }
}

.animated-box {
  width: 100px;
  height: 100px;
  border-radius: 12px;
  animation: multiStep 4s ease-in-out infinite;
  animation-fill-mode: both;
}

/* 动画控制 */
.paused {
  animation-play-state: paused;
}

.slow {
  animation-duration: 8s;
  animation-timing-function: ease-in;
}

.alternate {
  animation-direction: alternate;
}

7.2 性能优化的动画实践

/* GPU加速动画 */
.gpu-animated {
  /* 使用transform和opacity触发GPU加速 */
  transform: translateZ(0);
  will-change: transform, opacity;
  
  animation: smoothSlide 2s ease-in-out infinite;
}

@keyframes smoothSlide {
  0%, 100% {
    transform: translateX(0) translateZ(0);
  }
  50% {
    transform: translateX(100px) translateZ(0);
  }
}

/* 减少重绘的动画 */
.optimized-animation {
  /* 只动画transform和opacity属性 */
  animation: optimizedMove 3s infinite;
  
  /* 创建独立的合成层 */
  backface-visibility: hidden;
  -webkit-font-smoothing: subpixel-antialiased;
}

@keyframes optimizedMove {
  0% {
    transform: translateX(0) scale(1);
    opacity: 1;
  }
  100% {
    transform: translateX(300px) scale(1.5);
    opacity: 0.8;
  }
}

八、现代CSS特性与最佳实践

8.1 CSS自定义属性(CSS变量)

:root {
  --primary-color: #3498db;
  --secondary-color: #2ecc71;
  --spacing-unit: 8px;
  --border-radius: 12px;
  --transition-speed: 0.3s;
  --box-shadow: 0 10px 30px rgba(0,0,0,0.1);
  --gradient-bg: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
}

.component {
  background: var(--gradient-bg);
  padding: calc(var(--spacing-unit) * 3);
  border-radius: var(--border-radius);
  transition: all var(--transition-speed) ease;
  box-shadow: var(--box-shadow);
}

.component:hover {
  --primary-color: #2980b9; /* 动态修改变量 */
  transform: translateY(-5px);
  box-shadow: 0 20px 40px rgba(0,0,0,0.2);
}

/* JS与CSS变量交互 */
const element = document.querySelector('.component');
element.style.setProperty('--primary-color', '#e74c3c');

8.2 现代布局技术整合

/* 现代响应式布局系统 */
.modern-layout {
  display: grid;
  grid-template-columns: 
    [full-start] minmax(var(--spacing-unit), 1fr) 
    [content-start] min(100% - 2rem, 1200px) 
    [content-end] minmax(var(--spacing-unit), 1fr) 
    [full-end];
  
  gap: var(--spacing-unit);
}

.item {
  grid-column: content;
  
  /* 内部使用Flexbox */
  display: flex;
  flex-wrap: wrap;
  gap: var(--spacing-unit);
}

/* 子元素使用CSS Grid自动布局 */
.sub-item {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: calc(var(--spacing-unit) * 2);
}

/* 容器查询(前沿特性) */
@container (min-width: 400px) {
  .card {
    display: grid;
    grid-template-columns: 1fr 2fr;
  }
}

九、性能优化与最佳实践

9.1 渲染性能优化

/* 硬件加速技巧 */
.optimized-element {
  /* 触发GPU加速 */
  transform: translateZ(0);
  backface-visibility: hidden;
  perspective: 1000px;
  
  /* 减少布局抖动 */
  will-change: transform, opacity;
  
  /* 优化动画性能 */
  animation: optimizedAnimation 0.3s ease forwards;
}

@keyframes optimizedAnimation {
  from {
    opacity: 0;
    transform: translateY(20px) scale(0.95);
  }
  to {
    opacity: 1;
    transform: translateY(0) scale(1);
  }
}

/* 减少重排和重绘 */
.stable-layout {
  /* 避免频繁修改会引起重排的属性 */
  position: fixed; /* 脱离文档流 */
  
  /* 使用transform代替top/left */
  transition: transform 0.3s ease;
}

/* 使用content-visibility优化渲染 */
.large-list {
  content-visibility: auto;
  contain-intrinsic-size: 0 500px; /* 预估高度 */
}

9.2 现代CSS工作流程

/* 层叠层管理 */
@layer base, components, utilities;

@layer base {
  /* 重置样式和基础样式 */
  * { box-sizing: border-box; }
  body { font-family: system-ui, sans-serif; }
}

@layer components {
  /* 组件样式 */
  .card { /* 卡片组件样式 */ }
  .button { /* 按钮组件样式 */ }
}

@layer utilities {
  /* 工具类 */
  .text-center { text-align: center; }
  .flex-center { display: flex; align-items: center; justify-content: center; }
}

/* 容器查询支持 */
@container (min-width: 400px) {
  .responsive-card {
    grid-template-columns: 1fr 2fr;
  }
}

/* 媒体查询的现代写法 */
@media (width >= 768px) {
  .responsive-element {
    font-size: 1.125rem;
  }
}

结论:CSS3的演进与未来趋势

CSS3的发展已经从简单的样式描述语言演变为功能强大的布局和动画引擎。通过掌握Flexbox和Grid,我们可以构建响应式、灵活的布局系统;通过transform、transition和animation,我们可以创建流畅的交互动效;通过3D变换,我们可以为Web应用增加深度和空间感。

关键要点总结:

  1. 布局选择:Flexbox用于一维布局,Grid用于二维布局
  2. 居中策略:根据上下文选择最合适的居中方案
  3. 动画优化:优先使用transform和opacity,减少布局抖动
  4. 性能优先:合理使用GPU加速,避免强制同步布局
  5. 渐进增强:使用特性检测,为不支持的环境提供降级方案

随着CSS Container Queries、Subgrid、CSS Nesting等新特性的逐步落地,CSS的能力边界还在不断扩展。作为前端开发者,持续学习并掌握这些现代CSS特性,是构建高性能、高用户体验Web应用的关键所在。

学习路径建议:

  1. 掌握基础选择器和盒模型
  2. 深入学习Flexbox和Grid布局
  3. 实践变换和过渡效果
  4. 探索关键帧动画和3D变换
  5. 学习性能优化和现代工作流程

通过不断实践和探索,你将能够利用CSS3的强大功能,创造出既美观又高性能的现代Web界面。

AI全栈筑基:React Router DOM 路由配置

2026年1月29日 17:37

在AI全栈项目的开发征途中,路由配置往往是前端“骨架”搭建完成的标志性节点。当我们敲下最后一行路由代码,看着项目目录从混沌走向清晰,这不仅仅是功能的实现,更是架构思维的落地。

最近在搭建一个基于 React + NestJS + AI 的全栈项目时,我对前端路由有了更深层次的思考。路由不仅仅是URL的映射,它是连接用户与功能的桥梁,更是决定应用性能与可维护性的核心。

本文将结合我在项目中的实际配置,深入探讨 React Router DOM 在企业级应用中的核心应用、易错点以及与全栈架构的协同。

🚦 1. 路由模式的选择:History 与 Hash 的博弈

在项目初始化阶段,选择合适的路由模式是至关重要的决策。

现代 React 应用普遍倾向于使用 BrowserRouter(History 模式)。它利用 HTML5 History API 提供了干净、美观的 URL 结构(如 /home),符合 RESTful 规范,对 SEO 友好。

// src/App.jsx
import { BrowserRouter as Router } from 'react-router-dom';

export default function App() {
  return (
    <Router>
      {/* 路由内容 */}
    </Router>
  );
}

💡 架构思考:
虽然 BrowserRouter 看起来很“温柔”,但它背后隐藏着锋利的一面:它要求服务器端必须配置“兜底”策略
如果你的应用部署在 Nginx 或 Node 服务上,必须确保所有非 API 请求都重定向到 index.html。否则,当用户直接访问 /user/123 时,后端会因为找不到该路径而返回 404。这标志着在前后端分离架构中,前端不再是孤立的,而是需要与后端部署策略紧密配合。

🏗️ 2. 路由形态的深度解析:从嵌套到鉴权

在构建复杂应用时,单一的路由模式显然不够用。我们需要构建一套层次分明的路由体系。

2.1 嵌套路由:保持布局一致性

在项目中,我为产品模块配置了嵌套路由。父组件 Product 负责承载公共的导航栏或侧边栏,而子组件(详情页、新增页)通过 <Outlet> 渲染在指定位置。

// src/router/index.jsx
{
  path: "/product",
  element: <Product />, // 父级布局
  children: [
    { path: ":productId", element: <ProductDetail /> }, // 子路由
    { path: "new", element: <NewProduct /> },           // 子路由
  ],
}

这种模式避免了在每个子页面中重复编写相同的布局代码,极大地提升了用户体验的连贯性。

2.2 鉴权路由:路由守卫的实现

对于支付等敏感页面,直接暴露是危险的。我在路由配置中引入了 ProtectRoute 组件。

{
  path: "/pay",
  element: (
    <ProtectRoute>
      <Pay />
    </ProtectRoute>
  ),
}

💡 核心逻辑:
ProtectRoute 本质上是一个高阶组件(HOC)。它在渲染 props.children(即 Pay 组件)之前,会先检查用户的登录状态(如检查 Token)。如果未通过校验,直接重定向到登录页;如果通过,则放行。这种将横切关注点(Cross-Cutting Concerns)剥离的方式,是企业级应用的必备手段。

⚡ 3. 性能优化:懒加载与用户体验

单页应用(SPA)的一大痛点是首屏体积过大。为了解决这个问题,我采用了路由级代码分割(Code Splitting)

3.1 React.lazy 与 Suspense

利用 Webpack 的动态导入功能,我将不同页面的代码拆分成独立的 Chunk。

const Home = React.lazy(() => import('../pages/Home'));
const About = React.lazy(() => import('../pages/About'));

// 在渲染层
<Suspense fallback={<LoadingFallback />}>
  <Routes>{/* 路由配置 */}</Routes>
</Suspense>

只有当用户访问 /about 路径时,About 组件的代码才会被动态加载。这显著减小了首包体积,提升了首屏渲染速度。

3.2 加载状态的优雅处理

React.lazy 的动态导入是异步的,网络延迟不可避免。如果直接展示白屏,用户体验极差。

因此,<Suspense fallback={<LoadingFallback />}> 的作用至关重要。LoadingFallback 组件(如骨架屏或加载动画)作为“占位符”,在组件加载完成前提供视觉反馈。这是提升用户体验的微小但关键的细节。

🚨 4. 容错与边界处理:NotFound 的自动化

对于无效的 URL,我们需要一个“守门员”。我配置了通配符路由 * 来捕获所有未匹配的请求。

// NotFound.jsx
const NotFound = () => {
  let navigate = useNavigate();
  
  useEffect(() => {
    // 6秒后自动跳回首页,防止用户迷失
    setTimeout(() => { navigate('/') }, 6000)
  }, []);

  return <> 404 Not Found </>
}

这种自动化的跳转策略,比单纯展示一个死板的 404 页面更加人性化,能有效挽留因误操作而流失的用户。

🔮 5. 结语:全栈视角下的路由未来

路由配置的完成,标志着前端骨架的搭建完毕。从 BrowserRouter 的部署考量,到 ProtectRoute 的逻辑复用,再到 React.lazy 的性能优化,每一个细节都体现了工程化的思维。

站在这个基石上,我们已经可以看到后端 NestJS 框架的轮廓,以及 AI 模型接入的无限可能。未来的路由或许不仅仅是页面的跳转,它可能结合 AI 能力,根据用户的意图动态生成内容或调整导航路径。

全栈之路,始于足下,路由为引,未来可期。

Vercel 团队 10 年 React 性能优化经验:10 大核心策略让性能提升 300%

作者 冴羽
2026年1月29日 17:33

Vercel 最近发布了 React 最佳实践库,将十余年来积累的 React 和 Next.js 优化经验整合到了一个指南中。

其中一共包含8 个类别、40 多条规则

这些原则并不是纸上谈兵,而是 Vercel 团队在 10 余年从无数生产代码库中总结出的经验之谈。它们已经被无数成功案例验证,能切实改善用户体验和业务指标。

以下将是对你的 React 和 Next.js 项目影响最大的 10 大实践。

1. 将独立的异步操作并行

请求瀑布流是 React 应用性能的头号杀手。

每次顺序执行 await 都会增加网络延迟,消除它们可以带来最大的性能提升。

❌ 错误:

async function Page() {
  const user = await fetchUser();
  const posts = await fetchPosts();
  return <Dashboard user={user} posts={posts} />;
}

✅ 正确:

async function Page() {
  const [user, posts] = await Promise.all([fetchUser(), fetchPosts()]);
  return <Dashboard user={user} posts={posts} />;
}

当处理多个数据源时,这个简单的改变可以将页面加载时间减少数百毫秒。

策略1:并行异步操作

2. 避免桶文件导入

从桶文件导入会强制打包程序解析整个库,即使你只需要其中一个组件。

这就像把整个衣柜都搬走,只为了穿一件衣服。

❌ 错误:

import { Check, X, Menu } from "lucide-react";

✅ 正确:

import Check from "lucide-react/dist/esm/icons/check";
import X from "lucide-react/dist/esm/icons/x";
import Menu from "lucide-react/dist/esm/icons/menu";

更好的方式(使用 Next.js 配置):

// next.config.js
module.exports = {
  experimental: {
    optimizePackageImports: ["lucide-react", "@mui/material"],
  },
};

// 然后保持简洁的导入方式
import { Check, X, Menu } from "lucide-react";

直接导入可将启动速度提高 15-70%,构建难度降低 28%,冷启动速度提高 40%,HMR 速度显著提高。

策略2:避免桶文件导入

3. 使用延迟状态初始化

当初始化状态需要进行耗时的计算时,将初始化程序包装在一个函数中,确保它只运行一次。

❌ 错误:

function Component() {
  const [config, setConfig] = useState(JSON.parse(localStorage.getItem("config")));
  return <div>{config.theme}</div>;
}

✅ 正确:

function Component() {
  const [config, setConfig] = useState(() => JSON.parse(localStorage.getItem("config")));
  return <div>{config.theme}</div>;
}

组件每次渲染都会从 localStorage 解析 JSON 配置,但其实它只需要在初始化的时候读取一次,将其封装在回调函数中可以消除这种浪费。

策略3:延迟状态初始化

4. 最小化 RSC 边界的数据传递

React 服务端/客户端边界会将所有对象属性序列化为字符串并嵌入到 HTML 响应中,这会直接影响页面大小和加载时间。

❌ 错误:

async function Page() {
  const user = await fetchUser(); // 50 fields
  return <Profile user={user} />;
}

("use client");
function Profile({ user }) {
  return <div>{user.name}</div>; // uses 1 field
}

✅ 正确:

async function Page() {
  const user = await fetchUser();
  return <Profile name={user.name} />;
}

("use client");
function Profile({ name }) {
  return <div>{name}</div>;
}

只传递客户端组件实际需要的数据。

策略4:最小化RSC边界

5. 动态导入大型组件

仅在功能激活时加载大型库,减少初始包体积。

❌ 错误:

import { AnimationPlayer } from "./heavy-animation-lib";

function Component() {
  const [enabled, setEnabled] = useState(false);
  return enabled ? <AnimationPlayer /> : null;
}

✅ 正确:

function AnimationPlayer({ enabled, setEnabled }) {
  const [frames, setFrames] = useState(null);

  useEffect(() => {
    if (enabled && !frames && typeof window !== "undefined") {
      import("./animation-frames.js").then((mod) => setFrames(mod.frames)).catch(() => setEnabled(false));
    }
  }, [enabled, frames, setEnabled]);

  if (!frames) return <Skeleton />;
  return <Canvas frames={frames} />;
}

typeof window 可以防止将此模块打包用于 SSR,优化服务端包体积和构建速度。

策略5:动态导入组件

6. 延迟加载第三方脚本

分析和跟踪脚本不要阻塞用户交互。

❌ 错误:

export default function RootLayout({ children }) {
  useEffect(() => {
    initAnalytics();
  }, []);

  return (
    <html>
      <body>{children}</body>
    </html>
  );
}

✅ 正确:

import { Analytics } from "@vercel/analytics/react";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {children}
        <Analytics />
      </body>
    </html>
  );
}

在水合后加载分析脚本,优先处理交互内容。

策略6:延迟加载脚本

7. 使用 React.cache() 进行请求去重

防止服务端在同一渲染周期内重复请求。

❌ 错误:

async function Sidebar() {
  const user = await fetchUser();
  return <div>{user.name}</div>;
}

async function Header() {
  const user = await fetchUser(); // 重复请求
  return <nav>{user.email}</nav>;
}

✅ 正确:

import { cache } from "react";

const getUser = cache(async () => {
  return await fetchUser();
});

async function Sidebar() {
  const user = await getUser();
  return <div>{user.name}</div>;
}

async function Header() {
  const user = await getUser(); // 已缓存,无重复请求
  return <nav>{user.email}</nav>;
}

策略7-8:缓存去重

8. 实现跨请求数据的 LRU 缓存

React.cache() 仅在单个请求内有效,因此对于跨连续请求共享的数据,使用 LRU 缓存。

❌ 错误:

import { LRUCache } from "lru-cache";

const cache = new LRUCache({
  max: 1000,
  ttl: 5 * 60 * 1000, // 5 分钟
});

export async function getUser(id) {
  const cached = cache.get(id);
  if (cached) return cached;

  const user = await db.user.findUnique({ where: { id } });
  cache.set(id, user);
  return user;
}

这在 Vercel 的 Fluid Compute 中特别有效,多个并发请求共享同一个函数实例。

9. 通过组件组合实现并行化

React 服务端组件在树状结构中按顺序执行,因此需要使用组合对组件树进行重构以实现并行化数据获取:

❌ 错误:

async function Page() {
  const data = await fetchPageData();
  return (
    <>
      <Header />
      <Sidebar data={data} />
    </>
  );
}

✅ 正确:

async function Page() {
  return (
    <>
      <Header />
      <Sidebar />
    </>
  );
}

async function Sidebar() {
  const data = await fetchPageData();
  return <div>{data.content}</div>;
}

这样一来,页眉和侧边栏就可以并行获取数据了。

10. 使用 SWR 进行客户端请求去重

当客户端上的多个组件请求相同的数据时,SWR 会自动对请求进行去重。

❌ 错误:

function UserProfile() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch("/api/user")
      .then((r) => r.json())
      .then(setUser);
  }, []);

  return <div>{user?.name}</div>;
}

function UserAvatar() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch("/api/user")
      .then((r) => r.json())
      .then(setUser);
  }, []);

  return <img src={user?.avatar} />;
}

✅ 正确:

import useSWR from "swr";

const fetcher = (url) => fetch(url).then((r) => r.json());

function UserProfile() {
  const { data: user } = useSWR("/api/user", fetcher);
  return <div>{user?.name}</div>;
}

function UserAvatar() {
  const { data: user } = useSWR("/api/user", fetcher);
  return <img src={user?.avatar} />;
}

SWR 只发出一个请求,并将结果在两个组件之间共享。

11. 总结

这些最佳实践的美妙之处在于:它们不是复杂的架构变更。大多数都是简单的代码修改,却能产生显著的性能改进。

一个 600ms 的瀑布等待时间,会影响每一位用户,直到被修复。

一个桶文件导入造成的包膨胀,会减慢每一次构建和每一次页面加载

所以越早采用这些实践,就能避免积累越来越多的性能债务。

总结:立即行动

现在开始应用这些技巧,让你的 React 应用快如闪电吧!

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

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

Dart - 全面认识Stream

作者 浩辉
2026年1月29日 17:26

第一章:Flutter 进阶——为什么你需要 Stream?从 Future 到流的思维跃迁

在 Flutter 和 Dart 的异步编程世界里,大多数开发者都是从 Future 开始入门的。我们习惯了 await 一个网络请求,然后等待结果返回。

但是,当你试图实现一个“倒计时”、“文件下载进度条”或者“实时聊天室”时,你会发现 Future 变得力不从心。这时候,你就需要升级你的武器库,引入一个更强大的概念 —— Stream (流)

本章我们将不再仅仅罗列 API,而是从内存和执行原理的角度,解剖 Stream 到底是什么,以及它为什么被称为“异步数据的生命之河”。

一、 Future 的“一锤子买卖”

先来看一段我们最熟悉的 Future 代码:

Future<String> fetchUser() async {
  await Future.delayed(Duration(seconds: 2));
  return "Jason"; // 任务结束,返回结果
}

Future 的设计哲学非常简单:一次请求,一次响应。

它就像是网购。你下个单(调用函数),过几天快递员给你一个包裹(返回值)。交易完成后,你和快递员的关系就结束了。

在底层内存模型中,当你执行 return "Jason" 的那一瞬间,发生了两件事:

  1. 结果交付:数据被发送给了等待者。
  2. 现场销毁fetchUser 函数的栈帧 (Stack Frame) 被弹出、销毁。这个函数“死”了,它的生命周期彻底结束。

痛点来了: 如果你想要的是“连续的数据”呢? 比如,你不仅想知道“文件下载完了没有”,还想知道“现在下载了百分之几”。

  • 如果你用 Future,你只能得到一张下载完成时的截图
  • 但你真正想要的是一段录像

这时候,我们需要一个能“活着”并在时间轴上源源不断吐出数据的机制。

二、 Stream:时间轴上的传送带

如果说 Future 是静态的,那么 Stream 就是动态的线

你可以把 Stream 想象成回转寿司店里的一条自动传送带

在这个模型里,有三个核心角色:

  1. Sink (入口/厨师):这是生产端。厨师(生成器)把一盘盘寿司(数据)按顺序放上传送带。
  2. Stream (管道):这是传送带本身。它负责搬运数据,它不关心谁吃,只负责转动。
  3. Listener (出口/食客):这是消费端。你坐在传送带末端,监听 (Listen) 着它。来一盘,你吃一盘。

Future 不同,Stream 是一种 异步的 Iterable (Asynchronous Iterable)。它代表的不是“一个值”,而是“可能随时间推移而到达的一系列值”。

三、 语法上的“基因突变”:async* 与 yield

为了支持这种“源源不断”的特性,Dart 在语法层面做了一个极具深意的设计。

我们要重点关注两个关键字:async*yield

1. 那个神秘的星号 (*)

你可能注意到了,Stream 函数的定义后面必须带一个 *

// Future: 单数
Future<int> getScore() async { ... }

// Stream: 复数(生成器)
Stream<int> getScores() async* { ... }

这个星号 * 代表 Generator (生成器)。在计算机科学中,它暗示着“多”和“生产能力”。它告诉编译器:“嗨,这个函数有点特殊,它不会跑一遍就死掉,它是一个状态机。”

2. yield vs return:暂停与销毁

这是理解 Stream 底层原理最关键的一步。请看这段代码:

Stream<int> countDown() async* {
  for (int i = 5; i > 0; i--) {
    await Future.delayed(Duration(seconds: 1));
    yield i; // <--- 关键看这里!
  }
}

  • return (辞职): 当普通函数执行 return 时,它是彻底退出。它的栈帧被销毁,局部变量全部清空。下次再调用,一切从头开始。
  • yield (停薪留职/暂停): 当生成器函数执行 yield i 时,它做的是 “交出数据,原地暂停”
  • 交出数据:把 i 扔进事件循环,发给监听者。
  • 保留现场关键点! 此时函数的栈帧并没有被销毁!当前的局部变量 i 的值、代码执行到了第几行,通通被“冻结”在内存里。
  • 恢复执行:当函数再次被唤醒时,它会从 yield下一行继续执行,仿佛从未中断过。

正是因为有了 yield 这种**“保留状态”**的能力,Stream 才能做到记住了循环到了哪里,从而源源不断地产生数据。

四、 为什么我们需要 Stream?

既然 Future 简单好用,为什么还要折腾 Stream

1. 解决“过程”问题 现实世界的交互往往是连续的。

  • 倒计时:5, 4, 3, 2, 1...
  • 搜索联想:你输一个字母,推荐列表变一次。
  • WebSocket:服务器随时可能推一条新消息过来。 这些场景,用 Future 这种“一次性承诺”是无法优雅实现的,必须用 Stream

2. 变“主动轮询”为“被动响应”

  • 传统方式 (Pull):你不停地问服务器“好了没?好了没?”(轮询),浪费资源。
  • Stream 方式 (Push):你注册一个监听器 listen(),然后去干别的事。一旦有数据,Stream 会主动推给你。这也就是现在流行的 响应式编程 (Reactive Programming) 的核心思想。

小结

特性 Future (未来) Stream (流)
数据量 单个值 (Single) 多个值 (Multiple)
生命周期 一次性 (One-shot) 持续的时间轴 (Continuous)
结束动作 Return (销毁) Stream Done (关闭)
核心机制 栈帧销毁 栈帧暂停 (yield)
生活类比 拍一张照片 录一段视频

理解了 Stream 的传送带模型yield 的暂停机制,你就已经迈过了异步编程最难的一道坎。

但是,现在的传送带还很简陋。如果我想让多个人同时看一条传送带(多订阅)?或者我想在传送带中间加一个滤网,只过滤出我想要的寿司(操作符)?

下一章,我们将深入探讨 Stream 的两种形态:单订阅 (Single-subscription)广播 (Broadcast)


第二章:Flutter 进阶——Stream 的两种形态与掌控权

在上一章中,我们用 async* 函数轻松创建了一条传送带。 但是,当你试图在代码中对同一个 Stream 调用两次 listen 时,程序会毫不留情地抛出一个异常: Bad state: Stream has already been listened to.

这并不是 Bug,这是 Dart Stream 设计哲学的核心:根据消费场景的不同,Stream 分为两种截然不同的形态。

本章我们将深入探讨 单订阅 (Single-subscription)广播 (Broadcast) 的区别,并解锁 Stream 的手动挡模式 —— StreamController

一、 私密对话 vs 公共广播

在内存世界里,数据的流动方式决定了 Stream 的类型。

1. 单订阅 Stream (Single-subscription) —— “我的汉堡”

这是 Stream 的默认形态。当你使用 async* 或者 File.openRead() 创建流时,它就是单订阅的。

  • 特点一对一。这条传送带是为你专门铺设的。
  • 形象比喻“在餐厅点餐”。 厨师为你做了一份炒饭。这份炒饭(数据)只能被你一个人吃(消费)。如果你的朋友也想吃,他必须重新下一单(创建一个新的 Stream ),厨师会重新做一份。
  • 底层逻辑: 数据是为了保证完整性顺序性。比如读取文件,你绝不希望两个人在同时读一个文件流,导致你读一半,他读一半,数据全乱套了。
  • 致命限制只能监听一次! 即使第一个监听者取消了订阅 (cancel),这条 Stream 也废了,不能再被监听。

2. 广播 Stream (Broadcast) —— “村口大喇叭”

这是 Stream 的另一种形态。通常用于事件总线、鼠标点击、系统通知等场景。

  • 特点一对多
  • 形象比喻“听收音机”。 电台(数据源)在不停地播放。你听,或者隔壁老王听,甚至一百个人同时听,互不影响。
  • 关键差异
  • 过时不候:广播流通常是 "Hot" (热) 的。如果你 10:00 打开收音机,你听不到 9:50 播放的新闻。数据发出去没人听,就直接丢弃了。
  • 随时监听:你可以随时加入,也可以随时退出,支持多个监听者同时存在。

3. 代码实战:如何转换?

如果我非要让那盘“炒饭”大家一起吃怎么办?Dart 提供了 asBroadcastStream() 方法。

// 1. 创建一个普通的单订阅流
Stream<int> stream = getScoreStream();

// 2. 强行变成广播流
Stream<int> broadcastStream = stream.asBroadcastStream();

// 3. 现在可以多次监听了
broadcastStream.listen((v) => print("老王听到了: $v"));
broadcastStream.listen((v) => print("小李听到了: $v"));


二、 手动挡:StreamController

到目前为止,我们都是通过 async* 函数来**“自动”**生成 Stream。这种方式很简单,但它是被动的——必须等到函数里的 yield 执行时才有数据。

如果我们想在一个按钮点击事件里发送数据?或者在网络请求回调里发送数据? 这时候,我们需要 StreamController (流控制器)

如果说 async* 是设定好程序的自动流水线,那 StreamController 就是一个万能遥控器

1. 结构解剖

StreamController 把 Stream 的结构拆解得清清楚楚:

  • 入口 (Sink):你可以随时随地调用 sink.add(data) 往里面扔数据。
  • 出口 (Stream):就是我们熟悉的那个 Stream,给别人去 listen 的。
  • 控制器 (Controller):管理开关、暂停、以及流的状态。

2. 极简代码示范

import 'dart:async';

void main() {
  // 1. 创建控制器 (买了一个遥控器)
  // 如果想做广播流,就用 StreamController.broadcast();
  final controller = StreamController<String>();

  // 2. 拿到出口 (给别人听的)
  controller.stream.listen(
    (data) => print("收到推流: $data"),
    onError: (err) => print("发生错误: $err"),
    onDone: () => print("直播结束"),
  );

  // 3. 拿到入口 (自己在任意地方控制)
  print("准备发射数据...");
  controller.sink.add("第一条消息"); // 像不像 EventBus?
  controller.sink.add("第二条消息");
  
  // 4. 模拟发生错误
  controller.addError("信号丢失!");

  // 5. 关流 (非常重要!!!)
  // 不关流会导致内存泄漏,因为监听者会一直干等着
  controller.close();
}

3. 为什么它在 Flutter 中如此重要?

几乎所有 Flutter 的状态管理库(BLoC, Provider, Riverpod 等)的底层,或多或少都用到了 StreamController 的思想。

  • UI 层:只管 add 事件(比如点击按钮)。
  • 逻辑层:通过 Controller 处理业务。
  • UI 层StreamBuilder 监听 Controller.stream 并刷新界面。

这就是 “输入与输出分离” 的架构雏形。


三、 避坑指南:内存泄漏的隐患

在使用 StreamController 时,有一个新手最容易犯的错误:忘了关流 (Close)

  • 原理StreamController 在底层会持有监听者的引用。如果你的页面销毁了,但 Controller 没关闭,这个 Stream 依然认为“有人在听”,它不会释放资源,导致 内存泄漏 (Memory Leak)
  • 铁律:在 Flutter 的 dispose() 方法中,一定要调用 controller.close()

小结

这一章我们完成了从“使用者”到“掌控者”的转变:

  1. 分清形态
  • 单订阅(默认):数据完整,一对一,错过即毁。
  • 广播(Broadcast):实时性强,一对多,过时不候。
  1. 掌握控制
  • 使用 StreamController 可以让我们在代码的任何地方主动地“推”数据,它是连接命令式代码(普通函数)和响应式代码(Stream)的桥梁。

了解了形态和控制,下一章我们将进入 Stream 最强大的领域 —— 数学般的魔法。 我们将探索如何像操作数组一样操作时间流:mapwheredebounce(防抖)以及 distinct。这些操作符将彻底改变你写业务逻辑的方式。

第三章:流上建造流水线

在上一章,我们学会了用 StreamController 制造传送带。但在真实开发中,原始数据往往是“脏”的或者“不符合 UI 胃口”的。

  • 后端:推给你一堆 JSON 字符串。
  • UI层:想要的是一个转换好的 User 对象。
  • 用户:手抖,一秒钟点了 5 次按钮。
  • 逻辑层:只希望处理最后一次点击。

如果把这些逻辑都写在 listen 的回调里,代码会变成一坨乱麻。 Dart Stream 赋予了我们一种能力:在数据到达监听者之前,先在传送带上架设一排“机器手臂”,对数据进行全自动加工。

这就是 操作符 (Operators)

一、 熟悉的配方:从 List 到 Stream

Dart 最优雅的设计之一,就是它让操作 Stream (时间流) 就像操作 List (静态数组) 一样简单。

如果你会用 List 的方法,你已经学会了 90% 的 Stream 操作。

1. 过滤与转换 (Where & Map)

想象传送带上流过来的是一堆数字 1, 2, 3, 4, 5...

  • 需求:我只想要偶数,而且要把它放大 10 倍。
Stream<int> rawStream = Stream.fromIterable([1, 2, 3, 4, 5]);

rawStream
    .where((event) => event % 2 == 0) // 机器手臂1:过滤。只放行偶数。
    .map((event) => event * 10)       // 机器手臂2:加工。变成原来的10倍。
    .listen((data) {
      print(data); // 输出:20, 40
    });

底层原理: 每个操作符(.where, .map)本质上都返回了一个新的 Stream。 这就像接水管一样,我们把一节节短管子(操作符)拧在一起,构成了一条长长的处理管道。原始数据从一头进,经过层层净化,最后流出来的就是我们想要的纯净水。

二、 解决现实痛点:那些 Stream 独有的神技

除了通用的 map/where,Stream 还有一些专门处理“时间轴”问题的神技。

1. 去重神技:distinct

  • 场景:你要实现一个搜索框。用户想搜 "Flutter",但他输入 "F", "Fl", "Flu"...
  • 痛点:如果用户输入了 "Flu",停了一下,删掉 "u",又输了一次 "u"。输入内容还是 "Flu"。如果不处理,你会发两次完全一样的网络请求。
  • 解法
inputStream
    .distinct() // 只有当新数据和上一次数据不一样时,才放行
    .listen((text) => search(text));

它就像一个极其严格的质检员,拿着上一个通过的产品做对比,一样的直接扔掉。

2. 扁平化神技:expandasyncExpand

这是一个高级但必用的操作符。

  • 场景:Stream 里流过来的是“文件夹”,但监听者想要的是“文件”。 即:Stream 发出的每个数据,本身又包含了一组数据(Stream of List)。
  • 解法expand 会把“流过来的每一个元素”炸开,变成一堆元素,然后重新铺平在传送带上。
// 假设流过来的是:[1, 2], [3, 4]
stream
  .expand((element) => element) 
  .listen(print); 
// 输出:1, 2, 3, 4 (变成了扁平的流)

三、 终极武器:StreamTransformer

有时候,官方提供的 mapwhere 不够用了。 比如,Socket 连接发过来的是字节流 (List<int>),但你想按**“换行符”切分成一行行的文本流 (String)**。

这时候,你需要自定义一个“变压器” —— StreamTransformer

它是 stream.transform() 方法的参数。Dart 官方贴心地在 dart:convert 库里内置了一些最常用的变压器:

import 'dart:convert';
import 'dart:io';

void readFile() {
  File('log.txt')
    .openRead() // 原始流:一堆二进制字节
    .transform(utf8.decoder) // 变压器1:字节 -> 字符串
    .transform(const LineSplitter()) // 变压器2:一整块字符串 -> 按换行符切开的一行行字符串
    .listen((line) {
      print("读取到一行日志: $line");
    });
}

底层逻辑transform 是将流的控制权完全交给你。你可以控制输入什么,缓存多少,什么时候输出,甚至可以把一个数据变成两个,或者把两个数据合并成一个。

四、 降维打击:RxDart 的防抖与节流

讲到 Stream 操作符,如果不提 RxDart,那就是耍流氓。 虽然 Dart 原生库很强,但在处理复杂的交互事件时,RxDart 提供了“外挂”级别的操作符。

Flutter 面试必问的两大杀手锏:

  1. 防抖 (Debounce)
  • 比喻:电梯关门。如果一直有人进电梯(事件一直来),电梯门就一直不关。只有当大家都不动了(间隔超过一定时间),电梯门才会关上(执行逻辑)。
  • 用途:搜索框联想。打字停顿 500ms 后再请求 API。
  1. 节流 (Throttle)
  • 比喻:机关枪射速限制。不管你扣扳机的手速有多快,子弹最快只能每秒发 10 发。
  • 用途:防止按钮连点。

(注:RxDart 本质上就是把 StreamTransformer 封装好了给你用。)

小结

这一章我们把 Stream 从“传输工具”升级成了“处理工具”。

  1. 管道思维:用 mapwhere 像搭积木一样处理数据。
  2. 独有技能:用 distinct 过滤重复信号。
  3. 高级定制:用 transform 处理复杂的数据转换(如二进制转文本)。

现在,我们有了数据源(Controller),有了处理逻辑(Operators),有了监听者(Listen)。 但是,在 Flutter 的 UI 代码里写 listensetState 依然很痛苦,很容易忘掉 cancel 导致内存泄漏。

有没有一种 Widget,能直接把 Stream 插上去,它自己就会根据数据变来变去,还自动管理内存?

下一章,我们将介绍 Flutter 官方提供的终极组件 —— StreamBuilder,它是连接逻辑层与 UI 层的跨海大桥。


第四章:Flutter 实战——告别 setState,拥抱 StreamBuilder

在前面的章节中,我们在纯 Dart 环境下把 Stream 玩出了花。但当我们回到 Flutter 的 Widget 世界时,会遇到一个尴尬的现实。

痛点:手动管理的“地狱”

如果你不用专门的工具,想在界面上显示一个 Stream 的数据,你需要写大量的模版代码:

  1. 必须用 StatefulWidget
  2. initState 里手动 listen
  3. 在回调里手动 setState 触发刷新。
  4. 最要命的:必须在 dispose 里手动 subscription.cancel()。哪怕忘写一次,你的 App 就会在后台默默发生内存泄漏,直到崩溃。

为了把开发者从这种重复劳动中解救出来,Flutter 提供了一个终极组件 —— StreamBuilder

一、 什么是 StreamBuilder?

StreamBuilder 是一个 Widget,但它不画任何东西。它的唯一工作就是 “自动帮你看传送带”

  • 自动化:它负责 listen,它负责 setState,它负责 dispose。你完全不用管。
  • 响应式:传送带上每过来一个新数据,它就自动调用一次 builder 方法,重新画一遍子组件。

二、 代码实战:一个最简单的电子表

我们来做一个每秒更新时间的电子表。

1. 准备 Stream(数据源)

Stream<String> getTimerStream() async* {
  while (true) {
    await Future.delayed(Duration(seconds: 1));
    yield DateTime.now().toString().substring(11, 19); // 返回 "12:00:01"
  }
}

2. 使用 StreamBuilder(UI 构建)

class MyClock extends StatelessWidget { // 注意:可以用 StatelessWidget 了!
  final Stream<String> _timerStream = getTimerStream();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: StreamBuilder<String>(
          stream: _timerStream, // 1. 插上网线
          builder: (context, snapshot) { // 2. 根据快照画图
            // snapshot 包含了当前时刻 Stream 的所有信息
            if (snapshot.connectionState == ConnectionState.waiting) {
              return CircularProgressIndicator(); // 还没数据时显示转圈
            }
            
            if (snapshot.hasError) {
              return Text('出错了: ${snapshot.error}');
            }

            // 有数据了!
            return Text(
              snapshot.data ?? '无数据',
              style: TextStyle(fontSize: 30),
            );
          },
        ),
      ),
    );
  }
}

看,我们甚至不需要 StatefulWidget!所有的状态变化都封装在了 StreamBuilder 内部。

三、 解剖核心:AsyncSnapshot (快照)

builder 回调函数里那个 snapshot 参数,是理解 StreamBuilder 的关键。

你可以把它想象成 “Stream 在这一瞬间的体检报告”。它包含三个核心指标:

  1. ConnectionState (连接状态)
  • none: 没插网线(stream 为 null)。
  • waiting: 插了网线,但第一个数据还没来(通常显示 Loading)。
  • active: 数据正在源源不断地来(最主要的状态)。
  • done: 传送带停了(Stream 关闭)。
  1. data (数据)
  • 如果不为 null,说明这就是最新拿到的数据。
  1. error (错误)
  • 如果不为 null,说明刚才流里传来了一个错误事件。

最佳实践写法: 不要只写一个 return Text(...),一定要养成习惯处理三种状态:加载中、错误、正常显示

builder: (context, snapshot) {
  if (snapshot.hasError) return ErrorWidget();
  switch (snapshot.connectionState) {
    case ConnectionState.waiting: return LoadingWidget();
    case ConnectionState.active:
    case ConnectionState.done:
      return DataWidget(snapshot.data);
    default: return SizedBox();
  }
}

四、 新手必踩的超级大坑

在使用 StreamBuilder 时,90% 的新手会犯同一个错误:在 build 方法里创建 Stream

❌ 错误示范:

@override
Widget build(BuildContext context) {
  return StreamBuilder(
    // 错!每次父组件刷新,build 都会跑一遍
    // 这一行就会创建一个全新的 Stream!
    stream: createMyStream(), 
    builder: ...
  );
}

💥 后果: 每次你的界面刷新(比如键盘弹起、父组件 setState),createMyStream() 就会重新执行。 这就意味着:原本的连接断开了,一个新的连接建立了。 你会看到 Loading 转圈圈无限闪烁,或者倒计时明明走到 5 了,突然又变回 10 重新开始。

✅ 正确姿势: Stream 实例的创建必须在 build 方法之外

  1. 如果是 StatefulWidget,在 initState 里创建。
  2. 如果是 BLoC/Provider 模式,Stream 应该由业务逻辑类提供,UI 只负责引用。

小结

这一章我们见证了 Stream 与 Flutter UI 的完美融合。

  • StreamBuilder 是连接逻辑层与 UI 层的万能适配器
  • AsyncSnapshot 是携带数据的快递盒,我们要学会检查盒子的状态(Waiting/Active/Error)。
  • 铁律:永远不要在 build 方法里创建 Stream,那是“一次性筷子”,用完就丢,会导致状态重置。

到这里,关于 Stream 的基础、进阶和 UI 实战我们都讲完了。

但是,如果你正在开发一个中大型 APP,你会发现光有 Stream 还是不够。你需要一种架构模式,把 Stream 组织起来,让代码井井有条。 这就是 Flutter 官方推荐的 —— BLoC (Business Logic Component) 模式


第五章:Flutter 实战——BLoC 模式,给你的代码定规矩

经过前四章的学习,你手中已经握有了强大的武器:Stream,并且学会了 Stream 的所有招式(创建、变形、消费),是时候把它们组合成一套绝世武功了。

但你可能会发现一个新的问题:武器太灵活了,容易误伤自己。

如果你在 UI Widget 里随便创建 Controller,在 build 方法里随意处理数据,很快你的代码就会变成一碗“意大利面”——逻辑和 UI 纠缠不清,难以维护,难以测试。

为了解决这个问题,Flutter 社区诞生了一种基于 Stream 的架构模式:BLoC (Business Logic Component)

它的核心思想只有一句话:让 UI 只是 UI,让逻辑只是逻辑,两者通过 Stream 对话。

一、 BLoC 的“黑盒模型”

把 BLoC 想象成一台自动售货机

  1. 输入 (Input):你按下一个按钮(比如“购买可乐”)。这在 BLoC 里叫 Event (事件)
  2. 黑盒 (Processing):机器内部听到指令,检查库存,扣除余额,驱动机械臂。这就是 Business Logic (业务逻辑)
  3. 输出 (Output):机器吐出一听可乐,或者显示“余额不足”。这在 BLoC 里叫 State (状态)

关键规则:

  • UI 组件(Widget)绝对不允许直接修改数据。
  • UI 只能做一件事:往 BLoC 的 Sink 里扔事件
  • UI 只能听一件事:听 BLoC 的 Stream 里流出来的状态

二、 手写一个纯粹的 BLoC

在引入第三方库之前,我们先用原生 Dart 代码写一个 BLoC,你会发现它本质上就是我们第二章学的 StreamController 的封装。

我们来重构之前的“电子表”或“计数器”。

1. 定义 BLoC 类 (逻辑层)

import 'dart:async';

class CounterBloc {
  // 1. 状态流控制器 (Output):告诉 UI 当前是几
  // 使用广播流,允许多个页面同时监听
  final _stateController = StreamController<int>.broadcast();
  int _count = 0;

  // 2. 暴露给外部的 Stream (只读)
  Stream<int> get stream => _stateController.stream;

  // 3. 事件入口 (Input):UI 只能调这个方法
  void increment() {
    _count++;
    // 逻辑处理完,把新状态推出去
    _stateController.sink.add(_count); 
  }

  // 4. 资源释放
  void dispose() {
    _stateController.close();
  }
}

2. 在 UI 中使用 (视图层)

class CounterPage extends StatefulWidget {
  @override
  _CounterPageState createState() => _CounterPageState();
}

class _CounterPageState extends State<CounterPage> {
  final bloc = CounterBloc(); // 创建 BLoC

  @override
  void dispose() {
    bloc.dispose(); // 记得关流
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text("BLoC 模式")),
      body: Center(
        // 使用 StreamBuilder 监听 BLoC 的输出
        child: StreamBuilder<int>(
          stream: bloc.stream,
          initialData: 0,
          builder: (context, snapshot) {
            return Text(
              '${snapshot.data}', 
              style: TextStyle(fontSize: 40)
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        // UI 只负责触发动作
        onPressed: () => bloc.increment(),
        child: Icon(Icons.add),
      ),
    );
  }
}

看,代码清爽多了!

  • build 方法里没有任何 _count++ 这样的逻辑。
  • 逻辑代码全在 CounterBloc 里,你可以不依赖 Flutter UI 直接对 CounterBloc 写单元测试。

三、 进阶:为什么要用 flutter_bloc 库?

虽然手写 BLoC 帮我们理清了原理,但实际开发中,手动管理 StreamController 的关闭、手动定义 Sink 和 Stream 还是太繁琐了。

于是,大神 Felix Angelov 开源了 flutter_bloc 库,它把这套流程标准化了。

flutter_bloc 库中:

  1. 不再需要手动写 Controller:库帮你封装好了。
  2. 强制的 Event/State 定义:你必须先定义好所有的“动作”和“结果”,强制代码规范。
  3. BlocBuilder:它就是 StreamBuilder 的亲儿子,专门用来简化 BLoC 的监听。

四、 BLoC 的哲学意义

学习 BLoC,实际上是在学习一种 “单向数据流” (Unidirectional Data Flow) 的思想。

  • 没有 BLoC 时:数据满天飞,A 组件改了 B 的数据,C 组件又回调了 A 的方法。出了 Bug 根本找不到源头。
  • 有了 BLoC 后
  • 数据永远是 从上往下流 (Stream)。
  • 事件永远是 从下往上发 (Sink)。
  • 形成了一个完美的闭环。

五、 全剧终:Stream 学习之路

恭喜你!从第一章的 Future 单次请求,到 Stream 的传送带模型,再到 StreamController 的手动控制,最后上升到 BLoC 的架构模式。

你已经走完了 Dart 异步编程最核心的旅程。

回顾一下我们的成就:

  1. 底层原理:你懂了 yield 暂停机制,知道了异步不是魔法,是状态机的切换。
  2. 内存模型:你分清了单订阅和广播,知道如何避免内存泄漏。
  3. 工具箱:你掌握了 map, where, debounce 等操作符,能像做手术一样处理数据。
  4. 架构思维:你学会了用 Stream 将 UI 和逻辑彻底分离。

20个例子掌握RxJS——第十三章使用 interval 和 scan 实现定时器

作者 LeeYaMaster
2026年1月29日 17:08

RxJS 实战:使用 interval 和 scan 实现定时器

概述

定时器是一个常见的功能,用于测量经过的时间。在 Web 开发中,我们经常需要实现秒表、倒计时等功能。本章将介绍如何使用 RxJS 的 intervalscantakeUntil 操作符实现一个功能完整的定时器。

定时器的基本概念

定时器用于测量从某个时间点开始经过的时间。常见的定时器场景包括:

  • 秒表功能:测量经过的时间
  • 倒计时器:从指定时间倒计时到 0
  • 任务计时:记录任务执行时间
  • 游戏计时:游戏中的计时功能

为什么使用 RxJS?

使用 RxJS 实现定时器有以下优势:

  1. 响应式编程:使用 Observable 流处理时间,代码更清晰
  2. 易于控制:可以轻松实现开始、暂停、重置等功能
  3. 自动清理:使用 takeUntil 可以优雅地取消订阅
  4. 组合性强:可以轻松与其他 RxJS 操作符组合

核心操作符

1. interval

interval 创建一个按固定时间间隔发出递增数字的 Observable。

interval(1000) // 每秒发出一个值:0, 1, 2, 3...

2. scan

scan 类似数组的 reduce,但会发出每次累加的结果。

scan((acc, value) => acc + value, 0)
// 输入:0, 1, 2, 3...
// 输出:0, 1, 3, 6, 10...

3. startWith

startWith 在 Observable 开始前发出指定的值。

interval(1000).pipe(startWith(0))
// 立即发出 0,然后每秒发出 1, 2, 3...

4. takeUntil

takeUntil 当另一个 Observable 发出值时,完成当前 Observable。

interval(1000).pipe(takeUntil(stop$))
// 当 stop$ 发出值时,停止发出值

实战场景:实现一个秒表

假设我们需要实现一个秒表,具有开始、暂停、重置功能。

实现思路

  1. 使用 interval(1000) 每秒发出一个值
  2. 使用 startWith(0) 立即开始
  3. 使用 scan 累加时间
  4. 使用 takeUntil 控制停止、暂停和重置

核心代码

// 定时器状态
isRunning = false;
currentTime = 0;

// 销毁 Subject
private destroySubject$ = new Subject<void>();

// 暂停/继续控制 Subject
private pauseSubject$ = new Subject<void>();

// 重置控制 Subject
private resetSubject$ = new Subject<void>();

// 开始定时器
private startTimer(): void {
  if (this.isRunning) {
    return;
  }
  
  this.isRunning = true;
  
  // 使用 interval(1000) 每秒发出一个值
  // 使用 scan 累加时间
  // 使用 startWith 从当前时间开始
  // 使用 takeUntil 控制停止
  interval(1000)
    .pipe(
      startWith(0),
      scan((acc) => acc + 1, this.currentTime),
      takeUntil(this.destroySubject$),
      takeUntil(this.pauseSubject$),
      takeUntil(this.resetSubject$)
    )
    .subscribe({
      next: (time) => {
        this.currentTime = time;
        this.cdr.detectChanges();
      },
      complete: () => {
        // 如果是暂停,保持状态
        if (!this.pauseSubject$.closed) {
          this.isRunning = false;
          this.cdr.detectChanges();
        }
      }
    });
}

// 暂停定时器
private pauseTimer(): void {
  if (!this.isRunning) {
    return;
  }
  
  this.pauseSubject$.next();
  this.isRunning = false;
  this.cdr.detectChanges();
}

// 重置定时器
resetTimer(): void {
  // 如果正在运行,先停止
  if (this.isRunning) {
    this.pauseSubject$.next();
  }
  
  // 重置时间
  this.currentTime = 0;
  this.isRunning = false;
  
  // 创建新的 pauseSubject 和 resetSubject
  this.pauseSubject$ = new Subject<void>();
  this.resetSubject$ = new Subject<void>();
  
  this.cdr.detectChanges();
}

关键点解析

1. interval 的使用

interval(1000) 每秒发出一个值,从 0 开始:

  • 0 秒:发出 0
  • 1 秒:发出 1
  • 2 秒:发出 2
  • ...

2. scan 累加时间

scan((acc) => acc + 1, this.currentTime) 的作用:

  • this.currentTime 开始累加
  • 每次收到新值,累加 1
  • 如果从 10 秒开始,会输出:10, 11, 12, 13...

3. startWith 的作用

startWith(0) 确保:

  • 立即发出初始值,不等待第一个 interval
  • 定时器可以立即开始计时

4. takeUntil 的多重控制

使用多个 takeUntil 可以灵活控制定时器的停止:

  • takeUntil(this.destroySubject$):组件销毁时停止
  • takeUntil(this.pauseSubject$):暂停时停止
  • takeUntil(this.resetSubject$):重置时停止

5. 暂停和重置的实现

暂停和重置需要创建新的 Subject,确保可以重新启动:

// 暂停后,创建新的 Subject
this.pauseSubject$ = new Subject<void>();

// 重置后,创建新的 Subject
this.resetSubject$ = new Subject<void>();

时间格式化

定时器通常需要将秒数格式化为 HH:MM:SS 格式:

formatTime(seconds: number): string {
  const hours = Math.floor(seconds / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  const secs = seconds % 60;
  
  return [
    hours.toString().padStart(2, '0'),
    minutes.toString().padStart(2, '0'),
    secs.toString().padStart(2, '0')
  ].join(':');
}

与其他方案的对比

方案 1:使用 setInterval(不推荐)

// ❌ 不推荐:难以控制,容易导致内存泄漏
let interval: any;
let currentTime = 0;

function startTimer() {
  interval = setInterval(() => {
    currentTime++;
    updateDisplay();
  }, 1000);
}

function pauseTimer() {
  clearInterval(interval);
}

function resetTimer() {
  clearInterval(interval);
  currentTime = 0;
  updateDisplay();
}

问题

  • 需要手动管理 interval ID
  • 容易忘记清理,导致内存泄漏
  • 代码不够优雅

方案 2:使用 RxJS(推荐)✅

// ✅ 推荐:响应式编程,易于控制
interval(1000)
  .pipe(
    startWith(0),
    scan((acc) => acc + 1, this.currentTime),
    takeUntil(this.pauseSubject$)
  )
  .subscribe(time => {
    this.currentTime = time;
  });

优势

  • 响应式编程,代码清晰
  • 自动管理订阅,避免内存泄漏
  • 易于扩展和维护

高级用法

1. 倒计时器

实现从指定时间倒计时到 0:

const initialTime = 60; // 60秒倒计时

interval(1000)
  .pipe(
    startWith(0),
    scan((acc) => acc - 1, initialTime),
    takeWhile(time => time >= 0),
    takeUntil(this.destroySubject$)
  )
  .subscribe({
    next: (time) => {
      this.currentTime = time;
      if (time === 0) {
        this.onCountdownComplete();
      }
    }
  });

2. 多段计时

记录多个时间段:

interface TimeSegment {
  id: number;
  startTime: number;
  endTime?: number;
  duration?: number;
}

private segments: TimeSegment[] = [];
private currentSegmentId = 0;

startSegment(): void {
  const segment: TimeSegment = {
    id: ++this.currentSegmentId,
    startTime: this.currentTime
  };
  this.segments.push(segment);
}

endSegment(segmentId: number): void {
  const segment = this.segments.find(s => s.id === segmentId);
  if (segment) {
    segment.endTime = this.currentTime;
    segment.duration = segment.endTime - segment.startTime;
  }
}

3. 精确计时

使用更小的间隔实现更精确的计时:

// 每 100 毫秒更新一次(精确到 0.1 秒)
interval(100)
  .pipe(
    startWith(0),
    scan((acc) => acc + 0.1, 0),
    takeUntil(this.pauseSubject$)
  )
  .subscribe(time => {
    this.currentTime = Math.round(time * 10) / 10; // 保留一位小数
  });

4. 条件停止

根据条件自动停止:

interval(1000)
  .pipe(
    startWith(0),
    scan((acc) => acc + 1, 0),
    takeWhile(time => time < 60), // 60秒后自动停止
    takeUntil(this.destroySubject$)
  )
  .subscribe({
    next: (time) => {
      this.currentTime = time;
    },
    complete: () => {
      this.onTimerComplete();
    }
  });

实际应用场景

1. 秒表功能

// 测量经过的时间
startStopwatch(): void {
  interval(1000)
    .pipe(
      startWith(0),
      scan((acc) => acc + 1, 0),
      takeUntil(this.pauseSubject$)
    )
    .subscribe(time => {
      this.elapsedTime = time;
    });
}

2. 任务计时

// 记录任务执行时间
startTaskTimer(taskId: string): void {
  const startTime = Date.now();
  
  interval(1000)
    .pipe(
      map(() => Math.floor((Date.now() - startTime) / 1000)),
      takeUntil(this.taskComplete$)
    )
    .subscribe(time => {
      this.taskTimes[taskId] = time;
    });
}

3. 游戏计时

// 游戏中的计时功能
startGameTimer(): void {
  interval(1000)
    .pipe(
      startWith(0),
      scan((acc) => acc + 1, 0),
      takeUntil(this.gameOver$)
    )
    .subscribe(time => {
      this.gameTime = time;
      this.updateGameUI();
    });
}

性能优化建议

1. 使用 ChangeDetectorRef

在 Angular 中,使用 ChangeDetectorRef 手动触发变更检测,避免不必要的检查:

.subscribe({
  next: (time) => {
    this.currentTime = time;
    this.cdr.detectChanges(); // 手动触发变更检测
  }
});

2. 限制更新频率

如果不需要每秒更新,可以降低更新频率:

// 每 5 秒更新一次
interval(5000)
  .pipe(
    startWith(0),
    scan((acc) => acc + 5, 0)
  )

3. 在页面不可见时暂停

使用 Page Visibility API 在页面不可见时暂停定时器:

fromEvent(document, 'visibilitychange')
  .pipe(
    switchMap(() => {
      if (document.hidden) {
        this.pauseTimer();
        return EMPTY;
      } else {
        // 页面可见时可以选择恢复
        return EMPTY;
      }
    })
  )
  .subscribe();

注意事项

  1. 内存泄漏:确保在组件销毁时取消订阅
  2. 变更检测:在 Angular 中,可能需要手动触发变更检测
  3. 浏览器环境:使用 isPlatformBrowser 检查,避免 SSR 问题
  4. 暂停和重置:需要创建新的 Subject,确保可以重新启动
  5. 精度问题interval 不是完全精确的,可能受到浏览器性能影响

总结

使用 RxJS 实现定时器是一个优雅的解决方案,它通过响应式编程的方式:

  • 代码清晰:使用 Observable 流处理时间,逻辑清晰
  • 易于控制:可以轻松实现开始、暂停、重置等功能
  • 自动清理:使用 takeUntil 可以优雅地取消订阅
  • 组合性强:可以轻松与其他 RxJS 操作符组合

记住:定时器是响应式编程的典型应用场景,使用 RxJS 可以让代码更加优雅和可维护

码云地址:gitee.com/leeyamaster…

20个例子掌握RxJS——第十二章使用 throttleTime 实现弹幕系统

作者 LeeYaMaster
2026年1月29日 17:07

RxJS 实战:使用 throttleTime 实现弹幕系统

概述

弹幕(Danmaku)是一种在视频或直播中实时显示用户评论的功能。在实现弹幕系统时,我们需要处理:

  1. 点击节流:用户快速点击时,限制弹幕创建频率
  2. 动画管理:管理弹幕的创建、动画和销毁
  3. 位置随机:弹幕在随机位置出现
  4. 性能优化:避免创建过多弹幕导致性能问题

本章将介绍如何使用 RxJS 的 throttleTime 操作符实现弹幕系统,并处理点击事件的节流。

弹幕系统的基本需求

  1. 输入框发送:用户输入文字后发送弹幕
  2. 点击触发:用户点击区域时创建随机弹幕
  3. 动画效果:弹幕从右到左移动
  4. 自动清理:弹幕动画结束后自动移除

实现思路

1. 弹幕数据结构

// 弹幕项接口
interface DanmakuItem {
  id: number;
  text: string;
  top: number; // 弹幕的垂直位置(百分比)
  color: string; // 弹幕颜色
  speed: number; // 弹幕速度(秒)
}

2. 点击节流

使用 throttleTime 限制点击事件的触发频率:

// 点击节流 Subject
private clickSubject$ = new Subject<MouseEvent>();

// 销毁 Subject
private destroySubject$ = new Subject<void>();

ngOnInit(): void {
  // 设置点击节流:每 300ms 最多触发一次
  this.clickSubject$
    .pipe(
      throttleTime(300), // 节流:每 300ms 最多触发一次
      takeUntil(this.destroySubject$)
    )
    .subscribe((event) => {
      this.createDanmakuFromClick(event);
    });
}

// 点击区域触发弹幕(带节流)
onDanmakuAreaClick(event: MouseEvent): void {
  this.clickSubject$.next(event);
}

3. 创建弹幕

// 弹幕颜色池
private readonly colors = [
  '#ffffff',
  '#ff6b6b',
  '#4ecdc4',
  '#45b7d1',
  '#f9ca24',
  '#6c5ce7',
  '#a29bfe',
  '#fd79a8',
  '#00b894',
  '#e17055',
];

// 创建弹幕
private createDanmaku(text: string): void {
  const danmaku: DanmakuItem = {
    id: ++this.danmakuIdCounter,
    text,
    top: Math.random() * 80 + 10, // 10% - 90% 之间的随机位置
    color: this.colors[Math.floor(Math.random() * this.colors.length)],
    speed: Math.random() * 3 + 5, // 5-8 秒之间随机速度
  };

  this.danmakuList.push(danmaku);
  this.cdr.detectChanges();

  // 弹幕动画结束后移除(速度 + 0.5秒缓冲)
  setTimeout(() => {
    const index = this.danmakuList.findIndex((item) => item.id === danmaku.id);
    if (index !== -1) {
      this.danmakuList.splice(index, 1);
      this.cdr.detectChanges();
    }
  }, (danmaku.speed + 0.5) * 1000);
}

// 从点击事件创建弹幕
private createDanmakuFromClick(event: MouseEvent): void {
  const clickTexts = [
    '666',
    '太棒了!',
    '厉害!',
    '赞!',
    '好!',
    '不错!',
    '支持!',
    '加油!',
    '很棒!',
    '优秀!',
  ];
  const randomText = clickTexts[Math.floor(Math.random() * clickTexts.length)];
  this.createDanmaku(randomText);
}

4. 输入框发送

// 弹幕输入文字
danmakuText = '';

// 发送弹幕(从输入框)
sendDanmaku(): void {
  if (!this.danmakuText.trim()) {
    return;
  }

  this.createDanmaku(this.danmakuText.trim());
  this.danmakuText = ''; // 清空输入框
}

// 回车键发送弹幕
onKeyDown(event: KeyboardEvent): void {
  if (event.key === 'Enter' && !event.shiftKey) {
    event.preventDefault();
    this.sendDanmaku();
  }
}

CSS 动画实现

弹幕的移动动画通过 CSS 实现:

.danmaku-item {
  position: absolute;
  white-space: nowrap;
  font-size: 20px;
  font-weight: bold;
  text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
  animation: danmaku-move linear;
  pointer-events: none;
  z-index: 10;
}

@keyframes danmaku-move {
  from {
    left: 100%;
    transform: translateX(0);
  }
  to {
    left: 0;
    transform: translateX(-100%);
  }
}

关键点解析

1. throttleTime 的作用

使用 throttleTime(300) 可以:

  • 限制点击事件的触发频率
  • 避免用户快速点击时创建过多弹幕
  • 提升性能和用户体验

2. 弹幕位置随机

通过 Math.random() * 80 + 10 生成 10% - 90% 之间的随机位置,避免弹幕重叠。

3. 弹幕速度随机

通过 Math.random() * 3 + 5 生成 5-8 秒之间的随机速度,让弹幕移动更自然。

4. 自动清理

使用 setTimeout 在弹幕动画结束后自动移除,避免内存泄漏。

执行流程示例

假设用户快速点击弹幕区域:

  1. 0ms:用户点击 → clickSubject$ 发出事件
  2. 0msthrottleTime 立即处理 → 创建弹幕 A
  3. 100ms:用户再次点击 → clickSubject$ 发出事件
  4. 100msthrottleTime 忽略(在 300ms 内)
  5. 200ms:用户再次点击 → clickSubject$ 发出事件
  6. 200msthrottleTime 忽略(在 300ms 内)
  7. 400ms:用户再次点击 → clickSubject$ 发出事件
  8. 400msthrottleTime 处理(已超过 300ms)→ 创建弹幕 B

结果:300ms 内只创建 1 个弹幕,避免过多弹幕。

与其他方案的对比

方案 1:不使用节流(有问题)

// ❌ 错误示例:快速点击会创建过多弹幕
onDanmakuAreaClick(event: MouseEvent): void {
  this.createDanmakuFromClick(event); // 每次点击都创建
}

方案 2:使用防抖(不适合)

// ⚠️ 不适合:防抖会等待用户停止点击,但弹幕需要即时反馈
onDanmakuAreaClick(event: MouseEvent): void {
  debounceTime(300).subscribe(() => {
    this.createDanmakuFromClick(event);
  });
}

方案 3:使用节流(推荐)✅

// ✅ 推荐:限制频率但保持即时反馈
this.clickSubject$.pipe(
  throttleTime(300)
).subscribe(event => {
  this.createDanmakuFromClick(event);
});

实际应用场景

1. 视频弹幕

// 视频播放时显示弹幕
playVideo().pipe(
  switchMap(() => 
    this.danmakuService.getDanmakus(videoId).pipe(
      mergeMap(danmaku => {
        // 根据视频时间显示弹幕
        return timer(danmaku.time * 1000).pipe(
          map(() => danmaku)
        );
      })
    )
  )
).subscribe(danmaku => {
  this.createDanmaku(danmaku.text);
});

2. 直播弹幕

// 接收直播弹幕
this.websocketService.onMessage('danmaku').pipe(
  throttleTime(100) // 限制弹幕创建频率
).subscribe(danmaku => {
  this.createDanmaku(danmaku.text);
});

3. 互动游戏

// 游戏中的弹幕效果
onPlayerAction(action: string): void {
  this.actionSubject$.next(action);
}

this.actionSubject$.pipe(
  throttleTime(500) // 限制动作触发频率
).subscribe(action => {
  this.createDanmaku(action);
});

性能优化建议

1. 限制弹幕数量

限制同时显示的弹幕数量,避免性能问题:

// 限制弹幕数量
private readonly MAX_DANMAKU = 50;

private createDanmaku(text: string): void {
  // 如果弹幕数量超过限制,移除最旧的
  if (this.danmakuList.length >= this.MAX_DANMAKU) {
    this.danmakuList.shift();
  }
  
  // 创建新弹幕
  // ...
}

2. 使用虚拟滚动

对于大量弹幕,可以使用虚拟滚动技术:

// 只渲染可见区域的弹幕
getVisibleDanmakus(): DanmakuItem[] {
  return this.danmakuList.filter(danmaku => {
    // 判断弹幕是否在可见区域
    return this.isDanmakuVisible(danmaku);
  });
}

3. 使用 CSS 动画

使用 CSS 动画而不是 JavaScript 动画,性能更好:

.danmaku-item {
  animation: danmaku-move linear;
  will-change: transform; /* 优化性能 */
}

4. 防抖输入框

对于输入框发送,可以结合防抖:

this.danmakuInput.valueChanges.pipe(
  debounceTime(300),
  distinctUntilChanged()
).subscribe(value => {
  // 输入框变化处理
});

注意事项

  1. 内存泄漏:确保弹幕动画结束后及时移除
  2. 性能问题:限制同时显示的弹幕数量
  3. 用户体验:合理设置节流时间,既限制频率又保持响应
  4. 动画流畅:使用 CSS 动画和 will-change 优化性能

总结

使用 throttleTime 实现弹幕系统是一个优雅的解决方案,它通过限制点击事件的触发频率来确保:

  • 性能优化:避免创建过多弹幕导致性能问题
  • 用户体验:保持即时反馈,但限制频率
  • 代码简洁:使用 RxJS 操作符,代码清晰易读
  • 易于扩展:可以轻松添加更多功能(如弹幕过滤、弹幕样式等)

通过合理使用 RxJS 操作符(throttleTimetakeUntil 等),我们可以构建一个流畅、高效的弹幕系统。

记住:节流适合需要即时反馈但需要限制频率的场景,而防抖适合等待用户完成操作的场景

码云地址:gitee.com/leeyamaster…

20个例子掌握RxJS——第十章使用 RxJS 实现大文件分片上传

作者 LeeYaMaster
2026年1月29日 17:06

RxJS 实战:使用 RxJS 实现大文件分片上传

概述

大文件上传是 Web 开发中的常见需求。直接上传大文件可能会遇到以下问题:

  1. 超时:文件太大,上传时间过长,导致请求超时
  2. 内存占用:大文件占用大量内存
  3. 网络中断:网络不稳定时,需要重新上传整个文件
  4. 用户体验差:无法显示上传进度,用户不知道上传状态

分片上传(Chunk Upload)是解决这些问题的有效方案。本章将介绍如何使用 RxJS 实现大文件分片上传,包括断点续传、进度显示、并发控制等功能。

分片上传的基本概念

分片上传是指将大文件分割成多个小片段(Chunk),逐个上传,最后在服务器端合并。主要优势包括:

  1. 避免超时:每个分片较小,上传时间短
  2. 断点续传:网络中断后,只需上传未完成的分片
  3. 进度显示:可以显示每个分片和整体的上传进度
  4. 并发控制:可以控制同时上传的分片数量

实现思路

1. 文件分片

将文件按照指定大小(如 2MB)分割成多个分片:

private createChunks(file: File): ChunkInfo[] {
  const chunks: ChunkInfo[] = [];
  const totalChunks = Math.ceil(file.size / this.CHUNK_SIZE);
  
  for (let i = 0; i < totalChunks; i++) {
    const start = i * this.CHUNK_SIZE;
    const end = Math.min(start + this.CHUNK_SIZE, file.size);
    const blob = file.slice(start, end);
    
    chunks.push({
      index: i,
      start,
      end,
      blob,
      uploaded: false,
      progress: 0
    });
  }
  
  return chunks;
}

2. 上传单个分片

使用 HttpRequestreportProgress 选项来跟踪上传进度:

private uploadChunk(chunk: ChunkInfo, fileId: string, file: File): Observable<{ index: number; progress: number }> {
  // 如果已经上传,直接返回
  if (chunk.uploaded) {
    return of({ index: chunk.index, progress: 100 });
  }
  
  const formData = new FormData();
  formData.append('file', chunk.blob);
  formData.append('chunkIndex', chunk.index.toString());
  formData.append('fileId', fileId);
  formData.append('fileName', file.name);
  formData.append('totalChunks', Math.ceil(file.size / this.CHUNK_SIZE).toString());
  
  const req = new HttpRequest('POST', `${this.API_BASE_URL}/api/upload/chunk`, formData, {
    reportProgress: true // 启用进度报告
  });
  
  return this.http.request(req).pipe(
    map((event: HttpEvent<any>) => {
      switch (event.type) {
        case HttpEventType.UploadProgress:
          if (event.total) {
            const progress = Math.round((100 * event.loaded) / event.total);
            return { index: chunk.index, progress };
          }
          return { index: chunk.index, progress: 0 };
        case HttpEventType.Response:
          return { index: chunk.index, progress: 100 };
        default:
          return { index: chunk.index, progress: 0 };
      }
    }),
    catchError(error => {
      console.error(`分片 ${chunk.index} 上传失败:`, error);
      throw { index: chunk.index, error };
    })
  );
}

3. 并发上传多个分片

使用 mergeMap 并发上传多个分片,并通过第二个参数限制并发数:

// 获取未上传的分片
const pendingChunks = chunks.filter(c => !c.uploaded);

// 创建分片上传流
const chunkStreams$ = from(pendingChunks).pipe(
  mergeMap(chunk => {
    return this.uploadChunk(chunk, fileId, file).pipe(
      takeUntil(this.currentUploadCancel$),
      catchError(error => {
        // 处理错误,继续上传其他分片
        console.error(`分片 ${chunk.index} 上传失败:`, error);
        if (this.uploadState) {
          const chunkToUpdate = this.uploadState.chunks.find(c => c.index === chunk.index);
          if (chunkToUpdate) {
            chunkToUpdate.progress = 0;
          }
        }
        return EMPTY;
      })
    );
  }, this.CONCURRENT_LIMIT), // 并发限制:最多同时上传 3 个分片
  takeUntil(this.destroy$)
);

4. 聚合进度

使用 scan 操作符聚合所有分片的上传进度:

chunkStreams$.pipe(
  scan((acc, chunkProgress) => {
    if (!this.uploadState) {
      return acc;
    }
    
    const chunk = this.uploadState.chunks.find(c => c.index === chunkProgress.index);
    if (chunk) {
      chunk.progress = chunkProgress.progress;
      if (chunkProgress.progress === 100) {
        chunk.uploaded = true;
      }
    }
    
    // 计算总进度
    const uploadedSize = this.uploadState.chunks.reduce((sum, c) => {
      if (c.uploaded) {
        return sum + c.blob.size;
      }
      return sum + (c.blob.size * c.progress / 100);
    }, 0);
    
    const uploadedChunks = this.uploadState.chunks.filter(c => c.uploaded).length;
    
    const progress = {
      loaded: uploadedSize,
      total: this.uploadState.file.size,
      percentage: Math.round((uploadedSize / this.uploadState.file.size) * 100),
      uploadedChunks,
      totalChunks: this.uploadState.chunks.length
    };
    
    // 更新状态
    if (this.uploadState && this.uploadState.status === 'uploading') {
      this.uploadState.progress = progress;
      this.saveUploadProgress(this.uploadState); // 保存进度到 localStorage
      this.cdr.detectChanges();
    }
    
    return progress;
  }, this.uploadState.progress)
)

5. 断点续传

使用 localStorage 保存上传进度,支持断点续传:

// 保存上传进度
private saveUploadProgress(state: UploadState): void {
  try {
    const dataToSave = {
      fileId: state.fileId,
      chunks: state.chunks.map(c => ({
        index: c.index,
        uploaded: c.uploaded,
        progress: c.progress
      })),
      progress: state.progress,
      status: state.status
    };
    localStorage.setItem(`${STORAGE_KEY_PREFIX}${state.fileId}`, JSON.stringify(dataToSave));
  } catch (e) {
    console.error('保存上传进度失败:', e);
  }
}

// 加载上传进度
private loadUploadProgress(fileId: string): Partial<UploadState> | null {
  const stored = localStorage.getItem(`${STORAGE_KEY_PREFIX}${fileId}`);
  if (stored) {
    try {
      return JSON.parse(stored);
    } catch (e) {
      console.error('解析上传进度失败:', e);
    }
  }
  return null;
}

6. 合并分片

所有分片上传完成后,调用合并接口:

// 合并所有分片
private mergeChunks(fileId: string, fileName: string, totalChunks: number): Observable<any> {
  const params = new HttpParams()
    .set('fileId', fileId)
    .set('fileName', fileName)
    .set('totalChunks', totalChunks.toString());
  
  return this.http.post(`${this.API_BASE_URL}/api/upload/merge`, null, { params }).pipe(
    catchError(error => {
      console.error('合并分片失败:', error);
      return of({ success: true, message: '合并成功(模拟)' });
    })
  );
}

完整流程

1. 开始上传

startUpload(): void {
  const file = this.selectedFile;
  const fileId = this.generateFileId(file);
  
  // 创建分片
  let chunks = this.createChunks(file);
  
  // 尝试从 localStorage 恢复进度
  const savedProgress = this.loadUploadProgress(fileId);
  if (savedProgress && savedProgress.chunks) {
    // 恢复已上传的分片信息
    chunks = chunks.map(chunk => {
      const saved = savedProgress.chunks?.find(c => c.index === chunk.index);
      if (saved) {
        return {
          ...chunk,
          uploaded: saved.uploaded || false,
          progress: saved.progress || 0
        };
      }
      return chunk;
    });
  }
  
  // 初始化上传状态
  this.uploadState = {
    file,
    fileId,
    chunks,
    progress: { /* ... */ },
    status: 'uploading'
  };
  
  // 开始上传未完成的分片
  // ...
}

2. 暂停上传

pauseUpload(): void {
  if (this.uploadState && this.uploadState.status === 'uploading') {
    this.currentUploadCancel$.next(); // 取消当前上传
    this.currentUploadCancel$ = new Subject<void>(); // 创建新的取消 Subject
    this.uploadState.status = 'paused';
    this.saveUploadProgress(this.uploadState); // 保存进度
    this.cdr.detectChanges();
  }
}

3. 继续上传

resumeUpload(): void {
  if (this.uploadState && this.uploadState.status === 'paused') {
    this.startUpload(); // 从暂停处继续
  }
}

关键点解析

1. 并发控制

使用 mergeMap 的第二个参数限制并发数:

mergeMap(chunk => this.uploadChunk(chunk), 3) // 最多同时上传 3 个分片

2. 进度计算

总进度 = 所有分片的已上传大小 / 文件总大小

const uploadedSize = chunks.reduce((sum, c) => {
  if (c.uploaded) {
    return sum + c.blob.size; // 已上传的分片,使用完整大小
  }
  return sum + (c.blob.size * c.progress / 100); // 正在上传的分片,按进度计算
}, 0);

3. 错误处理

单个分片上传失败不影响其他分片:

catchError(error => {
  // 记录错误,继续上传其他分片
  console.error(`分片 ${chunk.index} 上传失败:`, error);
  return EMPTY; // 不中断流
})

4. 取消上传

使用 Subject 实现取消功能:

private currentUploadCancel$ = new Subject<void>();

// 上传时使用 takeUntil
this.uploadChunk(chunk).pipe(
  takeUntil(this.currentUploadCancel$)
)

// 取消时发出信号
cancelUpload(): void {
  this.currentUploadCancel$.next();
}

实际应用场景

1. 大文件上传

适用于上传视频、大型文档等大文件。

2. 断点续传

网络中断后,可以从上次中断的地方继续上传。

3. 进度显示

实时显示上传进度,提升用户体验。

4. 并发优化

通过控制并发数,平衡上传速度和服务器压力。

性能优化建议

1. 合理设置分片大小

根据网络环境和文件大小设置合理的分片大小:

  • 网络好:2-5MB
  • 网络一般:1-2MB
  • 网络差:500KB-1MB

2. 合理设置并发数

根据服务器性能设置合理的并发数:

  • 服务器性能好:3-5 个
  • 服务器性能一般:2-3 个
  • 服务器性能差:1-2 个

3. 压缩文件

对于可以压缩的文件(如图片),先压缩再上传。

4. 使用 Web Workers

对于大文件的分片处理,可以使用 Web Workers 避免阻塞主线程。

注意事项

  1. localStorage 限制:localStorage 有大小限制(通常 5-10MB),大文件的进度信息可能无法完全保存
  2. 服务器支持:需要服务器支持分片上传和合并接口
  3. 文件完整性:合并后需要验证文件完整性(如 MD5)
  4. 内存占用:大文件分片仍会占用内存,需要注意

总结

使用 RxJS 实现大文件分片上传是一个完整的解决方案,它提供了:

  • 分片上传:将大文件分割成小片段上传
  • 断点续传:支持从上次中断处继续上传
  • 进度显示:实时显示上传进度
  • 并发控制:控制同时上传的分片数量
  • 错误处理:单个分片失败不影响其他分片

通过合理使用 RxJS 操作符(mergeMapscantakeUntil 等),我们可以构建一个功能完整、性能优良的大文件上传系统。

码云地址:gitee.com/leeyamaster…

为什么程序员不自己开发一个小程序赚钱

2026年1月29日 17:06

大家好,我是凌览。

如果本文能给你提供启发或帮助,欢迎动动小手指,一键三连(点赞评论转发),给我一些支持和鼓励谢谢。


刷到一个挺扎心的话题:程序员为什么不自己做产品赚钱。

身边还真有不少人问过类似的话:"你天天写代码这么厉害,怎么不自己搞个App、做个小程序?随便弄弄不就发财了?"

每次听到这种问题,我都不知道该从哪儿开始解释。

image.png

最近在 X 乎上看到同行的回答,看完只能说:太真实了。

理想很丰满、现实很骨感

首先,假装我们是程序员,某天深夜加班回家,瘫在沙发上刷手机,突然一个念头炸开——"我去,这个功能市面上根本没有!我要是做一个,肯定爆火!”。

脑子里的画面瞬间清晰:产品上线、用户疯涨、投资人排队、财务自由...,满脑子都是"老子不干了,要创业"。

说干就干,流程走起来:

第一步:注册账号结果发现邮箱早就被自己多年前注册过,还冻结了。解冻、换邮箱,折腾一圈。

第二步:想名字绞尽脑汁想了个好名字,一搜,已被占用。再想想想,终于通过。

第三步:开发前端后端一把抓,不会前端?没事,Ai结伴编程一把梭。uniapp启动,一套代码多端运行,微信、QQ、抖音、快手平台全都要上。

第四步:买服务器,阿里云一核两G,一年600块,付款的时候手还没抖。

第五步:搞域名,随便挑一个,一年30块,便宜。

第六步:备案到这里,噩梦开始了。拍照、填表、等审核,来来回回折腾。好不容易过了,提交小程序审核——"该项目类型个人不支持,需要企业认证。"

卒。亏损-630元。

但程序员嘛,头铁。不信邪,继续:

第七步:注册公司个体户要经营场所,干脆直接注册公司。准备材料、开对公账户、刻公章,又是一顿操作。

第八步:重新认证企业认证要的材料堆成山,干脆重新注册个小程序。又是想名字(原来的还要等两天才能释放)、填资料、承诺书、盖章...

终于,小程序上线了。

上线只是开始,赚钱才是难题。

每天努力宣传、引流,结果广告收益长这样:昨日收入0.65元。

对,你没看错,六毛五。折线图上的曲线在0.3元到1.8元之间反复横跳,月收入6.72元。服务器钱还没赚回来,先赔进去几百块。

什么会这样?

  • 个人开发者不能收费,只能通过挂广告,而广告收入低到离谱。激励广告单价居然只有4.29元/千次展示,Banner广告更惨,几块钱千次展示。算笔账:日访问量要达到2万,才能日入500。2万UV什么概念?很多小公司的官网一天都没这么多人。
  • 推广难,小程序是个封闭生态,你不能诱导分享,否则直接封号。只能从其他平台往微信导流,但用户路径一长,流失率奇高。要开通流量主还得先引流500人,这第一道门槛就卡死不少人。
  • 审核机制让人头大,页面上文字一多,就说你涉及"内容资讯",不给过。个人开发者经营类目受限,动不动就踩红线。

不是技术问题,是商业问题

程序员不做小程序赚钱,不是因为不会写代码,而是因为写代码只是万里长征第一步。

做一个能赚钱的小程序,需要:

  • 产品能力:做什么?解决谁的什么问题?凭什么用你的?
  • 运营能力:流量从哪来?怎么留存?怎么变现?
  • 商业资质:公司、对公账户、各种许可证,合规成本不低;
  • 时间和精力:白天上班,晚上搞副业,服务器半夜挂了还得爬起来修。

而大多数程序员,只是喜欢写代码而已。让他们去搞流量、谈商务、处理工商税务,比写一万行代码还痛苦。

更扎心的是,就算你愿意干这些,小程序的红利期也早过了。2017年刚出来那会儿,确实有人靠简单工具类小程序赚到第一桶金。现在?各大平台库存量几百万个,用户注意力被某音、被红书切得稀碎,新入局者基本就是炮灰。

成功案例

网上经常能看到"做小程序月入过万"的帖子,但仔细看会发现,要么是卖课的,要么是有特殊资源的(比如手里有公众号矩阵导流),要么是早期入局者吃到了红利。 对于普通程序员来说,接个外包项目,按时薪算可能比折腾三个月小程序赚得还多,还省心。

技术只是工具,商业才是战场。会拿锤子的不一定会盖房子,会写代码的不一定能做出赚钱的产品。这不是技术问题,这是两个完全不同的赛道。

最后

所以,开发一个小程序到底能不能赚钱?

能,但跟你关系不大。

要么你有现成的流量池,比如几十万粉丝的公众号、抖音号,小程序只是变现工具;要么你有特殊资源,比如独家数据、行业资质;再要么你踩中了某个极小概率的风口,比如当年疫情期间的健康码周边工具。否则,个人开发者大概率是炮灰。

写代码是确定性的事,输入逻辑输出结果;做生意是概率性的事,投入不一定有回报。 大多数人适合前者,却误以为自己能驾驭后者。

你呢?有没有过"做个产品改变世界"的冲动?最后成了吗?

20个例子掌握RxJS——第九章使用 exhaustMap 实现轮询机制

作者 LeeYaMaster
2026年1月29日 17:06

RxJS 实战:使用 exhaustMap 实现轮询机制

概述

轮询(Polling)是一种定期检查数据更新的技术,常用于实时性要求不高的场景,比如检查任务状态、获取最新数据等。本章将介绍如何使用 RxJS 的 timerexhaustMap 操作符实现优雅的轮询机制。

轮询的基本概念

轮询是指定期(如每 3 秒)发起请求,检查数据是否有更新。常见的轮询场景包括:

  • 任务状态检查:定期检查后台任务是否完成
  • 数据同步:定期从服务器获取最新数据
  • 消息通知:定期检查是否有新消息

为什么使用 exhaustMap?

在轮询场景中,如果前一个请求还没完成,新的轮询周期又到了,我们通常希望:

  • 忽略新的请求:等待前一个请求完成
  • 避免请求堆积:防止多个请求同时进行

exhaustMap 正是为此设计的:它会忽略新的值,直到当前的内部 Observable 完成。

exhaustMap vs 其他操作符

操作符 行为 适用场景
mergeMap 并发执行所有请求 需要所有请求的结果
concatMap 按顺序执行请求 需要保证顺序
switchMap 取消之前的请求 只需要最新结果
exhaustMap 忽略新的请求 避免请求堆积(轮询)

实战场景:定期轮询 API

假设我们需要每 3 秒轮询一次 API,获取最新数据。如果前一个请求还没完成,应该忽略新的轮询周期。

实现思路

  1. 使用 timer(0, 3000) 创建定时器(立即执行第一次,然后每 3 秒执行一次)
  2. 使用 exhaustMap 确保前一个请求完成后再执行下一个
  3. 使用 catchError 处理单个请求的错误
  4. 记录每次轮询的结果

核心代码

// 轮询间隔(毫秒)
readonly pollInterval = 3000; // 3秒

// 轮询订阅
private pollSubscription?: Subscription;

// 轮询状态
isPolling = false;

// 开始轮询
startPolling(): void {
  // 如果已经在轮询,先停止
  if (this.isPolling) {
    return;
  }
  
  this.isPolling = true;
  
  // 使用 timer(0, 3000) 立即执行第一次请求,然后每3秒执行一次
  // 使用 exhaustMap 确保前一个请求完成后再执行下一个,避免请求堆积
  this.pollSubscription = timer(0, this.pollInterval)
    .pipe(
      exhaustMap(() => {
        const recordId = ++this.recordCounter;
        const startTime = new Date().toISOString();
        
        // 创建记录(先标记为 pending,实际在响应中更新)
        return this.http.get<PollApiResponse>(`${this.apiBaseUrl}${this.pollApiUrl}`)
          .pipe(
            catchError(error => {
              // 错误处理
              const errorRecord: PollRecord = {
                id: recordId,
                timestamp: startTime,
                status: 'error',
                error: error.message || '请求失败'
              };
              this.pollRecords.unshift(errorRecord);
              // 限制记录数量,最多保留50条
              if (this.pollRecords.length > 50) {
                this.pollRecords = this.pollRecords.slice(0, 50);
              }
              this.cdr.detectChanges();
              return of(null);
            })
          );
      })
    )
    .subscribe({
      next: (response) => {
        if (response) {
          const record: PollRecord = {
            id: this.recordCounter,
            timestamp: new Date().toISOString(),
            response,
            status: response.success ? 'success' : 'error'
          };
          this.pollRecords.unshift(record);
          // 限制记录数量,最多保留50条
          if (this.pollRecords.length > 50) {
            this.pollRecords = this.pollRecords.slice(0, 50);
          }
          this.cdr.detectChanges();
        }
      },
      error: (error) => {
        console.error('轮询错误:', error);
        this.stopPolling();
      }
    });
}

// 停止轮询
stopPolling(): void {
  if (this.pollSubscription) {
    this.pollSubscription.unsubscribe();
    this.pollSubscription = undefined;
  }
  this.isPolling = false;
  this.cdr.detectChanges();
}

关键点解析

1. timer 操作符

timer(0, 3000) 的含义:

  • 第一个参数(0):延迟时间,0 表示立即执行第一次
  • 第二个参数(3000):间隔时间,每 3000 毫秒(3 秒)执行一次

2. exhaustMap 的作用

exhaustMap 确保:

  • 如果前一个请求还在进行,忽略新的轮询周期
  • 只有当前一个请求完成后,才会处理下一个轮询周期
  • 避免请求堆积,减少服务器压力

3. 执行流程示例

假设 API 响应时间为 2 秒:

  1. 0 秒:timer 发出第一个值 → exhaustMap 发起请求 A(2 秒)
  2. 3 秒:timer 发出第二个值 → exhaustMap 忽略(请求 A 还在进行)
  3. 4 秒:请求 A 完成 → 可以处理下一个值
  4. 6 秒:timer 发出第三个值 → exhaustMap 发起请求 B(2 秒)
  5. 8 秒:请求 B 完成

结果:每 3-4 秒执行一次请求,不会堆积。

4. 错误处理

使用 catchError 确保单个请求的错误不会中断整个轮询流程:

catchError(error => {
  // 记录错误,但继续轮询
  this.handleError(error);
  return of(null);
})

与其他方案的对比

方案 1:使用 setInterval(不推荐)

// ❌ 不推荐:无法优雅地取消,容易导致内存泄漏
const interval = setInterval(() => {
  this.http.get('/api/data').subscribe();
}, 3000);

// 需要手动清理
clearInterval(interval);

方案 2:使用 mergeMap(有问题)

// ⚠️ 问题:可能同时发起多个请求
timer(0, 3000).pipe(
  mergeMap(() => this.http.get('/api/data'))
)

方案 3:使用 exhaustMap(推荐)✅

// ✅ 推荐:避免请求堆积
timer(0, 3000).pipe(
  exhaustMap(() => this.http.get('/api/data'))
)

高级用法

1. 条件轮询

根据条件决定是否继续轮询:

timer(0, 3000).pipe(
  exhaustMap(() => this.http.get('/api/task-status')),
  takeWhile(response => response.status !== 'completed'), // 任务完成时停止轮询
  finalize(() => console.log('轮询结束'))
)

2. 动态调整轮询间隔

根据响应结果动态调整轮询间隔:

let pollInterval = 3000;

timer(0, pollInterval).pipe(
  exhaustMap(() => this.http.get('/api/data')),
  tap(response => {
    // 根据响应调整轮询间隔
    if (response.hasUpdate) {
      pollInterval = 1000; // 有更新时加快轮询
    } else {
      pollInterval = 5000; // 无更新时减慢轮询
    }
  })
)

3. 指数退避轮询

如果请求失败,逐渐增加轮询间隔:

let pollInterval = 3000;
let consecutiveErrors = 0;

timer(0, pollInterval).pipe(
  exhaustMap(() => 
    this.http.get('/api/data').pipe(
      tap(() => {
        consecutiveErrors = 0; // 成功时重置错误计数
        pollInterval = 3000; // 重置间隔
      }),
      catchError(error => {
        consecutiveErrors++;
        pollInterval = Math.min(3000 * Math.pow(2, consecutiveErrors), 30000); // 指数退避
        return of(null);
      })
    )
  )
)

实际应用场景

1. 任务状态检查

// 检查后台任务是否完成
startPollingTaskStatus(taskId: string): void {
  timer(0, 2000).pipe(
    exhaustMap(() => this.getTaskStatus(taskId)),
    takeWhile(status => status !== 'completed' && status !== 'failed'),
    finalize(() => {
      // 任务完成,停止轮询
      this.onTaskComplete();
    })
  ).subscribe();
}

2. 数据同步

// 定期同步数据
startDataSync(): void {
  timer(0, 5000).pipe(
    exhaustMap(() => this.syncData()),
    catchError(error => {
      console.error('同步失败:', error);
      return of(null); // 继续轮询
    })
  ).subscribe();
}

3. 消息通知

// 定期检查新消息
startMessagePolling(): void {
  timer(0, 3000).pipe(
    exhaustMap(() => this.checkNewMessages()),
    tap(messages => {
      if (messages.length > 0) {
        this.showNotifications(messages);
      }
    })
  ).subscribe();
}

性能优化建议

1. 合理设置轮询间隔

根据业务需求设置合理的轮询间隔:

  • 实时性要求高:1-3 秒
  • 一般场景:3-5 秒
  • 实时性要求低:10-30 秒

2. 限制记录数量

对于轮询结果,限制记录数量,避免内存占用过大:

if (this.pollRecords.length > 50) {
  this.pollRecords = this.pollRecords.slice(0, 50);
}

3. 在页面不可见时暂停轮询

使用 Page Visibility API 在页面不可见时暂停轮询:

fromEvent(document, 'visibilitychange').pipe(
  switchMap(() => {
    if (document.hidden) {
      this.stopPolling();
      return EMPTY;
    } else {
      this.startPolling();
      return EMPTY;
    }
  })
).subscribe();

注意事项

  1. 内存泄漏:确保在组件销毁时取消订阅
  2. 服务器压力:合理设置轮询间隔,避免给服务器造成过大压力
  3. 网络消耗:轮询会持续消耗网络资源,考虑使用 WebSocket 替代
  4. 用户体验:给用户适当的反馈,告知正在轮询

总结

使用 exhaustMap 实现轮询机制是一个优雅的解决方案,它通过忽略新的请求来确保:

  • 避免请求堆积:前一个请求完成后再执行下一个
  • 资源节约:不会同时发起多个请求
  • 代码简洁:使用 RxJS 操作符,代码清晰易读
  • 易于管理:可以轻松启动和停止轮询

记住:轮询是一种简单但有效的实时数据获取方式,但对于实时性要求高的场景,考虑使用 WebSocket 或 Server-Sent Events

码云地址:gitee.com/leeyamaster…

20个例子掌握RxJS——第八章使用 retryWhen 实现失败重试机制

作者 LeeYaMaster
2026年1月29日 17:05

RxJS 实战:使用 retryWhen 实现失败重试机制

概述

在网络请求中,由于网络波动、服务器临时故障等原因,请求可能会失败。简单的重试机制(如 retry 操作符)可能不够灵活,无法满足复杂的需求。本章将介绍如何使用 retryWhen 操作符实现更灵活的重试机制,比如延迟重试、限制重试次数等。

retry 操作符的局限性

RxJS 提供了 retry 操作符,可以简单地重试指定次数:

this.http.get('/api/data').pipe(
  retry(3) // 失败后立即重试 3 次
)

问题

  • 立即重试,可能服务器还没恢复
  • 无法自定义重试逻辑(如延迟重试)
  • 无法根据错误类型决定是否重试

retryWhen 操作符简介

retryWhen 提供了更灵活的重试机制,它允许我们:

  1. 自定义重试逻辑:根据错误类型决定是否重试
  2. 延迟重试:在重试前等待一段时间
  3. 限制重试次数:使用 takescan 限制重试次数
  4. 指数退避:每次重试的延迟时间递增

基本语法

source$.pipe(
  retryWhen(errors => 
    errors.pipe(
      // 自定义重试逻辑
    )
  )
)

实战场景:延迟重试失败请求

假设我们有一个可能失败的 API,失败后需要等待 3 秒再重试,最多重试 3 次。

实现思路

  1. 使用 retryWhen 捕获错误
  2. 使用 scan 计数重试次数
  3. 使用 delay 延迟重试
  4. 使用 take 限制重试次数

核心代码

// 最大重试次数
private readonly maxRetries = 3;

// 发起请求(带重试逻辑)
private makeRequestWithRetry(): void {
  // 重置状态
  this.requestStatus = {
    status: 'requesting',
    retryCount: 0
  };
  this.cdr.detectChanges();
  
  this.http.get<ApiResponse>(`${this.apiBaseUrl}/api/fail`)
    .pipe(
      // 使用 retryWhen 实现失败后 3 秒重试
      retryWhen(errors => 
        errors.pipe(
          // 使用 scan 来计数重试次数
          scan((retryCount, error) => {
            // 更新重试次数
            this.requestStatus.retryCount = retryCount + 1;
            
            // 如果还没超过最大重试次数,更新状态为重试中
            if (retryCount < this.maxRetries) {
              this.requestStatus.status = 'retrying';
              this.cdr.detectChanges();
            }
            
            return retryCount + 1;
          }, 0),
          // 延迟 3 秒后重试
          delay(3000),
          // 最多重试 maxRetries 次
          take(this.maxRetries)
        )
      ),
      catchError(err => {
        // 如果最终失败,返回错误
        this.requestStatus.status = 'failed';
        this.requestStatus.error = err.message || '请求失败,已达到最大重试次数';
        this.cdr.detectChanges();
        return of(null);
      })
    )
    .subscribe({
      next: (response) => {
        if (response) {
          // 请求成功
          this.requestStatus.status = 'success';
          this.requestStatus.response = response;
          this.cdr.detectChanges();
        }
      },
      error: (err) => {
        // 处理错误(虽然已经在 catchError 中处理了)
        if (this.requestStatus.status !== 'failed') {
          this.requestStatus.status = 'failed';
          this.requestStatus.error = err.message || '请求失败';
          this.cdr.detectChanges();
        }
      }
    });
}

关键点解析

1. retryWhen 的工作机制

retryWhen 接收一个函数,该函数接收一个 Observable(错误流),返回一个 Observable。当返回的 Observable 发出值时,会重试源 Observable。

2. scan 操作符计数

scan 操作符用于累积值,这里用来计数重试次数:

scan((retryCount, error) => {
  return retryCount + 1; // 每次错误时递增
}, 0) // 初始值为 0

3. delay 延迟重试

delay(3000) 会在重试前等待 3 秒,给服务器恢复的时间。

4. take 限制重试次数

take(this.maxRetries) 确保最多重试 3 次,超过后不再重试。

5. 执行流程

  1. 第一次请求:失败 → 进入 retryWhen
  2. 第一次重试:等待 3 秒 → 重试 → 如果失败,继续
  3. 第二次重试:等待 3 秒 → 重试 → 如果失败,继续
  4. 第三次重试:等待 3 秒 → 重试 → 如果失败,不再重试
  5. 最终结果:成功或失败

高级用法

1. 指数退避(Exponential Backoff)

每次重试的延迟时间递增:

retryWhen(errors =>
  errors.pipe(
    scan((retryCount, error) => {
      const delay = Math.min(1000 * Math.pow(2, retryCount), 10000); // 指数递增,最多 10 秒
      return { retryCount: retryCount + 1, delay };
    }, { retryCount: 0, delay: 1000 }),
    mergeMap(({ delay }) => timer(delay)), // 使用动态延迟
    take(5)
  )
)

2. 根据错误类型决定是否重试

只对特定错误重试:

retryWhen(errors =>
  errors.pipe(
    mergeMap((error, index) => {
      // 只对 500 错误重试
      if (error.status === 500 && index < 3) {
        return timer(3000); // 延迟 3 秒后重试
      }
      return throwError(() => error); // 其他错误不重试
    })
  )
)

3. 重试前执行操作

在重试前执行一些操作(如刷新 Token):

retryWhen(errors =>
  errors.pipe(
    mergeMap((error, index) => {
      if (error.status === 401 && index < 1) {
        // 刷新 Token 后再重试
        return this.refreshToken().pipe(
          switchMap(() => timer(1000)) // 延迟 1 秒后重试
        );
      }
      return throwError(() => error);
    })
  )
)

与其他方案的对比

方案 1:使用 retry(简单但不灵活)

// ⚠️ 立即重试,无法延迟
this.http.get('/api/data').pipe(
  retry(3)
)

方案 2:手动实现(复杂)

// ⚠️ 需要手动管理状态和循环
let retryCount = 0;
const maxRetries = 3;

function makeRequest() {
  return this.http.get('/api/data').pipe(
    catchError(error => {
      if (retryCount < maxRetries) {
        retryCount++;
        return timer(3000).pipe(
          switchMap(() => makeRequest())
        );
      }
      return throwError(() => error);
    })
  );
}

方案 3:使用 retryWhen(推荐)✅

// ✅ 简洁、灵活
this.http.get('/api/data').pipe(
  retryWhen(errors =>
    errors.pipe(
      scan((count) => count + 1, 0),
      delay(3000),
      take(3)
    )
  )
)

实际应用场景

1. API 请求重试

// 网络请求失败后重试
this.http.get('/api/data').pipe(
  retryWhen(errors =>
    errors.pipe(
      scan((count) => count + 1, 0),
      mergeMap(count => {
        if (count > 3) {
          return throwError(() => new Error('重试次数过多'));
        }
        return timer(1000 * count); // 延迟时间递增
      })
    )
  )
)

2. WebSocket 连接重试

// WebSocket 连接失败后重试
connectWebSocket().pipe(
  retryWhen(errors =>
    errors.pipe(
      scan((count) => count + 1, 0),
      mergeMap(count => {
        if (count > 5) {
          return throwError(() => new Error('连接失败'));
        }
        return timer(2000 * count); // 延迟时间递增
      })
    )
  )
)

3. 文件上传重试

// 文件上传失败后重试
uploadFile(file).pipe(
  retryWhen(errors =>
    errors.pipe(
      scan((count) => count + 1, 0),
      mergeMap(count => {
        if (count > 2) {
          return throwError(() => new Error('上传失败'));
        }
        return timer(3000); // 固定延迟 3 秒
      })
    )
  )
)

性能优化建议

1. 合理设置重试次数

根据业务需求设置合理的重试次数:

  • 关键操作:3-5 次
  • 非关键操作:1-2 次
  • 实时性要求高:1 次或不重试

2. 使用指数退避

对于可能长时间故障的服务,使用指数退避:

retryWhen(errors =>
  errors.pipe(
    scan((count) => count + 1, 0),
    mergeMap(count => {
      const delay = Math.min(1000 * Math.pow(2, count), 30000);
      return timer(delay);
    }),
    take(5)
  )
)

3. 根据错误类型决定是否重试

只对可恢复的错误重试:

retryWhen(errors =>
  errors.pipe(
    mergeMap((error, index) => {
      // 只对网络错误和 5xx 错误重试
      if ((error.status >= 500 || !error.status) && index < 3) {
        return timer(3000);
      }
      return throwError(() => error);
    })
  )
)

注意事项

  1. 无限重试:确保使用 take 限制重试次数,避免无限重试
  2. 资源占用:重试会占用资源,需要合理设置重试次数和延迟
  3. 用户体验:给用户适当的反馈,告知正在重试
  4. 错误处理:确保最终失败时有适当的错误处理

总结

retryWhen 是实现灵活重试机制的强大工具,它允许我们:

  • 自定义重试逻辑:根据错误类型和次数决定是否重试
  • 延迟重试:在重试前等待,给服务器恢复的时间
  • 限制重试次数:避免无限重试
  • 指数退避:延迟时间递增,减少服务器压力

在实际项目中,根据具体需求选择合适的重试策略,既能提高请求的成功率,又能避免过度重试带来的资源浪费。

记住:重试是一种容错机制,但不是万能的。对于关键操作,还需要考虑其他容错方案,如降级、缓存等

码云地址:gitee.com/leeyamaster…

20个例子掌握RxJS——第七章使用 shareReplay 实现 Token 刷新的并发控制

作者 LeeYaMaster
2026年1月29日 17:05

RxJS 实战:使用 shareReplay 实现 Token 刷新的并发控制

概述

在需要身份验证的 Web 应用中,Token 过期是一个常见问题。当多个请求同时发起,且 Token 都已过期时,如果每个请求都独立刷新 Token,会导致:

  1. 多个重复的 Token 刷新请求
  2. 资源浪费
  3. 可能的竞态条件

本章将介绍如何使用 RxJS 的 shareReplay 操作符来实现 Token 刷新的并发控制,确保多个请求共享同一个 Token 刷新请求。

问题场景

假设我们有 3 个 API 请求同时发起,且 Token 都已过期:

  1. 请求 A:检测到 Token 过期 → 发起 Token 刷新请求 1
  2. 请求 B:检测到 Token 过期 → 发起 Token 刷新请求 2
  3. 请求 C:检测到 Token 过期 → 发起 Token 刷新请求 3

问题:3 个请求都独立刷新 Token,导致重复请求和资源浪费。

期望:3 个请求共享同一个 Token 刷新请求,只刷新一次。

shareReplay 操作符简介

shareReplay 是 RxJS 中用于共享 Observable 结果的操作符。它会:

  1. 共享订阅:多个订阅者共享同一个源 Observable
  2. 缓存结果:缓存指定数量的最新值
  3. 避免重复执行:源 Observable 只执行一次

基本语法

source$.pipe(
  shareReplay(1) // 缓存最新的 1 个值
)

解决方案:使用 shareReplay 共享 Token 刷新

实现思路

  1. 创建一个 Token 刷新 Observable,使用 shareReplay 共享
  2. 当请求检测到 Token 过期时,订阅共享的 Token 刷新 Observable
  3. Token 刷新完成后,所有等待的请求都使用新的 Token 重试

核心代码

// 当前token(初始为过期token)
private currentToken = 'expired_token';

// token刷新Observable(使用shareReplay确保只有一个请求)
private tokenRefresh$: Observable<TokenResponse> | null = null;

// 刷新token(使用shareReplay确保并发请求时只有一个token刷新请求)
private refreshToken(): Observable<TokenResponse> {
  // 如果已经有正在进行的token刷新请求,直接返回该Observable
  if (this.tokenRefresh$) {
    return this.tokenRefresh$;
  }
  
  // 标记正在刷新token
  this.refreshingToken = true;
  this.cdr.detectChanges();
  
  // 创建新的token刷新请求,使用shareReplay确保多个订阅者共享同一个请求
  this.tokenRefresh$ = this.http.post<TokenResponse>(`${this.apiBaseUrl}/api/refresh-token`, {})
    .pipe(
      shareReplay(1), // 关键:使用shareReplay确保只有一个请求,所有订阅者共享结果
      catchError(error => {
        console.error('Token刷新失败:', error);
        this.tokenRefresh$ = null; // 重置,允许重试
        this.refreshingToken = false;
        this.cdr.detectChanges();
        return throwError(() => error);
      }),
      finalize(() => {
        // 请求完成后重置tokenRefresh$,允许下次刷新
        setTimeout(() => {
          this.tokenRefresh$ = null;
          this.refreshingToken = false;
          this.cdr.detectChanges();
        }, 100);
      })
    );
  
  return this.tokenRefresh$;
}

// 发起带token的请求(自动处理token刷新)
private makeRequestWithToken(apiName: string, apiUrl: string): Observable<ApiResponse> {
  // 先尝试使用当前token请求
  return this.http.get<ApiResponse>(`${this.apiBaseUrl}${apiUrl}`, {
    headers: new HttpHeaders({
      'Authorization': `Bearer ${this.currentToken}`
    })
  }).pipe(
    catchError((error) => {
      // 如果token过期(401错误)
      if (error.status === 401 && error.error?.code === 'TOKEN_EXPIRED') {
        console.log(`${apiName} token过期,等待token刷新...`);
        
        // 刷新token(如果已经有正在进行的刷新请求,会复用)
        return this.refreshToken().pipe(
          switchMap((tokenResponse) => {
            // token刷新成功,更新当前token
            this.currentToken = tokenResponse.data.token;
            this.currentTokenDisplay = tokenResponse.data.token;
            this.cdr.detectChanges();
            console.log(`${apiName} token刷新成功,使用新token重试请求`);
            
            // 使用新token重试请求
            return this.http.get<ApiResponse>(`${this.apiBaseUrl}${apiUrl}`, {
              headers: new HttpHeaders({
                'Authorization': `Bearer ${this.currentToken}`
              })
            });
          }),
          catchError((refreshError) => {
            console.error(`${apiName} token刷新失败:`, refreshError);
            return throwError(() => refreshError);
          })
        );
      }
      
      // 其他错误直接抛出
      return throwError(() => error);
    })
  );
}

关键点解析

1. shareReplay 的共享机制

当多个请求同时检测到 Token 过期时:

  1. 第一个请求:调用 refreshToken(),创建新的 Token 刷新 Observable,并订阅它(发起 HTTP 请求)
  2. 第二个请求:调用 refreshToken(),发现 tokenRefresh$ 已存在,直接返回共享的 Observable,并订阅它
  3. 第三个请求:调用 refreshToken(),发现 tokenRefresh$ 已存在,直接返回共享的 Observable,并订阅它

等待机制

  • 第二个、第三个请求通过订阅同一个 ObservabletokenRefresh$)来实现等待
  • 当它们调用 this.refreshToken().pipe(switchMap(...)) 时,switchMap等待上游 Observable(tokenRefresh$)发出值
  • 由于使用了 shareReplay,多个订阅者会共享同一个底层的 Observable 执行
  • 当第一个订阅者已经发起 HTTP 请求时,后续的订阅者会"加入"这个正在进行的执行
  • 当 HTTP 请求完成并发出 Token 值时,所有订阅者都会同时收到这个值
  • 然后 switchMap 才会执行下游的操作(使用新 token 重试请求)

结果:3 个请求共享同一个 Token 刷新请求,只刷新一次,并且都会等待 Token 刷新完成后才继续。

2. 缓存机制

shareReplay(1) 会缓存最新的 1 个值。这意味着:

  • 如果 Token 刷新已完成,后续订阅者会立即收到缓存的结果
  • 不需要重新发起请求

3. 错误处理

如果 Token 刷新失败:

  1. 重置 tokenRefresh$null,允许重试
  2. 抛出错误,让调用者处理

4. 清理机制

finalize 中重置 tokenRefresh$,确保下次 Token 过期时可以重新刷新。

执行流程示例

假设 3 个请求同时发起,且 Token 都已过期:

  1. 请求 A:检测到 Token 过期

    • 调用 refreshToken(),创建 Token 刷新 Observable(使用 shareReplay
    • 调用 this.refreshToken().pipe(switchMap(...))订阅 tokenRefresh$
    • 由于是第一个订阅者,开始执行底层 Observable,发起 HTTP 请求(Token 刷新)
  2. 请求 B:检测到 Token 过期(在请求 A 的 Token 刷新完成前,例如 1 秒后)

    • 调用 refreshToken(),发现 tokenRefresh$ 已存在,返回同一个 Observable
    • 调用 this.refreshToken().pipe(switchMap(...))订阅同一个 tokenRefresh$
    • 由于使用了 shareReplay加入正在进行的 HTTP 请求执行(不发起新请求)
    • switchMap 等待上游 Observable(tokenRefresh$)发出值
  3. 请求 C:检测到 Token 过期(在请求 A 的 Token 刷新完成前,例如 2 秒后)

    • 调用 refreshToken(),发现 tokenRefresh$ 已存在,返回同一个 Observable
    • 调用 this.refreshToken().pipe(switchMap(...))订阅同一个 tokenRefresh$
    • 由于使用了 shareReplay加入正在进行的 HTTP 请求执行(不发起新请求)
    • switchMap 等待上游 Observable(tokenRefresh$)发出值
  4. Token 刷新完成(例如 3 秒后)

    • HTTP 请求返回新的 Token
    • tokenRefresh$ Observable 发出值(新的 Token)
    • 所有订阅者(请求 A、B、C)同时收到新的 Token
    • 所有请求的 switchMap 同时执行,使用新 Token 重试各自的请求

关键点

  • 第二个、第三个请求通过订阅同一个 Observable 来实现等待
  • switchMap阻塞等待上游 Observable 发出值,然后才执行下游操作
  • shareReplay 确保多个订阅者共享同一个底层的 HTTP 请求执行

结果:只发起 1 次 Token 刷新请求,3 个请求共享结果,并且都会等待 Token 刷新完成后才继续 ✅

与其他方案的对比

方案 1:不使用 shareReplay(有问题)

// ❌ 错误示例:每个请求都独立刷新 Token
private refreshToken(): Observable<TokenResponse> {
  return this.http.post('/api/refresh-token', {}); // 可能发起多次请求
}

方案 2:使用标志位(复杂)

// ⚠️ 可行但复杂:需要手动管理标志位和 Promise
private isRefreshing = false;
private refreshPromise?: Promise<TokenResponse>;

private async refreshToken(): Promise<TokenResponse> {
  if (this.isRefreshing && this.refreshPromise) {
    return this.refreshPromise; // 等待正在进行的刷新
  }
  
  this.isRefreshing = true;
  this.refreshPromise = this.http.post('/api/refresh-token', {}).toPromise();
  // ...
}

方案 3:使用 shareReplay(推荐)✅

// ✅ 推荐:简洁、自动管理
private tokenRefresh$ = this.http.post('/api/refresh-token', {}).pipe(
  shareReplay(1)
);

实际应用场景

1. HTTP 拦截器中的 Token 刷新

// HTTP 拦截器
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
  return next.handle(req).pipe(
    catchError(error => {
      if (error.status === 401) {
        return this.refreshToken().pipe(
          switchMap(token => {
            // 使用新 Token 重试请求
            const cloned = req.clone({
              setHeaders: { Authorization: `Bearer ${token}` }
            });
            return next.handle(cloned);
          })
        );
      }
      return throwError(() => error);
    })
  );
}

2. 多个 API 请求的 Token 刷新

// 同时发起多个请求
forkJoin({
  user: this.getUser(),
  orders: this.getOrders(),
  products: this.getProducts()
}).subscribe(results => {
  // 所有请求共享同一个 Token 刷新请求
});

性能优化建议

1. 添加 Token 过期时间检查

在刷新 Token 前,检查 Token 是否真的过期:

private isTokenExpired(): boolean {
  // 检查 Token 是否过期
  return this.tokenExpiryTime < Date.now();
}

private refreshToken(): Observable<TokenResponse> {
  if (!this.isTokenExpired()) {
    return of({ token: this.currentToken }); // Token 未过期,直接返回
  }
  // ...
}

2. 添加重试机制

对于 Token 刷新失败的情况,可以添加重试:

this.http.post('/api/refresh-token', {}).pipe(
  retry(2), // 失败后重试 2 次
  shareReplay(1)
)

3. 添加超时处理

为 Token 刷新添加超时处理:

this.http.post('/api/refresh-token', {}).pipe(
  timeout(5000), // 5 秒超时
  shareReplay(1)
)

注意事项

  1. 内存泄漏:确保在组件销毁时取消订阅
  2. 错误处理:确保 Token 刷新失败时有适当的错误处理
  3. 并发控制shareReplay 确保只有一个请求,但需要正确管理状态
  4. Token 存储:刷新后的 Token 需要正确存储和更新

总结

使用 shareReplay 实现 Token 刷新的并发控制是一个优雅的解决方案,它通过共享 Observable 来确保:

  • 避免重复请求:多个请求共享同一个 Token 刷新请求
  • 资源节约:减少不必要的网络请求
  • 代码简洁:不需要手动管理标志位和 Promise
  • 自动管理:RxJS 自动处理订阅和取消

记住:当你需要多个订阅者共享同一个 Observable 结果时,使用 shareReplay 是最佳选择。

码云地址:gitee.com/leeyamaster…

20个例子掌握RxJS——第六章防抖(debounce)与节流(throttle)的应用

作者 LeeYaMaster
2026年1月29日 17:04

RxJS 实战:防抖(debounce)与节流(throttle)的应用

概述

在用户交互频繁的场景中,比如搜索输入框、滚动事件、窗口调整等,如果不做处理,可能会触发大量不必要的请求或计算。防抖(debounce)和节流(throttle)是两种常用的优化技术,可以有效地控制事件触发的频率。本章将详细介绍如何在 RxJS 中使用 debounceTimethrottleTime 操作符。

防抖(Debounce)vs 节流(Throttle)

防抖(Debounce)

定义:在事件被触发后,等待一定时间(如 500ms),如果在这段时间内没有再次触发事件,才执行操作。如果在等待期间又触发了事件,则重新计时。

形象比喻:就像电梯门,当有人进入时,电梯门会等待一段时间,如果在这段时间内又有人进入,则重新计时。只有当等待时间内没有人进入时,电梯门才关闭。

适用场景

  • 搜索输入框:用户停止输入后才发起搜索请求
  • 窗口调整:用户停止调整窗口大小后才重新计算布局
  • 表单验证:用户停止输入后才进行验证

节流(Throttle)

定义:在指定时间间隔内,无论事件触发多少次,只执行一次操作。

形象比喻:就像水龙头,无论你拧多少次,水流的频率是固定的(比如每秒流一次)。

适用场景

  • 滚动事件:滚动时每 100ms 更新一次位置
  • 鼠标移动:鼠标移动时每 50ms 更新一次坐标
  • 按钮点击:防止用户快速重复点击

debounceTime 操作符

debounceTime 会延迟值的发出,直到源 Observable 在指定时间内没有发出新值。

基本语法

source$.pipe(
  debounceTime(500) // 500ms 内没有新值才发出
)

实战场景:搜索输入框

假设我们有一个搜索输入框,用户输入时会触发搜索请求。使用 debounceTime 可以确保只在用户停止输入后才发起请求。

核心代码

// 防抖输入框
debounceInput = new FormControl('');

ngOnInit(): void {
  // 防抖输入框:使用 debounceTime,停止输入 500ms 后发起请求
  this.debounceInput.valueChanges
    .pipe(
      distinctUntilChanged(), // 只有值真正改变时才触发
      debounceTime(500), // 防抖:停止输入 500ms 后才触发
      switchMap((query) => {
        if (!query || query.trim() === '') {
          return of(null);
        }
        
        const recordId = ++this.requestCounter;
        const record: RequestRecord = {
          id: recordId,
          type: 'debounce',
          query: query.trim(),
          timestamp: Date.now()
        };
        
        this.debounceRecords.unshift(record);
        this.debounceLoading = true;
        this.cdr.detectChanges();
        
        const params = new HttpParams().set('q', query.trim());
        return this.http.get<SearchApiResponse>(`${this.apiBaseUrl}/api/search`, { params })
          .pipe(
            catchError(err => {
              console.error('防抖请求失败:', err);
              record.error = err.message || '请求失败';
              return of({
                success: false,
                message: err.message || '请求失败',
                data: {
                  query: query.trim(),
                  timestamp: new Date().toISOString(),
                  results: []
                }
              } as SearchApiResponse);
            })
          );
      }),
      takeUntil(this.destroySubject$)
    )
    .subscribe({
      next: (response) => {
        if (response === null) {
          this.debounceLoading = false;
          this.cdr.detectChanges();
          return;
        }
        
        const latestRecord = this.debounceRecords.find(r => !r.response && !r.error);
        if (latestRecord) {
          latestRecord.response = response;
        }
        
        this.debounceCurrentResult = response;
        this.debounceLoading = false;
        this.cdr.detectChanges();
      }
    });
}

执行流程示例

假设用户输入 "rxjs":

  1. 用户输入 "r" → 等待 500ms
  2. 用户输入 "x"(在 500ms 内)→ 重新计时,等待 500ms
  3. 用户输入 "j"(在 500ms 内)→ 重新计时,等待 500ms
  4. 用户输入 "s"(在 500ms 内)→ 重新计时,等待 500ms
  5. 用户停止输入 → 500ms 后发起搜索请求 ✅

结果:只发起 1 次请求,搜索 "rxjs"

throttleTime 操作符

throttleTime 会在指定时间间隔内,只发出第一个值,忽略后续的值。

基本语法

source$.pipe(
  throttleTime(500) // 每 500ms 最多发出一次
)

实战场景:搜索输入框(节流版本)

使用 throttleTime 可以确保在指定时间间隔内最多发起一次请求。

核心代码

// 节流输入框
throttleInput = new FormControl('');

ngOnInit(): void {
  // 节流输入框:使用 throttleTime,每 500ms 最多触发一次
  this.throttleInput.valueChanges
    .pipe(
      distinctUntilChanged(), // 只有值真正改变时才触发
      throttleTime(500), // 节流:每 500ms 最多触发一次
      switchMap((query) => {
        if (!query || query.trim() === '') {
          return of(null);
        }
        
        const recordId = ++this.requestCounter;
        const record: RequestRecord = {
          id: recordId,
          type: 'throttle',
          query: query.trim(),
          timestamp: Date.now()
        };
        
        this.throttleRecords.unshift(record);
        this.throttleLoading = true;
        this.cdr.detectChanges();
        
        const params = new HttpParams().set('q', query.trim());
        return this.http.get<SearchApiResponse>(`${this.apiBaseUrl}/api/search`, { params })
          .pipe(
            catchError(err => {
              console.error('节流请求失败:', err);
              record.error = err.message || '请求失败';
              return of({
                success: false,
                message: err.message || '请求失败',
                data: {
                  query: query.trim(),
                  timestamp: new Date().toISOString(),
                  results: []
                }
              } as SearchApiResponse);
            })
          );
      }),
      takeUntil(this.destroySubject$)
    )
    .subscribe({
      next: (response) => {
        // 处理响应...
      }
    });
}

执行流程示例

假设用户快速输入 "rxjs"(每个字符间隔 100ms):

  1. 用户输入 "r" → 立即发起请求,搜索 "r"
  2. 用户输入 "x"(100ms 后)→ 被忽略(在 500ms 内)
  3. 用户输入 "j"(200ms 后)→ 被忽略(在 500ms 内)
  4. 用户输入 "s"(300ms 后)→ 被忽略(在 500ms 内)
  5. 500ms 后 → 可以发起新请求
  6. 用户输入其他字符 → 立即发起请求

结果:可能发起多次请求,但每 500ms 最多一次

对比总结

特性 debounceTime throttleTime
触发时机 停止触发后等待一段时间 固定时间间隔内触发一次
请求次数 通常更少(只在停止后触发) 可能更多(固定间隔触发)
适用场景 搜索输入框、窗口调整 滚动事件、鼠标移动
用户体验 等待用户完成操作 实时反馈但有限制

实际应用场景

1. 搜索输入框(推荐防抖)

searchInput.valueChanges.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(query => this.searchService.search(query))
).subscribe(results => {
  this.searchResults = results;
});

2. 滚动事件(推荐节流)

fromEvent(window, 'scroll').pipe(
  throttleTime(100)
).subscribe(() => {
  this.updateScrollPosition();
});

3. 窗口调整(推荐防抖)

fromEvent(window, 'resize').pipe(
  debounceTime(300)
).subscribe(() => {
  this.recalculateLayout();
});

4. 按钮点击(推荐节流)

buttonClick$.pipe(
  throttleTime(1000) // 1 秒内最多点击一次
).subscribe(() => {
  this.submitForm();
});

组合使用

防抖 + switchMap

// 搜索输入框:防抖 + 取消之前的请求
searchInput.valueChanges.pipe(
  debounceTime(300),
  switchMap(query => this.search(query))
)

节流 + distinctUntilChanged

// 滚动事件:节流 + 去重
scroll$.pipe(
  throttleTime(100),
  distinctUntilChanged()
)

性能优化建议

1. 合理设置时间间隔

  • 搜索输入框:300-500ms(给用户足够的输入时间)
  • 滚动事件:50-100ms(保持流畅性)
  • 窗口调整:300-500ms(避免频繁计算)

2. 结合 distinctUntilChanged

使用 distinctUntilChanged 可以避免相同值的重复处理:

input$.pipe(
  distinctUntilChanged(),
  debounceTime(300)
)

3. 结合 switchMap

使用 switchMap 可以取消之前的请求:

input$.pipe(
  debounceTime(300),
  switchMap(query => this.search(query))
)

注意事项

  1. 时间间隔选择:根据具体场景选择合适的间隔时间
  2. 用户体验:防抖可能会让用户感觉响应慢,需要适当的加载提示
  3. 错误处理:确保每个请求都有适当的错误处理
  4. 内存泄漏:确保在组件销毁时取消订阅

总结

防抖和节流是优化用户交互的重要技术:

  • 防抖(debounceTime):适合"等待用户完成操作"的场景,如搜索输入框
  • 节流(throttleTime):适合"需要实时反馈但有限制"的场景,如滚动事件

在实际项目中,根据具体需求选择合适的策略,有时候也可以组合使用多个操作符来达到最佳效果。记住:防抖是等待,节流是限制频率

码云地址:gitee.com/leeyamaster…

20个例子掌握RxJS——第五章使用 switchMap 处理标签页切换

作者 LeeYaMaster
2026年1月29日 17:03

RxJS 实战:使用 switchMap 处理标签页切换

概述

在标签页(Tab)组件中,用户快速切换标签时,每个标签页都可能发起数据请求。如果不做处理,可能会导致:

  1. 多个请求同时进行,浪费资源
  2. 旧标签页的请求完成后覆盖新标签页的数据
  3. 用户体验差,数据混乱

本章将介绍如何使用 switchMap 在标签页切换时自动取消之前的请求,确保只显示当前标签页的数据。

问题场景

假设我们有一个标签页组件,包含 3 个标签页,每个标签页需要加载不同的数据:

  • 标签页 1:调用 /api/delay1(延迟 1 秒)
  • 标签页 2:调用 /api/delay2(延迟 2 秒)
  • 标签页 3:调用 /api/delay3(延迟 3 秒)

如果用户快速切换标签页,可能会出现以下问题:

  1. 用户点击"标签页 1" → 发起请求 A(1 秒)
  2. 用户立即点击"标签页 2" → 发起请求 B(2 秒)
  3. 用户立即点击"标签页 3" → 发起请求 C(3 秒)
  4. 请求 A 先完成 → 显示标签页 1 的数据(错误!)
  5. 请求 B 完成 → 显示标签页 2 的数据(错误!)
  6. 请求 C 完成 → 显示标签页 3 的数据(正确,但已经晚了)

switchMap 解决方案

使用 switchMap 可以完美解决这个问题:当切换标签页时,自动取消之前未完成的请求,只处理最新标签页的请求。

实现思路

  1. 使用 Subject 作为标签页切换触发器
  2. 使用 switchMap 处理标签页切换,自动取消之前的请求
  3. 记录请求历史,展示哪些请求被取消了
  4. 在组件销毁时取消所有订阅

核心代码

// 标签页列表
tabs: Tab[] = [
  { id: 'tab1', name: '标签页 1', apiUrl: '/api/delay1', apiName: 'delay1' },
  { id: 'tab2', name: '标签页 2', apiUrl: '/api/delay2', apiName: 'delay2' },
  { id: 'tab3', name: '标签页 3', apiUrl: '/api/delay3', apiName: 'delay3' }
];

// 当前激活的标签页
activeTabId: string = this.tabs[0].id;

// 标签页切换 Subject
private tabSwitch$ = new Subject<string>();

// 销毁 Subject
private destroy$ = new Subject<void>();

ngOnInit(): void {
  // 使用 switchMap 处理标签页切换时的请求取消
  this.tabSwitch$
    .pipe(
      switchMap((tabId) => {
        const tab = this.tabs.find(t => t.id === tabId);
        if (!tab) {
          return of(null);
        }
        
        // 创建请求记录
        const recordId = ++this.requestCounter;
        const record: RequestRecord = {
          id: recordId,
          tabId: tab.id,
          tabName: tab.name,
          apiName: tab.apiName,
          apiUrl: tab.apiUrl,
          startTime: Date.now(),
          status: 'pending'
        };
        
        // 将之前的 pending 请求标记为 cancelled(切换标签时取消)
        this.requestRecords.forEach(r => {
          if (r.status === 'pending') {
            r.status = 'cancelled';
            r.endTime = Date.now();
          }
        });
        
        this.requestRecords.unshift(record);
        this.loading = true;
        this.currentResult = null;
        this.cdr.detectChanges();
        
        return this.http.get<DelayApiResponse>(`${this.apiBaseUrl}${tab.apiUrl}`)
          .pipe(
            catchError(err => {
              // 捕获错误
              console.error(`请求 ${tab.apiUrl} 失败:`, err);
              return of({
                success: false,
                message: err.message || '请求失败',
                data: {
                  delay: null as any,
                  timestamp: new Date().toISOString(),
                  info: '请求失败'
                }
              } as DelayApiResponse);
            })
          );
      }),
      takeUntil(this.destroy$) // 路由切换时取消所有订阅
    )
    .subscribe({
      next: (response) => {
        if (!response) {
          return;
        }
        
        // 找到最新的 pending 请求记录
        const latestRecord = this.requestRecords.find(r => r.status === 'pending');
        if (latestRecord) {
          latestRecord.status = 'completed';
          latestRecord.endTime = Date.now();
          latestRecord.response = response;
        }
        
        this.currentResult = response;
        this.loading = false;
        this.cdr.detectChanges();
      },
      error: (err) => {
        // 处理错误
        const latestRecord = this.requestRecords.find(r => r.status === 'pending');
        if (latestRecord) {
          latestRecord.status = 'completed';
          latestRecord.endTime = Date.now();
          latestRecord.error = err.message || '请求失败';
        }
        
        this.loading = false;
        this.cdr.detectChanges();
      }
    });
  
  // 初始化时加载第一个标签页的数据
  this.switchTab(this.activeTabId);
}

// 切换标签页
switchTab(tabId: string): void {
  this.activeTabId = tabId;
  this.tabSwitch$.next(tabId);
}

ngOnDestroy(): void {
  // 路由切换时,取消所有订阅和请求
  this.destroy$.next();
  this.destroy$.complete();
  
  // 将未完成的请求标记为 cancelled
  this.requestRecords.forEach(r => {
    if (r.status === 'pending') {
      r.status = 'cancelled';
      r.endTime = Date.now();
    }
  });
}

关键点解析

1. switchMap 的自动取消机制

tabSwitch$ 发出新的标签页 ID 时:

  1. switchMap 自动取消之前未完成的 HTTP 请求
  2. 开始新的请求
  3. 只处理最新标签页的响应

2. 请求记录管理

通过维护请求记录列表,我们可以:

  • 追踪每个请求的状态(pending、completed、cancelled)
  • 展示请求历史,帮助调试
  • 分析哪些请求被取消了

3. 组件销毁时的清理

ngOnDestroy 中:

  1. 使用 destroy$ 取消所有订阅
  2. 将未完成的请求标记为 cancelled
  3. 防止内存泄漏

4. 初始化加载

ngOnInit 中调用 switchTab,确保第一个标签页的数据会被加载。

执行流程示例

假设用户的操作序列如下:

  1. 初始化:加载标签页 1 的数据

    • 发起请求 A(delay1,1 秒)
    • 状态:pending
  2. 用户点击标签页 2(请求 A 还未完成)

    • switchMap 取消请求 A
    • 请求 A 状态变为:cancelled
    • 发起请求 B(delay2,2 秒)
    • 状态:pending
  3. 用户点击标签页 3(请求 B 还未完成)

    • switchMap 取消请求 B
    • 请求 B 状态变为:cancelled
    • 发起请求 C(delay3,3 秒)
    • 状态:pending
  4. 请求 C 完成

    • 请求 C 状态变为:completed
    • 显示标签页 3 的数据 ✅

最终结果:只显示标签页 3 的数据,请求 A 和 B 都被取消了。

与其他方案的对比

方案 1:不使用 switchMap(有问题)

// ❌ 错误示例:多个请求可能同时完成,导致数据混乱
switchTab(tabId: string): void {
  this.activeTabId = tabId;
  this.loadTabData(tabId).subscribe(data => {
    this.currentResult = data; // 可能显示旧标签页的数据
  });
}

方案 2:手动取消订阅(复杂)

// ⚠️ 可行但复杂:需要手动管理订阅
private currentSubscription?: Subscription;

switchTab(tabId: string): void {
  // 取消之前的订阅
  if (this.currentSubscription) {
    this.currentSubscription.unsubscribe();
  }
  
  this.activeTabId = tabId;
  this.currentSubscription = this.loadTabData(tabId).subscribe(data => {
    this.currentResult = data;
  });
}

方案 3:使用 switchMap(推荐)✅

// ✅ 推荐:简洁、自动管理
this.tabSwitch$.pipe(
  switchMap(tabId => this.loadTabData(tabId))
).subscribe(data => {
  this.currentResult = data; // 只显示最新标签页的数据
});

实际应用场景

1. 多标签页数据加载

// 标签页组件
tabs = ['用户', '订单', '商品'];
activeTab = '用户';

tabChange$.pipe(
  switchMap(tab => this.loadTabData(tab))
).subscribe(data => {
  this.tabData = data;
});

2. 路由参数变化

// 路由参数变化时,取消之前的数据请求
route.params.pipe(
  switchMap(params => this.loadData(params.id))
).subscribe(data => {
  this.data = data;
});

3. 模态框内容加载

// 打开不同模态框时,取消之前的内容加载
modalOpen$.pipe(
  switchMap(modalType => this.loadModalContent(modalType))
).subscribe(content => {
  this.modalContent = content;
});

性能优化建议

1. 添加缓存机制

对于不经常变化的数据,可以添加缓存:

private tabDataCache = new Map<string, any>();

switchMap(tabId => {
  if (this.tabDataCache.has(tabId)) {
    return of(this.tabDataCache.get(tabId));
  }
  return this.loadTabData(tabId).pipe(
    tap(data => this.tabDataCache.set(tabId, data))
  );
})

2. 预加载相邻标签页

可以在用户切换到某个标签页时,预加载相邻标签页的数据:

switchTab(tabId: string): void {
  this.tabSwitch$.next(tabId);
  // 预加载相邻标签页
  this.preloadAdjacentTabs(tabId);
}

3. 添加加载状态

通过维护加载状态,给用户更好的反馈:

switchMap(tabId => {
  this.loading = true;
  return this.loadTabData(tabId).pipe(
    finalize(() => this.loading = false)
  );
})

注意事项

  1. 副作用处理:如果请求有副作用(如创建资源),需要谨慎使用 switchMap
  2. 用户体验:频繁取消请求可能会让用户困惑,需要适当的 UI 反馈
  3. 错误处理:确保每个请求都有适当的错误处理
  4. 内存泄漏:确保在组件销毁时取消所有订阅

总结

使用 switchMap 处理标签页切换是一个优雅的解决方案,它通过自动取消之前的请求来确保:

  • 数据一致性:只显示当前标签页的数据
  • 资源节约:取消不必要的请求,减少服务器压力
  • 代码简洁:不需要手动管理订阅和取消逻辑
  • 用户体验:避免数据混乱,提供流畅的交互体验

记住:当你需要在切换时取消之前的操作时,使用 switchMap 是最佳选择。

码云地址:gitee.com/leeyamaster…

❌
❌