阅读视图

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

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

在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

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

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

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

本文将从原理角度切入,手把手带你设计并实现一个轻量级、功能完备的前端错误监控 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,你的导航效率翻倍!

朋友们,你们有没有在开发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来帮你,效率提升真的不止一点点。

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

引言

在 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!如果有任何问题或建议,欢迎讨论交流。

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

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 用法全解析

前言

在之前的文章里,介绍了 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 用法全解析

前言

上一篇介绍了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年最新前端场景题可以看这里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]

背景

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 在前端面试中是很常考的一道题 - 掘金

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

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


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

用户点击「发送」,页面死气沉沉地转圈圈 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计算属性如何通过缓存特性优化表单验证与数据过滤?

在日常开发中,表单验证和动态数据过滤几乎是每个项目都会遇到的需求——比如用户注册时要检查输入是否合法,商品列表要根据关键词实时筛选。这两个场景看似简单,但处理不好容易写出冗余、低效的代码。而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 构建流式大模型聊天应用的完整实现

深入解析:基于 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 如何让机器学会“慢慢说话”

当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 动态规划:从 “无限套娃计算器” 到 “积木式解题神器”

就占用你 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

在当前 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 + 前端的创意!

React Native vs React Web:深度对比与架构解析

一、引言:同一个理念,不同的实现

React 技术栈以其"Learn Once, Write Anywhere"的理念改变了前端开发格局。然而,许多开发者常混淆 React Native 和 React Web(通常简称 React)之间的区别。虽然它们共享相同的设计哲学,但在实现、架构和应用场景上存在本质差异。本文将深入探讨两者的核心区别,并通过代码示例、架构图展示实际差异。

二、核心理念对比

1. 设计哲学的异同

mindmap
  root(React 技术栈)
    核心理念
      组件化
      声明式UI
      单向数据流
    技术实现
      React Web
        :DOM操作
        :CSS样式
        :浏览器API
      React Native
        :原生组件
        :平台API
        :原生渲染

相同点:

  • 组件化开发模式
  • 虚拟DOM概念
  • JSX语法
  • 单向数据流
  • 生命周期管理(在函数组件中为Hooks)

不同点:

  • 渲染目标:React Web 渲染到浏览器DOM,React Native 渲染到原生UI组件
  • 样式系统:React Web 使用CSS,React Native 使用JavaScript对象
  • 生态体系:完全不同的第三方库生态系统
  • 平台能力:访问的平台API完全不同

三、架构深度解析

1. React Web 架构

// React Web 渲染流程
import React from 'react';
import ReactDOM from 'react-dom';

const App = () => {
  return (
    <div className="container">
      <h1>Hello React Web</h1>
      <p>This renders to DOM</p>
    </div>
  );
};

// 渲染到浏览器DOM
ReactDOM.render(<App />, document.getElementById('root'));

React Web 架构流程图:

flowchart TD
    A[JSX/组件] --> B[React.createElement<br>创建虚拟DOM]
    B --> C[Reconciliation<br>对比虚拟DOM差异]
    C --> D[DOM操作<br>更新实际DOM]
    D --> E[浏览器渲染<br>布局与绘制]
    E --> F[用户界面<br>HTML/CSS渲染]
    
    G[用户交互] --> H[事件处理]
    H --> A

2. React Native 架构

// React Native 渲染流程
import React from 'react';
import { 
  View, 
  Text, 
  StyleSheet,
  AppRegistry 
} from 'react-native';

const App = () => {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>Hello React Native</Text>
      <Text>This renders to native components</Text>
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  text: {
    fontSize: 20,
    fontWeight: 'bold',
  },
});

// 注册并启动应用
AppRegistry.registerComponent('MyApp', () => App);

React Native 架构流程图:

flowchart TD
    A[JSX/组件] --> B[JavaScript Core<br>执行React代码]
    B --> C[React Native Bridge<br>跨平台通信]
    C --> D{iOS/Android<br>原生模块}
    D --> E[iOS UIKit<br>Objective-C/Swift]
    D --> F[Android Views<br>Java/Kotlin]
    E --> G[原生UI渲染<br>iOS屏幕]
    F --> H[原生UI渲染<br>Android屏幕]
    
    I[用户交互] --> J[原生事件]
    J --> C
    C --> B

四、组件系统对比

1. 基础组件差异对比表

组件类型 React Web (HTML) React Native (原生) 功能说明
容器 <div> <View> 布局容器
文本 <span>, <p> <Text> 文本显示
图片 <img> <Image> 图片显示
按钮 <button> <Button>, <TouchableOpacity> 交互按钮
输入 <input> <TextInput> 文本输入
列表 <ul>, <table> <FlatList>, <ScrollView> 列表展示
滚动 <div style="overflow:auto"> <ScrollView> 滚动容器

2. 实际代码对比

// ============ REACT WEB ============
import React, { useState } from 'react';
import './styles.css'; // 引入CSS文件

const WebComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <div className="container">
      <header className="header">
        <h1 className="title">React Web App</h1>
      </header>
      <main className="content">
        <p className="count-text">Count: {count}</p>
        <button 
          className="button" 
          onClick={() => setCount(count + 1)}
        >
          Increment
        </button>
        <input 
          type="text" 
          className="input" 
          placeholder="Enter text..."
        />
        <img 
          src="/logo.png" 
          alt="Logo" 
          className="logo"
        />
      </main>
    </div>
  );
};

// ============ REACT NATIVE ============
import React, { useState } from 'react';
import {
  View,
  Text,
  TouchableOpacity,
  TextInput,
  Image,
  StyleSheet,
  SafeAreaView,
} from 'react-native';

const NativeComponent = () => {
  const [count, setCount] = useState(0);

  return (
    <SafeAreaView style={styles.container}>
      <View style={styles.header}>
        <Text style={styles.title}>React Native App</Text>
      </View>
      <View style={styles.content}>
        <Text style={styles.countText}>Count: {count}</Text>
        <TouchableOpacity
          style={styles.button}
          onPress={() => setCount(count + 1)}
        >
          <Text style={styles.buttonText}>Increment</Text>
        </TouchableOpacity>
        <TextInput
          style={styles.input}
          placeholder="Enter text..."
          placeholderTextColor="#999"
        />
        <Image
          source={require('./logo.png')}
          style={styles.logo}
          resizeMode="contain"
        />
      </View>
    </SafeAreaView>
  );
};

// React Native 样式定义
const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#f5f5f5',
  },
  header: {
    padding: 20,
    backgroundColor: '#007AFF',
    alignItems: 'center',
  },
  title: {
    fontSize: 24,
    fontWeight: 'bold',
    color: 'white',
  },
  content: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
    padding: 20,
  },
  countText: {
    fontSize: 32,
    marginBottom: 20,
  },
  button: {
    backgroundColor: '#007AFF',
    paddingHorizontal: 30,
    paddingVertical: 15,
    borderRadius: 8,
    marginBottom: 20,
  },
  buttonText: {
    color: 'white',
    fontSize: 18,
    fontWeight: '600',
  },
  input: {
    width: '80%',
    height: 50,
    borderWidth: 1,
    borderColor: '#ddd',
    borderRadius: 8,
    paddingHorizontal: 15,
    marginBottom: 20,
    fontSize: 16,
  },
  logo: {
    width: 100,
    height: 100,
  },
});

五、样式系统深度对比

1. React Web 样式系统

/* styles.css - CSS Modules 示例 */
.container {
  max-width: 1200px;
  margin: 0 auto;
  padding: 20px;
}

.button {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  padding: 12px 24px;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s ease;
}

.button:hover {
  transform: translateY(-2px);
  box-shadow: 0 10px 20px rgba(0,0,0,0.2);
}

/* CSS-in-JS 示例 (styled-components) */
import styled from 'styled-components';

const StyledButton = styled.button`
  background: ${props => props.primary ? '#007AFF' : '#ccc'};
  color: white;
  padding: 12px 24px;
  border: none;
  border-radius: 6px;
  font-size: 16px;
  
  &:hover {
    opacity: 0.9;
  }
  
  &:active {
    transform: scale(0.98);
  }
`;

2. React Native 样式系统

// StyleSheet 示例
import { StyleSheet, Dimensions } from 'react-native';

const { width, height } = Dimensions.get('window');

const styles = StyleSheet.create({
  container: {
    flex: 1,
    width: width, // 响应式宽度
    backgroundColor: '#ffffff',
  },
  card: {
    shadowColor: '#000',
    shadowOffset: {
      width: 0,
      height: 2,
    },
    shadowOpacity: 0.25,
    shadowRadius: 3.84,
    elevation: 5, // Android阴影
    borderRadius: 10,
    backgroundColor: 'white',
    margin: 10,
    padding: 15,
  },
  gradientButton: {
    // 注意:React Native 需要第三方库实现渐变
    backgroundColor: '#007AFF',
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 8,
  },
});

// 响应式布局示例
const responsiveStyles = StyleSheet.create({
  container: {
    flexDirection: width > 768 ? 'row' : 'column',
  },
  column: {
    flex: width > 768 ? 1 : undefined,
  },
});

// 平台特定样式
const platformStyles = StyleSheet.create({
  header: {
    paddingTop: Platform.OS === 'ios' ? 50 : 25, // iOS有安全区域
    ...Platform.select({
      ios: {
        backgroundColor: '#f8f8f8',
      },
      android: {
        backgroundColor: '#ffffff',
      },
    }),
  },
});

六、导航系统对比

1. React Web 导航

// React Router 示例
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom';

