普通视图

发现新文章,点击刷新页面。
今天 — 2025年12月8日技术

flutter-屏幕自适应插件flutter_screenutil教程全指南

作者 鹏多多
2025年12月8日 08:21

在Flutter开发中,屏幕尺寸碎片化是影响用户体验的关键问题。不同设备的分辨率、像素密度差异会导致UI布局在部分设备上出现错位、拉伸或留白等问题。flutter_screenutil作为一款专注于屏幕自适应的第三方插件,通过简单直观的API实现了"一套代码适配所有屏幕"的目标,已成为Flutter生态中最受欢迎的自适应解决方案之一。

1. 插件核心价值与工作原理

1.1 核心解决的问题

传统的固定尺寸开发模式存在三大痛点:

  • 尺寸适配难题:相同数值的尺寸在不同分辨率设备上显示效果差异巨大
  • 像素密度适配:不同DPI设备对图片、字体的渲染精度要求不同
  • 屏幕比例适配:从4.7英寸手机到10.9英寸平板的宽高比差异导致布局变形

flutter_screenutil通过统一的尺寸转换机制,将设计稿尺寸自动映射为不同设备的实际显示尺寸,完美解决了以上问题。

1.2 核心工作原理

该插件的核心实现基于两个关键概念:

  1. 设计稿基准:以特定尺寸的设计稿(如375×812px的iPhone X)作为基准
  2. 动态比例计算:根据当前设备屏幕尺寸与设计稿尺寸的比例,动态计算实际显示尺寸

具体计算公式如下:

  • 宽度适配:实际宽度 = 设计稿宽度 × (设备屏幕宽度 / 设计稿基准宽度)
  • 高度适配:实际高度 = 设计稿高度 × (设备屏幕高度 / 设计稿基准高度)
  • 字体适配:在宽度适配基础上,可额外设置字体缩放比例

2. 基础集成与初始化

2.1 环境要求

  • Flutter版本 ≥ 2.0.0
  • Dart版本 ≥ 2.12.0(空安全支持)

2.2 集成步骤

第一步:添加依赖

pubspec.yaml文件中添加最新版本依赖:

dependencies:
  flutter:
    sdk: flutter
  flutter_screenutil: ^5.9.0  # 建议使用最新版本

执行依赖安装命令:

flutter pub get

第二步:导入包

在需要使用的dart文件中导入:

import 'package:flutter_screenutil/flutter_screenutil.dart';

第三步:初始化配置

在应用入口MaterialAppbuilder中初始化ScreenUtilInit,配置设计稿基准尺寸:

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return ScreenUtilInit(
      // 设计稿宽度(单位:px)
      designSize: const Size(375, 812), 
      // 是否允许字体根据系统缩放比例调整
      minTextAdapt: true,
      // 字体缩放比例(可选,默认1.0)
      splitScreenMode: true,
      builder: (context, child) {
        return MaterialApp(
          title: 'ScreenUtil Demo',
          // 设置全局字体大小适配
          theme: ThemeData(
            textTheme: TextTheme(
              bodyLarge: TextStyle(fontSize: 16.sp),
              bodyMedium: TextStyle(fontSize: 14.sp),
            ),
          ),
          home: const HomePage(),
        );
      },
    );
  }
}

关键参数说明

  • designSize:必填,设计稿的宽高尺寸(建议使用UI提供的标准设计稿尺寸)
  • minTextAdapt:可选,是否开启字体最小适配,防止字体过小
  • splitScreenMode:可选,是否支持分屏模式适配
  • builder:必填,初始化完成后的构建回调,返回应用主界面

3. 核心API与基础用法

flutter_screenutil提供了直观的尺寸转换API,通过在数值后添加特定后缀实现不同类型的适配,主要包括以下三类:

3.1 尺寸适配(dp单位)

用于Widget的宽高、内边距、外边距等尺寸属性,使用.w(宽度方向)和.h(高度方向)后缀:

// 1. 容器尺寸适配
Container(
  // 设计稿宽度100px → 实际宽度=100 × (设备宽度/375)
  width: 100.w,
  // 设计稿高度50px → 实际高度=50 × (设备高度/812)
  height: 50.h,
  color: Colors.blue,
  child: const Text('尺寸适配示例'),
)

// 2. 内边距适配
Padding(
  // 上下左右各16px的内边距,分别按宽高方向适配
  padding: EdgeInsets.all(16.w), 
  child: const Text('内边距适配'),
)

// 3. 外边距适配
Margin(
  margin: EdgeInsets.symmetric(
    horizontal: 20.w,  // 水平方向按宽度比例适配
    vertical: 10.h     // 垂直方向按高度比例适配
  ),
  child: const Text('外边距适配'),
)

注意事项

  • 宽度相关属性(如widthhorizontal)建议使用.w后缀
  • 高度相关属性(如heightvertical)建议使用.h后缀
  • 正方形尺寸(如圆形头像)建议统一使用.w.h,避免宽高比例不一致

3.2 字体适配(sp单位)

用于文本字体大小适配,使用.sp后缀,自动适配不同设备的字体缩放设置:

// 基础字体适配
Text(
  '字体适配示例',
  style: TextStyle(
    // 设计稿字体大小18px → 自动适配设备
    fontSize: 18.sp,
    fontWeight: FontWeight.bold,
  ),
)

// 带最小字体限制的适配
Text(
  '最小字体适配',
  style: TextStyle(
    fontSize: 12.spMin,  // 确保字体不小于12px
  ),
)

字体适配优势

  • 自动响应系统字体缩放设置(如用户在系统设置中放大字体)
  • 通过spMin确保字体不会因屏幕过小而变得难以阅读
  • 全局统一的字体缩放比例,便于整体调整

3.3 屏幕尺寸工具类

ScreenUtil类提供了丰富的屏幕信息获取方法,方便在特殊场景下使用:

// 获取屏幕宽度(px)
double screenWidth = ScreenUtil().screenWidth;

// 获取屏幕高度(px)
double screenHeight = ScreenUtil().screenHeight;

// 获取状态栏高度
double statusBarHeight = ScreenUtil().statusBarHeight;

// 获取底部安全区域高度(适用于全面屏)
double bottomBarHeight = ScreenUtil().bottomBarHeight;

// 获取屏幕像素密度
double pixelRatio = ScreenUtil().pixelRatio;

// 设计稿宽度与实际屏幕宽度的比例
double scaleWidth = ScreenUtil().scaleWidth;

// 设计稿高度与实际屏幕高度的比例
double scaleHeight = ScreenUtil().scaleHeight;

实用场景

  • 根据屏幕宽度动态调整网格布局的列数
  • 根据安全区域高度调整底部按钮位置
  • 根据屏幕比例决定是否显示某些UI元素

4. 高级应用场景

4.1 多设计稿尺寸适配

对于需要同时适配手机和平板的应用,可以通过ScreenUtilInitdesignSize动态切换设计稿基准:

ScreenUtilInit(
  // 根据屏幕宽度判断使用手机还是平板设计稿
  designSize: MediaQuery.of(context).size.width > 600 
      ? const Size(1024, 1366)  // 平板设计稿
      : const Size(375, 812),   // 手机设计稿
  builder: (context, child) {
    // ...
  },
)

4.2 响应式布局结合

flutter_screenutil与Flutter原生的LayoutBuilderMediaQuery结合,实现更精细的响应式布局:

LayoutBuilder(
  builder: (context, constraints) {
    return Column(
      children: [
        // 固定高度的头部(使用h适配)
        Container(height: 80.h, color: Colors.blue),
        // 占满剩余高度的内容区
        Expanded(
          child: Container(
            width: constraints.maxWidth.w,  // 结合布局约束
            color: Colors.grey[200],
          ),
        ),
      ],
    );
  },
)

4.3 图片自适应

结合Image组件和flutter_screenutil,实现图片在不同屏幕上的自适应显示:

Image.asset(
  'assets/images/banner.png',
  // 宽度适配屏幕,高度按比例缩放
  width: double.infinity,
  height: 200.h,
  fit: BoxFit.cover,
)

// 圆形头像适配
Container(
  width: 80.w,
  height: 80.w,  // 宽高一致确保圆形
  decoration: BoxDecoration(
    shape: BoxShape.circle,
    image: DecorationImage(
      image: AssetImage('assets/images/avatar.png'),
      fit: BoxFit.cover,
    ),
  ),
)

4.4 适配测试工具

flutter_screenutil提供了ScreenUtilDebug组件,方便在开发过程中查看适配信息:

// 在界面底部添加调试信息
Stack(
  children: [
    // 主内容区
    const YourMainContent(),
    // 调试信息(仅在开发环境显示)
    if (kDebugMode)
      Positioned(
        bottom: 20.h,
        left: 0,
        right: 0,
        child: ScreenUtilDebug(
          // 显示当前屏幕信息、适配比例等
          infoType: ScreenUtilInfoType.all,
        ),
      ),
  ],
)

5. 实战案例:登录页面适配

下面通过一个完整的登录页面案例,展示flutter_screenutil的综合应用:

class LoginPage extends StatelessWidget {
  const LoginPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SingleChildScrollView(
        padding: EdgeInsets.symmetric(horizontal: 30.w, vertical: 80.h),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.center,
          children: [
            // 应用Logo
            Image.asset(
              'assets/images/logo.png',
              width: 120.w,
              height: 120.w,
            ),
            SizedBox(height: 40.h),
            
            // 标题
            Text(
              '欢迎登录',
              style: TextStyle(
                fontSize: 24.sp,
                fontWeight: FontWeight.bold,
                color: Colors.black87,
              ),
            ),
            SizedBox(height: 60.h),
            
            // 账号输入框
            TextField(
              decoration: InputDecoration(
                hintText: '请输入账号',
                hintStyle: TextStyle(fontSize: 14.sp, color: Colors.grey),
                contentPadding: EdgeInsets.symmetric(
                  horizontal: 16.w,
                  vertical: 14.h,
                ),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(8.w),
                  borderSide: BorderSide(width: 1.w, color: Colors.grey[300]!),
                ),
              ),
              style: TextStyle(fontSize: 16.sp),
            ),
            SizedBox(height: 20.h),
            
            // 密码输入框
            TextField(
              obscureText: true,
              decoration: InputDecoration(
                hintText: '请输入密码',
                hintStyle: TextStyle(fontSize: 14.sp, color: Colors.grey),
                contentPadding: EdgeInsets.symmetric(
                  horizontal: 16.w,
                  vertical: 14.h,
                ),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(8.w),
                  borderSide: BorderSide(width: 1.w, color: Colors.grey[300]!),
                ),
              ),
              style: TextStyle(fontSize: 16.sp),
            ),
            SizedBox(height: 30.h),
            
            // 登录按钮
            SizedBox(
              width: double.infinity,
              height: 50.h,
              child: ElevatedButton(
                onPressed: () {},
                style: ElevatedButton.styleFrom(
                  backgroundColor: Colors.blue,
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(8.w),
                  ),
                ),
                child: Text(
                  '登录',
                  style: TextStyle(fontSize: 18.sp, color: Colors.white),
                ),
              ),
            ),
            
            // 底部文字
            SizedBox(height: 40.h),
            Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  '还没有账号?',
                  style: TextStyle(fontSize: 14.sp, color: Colors.grey[600]),
                ),
                TextButton(
                  onPressed: () {},
                  child: Text(
                    '立即注册',
                    style: TextStyle(fontSize: 14.sp, color: Colors.blue),
                  ),
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

6. 性能优化与最佳实践

6.1 性能优化建议

  1. 避免频繁初始化ScreenUtilInit应在应用顶层初始化一次,避免在子页面重复初始化
  2. 减少不必要的尺寸计算:对于固定比例的UI元素,可缓存计算结果
  3. 合理使用const构造函数:对于不依赖尺寸变化的Widget,使用const修饰以提高性能
  4. 避免过度适配:对于装饰性元素(如分割线),可使用固定像素值(如1px),无需适配

6.2 最佳实践总结

  1. 统一设计稿基准:与UI团队约定统一的设计稿尺寸(如iPhone X的375×812px)
  2. 优先使用方向后缀:宽度相关用.w,高度相关用.h,字体用.sp
  3. 测试多设备场景:在不同尺寸、不同DPI的模拟器/真机上测试适配效果
  4. 结合系统设置:通过minTextAdapt确保字体适配系统缩放设置
  5. 文档化适配规则:在项目中建立适配规范文档,确保团队成员统一使用

7. 常见问题与解决方案

Q1: 适配后UI在某些设备上仍有变形?

A1: 检查是否混用了适配单位和原始单位(如同时使用100.w100.0),确保所有尺寸都使用flutter_screenutil的适配单位。

Q2: 字体适配后仍不响应系统字体缩放?

A2: 确认ScreenUtilInitminTextAdapt设置为true,并且字体尺寸使用.sp后缀,而非.w.h

Q3: 全面屏底部有留白或内容被遮挡?

A3: 使用ScreenUtil().bottomBarHeight获取底部安全区域高度,在底部添加对应高度的SizedBoxPadding

Q4: 横竖屏切换时适配失效?

A4: 在ScreenUtilInit中设置splitScreenMode: true,并确保布局能够响应屏幕方向变化。

8. 插件对比与选型建议

适配方案 优点 缺点 适用场景
flutter_screenutil API简洁、学习成本低、功能全面 需依赖第三方库 大多数Flutter应用,尤其是中小型项目
原生MediaQuery 无依赖、系统原生支持 需手动计算比例、代码冗余 简单适配场景,或对第三方库敏感的项目
responsive_framework 支持断点适配、布局重组 配置复杂、学习成本高 大型应用、需要精细响应式布局的场景

选型建议

  • 对于大多数Flutter应用,flutter_screenutil是性价比最高的选择,能够以最低的学习成本实现高质量的屏幕适配
  • 对于需要支持多种屏幕尺寸(如手机、平板、电脑)的复杂应用,可结合responsive_frameworkflutter_screenutil使用
  • 对于极简应用或对包体积有严格要求的场景,可考虑原生MediaQuery方案

官方仓库:flutter_screenutil GitHub,可获取最新版本、提交 Issue、查看官方示例代码


本次分享就到这儿啦,我是鹏多多,深耕前端的技术创作者,如果您看了觉得有帮助,欢迎评论,关注,点赞,转发,我们下次见~

PS:在本页按F12,在console中输入document.getElementsByClassName('panel-btn')[0].click();有惊喜哦~

往期文章

【错误监控】别只做工具人了!手把手带你写一个前端错误监控 SDK

2025年12月8日 07:52

你是否一直对前端错误监控系统的底层原理充满好奇?

想知道那些“黑科技”是如何拦截报错、上报数据的吗?

与其只做工具的使用者,不如深入底层,探寻其背后的实现机制。

本文将从原理角度切入,手把手带你设计并实现一个轻量级、功能完备的前端错误监控 SDK

学习完本文,你将收获什么?

通过手写这个 SDK,你不仅能获得一个可用的监控工具,更能深入掌握以下核心知识点:

  1. 浏览器底层原理:事件冒泡/捕获机制,以及 onerrorunhandledrejection 等 API 的工作细节。
  2. AOP 面向切面编程:学会如何通过劫持(Hook)原生方法(如 XMLHttpRequestfetch)来实现无感监控。
  3. 高可靠数据上报:掌握 Navigator.sendBeacon 的使用场景,确保在页面卸载时也能稳定上报数据。
  4. 工程化实践:从架构设计到 NPM 发布,体验完整的 SDK 开发全流程。

1. 架构设计

别被“监控系统”这四个字吓到了。拆解下来,核心逻辑就三步:监听 -> 收集 -> 上报

在开始编码之前,我们先梳理一下 SDK 的整体架构。我们需要监控 JS 运行时错误网络请求错误 以及 资源加载错误,并将这些数据统一格式化后上报到服务端。

graph TD
    A[应用启动] --> B{SDK 初始化}
    B --> C[JS 错误监控]
    B --> D[网络请求监控]
    B --> E[资源加载监控]

    C -->|捕获 onerror/unhandledrejection| F[错误处理中心]
    D -->|劫持 XHR/Fetch| F
    E -->|捕获 error 事件| F

    F --> G[数据组装]
    G --> H[上报模块]
    H -->|Navigator.sendBeacon / Fetch| I[服务端 API]
    I --> J[数据库/日志系统]

项目结构

为了保持代码的模块化和可维护性,我们采用以下目录结构:

error-monitor/
├── dist/                # 打包产物
├── src/                 # 源码目录
│   ├── index.ts         # 入口文件
│   ├── errorHandler.ts  # JS 错误捕获
│   ├── networkMonitor.ts# 网络请求监控
│   ├── resourceMonitor.ts# 资源加载监控
│   ├── sender.ts        # 上报逻辑
│   └── utils.ts         # 工具函数
├── test/                # 测试靶场
│   ├── server.js        # 本地测试服务
│   └── index.html       # 错误触发页面
├── package.json         # 项目配置
├── rollup.config.js     # Rollup 打包配置
├── tsconfig.json        # TypeScript 配置
└── README.md

错误监控源码在 src目录下 ,最终使用rollup对代码进行打包,dist是打包产物 ; test目录下是对打包产物的测试:能否拦截 JS/请求/资源错误,能否稳妥上报。现在就从 0 到 1 开干,做个mini版的错误监控 SDK

🚀 浏览项目的完整代码及示例可以点击这里 error-monitor github.com/Teernage/er… ,如果对您有帮助欢迎Star。

2. 核心实现详解

2.1 SDK 初始化入口 (index.ts)

SDK 的入口主要负责接收配置(如上报地址、项目名称)并启动各个监控模块。

// src/index.ts
import { monitorJavaScriptErrors } from './errorHandler';
import { monitorNetworkErrors } from './networkMonitor';
import { monitorResourceErrors } from './resourceMonitor';

interface ErrorMonitorConfig {
  reportUrl: string; // 上报接口地址
  projectName: string; // 项目标识
  environment: string; // 环境 (dev/prod)
}

export const initErrorMonitor = (config: ErrorMonitorConfig) => {
  const { reportUrl, projectName, environment } = config;

  // 启动三大监控模块
  monitorJavaScriptErrors(reportUrl, projectName, environment);
  monitorNetworkErrors(reportUrl, projectName, environment);
  monitorResourceErrors(reportUrl, projectName, environment);
};

2.2 全局异常捕获 (errorHandler.ts)

这是错误监控的核心部分。JavaScript 的错误主要分为两类,我们需要分别处理:

  1. 常规运行时错误:代码逻辑错误(如 undefined is not a function)。这部分由老牌的 window.onerror 事件负责,它能提供详细的行号、列号和堆栈信息。
  2. Promise 异常:随着 async/await 的普及,未被 catch 的 Promise 错误越来越常见。这部分错误 不会 触发 onerror,需要通过监听 unhandledrejection 事件来捕获。

双管齐下,才能确保代码逻辑错误不被遗漏。

// src/errorHandler.ts
import { sendErrorData } from './sender';

export const monitorJavaScriptErrors = (
  reportUrl: string,
  projectName: string,
  environment: string
) => {
  // 1. 捕获 JS 运行时错误
  const originalOnError = window.onerror;
  window.onerror = (message, source, lineno, colno, error) => {
    const errorInfo = {
      type: 'JavaScript Error',
      message,
      source,
      lineno,
      colno,
      stack: error ? error.stack : null,
      projectName,
      environment,
      timestamp: new Date().toISOString(),
    };
    sendErrorData(errorInfo, reportUrl);

    // 关键点:如果原来有 onerror 处理函数,继续执行它,避免覆盖用户逻辑
    // 这样做是为了不破坏宿主环境(例如用户自己写的或其他 SDK)已有的错误处理逻辑
    if (originalOnError) {
      return originalOnError(message, source, lineno, colno, error);
    }
  };

  // 2. 捕获未处理的 Promise Rejection
  const originalOnUnhandledRejection = window.onunhandledrejection;
  window.onunhandledrejection = (event) => {
    const errorInfo = {
      type: 'Unhandled Promise Rejection',
      message: event.reason?.message || event.reason,
      stack: event.reason?.stack,
      projectName,
      environment,
      timestamp: new Date().toISOString(),
    };
    sendErrorData(errorInfo, reportUrl);

    // 关键点:执行原有的 Promise 错误处理逻辑
    // 这样做是为了不破坏宿主环境(例如用户自己写的或其他 SDK)已有的错误处理逻辑
    if (originalOnUnhandledRejection) {
      return originalOnUnhandledRejection.call(window, event);
    }
  };
};

2.3 网络请求监控 (networkMonitor.ts)

接口监控是监控的难点,因为浏览器并没有提供一个全局的 onNetworkError 事件。

怎么办?

我们需要使用 AOP(面向切面编程) 的思想,重写浏览器原生的 XMLHttpRequestfetch方法。

简单来说,就是把原生的方法“包”一层:在请求发出前/响应返回后,插入我们的监控代码,然后再执行原有的逻辑。这样业务代码完全无感知,而我们却能拿到所有的请求细节。

// src/networkMonitor.ts
export const monitorNetworkErrors = (
  reportUrl: string,
  projectName: string,
  environment: string
) => {
  // 1. 劫持 XMLHttpRequest
  const originalXhrOpen = XMLHttpRequest.prototype.open;
  XMLHttpRequest.prototype.open = function (
    method: string,
    url: string | URL,
    ...args: any[]
  ) {
    // 关键点:排除上报接口自身的请求,防止死循环
    const urlStr = typeof url === 'string' ? url : String(url);
    if (urlStr.includes(reportUrl)) {
      return originalXhrOpen.apply(this, [method, url, ...args] as any);
    }

    // 监听 error 事件
    this.addEventListener('error', () => {
      sendErrorData(
        {
          type: 'Network Error',
          message: `Request Failed: ${method} ${url}`,
          projectName,
          environment,
        },
        reportUrl
      );
    });
    return originalXhrOpen.apply(this, [method, url, ...args] as any);
  };

  // 2. 劫持 Fetch
  const originalFetch = window.fetch;
  window.fetch = async (input, init) => {
    // 关键点:排除上报接口自身的请求,防止死循环
    const urlStr = (input instanceof Request) ? input.url : String(input);
    if (urlStr.includes(reportUrl)) {
      return originalFetch(input, init);
    }

    try {
      const response = await originalFetch(input, init);
      if (!response.ok) {
        sendErrorData(
          {
            type: 'Fetch Error',
            message: `HTTP ${response.status}: ${response.statusText}`,
            url: input instanceof Request ? input.url : input,
            projectName,
            environment,
          },
          reportUrl
        );
      }
      return response;
    } catch (error) {
      // 网络故障等无法发出请求的情况
      sendErrorData(
        {
          type: 'Fetch Error',
          message: `Fetch Failed: ${input}`,
          projectName,
          environment,
        },
        reportUrl
      );
      throw error;
    }
  };
};

2.4 资源加载监控 (resourceMonitor.ts)

使用 window.onerror 检测不到资源的错误,因为 资源加载失败(如 img/script src 404)产生的 error 事件是不会冒泡的

window.onerror 依靠事件冒泡来捕获错误,所以它对资源错误无能为力。

但是 window.addEventListener('error', handler, true)捕获阶段 可以将资源加载的错误“拦截”下来。

所以资源加载监控这里,我们使用 window.addEventListener('error', () => {}, true) 来进行监控。

// src/resourceMonitor.ts
export const monitorResourceErrors = (
  reportUrl: string,
  projectName: string,
  environment: string
) => {
  // 注意:useCapture 设置为 true,在捕获阶段处理
  window.addEventListener(
    'error',
    (event) => {
      const target = event.target as HTMLElement;
      // 过滤掉 window 自身的 error,只处理资源元素的 error
      if (target && (target.tagName === 'IMG' || target.tagName === 'SCRIPT')) {
        sendErrorData(
          {
            type: 'Resource Load Error',
            message: `Failed to load ${target.tagName}: ${
              target.getAttribute('src') || target.getAttribute('href')
            }`,
            projectName,
            environment,
          },
          reportUrl
        );
      }
    },
    true // 捕获阶段
  );
};

2.5 数据上报 (sender.ts)

收集到错误数据后,如何发给后端?这看似简单,实则暗藏玄机。

痛点:页面卸载时的“遗言”发不出去

用户遇到 Bug 的第一反应往往是关闭页面。如果我们使用普通的 fetchXHR 上报:

  1. 异步请求可能会被取消:页面关闭时,浏览器通常会 cancel 掉所有未完成的请求。
  2. 同步请求会阻塞跳转:虽然能强行发出去,但会卡住页面切换,严重影响体验。

救星:Navigator.sendBeacon

sendBeacon 是专门为此场景设计的 API。它有三大优势:

  1. 可靠:即使页面卸载,浏览器也会在后台保证数据发送成功。
  2. 异步:完全不阻塞页面关闭或跳转。
  3. 高效:传输少量数据时性能极佳。

因此,我们的上报策略是:优先 sendBeacon,不支持则降级为 fetch

// src/sender.ts
export const sendErrorData = (errorData: Record<string, any>, url: string) => {
  // 补充浏览器信息(UserAgent 等)
  const dataToSend = {
    ...errorData,
    userAgent: navigator.userAgent,
    // 还可以添加更多环境信息,如屏幕分辨率、当前 URL 等
  };

  // 优先使用 sendBeacon (异步,不阻塞,页面卸载时仍有效)
  if (navigator.sendBeacon) {
    const blob = new Blob([JSON.stringify(dataToSend)], {
      type: 'application/json',
    });
    navigator.sendBeacon(url, blob);
  } else {
    // 降级使用 fetch
    fetch(url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(dataToSend),
    }).catch(console.error);
  }
};

💡 知识扩展:经典的 1x1 GIF 打点

你可能听说过用 new Image().src = 'http://api.com/report?data=...' 这种方式上报。这在统计 PV/UV 时非常流行,因为它兼容性极好且天然跨域。

但在错误监控场景下,通常不推荐作为主力方案。 核心原因正是数据量

  1. URL 长度限制:GIF 打点本质是 GET 请求,数据都挂在 URL 上。浏览器对 URL 长度有限制(通常 2KB~8KB)。
  2. 堆栈过长:一个完整的报错堆栈(Stack Trace)动辄几千字符,很容易就被浏览器截断,导致我们看不到关键的报错信息。

所以,对于体积较大的错误数据,走 POST 通道的 sendBeaconfetch 是更稳妥的选择。

2.6 进阶优化:采样与缓冲,别把服务器搞崩了

如果线上出现大规模故障,成千上万的用户同时上报错误,可能会瞬间把监控服务器打挂(DDoS 既视感)。