const WebNavigation = () => (
  <BrowserRouter>
    <nav>
      <Link to="/">Home</Link>
      <Link to="/about">About</Link>
      <Link to="/contact">Contact</Link>
    </nav>
    
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
      <Route path="/contact" element={<Contact />} />
      <Route path="/user/:id" element={<UserProfile />} />
    </Routes>
  </BrowserRouter>
);

// 历史记录API访问
const navigateToAbout = () => {
  window.history.pushState({}, '', '/about');
  // 或使用react-router的useNavigate
};

2. React Native 导航

// React Navigation 示例
import { NavigationContainer } from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';

const Stack = createNativeStackNavigator();
const Tab = createBottomTabNavigator();

// 栈式导航
const AppNavigator = () => (
  <NavigationContainer>
    <Stack.Navigator
      initialRouteName="Home"
      screenOptions={{
        headerStyle: {
          backgroundColor: '#007AFF',
        },
        headerTintColor: '#fff',
      }}
    >
      <Stack.Screen 
        name="Home" 
        component={HomeScreen}
        options={{ title: '首页' }}
      />
      <Stack.Screen 
        name="Details" 
        component={DetailsScreen}
        options={({ route }) => ({ 
          title: route.params?.title || '详情'
        })}
      />
    </Stack.Navigator>
  </NavigationContainer>
);

// 标签页导航
const TabNavigator = () => (
  <Tab.Navigator
    screenOptions={({ route }) => ({
      tabBarIcon: ({ focused, color, size }) => {
        let iconName;
        if (route.name === 'Home') {
          iconName = focused ? 'home' : 'home-outline';
        } else if (route.name === 'Settings') {
          iconName = focused ? 'settings' : 'settings-outline';
        }
        return <Icon name={iconName} size={size} color={color} />;
      },
    })}
  >
    <Tab.Screen name="Home" component={HomeScreen} />
    <Tab.Screen name="Settings" component={SettingsScreen} />
  </Tab.Navigator>
);

七、平台API访问对比

1. React Web API 访问

// 浏览器API访问示例
class WebAPIService {
  // 本地存储
  static saveData(key, value) {
    localStorage.setItem(key, JSON.stringify(value));
  }
  
  static getData(key) {
    const data = localStorage.getItem(key);
    return data ? JSON.parse(data) : null;
  }
  
  // 地理位置
  static async getLocation() {
    return new Promise((resolve, reject) => {
      if (!navigator.geolocation) {
        reject(new Error('Geolocation not supported'));
        return;
      }
      
      navigator.geolocation.getCurrentPosition(
        position => resolve(position.coords),
        error => reject(error),
        { enableHighAccuracy: true }
      );
    });
  }
  
  // 摄像头访问
  static async accessCamera() {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true,
    });
    return stream;
  }
  
  // 网络状态
  static getNetworkStatus() {
    return {
      online: navigator.onLine,
      connection: navigator.connection || {},
    };
  }
}

// 使用示例
WebAPIService.saveData('user', { name: 'John' });
const location = await WebAPIService.getLocation();

2. React Native API 访问

// React Native 原生模块访问
import {
  AsyncStorage,
  Geolocation,
  PermissionsAndroid,
  Platform,
} from 'react-native';
import CameraRoll from '@react-native-community/cameraroll';
import NetInfo from '@react-native-community/netinfo';

class NativeAPIService {
  // 本地存储(使用AsyncStorage)
  static async saveData(key, value) {
    try {
      await AsyncStorage.setItem(key, JSON.stringify(value));
    } catch (error) {
      console.error('保存数据失败:', error);
    }
  }
  
  static async getData(key) {
    try {
      const value = await AsyncStorage.getItem(key);
      return value ? JSON.parse(value) : null;
    } catch (error) {
      console.error('读取数据失败:', error);
      return null;
    }
  }
  
  // 地理位置(需要权限)
  static async getLocation() {
    if (Platform.OS === 'android') {
      const granted = await PermissionsAndroid.request(
        PermissionsAndroid.PERMISSIONS.ACCESS_FINE_LOCATION
      );
      if (granted !== PermissionsAndroid.RESULTS.GRANTED) {
        throw new Error('Location permission denied');
      }
    }
    
    return new Promise((resolve, reject) => {
      Geolocation.getCurrentPosition(
        position => resolve(position.coords),
        error => reject(error),
        { enableHighAccuracy: true, timeout: 15000 }
      );
    });
  }
  
  // 访问相册
  static async getPhotos(params) {
    try {
      const photos = await CameraRoll.getPhotos(params);
      return photos;
    } catch (error) {
      console.error('获取照片失败:', error);
      throw error;
    }
  }
  
  // 网络状态监听
  static setupNetworkListener(callback) {
    return NetInfo.addEventListener(state => {
      callback(state);
    });
  }
  
  // 设备信息
  static getDeviceInfo() {
    return {
      platform: Platform.OS,
      version: Platform.Version,
      isPad: Platform.isPad,
      isTV: Platform.isTV,
    };
  }
}

// 使用示例
const location = await NativeAPIService.getLocation();
const unsubscribe = NativeAPIService.setupNetworkListener(state => {
  console.log('网络状态:', state.isConnected);
});

八、性能优化策略对比

性能优化对比表

优化维度 React Web React Native 说明
渲染优化 Virtual DOM Diff 原生组件更新 React Web 操作DOM,RN直接更新原生组件
图片优化 Lazy Loading FastImage RN需要特殊处理图片缓存
列表优化 Virtual Scrolling FlatList优化 两者都需要虚拟化长列表
代码分割 Webpack动态导入 Metro Bundle分块 RN需要原生配置支持
内存管理 自动垃圾回收 需注意原生模块内存 RN需要手动管理部分内存

1. React Web 性能优化

// 代码分割和懒加载
const LazyComponent = React.lazy(() => import('./HeavyComponent'));

// 使用memo和useCallback
const MemoizedComponent = React.memo(({ data }) => (
  <div>{data}</div>
));

// 虚拟化长列表
import { FixedSizeList } from 'react-window';

const VirtualizedList = ({ items }) => (
  <FixedSizeList
    height={400}
    width={300}
    itemCount={items.length}
    itemSize={50}
  >
    {({ index, style }) => (
      <div style={style}>
        Item {items[index]}
      </div>
    )}
  </FixedSizeList>
);

// Web Workers 处理耗时任务
const worker = new Worker('./heavy-task.worker.js');
worker.postMessage(data);
worker.onmessage = (event) => {
  console.log('结果:', event.data);
};

2. React Native 性能优化

// 使用PureComponent或memo
class OptimizedComponent extends React.PureComponent {
  render() {
    return <Text>{this.props.data}</Text>;
  }
}

// 优化FlatList
const OptimizedList = ({ data }) => (
  <FlatList
    data={data}
    keyExtractor={item => item.id}
    renderItem={renderItem}
    initialNumToRender={10}
    maxToRenderPerBatch={5}
    windowSize={21}
    removeClippedSubviews={true}
    getItemLayout={(data, index) => ({
      length: 50,
      offset: 50 * index,
      index,
    })}
  />
);

// 使用InteractionManager处理动画
InteractionManager.runAfterInteractions(() => {
  // 耗时操作,避免阻塞动画
});

// 图片优化
import FastImage from 'react-native-fast-image';

<FastImage
  style={styles.image}
  source={{
    uri: 'https://example.com/image.jpg',
    priority: FastImage.priority.normal,
    cache: FastImage.cacheControl.immutable,
  }}
/>;

九、开发体验对比

开发环境配置差异

# React Web 开发环境
开发工具: VSCode/WebStorm
包管理器: npm/yarn
构建工具: Webpack/Vite
开发服务器: webpack-dev-server
热重载: 内置支持
调试工具: Chrome DevTools

# React Native 开发环境
开发工具: VSCode/WebStorm/Xcode/Android Studio
包管理器: npm/yarn
构建工具: Metro Bundler
模拟器: iOS Simulator/Android Emulator
真机调试: 需要USB连接
调试工具: React Native Debugger/Flipper

热重载机制对比

// React Web 热重载流程
1. 文件保存 → 2. Webpack检测变化 → 3. 重新编译模块
4. 通过WebSocket推送更新 → 5. 客户端接收更新
6. 替换模块 → 7. 保留应用状态

// React Native 热重载流程
1. 文件保存 → 2. Metro检测变化 → 3. 增量构建
4. 推送更新到设备 → 5. 原生容器重新渲染
6. 保持JavaScript状态

十、跨平台复用策略

1. 共享业务逻辑

// shared/ 目录结构
shared/
├── api/
│   └── apiClient.js      # 网络请求封装
├── utils/
│   ├── dateFormatter.js  # 日期格式化
│   ├── validator.js      # 表单验证
│   └── constants.js      # 常量定义
├── services/
│   └── authService.js    # 认证服务
└── hooks/
    └── useFetch.js       # 自定义Hook

// 示例:共享的API客户端
class ApiClient {
  constructor(baseURL) {
    this.baseURL = baseURL;
  }

  async request(endpoint, options = {}) {
    const url = `${this.baseURL}${endpoint}`;
    const response = await fetch(url, {
      headers: {
        'Content-Type': 'application/json',
        ...options.headers,
      },
      ...options,
    });

    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }

    return response.json();
  }

  // 可在Web和Native中复用的方法
  async getUser(id) {
    return this.request(`/users/${id}`);
  }

  async createPost(data) {
    return this.request('/posts', {
      method: 'POST',
      body: JSON.stringify(data),
    });
  }
}

2. 条件平台渲染

// PlatformSpecific.js
import React from 'react';
import { Platform } from 'react-native';

// 方法1: 平台特定文件扩展名
// MyComponent.ios.js 和 MyComponent.android.js