这时候我们需要引入两个机制:

  1. 采样 (Sampling)

    • 大白话:不要每个错误都报。比如只允许 20% 的运气不好的用户上报,剩下的忽略。这样既能发现问题,又能节省 80% 的流量。
    • 实现if (Math.random() > 0.2) return;
  2. 缓冲 (Buffering)

    • 大白话:不要出一条错就发一个请求,太浪费资源。先把错误攒在数组里,凑够 10 条或者每隔 5 秒统一发一车。
    • 注意:记得在页面卸载(关闭)时,把车上剩下的货强制发出去,别丢了。

3. 工程化构建配置

既然是 SDK,最好的分发方式当然是发布到 NPM。这样其他项目只需要一行命令就能接入你的前端错误监控系统。

这里我们选择 Rollup对代码进行打包,因为它比 Webpack 更适合打包库(Library),生成的代码更简洁。

3.1 package 配置 (package.json)

package.json 不仅仅是依赖管理,它还定义了你的包如何被外部使用。配置不当会导致用户引入报错或无法获得代码提示。

{
  "name": "error-monitor-sdk",
  "version": "1.0.0",
  "description": "A lightweight front-end error monitoring SDK",
  "main": "dist/index.cjs.js", // CommonJS 入口
  "module": "dist/index.esm.js", // ESM 入口
  "browser": "dist/index.umd.js", // UMD 入口
  "type": "module",
  "scripts": {
    "build": "rollup -c",
    "dev": "rollup -c -w"
  },
  "keywords": ["error-monitor", "frontend", "sdk"],
  "license": "MIT",
  "files": ["dist"], // 发布时仅包含 dist 目录
  "devDependencies": {
    "rollup": "^4.9.0",
    "@rollup/plugin-typescript": "^11.1.0",
    "@rollup/plugin-terser": "^0.4.0", // 用于压缩代码
    "typescript": "^5.3.0",
    "tslib": "^2.6.0"
  }
}

💡 关键字段解读:

  • name: 包的“身份证号”。在 NPM 全球范围内必须唯一,发布前记得先去搜一下有没有重名。
  • 入口文件“三剑客”(决定了别人怎么引用你的包):
    • main: CommonJS 入口。给 Node.js 环境或老旧构建工具(如 Webpack 4)使用的。
    • module: ESM 入口。给现代构建工具(Vite, Webpack 5)使用的。支持 Tree Shaking(摇树优化),能减小体积。
    • browser: UMD 入口。给浏览器直接通过 <script> 标签引入使用的(如 CDN)。
  • files: 发布白名单。指定 npm publish 时只上传哪些文件(这里我们只传编译后的 dist 目录)。源码、测试代码等不需要发上去,以减小包体积。

3.2 TypeScript 配置 (tsconfig.json)

我们需要配置 TypeScript 如何编译代码,并生成类型声明文件(.d.ts),这对使用 TS 的用户非常友好。

{
  "compilerOptions": {
    "target": "es5", // 编译成 ES5,兼容旧浏览器
    "module": "esnext", // 保留 ES 模块语法,交给 Rollup 处理
    "declaration": true, // 生成 .d.ts 类型文件 (关键!)
    "declarationDir": "./dist", // 类型文件输出目录
    "strict": true, // 开启严格模式,代码更健壮
    "moduleResolution": "node" // 按 Node 方式解析模块
  },
  "include": ["src/**/*"] // 编译 src 下的所有文件
}

3.3 Rollup 打包配置 (rollup.config.js)

为了兼容各种使用场景,我们配置 Rollup 输出三种格式:

  1. ESM (.esm.js): 给现代构建工具(Vite, Webpack)使用,支持 Tree Shaking。
  2. CJS (.cjs.js): 给 Node.js 或旧版工具使用。
  3. UMD (.umd.js): 可以直接在浏览器通过 <script> 标签引入,会挂载全局变量。
import typescript from '@rollup/plugin-typescript';
import terser from '@rollup/plugin-terser';

export default {
  input: 'src/index.ts', // 入口文件
  output: [
    {
      file: 'dist/index.cjs.js',
      format: 'cjs',
      sourcemap: true,
    },
    {
      file: 'dist/index.esm.js',
      format: 'es',
      sourcemap: true,
    },
    {
      file: 'dist/index.umd.js',
      format: 'umd',
      name: 'ErrorMonitor', // <script> 引入时的全局变量名',
      sourcemap: true,
      plugins: [terser()], // UMD 格式进行压缩体积
    },
  ],
  plugins: [
    typescript({
      tsconfig: './tsconfig.json',
      declaration: true,
      declarationDir: 'dist',
    }),
  ],
};

4. 发布到 NPM (保姆级教程)

4.1 准备工作

  1. 注册账号:去 npmjs.com 注册一个账号(记得验证邮箱,否则无法发布)。
  2. 检查包名:在 NPM 搜一下你的 package.json 里的 name,确保没有被占用。如果不幸重名,改个独特的名字,比如 error-monitor-sdk-vip

4.2 终端操作三步走

打开终端(Terminal),在项目根目录下操作:

第一步:登录 NPM

npm login
  • 输入命令后按回车,浏览器会弹出登录页面。
  • 或者在终端根据提示输入用户名、密码和邮箱验证码。
  • 登录成功后会显示 Logged in as <your-username>.
  • 注意:如果你之前切换过淘宝源,发布时必须切回官方源:npm config set registry https://registry.npmjs.org/

第二步:打包代码

确保 dist 目录是最新的,不要发布空代码。

npm run build

第三步:正式发布

npm publish --access public
  • --access public 参数用于确保发布的包是公开的(特别是当包名带 @ 前缀时)。
  • 看到 + error-monitor-sdk@1.0.0 字样,恭喜你,发布成功!

7141076e-3f21-4ab4-91d3-5f5918624c9b.png

现在,全世界的开发者都可以通过 npm install error-monitor-sdk 来使用你的作品了!

5. 如何使用

SDK 发布后,支持多种引入方式,适配各种开发场景。

方式 1:NPM + ES Modules (推荐)

适用于现代前端项目(Vite, Webpack, Rollup 等)。

# 请将 error-monitor-sdk 替换为你实际发布的包名
npm install error-monitor-sdk

在你的业务代码入口(如 main.tsapp.js)引入并初始化:

// 请将 error-monitor-sdk 替换为你实际发布的包名
import { initErrorMonitor } from 'error-monitor-sdk';

initErrorMonitor({
  reportUrl: 'http://localhost:3000/error-report',
  projectName: 'MyAwesomeProject',
  environment: 'production',
});

方式 2:NPM + CommonJS

适用于 Node.js 环境或旧版打包工具。

# 请将 error-monitor-sdk 替换为你实际发布的包名
npm install error-monitor-sdk
// 请将 error-monitor-sdk 替换为你实际发布的包名
const { initErrorMonitor } = require('error-monitor-sdk');

initErrorMonitor({
  reportUrl: 'http://localhost:3000/error-report',
  projectName: 'MyAwesomeProject',
  environment: 'production',
});

方式 3:CDN 直接引入

适用于不使用构建工具的传统项目或简单的 HTML 页面。

<!-- 请将 error-monitor-sdk 替换为你实际发布的包名,x.x.x 替换为具体版本号 -->
<script src="https://unpkg.com/error-monitor-sdk@x.x.x/dist/index.umd.js"></script>

<script>
  // UMD 版本会将 SDK 挂载到 window.ErrorMonitor
  ErrorMonitor.initErrorMonitor({
    reportUrl: 'http://localhost:3000/error-report',
    projectName: 'MyAwesomeProject',
    environment: 'production',
  });
</script>

6. 总结与展望

到这里,我们这个“麻雀虽小,五脏俱全”的错误监控 SDK 就算是跑起来了。

回头看看,几百行代码没白写,实打实搞定了三件事:

  1. 啥都能抓:JS 报错、Promise 挂了、接口 500、图片 404,一个都跑不掉,统统收入囊中。
  2. 死活都能报:用了 Navigator.sendBeacon,哪怕用户秒关页面,最后那条“遗言”也能顽强地发给服务器。
  3. 拿来就能用:打包好了三种格式,还送了个“靶场”页面,点点按钮就能看效果,主打一个省心。

不过说实话,这离真正的“企业级”监控还有点距离。

想在生产环境(特别是高流量业务)扛大旗,还得把下面这些坑填了:

  • 别盲猜 Bug:线上代码都是压缩的,得搞定 Sourcemap 还原,不然对着 a.b is not a function 只有哭的份。
  • 页面白了没:有时候没报错但页面一片白,这种“假死”得靠 白屏检测 来发现。
  • 到底快不快:光不报错不够,还得看 性能指标 (FCP/LCP),监控页面加载速度。
  • 用户干了啥:复现 Bug 全靠猜?不行,得把用户出事前的点击、路由跳转全记下来,来个 行为回溯(案发现场还原)。
  • 别把服务器搞崩:报错太多得限流、去重,引入 采样率,不然监控服务先挂了就尴尬了。

贪多嚼不烂,这次我们先聚焦在最核心的“错误监控”闭环。

至于上面那些进阶玩法,我们下篇文章接着聊,带你一步步把这个系统打磨得更完美。

造轮子不是为了重复造,而是为了亲手拆开看看里面的齿轮是怎么转的,这才是学习的本质。

希望这篇文章能是你打造专属监控系统的起点。Happy Coding!

还在手动处理页面跳转?掌握Vue Router 4,你的导航效率翻倍!

2025年12月8日 07:29

朋友们,你们有没有在开发Vue应用时,对着页面跳转和权限管理头疼过?手动拼接URL?自己处理前进后退?用户没登录也能进入管理后台?别担心,今天我们就来彻底搞定Vue Router 4。

Vue Router是Vue.js官方的路由管理器,它能让你用组件化的思维来管理你的页面和视图。说白了,就是让你的单页面应用看起来像是有很多个“页面”一样,并且能无缝地在它们之间跳转。从基础的路径配置到高级的导航守卫,这篇文章会带你从零开始,快速上手。相信我,学完它,你的开发体验会顺畅得多。

先让你的项目“动”起来:安装与基础配置

第一步,我们得把它请进项目里。打开你的终端,在你Vue项目的根目录下,运行这条命令。假设你用的是Vue 3项目。

npm install vue-router@4

安装好了,我们就来创建路由的核心——路由实例。通常,我们会在一个单独的src/router/index.js文件中做这件事。跟着下面的代码走一遍,你就明白了。

// src/router/index.js
// 1. 导入必要的函数和组件
import { createRouter, createWebHistory } from 'vue-router'
// 2. 导入你希望成为“页面”的Vue组件
import HomeView from '../views/HomeView.vue'
import AboutView from '../views/AboutView.vue'

// 3. 定义路由规则数组
const routes = [
  {
    path: '/', // 当浏览器地址是根路径时
    name: 'home', // 给路由起个名字,方便跳转
    component: HomeView // 显示HomeView这个组件
  },
  {
    path: '/about',
    name: 'about',
    component: AboutView
  }
]

// 4. 创建路由实例
const router = createRouter({
  // 使用HTML5的历史模式,URL看起来更干净(比如 /about)
  // 另一种模式是 createWebHashHistory,URL会带#号
  history: createWebHistory(),
  routes // 简写,等同于 routes: routes
})

// 5. 导出这个实例,在main.js中会用到
export default router

创建好路由实例后,我们需要告诉Vue应用去使用它。找到你的应用入口文件src/main.js,像下面这样修改。

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
// 导入我们刚才创建的路由实例
import router from './router'

const app = createApp(App)

// 使用路由插件
app.use(router)

app.mount('#app')

最后一步,我们得在根组件App.vue里放一个“插座”,路由匹配到的组件就会在这个“插座”里显示出来。

<!-- src/App.vue -->
<template>
  <div id="app">
    <nav>
      <!-- 使用 router-link 组件进行导航,它会被渲染成一个 <a> 标签 -->
      <router-link to="/">首页</router-link> |
      <router-link to="/about">关于</router-link>
    </nav>
    <!-- 路由匹配到的组件将在这里被渲染,这就是那个“插座” -->
    <router-view />
  </div>
</template>

好了!现在运行你的项目,点击“首页”和“关于”,你应该能看到页面内容在<router-view />的位置切换了,而浏览器地址栏的URL也会跟着变化。恭喜,你的第一个带路由的Vue应用跑通了!

进阶导航:如何在不同页面间穿梭

光能显示页面还不够,我们常常需要在代码里控制跳转,比如登录成功后自动跳转到主页。有两种主要方式。

第一种,声明式导航。就像上面的例子,我们用<router-link>标签。它比用<a href="...">好在,它是在单页面应用内部跳转,不会真的向服务器重新加载页面,速度飞快。

<template>
  <!-- 使用‘to’属性指定目标地址,可以是字符串路径,也可以是路由对象 -->
  <router-link to="/about">去关于页面</router-link>

  <!-- 更推荐使用命名路由,这样即使你改了path路径,这里的链接也不用改 -->
  <router-link :to="{ name: 'home' }">返回首页</router-link>

  <!-- 甚至可以带参数 -->
  <router-link :to="{ name: 'user', params: { id: 123 } }">查看用户123</router-link>
</template>

第二种,编程式导航。在你的组件方法里,通过this.$router(在组合式API中是useRouter())来跳转。这给了你最大的灵活性。

<script>
// 选项式 API 写法
export default {
  methods: {
    goToAboutPage() {
      // 和 router-link 的 `to` 属性一样,可以接受路径或路由对象
      this.$router.push('/about')
      // 或者
      this.$router.push({ name: 'about' })
    },
    goBack() {
      // 返回上一页,相当于浏览器的后退按钮
      this.$router.back()
    },
    replaceHome() {
      // 用‘替换’的方式跳转,不会在历史记录里留下当前页
      this.$router.replace({ name: 'home' })
    }
  }
}
</script>
<script setup>
// 组合式 API 写法(现在更常用)
import { useRouter } from 'vue-router'

const router = useRouter()

const goToAboutPage = () => {
  router.push('/about')
}
const goBack = () => {
  router.back()
}
</script>

让路由活起来:动态路由与参数传递

很多时候,我们的路径不是固定的,比如用户详情页/user/1/user/2。这就需要动态路由。

// 在路由规则中,用冒号 : 来标记动态部分
const routes = [
  {
    path: '/user/:id', // `:id`就是一个动态参数
    name: 'user',
    component: UserView
  }
]

在目标组件UserView.vue里,我们怎么拿到这个id呢?通过useRoute()这个函数。

<!-- src/views/UserView.vue -->
<template>
  <div>
    <h2>用户详情页</h2>
    <!-- 在模板中直接使用 -->
    <p>当前用户ID是:{{ $route.params.id }}</p>
    <p>另一种方式获得的ID:{{ userId }}</p>
  </div>
</template>

<script setup>
import { useRoute } from 'vue-router'
import { computed } from 'vue'

// 使用 useRoute() 获取当前路由信息
const route = useRoute()

// 动态参数是响应式的,我们可以用计算属性来获取
const userId = computed(() => route.params.id)

// 你也可以通过 route.query 获取查询参数
// 比如 /user/123?name=zhangsan,则 route.query.name 为 ‘zhangsan’
const userName = computed(() => route.query.name)
</script>

除了通过URL(params, query)传递参数,你还可以用props传参,让组件更干净。

// 在路由规则中开启 props 传参
const routes = [
  {
    path: '/user/:id',
    name: 'user',
    component: UserView,
    props: true // 将 route.params 作为组件的 props 传入
    // 或者使用函数模式,实现更灵活的控制
    // props: (route) => ({ id: Number(route.params.id) })
  }
]

然后你的组件就可以像接收普通props一样接收它了。

<!-- src/views/UserView.vue -->
<script setup>
// 直接定义 props
defineProps({
  id: {
    type: [String, Number],
    required: true
  }
})
</script>

<template>
  <div>用户ID是:{{ id }}</div>
</template>

项目的“守门神”:路由守卫深度解析

重头戏来了!路由守卫是Vue Router里非常强大的一环,它允许你在导航发生前、发生后进行拦截或处理。最常见的场景就是:检查用户是否登录。

全局前置守卫router.beforeEach:这是最常用的守卫,在每一次导航开始时触发。我们可以在这里做权限检查。

// 回到你的 src/router/index.js
// 在创建并导出 router 实例之后,可以添加守卫

// ... createRouter 代码 ...

// 添加全局前置守卫
router.beforeEach((to, from, next) => {
  // to: 即将要进入的目标路由对象
  // from: 当前导航正要离开的路由对象
  // next: 一个函数,必须调用它来解析这个守卫

  // 假设我们有一个简单的登录检查
  const isAuthenticated = localStorage.getItem('token') // 从本地存储读取token

  // 如果用户想去一个需要登录的页面(比如页面元信息里标记了 requiresAuth: true)
  if (to.meta.requiresAuth && !isAuthenticated) {
    // 把他重定向到登录页
    next({
      name: 'login',
      // 可以附带一个查询参数,记录他原本想去哪里,登录后直接跳过去
      query: { redirect: to.fullPath }
    })
  } else {
    // 放行,去往目标页面
    next()
  }
})

路由独享的守卫beforeEnter:你可以只为某一条路由规则定义守卫,它只在进入该路由时触发。

const routes = [
  {
    path: '/admin',
    name: 'admin',
    component: AdminView,
    meta: { requiresAdmin: true }, // 自定义元信息,标记需要管理员权限
    beforeEnter: (to, from, next) => {
      const userRole = getUserRoleSomehow() // 假设有个获取用户角色的方法
      if (userRole === 'admin') {
        next() // 是管理员,允许进入
      } else {
        next({ name: 'forbidden' }) // 不是管理员,跳转到无权限页面
      }
    }
  }
]

组件内的守卫:你还可以在组件内部定义守卫。

  • onBeforeRouteUpdate:当前组件复用时(仅参数变化)调用,非常适合用来根据新参数获取数据。
  • onBeforeRouteLeave:离开该组件时调用,可以用来防止用户在编辑表单时误操作离开。
<script setup>
import { onBeforeRouteLeave, onBeforeRouteUpdate } from 'vue-router'

// 守卫1:离开组件前提示用户
onBeforeRouteLeave((to, from) => {
  const answer = window.confirm('你有未保存的更改,确定要离开吗?')
  // 如果用户取消,就取消这次导航
  if (!answer) return false
})

// 守卫2:当路由更新(比如从 /user/1 跳转到 /user/2)时,重新获取数据
import { fetchUserData } from '@/api'
onBeforeRouteUpdate(async (to) => {
  // 组件实例复用,但参数从 1 变成了 2
  // 在这里用新的参数 to.params.id 重新获取用户数据
  await fetchUserData(to.params.id)
})
</script>

最后:几条让你走得更远的实战建议

看到这里,你已经掌握了Vue Router 4的核心技能。为了让你的项目结构更清晰、代码更健壮,这里还有几个小贴士:

用好路由元信息meta字段:这个字段就像给你的路由贴标签,非常适合在全局守卫里做统一的权限判断,或者在生成导航菜单时标记是否需要显示。

考虑路由懒加载:当你的应用页面很多时,把所有组件的代码打包到一个文件里会让首次加载很慢。路由懒加载可以让你在访问某个页面时才去加载它的代码,大幅提升首屏速度。

const routes = [
  {
    path: '/about',
    name: 'about',
    // 使用动态 import 语法,Webpack/Vite会自动进行代码分割
    component: () => import('../views/AboutView.vue')
  }
]

处理好404页面:在路由规则的最后,添加一个通配符*的路由,来捕获所有未匹配的路径,展示一个友好的404页面。

const routes = [
  // ... 你的其他路由 ...
  {
    path: '/:pathMatch(.*)*', // 这是Vue Router 4的捕获所有路由的写法
    name: 'not-found',
    component: () => import('../views/NotFoundView.vue')
  }
]

好了,我们从最基础的安装配置,一路聊到了动态参数和强大的路由守卫。现在回头看看,页面跳转、权限控制这些让人头疼的问题,是不是都有了清晰的解决思路?

关键在于动手去试。创建一个新项目,把这些代码示例都敲一遍,再试着结合你的实际业务(比如从后端API获取用户权限,动态生成路由),你就能真正驾驭Vue Router,让它成为你构建复杂单页面应用的得力助手。别再手动处理那些繁琐的导航逻辑了,让Vue Router来帮你,效率提升真的不止一点点。

每日一题-统计平方和三元组的数目🟢

2025年12月8日 00:00

一个 平方和三元组 (a,b,c) 指的是满足 a2 + b2 = c2 的 整数 三元组 ab 和 c 。

给你一个整数 n ,请你返回满足 1 <= a, b, c <= n 的 平方和三元组 的数目。

 

示例 1:

输入:n = 5
输出:2
解释:平方和三元组为 (3,4,5) 和 (4,3,5) 。

示例 2:

输入:n = 10
输出:4
解释:平方和三元组为 (3,4,5),(4,3,5),(6,8,10) 和 (8,6,10) 。

 

提示:

  • 1 <= n <= 250

给一个 O(n^{2/3} log n) 的算法

作者 hqztrue
2021年7月14日 03:54

首先给出一个$O(n)$的算法。根据wiki - Pythagorean_triple,平方和三元组$(a,b,c)$可以由数对$(i,j)$用以下公式生成:
$a=k\cdot (j^2-i^2)$, $b=k\cdot (2ij)$, $c=k\cdot (i^2+j^2)$,
其中$i<j$, $(i,j)$互质且不同时为奇数。那么只要在$[\sqrt{n}]$的范围内枚举$i,j$即可(因为$i^2+j^2\leq n$),满足条件的数对$(i,j)$会对答案产生$\dfrac{n}{i^2+j^2}$的贡献(即$k$的数量),总复杂度$O(n)$。

class Solution {
public:
int countTriples(int n) {
int ans=0;
for (int i=1;i*i<n;++i)
for (int j=i+1;i*i+j*j<=n;++j)
if (__gcd(i,j)==1&&!(i*j%2))ans+=n/(i*i+j*j);
return ans*2;
}
};

注:这里gcd的$O(\log n)$复杂度可以被消掉。

接下来使用更快的计数算法来加速。首先如果不考虑$k$的部分,那么满足条件的数对$(i,j)$的数量大致为$\sum_{1\leq j\leq \sqrt{n}}\phi(\min{\sqrt{n-j^2},j-1},j)$,其中$\phi(m,n)$表示$1,\dots,m$中与$n$互质的数的个数(暂时忽略“不同时为奇数”的限制条件,这个容易处理)。$\phi(m,n)$可以用容斥原理在$O(\sigma(n))$的时间内求出,其中$\sigma(n)$表示$n$的因子个数。
然后考虑$k$的部分。对于固定的$j$,$i$的取值可以在$[1,j]$内被分成若干个区间,每段区间共用同一个$k$的个数(因为$k$的上限为$\lfloor\dfrac{n}{i^2+j^2}\rfloor$)。那么只要用之前提到的容斥算法算出区间内与$j$互质的$i$的数量,再乘上这些$i$共用的$k$的上限值就行了。对于$j\leq n^{1/3}$,可行的$i$显然只有$\leq j=O(n^{1/3})$种。对于$j>n^{1/3}$,$\lfloor\dfrac{n}{i^2+j^2}\rfloor$的取值范围只有$O(\dfrac{n}{j^2})$种可能,所以$i$的范围可以被分成$O(\dfrac{n}{j^2})$段。对于所有$j$求和,总段数为$O(n^{1/3})\cdot O(n^{1/3})+\sum_{n^{1/3}\leq j\leq n^{1/2}}O(\dfrac{n}{j^2})=O(n^{2/3})$。
$\sigma(n)$在$1$~$n$内的平均值是$O(\log n)$量级的,所以总复杂度为$O(n^{2/3} \log n)$。

统计平方和三元组的数目

2021年7月11日 14:36

方法一:枚举

思路与算法

我们可以枚举整数三元组 $(a, b, c)$ 中的 $a$ 和 $b$,并判断 $a^2 + b^2$ 是否为完全平方数,且 $\sqrt{a^2 + b^2}$ 是否为不大于 $n$ 的整数。

我们可以对 $a^2 + b^2$ 开平方,计算 $\left\lfloor \sqrt{a^2 + b^2} \right\rfloor^2$ 是否等于 $a^2 + b^2$ 以判断 $a^2 + b^2$ 是为完全平方数。同时,我们还需要判断 $\left\lfloor \sqrt{a^2 + b^2} \right\rfloor$ 是否不大于 $n$。

在遍历枚举的同时,我们维护平方和三元组的数目,如果符合要求,我们将计数加 $1$。最终,我们返回该数目作为答案。

细节

在计算 $\left\lfloor \sqrt{a^2 + b^2} \right\rfloor$ 时,为了防止浮点数造成的潜在误差,同时考虑到完全平方正数之间的距离一定大于 $1$,的我们可以用 $\sqrt{a^2 + b^2 + 1}$ 来替代 $\sqrt{a^2 + b^2}$。

代码

###C++

class Solution {
public:
    int countTriples(int n) {
        int res = 0;
        // 枚举 a 与 b
        for (int a = 1; a <= n; ++a){
            for (int b = 1; b <= n; ++b){
                // 判断是否符合要求
                int c = int(sqrt(a * a + b * b + 1.0));
                if (c <= n && c * c == a * a + b * b){
                    ++res;
                }
            }
        }
        return res;
    }
};

###Python

from math import sqrt

class Solution:
    def countTriples(self, n: int) -> int:
        res = 0
        # 枚举 a 与 b
        for a in range(1, n + 1):
            for b in range(1, n + 1):
                # 判断是否符合要求
                c = int(sqrt(a ** 2 + b ** 2 + 1))
                if c <= n and c ** 2 == a ** 2 + b ** 2:
                    res += 1
        return res

###Java

class Solution {
    public int countTriples(int n) {
        int res = 0;
        // 枚举 a 与 b
        for (int a = 1; a <= n; ++a) {
            for (int b = 1; b <= n; ++b) {
                // 判断是否符合要求
                int c = (int) Math.sqrt(a * a + b * b + 1.0);
                if (c <= n && c * c == a * a + b * b) {
                    ++res;
                }
            }
        }
        return res;
    }
}

###C#

public class Solution {
    public int CountTriples(int n) {
        int res = 0;
        // 枚举 a 与 b
        for (int a = 1; a <= n; ++a) {
            for (int b = 1; b <= n; ++b) {
                // 判断是否符合要求
                int c = (int) Math.Sqrt(a * a + b * b + 1.0);
                if (c <= n && c * c == a * a + b * b) {
                    ++res;
                }
            }
        }
        return res;
    }
}

###Go

func countTriples(n int) int {
    res := 0
    // 枚举 a 与 b
    for a := 1; a <= n; a++ {
        for b := 1; b <= n; b++ {
            // 判断是否符合要求
            c := int(math.Sqrt(float64(a * a + b * b + 1)))
            if c <= n && c * c == a * a + b * b {
                res++
            }
        }
    }
    return res
}

###C