// 方法2: 平台检测
const PlatformSpecificComponent = () => {
  if (Platform.OS === 'web') {
    return (
      <div className="web-container">
        <p>This is web version</p>
      </div>
    );
  }

  return (
    <View style={styles.nativeContainer}>
      <Text>This is native version</Text>
    </View>
  );
};

// 方法3: 平台特定Hook
const usePlatform = () => {
  return {
    isWeb: Platform.OS === 'web',
    isIOS: Platform.OS === 'ios',
    isAndroid: Platform.OS === 'android',
    platform: Platform.OS,
  };
};

// 方法4: 共享组件适配器
const Button = ({ title, onPress }) => {
  const { isWeb } = usePlatform();
  
  if (isWeb) {
    return (
      <button 
        className="button"
        onClick={onPress}
      >
        {title}
      </button>
    );
  }
  
  return (
    <TouchableOpacity
      style={styles.button}
      onPress={onPress}
    >
      <Text style={styles.buttonText}>{title}</Text>
    </TouchableOpacity>
  );
};

十一、实际项目架构示例

跨平台项目结构

my-cross-platform-app/
├── packages/
│   ├── web/                    # React Web 应用
│   │   ├── public/
│   │   ├── src/
│   │   ├── package.json
│   │   └── webpack.config.js
│   ├── mobile/                 # React Native 应用
│   │   ├── ios/
│   │   ├── android/
│   │   ├── src/
│   │   └── package.json
│   └── shared/                 # 共享代码
│       ├── components/         # 跨平台组件
│       ├── utils/             # 工具函数
│       ├── services/          # API服务
│       └── hooks/             # 自定义Hooks
├── package.json
└── yarn.lock

跨平台组件实现

// shared/components/Button/index.js
import React from 'react';
import { Platform } from 'react-native';

// 平台特定的实现
import { ButtonWeb } from './Button.web';
import { ButtonNative } from './Button.native';

const Button = (props) => {
  if (Platform.OS === 'web') {
    return <ButtonWeb {...props} />;
  }
  
  return <ButtonNative {...props} />;
};

export default Button;

// shared/components/Button/Button.web.js
import React from 'react';
import PropTypes from 'prop-types';

export const ButtonWeb = ({ 
  title, 
  onPress, 
  variant = 'primary',
  disabled 
}) => {
  return (
    <button
      className={`button button-${variant}`}
      onClick={onPress}
      disabled={disabled}
      style={{
        padding: '12px 24px',
        borderRadius: '6px',
        border: 'none',
        cursor: disabled ? 'not-allowed' : 'pointer',
        opacity: disabled ? 0.6 : 1,
      }}
    >
      {title}
    </button>
  );
};

// shared/components/Button/Button.native.js
import React from 'react';
import { 
  TouchableOpacity, 
  Text, 
  StyleSheet,
  ActivityIndicator 
} from 'react-native';

export const ButtonNative = ({ 
  title, 
  onPress, 
  variant = 'primary',
  disabled,
  loading 
}) => {
  const variantStyles = {
    primary: styles.primaryButton,
    secondary: styles.secondaryButton,
    outline: styles.outlineButton,
  };

  return (
    <TouchableOpacity
      style={[
        styles.button,
        variantStyles[variant],
        disabled && styles.disabled,
      ]}
      onPress={onPress}
      disabled={disabled || loading}
      activeOpacity={0.7}
    >
      {loading ? (
        <ActivityIndicator 
          color={variant === 'outline' ? '#007AFF' : 'white'} 
        />
      ) : (
        <Text style={[
          styles.buttonText,
          variant === 'outline' && styles.outlineText,
        ]}>
          {title}
        </Text>
      )}
    </TouchableOpacity>
  );
};

const styles = StyleSheet.create({
  button: {
    paddingVertical: 12,
    paddingHorizontal: 24,
    borderRadius: 6,
    alignItems: 'center',
    justifyContent: 'center',
    minHeight: 48,
  },
  primaryButton: {
    backgroundColor: '#007AFF',
  },
  secondaryButton: {
    backgroundColor: '#6c757d',
  },
  outlineButton: {
    backgroundColor: 'transparent',
    borderWidth: 1,
    borderColor: '#007AFF',
  },
  buttonText: {
    color: 'white',
    fontSize: 16,
    fontWeight: '600',
  },
  outlineText: {
    color: '#007AFF',
  },
  disabled: {
    opacity: 0.6,
  },
});

十二、总结与选择建议

关键差异总结表

方面 React Web React Native 建议
目标平台 浏览器 iOS/Android移动端 根据目标用户选择
渲染方式 DOM操作 原生组件调用 Web适合内容展示,RN适合应用体验
开发体验 浏览器DevTools 模拟器/真机调试 Web开发更直观
性能特点 受浏览器限制 接近原生性能 性能敏感选RN
热更新 即时生效 需要重新打包 快速迭代选Web
发布流程 直接部署 应用商店审核 频繁更新选Web