int countTriples(int n) {
    int res = 0;
    // 枚举 a 与 b
    for (int a = 1; a <= n; ++a) {
        for (int b = 1; b <= n; ++b) {
            // 判断是否符合要求
            int c = (int) sqrt(a * a + b * b + 1.0);
            if (c <= n && c * c == a * a + b * b) {
                ++res;
            }
        }
    }
    return res;
}

###JavaScript

var countTriples = function(n) {
    let res = 0;
    // 枚举 a 与 b
    for (let a = 1; a <= n; a++) {
        for (let b = 1; b <= n; b++) {
            // 判断是否符合要求
            let c = Math.floor(Math.sqrt(a * a + b * b + 1));
            if (c <= n && c * c === a * a + b * b) {
                res++;
            }
        }
    }
    return res;
};

###TypeScript

function countTriples(n: number): number {
    let res = 0;
    // 枚举 a 与 b
    for (let a = 1; a <= n; a++) {
        for (let b = 1; b <= n; b++) {
            // 判断是否符合要求
            let c = Math.floor(Math.sqrt(a * a + b * b + 1));
            if (c <= n && c * c === a * a + b * b) {
                res++;
            }
        }
    }
    return res;
};

###Rust

impl Solution {
    pub fn count_triples(n: i32) -> i32 {
        let mut res = 0;
        // 枚举 a 与 b
        for a in 1..= n {
            for b in 1..= n {
                // 判断是否符合要求
                let c = ((a * a + b * b) as f64).sqrt().floor() as i32;
                if c <= n && c * c == a * a + b * b {
                    res += 1;
                }
            }
        }
        res
    }
}

复杂度分析

  • 时间复杂度:$O(n^2)$,其中 $n$ 为三元组元素的上界。即为遍历 $a$ 与 $b$ 的时间复杂度。

  • 空间复杂度:$O(1)$。

非暴力做法:本原勾股数组+数论分块+容斥(Python/Java/C++/Go)

作者 endlesscheng
2021年7月11日 10:01

方法一:暴力枚举

先说暴力怎么做。

我们可以枚举所有 $a>b$ 的平方和三元组 $(a,b,c)$。由于 $a^2+b^2=b^2+a^2$,所以 $(b,a,c)$ 也是平方和三元组。所以只需统计 $a>b$ 的情况,最后把统计结果乘以 $2$,即为答案。

$a=b$ 的情况呢?如果 $2a^2 = c^2$,那么 $c = \sqrt 2 a$,所以 $c$ 一定不是整数(反证法:如果 $c$ 是整数,式子变形得 $\dfrac{c}{a} = \sqrt 2$,左边是有理数,右边是无理数,有理数不可能等于无理数)。所以无需考虑 $a=b$ 的情况。

枚举 $a=2,3,\ldots,n-1$,枚举 $b=1,2,\ldots,a-1$,如果 $a^2+b^2\le n$ 且 $c = \sqrt{a^2+b^2}$ 是整数,那么我们找到了一个平方和三元组 $(a,b,c)$,计数器加一。

class Solution:
    def countTriples(self, n: int) -> int:
        ans = 0
        for a in range(1, n):
            for b in range(1, a):
                if a * a + b * b > n * n:
                    break
                c2 = a * a + b * b
                if isqrt(c2) ** 2 == c2:
                    ans += 1
        return ans * 2  # (a,b,c) 和 (b,a,c) 各算一次
class Solution {
    public int countTriples(int n) {
        int ans = 0;
        for (int a = 1; a < n; a++) {
            for (int b = 1; b < a && a * a + b * b <= n * n; b++) {
                int c2 = a * a + b * b;
                int rt = (int) Math.sqrt(c2);
                if (rt * rt == c2) {
                    ans++;
                }
            }
        }
        return ans * 2; // (a,b,c) 和 (b,a,c) 各算一次
    }
}
class Solution {
public:
    int countTriples(int n) {
        int ans = 0;
        for (int a = 1; a < n; a++) {
            for (int b = 1; b < a && a * a + b * b <= n * n; b++) {
                int c2 = a * a + b * b;
                int rt = sqrt(c2);
                if (rt * rt == c2) {
                    ans++;
                }
            }
        }
        return ans * 2; // (a,b,c) 和 (b,a,c) 各算一次
    }
};
func countTriples(n int) (ans int) {
for a := 1; a < n; a++ {
for b := 1; b < a && a*a+b*b <= n*n; b++ {
c2 := a*a + b*b
rt := int(math.Sqrt(float64(c2)))
if rt*rt == c2 {
ans++
}
}
}
return ans * 2 // (a,b,c) 和 (b,a,c) 各算一次
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n^2)$。
  • 空间复杂度:$\mathcal{O}(1)$。

方法二:本原勾股数组

请看 勾三股四弦五:生成勾股数的公式

class Solution:
    def countTriples(self, n: int) -> int:
        ans = 0
        u = 3
        while u * u < n * 2:
            v = 1
            while v < u and u * u + v * v <= n * 2:
                if gcd(u, v) == 1:
                    ans += n * 2 // (u * u + v * v)
                v += 2
            u += 2
        return ans * 2  # (a,b,c) 和 (b,a,c) 各算一次
MX = isqrt(500) + 1
gcds = [[0] * MX for _ in range(MX)]
for i in range(1, MX):
    gcds[i][0] = i
    for j in range(1, MX):
        # 更相减损术
        gcds[i][j] = gcds[i][j - i] if j >= i else gcds[j][i]

class Solution:
    def countTriples(self, n: int) -> int:
        ans = 0
        u = 3
        while u * u < n * 2:
            v = 1
            while v < u and u * u + v * v <= n * 2:
                if gcds[u][v] == 1:
                    ans += n * 2 // (u * u + v * v)
                v += 2
            u += 2
        return ans * 2  # (a,b,c) 和 (b,a,c) 各算一次
class Solution {
    public int countTriples(int n) {
        int ans = 0;
        for (int u = 3; u * u < n * 2; u += 2) {
            for (int v = 1; v < u && u * u + v * v <= n * 2; v += 2) {
                if (gcd(u, v) == 1) {
                    ans += n * 2 / (u * u + v * v);
                }
            }
        }
        return ans * 2; // (a,b,c) 和 (b,a,c) 各算一次
    }

    private int gcd(int a, int b) {
        while (a != 0) {
            int tmp = a;
            a = b % a;
            b = tmp;
        }
        return b;
    }
}
class Solution {
public:
    int countTriples(int n) {
        int ans = 0;
        for (int u = 3; u * u < n * 2; u += 2) {
            for (int v = 1; v < u && u * u + v * v <= n * 2; v += 2) {
                if (gcd(u, v) == 1) {
                    ans += n * 2 / (u * u + v * v);
                }
            }
        }
        return ans * 2; // (a,b,c) 和 (b,a,c) 各算一次
    }
};
func countTriples(n int) (ans int) {
for u := 3; u*u < n*2; u += 2 {
for v := 1; v < u && u*u+v*v <= n*2; v += 2 {
if gcd(u, v) == 1 {
ans += n * 2 / (u*u + v*v)
}
}
}
return ans * 2 // (a,b,c) 和 (b,a,c) 各算一次
}

func gcd(a, b int) int {
for a != 0 {
a, b = b%a, a
}
return b
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n)$ 或 $\mathcal{O}(n)$。枚举 $\mathcal{O}(\sqrt n\cdot \sqrt n) = \mathcal{O}(n)$ 次,每次花费 $\mathcal{O}(\log n)$ 的时间计算 GCD。如果预处理 $\sqrt {2n}$ 以内的数对 GCD,则可以做到 $\mathcal{O}(n)$ 时间,参考 Python3 写法二。
  • 空间复杂度:$\mathcal{O}(1)$。

方法三:数论分块 + 容斥原理

数论分块

更仔细地考察枚举 $(u,v)$ 计算 $\left\lfloor\dfrac{2n}{u^2+v^2}\right\rfloor$ 的过程:

  • 当 $u\le \sqrt[3] {n}$ 时,枚举 $(u,v)$ 的循环次数为 $\mathcal{O}(n^{2/3})$。
  • 当 $u > \sqrt[3] {n}$ 时,由于 $\dfrac{2n}{u^2+v^2} < \dfrac{2n}{u^2}$,所以对于固定的 $u$,$\left\lfloor\dfrac{2n}{u^2+v^2}\right\rfloor$ 只有 $\mathcal{O}\left(\dfrac{n}{u^2}\right)$ 个不同的整数值。横看成岭侧成峰,考虑对这 $\mathcal{O}\left(\dfrac{n}{u^2}\right)$ 个不同的整数值,分别计算有多少个 $v$。

推荐读者先看 数论分块 中的图,先理解怎么算 $\displaystyle\sum_{i=1}^{n} \left\lfloor\dfrac{n}{i}\right\rfloor$,再理解更复杂的 $\left\lfloor\dfrac{2n}{u^2+v^2}\right\rfloor$。

外层循环枚举 $u=3,5,7,\ldots$ 内层循环用数论分块枚举 $v$。

设闭区间 $[L,R]$ 内的 $v$,对应的 $\left\lfloor\dfrac{2n}{u^2+v^2}\right\rfloor$ 都相等。

如果已知 $L$,如何计算 $R$?一开始 $L=1$,只要我们算出了 $R$,那么下一个区间的左端点 $L'$ 就是 $R+1$。再用同样的方法根据 $L'$ 算出 $R'$,得到下下个区间的左端点 $L'' = R'+1$,依此类推,用这个方法可以算出后续所有区间。

设 $\textit{num} = \left\lfloor\dfrac{2n}{u^2+L^2}\right\rfloor$。

由于 $\dfrac{2n}{u^2+v^2}$ 的整数部分是 $\textit{num}$,即

$$
\dfrac{2n}{u^2+v^2}\ge \textit{num}
$$

所以

$$
(u^2+v^2)\cdot \textit{num}\le 2n
$$

在正整数情况下,上式等价于

$$
u^2+v^2\le \left\lfloor\dfrac{2n}{\textit{num}}\right\rfloor
$$

解得

$$
v\le \sqrt{\left\lfloor\dfrac{2n}{\textit{num}}\right\rfloor - u^2}
$$

由于 $v < u$,所以还要与 $u-1$ 取最小值,得到

$$
R = \min\left(\sqrt{\left\lfloor\dfrac{2n}{\textit{num}}\right\rfloor - u^2},u-1\right)
$$

互质个数

对于 $[L,R]$ 内的 $v$,还要满足 $v$ 是奇数,且 $v$ 与 $u$ 互质。

现在问题变成:

  • 给定正奇数 $u$ 和整数 $L,R$,计算 $[L,R]$ 内的与 $u$ 互质的奇数个数。

用 $[1,R]$ 的答案,减去 $[1,L-1]$ 的答案,就是 $[L,R]$ 的答案。

$[1,R]$ 内的与 $u$ 互质的奇数个数,等于 $[1,R]$ 内的与 $u$ 互质的整数个数,减去 $[1,R]$ 内的与 $u$ 互质的偶数个数。对于后者,设偶数为 $2k$,由于 $u$ 是奇数(不含质因子 $2$),所以 $\gcd(2k,u) = \gcd(k,u)$,我们只需考虑 $k$ 与 $u$ 是否互质。由于 $2k\le R$,所以 $k\le \lfloor\frac{R}{2}\rfloor$。所以 $[1,R]$ 内的与 $u$ 互质的偶数个数,等于 $[1,\lfloor\frac{R}{2}\rfloor]$ 内的与 $u$ 互质的整数个数。

设 $n$ 以内的与 $u$ 互质的正整数个数为 $f(n,u)$。那么 $[1,R]$ 的答案就是 $f(R,u) - f(\lfloor\frac{R}{2}\rfloor, u)$。

容斥原理

计算 $n$ 以内的与 $u$ 互质的正整数个数 $f(n,u)$。

例如 $u=15$。与 $15$ 互质的数,不能包含质因子 $3$ 和 $5$。

  • $[1,n]$ 中有 $n$ 个数。
  • 其中有 $\left\lfloor\dfrac{n}{3}\right\rfloor$ 个数是 $3$ 的倍数,这些数不与 $15$ 互质,减掉。
  • 其中有 $\left\lfloor\dfrac{n}{5}\right\rfloor$ 个数是 $5$ 的倍数,这些数不与 $15$ 互质,减掉。
  • 其中有 $\left\lfloor\dfrac{n}{15}\right\rfloor$ 个数既是 $3$ 的倍数,又是 $5$ 的倍数(即 $15$ 的倍数),我们多减了一次,加回来。

所以

$$
f(n,15) = n - \left\lfloor\dfrac{n}{3}\right\rfloor - \left\lfloor\dfrac{n}{5}\right\rfloor + \left\lfloor\dfrac{n}{15}\right\rfloor
$$

一般地,设 $u$ 的质因子集合为 $P$,由容斥原理可得

$$
f(n,u) = \sum_{S\subseteq P} (-1)^{|S|} \left\lfloor\dfrac{n}{\prod S}\right\rfloor
$$

用莫比乌斯函数表示就是

$$
f(n,u) = \sum_{d|u} \mu(d) \left\lfloor\dfrac{n}{d}\right\rfloor
$$

其中莫比乌斯函数为

$$
\mu(n)=
\begin{cases}
1, & n=1\
(-1)^k, & n = p_1p_2\dots p_k\
0, & n\ 有大于\ 1\ 的平方因子
\end{cases}
$$

注:也可以用莫比乌斯反演推导 $f(n,u)$ 的式子。

MX = isqrt(500) + 1

# 预处理莫比乌斯函数
# 当 n > 1 时,sum_{d|n} mu[d] = 0
# 所以 mu[n] = -sum_{d|n ∧ d<n} mu[d]
mu = [0] * MX
mu[1] = 1
for i in range(1, MX):
    for j in range(i * 2, MX, i):
        mu[j] -= mu[i]  # i 是 j 的真因子

# 预处理不含平方因子的因子列表,用于 count_coprime
divisors = [[] for _ in range(MX)]
for i in range(1, MX):
    if mu[i]:
        for j in range(i, MX, i):
            divisors[j].append(i)  # i 是 j 的因子,且 mu[i] != 0