选择建议流程图

flowchart TD
    A[项目需求分析] --> B{目标平台?}
    B --> C[仅Web/桌面端]
    B --> D[仅移动端<br>iOS/Android]
    B --> E[全平台覆盖]
    
    C --> F[选择 React Web<br>最佳开发体验]
    D --> G[选择 React Native<br>原生体验]
    E --> H{项目类型?}
    
    H --> I[内容型应用<br>新闻/博客/电商]
    H --> J[交互型应用<br>社交/工具/游戏]
    
    I --> K[优先 React Web<br>考虑PWA]
    J --> L[优先 React Native<br>考虑跨平台组件]
    
    K --> M[评估用户需求<br>适时添加React Native]
    L --> N[复用业务逻辑<br>平台特定UI]
    
    M --> O[监控用户反馈<br>数据驱动决策]
    N --> P[持续优化<br>保持代码复用]

最佳实践建议

  1. 新项目启动

    • 明确目标平台和用户群体
    • 评估团队技术栈熟悉度
    • 考虑长期维护成本
  2. 现有项目扩展

    • React Web项目可逐步集成PWA
    • React Native项目可考虑Web支持
    • 优先复用业务逻辑,平台差异通过适配层处理
  3. 团队建设

    • React Web和React Native需要不同的专业知识
    • 建立共享代码规范和组件库
    • 培养全栈React开发工程师
  4. 技术选型

    • 内容为主的应用优先考虑Web + PWA
    • 需要设备功能的应用优先考虑React Native
    • 大型企业应用考虑微前端架构

结语

React Web和React Native虽然共享相同的设计理念,但在实现、架构和应用场景上有本质区别。理解这些差异不仅有助于选择正确的技术栈,还能在跨平台开发中做出更明智的架构决策。

随着React生态的不断发展,两个平台之间的界限逐渐模糊。React Native for Web等项目正在尝试弥合这个鸿沟,未来的开发可能会更加无缝。无论选择哪个平台,深入理解React的核心概念都是成功的关键。

JS 实现指定 UA 访问网站跳转弹窗提醒,解决夸克等浏览器兼容性问题

在近期的网站使用过程中,我们发现来自部分移动端浏览器(尤其是 夸克浏览器、UC 浏览器、百度 APP 内置浏览器、微信内置浏览器)的访问量虽然不低,但这些浏览器在解析网页脚本、CSS 动画、内嵌组件等方面存在一定兼容性问题,导致页面在这些环境中出现:

  • 布局错乱
  • 按钮点击无反应
  • JS 逻辑异常
  • 视频、音频组件无法正常加载

这些问题严重影响了用户体验。经过多次调试和对比测试,我们最终决定对 不兼容的浏览器进行识别,并给出友好的弹窗提醒或跳转提示页,以引导用户使用更标准、兼容性更好的浏览器,例如 手机自带浏览器或 Edge 浏览器


一、问题出现的原因分析

由于部分国产浏览器对 Web 标准的支持不够完整,或在系统内嵌中屏蔽了某些关键 API(例如微信屏蔽文件下载、百度 APP 限制外链等),网站在这些浏览器中运行时容易出现:

  • 资源加载失败
  • DOM 或事件机制被限制
  • JS 执行顺序异常
  • WebView 内核差异导致样式渲染不一致

即使对前端代码进行兼容性优化,也难以完全规避这些内核级别的限制。

因此,我们决定采用 前端 User-Agent 判断 + 跳转提示页或弹窗提示 的方式,让用户主动切换到更稳定的浏览器环境。


二、解决方案:使用 JS 判断 UA 并提示用户更换浏览器

相比通过 nginx 层面判断,前端 JS 方案具有更灵活、更易部署的优势:

  • 无需修改服务器配置,前端即可快速发布
  • 可自由定制弹窗样式与行为
  • 可根据业务需求选择跳转或仅弹窗提醒

核心思路是通过 navigator.userAgent 检测访问者的浏览器类型,并对不兼容浏览器执行跳转或弹窗逻辑。


三、JS 代码实现(跳转或弹窗两种方式)

1. 判断 UA 的核心代码

(function() {
  var ua = navigator.userAgent || '';

  // 不兼容浏览器关键词
  var isBadBrowser = /Quark|UCBrowser|UCWEB|baiduboxapp|baidu|MicroMessenger/i.test(ua);

  // 是否为移动端(可选)
  var isMobile = /Android|iPhone|iPad|iPod|Windows Phone/i.test(ua);

  if (isMobile && isBadBrowser) {
    // 跳转到提示页面
    window.location.href = 'https://gptmirror.pftedu.com/browser_notice.html';
  }
})();

该脚本可放在网站的公共 JS 中,也可以直接写入需要保护的页面内。


四、提示页面示例(browser_notice.html)

用户访问后会自动展示弹窗提示,内容可按需求调整:

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <title>浏览器不兼容提示</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <script>
  window.onload = function() {
    alert('当前浏览器不兼容,请使用手机自带浏览器或 Edge 浏览器访问网站。');
  };
  </script>
</head>
<body style="text-align:center;padding:40px 20px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto">
  <h2>浏览器不兼容</h2>
  <p style="margin-top:20px;line-height:1.6;">
    检测到您正在使用:夸克 / UC / 百度APP / 微信内置浏览器。<br>
    为了保证良好的访问体验,请使用:
  </p>
  <p style="margin-top:10px;font-weight:bold;">
    手机自带浏览器 或 Microsoft Edge 浏览器
  </p>
</body>
</html>

五、方案效果与优点

实测效果表明:

  • 在夸克、UC、百度 APP、微信内置浏览器中均成功跳转提示页
  • 弹窗提醒清晰明确,用户理解成本低
  • 使用标准浏览器访问则完全不影响正常使用

最终实现了:

✔ 避免浏览器兼容性差导致页面异常 ✔ 提高整体访问稳定性与用户体验 ✔ 易于维护和扩展,可随时增加或修改 UA 规则


六、总结

由于某些浏览器(尤其是 APP 内置 WebView)对 Web 标准的支持不足,我们的网站在这些环境下出现了功能和显示问题。通过前端 JS 实现 指定 UA 自动跳转并弹窗提示,成功解决了用户反馈的兼容性错误。

这是一种简单、高效、可快速上线的浏览器兼容性解决方案。

代码的“病历本”:深入解读C#常见异常

异常的常见几种类型

1. NullReferenceException:空指针引用异常

```csharp
// 场景1: 未初始化的对象
List<string> names = null;
Console.WriteLine(names.Count); // 异常: names 是 null

// 场景2: 方法返回了 null
string FindUserById(int id)
{
    // 假设数据库里没找到id=999的用户
    if (id == 999) return null;
    return "Admin";
}
string user = FindUserById(999);
Console.WriteLine(user.ToUpper()); // 异常: user 是 null
```
  • 解决方案:
    1. 防御性检查:在访问任何可能为 null 的对象前,进行显式的 null 检查。
      if (user != null) {
          Console.WriteLine(user.ToUpper());
      }
      
    2. 空条件运算符 ?.??
      // 如果 user 是 null,整个表达式直接返回 null,而不是抛出异常
      string upperUser = user?.ToUpper(); 
      
      // 如果 user 是 null,则使用 "Guest" 作为默认值
      string displayName = user ?? "Guest"; 
      
      // 组合使用
      Console.WriteLine(user?.ToUpper() ?? "USER NOT FOUND");
      
    3. 可空引用类型:通过静态分析,在编译时就警告你潜在的 NullReferenceException
      #nullable enable // 开启可空引用类型检查
      string? user = FindUserById(999); // ? 表示 user 可以为 null
      Console.WriteLine(user.ToUpper()); // 编译器会在这里发出警告!
      

2. IndexOutOfRangeException:索引越界异常

  • 当你试图用一个无效的索引来访问数组、列表(List<T>)或其他基于索引的集合的元素时,此异常就会被抛出。无效索引指的是小于0,或大于等于集合的元素数量。

    int[] scores = { 98, 76, 100 };
    // 场景1: 访问不存在的索引
    Console.WriteLine(scores[3]); //  有效索引是 0, 1, 2
    
    // 场景2: 循环条件错误
    for (int i = 0; i <= scores.Length; i++) { // 错误在于 i <= Length
        Console.WriteLine(scores[i]); // 当 i 等于 scores.Length (3) 时 BOOM!
    }
    
    // 场景3: 对空集合进行索引访问
    var emptyList = new List<string>();
    Console.WriteLine(emptyList[0]); // BOOM!
    
  • 解决方案:

    1. 正确的循环:在 for 循环中,永远使用 < 而不是 <= 来比较索引和集合长度。
      for (int i = 0; i < scores.Length; i++) { /* 安全 */ }
      
    2. 优先使用 foreachforeach 循环在内部处理了迭代逻辑,完全避免了手动操作索引,从而根除了此类错误。
      foreach (var score in scores) { /* 绝对安全 */ }
      
    3. 边界检查:在直接通过索引访问前,检查索引是否在有效范围内。
      int index = GetUserInput();
      if (index >= 0 && index < scores.Length) {
          Console.WriteLine(scores[index]);
      } else {
          Console.WriteLine("索引无效!");
      }
      