# 返回 [1,n] 中与 x 互质的整数个数
def count_coprime(n: int, x: int) -> int:
    return sum(mu[d] * (n // d) for d in divisors[x])

# 返回 [1,n] 中与奇数 x 互质的奇数个数
# 与 x 互质的整数个数 - 与 x 互质的偶数个数
def count_coprime_odd(n: int, x: int) -> int:
    return count_coprime(n, x) - count_coprime(n // 2, x)

class Solution:
    def countTriples(self, n: int) -> int:
        ans = 0
        u = 3
        while u * u < n * 2:
            l = 1
            while l < u and u * u + l * l <= n * 2:
                num = (n * 2) // (u * u + l * l)
                # 对于 [l,r] 中的整数 v,2n // (u^2 + v^2) 都等于 num
                r = min(isqrt(n * 2 // num - u * u), u - 1)  # 推导过程见题解
                # 只有与 u 互质的奇数 v 才能得到本原勾股数组
                num_coprime_odd_v = count_coprime_odd(r, u) - count_coprime_odd(l - 1, u)
                ans += num * num_coprime_odd_v
                l = r + 1
            u += 2
        return ans * 2  # (a,b,c) 和 (b,a,c) 各算一次
class Solution {
    private static final int MX = 23; // floor(sqrt(250 * 2)) + 1
    private static final int[] mu = new int[MX];
    private static final List<Integer>[] divisors = new ArrayList[MX];

    static {
        // 预处理莫比乌斯函数
        // 当 n > 1 时,sum_{d|n} mu[d] = 0
        // 所以 mu[n] = -sum_{d|n ∧ d<n} mu[d]
        mu[1] = 1;
        for (int i = 1; i < MX; i++) {
            for (int j = i * 2; j < MX; j += i) {
                mu[j] -= mu[i]; // i 是 j 的真因子
            }
        }

        // 预处理不含平方因子的因子列表,用于 countCoprime
        Arrays.setAll(divisors, _ -> new ArrayList<>());
        for (int i = 1; i < MX; i++) {
            if (mu[i] == 0) {
                continue;
            }
            for (int j = i; j < MX; j += i) {
                divisors[j].add(i); // i 是 j 的因子,且 mu[i] != 0
            }
        }
    }

    public int countTriples(int n) {
        int ans = 0;
        for (int u = 3; u * u < n * 2; u += 2) {
            for (int l = 1, r; l < u && u * u + l * l <= n * 2; l = r + 1) {
                int num = (n * 2) / (u * u + l * l);
                // 对于 [l,r] 中的整数 v,floor(2n / (u^2 + v^2)) 都等于 num
                r = Math.min((int) Math.sqrt(n * 2 / num - u * u), u - 1); // 推导过程见题解
                // 只有与 u 互质的奇数 v 才能得到本原勾股数组
                int numCoprimeOddV = countCoprimeOdd(r, u) - countCoprimeOdd(l - 1, u);
                ans += num * numCoprimeOddV;
            }
        }
        return ans * 2; // (a,b,c) 和 (b,a,c) 各算一次
    }

    // 返回 [1,n] 中与奇数 x 互质的奇数个数
    // 与 x 互质的整数个数 - 与 x 互质的偶数个数
    private int countCoprimeOdd(int n, int x) {
        return countCoprime(n, x) - countCoprime(n / 2, x);
    }

    // 返回 [1,n] 中与 x 互质的整数个数
    private int countCoprime(int n, int x) {
        int res = 0;
        for (int d : divisors[x]) {
            res += mu[d] * (n / d);
        }
        return res;
    }
}
constexpr int MX = 23; // floor(sqrt(250 * 2)) + 1
int mu[MX];
vector<int> divisors[MX];

int init = [] {
    // 预处理莫比乌斯函数
    // 当 n > 1 时,sum_{d|n} mu[d] = 0
    // 所以 mu[n] = -sum_{d|n ∧ d<n} mu[d]
    mu[1] = 1;
    for (int i = 1; i < MX; i++) {
        for (int j = i * 2; j < MX; j += i) {
            mu[j] -= mu[i]; // i 是 j 的真因子
        }
    }

    // 预处理不含平方因子的因子列表,用于 count_coprime
    for (int i = 1; i < MX; i++) {
        if (mu[i]) {
            for (int j = i; j < MX; j += i) {
                divisors[j].push_back(i); // i 是 j 的因子,且 mu[i] != 0
            }
        }
    }
    return 0;
}();

class Solution {
    // 返回 [1,n] 中与 x 互质的整数个数
    int count_coprime(int n, int x) {
        int res = 0;
        for (int d : divisors[x]) {
            res += mu[d] * (n / d);
        }
        return res;
    }

    // 返回 [1,n] 中与奇数 x 互质的奇数个数
    // 与 x 互质的整数个数 - 与 x 互质的偶数个数
    int count_coprime_odd(int n, int x) {
        return count_coprime(n, x) - count_coprime(n / 2, x);
    }

public:
    int countTriples(int n) {
        int ans = 0;
        for (int u = 3; u * u < n * 2; u += 2) {
            for (int l = 1, r; l < u && u * u + l * l <= n * 2; l = r + 1) {
                int num = (n * 2) / (u * u + l * l);
                // 对于 [l,r] 中的整数 v,floor(2n / (u^2 + v^2)) 都等于 num
                r = min((int) sqrt(n * 2 / num - u * u), u - 1); // 推导过程见题解
                // 只有与 u 互质的奇数 v 才能得到本原勾股数组
                int num_coprime_odd_v = count_coprime_odd(r, u) - count_coprime_odd(l - 1, u);
                ans += num * num_coprime_odd_v;
            }
        }
        return ans * 2; // (a,b,c) 和 (b,a,c) 各算一次
    }
};
const mx = 23 // floor(sqrt(250 * 2)) + 1
var mu = [mx]int{1: 1}
var divisors [mx][]int

func init() {
// 预处理莫比乌斯函数
// 当 n > 1 时,sum_{d|n} mu[d] = 0
// 所以 mu[n] = -sum_{d|n ∧ d<n} mu[d]
for i := 1; i < mx; i++ {
for j := i * 2; j < mx; j += i {
mu[j] -= mu[i] // i 是 j 的真因子
}
}

// 预处理不含平方因子的因子列表,用于 countCoprime
for i := 1; i < mx; i++ {
if mu[i] == 0 {
continue
}
for j := i; j < mx; j += i {
divisors[j] = append(divisors[j], i) // i 是 j 的因子,且 mu[i] != 0
}
}
}

// 返回 [1,n] 中与 x 互质的整数个数
func countCoprime(n, x int) (res int) {
for _, d := range divisors[x] {
res += mu[d] * (n / d)
}
return
}

// 返回 [1,n] 中与奇数 x 互质的奇数个数
// 与 x 互质的整数个数 - 与 x 互质的偶数个数
func countCoprimeOdd(n, x int) (res int) {
return countCoprime(n, x) - countCoprime(n/2, x)
}

func countTriples(n int) (ans int) {
for u := 3; u*u < n*2; u += 2 {
for l, r := 1, 0; l < u && u*u+l*l <= n*2; l = r + 1 {
num := n * 2 / (u*u + l*l)
// 对于 [l,r] 中的整数 v,floor(2n / (u^2 + v^2)) 都等于 num
r = min(int(math.Sqrt(float64(n*2/num-u*u))), u-1) // 推导过程见题解
// 只有与 u 互质的奇数 v 才能得到本原勾股数组
numCoprimeOddV := countCoprimeOdd(r, u) - countCoprimeOdd(l-1, u)
ans += num * numCoprimeOddV
}
}
return ans * 2 // (a,b,c) 和 (b,a,c) 各算一次
}

func gcd(a, b int) int {
for a != 0 {
a, b = b%a, a
}
return b
}

复杂度分析

预处理的时空复杂度为 $\mathcal{O}(\sqrt N\log N)$,$N=500$,不计入。

  • 时间复杂度:$\mathcal{O}(n^{2/3} \log n)$。为方便分析,这里省略 $2n$ 中的常系数 $2$(不影响时间复杂度)。当 $u\le \sqrt[3] n$ 时,枚举 $u$ 和 $v$ 是 $\mathcal{O}(n^{2/3})$ 的。当 $u > \sqrt[3] n$ 时,对于固定的 $u$,$\left\lfloor\dfrac{n}{u^2+v^2}\right\rfloor$ 有 $\mathcal{O}\left(\dfrac{n}{u^2}\right)$ 种不同的整数值。用积分估计,$\displaystyle\sum_{u=n^{1/3}}^{n^{1/2}} \dfrac{n}{u^2}\approx \displaystyle \int_{n^{1/3}}^{n^{1/2}} \dfrac{n}{u^2} , \mathrm{d}u = n^{2/3} - n^{1/2}$,所以当 $u > \sqrt[3] n$ 时的循环次数为 $\mathcal{O}(n^{2/3})$。由于 $\sqrt n$ 以内正整数的平均因子个数是 $\mathcal{O}(\log n)$ 的(即使只考虑 $\mu(d)\ne 0$ 的因子,平均因子个数仍然是 $\mathcal{O}(\log n)$ 的),所以总的时间复杂度为 $\mathcal{O}(n^{2/3} \log n)$。
  • 空间复杂度:$\mathcal{O}(1)$。

:进一步地,考虑根号分解,取阈值 $B = \sqrt[3] {n\ln n} $,当 $u\le B$ 时使用方法二,当 $u>B$ 时使用方法三,可以做到 $\mathcal{O}\left((n\log n)^{2/3}\right)$ 的时间复杂度。

分类题单

如何科学刷题?

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

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

手写 EventEmitter:深入理解发布订阅模式

作者 1024肥宅
2025年12月7日 23:31

引言

在 JavaScript 开发中,事件驱动编程是构建可维护、可扩展应用的核心范式之一。从浏览器 DOM 事件到 Node.js 的异步 I/O,从 Vue 的组件通信到 React 的状态管理,发布订阅模式无处不在。

通过手写一个符合 Node.js EventEmitter 标准的实现,我们不仅能深入理解事件驱动架构的设计原理,还能掌握 JavaScript 中闭包、内存管理、设计模式等核心概念。更重要的是,这是面试中常见的高级题目,能体现你对JavaScript设计模式的理解深度。

本文将带你从零实现一个功能完整的EventEmitter,并探讨其在实际项目中的应用和优化策略。

一、发布订阅模式的核心概念

1.1 什么是发布订阅模式

发布订阅模式(Pub/Sub)是一种消息传递范式,消息的发送者(发布者)不会将消息直接发送给特定的接收者(订阅者),而是通过一个中间件(事件中心)进行通信。

// 类比:报纸订阅系统
class NewspaperSystem {
    constructor() {
        this.subscribers = new Map(); // 事件中心
    }
    
    // 订阅(读者订阅报纸)
    subscribe(topic, reader) {
        if (!this.subscribers.has(topic)) {
            this.subscribers.set(topic, []);
        }
        this.subscribers.get(topic).push(reader);
    }
    
    // 发布(报社发布新闻)
    publish(topic, news) {
        if (this.subscribers.has(topic)) {
            this.subscribers.get(topic).forEach(reader => reader(news));
        }
    }
    
    // 取消订阅(读者退订)
    unsubscribe(topic, reader) {
        if (this.subscribers.has(topic)) {
            const readers = this.subscribers.get(topic);
            const index = readers.indexOf(reader);
            if (index > -1) {
                readers.splice(index, 1);
            }
        }
    }
}
1.2 核心组件
  1. 事件中心(EventEmitter): 存储事件和回调的对应关系
  2. 发布者(Publisher): 触发事件,传递数据
  3. 订阅者(Subscriber): 监听事件,处理数据
1.3 与观察者模式的对比
特性 观察者模式 发布订阅模式
耦合度 直接耦合 通过事件中心解耦
通信方式 直接调用 间接通信
灵活性 较低 更高
典型实现 Vue响应式 NodeJS EventEmitter

二、基础 EventEmitter 实现

2.1 最小可行实现
class EventEmitter {
  constructor() {
    // 存储事件和对应的回调函数
    this.events = new Map();
  }

  /**
   * 订阅事件
   * @param {string} eventName 事件名称
   * @param {Function} callback 回调函数
   * @returns {Function} 取消订阅的函数
   */
  on(eventName, callback) {
    if (!this.events.has(eventName)) {
      this.events.set(eventName, []);
    }
    this.events.get(eventName).push(callback);

    // 返回取消订阅的函数
    return () => this.off(eventName, callback);
  }

  /**
   * 触发事件
   * @param {string} eventName 事件名称
   * @param {...any} args 传递给回调函数的参数
   */
  emit(eventName, ...args) {
    if (!this.events.has(eventName)) {
      return;
    }

    const callbacks = this.events.get(eventName);
    callbacks.forEach((callback) => {
      try {
        callback.apply(null, args);
      } catch (error) {
        console.error(`Error in event listener for ${eventName}:`, error);
      }
    });
  }

  /**
   * 取消订阅
   * @param {string} eventName 事件名称
   * @param {Function} callback 要移除的回调函数
   */
  off(eventName, callback) {
    if (!this.events.has(eventName)) {
      return;
    }

    const callbacks = this.events.get(eventName);
    const index = callbacks.indexOf(callback);

    if (index > -1) {
      callbacks.splice(index, 1);

      // 如果没有回调函数了, 删除这个事件
      if (callbacks.length === 0) {
        this.events.delete(eventName);
      }
    }
  }

  /**
   * 一次性订阅
   * @param {string} eventName 事件名称
   * @param {Function} callback 回调函数
   */
  once(eventName, callback) {
    const onceCallback = (...args) => {
      callback.apply(null, args);
      this.off(eventName, callback);
    };

    this.on(eventName, onceCallback);
    return () => this.off(eventName, onceCallback);
  }

  /**
   * 移除所有事件监听器, 或指定事件的所有监听器
   * @param {string} eventName 可选, 事件名称
   */
  removeAllListeners(eventName) {
    if (eventName) {
      this.events.delete(eventName);
    } else {
      this.events.clear();
    }
  }
}

// 基础使用示例
const emitter = new EventEmitter();

// 订阅事件
const unsubscribe = emitter.on("message", (data) => {
  console.log("收到消息:", data);
});

// 触发事件
emitter.emit("message", "Hello World!"); // 输出: 收到消息: Hello World!

// 取消订阅
unsubscribe();

// 再次触发,不会有输出
emitter.emit("message", "Hello Again!");

三、完整的 EventEmitter 实现

3.1 支持更多特性的完整实现
class EventEmitter {
  constructor() {
    // 使用 Object.create(null) 避免原型污染
    this._events = Object.create(null);
    this._maxListeners = 10;
  }

  // ================ 核心方法 ================

  /**
   * 添加事件监听器
   * @param {string} eventName 事件名称
   * @param {Function} listener 监听器函数
   * @returns {EventEmitter} this
   */
  on(eventName, listener) {
    return this._addListener(eventName, listener, false);
  }

  /**
   * 添加事件监听器(别名)
   */
  addListener(eventName, listener) {
    return this.on(eventName, listener);
  }

  /**
   * 添加一次性事件监听器
   * @param {string} eventName 事件名称
   * @param {Function} listener 监听器函数
   * @returns {EventEmitter} this
   */
  once(eventName, listener) {
    return this._addListener(eventName, listener, true);
  }

  /**
   * 触发事件
   * @param {string} eventName 事件名称
   * @param {...any} args 传递给监听器的参数
   * @returns {boolean} 是否有监听器被调用
   */
  emit(eventName, ...args) {
    if (!this._events[eventName]) {
      // 如果没有 error 事件的监听器,抛出错误
      if (eventName === "error") {
        const error = args[0];
        if (error instanceof Error) {
          throw error;
        } else {
          throw new Error("Unhandled error event");
        }
      }
      return false;
    }

    const listeners = this._events[eventName];
    const listenersCopy = listeners.slice(); // 创建副本避免迭代时修改

    let called = false;

    for (const listener of listenersCopy) {
      try {
        // 检查是否为 once 包装函数
        if (listener._once) {
          // 移除原始监听器
          this._removeListener(eventName, listener);
        }

        listener.apply(this, args);
        called = true;
      } catch (error) {
        // 触发错误事件
        if (eventName !== "error") {
          this.emit("error", error);
        }
      }
    }

    return called;
  }

  /**
   * 移除事件监听器
   * @param {string} eventName 事件名称
   * @param {Function} listener 要移除的监听器
   * @returns {EventEmitter} this
   */
  off(eventName, listener) {
    return this.removeListener(eventName, listener);
  }

  /**
   * 移除事件监听器
   * @param {string} eventName 事件名称
   * @param {Function} listener 要移除的监听器
   * @returns {EventEmitter} this
   */
  removeListener(eventName, listener) {
    return this._removeListener(eventName, listener);
  }

  /**
   * 移除所有事件监听器
   * @param {string} [eventName] 可选,事件名称
   * @returns {EventEmitter} this
   */
  removeAllListeners(eventName) {
    if (eventName) {
      delete this._events[eventName];
    } else {
      this._events = Object.create(null);
    }
    return this;
  }

  // ================ 辅助方法 ================

  /**
   * 设置最大监听器数量
   * @param {number} n 最大监听器数量
   * @returns {EventEmitter} this
   */
  setMaxListeners(n) {
    if (typeof n !== "number" || n < 0) {
      throw new TypeError("n must be a non-negative number");
    }
    this._maxListeners = n;
    return this;
  }

  /**
   * 获取最大监听器数量
   * @returns {number} 最大监听器数量
   */
  getMaxListeners() {
    return this._maxListeners;
  }

  /**
   * 获取指定事件的监听器数量
   * @param {string} eventName 事件名称
   * @returns {number} 监听器数量
   */
  listenerCount(eventName) {
    if (!this._events[eventName]) {
      return 0;
    }
    return this._events[eventName].length;
  }

  /**
   * 获取所有事件名称
   * @returns {string[]} 事件名称数组
   */
  eventNames() {
    return Object.keys(this._events);
  }

  /**
   * 获取指定事件的所有监听器
   * @param {string} eventName 事件名称
   * @returns {Function[]} 监听器数组
   */
  listeners(eventName) {
    if (!this._events[eventName]) {
      return [];
    }
    // 返回副本,避免外部修改内部数组
    return this._events[eventName].slice();
  }

  /**
   * 添加监听器到数组开头
   * @param {string} eventName 事件名称
   * @param {Function} listener 监听器函数
   * @returns {EventEmitter} this
   */
  prependListener(eventName, listener) {
    return this._addListener(eventName, listener, false, true);
  }

  /**
   * 添加一次性监听器到数组开头
   * @param {string} eventName 事件名称
   * @param {Function} listener 监听器函数
   * @returns {EventEmitter} this
   */
  prependOnceListener(eventName, listener) {
    return this._addListener(eventName, listener, true, true);
  }

  /**
   * 内部方法:添加监听器
   * @private
   */
  _addListener(eventName, listener, once = false, prepend = false) {
    if (typeof listener !== "function") {
      throw new TypeError("listener must be a function");
    }

    // 初始化事件数组
    if (!this._events[eventName]) {
      this._events[eventName] = [];
    }

    const listeners = this._events[eventName];

    // 检查最大监听器限制
    if (listeners.length >= this._maxListeners && this._maxListeners !== 0) {
      console.warn(
        `MaxListenersExceededWarning: Possible EventEmitter memory leak detected. ` +
          `${listeners.length} ${eventName} listeners added. ` +
          `Use emitter.setMaxListeners() to increase limit`
      );
    }

    // 如果是 once,创建包装函数
    let listenerToAdd = listener;
    if (once) {
      const onceWrapper = (...args) => {
        listener.apply(this, args);
        // 标记为 once 包装函数
        onceWrapper._once = true;
      };
      // 保存原始监听器引用,用于移除
      onceWrapper._originalListener = listener;
      listenerToAdd = onceWrapper;
    }

    // 添加到数组开头或结尾
    if (prepend) {
      listeners.unshift(listenerToAdd);
    } else {
      listeners.push(listenerToAdd);
    }

    return this;
  }

  /**
   * 内部方法:移除监听器
   * @private
   */
  _removeListener(eventName, listener) {
    if (!this._events[eventName]) {
      return this;
    }

    const listeners = this._events[eventName];

    // 查找要移除的监听器
    // 需要考虑两种情况:
    // 1. 直接传入监听器
    // 2. 传入 once 包装函数的原始监听器
    let index = -1;

    // 尝试直接查找
    index = listeners.indexOf(listener);

    // 如果没找到,尝试查找原始监听器
    if (index === -1) {
      for (let i = 0; i < listeners.length; i++) {
        const current = listeners[i];
        if (current._originalListener === listener) {
          index = i;
          break;
        }
      }
    }

    if (index > -1) {
      listeners.splice(index, 1);

      // 如果数组为空,删除事件
      if (listeners.length === 0) {
        delete this._events[eventName];
      }
    }

    return this;
  }
}
3.2 类型安全的TypeScript版本
type Listener = (...args: any[]) => void;
type OnceWrapper = Listener & { _originalListener?: Listener; _once?: boolean };

class EventEmitter {
    private _events: Record<string, Listener[]> = Object.create(null);
    private _maxListeners: number = 10;
    
    // 核心方法
    on(eventName: string, listener: Listener): this {
        return this._addListener(eventName, listener, false);
    }
    
    addListener(eventName: string, listener: Listener): this {
        return this.on(eventName, listener);
    }
    
    once(eventName: string, listener: Listener): this {
        return this._addListener(eventName, listener, true);
    }
    
    emit(eventName: string, ...args: any[]): boolean {
        const listeners = this._events[eventName];
        if (!listeners) {
            if (eventName === 'error') {
                const error = args[0];
                throw error instanceof Error ? error : new Error('Unhandled error event');
            }
            return false;
        }
        
        const listenersCopy = listeners.slice();
        let called = false;
        
        for (const listener of listenersCopy) {
            try {
                // 检查是否为 once 包装函数
                const onceWrapper = listener as OnceWrapper;
                if (onceWrapper._once) {
                    this._removeListener(eventName, listener);
                }
                
                listener.apply(this, args);
                called = true;
            } catch (error) {
                if (eventName !== 'error') {
                    this.emit('error', error);
                }
            }
        }
        
        return called;
    }
    
    off(eventName: string, listener: Listener): this {
        return this.removeListener(eventName, listener);
    }
    
    removeListener(eventName: string, listener: Listener): this {
        return this._removeListener(eventName, listener);
    }
    
    removeAllListeners(eventName?: string): this {
        if (eventName) {
            delete this._events[eventName];
        } else {
            this._events = Object.create(null);
        }
        return this;
    }
    
    // 辅助方法
    setMaxListeners(n: number): this {
        if (typeof n !== 'number' || n < 0) {
            throw new TypeError('n must be a non-negative number');
        }
        this._maxListeners = n;
        return this;
    }
    
    getMaxListeners(): number {
        return this._maxListeners;
    }
    
    listenerCount(eventName: string): number {
        const listeners = this._events[eventName];
        return listeners ? listeners.length : 0;
    }
    
    eventNames(): string[] {
        return Object.keys(this._events);
    }
    
    listeners(eventName: string): Listener[] {
        const listeners = this._events[eventName];
        return listeners ? listeners.slice() : [];
    }
    
    prependListener(eventName: string, listener: Listener): this {
        return this._addListener(eventName, listener, false, true);
    }
    
    prependOnceListener(eventName: string, listener: Listener): this {
        return this._addListener(eventName, listener, true, true);
    }
    
    // 私有方法
    private _addListener(eventName: string, listener: Listener, once: boolean, prepend: boolean = false): this {
        if (typeof listener !== 'function') {
            throw new TypeError('listener must be a function');
        }
        
        if (!this._events[eventName]) {
            this._events[eventName] = [];
        }
        
        const listeners = this._events[eventName];
        
        // 检查最大监听器限制
        if (listeners.length >= this._maxListeners && this._maxListeners !== 0) {
            console.warn(`MaxListenersExceededWarning for event ${eventName}`);
        }
        
        let listenerToAdd: Listener = listener;
        
        if (once) {
            const onceWrapper: OnceWrapper = (...args: any[]) => {
                listener.apply(this, args);
                onceWrapper._once = true;
            };
            onceWrapper._originalListener = listener;
            listenerToAdd = onceWrapper;
        }
        
        if (prepend) {
            listeners.unshift(listenerToAdd);
        } else {
            listeners.push(listenerToAdd);
        }
        
        return this;
    }
    
    private _removeListener(eventName: string, listener: Listener): this {
        const listeners = this._events[eventName];
        if (!listeners) return this;
        
        let index = listeners.indexOf(listener);
        
        // 如果没找到,尝试查找原始监听器
        if (index === -1) {
            for (let i = 0; i < listeners.length; i++) {
                const current = listeners[i] as OnceWrapper;
                if (current._originalListener === listener) {
                    index = i;
                    break;
                }
            }
        }
        
        if (index > -1) {
            listeners.splice(index, 1);
            if (listeners.length === 0) {
                delete this._events[eventName];
            }
        }
        
        return this;
    }
}

四、测试用例

4.1 基础功能测试
console.log("=== EventEmitter 基础功能测试 ===");

const emitter = new EventEmitter();

// 测试1: 基本订阅和触发
let test1Count = 0;
emitter.on("test1", (data) => {
  console.log("测试1 - 收到数据:", data);
  test1Count++;
});

emitter.emit("test1", "Hello"); // 测试1 - 收到数据: Hello
emitter.emit("test1", "World"); // 测试1 - 收到数据: World
console.log(`测试1 - 调用次数: ${test1Count}`); // 测试1 - 调用次数: 2

// 测试2: 多个监听器
let test2Result = [];
emitter.on("test2", (data) => {
  test2Result.push(`listener1: ${data}`);
});
emitter.on("test2", (data) => {
  test2Result.push(`listener2: ${data.toUpperCase()}`);
});

emitter.emit("test2", "hello");
console.log("测试2 - 多个监听器:", test2Result); // 测试2 - 多个监听器: [ 'listener1: hello', 'listener2: HELLO' ]

// 测试3: 取消订阅
let test3Count = 0;
const test3Listener = () => {
  test3Count++;
  console.log("测试3 - 监听器被调用");
};

emitter.on("test3", test3Listener);
emitter.emit("test3"); // 测试3 - 监听器被调用
emitter.off("test3", test3Listener);
emitter.emit("test3"); // 不调用
console.log(`测试3 - 最终调用次数: ${test3Count}`); // 测试3 - 最终调用次数: 1

// 测试4: once 方法
let test4Count = 0;
emitter.once("test4", () => {
  test4Count++;
  console.log("测试4 - once 监听器被调用");
});

emitter.emit("test4"); // 测试4 - once 监听器被调用
emitter.emit("test4"); // 测试4 - once 监听器被调用
emitter.emit("test4"); // 不调用
console.log(`测试4 - once 调用次数: ${test4Count}`); // 测试4 - once 调用次数: 2

// 测试5: 错误处理
let errorCaught = false;
emitter.on("error", (error) => {
  errorCaught = true;
  console.log("测试5 - 捕获到错误:", error.message);
});

emitter.on("test5", () => {
  throw new Error("测试错误"); // 测试5 - 捕获到错误: 测试错误
});

emitter.emit("test5");
console.log(`测试5 - 错误是否被捕获: ${errorCaught}`); // 测试5 - 错误是否被捕获: true
4.2 高级功能测试
console.log("\n=== EventEmitter 高级功能测试 ===");

const emitter2 = new EventEmitter();

// 测试6: prependListener 方法
let test6Order = [];
emitter2.on("test6", () => test6Order.push("normal1"));
emitter2.on("test6", () => test6Order.push("normal2"));
emitter2.prependListener("test6", () => test6Order.push("prepended"));

emitter2.emit("test6");
console.log("测试6 - 监听器顺序:", test6Order);
// 测试6 - 监听器顺序: [ 'prepended', 'normal1', 'normal2' ]

// 测试7: 最大监听器限制
emitter2.setMaxListeners(2);
console.log(`测试7 - 最大监听器数: ${emitter2.getMaxListeners()}`); // 测试7 - 最大监听器数: 2

emitter2.on("test7", () => {});
emitter2.on("test7", () => {});
// 第三个应该触发警告
emitter2.on("test7", () => {});

// 测试8: 获取监听器信息
emitter2.on("test8", () => {});
emitter2.on("test8", () => {});
emitter2.once("test8", () => {});

console.log(`测试8 - 监听器数量: ${emitter2.listenerCount("test8")}`); // 3
console.log(`测试8 - 监听器数组长度: ${emitter2.listeners("test8").length}`); // 3
console.log(`测试8 - 事件名称: ${emitter2.eventNames()}`); // ['test6', 'test7', 'test8']

// 测试9: removeAllListeners
emitter2.removeAllListeners("test8");
console.log(`测试9 - 移除后监听器数量: ${emitter2.listenerCount("test8")}`); // 0

// 测试10: 链式调用
emitter2
  .on("test10", () => console.log("测试10 - 链式调用1"))
  .on("test10", () => console.log("测试10 - 链式调用2"))
  .emit("test10");
4.3 边界情况测试
console.log('\n=== EventEmitter 边界情况测试 ===');

const emitter3 = new EventEmitter();

// 测试11: 重复添加相同监听器
let test11Count = 0;
const test11Listener = () => test11Count++;

emitter3.on('test11', test11Listener);
emitter3.on('test11', test11Listener); // 重复添加

emitter3.emit('test11');
console.log(`测试11 - 重复监听器调用次数: ${test11Count}`); // 2

// 测试12: 移除不存在的监听器
const fakeListener = () => {};
emitter3.off('nonexistent', fakeListener); // 应该不报错

// 测试13: 触发没有监听器的事件
const result = emitter3.emit('nonexistent');
console.log(`测试13 - 触发无监听器事件返回值: ${result}`); // false

// 测试14: 错误事件处理
try {
    emitter3.emit('error', new Error('未处理的错误'));
} catch (error) {
    console.log(`测试14 - 捕获未处理的错误: ${error.message}`);
}

// 测试15: once 监听器移除后再次触发
let test15Count = 0;
const test15Listener = () => {
    test15Count++;
    console.log(`测试15 - 第 ${test15Count} 次调用`);
};

const removeOnce = emitter3.once('test15', test15Listener);
emitter3.emit('test15'); // 调用
removeOnce(); // 手动移除
emitter3.emit('test15'); // 不调用
console.log(`测试15 - 最终调用次数: ${test15Count}`); // 1
4.4 性能测试
console.log('\n=== EventEmitter 性能测试 ===');

const performanceEmitter = new EventEmitter();
const iterations = 100000;

// 准备测试数据
const listeners = [];
for (let i = 0; i < 100; i++) {
    listeners.push(() => {});
}

// 测试添加监听器的性能
console.time('添加监听器');
for (let i = 0; i < iterations; i++) {
    performanceEmitter.on('performance', listeners[i % listeners.length]);
}
console.timeEnd('添加监听器');

console.log(`添加后监听器数量: ${performanceEmitter.listenerCount('performance')}`);

// 测试触发事件的性能
console.time('触发事件');
for (let i = 0; i < iterations; i++) {
    performanceEmitter.emit('performance', i);
}
console.timeEnd('触发事件');

// 测试移除监听器的性能
console.time('移除监听器');
for (let i = 0; i < iterations; i++) {
    performanceEmitter.off('performance', listeners[i % listeners.length]);
}
console.timeEnd('移除监听器');

console.log(`移除后监听器数量: ${performanceEmitter.listenerCount('performance')}`);

五、EventEmitter的核心原理分析

51 数据结构设计
// EventEmitter 的核心数据结构
class EventEmitter {
    constructor() {
        // 使用普通对象而不是 Map 的原因:
        // 1. 在 V8 中,普通对象性能更好
        // 2. 事件名通常是字符串,适合作为对象键
        // 3. Object.create(null) 创建没有原型的对象,避免原型污染
        this._events = Object.create(null);
        
        // 每个事件对应一个监听器数组
        // {
        //   'event1': [listener1, listener2, ...],
        //   'event2': [listener3, listener4, ...]
        // }
    }
}
5.2 once 方法的实现原理
// once 方法的实现细节
once(eventName, listener) {
    // 创建包装函数
    const onceWrapper = (...args) => {
        // 1. 执行原始监听器
        listener.apply(this, args);
        
        // 2. 标记为已执行
        onceWrapper._once = true;
        
        // 3. 在 emit 中检测到这个标记后会移除监听器
    };
    
    // 保存原始监听器引用,用于 off 方法
    onceWrapper._originalListener = listener;
    
    // 添加到监听器数组
    this._addListener(eventName, onceWrapper, false);
    
    return this;
}

// emit 方法中处理 once 监听器
emit(eventName, ...args) {
    // ...
    for (const listener of listenersCopy) {
        // 检查是否为 once 包装函数
        if (listener._once) {
            // 移除监听器
            this._removeListener(eventName, listener);
        }
        // ...
    }
    // ...
}
5.3 内存管理策略
// 避免内存泄漏的实现
class SafeEventEmitter extends EventEmitter {
    constructor() {
        super();
        // 跟踪所有订阅,便于清理
        this._subscriptions = new WeakMap();
    }
    
    safeOn(eventName, listener, context = null) {
        // 绑定上下文
        const boundListener = context ? listener.bind(context) : listener;
        
        // 存储元数据
        const meta = {
            eventName,
            originalListener: listener,
            boundListener,
            unsubscribe: () => this.off(eventName, boundListener)
        };
        
        // 使用 WeakMap 存储,不会阻止垃圾回收
        this._subscriptions.set(listener, meta);
        
        // 添加监听器
        this.on(eventName, boundListener);
        
        // 返回增强的取消订阅函数
        return () => {
            this.off(eventName, boundListener);
            this._subscriptions.delete(listener);
        };
    }
}

六、常见面试题实现

6.1 实现一个简单的 EventBus
// 全局事件总线(类似 Vue 中的 EventBus)
class EventBus {
    constructor() {
        this._events = Object.create(null);
    }
    
    // 单例模式
    static getInstance() {
        if (!EventBus._instance) {
            EventBus._instance = new EventBus();
        }
        return EventBus._instance;
    }
    
    $on(event, callback) {
        if (!this._events[event]) {
            this._events[event] = [];
        }
        this._events[event].push(callback);
    }
    
    $emit(event, ...args) {
        const callbacks = this._events[event];
        if (!callbacks) return;
        
        // 使用 slice 创建副本,避免迭代时修改数组
        callbacks.slice().forEach(callback => {
            try {
                callback(...args);
            } catch (error) {
                console.error(`EventBus error in ${event}:`, error);
            }
        });
    }
    
    $off(event, callback) {
        if (!this._events[event]) return;
        
        if (callback) {
            const index = this._events[event].indexOf(callback);
            if (index > -1) {
                this._events[event].splice(index, 1);
            }
        } else {
            delete this._events[event];
        }
    }
    
    $once(event, callback) {
        const onceWrapper = (...args) => {
            callback(...args);
            this.$off(event, onceWrapper);
        };
        this.$on(event, onceWrapper);
    }
}

// 使用示例
const bus = EventBus.getInstance();

// 组件 A
bus.$on('user-login', (user) => {
    console.log('组件A: 用户登录', user.name);
});

// 组件 B
bus.$on('user-login', (user) => {
    console.log('组件B: 更新用户信息', user.id);
});

// 登录成功后
bus.$emit('user-login', { id: 1, name: 'Alice' });
6.2 实现带命名空间的事件系统
class NamespacedEventEmitter {
    constructor() {
        this._events = Object.create(null);
        this._separator = ':';
    }
    
    // 解析事件名,支持命名空间
    _parseEvent(eventString) {
        const parts = eventString.split(this._separator);
        if (parts.length === 1) {
            return { namespace: null, event: parts[0] };
        }
        return { namespace: parts[0], event: parts.slice(1).join(this._separator) };
    }
    
    // 生成完整的事件键
    _getEventKey(namespace, event) {
        return namespace ? `${namespace}${this._separator}${event}` : event;
    }
    
    on(eventString, listener) {
        const { namespace, event } = this._parseEvent(eventString);
        const eventKey = this._getEventKey(namespace, event);
        
        if (!this._events[eventKey]) {
            this._events[eventKey] = [];
        }
        
        this._events[eventKey].push({
            listener,
            namespace,
            event
        });
        
        return () => this.off(eventString, listener);
    }
    
    emit(eventString, ...args) {
        const { namespace, event } = this._parseEvent(eventString);
        
        // 收集所有匹配的监听器
        const listenersToCall = [];
        
        // 如果指定了命名空间,只触发该命名空间的事件
        if (namespace) {
            const eventKey = this._getEventKey(namespace, event);
            if (this._events[eventKey]) {
                listenersToCall.push(...this._events[eventKey]);
            }
        } else {
            // 如果没有指定命名空间,触发所有匹配的事件
            for (const eventKey in this._events) {
                const listeners = this._events[eventKey];
                for (const listenerInfo of listeners) {
                    if (listenerInfo.event === event) {
                        listenersToCall.push(listenerInfo);
                    }
                }
            }
        }
        
        // 执行监听器
        listenersToCall.forEach(({ listener }) => {
            try {
                listener(...args);
            } catch (error) {
                console.error(`Error in ${eventString}:`, error);
            }
        });
    }
    
    off(eventString, listener) {
        const { namespace, event } = this._parseEvent(eventString);
        
        if (namespace) {
            const eventKey = this._getEventKey(namespace, event);
            if (this._events[eventKey]) {
                const listeners = this._events[eventKey];
                const index = listeners.findIndex(item => item.listener === listener);
                if (index > -1) {
                    listeners.splice(index, 1);
                    if (listeners.length === 0) {
                        delete this._events[eventKey];
                    }
                }
            }
        } else {
            // 移除所有命名空间下的事件
            for (const eventKey in this._events) {
                const listeners = this._events[eventKey];
                for (let i = listeners.length - 1; i >= 0; i--) {
                    if (listeners[i].listener === listener && listeners[i].event === event) {
                        listeners.splice(i, 1);
                    }
                }
                if (listeners.length === 0) {
                    delete this._events[eventKey];
                }
            }
        }
    }
}

// 使用示例
const nsEmitter = new NamespacedEventEmitter();

nsEmitter.on('user:login', () => console.log('用户模块: 登录'));
nsEmitter.on('admin:login', () => console.log('管理员模块: 登录'));
nsEmitter.on('login', () => console.log('全局: 登录'));

nsEmitter.emit('user:login');    // 只输出: 用户模块: 登录
nsEmitter.emit('admin:login');   // 只输出: 管理员模块: 登录
nsEmitter.emit('login');         // 输出所有
6.3 实现支持异步监听器的 EventEmitter
class AsyncEventEmitter extends EventEmitter {
    /**
     * 异步触发事件,等待所有监听器完成
     */
    async emitAsync(eventName, ...args) {
        const listeners = this.listeners(eventName);
        if (listeners.length === 0) {
            return;
        }
        
        // 并行执行所有监听器
        const promises = listeners.map(async (listener) => {
            try {
                const result = listener(...args);
                // 如果监听器返回 Promise,等待它完成
                if (result && typeof result.then === 'function') {
                    await result;
                }
            } catch (error) {
                if (eventName !== 'error') {
                    await this.emitAsync('error', error);
                } else {
                    throw error;
                }
            }
        });
        
        await Promise.all(promises);
    }
    
    /**
     * 顺序执行监听器(一个接一个)
     */
    async emitSeries(eventName, ...args) {
        const listeners = this.listeners(eventName);
        
        for (const listener of listeners) {
            try {
                const result = listener(...args);
                // 如果监听器返回 Promise,等待它完成
                if (result && typeof result.then === 'function') {
                    await result;
                }
            } catch (error) {
                if (eventName !== 'error') {
                    await this.emitSeries('error', error);
                } else {
                    throw error;
                }
            }
        }
    }
}

// 使用示例
const asyncEmitter = new AsyncEventEmitter();

asyncEmitter.on('process', async (data) => {
    await new Promise(resolve => setTimeout(resolve, 100));
    console.log('处理完成:', data);
});

asyncEmitter.on('process', async (data) => {
    console.log('第二个监听器:', data);
});

asyncEmitter.emitAsync('process', '测试数据');
6.5 实现支持优先级的 EventEmitter
class PriorityEventEmitter {
    constructor() {
        this._events = Object.create(null);
        this._defaultPriority = 0;
    }
    
    on(eventName, listener, priority = this._defaultPriority) {
        if (!this._events[eventName]) {
            this._events[eventName] = [];
        }
        
        const listeners = this._events[eventName];
        listeners.push({ listener, priority });
        
        // 按优先级排序(数字越小优先级越高)
        listeners.sort((a, b) => a.priority - b.priority);
        
        return () => this.off(eventName, listener);
    }
    
    emit(eventName, ...args) {
        const listeners = this._events[eventName];
        if (!listeners) return;
        
        // 遍历已排序的监听器
        for (const { listener } of listeners) {
            try {
                const result = listener(...args);
                // 如果监听器返回 false,停止后续监听器的执行
                if (result === false) {
                    break;
                }
            } catch (error) {
                console.error(`Error in ${eventName}:`, error);
            }
        }
    }
    
    off(eventName, listener) {
        const listeners = this._events[eventName];
        if (!listeners) return this;
        
        const index = listeners.findIndex(item => item.listener === listener);
        if (index > -1) {
            listeners.splice(index, 1);
            if (listeners.length === 0) {
                delete this._events[eventName];
            }
        }
        
        return this;
    }
}

// 使用示例
const priorityEmitter = new PriorityEventEmitter();

priorityEmitter.on('process', () => console.log('优先级 10'), 10);
priorityEmitter.on('process', () => console.log('优先级 0'), 0);
priorityEmitter.on('process', () => console.log('优先级 5'), 5);

priorityEmitter.emit('process');
// 输出顺序: 优先级 0, 优先级 5, 优先级 10

七、实际应用场景

7.1 在 Vue 中实现组件通信
// 全局事件总线
const EventBus = new EventEmitter();

// 在 Vue 组件中使用
// ComponentA.vue
export default {
    mounted() {
        EventBus.on('user-updated', this.handleUserUpdate);
    },
    methods: {
        handleUserUpdate(user) {
            console.log('用户更新:', user);
            this.user = user;
        }
    },
    beforeDestroy() {
        EventBus.off('user-updated', this.handleUserUpdate);
    }
};

// ComponentB.vue
export default {
    methods: {
        updateUser() {
            EventBus.emit('user-updated', { id: 1, name: 'John' });
        }
    }
};

// 或者在 Vue 原型上添加
Vue.prototype.$bus = new EventEmitter();

// 在组件中使用
this.$bus.on('event', handler);
this.$bus.emit('event', data);
this.$bus.off('event', handler);
7.2 在 Express 中实现事件驱动架构
const express = require('express');
const EventEmitter = require('./EventEmitter');

class AppEvents extends EventEmitter {
    constructor() {
        super();
        this.setupEvents();
    }
    
    setupEvents() {
        // 定义应用级别事件
        this.on('user:registered', (user) => {
            console.log('新用户注册:', user.email);
            // 发送欢迎邮件
            // 创建用户目录
            // 更新统计
        });
        
        this.on('order:created', (order) => {
            console.log('新订单:', order.id);
            // 发送确认邮件
            // 更新库存
            // 通知物流
        });
        
        this.on('error', (error, context) => {
            console.error('应用错误:', error.message, context);
            // 发送错误报告
            // 记录到监控系统
        });
    }
}

// 创建 Express 应用
const appEvents = new AppEvents();
const app = express();

// 中间件:将事件发射器添加到请求对象
app.use((req, res, next) => {
    req.appEvents = appEvents;
    next();
});

// 路由处理
app.post('/register', (req, res) => {
    const user = createUser(req.body);
    
    // 触发事件
    req.appEvents.emit('user:registered', user);
    
    res.json({ success: true, user });
});

app.post('/order', (req, res) => {
    const order = createOrder(req.body);
    
    // 触发事件
    req.appEvents.emit('order:created', order);
    
    res.json({ success: true, order });
});

// 错误处理中间件
app.use((error, req, res, next) => {
    // 触发错误事件
    req.appEvents.emit('error', error, {
        url: req.url,
        method: req.method,
        userId: req.user?.id
    });
    
    res.status(500).json({ error: 'Internal server error' });
});
7.3 实现简单的状态管理
class ObservableStore {
    constructor(initialState = {}) {
        this._state = initialState;
        this._prevState = null;
        this._emitter = new EventEmitter();
    }
    
    // 获取当前状态
    getState() {
        return this._state;
    }
    
    // 设置状态
    setState(updates) {
        this._prevState = { ...this._state };
        this._state = { ...this._state, ...updates };
        
        // 触发状态变化事件
        this._emitter.emit('state:changed', this._state, this._prevState);
        
        // 触发特定属性的变化事件
        Object.keys(updates).forEach(key => {
            this._emitter.emit(`state:${key}:changed`, updates[key], this._prevState[key]);
        });
    }
    
    // 订阅状态变化
    subscribe(callback) {
        return this._emitter.on('state:changed', callback);
    }
    
    // 订阅特定状态变化
    subscribeTo(key, callback) {
        return this._emitter.on(`state:${key}:changed`, callback);
    }
    
    // 批量更新
    batchUpdate(updater) {
        const updates = updater(this._state);
        this.setState(updates);
    }
}

// 使用示例
const store = new ObservableStore({
    user: null,
    theme: 'light',
    notifications: []
});

// 订阅状态变化
store.subscribe((newState, oldState) => {
    console.log('状态变化:', newState);
});

// 订阅特定状态变化
store.subscribeTo('theme', (newTheme, oldTheme) => {
    console.log('主题变化:', oldTheme, '->', newTheme);
    document.body.setAttribute('data-theme', newTheme);
});

// 更新状态
store.setState({ theme: 'dark' });
store.setState({ user: { id: 1, name: 'Alice' } });

// 批量更新
store.batchUpdate(state => ({
    notifications: [...state.notifications, '新消息'],
    theme: 'dark'
}));

八、性能优化和注意事项

8.1 内存泄漏预防
// 安全的 EventEmitter,自动清理订阅
class SafeEventEmitter extends EventEmitter {
    constructor() {
        super();
        // 使用 WeakRef 跟踪组件引用
        this._componentRefs = new WeakMap();
    }
    
    // 为组件绑定事件,自动清理
    bindToComponent(component, eventName, listener) {
        // 创建绑定函数
        const boundListener = listener.bind(component);
        
        // 添加监听器
        this.on(eventName, boundListener);
        
        // 存储引用
        if (!this._componentRefs.has(component)) {
            this._componentRefs.set(component, []);
        }
        this._componentRefs.get(component).push({ eventName, listener: boundListener });
        
        // 返回清理函数
        return () => {
            this.off(eventName, boundListener);
            const refs = this._componentRefs.get(component);
            if (refs) {
                const index = refs.findIndex(ref => 
                    ref.eventName === eventName && ref.listener === boundListener
                );
                if (index > -1) {
                    refs.splice(index, 1);
                }
            }
        };
    }
    
    // 清理组件的所有事件
    cleanupComponent(component) {
        const refs = this._componentRefs.get(component);
        if (refs) {
            refs.forEach(({ eventName, listener }) => {
                this.off(eventName, listener);
            });
            this._componentRefs.delete(component);
        }
    }
}

// 使用示例
class Component {
    constructor(emitter) {
        this.emitter = emitter;
        this._cleanupFns = [];
    }
    
    setupEvents() {
        // 绑定事件,自动管理生命周期
        const cleanup1 = this.emitter.bindToComponent(
            this,
            'data',
            this.handleData.bind(this)
        );
        this._cleanupFns.push(cleanup1);
        
        const cleanup2 = this.emitter.bindToComponent(
            this,
            'error',
            this.handleError.bind(this)
        );
        this._cleanupFns.push(cleanup2);
    }
    
    handleData(data) {
        console.log('处理数据:', data);
    }
    
    handleError(error) {
        console.error('处理错误:', error);
    }
    
    destroy() {
        // 清理所有事件
        this._cleanupFns.forEach(fn => fn());
        this._cleanupFns = [];
        
        // 或者使用自动清理
        this.emitter.cleanupComponent(this);
    }
}
8.2 性能优化技巧
// 高性能 EventEmitter
class HighPerformanceEventEmitter {
    constructor() {
        // 使用空对象作为原型,避免原型链查找
        this._events = Object.create(null);
        // 缓存空数组,避免频繁创建
        this._emptyArray = Object.freeze([]);
    }
    
    on(eventName, listener) {
        if (!this._events[eventName]) {
            // 预分配数组空间
            this._events[eventName] = [];
        }
        
        this._events[eventName].push(listener);
        return this;
    }
    
    emit(eventName, ...args) {
        // 快速路径:没有监听器
        const listeners = this._events[eventName];
        if (!listeners) return false;
        
        // 使用 for 循环而不是 forEach,性能更好
        for (let i = 0, len = listeners.length; i < len; i++) {
            try {
                listeners[i].apply(this, args);
            } catch (error) {
                // 错误处理
                if (eventName !== 'error') {
                    const errorListeners = this._events.error;
                    if (errorListeners) {
                        // 避免递归调用
                        for (let j = 0; j < errorListeners.length; j++) {
                            try {
                                errorListeners[j].call(this, error);
                            } catch (e) {
                                // 忽略错误处理函数中的错误
                            }
                        }
                    }
                }
            }
        }
        
        return true;
    }
    
    off(eventName, listener) {
        const listeners = this._events[eventName];
        if (!listeners) return this;
        
        // 从后向前遍历,避免数组移动
        for (let i = listeners.length - 1; i >= 0; i--) {
            if (listeners[i] === listener) {
                listeners.splice(i, 1);
                break;
            }
        }
        
        // 如果没有监听器了,删除属性(让 V8 优化)
        if (listeners.length === 0) {
            delete this._events[eventName];
        }
        
        return this;
    }
    
    // 批量操作优化
    emitMany(eventNames, ...args) {
        const results = [];
        for (const eventName of eventNames) {
            results.push(this.emit(eventName, ...args));
        }
        return results;
    }
}
8,3 调试和监控
// 可监控的 EventEmitter
class MonitoredEventEmitter extends EventEmitter {
    constructor(options = {}) {
        super();
        this._monitoring = {
            enabled: options.enabled !== false,
            emitCount: 0,
            listenerCount: 0,
            eventStats: new Map(),
            errorStats: new Map(),
            slowListeners: []
        };
        
        // 性能监控阈值(毫秒)
        this._slowThreshold = options.slowThreshold || 100;
    }
    
    // 重写 emit 方法以收集监控数据
    emit(eventName, ...args) {
        if (!this._monitoring.enabled) {
            return super.emit(eventName, ...args);
        }
        
        this._monitoring.emitCount++;
        
        // 更新事件统计
        const eventStat = this._monitoring.eventStats.get(eventName) || {
            count: 0,
            lastEmitted: null,
            avgDuration: 0,
            maxDuration: 0
        };
        
        eventStat.count++;
        eventStat.lastEmitted = new Date();
        
        const startTime = performance.now();
        const result = super.emit(eventName, ...args);
        const duration = performance.now() - startTime;
        
        // 更新性能统计
        eventStat.avgDuration = 
            (eventStat.avgDuration * (eventStat.count - 1) + duration) / eventStat.count;
        eventStat.maxDuration = Math.max(eventStat.maxDuration, duration);
        
        this._monitoring.eventStats.set(eventName, eventStat);
        
        // 记录慢监听器
        if (duration > this._slowThreshold) {
            this._monitoring.slowListeners.push({
                eventName,
                duration,
                timestamp: new Date(),
                args: args.slice(0, 3) // 只记录前三个参数
            });
            
            // 保持慢监听器记录的数量
            if (this._monitoring.slowListeners.length > 100) {
                this._monitoring.slowListeners.shift();
            }
        }
        
        return result;
    }
    
    // 获取监控数据
    getMonitoringData() {
        return {
            ...this._monitoring,
            currentListeners: this._getListenersCount(),
            eventNames: this.eventNames(),
            timestamp: new Date()
        };
    }
    
    // 重置监控数据
    resetMonitoring() {
        this._monitoring = {
            enabled: true,
            emitCount: 0,
            listenerCount: 0,
            eventStats: new Map(),
            errorStats: new Map(),
            slowListeners: []
        };
    }
    
    // 生成监控报告
    generateReport() {
        const data = this.getMonitoringData();
        
        console.log('=== EventEmitter 监控报告 ===');
        console.log(`运行时间: ${data.timestamp.toISOString()}`);
        console.log(`总触发次数: ${data.emitCount}`);
        console.log(`活跃事件数量: ${data.eventNames.length}`);
        
        console.log('\n事件统计:');
        for (const [eventName, stat] of data.eventStats) {
            console.log(`  ${eventName}:`);
            console.log(`    触发次数: ${stat.count}`);
            console.log(`    平均耗时: ${stat.avgDuration.toFixed(2)}ms`);
            console.log(`    最大耗时: ${stat.maxDuration.toFixed(2)}ms`);
            console.log(`    最后触发: ${stat.lastEmitted.toISOString()}`);
        }
        
        if (data.slowListeners.length > 0) {
            console.log('\n慢监听器警告:');
            data.slowListeners.forEach((item, index) => {
                console.log(`  ${index + 1}. ${item.eventName} - ${item.duration.toFixed(2)}ms`);
            });
        }
        
        return data;
    }
    
    // 私有方法:获取监听器计数
    _getListenersCount() {
        const result = {};
        for (const eventName in this._events) {
            result[eventName] = this._events[eventName].length;
        }
        return result;
    }
}

九、总结与最佳实践

9.1 核心要点总结
  1. 发布订阅模式的核心: 通过事件中心解耦发布者和订阅者
  2. EventEmitter的实现要点:
  • 使用合适的数据结构存储事件和监听器
  • 正确处理 once 监听器
  • 实现错误处理机制
  • 支持链式调用
  1. 内存管理: 及时清理监听器, 避免内存泄漏
  2. 性能考虑: 选择合适的数据结构和算法
9.2 最佳实现
  1. 命名规范:
// 好的命名
emitter.on('user:login', handler);
emitter.on('order:created', handler);

// 不好的命名
emitter.on('login', handler);
emitter.on('newOrder', handler);
  1. 错误处理:
// 总是监听 error 事件
emitter.on('error', (error) => {
    console.error('EventEmitter error:', error);
    // 发送到错误监控系统
});

// 或者在监听器内部处理错误
emitter.on('data', (data) => {
    try {
        processData(data);
    } catch (error) {
        console.error('处理数据时出错:', error);
        emitter.emit('error', error);
    }
});
  1. 资源清理:
class Component {
    constructor() {
        this._cleanupFns = [];
    }
    
    setupEvents() {
        const cleanup1 = emitter.on('event1', this.handler1.bind(this));
        this._cleanupFns.push(cleanup1);
        
        const cleanup2 = emitter.once('event2', this.handler2.bind(this));
        this._cleanupFns.push(cleanup2);
    }
    
    destroy() {
        // 清理所有事件监听器
        this._cleanupFns.forEach(fn => fn());
        this._cleanupFns = [];
    }
}
9.3 使用建议
场景 推荐方案 理由
简单组件通信 基础 EventEmitter 轻量、简单
大型应用状态管理 Observable Store 结构化、可预测
异步任务协调 AsyncEventEmitter 更好的异步支持
性能敏感场景 HighPerformanceEventEmitter 优化过的实现
需要监控调试 MonitoredEventEmitter 内置监控功能

结语

通过手写 EventEmitter,我们不仅掌握了发布订阅模式的实现原理,更重要的是理解了事件驱动编程的核心思想。EventEmitter 虽然简单,但其设计思想在现代前端框架、Node.js 后端系统以及各种复杂应用中都有广泛的应用。

记住,好的事件系统应该:

  • ✅ 职责清晰:事件中心只负责转发消息

  • ✅ 性能优秀:高频事件触发时表现良好

  • ✅ 易于调试:有良好的监控和错误处理

  • ✅ 内存安全:避免内存泄漏和资源浪费


延伸阅读:

希望这篇经过严格测试的博客能帮助你深入理解 EventEmitter!如果有任何问题或建议,欢迎讨论交流。

昨天 — 2025年12月7日技术

HTML5 与 JavaScript 中的二进制数据处理:ArrayBuffer 与 TextEncoder/Decoder 实践

作者 冻梨政哥
2025年12月7日 21:55

HTML5 与 JavaScript 中的二进制数据处理:ArrayBuffer 与 TextEncoder/Decoder 实践

在 Web 开发的演进过程中,HTML5 为 JavaScript 带来了强大的二进制数据处理能力,这使得前端能够更高效地处理文件、网络数据等二进制内容。其中,ArrayBuffer 作为核心的二进制数据缓冲区,结合 TextEncoder 和 TextDecoder 实现的字符串与二进制数据的编码解码,成为前端二进制处理的基础组合。本文将通过实际代码案例,深入解析这一技术组合的应用逻辑与实践要点。

字符串的二进制编码:TextEncoder 的应用

TextEncoder 是 HTML5 引入的 API,其主要功能是将字符串转换为 UTF-8 编码的二进制数据(Uint8Array 类型)。在代码中,首先实例化 TextEncoder 对象,随后调用encode()方法并传入目标字符串 “你好 HTML5”,即可得到对应的二进制数组:

const encoder = new TextEncoder();
const myBuffer = encoder.encode("你好 HTML5");

这一过程实现了字符串到二进制数据的转换,其中中文字符 “你”“好” 在 UTF-8 编码下各占 3 个字节,英文字符和空格各占 1 个字节,因此 “你好 HTML5” 最终被编码为 12 个字节的 Uint8Array 数组(可通过myBuffer.length验证)。

ArrayBuffer:二进制数据的缓冲区基石

ArrayBuffer 是一个用于存储原始二进制数据的固定长度缓冲区,它本身无法直接操作数据,需要通过 “视图”(如 Uint8Array、Float32Array 等类型化数组)来读写内容。代码中创建了一个长度为 12 字节的 ArrayBuffer:

const buffer = new ArrayBuffer(12);

随后通过 Uint8Array 视图对该缓冲区进行操作,Uint8Array 以 8 位无符号整数的形式访问缓冲区数据,这是最常用的二进制数据操作视图之一。通过循环将myBuffer中的二进制数据逐一复制到 Uint8Array 视图中,实现了对 ArrayBuffer 缓冲区的填充:

const view = new Uint8Array(buffer);
for(let i=0;i<myBuffer.length;i++){
    view[i]=myBuffer[i];
}

二进制数据的字符串解码:TextDecoder 的作用

TextDecoder 作为 TextEncoder 的互补 API,负责将二进制数据(ArrayBuffer 或类型化数组)解码为字符串。代码中通过实例化 TextDecoder 并调用decode()方法,将 ArrayBuffer 缓冲区中的数据解码为原始字符串:

const decoder = new TextDecoder();
const originalText = decoder.decode(buffer);

由于缓冲区中完整存储了编码后的二进制数据,因此解码后可准确还原出字符串 “你好 HTML5”,这验证了编码 - 解码流程的完整性与正确性。

数据展示与验证:前端页面的交互呈现

为了直观展示二进制数据处理的结果,代码通过 DOM 操作将关键数据渲染到页面中:

  • 展示 Uint8Array 视图的完整数据,可直观看到每个字节的数值;
  • 输出第一个字节的数值(对应 “你” 字 UTF-8 编码的第一个字节);
  • 显示 ArrayBuffer 缓冲区的总字节长度,验证缓冲区大小设置;
  • 呈现解码后的原始文本,确认数据处理的准确性。

技术应用场景与价值

这种二进制数据处理模式在前端开发中具有广泛的应用场景:

  • 文件处理:在 File API 中,可通过 ArrayBuffer 读取文件的二进制内容,结合编码解码实现文本文件的读写与编码转换;
  • 网络通信:在 WebSocket 或 Fetch API 中,处理二进制格式的网络数据(如 ArrayBuffer 格式的响应数据),提升数据传输与解析效率;
  • 本地存储:IndexedDB 支持存储 ArrayBuffer 类型的数据,可将二进制数据或编码后的字符串存入本地数据库,实现高效的数据持久化。

总结

HTML5 引入的 ArrayBuffer、TextEncoder 和 TextDecoder 为 JavaScript 提供了标准化的二进制数据处理能力,使得前端能够摆脱对字符串的单一依赖,高效处理二进制数据。通过本文的实践案例,不仅理解了核心 API 的使用逻辑,也掌握了字符串与二进制数据之间的编码解码流程,这为前端处理复杂数据格式、优化性能提供了重要的技术支撑。在实际开发中,合理运用这些 API,可显著提升前端应用处理二进制数据的能力与灵活性。

前端跨页面通讯终极指南⑤:window.name 用法全解析

2025年12月7日 20:20

前言

在之前的文章里,介绍了 BroadcastChannel 的广播、postMessage 的灵活以及 MessageChannel 的精准。这些现代 API 为我们提供了标准的通信能力。

今天我们要介绍下——window.name。它不是一个为通信而生的 API,但是有其独特的“跨页面持久性”特性,成为解决跨域数据传输问题的方案之一。

1. window.name是什么?

window.name 是一个极其简单的属性,它的原始设计目的是用来设置或获取窗口(浏览器标签页)的名称。

// 设置窗口名称
window.name = 'My Awesome Window';

// 获取窗口名称
console.log(window.name); // 'My Awesome Window'

它有一个重要的特性:

只要是在同一个浏览器标签页中,即使页面发生了跳转(从一个域名到另一个域名),window.name 的值也不会被重置,它会一直保留。 更惊人的是,它的容量非常大,通常可以达到 2MB 左右!

2. window.name 的作用域

window.name 属性是属于每一个独立的窗口上下文(Window Context的。

  1. 父页面是一个窗口上下文,它有自己的 window.name
  2. iframe 子页面是另一个完全独立的窗口上下文,它也有自己window.name

它们是两个完全不同的变量,互不影响。当你在子页面中直接调用 window.name 时,你访问的是子页面自己name,而不是父页面的。

2.1 如何正确访问父页面的 window.name

虽然不能直接读取,但 iframe 提供了访问父窗口的路径:window.parent,可以通过iframe.contentWindow.name设置子页面的name。

关键点: iframe 的刷新只会重置它自己window.name,对父页面的 window.name 毫无影响。并且如果需要设置子页面的name,必须是同源。

只能读取同源子页面的name,否则会报错:

image.png

3. 实战案例

通过一个简单的例子来说明,具体看代码:

  1. 父页面 (parent.html)
<!DOCTYPE html>
<html lang="en">
<head>
    <title>父页面</title>
</head>
<body>
    <h1>这是父页面</h1>
    <p>父页面的 window.name: <strong id="parentName"></strong></p>

    <hr>
    <iframe src="child.html" id="myIframe" style="width: 100%; height: 300px; border: 1px solid black;"></iframe>

    <script>
        // 1. 设置父页面的 name
        window.name = "我是父页面的秘密数据";
        document.getElementById('parentName').textContent = window.name;

        console.log('父页面: 已设置 window.name =', window.name);
    </script>
</body>
</html>
  1. 子页面 (child.html)
<!DOCTYPE html>
<html lang="en">
<head>
    <title>子页面</title>
</head>
<body>
    <h2>这是 iframe 子页面</h2>
    <button onclick="location.reload()">刷新本页面</button>
    <hr>
    <p><strong>子页面自己的 window.name:</strong> <span id="childName"></span></p>
    <p><strong>通过 window.parent.name 访问父页面:</strong> <span id="parentNameFromChild"></span></p>

    <script>
        function updateDisplay() {
            // 读取子页面自己的 name
            document.getElementById('childName').textContent = window.name || '(空)';

            // 通过 window.parent.name 访问父页面的 name
            try {
                const parentName = window.parent.name;
                document.getElementById('parentNameFromChild').textContent = parentName;
            } catch (e) {
                document.getElementById('parentNameFromChild').textContent = '访问失败 (跨域?)';
            }
        }
        updateDisplay();

        // 为了演示,我们也可以设置一下子页面自己的 name
        window.name = "我是子页面自己的数据";
    </script>
</body>
</html>

子页面会显示:子页面自己的 window.name: 我是子页面自己的数据,子页面会显示:通过 window.parent.name 访问父页面: 我是父页面的秘密数据点击“刷新本页面”按钮后:子页面会重新加载,其自己window.name 会被重置为空字符串。但是,window.parent.name 的值依然是 我是父页面的秘密数据,完全不受影响。

4. 总结

最后总结一下:window.name作为浏览器的一个老古董方案,简单了解介绍下,如果需要通讯,还是推荐postMessage等通讯方式。

前端跨页面通讯终极指南④:MessageChannel 用法全解析

2025年12月7日 20:18

前言

上一篇介绍了Localstorage跨页面通讯的方式。在前面的文章中,介绍了多种跨页面通信方式,从适用于同源页面的 BroadcastChannel,到解决跨域的 postMessage。当多个通信进行混杂在一起,使用全局的message事件监听时,会通过各种类型判断消息来源进行处理。

那有没有一种方法,既能实现跨上下文通信,又能像打电话一样,建立起一条专属的、双向的、点对点的私密通道呢?

今天介绍一个方案——MessageChannel API。提供了一种更为优雅和私密的方式,建立起一条点对点的“专线电话”,让通信双方可以清晰地、无干扰地对话。

1. MessageChannel 是什么?

Channel Messaging API 的 MessageChannel 接口允许我们创建一个新的消息通道,并通过它的两个 MessagePort 属性发送数据。

核心是创建一个双向通讯的管道,这个管道包含两个相互关联的端口——port1port2。数据从 port1 发送,就只能由 port2 接收;反之,port2 发送的数据也只能被 port1 捕获,这种“点对点”的通讯模式,从根源上避免了数据被无关页面拦截的风险。

举个通俗的例子: BroadcastChannel 是“小区广播”,所有人都能听到;而 MessageChannel 就是“专线电话”,只有两个端口对应的设备能接通。

2. 如何使用

使用 MessageChannel 流程如下:

  1. 创建通道: 创建一个 MessageChannel 实例,包含两个端口属性:port1port2
const channel = new MessageChannel();

// channel.port1 和 channel.port2 都是 MessagePort 对象

image.png

  1. 监听消息: 在其中一个端口上设置 onmessage 事件处理器,用于接收来自另一个端口的消息。
channel.port1.onmessage = (event) => {
    console.log('收到消息:', event.data);
};
  1. 发送消息: 通过一个端口的 postMessage方法向另一个端口发送数据。
channel.port2.postMessage('Hello from port2!');
  1. 转移端口所有权MessageChannel 的威力在于可以将一个端口发送到另一个浏览上下文(例如 iframe)。这需要通过 window.postMessage 的第三个参数 transfer 来实现, 可以直接将端口下发到两个子iframe,直接进行通讯。
// 假设 iframe 是我们想要通信的目标
const iframe = document.querySelector('iframe').contentWindow;

// 将 port2 的所有权转移给 iframe
// 转移后,当前页面就不再拥有 port2,只有 iframe 能使用它

iframe.postMessage('init', '*', [channel.port2]);

使用postMessage发送端口会出现ports:

image.png

接收方(iframe)可以在其 message 事件中获取到这个端口,开始通信。

3. 实践场景

下面使用MessageChannel进行父子双向、兄弟通讯进行说明。

3.1 父子双向通讯

父页面引入一个 iframe,创建MessageChannel,初始化时将其中一个port1使用postMessage传递,后面直接通过port进行双向通讯。

步骤1:父页面(发送端口+通讯逻辑)

// 1. 创建 MessageChannel 实例,生成两个端口
const channel = new MessageChannel();
const { port1, port2 } = channel;

// 2. 获取 iframe 元素,监听加载完成事件
const iframe = document.getElementById('myIframe');
iframe.onload = () => {
  // 3. 向 iframe 传递 port2(关键:只有传递端口后才能通讯)
  iframe.contentWindow.postMessage('init', '*', [port2]);
};

// 4. 监听 port1 接收的消息(来自 iframe)
port1.onmessage = (e) => {
  console.log('父页面收到 iframe 消息:', e.data);
  // 收到消息后回复
  if (e.data === 'hello from iframe') {
    port1.postMessage('hi iframe, I am parent');
  }
};

// 5. 可选:监听错误事件
port1.onerror = (error) => {
  console.error('通讯错误:', error);
  port1.close(); // 出错后关闭端口
};

步骤2:iframe 页面(接收端口+响应逻辑)

// 1. 监听父页面发送的初始化消息
window.onmessage = (e) => {
  // 2. 验证消息类型,获取传递的 port2
  if (e.data === 'init' && e.ports.length) {
    const port2 = e.ports[0];

    // 3. 监听 port2 接收的消息(来自父页面)
    port2.onmessage = (msg) => {
      console.log('iframe 收到父页面消息:', msg.data);
    };

    // 4. 向父页面发送消息
    port2.postMessage('hello from iframe');
  }
};

需要注意的是:

  • 父页面通过 postMessage 传递 port2 时,必须将 port2 放在第三个参数(transferList)中,这是 “端口传递”的固定写法;
  • 端口一旦传递,父页面的 port2 就会失效,只能通过 iframe 中的 port2 通讯。

3.2 兄弟通讯

实现思路是: 父页面作为“总机”,负责创建 MessageChannel,并将两个端口分别分配给两个 iframe,让它们之间建立起直连专线。

步骤1:父页面代码

const channel = new MessageChannel();
const frame1 = document.getElementById('frame1').contentWindow;
const frame2 = document.getElementById('frame2').contentWindow;
// 等待两个 iframe 加载完成
let loadCount = 0;
const onLoad = () => {
  loadCount++;
  if (loadCount === 2) {
    // 将 port1 发送给 iframe1
    frame1.postMessage('init', '*', [channel.port1]);
    // 将 port2 发送给 iframe2
    frame2.postMessage('init', '*', [channel.port2]);
    console.log('父页面:专线已建立,端口已分发。');
  }
};
document.getElementById('frame1').onload = onLoad;
document.getElementById('frame2').onload = onLoad;

步骤2:子页面接收port


let port;
// 1. 接口端口
window.addEventListener('message', (event) => {
  // 确认是父页面发来的初始化消息,并接收端口
  if (event.data === 'init') {
    port = event.ports[0];
    console.log('iframe1:已接收 port1,准备发送消息。');
  }
});
// 2. 发送消息
document.getElementById('sendBtn').onclick = () => {
  if (port) {
    const message = `来自 iframe1 的问候,时间:${new Date().toLocaleTimeString()}`;
    port.postMessage(message);
    console.log('iframe1:消息已发送 ->', message);
  }
};
// 3. 监听 port 消息
port.onmessage = (e) => {
  console.log('页面B收到消息:', e.data);
};

实际效果:

image.pngimage.png

4. 注意事项

  1. 端口传递必须用 transferList

传递端口时,必须将 port 放在 postMessage 的第三个参数(transferList)中,而不是作为第一个参数(data)。错误写法会导致端口无法正常绑定,通讯失效。

  1. 通讯完成后及时关闭端口

不需要通讯时,调用 port.close() 关闭端口,避免内存泄漏。

5. 总结

最后总结一下:MessageChannel 通过创建一个专属的双向通道,解决了点对点通信的需求,唯一不足的是无论是父子还是兄弟通讯都是需要使用postMessage进行传递端口。

聊聊前端容易翻车的“环境管理”

2025年12月7日 19:56

想获取更多2025年最新前端场景题可以看这里fe.ecool.fun

大家好,我是刘布斯。

周末在家整理以前的老硬盘,翻到了 10 年前做的一个外包项目源码。打开一看,入口文件里赫然写着一行被注释掉的代码:

// var API_HOST = "http://localhost:8080"; var API_HOST = "http://192.168.1.100:8080"; // 只有内网能访问

看到这行代码,那种被“发布上线后页面一片白屏”支配的恐惧瞬间攻击了我。

入行十几年,前端工程化变了好几轮,但“环境管理”这个问题,反而是很多团队(甚至大厂内部的小组)最容易翻车的地方。今天不讲什么高大上的微前端或 Serverless,单纯聊聊在 Dev、Test、UAT、Prod 这一堆环境里打滚摸出来的路子。

最开始的“硬编码”时代

最早那会,根本没有 process.env

那时候发版简直是玄学。周五晚上要上线,整个流程大概是这样的:我在本地把 API 地址改成线上的,Ctrl+S 保存,然后用 FTP 传到服务器。

最要命的不是忘了改地址,而是改错了没改回来

我有次在生产环境调试一个 bug,顺手把 URL 改成了测试环境的模拟接口,验证完觉得没问题,就直接关机下班……结果第二天客服群炸了,老板当时看我的眼神,感觉已经在琢磨开除我对项目有没有影响了。

这个阶段的痛苦在于:代码和配置不分家。代码逻辑是同一套,但因为环境不同,竟然需要每次手动去改源码。这本身就是个巨大的风险源。

后来有了 Webpack 和 DefinePlugin

后来构建工具起来了,Webpack 简直是救世主。

大家开始习惯用 DefinePlugin,或者后来的 dotenv。在根目录下建几个文件:.env.development.env.production

// webpack.config.js
new webpack.DefinePlugin({
  'process.env.API_URL': JSON.stringify('https://api.prod.com')
})

这时候最常见的一个坑是:以为写了 process.env 就万事大吉了

记得有次带新人,小伙子把阿里云的 OSS AccessKeySecret 直接写到了 .env 文件里,并且还被 Webpack 打包进了 bundle.js。

他觉得 .env 在服务器上,很安全。但他忘了前端代码是跑在用户浏览器里的。我随手打开 Chrome 控制台,切到 Network 面板,搜一下源码,那个 Key 就赤裸裸地躺在那儿。

从那以后,我在 Code Review 里加了一条铁律:凡是打进前端包里的变量,默认就是公开的。 涉及私密的配置,一律要在 Nginx 层或者 BFF 层(Node.js 中间件)处理,绝对不能进 Webpack。

Docker 时代的“一次构建,到处运行”

这几年容器化成了标配,问题又升级了。

用 .env 文件最大的问题是:配置是构建时(Build-time)注入的。

测试那边的老哥不止一次跟我抱怨:“你们前端真麻烦,我这只是想把测试环境的镜像推到预发环境验证一下,结果因为 API 地址变了,我还得重新跑一遍 npm run build?这镜像还能叫不可变交付吗?”

这确实是个硬伤。理想的 Docker 流程是:镜像 build 出来后,这是一个死的包。我在测试环境跑它,它连测试库;我在生产环境跑它,它连生产库。镜像本身不应该变,变的是启动容器时传进去的环境变量。

如果用 Webpack 把 API 地址写死在 JS 里,这镜像就废了,只能在那一个环境用。

终极方案:运行时注入

为了解决这个问题,也为了少被测试大佬的抱怨,我们现在的很多项目都切到了运行时注入的方案。

原理其实也很简单,就是“回光返照”到了 jQuery 时代:把配置挂在 window 上

核心逻辑就这两步:

  1. 代码里不读 process.env: 前端代码里所有需要区分环境的地方,全部改成读取 window.__APP_CONFIG__.API_URL

  2. HTML 模板里留个坑: 在 index.html 的 <head> 里,放一个空的 script 标签,或者特殊的占位符。

  3. 容器启动时填坑: 这是最关键的一步。容器启动的时候(或者 Nginx 启动时),通过写一个简单的 Shell 脚本,去读取机器的环境变量,然后生成一个 config.js 文件,内容就是:

    window.__APP_CONFIG__ = {
      API_URL: "https://api.real-prod.com",
      THEME_COLOR: "blue"
    };
    

    然后把这个文件塞进 Nginx 的静态资源目录里。

这样一来,前端打出来的包是完全干净的,不带任何环境信息。镜像推到哪里,只要那个环境的 Docker 启动参数配对了,页面就能正常跑。

# docker-compose 示例
environment:
  - API_URL=https://api.staging.com

这个方案不仅解决了“一次构建”的问题,还有一个隐藏的好处:回滚极快

以前发版如果配置错了,要重新打包发布,起码 10 分钟。现在只要改一下容器的环境变量重启一下,30 秒搞定。对于那种高压力的线上故障修复,这几分钟就是命。

几个小 Tips

最后再唠叨几个细节,都是踩坑踩出来的:

  • 不要信任 .gitignore:总有人会手抖把 .env.local 提交上去。我们在 CI/CD 流程里加了扫描,一旦发现这就没法 merge。

  • 版本号要在控制台打印出来:每次打包,我会把当前的 git commit hash 和打包时间注入到 window 对象里,并在 console 里打印出来。

  • 以前测试提 bug,我问“是最新版吗?”,对方说“是”。结果查半天是缓存。

  • 现在我让他们截图控制台,我看一眼 hash 也就知道是不是最新版,省了太多扯皮时间。

  • Feature Flag 也可以用环境配置:不要傻傻地用注释代码的方式来开关功能,而是直接把它做成配置项。万一上线后这功能有 bug,改个配置就能关掉,不用重新发版,这在在大促期间简直是保命符。

环境管理看着不起眼,也没什么高深的算法,但它决定了一个团队开发的“下限”。

下限越稳,大家才越敢在上面折腾新东西。毕竟,谁也不想半夜三点爬起来因为少改了一个 URL 而回滚代码,对吧?

如果你觉得现在的项目构建太慢,或者经常因为环境配置问题和各方大佬扯皮,可以去检查一下构建脚本,是不是还在针对每个环境单独打包?

如果是,试着把那部分配置抽离出来,哪怕先不用 Docker,先试着写一个 config.js 加载一下,你会发现世界瞬间清静了很多。

梳理SPA项目Router原理和运行机制 [共2500字-阅读时长10min]

2025年12月7日 17:59

背景

SPA单页面应用实际是只有一个HTML文件,路由的切换都是通过JS动态切换组件的显示与隐藏,MPA应用拥有多个HTML文件。

Vue-Router和React-Router的原理类似,本文通过React-Router举例

路由模式分类

  1. history模式
  1. hash模式

前置知识history

History提供了浏览器会话历史的管理能力。通过history对象,开发者可以:

  1. 通过使用go, backforward在会话历史中导航,目标会话历史条目如果是通过传统方式跳转的,如直接修改window.location.href则会刷新页面。如果是通过pushStatereplaceState修改的则不会修改刷新页面。
  1. 通过使用pushStatereplaceState添加和替换当前的会话历史,不会刷新页面。但是新url和旧url必须是同源的。

pushStatereplaceState是HTML5新特性。

API 定义
history.pushState(data, title [, url]) pushState主要用于往历史记录堆栈顶部添加一条记录。各参数解析如下:①data会在onpopstate事件触发时作为参数传递过去;②title为页面标题,当前所有浏览器都会忽略此参数;③url为页面地址,可选,缺少时表示为当前页地址
history.replaceState(data, title [, url]) 更改当前的历史记录,参数同上; 上面的pushState是添加,这个更改
history.state 用于存储以上方法的data数据,不同浏览器的读写权限不一样
window.onpopstate 修改当前会话历史条目时候,都会触发这个回调函数

注意⚠️的是:用history.pushState() 或者 history.replaceState() 不会触发popstate事件。(区分出监听路由和改变路由的区别)

hash模式原理

一句话总结:监听url中hash的变化,来做页面组件的显示与隐藏。特点是在url中有#美观程度低。

关于hash需要知道的三点是:

  1. url中的hash变化不会导致页面重新加载。
  1. url中的hash变化会被window.history记录下来,当使用浏览器的页面的前进后退功能,是可以看到url中hash的变化的。
  1. 当通过url向服务端请求资源的时候,url中hash是不会传递给服务端的。hash路由模式是纯前端实现的路由跳转。

改变路由

通过window.location.hash可以获取到urlhash值,对应着项目的页面路径。

监听路由

通过hashchange事件监听urlhash的变化,也就是项目页面路径的变化。

window.addEventListener("hashchange",callback)

history模式原理

一句话总结:利用HTML5推出的pushStatereplaceState改变url路径,而不会触发页面的刷新,通过监听popState事件来实现项目的页面组件切换。

改变路由

1.pushState:向会话历史中压入一个新的会话历史记录,相当于入栈。

2.replaceState:更改当前的会话记录历史 ,相当于更新栈顶元素。

监听路由

popState事件 这个名字起的不好,他是一个监听事件,当会话历史记录发生变化就会回调出发(前进和后退都会触发),单看名字好像是一个可以主动调用的方法,极具迷惑性。

window.addEventListener("popstate",callback);

改变路由和监听路由的作用

  1. 改变路由,开发者通过React-Router提供的组件或者API主动的进行页面切换时使用,强调的是开发者主动。
  1. 监听路由,当用户使用手动修改浏览器地址栏中URL,手动点击浏览器的前进后退按钮的方式改变    URL的时候,项目需要监听到这些URL路径改变的操作,从而做出响应,强调的是外部变化,被动响应 。

React-Router基础的路由结构

<Router>

  <Switch>

    <Route exact path="/" component={Home} />

    <Route path="/about" component={About} />

    <Redirect from="/old" to="/new" />

    <Route path="/new" component={NewPage} />

    <Route component={NotFound} />

  </Switch>

</Router>

Router组件

// 在 Router 组件内部:

componentDidMount() {

  // 1. 监听 history 变化

  this.unlisten = props.history.listen(location => {

    // 2. URL 变了!更新状态

    this.setState({ location: newLocation });

  });

}

Router组件的主要作用:

  1. 根据选择的路由模式,添加改变路由的监听事件hash采用hashchangehistory模式采用popState事件。
  1. 将变化后的location通过Context传递给子组件

总结:监听路径的变化,然后将变化的location传递给子组件,相当于做了一层【事件委托】,避免子组件都要进行监听。

Switch组件

// Switch 内部逻辑:

// 1. 从 Router 获取最新的 location

const location = context.location; // { pathname: '/about' }



// 2. 按顺序检查每个子组件

React.Children.forEach(children, child => {

  // 检查顺序:

  // 1. <Route exact path="/" />       ❌ 不匹配(不是 exact /)

  // 2. <Route path="/about" />        ✅ 匹配!停止检查

  // 3. <Redirect from="/old" to="/new" />  不检查

  // 4. <Route path="/new" />         不检查

  // 5. <Route component={NotFound} />不检查

});

Switch组件的主要作用是:

  1. 所有的Route组件都要直接放在Switch组件的children
  1. 遍历所有的Route组件,根据从Router组件接收到的新location信息,和Route组件配置的path进行匹配,找到当前URL对应的Route组件

总结:根据当前的URL找到匹配的Route组件。

Route组件

// Route 内部逻辑:

// 1. 从 Switch 收到 computedMatch(匹配信息)

const match = this.props.computedMatch;



// 2. 匹配成功,准备渲染

if (match) {

  // 根据配置决定渲染方式:

  if (this.props.component) {

    // 使用 component 方式:创建 About 组件

    return React.createElement(About, {

      history: context.history,

      location: context.location,

      match: match  // { path: '/about', url: '/about', isExact: true }

    });

  }

  // 如果是 render 或 children 方式,也类似

}

Route组件的作用:

根据配置在组件上的参数来决定最终需要渲染的组件。

// 场景1:只想在匹配时显示组件
<Route path="/home" component={Home} />


// 场景2:匹配时要显示,但需要传额外参数
<Route 
  path="/user/:id" 
  render={(props) => <User userId={extraId} {...props} />} 
/>

组件结构作用分层

代码层面解析路由跳转

背景示例:

// 应用结构

<Router>

  <Switch>

    <Route path="/home" component={Home} />

    <Route path="/about" component={About} />

  </Switch>

</Router>

初始状态,显示Home页面

URL:/home

用户点击链接切换到About

<Link to="/about">About</Link> // 也可以使用redirect和useNavigate

// 点击后调用 history.push('/about')

详细步骤分析

第1步:history.push 改变 URL

// history.push 内部简化代码:

function push(path) {

  // 1. 改变浏览器 URL(不刷新页面)

  window.history.pushState({}, '', path);

  

  // 2. 创建新的 location 对象

  const location = createLocation(path);

  

  // 3. ✅ 关键:调用 setState

  setState({

    location: location,

    action: 'PUSH'

  });

}

第2步:setState 的内部操作

// setState 函数内部:

function setState(nextState) {

  // 1. 更新 history 内部的状态

  Object.assign(history, nextState);

  

  // 2. ✅ 核心:通知所有监听器

  listeners.forEach(listener => {

    // 每个 listener 都是一个回调函数

    listener(history.location, history.action);

  });

}

第3步:Router 监听器被调用

// 在 Router 组件构造函数中:

this.unlisten = props.history.listen(location => {

  // ✅ 当 setState 通知监听器时,这个函数被调用

  this.setState({ location: location });

});

第4步:Router 的 setState 触发重新渲染

// React 内部:当调用 this.setState 时

class Router extends React.Component {

  this.setState({ location: newLocation }, () => {

    // setState 完成后,React 会自动调用 render 方法

    this.render();

  });

  

  render() {

    // ✅ Router 重新渲染,使用新的 location

    return (

      <RouterContext.Provider value={{

        history: this.props.history,

        location: this.state.location, // ✅ 这里是新的 location

        match: /* ... */

      }}>

        {this.props.children}

      </RouterContext.Provider>

    );

  }

}

第5步:Context 值变化触发子组件更新

// Switch 组件内部:

<RouterContext.Consumer>

  {(context) => {

    // ✅ context.location 现在是新的 location

    const location = context.location; // { pathname: '/about' }

    

    // Switch 重新计算匹配

    let match = null;

    React.Children.forEach(this.props.children, child => {

      if (match == null) {

        // 检查每个 Route 是否匹配

        const path = child.props.path;

        if (path === '/about') {

          match = true; // ✅ 匹配!

        }

      }

    });

    

    // 渲染匹配的 Route

    if (match) {

      return React.cloneElement(foundChild, {

        location,

        computedMatch: match

      });

    }

  }}

</RouterContext.Consumer>

第6步:Route 组件渲染新页面

// Route 组件收到新的 match 后:

render() {

  if (this.props.computedMatch) {

    // ✅ 匹配成功,渲染对应的组件

    return React.createElement(this.props.component, {

      history: context.history,

      location: context.location,

      match: this.props.computedMatch

    });

  }

  return null; // 不匹配的 Route 不渲染

}

JavaScript

****从 setState 到 DOM 更新的完整链条

// 完整的调用链条:

history.setState()


调用 Router 的监听函数 (listener)


Router.setState({ location })


React 调度 Router 重新渲染


Router.render() 调用


Context.Provider value 更新


Switch (Consumer) 检测到变化


Switch.render() 重新计算匹配


匹配的 Route 重新渲染


Route 创建/更新组件实例


组件的 render 方法调用


React 更新 Virtual DOM


ReactDOM 更新实际 DOM


页面显示新内容

一句话总结:setState 通过改变状态,触发 React 的重新渲染机制,配合 Context 将变化传播到整个组件树,最终实现页面的无缝切换。

为什么History模式需要服务端的支持,而Hash模式不需要

在页面刷新的时候,相当于使用浏览器地址栏中的URL发送了一次GET请求,请求项目的HTML文件。Hash模式下,路由跳转是通过Hash值变化来实现的,而在请求的时候Hash是不会传递给服务端的,所以使用www.baidu.com#/123向服务端请求资源和www.baidu.com 是等价的。所以能被nginx配置的静态资源代理拦截并正常匹配返回HTML文件

而History模式,则是通过改变URL的路径来实现路由跳转的,使用www.baidu.com/abc和使用www.baidu.com/123 向服务端请求资源是完全不同的。此时nginx配置的静态资源代理拦截到这个请求后会去服务器找/abc/123路径中的资源,但是肯定是找不到的,所以会返回404给浏览器,要解决这个404的问题就在nginx加一个404找不到,重定向到默认HTML文件的配置。

本文参考:

  1. 「源码解析 」这一次彻底弄懂react-router路由原理 个人理解,单页面应用是使用一个html下,一次性加载js, - 掘金
  2. 浅谈前端路由原理hash和history🎹序言 众所周知, hash 和 history 在前端面试中是很常考的一道题 - 掘金

彻底搞定大模型流式输出:从二进制碎块到“嘚嘚嘚”打字机效果,让底层逻辑飞起来

作者 不会js
2025年12月7日 16:34

彻底搞定大模型流式输出:从二进制碎块到“嘚嘚嘚”打字机效果,让底层逻辑飞起来


你有没有遇到过这种场景:

用户点击「发送」,页面死气沉沉地转圈圈 5 秒,然后「啪」一下整段 500 字答案全部吐出来。
用户体验 = 灾难。

而真正丝滑的 ChatGPT、Claude、DeepSeek Web 版是怎么做的?

答案就是:流式输出(Streaming)

今天我们就用最硬核的方式,把流式输出的底层原理、字节流处理、SSE 协议、Vue3 响应式结合、常见坑与终极优化全部讲透

前三段,我们来了解一下流式输出所涉及到的知识点,到第四段我们直接让流式输出底层逻辑直接飞起来!

7498560dcaf6f397b1405522836210af.jpg

一、为什么流式输出能让用户「爽到飞起」?

普通请求(stream: false):

用户点击 → 前端等待 → LLM 思考 8 秒 → 完整返回 500 字 → 前端一次性渲染
感知延迟 = 8 秒 + 网络

流式请求(stream: true):

用户点击 → LLM 每生成 1~3 个 token 立刻返回 → 前端实时追加显示
感知延迟 ≈ 300~800ms(第一个 token 到达的时间)

这就是为什么 ChatGPT 打字像真人一样「一字一字冒出来」

结论:流式不是「锦上添花」,而是现代 AI 聊天界面「雪中送炭」的标配。

二、流式输出的真实数据长什么样?

DeepSeek、OpenAI、通用的 Server-Sent Events(SSE)格式:

45672f90391347be29a14c3da5d44e3c.png

关键点:

  • 每行以 data: 开头
  • 每一行都是一个完整的 JSON(除了最后一行 [DONE]
  • delta.content 就是本次新增的文字片段
  • 网络传输的是 二进制 Chunk,前端需要自己拼接、解码、解析

这也是为什么很多人写流式会出错——没处理好残缺的 JSON 行


三、底层:从二进制 Buffer 到文字的全过程(最硬核的部分)

我们用最直白的方式还原浏览器收到数据的真实过程:

graph TD
    A[TCP 二进制流] --> B(ArrayBuffer Chunk)
    B --> C{TextDecoder 解码}
    C --> D[UTF-8 字符串]
    D --> E[按\n拆分成多行]
    E --> F[过滤 data: 开头]
    F --> G[JSON.parse]
    G --> H[取出 delta.content]
    H --> I[追加到 Vue ref]
    I --> J[页面实时更新]
关键 API 一览(现代浏览器原生支持)
API 作用 备注
fetch() + stream: true 开启流式请求 必须设置
response.body.getReader() 获取二进制流读取器 返回 ReadableStreamDefaultReader
reader.read() 每次读取一个 chunk(Uint8Array) 返回 { value, done }
new TextDecoder() 把 Uint8Array → 字符串 支持 UTF-8,默认就是
new TextEncoder() 字符串 → Uint8Array(编码时用) 发请求时用不到,但面试常考
经典 Demo:手动玩转 Buffer
<script>
  const encoder = new TextEncoder();
  const buf = encoder.encode("你好 HTML5"); // Uint8Array(12)
  
  const buffer = new ArrayBuffer(12);
  const view = new Uint8Array(buffer);
  view.set(buf); // 复制进去

  const decoder = new TextDecoder();
  console.log(decoder.decode(buffer)); // "你好 HTML5"
</script>

这个例子说明:所有网络传输底层都是字节,中文一个字 = 3 字节,所以「你好」占 6 字节。


四、 流式输出终极解析

先来上一段完整代码,方便后面打飞他

03998dfb2be956b19c909a672ec27e78.jpg

<script setup>
import { ref } from 'vue'
const question = ref('讲一个喜洋洋和灰太狼的故事,20字')
const stream = ref(true)
const content = ref("") // 单向绑定  主要的
// 调用LLM
const askLLM = async () => { 
  // question 可以省.value  getter
  if (!question.value) {
    console.log('question 不能为空');
    return 
  }
  // 用户体验
  content.value = '思考中...';
  const endpoint = 'https://api.deepseek.com/chat/completions';
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
  }
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream:stream.value,
      messages: [
        {
          role: 'user',
          content: question.value
        }
      ]
    })
  })
  if(stream.value){
    //流式输出
    content.value='';//把上次的清空
    //html5 流式响应对象
    //响应体的读对象
    const reader = response.body?.getReader();
    //流出来的是二进制流  buffer
    const decoder = new TextDecoder();
    let done = false;//流是否结束 没有结束
    let buffer = '';
    while(!done){//只要没有完成就一直去拼接buffer
      //解构的同时 重命名
      const {value,done:doneReaing}=await reader?.read()
      console.log(value,doneReaing);
      done = doneReaing;
      //chunk 内容块 包含多行data  有多少行不确定
      //data:{} 能不能传完也不知道
      const chunkValue = buffer +decoder.decode(value,{ stream: true });//decode完之后就是文本字符串
      console.log(chunkValue);
      buffer='';
      const lines = chunkValue.split('\n').filter(line=>line.startsWith('data: '))
      for(const line of lines){
        const incoming = line.slice(6)//干掉数据标志
        if(incoming==='[DONE]'){
          done=true;
          break;
        }
        try{
          //llm 流式生成  tokens 长度不定的
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if(delta){
            content.value+=delta;
          }
        }catch(err){
          //JSON.parse解析失败 
          buffer+=`data: ${incoming}`
        }
      }
    }
  }else{
  const data = await response.json();
  console.log(data);
  content.value = data.choices[0].message.content;
 
  }
}
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input class="input" v-model="question"/>
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream" />
        <div>{{content}}</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
* {
  margin: 0;
  padding: 0;
}
.container {
  display: flex;
  flex-direction: column;
  /* 主轴、次轴 */
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: 0.85rem;
}
.input {
  width: 200px;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}
</style>
1、网络传的永远只有 0 和 1

“电脑上传输的都是二进制的方式,网络底层永远只有 0 和 1”

无论你是发“你好”两个字,还是发 4K 视频,本质上都是下面这玩意儿在网线里飞:

01001000 01100101 01101100 01101100 01101111

浏览器收到后,先把它们塞进一个叫 ArrayBuffer 的盒子,再给你一个 Uint8Array 的“视图”去操作它。

看一段简单代码:

const myBuffer = encoder.encode("你好 HTML5"); // → Uint8Array(12)
const buffer = new ArrayBuffer(12);
const view = new Uint8Array(buffer);
view.set(myBuffer);

结论:
流式输出从出生那一刻起,就是一堆碎掉的二进制垃圾。
你要的“丝滑打字”?对不起,先自己捡垃圾。

2、两大神器:水龙头 + 翻译官


| 角色       | API                        | 作用                             | 对应你文件里的代码                             |
|------------|----------------------------|----------------------------------|------------------------------------------------|
| 水龙头     | response.body.getReader()  | 把网络流变成可控的“水管”         | const reader = response.body?.getReader()      |
| 翻译官     | new TextDecoder()          | 把二进制水翻译成人类能看的汉字   | const decoder = new TextDecoder()              |

缺一不可。  
没有水龙头 → 拿不到数据  
没有翻译官 → 拿到一堆数字垃圾

3、读数据时的“解构+重命名”黑魔法

const { value, done: doneReading } = await reader.read()
done = doneReading
为什么不直接写 const { value, done }?

因为外层 while 循环要靠一个叫 done 的变量控制死活:

```js
let done = false;
while (!done) {
  const { value, done: doneReading } = await reader.read();
  done = doneReading;   // 这一步才真正结束循环
}

这就是代码里“解构重命名的终极原因——避免变量名冲突,保持逻辑清晰

4、最重要的一环:chunk 为什么是“狗啃过的”?

chunk(内容块)是浏览器网络层每次 read() 吐给你的二进制包,常见大小 16KB~64KB,完全随机。

可能出现的三种惨状:

  1. 一个完整的 data: 行被切成两半
    chunk1: data: {"choices":[{"delta":{"content":"你
    chunk2: 好啊"}}}]

  2. 一个 chunk 塞了 8 条完整行 + 半条残缺行

  3. 最后一个 chunk 只有 data: [DONE]

这就是为什么 大部分 的人写流式会寄——他们天真地以为一次 read() 就等于一条完整的 JSON。

5、 处理数据

再看到这张图

45672f90391347be29a14c3da5d44e3c.png

流式响应中,每一行理论上以 data: 开头,后面跟着一个完整的 JSON 对象。但实际情况经常会出现:

  1. 多个 data: 粘在一起(网络分块边界刚好切在中间)
  2. 最后一行 JSON 不完整(只收到一半就被截断)

所以我们先拆分再解析

const lines = chunkValue.split('\n').filter(line=>line.startsWith('data: '))

先把多个粘连的data给分成单个的

 const incoming =line.slice(6)

再把data: 前缀给削掉,这样就得到了我们需要的JSON对象

最后在进行解析

 const data = JSON.parse(incoming);

如果JOSN是不完整的,parse解析就会报错,这就是我们为什么要用buffer拼接

6、buffer:流式输出的灵魂(垃圾桶理论)

let buffer = ''  // 残缺 JSON 临时停车场

这是整套方案的灵魂——buffer 蓄水池机制

因为网络可能把一行 JSON 切成两半、三半、甚至十半,我们必须准备一个“垃圾桶”先存着:

JavaScript

let buffer = '';  // 全局的残缺字符串蓄水池

每次读到新 chunk,都要先拼到 buffer 里:

JavaScript

let chunkText = buffer + decoder.decode(value, { stream: true });
// 注意这里的 { stream: true }!告诉 decoder “我可能还没完”
buffer = ''; // 先清空,准备重新装垃圾

7. delta.content 追加,ref 一动页面舞

Vue3 的 ref 是响应式的,只要改 .value,页面就自动更新:

JavaScript

content.value += delta;

这就是你看到文字一个一个蹦出来的根本原因。

不需要 setTimeout,不需要 requestAnimationFrame,Vue 自己搞定。


五、总结

流式输出底层逻辑汇总:

电脑上传输的都是二进制的方式
网络底层永远只有 0 和 1

想要拿到我们看得懂的文本,首先需要两个工具:
getReader() 和 TextDecoder()
一个取“水龙头”,一个把“二进制水”翻译成汉字

一个负责拿到二进制流 Buffer,一个负责把拿到的这个二进制流解码成我们看得懂的字符串
value 就是 Uint8Array(专业叫法就是 Buffer)

reader读取的时候默认为{value, done},为了不影响外层while循环,解构的时候选择重命名
这就是为什么写 done: doneReading 的终极原因

接下来进入流式输出的最重要一环:
因为 token 是按序生成、随时发送的,网络每次也只能打包固定大小的数据,
所以我们实际拿到的二进制 chunk 可能是残缺的,也可能是多条完整的混在一起

这个时候需要我们手动进行字符串拼接,使用一个空字符串 buffer 做“蓄水池”
buffer 就是“残缺 JSON 临时停车场”

得到的二进制流解码后叫做 chunk 内容块

我们需要进行“过滤 + 拆行”操作:

  1. 因为可能一次 chunk 包含多个 data: 行
  2. 也可能一个 data: 行被拆成两个 chunk
    所以必须:buffer += 新chunk → 按 \n 拆成数组 → 把最后一行(可能不完整)重新塞回 buffer

然后将每行完整的 data: 去掉前缀,得到真正的 JSON 字符串

如果这行 JSON 不完整 → JSON.parse 会报错 → 被 catch 抓住 → 自动留到 buffer 等下次拼接

最后成功解析 → 取出 choices[0].delta.content → 追加到 Vue 的 ref → 页面实时刷新 → 流式输出达成!


流式输出的整条命脉就一句话:

“网络不负责给你整行 JSON,它只管扔二进制垃圾给你,你得自己捡垃圾、拼成完整的 JSON 才能吃。”


彩蛋

stream.value = falsetrue 切换对比,你会立刻感受到「从石器时代到现代文明」的体验差。

现在,你已经完全掌握了大模型流式输出的底层原理与最佳实践。

Vue3计算属性如何通过缓存特性优化表单验证与数据过滤?

作者 kknone
2025年12月7日 16:03

在日常开发中,表单验证和动态数据过滤几乎是每个项目都会遇到的需求——比如用户注册时要检查输入是否合法,商品列表要根据关键词实时筛选。这两个场景看似简单,但处理不好容易写出冗余、低效的代码。而Vue3的**计算属性(Computed Properties)**正好是解决这类问题的“神器”,今天我们就通过实战案例,看看它如何简化状态管理和逻辑复用。

一、表单验证:用计算属性简化状态管理

1.1 为什么用计算属性做表单验证?

做表单验证时,我们需要判断“所有字段是否合法”——比如用户名不能为空、密码至少6位、确认密码要一致。如果用methods写,每次模板渲染都会重新调用方法,哪怕字段没变化;而计算属性会缓存结果,只有当依赖的响应式数据(比如form.username)变化时,才会重新计算。这样既省性能,又让代码更简洁。

Vue官网是这么说的:“计算属性基于它们的依赖进行缓存。只在相关依赖发生改变时才会重新求值。”(参考:vuejs.org/guide/essen…

1.2 实战:用户注册表单验证

我们来写一个用户注册表单,用计算属性判断表单是否可以提交。代码如下:

<template>
  <form class="register-form" @submit.prevent="handleSubmit">
    <!-- 用户名输入框 -->
    <div class="form-group">
      <label>用户名:</label>
      <input 
        v-model.trim="form.username" 
        placeholder="请输入3-10位字符" 
        class="form-input"
      />
      <!-- 错误提示 -->
      <p v-if="!form.username" class="error-msg">用户名不能为空</p>
    </div>

    <!-- 密码输入框 -->
    <div class="form-group">
      <label>密码:</label>
      <input 
        type="password" 
        v-model="form.password" 
        placeholder="请输入6-16位密码" 
        class="form-input"
      />
      <p v-if="form.password.length < 6" class="error-msg">密码至少6位</p>
    </div>

    <!-- 确认密码 -->
    <div class="form-group">
      <label>确认密码:</label>
      <input 
        type="password" 
        v-model="form.confirmPassword" 
        placeholder="请再次输入密码" 
        class="form-input"
      />
      <p v-if="form.confirmPassword !== form.password" class="error-msg">两次密码不一致</p>
    </div>

    <!-- 提交按钮:禁用状态由计算属性控制 -->
    <button 
      type="submit" 
      class="submit-btn" 
      :disabled="!formIsValid"
    >
      提交注册
    </button>
  </form>
</template>

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

// 1. 响应式表单数据
const form = ref({
  username: '',   // 用户名
  password: '',   // 密码
  confirmPassword: ''  // 确认密码
})

// 2. 计算属性:判断表单是否合法
const formIsValid = computed(() => {
  // 解构form数据,简化代码
  const { username, password, confirmPassword } = form.value
  // 验证逻辑:用户名非空 + 密码≥6位 + 两次密码一致
  return (
    username.trim() !== '' &&  // 去掉空格后非空
    password.length >= 6 &&    // 密码长度足够
    confirmPassword === password  // 确认密码一致
  )
})

// 3. 提交事件处理
const handleSubmit = () => {
  if (formIsValid.value) {
    alert('注册成功!');
    // 这里可以加向后端提交数据的逻辑,比如axios.post('/api/register', form.value)
  }
}
</script>

<style scoped>
.register-form { max-width: 400px; margin: 20px auto; }
.form-group { margin-bottom: 15px; }
.form-input { width: 100%; padding: 8px; margin-top: 5px; }
.error-msg { color: red; font-size: 12px; margin: 5px 0 0 0; }
.submit-btn { width: 100%; padding: 10px; background: #42b983; color: white; border: none; border-radius: 4px; cursor: pointer; }
.submit-btn:disabled { background: #ccc; cursor: not-allowed; }
</style>

代码解释:

  • 响应式数据:用ref包裹表单对象form,这样输入时form的属性会自动更新。
  • 计算属性formIsValid:依赖form的三个属性,当其中任何一个变化时,自动重新计算“表单是否合法”。
  • 禁用按钮:用:disabled="!formIsValid"绑定按钮状态——只有formIsValidtrue时,按钮才能点击。
  • 提交逻辑handleSubmit里先判断formIsValid.value,确保提交的是合法数据。

1.3 流程图:表单验证的计算属性逻辑

为了更直观,我们用流程图展示计算属性的工作流程:

flowchart LR
A[用户输入表单内容] --> B[form数据响应式更新]
B --> C[计算属性formIsValid重新计算]
C --> D{formIsValid是否为true?}
D -->|是| E[提交按钮可用,允许提交]
D -->|否| F[提交按钮禁用,提示错误]

二、动态数据过滤:计算属性的缓存魔法

2.1 为什么用计算属性做动态过滤?

另一个常见场景是动态数据过滤——比如商品列表,用户输入关键词后,实时显示包含关键词的商品。这时计算属性的缓存特性就很有用:只有当搜索关键词(searchQuery)或商品列表(products)变化时,才会重新过滤,避免不必要的重复计算。

2.2 实战:商品列表动态过滤

我们来写一个商品列表,用计算属性实现实时过滤:

<template>
  <div class="product-filter">
    <!-- 搜索输入框 -->
    <input 
      v-model.trim="searchQuery" 
      placeholder="搜索商品名称" 
      class="search-input"
    />

    <!-- 过滤后的商品列表 -->
    <ul class="product-list">
      <li 
        v-for="product in filteredProducts" 
        :key="product.id" 
        class="product-item"
      >
        {{ product.name }} - {{ product.price }}元
      </li>
    </ul>
  </div>
</template>

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

// 1. 模拟后端获取的商品数据(响应式)
const products = ref([
  { id: 1, name: 'Vue3实战教程', price: 99 },
  { id: 2, name: 'React入门指南', price: 79 },
  { id: 3, name: 'JavaScript进阶', price: 129 },
  { id: 4, name: 'Vue3组件库', price: 59 }
])

// 2. 搜索关键词(响应式)
const searchQuery = ref('')

// 3. 计算属性:过滤后的商品列表
const filteredProducts = computed(() => {
  // 统一转为小写,避免大小写问题
  const query = searchQuery.value.toLowerCase()
  // 过滤逻辑:商品名称包含关键词
  return products.value.filter(product => {
    return product.name.toLowerCase().includes(query)
  })
})
</script>

<style scoped>
.product-filter { max-width: 600px; margin: 20px auto; }
.search-input { width: 100%; padding: 10px; margin-bottom: 15px; }
.product-list { list-style: none; padding: 0; }
.product-item { padding: 10px; border-bottom: 1px solid #eee; }
</style>

代码解释:

  • 响应式数据products是商品列表(模拟后端数据),searchQuery是用户输入的关键词。
  • 计算属性filteredProducts:依赖searchQueryproducts,当其中任何一个变化时,重新过滤商品列表。
  • 过滤逻辑:用filter方法筛选出名称包含关键词的商品,toLowerCase()统一大小写,避免“Vue”和“vue”不匹配的问题。

2.3 流程图:动态过滤的计算属性流程

flowchart LR
A[用户输入搜索关键词] --> B[searchQuery响应式更新]
B --> C[计算属性filteredProducts重新计算]
C --> D[过滤products列表]
D --> E[渲染过滤后的商品列表]

三、计算属性的进阶技巧:组合逻辑复用

3.1 抽取可复用的验证逻辑

在表单验证中,我们可能需要复用逻辑——比如“密码强度检查”,多个表单都需要判断密码是否包含大小写字母和数字。这时可以把逻辑抽成可组合函数(Composable),让代码更简洁、可复用。

往期文章归档
免费好用的热门在线工具

示例:抽取密码强度验证

我们创建一个usePasswordStrength.js文件,封装密码强度检查逻辑:

// composables/usePasswordStrength.js
import { computed } from 'vue'

/**
 * 密码强度检查的可组合函数
 * @param {Ref<string>} passwordRef - 密码的响应式引用
 * @returns {Object} 包含密码强度的计算属性
 */
export function usePasswordStrength(passwordRef) {
  // 计算属性:密码强度
  const passwordStrength = computed(() => {
    const password = passwordRef.value
    if (password.length === 0) return '请输入密码'
    if (password.length < 6) return '弱(至少6位)'
    // 检查是否包含小写、大写、数字
    const hasLower = /[a-z]/.test(password)
    const hasUpper = /[A-Z]/.test(password)
    const hasNumber = /\d/.test(password)
    // 强度等级:3项都满足→强,2项→中,1项→弱
    const strengthCount = [hasLower, hasUpper, hasNumber].filter(Boolean).length
    if (strengthCount === 3) return '强'
    if (strengthCount === 2) return '中'
    return '弱'
  })

  return { passwordStrength }
}

然后在注册表单中使用这个函数:

<template>
  <!-- 密码输入框 -->
  <div class="form-group">
    <label>密码:</label>
    <input type="password" v-model="form.password" class="form-input" />
    <p class="strength-msg">密码强度:{{ passwordStrength }}</p>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { usePasswordStrength } from '@/composables/usePasswordStrength'

const form = ref({ password: '' })
// 使用可组合函数,传入密码的响应式引用
const { passwordStrength } = usePasswordStrength(() => form.value.password)
</script>

<style scoped>
.strength-msg { font-size: 12px; margin: 5px 0 0 0; }
.strength-msg:contains('弱') { color: #f56c6c; }
.strength-msg:contains('中') { color: #e6a23c; }
.strength-msg:contains('强') { color: #67c23a; }
</style>

这样,不管多少个表单需要密码强度检查,只需引入usePasswordStrength即可,大大提高了代码的复用性!

四、课后Quiz:巩固所学知识

Quiz1:在动态数据过滤的示例中,如果把computed换成methods,会有什么区别?为什么?

答案解析

  • 区别computed会缓存结果,只有依赖的响应式数据(searchQueryproducts)变化时才重新计算;methods每次组件渲染都会重新调用,即使依赖的数据没变化。
  • 原因:比如用户输入关键词后,searchQuery变化,computed会重新过滤一次;但如果页面上有其他变化(比如时间戳更新),methods会再次调用filter方法,而computed不会——因为它的依赖没变化。
  • 结论computed更适合“衍生状态”(由其他数据推导而来),methods更适合“执行动作”(比如点击事件)。参考:vuejs.org/guide/essen…

Quiz2:表单验证中的formIsValid,为什么不用watch来实现?

答案解析

  • watch是“观察数据变化并执行副作用”(比如异步请求、DOM操作),而computed是“推导新的响应式数据”。
  • 如果用watch实现formIsValid,需要手动维护一个isValid变量:
    const formIsValid = ref(false)
    watch([() => form.value.username, () => form.value.password, () => form.value.confirmPassword], () => {
      formIsValid.value = /* 验证逻辑 */
    })
    
  • 相比之下,computed更简洁:const formIsValid = computed(() => /* 验证逻辑 */),而且自动缓存结果。
  • 结论:computed是“声明式”的(告诉Vue“我要什么”),watch是“命令式”的(告诉Vue“要做什么”)。参考:vuejs.org/guide/essen…

五、常见报错及解决方案

1. 报错:“Computed property "formIsValid" was assigned to but it has no setter.”

  • 原因:试图给computed属性赋值(比如formIsValid = true),但computed默认是只读的(除非定义setter)。
  • 解决:不要直接修改computed属性,而是修改它依赖的响应式数据(比如form.value.username = 'admin')。如果需要可写的computed,可以定义gettersetter
    const fullName = computed({
      get() { return this.firstName + ' ' + this.lastName },
      set(value) {
        [this.firstName, this.lastName] = value.split(' ')
      }
    })
    
  • 预防:记住computed是“衍生状态”,修改依赖的数据即可,不要直接赋值。

2. 报错:“Property "formIsValid" was accessed during render but is not defined on instance.”

  • 原因:模板中用了formIsValid,但script中没有定义,或者定义错误(比如写成了methods里的函数)。
  • 解决:检查script中是否正确定义了computed属性:const formIsValid = computed(...),并且script setup会自动导出顶层变量(不需要export)。
  • 预防:写模板时同步修改script,确保变量名一致。

3. 报错:“Invalid watch source: 5 A watch source can only be a getter/effect function, a ref, a reactive object, or an array of these.”

  • 原因watch的源不是响应式数据或函数(比如watch(5, () => {}))。
  • 解决:确保watch的源是响应式的,比如:
    watch(() => form.value.username, () => { /* 逻辑 */ }) // getter函数
    watch(searchQuery, () => { /* 逻辑 */ }) // ref变量
    
  • 预防:使用watch时,源要指向响应式数据的getter函数或ref/reactive对象。

参考链接

深入解析:基于 Vue 3 与 DeepSeek API 构建流式大模型聊天应用的完整实现

作者 Yira
2025年12月7日 15:54

深入解析:基于 Vue 3 与 DeepSeek API 构建流式大模型聊天应用的完整实现

在人工智能技术日新月异的今天,大语言模型(Large Language Models, LLMs)已从实验室走向大众开发者手中。从前端视角看,我们不再只是静态页面的构建者,而是可以轻松集成智能对话能力、打造具备“思考”功能的交互式 Web 应用。本文将对一段使用 Vue 3 组合式 APIDeepSeek 大模型 API 实现的简易聊天界面代码进行逐行深度剖析,不仅讲解其表面逻辑,更深入探讨流式响应(Streaming)、SSE(Server-Sent Events)协议解析、前端安全实践、性能优化策略等核心概念,帮助你全面掌握现代 AI 应用的前端架构。


一、项目背景与目标

该应用的目标非常明确:

用户在输入框中输入自然语言问题 → 点击“提交”按钮 → 调用 DeepSeek 的 deepseek-chat 模型 → 将模型生成的回答实时或一次性显示在页面上。

其中,“实时显示”即流式输出(streaming) ,是提升用户体验的关键特性——用户无需等待数秒才能看到完整回答,而是像观看真人打字一样,逐字逐句地接收内容。


二、整体架构:Vue 3 单文件组件(SFC)

该应用采用 Vue 3 的 <script setup> 语法糖,这是一种编译时优化的组合式 API 写法,代码简洁且性能优异。整个组件分为三部分:

  • <script setup> :逻辑层,定义响应式状态、封装 API 调用函数。
  • <template> :视图层,声明式描述 UI 结构与数据绑定。
  • <style scoped> :样式层,使用 Flex 布局实现自适应垂直排列。

这种结构高度内聚,非常适合快速原型开发或教学演示。


三、响应式状态设计

import { ref } from 'vue'

const question = ref('讲一个喜洋洋和灰太狼的故事不低于20字')
const stream = ref(true)
const content = ref("")

1. ref 的作用机制

ref 是 Vue 3 提供的基础响应式 API,它将原始值包装在一个带有 .value 属性的对象中。例如:

let count = ref(0)
console.log(count.value) // 0
count.value++ // 触发依赖更新

在模板中,Vue 会自动解包 .value,因此可以直接写 {{ question }} 而非 {{ question.value }}

2. 状态含义

  • question:用户输入的问题文本。默认值设为一个具体示例,便于测试,避免空请求。
  • stream:布尔开关,控制是否启用流式响应。默认开启,体现“实时性”优势。
  • content:LLM 返回的内容容器。初始为空,调用前设为“思考中...”,调用后逐步填充。

这三个状态共同构成了“输入-处理-输出”的闭环。


四、API 调用逻辑详解:askLLM 函数

这是整个应用的核心函数,负责与 DeepSeek 后端通信。

1. 输入校验与 UX 优化

if (!question.value) {
  console.log('question 不能为空');
  return 
}
content.value = '思考中...';
  • 防御性编程:防止空字符串触发无效请求,节省资源。
  • 即时反馈:设置 content 为提示语,让用户知道系统正在工作,避免“无响应”错觉。

2. 请求构建

const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
  'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
  'Content-Type': 'application/json'
}
  • 环境变量安全VITE_DEEPSEEK_API_KEY 是 Vite 特有的客户端环境变量前缀。Vite 会在构建时将其注入到 import.meta.env 中。
  • 认证方式:采用 Bearer Token,符合 RESTful API 最佳实践。
  • 内容类型:指定为 application/json,确保后端正确解析请求体。

⚠️ 重要安全警告:此方式将 API 密钥暴露在前端 JavaScript 中,任何用户均可通过浏览器开发者工具查看。仅适用于本地开发或演示环境。生产环境必须通过后端代理(如 Express、Nginx)转发请求,密钥应存储在服务器环境变量中。

3. 发送请求

const response = await fetch(endpoint, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    model: 'deepseek-chat',
    stream: stream.value,
    messages: [{ role: 'user', content: question.value }]
  })
})
  • OpenAI 兼容格式:DeepSeek API 遵循 OpenAI 的消息格式,messages 数组包含角色(user/assistant)和内容。
  • 动态流式控制stream 参数决定返回格式——流式(text/event-stream)或非流式(application/json)。

五、响应处理:流式 vs 非流式

A. 非流式模式(简单直接)

const data = await response.json();
content.value = data.choices[0].message.content;

适用于:

  • 对实时性要求不高的场景
  • 调试阶段快速验证 API 是否正常
  • 网络环境不稳定,流式连接易中断

B. 流式模式(复杂但体验更佳)

1. 初始化流读取器
content.value = "";
const reader = response.body.getReader();
const decoder = new TextDecoder();
  • getReader() 返回一个 ReadableStreamDefaultReader,用于逐块读取响应体。
  • TextDecoder 将二进制数据(Uint8Array)解码为 UTF-8 字符串。
2. 循环读取与解析
let done = false;
let buffer = '';

while (!done) {
  const { value, done: doneReading } = await reader.read();
  done = doneReading;
  const chunkValue = buffer + decoder.decode(value);
  buffer = ''; // ← 此处存在严重缺陷!
🔍 问题分析:缓冲区(Buffer)处理错误

网络传输的 TCP 包大小不确定,一个完整的 SSE 行可能被拆分到多个 chunk 中。例如:

  • Chunk 1: "data: {"choices": [{"delta": {"cont"
  • Chunk 2: "ent": "今天灰太狼又失败了..."}}]}\n"

若在每次循环开始时清空 buffer,第二块数据将丢失前半部分,导致 JSON 解析失败。

正确实现

const chunkValue = buffer + decoder.decode(value, { stream: true });
const lines = chunkValue.split('\n');
buffer = lines.pop() || ''; // 保留不完整的最后一行
const validLines = lines.filter(line => line.trim().startsWith('data:'));
  • 使用 { stream: true } 选项,确保跨 chunk 的 UTF-8 字符(如 emoji)能正确解码。
  • lines.pop() 取出可能不完整的尾行,留待下次拼接。
3. SSE 行解析
for (const line of validLines) {
  const payload = line.slice(5).trim(); // "data: xxx" → "xxx"
  if (payload === '[DONE]') {
    done = true;
    break;
  }
  try {
    const parsed = JSON.parse(payload);
    const delta = parsed.choices?.[0]?.delta?.content;
    if (delta) content.value += delta;
  } catch (e) {
    console.warn('Failed to parse SSE line:', payload);
  }
}
  • 跳过非 data 行:SSE 协议还支持 event:id: 等字段,此处仅处理 data:
  • 安全访问嵌套属性:使用可选链(?.)避免因结构变化导致崩溃。
  • 忽略解析错误:部分 chunk 可能包含空行或注释,应静默跳过。

六、模板与交互设计

<input v-model="question" />
<button @click="askLLM">提交</button>
<input type="checkbox" v-model="stream" />
<div>{{ content }}</div>
  • 双向绑定v-model 自动同步输入框与 question,复选框与 stream
  • 响应式更新content 的任何变化都会触发 DOM 更新,实现“打字机”效果。
  • 无障碍基础:可通过添加 label for、ARIA 属性进一步优化。

七、安全、性能与扩展建议

1. 安全加固

  • 后端代理:创建 /api/proxy-deepseek 接口,前端只调用本地路径。
  • CORS 限制:后端设置 Access-Control-Allow-Origin 为可信域名。
  • 请求频率限制:防止恶意刷接口。

2. 错误处理增强

try {
  const response = await fetch(...);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  // ...处理响应
} catch (err) {
  content.value = `请求失败: ${err.message}`;
}

3. 加载状态管理

const loading = ref(false);
const askLLM = async () => {
  if (loading.value) return;
  loading.value = true;
  try { /* ... */ } finally {
    loading.value = false;
  }
}

并在按钮上绑定 :disabled="loading"

4. 多轮对话支持

维护一个 messages 数组:

const messages = ref([
  { role: 'user', content: '你好' },
  { role: 'assistant', content: '你好!有什么我可以帮你的吗?' }
]);

每次提问后追加用户消息,收到回答后追加助手消息。


八、总结

这段看似简单的代码,实则融合了前端响应式编程、异步流处理、AI API 集成、用户体验设计四大维度。通过深入理解其每一行背后的原理——尤其是流式响应的缓冲区管理与 SSE 协议解析——你不仅能复现此功能,更能在此基础上构建企业级的智能对话应用。

未来,随着 Web 标准的演进(如 ReadableStream 的普及)和 AI 模型能力的增强,前端开发者将在人机交互中扮演越来越重要的角色。而扎实掌握这些底层机制,正是迈向高阶开发的关键一步。

最后忠告:技术探索值得鼓励,但请永远将安全性放在首位。不要让 API 密钥成为你项目的“定时炸弹”。

AI打字机的秘密:一个 buffer 如何让机器学会“慢慢说话”

作者 xhxxx
2025年12月7日 14:52

当AI像打字机一样说话:揭秘流式输出背后的魔法

你有没有过这样的体验:和ChatGPT对话时,它不是突然蹦出整段文字,而是像真人一样,一个字一个字地敲出来?这种"打字机效果"不仅让交互更自然,还大大提升了用户体验——你不再需要焦虑地等待,而是能实时感受到AI的"思考过程"。


话不多说,先奉上代码效果

lovegif_1765087971567.gif

今天,我们将一起探索这个神奇效果背后的原理,并亲手实现一个Vue 3 + DeepSeek API的流式对话界面。在开始代码之前,先让我们理解一个关键概念:缓冲区(Buffer)

🌊 为什么需要"缓冲区"?—— 流式输出的基础

LLM 流式接口返回的是 Server-Sent Events (SSE) 格式的数据流,例如

image.png 但底层传输使用的是 HTTP/1.1 Chunked Transfer Encoding 或 HTTP/2 流,数据被切成任意大小的 二进制块(chunks) 发送。

所以每一行的数据可能是不完整的,这就有可能造成数据的丢失从而无法解析完整的数据

这时,缓冲区(Buffer) 就登场了!它像一个临时存储区,把不完整的数据先存起来,等到拼出完整的一行(如data: {"choices":[{"delta":{"content":"好"}}]})再处理。

Buffer的核心作用:解决网络分包导致的JSON解析失败问题,确保每个字都能正确显示。

HTML5中的Buffer实现

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTML5 Buffer</title>
</head>
<body>
    <h1>HTML5 Buffer</h1>
    <div id="output"></div>
    <script>
        //JS 二进制、数组缓存
        //html5 编码对象
        const encoder = new TextEncoder();
        console.log(encoder);
        const myBuffer =encoder.encode("你好 HTML5");
        console.log(myBuffer);
        // 数组缓存 12 字节
        // 创建一个缓冲区
        const buffer = new ArrayBuffer(12);
        // 创建一个视图(View)来操作这个缓冲区 
        const view = new Uint8Array(buffer);
        for(let i=0;i<myBuffer.length;i++){
           //   console.log(myBuffer[i]);
           view[i] = myBuffer[i];   
        }
        const decoder = new TextDecoder();
        const originalText = decoder.decode(buffer);
        console.log(originalText);
        const outputDiv = document.getElementById("output");
        outputDiv.innerHTML =`
        完整数据:[${view}]<br>
        第一个字节:${view[0]}<br>
        缓冲区的字节长度${buffer.byteLength}<br>
        原始文本:${originalText}<br>

        `
    </script>
</html>

请看这样一张图

我们输入的文本是你好 HTML5但通过二进制传输就变成了这样的Uint8Array —— 无符号 8 位整数数组 image.png 实现的原理?
关键点解析:

  1. TextEncoder:文本->字节的转换器通过调用encode方法将文本(字符串)编码成计算机能读懂的二进制字节序列(Uint8Array),这就是网络传输中的原始数据
  2. TextDecoder:字节->文本,他是encode的逆向过程,把二进制的数据解读为文本
  3. Uint8Array的底层内存块:ArrayBuffer:- ArrayBuffer 是底层的内存区域,存储实际的二进制数据,而你可以认为Uint8Array是对ArrayBuffer一种解读方式(UTF-8)

🌐 为什么这对流式输出很重要?

当你调用 LLM 接口时:

  1. 服务器发送的是 二进制流(chunked transfer encoding)
  2. 浏览器收到的是 Uint8Array 形式的 chunk
  3. 你需要用 TextDecoder 将其解码为字符串
  4. 再用 buffer 拼接不完整的行(如 data: {"delta":...}

🚨 所以:TextEncoder/TextDecoder 是连接“文本世界”和“字节世界”的桥梁

现在你可以明白:当 AI 一个字一个字地输出时,背后正是这些 Uint8ArrayTextDecoder 在默默工作


🧩 现在,让我们用代码实现这个"打字机"效果

下面,我们将从零开始构建一个Vue 3应用,实现LLM流式输出。


1. 响应式数据定义:Vue 3的"心脏"

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

const question = ref('讲一个喜洋洋和灰太狼的故事,200字')
const stream = ref(true)   // 默认开启流式
const content = ref("")    // 用于显示模型回答
</script>

关键点解析:使用ref响应式数据,能够更方便的快速绑定数据,当变量改变时能够实时更新页面内容,这也是我们选择vue框架的原因


2. 调用LLM的核心函数:askLLM

const askLLM = async () => { 
  if (!question.value) {
    console.log('question 不能为空');
    return 
  }
  content.value = '思考中...';  // 提前反馈

  // 构造API请求
  const endpoint = 'https://api.deepseek.com/chat/completions';
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
  }

  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,
      messages: [{ role: 'user', content: question.value }]
    })
  })

关键点解析:

  1. 使用.env文件存储apikey
  2. 当调用大模型时,初始化content的值为“思考中”,优化用户体验
  3. 使用stream控制大模型的流式输出
  4. 通过messsges给出大模型清晰的上下文

3. 非流式模式:简单但不够"丝滑"

  if (!stream.value) {
    const data = await response.json();
    content.value = data.choices[0].message.content;
  }

产品设计的理念:
非流式模型的实现很简单,等待大模型完成所有的输出后,一次性将其输出到页面,但是这对用户来说是一个糟糕的体验,对于一个产品来说,能更快的显示出页面数据,减少用户的等待时间就能留住更多的用户,没有人喜欢看不见进度条的一直等待!!!

4. 流式模式:核心魔法所在(重点!)

  if (stream.value) {
    content.value = "";  // 清空上一次的输出
    const reader = response.body?.getReader();
    const decoder = new TextDecoder();
    let done = false;
    let buffer = '';

    while (!done) {
      const { value, done: doneReading } = await reader?.read();
      done = doneReading;
      if (!value) continue;

      // 关键:用buffer拼接不完整的JSON行
      const chunkValue = buffer + decoder.decode(value);
      buffer = '';

      // 按行分割,只处理有效的data:行
      const lines = chunkValue.split('\n')
        .filter(line => line.startsWith('data: '));

      for (const line of lines) {
        const incoming = line.slice(6); // 移除"data: "

        if (incoming === '[DONE]') {
          done = true;
          break;
        }

        try {
          // 解析JSON,获取新增的文本片段(delta)
          const data = JSON.parse(incoming);
          const delta = data.choices[0].delta.content;
          if (delta) {
            content.value += delta; // 响应式更新!
          }
        } catch (err) {
          // JSON解析失败?存入buffer等待下一次拼接
          buffer += `data: ${incoming}`;
        }
      }
    }
  }

关键点解析:

  1. reder-->读取二进制流,decoder将二进制块解码为字符串
  2. const reader = response.body?.getReader();这是一条可选链。如果body不为空,则调用getReader();
  3. 这个 reader 有一个关键方法:read(),它返回一个 Promise,解析为 { value: Uint8Array, done: boolean }:我们通过const { value, done: doneReading } = await reader?.read();将value和done解构出来,同时为了避免done和我们上面定义的done冲突,我们采用重命名的方式解构,将他重命名为doneReading
  4. 什么是chunk?在计算机网络中,数据不是一次性全部发送的,而是被切成一个个小段,逐个发送。这些小段就叫 chunks(数据块) 。而在浏览器的fetchAPI中,chunk就是通过reader.read()读取到的一个一个对象中的value
  5. 数据过滤,通过filter和startWith筛选出以data:开头的有效数据,然后通过slice()方法,将所有筛选出的数据切割掉data: 部分,方便后续解析JSON
  6. 使用try/catch防止丢字:因为大模型一次给出的token是不确定的,而我们的data{}一次能不能传完也不确定,所以一行data{}可能会被拆成两部分,这就会导致这一段解析失败,那么解析失败的数据并不是我们不需要的,只是它不小心被分成了两部分,所以我们需要对它进行存储,你能想到什么?没错就是buffer,我们将能够解析的部分先拼接到content中显示到页面,解析失败的我们则倒退的到它的“初态”,将data:拼接回去,然后存入bufer,在读取下一行时,把它拼接到最前面,与自己丢失的部分匹配,然后进行新一轮的流式处理,当我们完成拼接后,需要把buffer清空,不然会影响到下一次的拼接
  7. 流式输出结束的标志[DONE]:当我们对字符串进行处理时,如果剩余部分是[DONE]则代表所有内容已经输出完毕,我们就设置done为true来结束读取;

image.png


5. 模板与交互:让UI活起来

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input v-model="question" />
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <label>Streaming</label>
      <input type="checkbox" v-model="stream" />
      <div>{{ content }}</div>
    </div>
  </div>
</template>

image.png

关键点解析:

  1. v-model:双向绑定表单数据,无论我们修改表单数据还是直接修改变量,另外一边也能同时进行更新
  2. @click ="":vue中不再需要像JS中一样机械的流程式去监听DOM元素,我们直接可以为DOM元素绑定事件,当触发事件时自动调用方法

✨ 为什么这个"打字机"效果如此重要?

传统模式 流式模式
等待完整回答(2-5秒) 逐字显示(0.1-0.5秒/字)
用户焦虑等待 实时反馈,感觉AI在"思考"
体验生硬 交互自然,像真人对话

💡 关键洞察:流式输出不是技术炫技,而是用户心理的深度优化——它让AI从"工具"变成了"对话伙伴"。 代码虽短,但蕴含了现代AI交互的核心思想。真正的技术不是写代码,而是理解用户在等待时的心理
最后的思考:当AI能"打字"时,它不再是冰冷的机器,而成了你对话中的伙伴。而你,已经掌握了创造这种对话的魔法。

递归 VS 动态规划:从 “无限套娃计算器” 到 “积木式解题神器”

2025年12月7日 13:49

就占用你 5 分钟,让你搞懂递归和 DP 算法!

前言

你有没有试过:对着计算器按 “× 上一个数”,按到手指酸?或者自己照镜子,镜子里面有个自己,镜子的镜子里面还有个自己?这就是递归 —— 像台 “无限套娃计算器”,算大数能把自己 “算死机”;而动态规划是 “积木式解题神器”,从最小块开始拼,再复杂的题都能稳准狠搞定。

一、递归:“套娃计算器” 的用法

递归就俩关键:找 “套娃公式”(规律)+ 找 “最小娃”(出口)

例 1:算阶乘 —— 套娃停不下来?

如果要你计算5的阶乘,你会怎么做?大部分人第一想法应该都是:直接一个for循环不就完事了吗?没错:

function mul(n) {
    let num = 1;
    for(let i = n; i >= 1; i--){
        num *= i;
    }
    return num;
}
console.log(mul(5));

image.png

但是你用你聪明的脑袋想了又想,阶乘是 “5! = 5×4×3×2×1”,套娃公式是 “n! = n × (n-1)!”(大娃里塞小娃);最小娃是 “1! = 1”(塞到最小的娃就停)。好像这样也能写出来!

看代码(递归版阶乘):

function mul(n) {
    // 最小娃:1!直接返回 1
    if(n == 1){
        return 1;
    }
    // 套娃公式:大娃=自己×小娃
    return n * mul(n - 1);
}
console.log(mul(5));

image.png

我嘞个豆,答案✅️了,这就是我们今天的主角--大名鼎鼎的递归。但这计算器有 bug:例如算mul(50000)会 “套娃太多死机”(栈溢出)。在我写的浅拷贝 VS 深拷贝这篇文章中有掘u问到了这个问题。所以递归一般在非必要情况下使用,因为数据一大就会爆栈。

例 2:斐波那契数列 —— 套两层娃?

斐波那契是 “1,1,2,3,5……”,套娃公式是 “第 n 项 = 第 n-1 项 + 第 n-2 项”(一个娃里塞俩小娃);最小娃是 “第 1、2 项 = 1”。

function fb(n) {
    // 最小娃:前两个直接返回 1
    if(n == 1 || n == 2){
        return 1;
    } 
    // 套娃公式:自己=左边娃+右边娃
    return fb(n - 1) + fb(n - 2);
}
console.log(fb(5));

image.png

这计算器更费电:算fb(100)要重复掏同一批娃,卡到你怀疑人生~,这就是递归,好理解但难用,接下来我们开始经典算法--动态规划

二、动态规划:“积木神器” 怎么拼?

动态规划是 “反着来”—— 不用拆娃,直接从 最小积木块(已知结果) 开始,一块一块拼出最终答案,像搭乐高一样稳。

例:爬楼梯 - 力扣(LeetCode)

有一个思路,那就是直接暴力通项公式,看看官方题解:

image.png

var climbStairs = function(n) {
    const sqrt5 = Math.sqrt(5);
    const fibn = Math.pow((1 + sqrt5) / 2, n + 1) - Math.pow((1 - sqrt5) / 2, n + 1);
    return Math.round(fibn / sqrt5);
};

但是这里我们用算法思想,也就是动态规划。积木公式:第 n 级的拼法 = 第 n-1 级拼法(最后加 1 块)+ 第 n-2 级拼法(最后加 2 块)。

看代码(动态规划版爬楼梯):

var climbStairs = function (n) {
    // 积木盒dp:存每级台阶的拼法数
    let dp = [];
    // 基础积木:0级(起点)和1级各1种拼法
    dp[0] = 1;
    dp[1] = 1;
    // 从2级开始,用现有积木拼新台阶
    for (let i = 2; i <= n; i++) {
        dp[i] = dp[i - 1] + dp[i - 2];
    }
    // 第n级的拼法就是dp[n]
    return dp[n];
};
console.log(climbStairs(5)); 

image.png

这神器不卡不崩,再大的 n 都能轻松搞定~

总结:套娃计算器 vs 积木神器

  • 递归(套娃计算器):上手快,但算大数容易 “死机”;
  • 动态规划(积木神器):从基础块开拼,稳、准、快,复杂题克星。

我最后总结这样一份表格,希望能够帮到各位:

维度 递归(Recursion) 动态规划(Dynamic Programming, DP)
核心思想 把大问题拆解为规模更小的子问题,通过调用自身解决子问题,最终合并子问题结果得到原问题解 将大问题拆解为重叠子问题,通过存储子问题的解(记忆化 / 表格)避免重复计算,自底向上或自顶向下求解
问题特征 1. 问题可自然拆解为独立子问题(无大量重复计算)2. 问题具有递归结构(如树形结构、分治场景)3. 子问题规模递减且无重叠 1. 问题具有重叠子问题(子问题被重复求解)2. 问题具有最优子结构(原问题最优解由子问题最优解组成)3. 存在状态转移关系
适用场景 1. 分治算法(如归并排序、快速排序)2. 树形结构遍历 / 操作(如二叉树的遍历、求深度、路径和)3. 排列组合枚举(如全排列、子集生成)4. 回溯算法(如 N 皇后、迷宫问题)5. 问题子问题无重叠,递归深度可控 1. 优化类问题(如最短路径、最大收益、最小代价)2. 计数类问题(如不同路径数、解码方式数)3. 子问题大量重复的场景(如斐波那契数列、背包问题)4. 状态可清晰定义且存在转移关系的问题
实现方式 1. 纯递归(无记忆化,直接调用自身)2. 递归 + 记忆化(Top-Down DP,属于 DP 的一种) 1. 自顶向下(记忆化递归)2. 自底向上(迭代 + 表格,如二维 / 一维 DP 数组)
时间复杂度 纯递归:若存在重叠子问题,时间复杂度指数级(如斐波那契纯递归为 O (2ⁿ));无重叠子问题时为 O (n) 或 O (nlogn)(如归并排序) 消除重复计算,时间复杂度通常为 O (n)、O (nm) 等多项式级别(如斐波那契 DP 为 O (n),01 背包为 O (nm))
空间复杂度 纯递归:递归调用栈深度(如二叉树递归遍历为 O (h),h 为树高);若递归深度过大可能栈溢出 自底向上 DP:存储状态的数组 / 表格空间(如 O (n) 或 O (nm));可优化空间(如滚动数组),无栈溢出风险
代码风格 代码简洁、直观,符合问题的自然拆解逻辑,易编写和理解 需手动定义状态和转移方程,代码相对复杂,但效率更高
典型例子 1. 二叉树的前 / 中 / 后序遍历2. 归并排序 / 快速排序3. 全排列 / 组合总和4. 汉诺塔问题5. 回溯法解 N 皇后 1. 斐波那契数列(优化版)2. 01 背包 / 完全背包问题3. 最长公共子序列(LCS)4. 最短路径(Floyd-Warshall/Dijkstra 的 DP 思想)5. 最大子数组和(Kadane 算法)
局限性 1. 重叠子问题导致重复计算,效率低2. 递归深度过大易引发栈溢出(如 n=10000 的斐波那契递归)3. 函数调用开销 1. 需明确状态定义和转移方程,对问题建模要求高2. 不适用于子问题无重叠的场景(反而增加空间开销)

使用 Vue 3 实现大模型流式输出:从零搭建一个简易对话 Demo

作者 ohyeah
2025年12月7日 13:39

在当前 AI 应用快速发展的背景下,前端开发者越来越多地需要与大语言模型(LLM)进行交互。本文将基于你提供的 App.vue 代码和学习笔记,带你一步步理解如何使用 Vue 3 + Composition API 构建一个支持 流式输出(Streaming) 的 LLM 对话界面。我们将重点解析代码结构、响应式原理、流式数据处理逻辑,并确保内容通俗易懂,适合初学者或希望快速上手的开发者。


一、项目初始化与技术选型

项目是通过 Vite 初始化的:

npm init vite

选择 Vue 3 + JavaScript 模板。Vite 作为新一代前端构建工具,以其极速的冷启动和热更新能力,成为现代 Vue 项目的首选脚手架。

生成的项目结构简洁清晰,核心开发文件位于 src/ 目录下,而 App.vue 就是整个应用的根组件。


二、Vue 3 的“三明治”结构

.vue 文件由三部分组成:

  • <script setup>:逻辑层(使用 Composition API)
  • <template>:模板层(声明式 UI)
  • <style scoped>:样式层(作用域 CSS)

这种结构让代码职责分明,也便于维护。


三、响应式数据:ref 的核心作用

<script setup> 中,你使用了 ref 来创建响应式变量:

import { ref } from 'vue'

const question = ref('讲一个喜羊羊和灰太狼的小故事,不低于20字')
//控制是否启用流式输出(streaming) 默认开启
const stream = ref(true)
//声明content 单向绑定 用于呈现LLM输出的值
const content = ref('')

什么是 ref

  • ref 是 Vue 3 Composition API 提供的一个函数,用于创建响应式引用对象
  • 它内部包裹一个值(如字符串、数字等),并通过 .value 访问或修改。
  • 在模板中使用时,Vue 会自动解包 .value,所以你只需写 {{ content }} 而非 {{ content.value }}

关键点:当 ref 的值发生变化时,模板会自动重新渲染——这就是“响应式”的核心。

例如:

let count = ref(111)//此时count就为响应式对象
setTimeout(() => {
  count.value = 222 // 模板中绑定 {{ count }} 会自动更新为 222
}, 2000)

这避免了传统 DOM 操作(如 getElementById().innerText = ...),让开发者更专注于业务逻辑。


四、双向绑定:v-model 的妙用

在输入框中,你使用了 v-model

<input type="input" v-model="question" />

v-model 是什么?

  • 它是 Vue 提供的双向数据绑定指令。
  • 输入框的值与 question.value 实时同步:用户输入 → question 更新;question 变化 → 输入框内容更新。
  • 如果改用 :value="question",则只能单向绑定(数据 → 视图),无法实现用户输入自动更新数据。

这使得表单处理变得极其简单。


五、调用大模型 API:异步请求与流式处理

核心功能在 askLLM 函数中实现:

//调用大模型 async await 异步任务同步化
const askLLM = async () => {
  if (!question.value) {
    console.log('question 不能为空!')
    return
    //校验question.value 为空直接return 避免无意义地进行下一步操作
  }
  //提升用户体验 先显示'思考中...' 表示正在处理
  content.value = '思考中...'
  
  //发生请求的时候 首先发送 请求行(方法 url 版本)
  const endpoint = 'https://api.deepseek.com/chat/completions'
  const headers = {
    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
    'Content-Type': 'application/json'
  }
  
  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value,//启用流式输出
      messages: [{ role: 'user', content: question.value }]
    })
  })

关键细节:

  1. 环境变量安全:API Key 通过 import.meta.env.VITE_DEEPSEEK_API_KEY 引入。Vite 要求以 VITE_ 开头的环境变量才能在客户端暴露,这是一种安全实践。
  2. 请求体结构:符合 OpenAI 兼容 API 标准,指定模型、是否流式、消息历史。
  3. 用户体验优化:请求发起后立即显示“思考中...”,避免界面卡顿感。

六、流式输出(Streaming)的实现原理

这是本文的重点。当 stream.value === true 时,采用流式处理:

if (stream.value) {
  content.value = ''//先把上一次的输出清空
  //html5 流式响应体 getReader() 响应体的读对象
  const reader = response.body?.getReader()
  const decoder = new TextDecoder()
  let done = false //用来判断流是否结束
  let buffer = ''

  while (!done) {
  //只要流还未结束 就一直拼接buffer
  //解构的同时  重命名 done-> doneReading
    const { value, done: doneReading } = await reader?.read()
    done = doneReading //当数据流结束后 赋值给外部的done 结束while
    const chunkValue = buffer + decoder.decode(value, { stream: true })
    buffer = ''

    const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
    for (const line of lines) {
      const incoming = line.slice(6) // 去掉 "data: "  去除数据标签
      if (incoming === '[DONE]') {
        done = true //将外部done改为true 结束循环
        break
      }
      try {
        const data = JSON.parse(incoming)
        const delta = data.choices[0].delta.content
        if (delta) {
          content.value += delta
        }
      } catch (err) {
      //JSON.parse解析失败  拿给下一次去解析
        buffer += `data: ${incoming}`
      }
    }
  }
}

流式输出的工作流程:

  1. 获取可读流response.body.getReader() 返回一个 ReadableStreamDefaultReader

  2. 逐块读取:每次 reader.read() 返回一个 { value, done } 对象,valueUint8Array(二进制数据)。

  3. 解码为字符串:使用 TextDecoder 将二进制转为文本。注意传入 { stream: true } 避免 UTF-8 截断问题。

  4. 按行解析 SSE(Server-Sent Events)

    • 服务端返回格式为多行 data: {...}\n
    • 每行以 data: 开头,末尾可能有 \n\n
    • 遇到 [DONE] 表示流结束。
  5. 拼接增量内容delta.content 是当前 token 的文本片段,不断追加到 content.value,实现“打字机”效果。

💡 为什么需要 buffer?
因为网络传输的 chunk 可能不完整(比如一个 JSON 被切成两半),所以未解析成功的部分暂存到 buffer,下次循环再拼接处理。


七、非流式模式的简化处理

如果不启用流式(stream = false),则直接等待完整响应:

else {
  const data = await response.json()
  content.value = data.choices[0].message.content
}

这种方式简单直接,但用户体验较差——用户需等待全部内容生成完毕才能看到结果。


八、模板与样式:简洁直观的 UI

<template>
  <div class="container">
    <div>
      <label>输入: </label>
      <input v-model="question" />
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <label>Streaming</label>
      <input type="checkbox" v-model="stream" />
      <div>{{ content }}</div>
    </div>
  </div>
</template>
  • 用户可切换流式/非流式模式。
  • 输出区域实时展示 LLM 的回复。

样式使用 flex 布局,确保在不同屏幕下良好显示。


九、总结与延伸

通过这个 Demo,我们实现了:

✅ 使用 ref 管理响应式状态
✅ 利用 v-model 实现表单双向绑定
✅ 调用 DeepSeek API 发起聊天请求
✅ 支持流式与非流式两种输出模式
✅ 处理 SSE 流式响应,实现逐字输出效果


结语

这个项目虽小,却涵盖了 Vue 3 响应式、异步请求、流式处理等核心概念。正如笔记所说:“我们就可以聚焦于业务,不用写 DOM API 了”。这正是现代前端框架的价值所在——让我们从繁琐的 DOM 操作中解放出来,专注于创造更好的用户体验。

希望这篇解析能帮助你在稀土掘金的读者快速理解代码逻辑,并激发更多关于 AI + 前端的创意!

❌
❌