3. FormatException:格式异常

  • 当一个方法的参数格式不符合预期时抛出,最常见于字符串向其他数据类型(如数字、日期)的转换。

    string userInput = "twelve";
    int number = int.Parse(userInput); // "twelve" 不是有效的整数格式
    
    string dateString = "2023-30-01"; // 无效的日期 (30月)
    DateTime date = DateTime.Parse(dateString); //
    
  • 解决方案:

    1. 使用 TryParse 模式:这是应对 FormatException最佳实践TryParse 方法会尝试转换,如果成功,返回 true 并通过 out 参数提供结果;如果失败,返回 false不会抛出异常。这遵循了“先看后跳”(LBYL)的原则。
      string userInput = Console.ReadLine();
      if (int.TryParse(userInput, out int number)) {
          Console.WriteLine($"转换成功: {number}");
      } else {
          Console.WriteLine("输入无效,请输入一个数字。");
      }
      

3.1 ArgumentException 家族 (ArgumentNullException, ArgumentOutOfRangeException)

  • ArgumentException:通用的参数错误,表示传递给方法的某个参数不合法。

  • ArgumentNullExceptionArgumentException 的子类,特指一个不应为 null 的参数被传入了 null

  • ArgumentOutOfRangeExceptionArgumentException 的子类,特指一个参数的值超出了可接受的范围。

  • 示例:

    public void SetUserName(string name) 
    {
        // 卫语句:主动防御,而不是等着用到 name 时再爆炸
        if (string.IsNullOrWhiteSpace(name)) 
        {
            throw new ArgumentException("用户名不能为空或仅包含空白字符。", nameof(name));
        }
        this.UserName = name;
    }
    
    public void SetDiscount(double percentage) 
    {
        if (percentage < 0 || percentage > 1) 
        {
            throw new ArgumentOutOfRangeException(nameof(percentage), "折扣必须在0和1之间。");
        }
        this.Discount = percentage;
    }
    
    // 调用者犯错
    myObject.SetUserName(null); // 会立即捕获到 ArgumentException,而不是后面的 NullReferenceException
    

4. InvalidCastException:无效转换异常

  • 在运行时执行了一个显式的类型转换,但源类型无法被转换为目标类型时发生

  • 示例:

    object myObject = "Hello World";
    // 这是一个字符串,不能被强制转换为一个 StringBuilder
    StringBuilder sb = (StringBuilder)myObject; 
    
  • 解决方案:

    1. 使用 as 运算符as 运算符尝试进行转换,如果成功,返回转换后的对象;如果失败,它会返回 null不是抛出异常。然后你可以配合 null 检查来安全地执行后续操作。
      StringBuilder sb = myObject as StringBuilder;
      if (sb != null) {
          sb.Append("...");
      } else {
          Console.WriteLine("对象不是 StringBuilder 类型。");
      }
      
    2. 使用 is 运算符和模式匹配is 运算符检查一个对象是否兼容某个类型
      if (myObject is StringBuilder sb) {
          sb.Append("...");
      }
      

    as/is vs. 强制转换:与 TryParse vs. Parse 类似,如果你不确定转换能否成功,就应该使用 asis。只有在你100%确定类型兼容时,才使用强制转换,因为它能更早地暴露逻辑错误。

5. InvalidOperationException:无效操作异常

  • 当方法调用对于对象的当前状态无效时抛出。错误不在于参数,而在于对象“还没准备好”或“已处于不当状态”。

  • 示例:

    // 场景1: 修改正在迭代的集合
    var numbers = new List<int> { 1, 2, 3, 4 };
    foreach (var number in numbers) {
        if (number == 2) {
            numbers.Remove(number); //  不能在 foreach 循环中修改集合
        }
    }
    
    // 场景2: 使用已耗尽的迭代器
    var enumerator = numbers.GetEnumerator();
    while(enumerator.MoveNext()) { }
    // 迭代器已到末尾
    Console.WriteLine(enumerator.Current); // BOOM! (或返回默认值,取决于实现)
    

6. IOException 家族 (FileNotFoundException, DirectoryNotFoundException, etc.)

  • 所有与输入/输出(I/O)操作相关的错误的基类。
    • FileNotFoundException:尝试访问一个不存在的文件。
    • DirectoryNotFoundException:文件路径中的某个目录不存在。
    • UnauthorizedAccessException:程序没有足够的权限去访问文件或目录。
  • 核心特点:这类异常是典型的必须使用 try-catch 来处理的场景。因为即使你在操作前用 File.Exists 检查,也无法保证在检查和实际操作之间的瞬间,文件不会被用户或其他程序删除(这被称为“竞态条件”)。
  • 最佳实践
    try
    {
        string content = File.ReadAllText(@"C:\secret\data.txt");
        // ... process content ...
    }
    catch (FileNotFoundException ex)
    {
        Console.WriteLine("错误:文件未找到。请确认文件路径是否正确。");
    }
    catch (UnauthorizedAccessException ex)
    {
        Console.WriteLine("错误:权限不足。请尝试以管理员身份运行程序。");
    }
    catch (IOException ex) // 兜底处理其他I/O错误
    {
        Console.WriteLine($"发生了一个I/O错误: {ex.Message}");
    }
    

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文

❌