阅读视图

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

上架元服务-味寻纪 技术分享

项目概述

味寻纪-元服务是一款基于鸿蒙HarmonyOS Next的美食应用,集成了鸿蒙官方推荐的zrouter路由管理方案,并实现了完整的用户认证体系包括静默登录功能。项目采用现代的组件化架构,提供流畅的用户体验。

image-20251121103048327

zrouter核心特性与功能

image-20251121103359970

1. 统一路由管理

zrouter作为鸿蒙官方推荐的路由管理方案,为应用提供了强大的页面导航能力。在味寻纪项目中,zrouter承担了以下核心功能:

1.1 自动路由生成

通过router-register-plugin插件自动扫描生成路由构建器,项目中已自动生成了11个页面的路由文件:

// _generated目录下的自动生成的路由文件
entry\src\main\ets_generated\
├── ZRBrowseHistoryPage.ets      // 浏览历史页面
├── ZRCookingTechniqueDetailPage.ets  // 烹饪技法详情
├── ZRCookingToolDetailPage.ets       // 烹饪工具详情
├── ZREditProfilePage.ets             // 编辑资料页面
├── ZRFamilyMemberEditPage.ets        // 家庭成员编辑
├── ZRFamilyMemberListPage.ets        // 家庭成员列表
├── ZRFamilyMemberRecipesPage.ets     // 家庭成员菜谱
├── ZRFavoriteListPage.ets            // 收藏列表
├── ZRIngredientDetailPage.ets        // 食材详情
├── ZRRecipeDetailPage.ets            // 菜谱详情
├── ZRRecipeFilterPage.ets            // 菜谱筛选
├── ZRRecipeListPage.ets              // 菜谱列表
└── ZRTestPage.ets                    // 测试页面

image-20251121103117978

1.2 生命周期管理

zrouter内置了完整的页面生命周期管理,支持页面创建、销毁、返回等状态控制:

// 生命周期管理示例
import { Lifecycle, LifecycleEvent, LifecycleRegistry, ZRouterfrom "@hzw/zrouter";

@Builder
export function ZRTestPageBuilder() {
  ZRTestPage()
}

@ComponentV2
export struct ZRTestPage {
  @Local navDestinationIdstring = '';
  private pageStack: NavPathStacknull = null;

  @Lifecycle
  onCreate(want: Want, navStack: NavPathStack) {
    this.navDestinationId = want.parameters?.['navDestinationId'as string || '';
    this.pageStack = navStack;
    // 注册模板管理器
    ZRouter.templateMgr().register(this.navDestinationId)
  }

  @Lifecycle
  onShow() {
    // 页面显示时的逻辑
    if (this.pageStack) {
      const router = new LifecycleRegistry(this.navDestinationId);
      ZRouter.templateMgr().dispatch(this.navDestinationId, LifecycleEvent.ON_SHOW, router)
    }
  }

  @Lifecycle
  onBackPress(): boolean {
    // 处理返回键逻辑
    const router = new LifecycleRegistry(this.navDestinationId);
    const r = ZRouter.templateMgr().dispatch(this.navDestinationId, LifecycleEvent.ON_BACK_PRESS, router)
    return r || false;
  }
}

2. 强大的参数传递机制

zrouter提供了类型安全的参数传递功能,支持复杂的参数类型和结构。

2.1 基本参数传递
// 从首页跳转到菜谱详情页,并传递参数
ZRouter.getInstance().setParam({ recipeId: recipe.id }).push('RecipeDetailPage');

// 从筛选页面跳转到列表页面,传递筛选条件
ZRouter.getInstance().setParam(params).push('RecipeListPage');
2.2 复杂参数传递
// 传递多个筛选参数
const filterParams = {
  category: '川菜',
  difficulty: '简单',
  flavor: '香辣',
  includeIngredients: '牛肉,青椒',
  excludeIngredients: '洋葱',
  minTime: 10,
  maxTime: 60,
  keyword: '麻婆豆腐'
};

ZRouter.getInstance().setParam(filterParams).push('RecipeListPage');
2.3 参数获取与类型转换
// 在目标页面中获取参数
@ComponentV2
export struct RecipeListPage {
  @Local selectModeboolean = false;
  @Local memberIdnumber = 0;

  aboutToAppear() {
    // 获取路由参数
    this.selectMode = ZRouter.getInstance().getParamByKey('selectMode'as boolean;
    this.memberId = ZRouter.getInstance().getParamByKey('memberId'as number;
    
    // 获取筛选参数
    const category = ZRouter.getInstance().getParamByKey('category'as string;
    const difficulty = ZRouter.getInstance().getParamByKey('difficulty'as string;
    const flavor = ZRouter.getInstance().getParamByKey('flavor'as string;
    const includeIngredients = ZRouter.getInstance().getParamByKey('includeIngredients'as string;
    const excludeIngredients = ZRouter.getInstance().getParamByKey('excludeIngredients'as string;
    const minTime = ZRouter.getInstance().getParamByKey('minTime'as number;
    const maxTime = ZRouter.getInstance().getParamByKey('maxTime'as number;
    const keyword = ZRouter.getInstance().getParamByKey('keyword'as string;
  }
}

3. 声明式路由配置

zrouter支持简洁的装饰器语法,让路由配置更加优雅:

import { Routefrom '@hzw/zrouter';

@Route({ name'TestPage', useTemplatetrue })
@Component
export struct TestPage {
  @State messagestring = 'This is Test Page';

  build() {
    NavDestination() {
      Column({ space20 }) {
        Text(this.message)
          .fontSize(24)
          .fontWeight(FontWeight.Bold)
        
        Text('ZRouter 引入成功!')
          .fontSize(18)
          .fontColor('#4CAF50')
      }
      .width('100%')
      .height('100%')
      .justifyContent(FlexAlign.Center)
    }
    .title('测试页面')
    .width('100%')
    .height('100%')
  }
}

项目集成实践

1. 依赖配置

在项目的oh-package.json5中添加zrouter依赖:

{
  "dependencies": {
    "@hzw/zrouter": "^1.8.2"
  }
}

![依赖配置示意图]图3:oh-package.json5中的zrouter依赖配置

2. 初始化配置

EntryAbilityonCreate方法中初始化zrouter:

import { ZRouter } from '@hzw/zrouter';

export default class EntryAbility extends UIAbility {
  onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
    // 初始化 ZRouter
    ZRouter.initialize((config) => {
      config.context = this.context;
      config.isLoggingEnabled = true// 开发环境开启日志
      config.isHSPModuleDependent = false// 如果有HSP模块设置为true
    });
  }
}

3. 导航容器配置

在主页面中使用zrouter的导航栈:

import { ZRouterfrom '@hzw/zrouter';

@ComponentV2
export struct Index {
  build() {
    Navigation(ZRouter.getNavStack()) {
      HomePage()
    }
    .mode(NavigationMode.Stack)
  }
}

4. 代码混淆适配

在混淆规则文件obfuscation-rules.txt中配置zrouter相关的保护规则:

# ZRouter 混淆配置
-keep-file-name
Index
_generated
ZR*

这确保了zrouter生成的页面名称和路由配置在混淆后仍能正常工作。

image-20251121103208018

静默登录功能实现

除了zrouter路由管理,项目还实现了完整的用户认证体系,其中静默登录是一大亮点功能。

image-20251121103220738

1. 静默登录核心逻辑

/**
 * 静默登录 - 不显示登录界面
 * @param enableSilentLogin 是否启用静默登录
 * @returns 登录是否成功
 */
async performSilentLogin(enableSilentLoginboolean = true): Promise<boolean> {
  // 如果未启用静默登录,直接返回
  if (!enableSilentLogin) {
    hilog.info(DOMAIN'AuthManager''静默登录已关闭');
    return false;
  }

  try {
    hilog.info(DOMAIN'AuthManager''开始静默登录');

    // 创建静默登录请求
    const loginRequest = new authentication.HuaweiIDProvider().createLoginWithHuaweiIDRequest();
    loginRequest.forceLoginfalse// 静默登录
    loginRequest.state = util.generateRandomUUID();

    const controller = new authentication.AuthenticationController();
    const response = await controller.executeRequest(loginRequest);

    const loginWithHuaweiIDResponse = response as authentication.LoginWithHuaweiIDResponse;
    const state = loginWithHuaweiIDResponse.state;

    // 验证 state
    if (state && loginRequest.state !== state) {
      hilog.error(DOMAIN'AuthManager''状态验证失败');
      return false;
    }

    const credential = loginWithHuaweiIDResponse.data;
    if (!credential) {
      hilog.error(DOMAIN'AuthManager''未获取到凭证');
      return false;
    }

    // 调用后端API完成登录
    const loginResponse = await huaweiLogin({
      authorizationCode: credential.authorizationCode || '',
      openID: credential.openID || '',
      unionID: credential.unionID || '',
      idToken: credential.idToken
    });

    // 保存登录状态
    await this.saveLoginState(loginResponse);

    // 更新全局用户状态
    this.userStore.setUser(loginResponse.user);
    this.userStore.setLoggedIn(true);

    hilog.info(DOMAIN'AuthManager''静默登录成功: %{public}s', 
      loginResponse.user.nickname || loginResponse.user.username);
    
    return true;

  } catch (error) {
    const err = error as BusinessError;
    if (err.code === 1001502001) {
      // 用户未登录华为账号,这是正常情况
      hilog.info(DOMAIN'AuthManager''用户未登录华为账号');
    } else {
      hilog.error(DOMAIN'AuthManager''静默登录失败: %{public}s'JSON.stringify(error));
    }
    return false;
  }
}

2. 启动登录流程

在应用启动时,系统会自动执行以下登录逻辑:

  1. 本地状态恢复:优先从本地存储恢复登录状态
  2. 静默登录:如果本地恢复失败且开启静默登录,则执行静默登录
  3. 用户状态更新:更新全局用户状态管理
/**
 * 应用启动时的登录逻辑
 * 1. 先尝试从本地恢复登录状态
 * 2. 如果本地恢复成功,不再执行静默登录
 * 3. 如果本地恢复失败且开启了静默登录,则执行静默登录
 */
private async performStartupLogin(): Promise<void> {
  try {
    hilog.info(DOMAIN, 'EntryAbility''开始执行启动登录逻辑');

    // 1. 先尝试从本地恢复登录状态
    const restored = await this.authManager.restoreLoginState();
    if (restored) {
      hilog.info(DOMAIN, 'EntryAbility''从本地恢复登录状态成功');
      return;
    }

    // 2. 检查是否启用静默登录
    const enableSilentLogin = await this.storageManager.getBoolean(
      StorageKeys.ENABLE_SILENT_LOGIN, 
      true // 默认开启
    );

    if (!enableSilentLogin) {
      hilog.info(DOMAIN, 'EntryAbility''静默登录已关闭,跳过');
      return;
    }

    // 3. 执行静默登录
    const success = await this.authManager.performSilentLogin(enableSilentLogin);
    if (success) {
      hilog.info(DOMAIN, 'EntryAbility''静默登录成功');
    } else {
      hilog.info(DOMAIN, 'EntryAbility''静默登录失败或用户未登录');
    }

  } catch (error) {
    hilog.error(DOMAIN, 'EntryAbility''启动登录逻辑执行失败: %{public}s', JSON.stringify(error));
  }
}

image-20251121103306865

image-20251121103306865

3. 手动登录支持

除了静默登录,项目还支持手动登录,用户可以主动触发登录流程:

/**
 * 手动登录 - 显示登录界面
 * @returns 登录响应数据
 */
async performManualLogin(): Promise<LoginResponse | null> {
  try {
    hilog.info(DOMAIN, 'AuthManager''开始手动登录');

    // 创建登录请求
    const loginRequest = new authentication.HuaweiIDProvider().createLoginWithHuaweiIDRequest();
    loginRequest.forceLogin = true// 强制显示登录页面
    loginRequest.state = util.generateRandomUUID();

    const controller = new authentication.AuthenticationController();
    const response = await controller.executeRequest(loginRequest);

    const loginWithHuaweiIDResponse = response as authentication.LoginWithHuaweiIDResponse;
    const state = loginWithHuaweiIDResponse.state;

    if (state && loginRequest.state !== state) {
      hilog.error(DOMAIN, 'AuthManager''状态验证失败');
      return null;
    }

    const credential = loginWithHuaweiIDResponse.data;
    if (!credential) {
      hilog.error(DOMAIN, 'AuthManager''未获取到凭证');
      return null;
    }

    // 调用后端API完成登录
    const loginResponse = await huaweiLogin({
      authorizationCode: credential.authorizationCode || '',
      openID: credential.openID || '',
      unionID: credential.unionID || '',
      idToken: credential.idToken
    });

    // 保存登录状态
    await this.saveLoginState(loginResponse);

    // 更新全局用户状态
    this.userStore.setUser(loginResponse.user);
    this.userStore.setLoggedIn(true);

    hilog.info(DOMAIN, 'AuthManager''手动登录成功: %{public}s',
      loginResponse.user.nickname || loginResponse.user.username);

    return loginResponse;

  } catch (error) {
    const err = error as BusinessError;
    hilog.error(DOMAIN, 'AuthManager''手动登录失败: %{public}s', JSON.stringify(error));
    return null;
  }
}

核心亮点与优势

1. 零配置路由管理

  • 自动生成路由:通过插件自动扫描生成路由配置,无需手动维护路由表
  • 类型安全:参数传递和获取过程中提供完整的类型支持
  • 声明式配置:使用装饰器语法,让路由配置更加简洁直观

2. 强大的页面生命周期管理

  • 完整的生命周期钩子:支持页面创建、显示、隐藏、销毁等完整生命周期
  • 灵活的事件处理:内置多种事件类型,便于处理复杂的页面交互逻辑
  • 返回键管理:提供统一的返回键处理机制

3. 智能参数传递机制

  • 类型转换支持:自动处理基本类型和复杂对象的序列化/反序列化
  • 参数验证:内置参数验证机制,提高应用稳定性
  • 状态管理集成:与鸿蒙的AppStorageV2等状态管理方案完美集成

4. 生产级混淆适配

  • 混淆保护规则:提供完整的混淆配置建议
  • 文件名称保护:确保混淆后路由名称的正确映射
  • 编译时优化:支持编译时的路由优化和压缩

5. 无缝认证集成

  • 静默登录体验:应用启动时自动完成身份验证,用户无感知
  • 本地状态恢复:优先使用本地存储的用户信息,提升启动速度
  • 灵活认证策略:支持静默登录和手动登录的灵活切换

性能与优化

1. 路由性能优化

  • 懒加载支持:zrouter支持页面懒加载,减少应用启动时间
  • 路由缓存:内置路由状态缓存,提升页面切换性能
  • 内存管理:智能的内存管理机制,避免内存泄漏

2. 开发体验优化

  • 开发日志:开发模式下提供详细的路由操作日志
  • 热更新支持:支持开发时的路由热更新
  • 调试工具:提供丰富的调试工具和状态监控

3. 兼容性保障

  • 版本兼容:支持不同版本的HarmonyOS系统
  • API适配:自动适配不同API版本的功能差异
  • 向后兼容:确保老版本代码的兼容性问题

最佳实践建议

1. 路由命名规范

  • 使用语义化的页面名称
  • 统一命名风格(如驼峰命名法)
  • 避免使用特殊字符

2. 参数设计原则

  • 传递最小必要信息
  • 避免传递过大的对象
  • 合理设计参数结构

3. 页面生命周期管理

  • 合理使用生命周期钩子
  • 避免在生命周期中执行耗时操作
  • 及时清理资源,避免内存泄漏

4. 安全考虑

  • 对敏感参数进行加密处理
  • 验证参数的合法性
  • 避免在URL中暴露敏感信息

总结

味寻纪-元服务项目成功集成了zrouter路由管理方案,并实现了完善的静默登录功能。通过zrouter的强大功能,项目获得了:

  • 统一且强大的路由管理:简化了页面导航逻辑,提升了开发效率
  • 类型安全的参数传递:减少了运行时错误,提升了代码质量
  • 完整的生活周期管理:提供了更好的用户体验
  • 生产级的混淆支持:确保了应用的安全性
  • 无缝的认证体验:静默登录提升了用户使用体验

zrouter作为鸿蒙官方推荐的路由解决方案,为HarmonyOS Next应用开发提供了强大而稳定的路由管理能力,是构建高质量鸿蒙应用的重要工具。通过味寻纪项目的实践,我们可以看到zrouter在实际项目中的强大功能和良好体验。

群晖 DSM 更新后 Cloudflare DDNS 失效的排查记录

前言

前两天我的群晖 NAS 提示 DSM 有新版本更新。由于已经好久没更新,一瞄发行说明发现新 feature 和 bugfix 还挺多,想着时间也不短了,那就顺手更一下吧。

系统更新得倒是很顺利,更新后啥异常也没发现,内网/外网访问都正常,我也就没再管。

直到昨晚事情才开始不对:我突然发现通过 DDNS 完全无法访问 NAS 上的任何应用。不过当时家里网络卡得要命,我也没太当回事,只以为是网络抽风。

但今天一早起来网络速度恢复,可域名依然不通,这时候我才知道绝对不是网络速度的问题了,这才开始正式排查。

排查链路复盘

下面是我这次遇到问题后的完整排查过程。一方面是给可能遇到同样坑的朋友提供一个参考思路,另一方面也是给自己做个记录,方便以后再遇到类似情况时能更快定位问题。

1. 先确认 NAS 服务本身是否正常

第一步很简单: 通过内网 IP 访问 NAS,一切正常,系统服务都在。

2. 再确认公网访问是否正常

用家里的 公网 IP 直连 NAS 暴露出去的端口,能通。说明 ISP 没问题,端口也没被封。

3. 检查群晖 DSM 自带的 DDNS 状态

打开 控制面板 → 外部访问 → DDNS

果然看到 DDNS 状态一直卡在 “正在连接...”, 点“测试连接”,转半天圈,最后报 连接超时

至此基本确认问题出在 DDNS。

4. 怀疑是否是 NAS 上的代理导致的问题

我之前遇到过: 只要开了系统代理 → 重启 NAS → 自定义 DDNS 会无法连接

必须是:

  1. 先连上 DDNS
  2. 再手动开启系统代理

这很奇怪,但确实存在这个问题,我也一直没有去解决,所以这次我也先排查了一遍代理设置,结果发现代理是关闭的,这条可能性排除了。

5. 怀疑 Cloudflare 又宕机?

突然想起前两天 Cloudflare 宕机,难不成又宕机了?

我登录 CF 控制台看了眼,没有任何告警。为了确认,我还顺手访问了几个托管在 Vercel、经过 Cloudflare 的服务,一切正常,说明 Cloudflare 没锅。

6. 终于把怀疑对象指向 DSM 更新

这时我不禁想起了福尔摩斯法则:

“当你排除了所有不可能的,剩下的即使再离谱,也必然是真相。”

我先是在 Nas 上添加 DDNS 的地方看了一下服务提供商,果然,没有之前自定义的 Cloudflare 了。

我回忆了一下,群晖默认不支持 Cloudflare DDNS,需要自己手动部署第三方脚本,比如很多玩家都用的 SynologyCloudflareDDNS

但我这是两三年前配置的了,早忘得干干净净,只记得当时是用 SSH 登进去扔了些脚本、改了 ddns_provider 配置,我只好去网上重新查教程。

7. 转折:ddns_provider 配置“消失了”

网上的做法都差不多,我也照着检查了一下 NAS 的 /etc/ddns_provider.conf

结果一打开就发现:我之前手动添加的 Cloudflare provider 配置彻底没了。

八成是这次 DSM 更新覆盖了系统文件,把自定义的 DDNS provider 给清干净了。

于是我把缺失的配置重新补上、保存,然后再回到 DSM 的 DDNS 界面一看,果然有变化: 列表里多出了一个“访问 DDNS 供应商的网站”的按钮。

这说明新增的 provider 已成功加载(因为在 ddns_provider.conf 里设置 website 字段后,DSM 会自动在面板中显示这个入口)。

Pasted image 20251122094435.png

再测试连接,结果:还是失败。

8. 再一查:原先放在 /sbin 的脚本也没了

我继续顺着配置里引用的脚本路径查下去(就是 SynologyCloudflareDDNS 的核心脚本)。

结果:指定路径下根本没有脚本! 我甚至怀疑是不是我之前放在其他地方了,于是全盘搜索了一遍。

找不到,根本找不到,基本可以确认整个 /sbin 目录都被更新重置了。

至此终于搞清楚问题根因:

DSM 更新时覆盖了系统目录,导致自定义 DDNS Provider 配置 + 脚本全部消失。

9. 修复:重新上传脚本 + 重新配置 provider

我重新下载脚本,上传到 /sbin,给可执行权限,再更新 ddns_provider 配置。

测试连接,这次成功了,DDNS 恢复正常!

10. 为什么前两天还能用?

现在回头一想,为什么更新当天没问题?

很简单:因为我的公网 IP 那几天没变。 DDNS 虽然挂了,但只要 Cloudflare 上的 DNS A 记录还指向老 IP,访问就不会出问题。

直到昨天公网 IP 有一点点变动(可能只差一两位),我登陆 CF 又没仔细看 A 记录,这才导致域名完全失效。

总结

这次问题的根因其实非常隐蔽:

  • DSM 更新 → 覆盖系统目录
  • 自定义 DDNS Provider 配置被删
  • /sbin 下的 Cloudflare 更新脚本也清空
  • 但因为公网 IP 没变,所以问题延后了两天才暴露
  • 最后一度误以为是 Cloudflare、代理或网络的问题

最终的解决办法就是:重新放回脚本 + 重写 ddns_provider.conf。

React Suite v6:面向现代化的稳健升级

React Suite (rsuite) v6 正式发布了。这一版本聚焦于现代化改造:重构底层样式系统、提供新的布局能力,并整体提升响应式体验和开发流程。v6 代表 React Suite 在稳定性的前提下,持续向更具适应性的 UI 方案演进。

React Suite v6 Banner

1. 样式系统的全面重构:拥抱 CSS 变量

v6 最重大的底层变革是将样式系统从 Less 彻底迁移到了 SCSS,并全面采用 CSS 变量 (CSS Variables) 作为主题定制的核心方案。开发者只需覆盖变量值,就能在运行时动态切换品牌色、间距或圆角,无需重新编译或配置额外的构建流程。

完整的变量清单与使用方式,可参考 CSS Variables 文档,也可以借助 Palette 工具 可视化调整品牌配色。

其他样式系统改进

  • 逻辑属性 (Logical Properties):全面采用 CSS 逻辑属性(如 margin-inline-start 代替 margin-left),原生支持 RTL(从右到左)排版。
  • rem 单位:字体大小、间距等尺寸从 px 转换为 rem,更好地支持响应式排版和无障碍缩放。

2. 拥抱 AI 辅助编程

React Suite v6 不仅关注组件本身,更致力于提升 AI 时代的开发效率。我们引入了对 AI 编程助手的原生支持,让 Cursor、Windsurf 等工具能更懂 RSuite。

LLMs.txt 支持

我们遵循 llms.txt 标准,为文档站添加了 /llms.txt 文件。这是一个专为大语言模型 (LLM) 优化的文档索引。

当你在 Cursor 或其他 AI 工具中引用 RSuite 文档时,AI 可以通过这个文件快速获取组件的 API 定义、最佳实践和代码示例,从而生成更准确、符合 v6 规范的代码。

MCP Server 集成

我们推出了官方的 Model Context Protocol (MCP) Server。

通过 MCP,你的 AI 助手可以直接连接到 RSuite 的知识库。这意味着:

  • 实时检索:AI 可以直接读取最新的组件文档和 API,无需手动复制粘贴。
  • 上下文感知:在编写代码时,AI 能自动推荐适合当前场景的组件和属性。
  • 减少幻觉:基于官方文档的上下文投喂,大幅降低 AI 生成过时或错误代码的概率。

3. 布局能力的原子化:引入 Box 与 Center

为了让布局更加灵活高效,v6 引入了基础的布局原子组件,让开发者能够像搭积木一样构建复杂的 UI。

Box 组件

Box 是构建布局的基石,它允许你直接在组件上通过 props 控制样式,无需编写额外的 CSS 类。

import { Box, Button } from 'rsuite';

function App() {
  return (
    <Box p={20} m={10} bg="gray-100" borderRadius={8} display="flex" justifyContent="space-between">
      <h2>Welcome to v6</h2>
      <Button appearance="primary">Get Started</Button>
    </Box>
  );
}

Center 组件

垂直水平居中一直是 CSS 中的高频需求,现在你可以使用 Center 组件轻松实现:

import { Center } from 'rsuite';

<Center height={200} className="bg-blue-50">
  <div>Perfectly Centered Content</div>
</Center>;

4. 响应式设计的全面增强

在移动互联网时代,跨端适配至关重要。v6 对核心组件进行了响应式能力的增强。

  • Grid 系统升级:重构了 RowCol,提供更灵活的断点控制对象语法。

    <Row align="center" justify="space-between">
      <Col span={{ xs: 24, md: 12, lg: 8 }}>...</Col>
      <Col span={{ xs: 24, md: 12, lg: 8 }}>...</Col>
    </Row>
    
  • Navbar & Sidenav:新增了对移动端的自适应支持,使用 Navbar.Content 替代了废弃的 pullRight,布局更清晰。

    <Navbar>
      <Navbar.Brand>BRAND</Navbar.Brand>
      <Navbar.Content>
        <Nav>...</Nav>
      </Navbar.Content>
      <Navbar.Content>
        <Avatar />
      </Navbar.Content>
    </Navbar>
    
  • Picker 组件:所有的 Picker 组件现在都拥有了更好的移动端适配体验,在小屏幕设备上会自动切换为更友好的交互模式。

5. 全新的组件与 Hooks

除了布局组件,v6 还带来了一系列实用的新组件和 Hooks,进一步丰富了组件库的能力。

新增组件

  • SegmentedControl:分段控制器,提供更现代的选项卡切换体验,适用于筛选、视图切换等场景。

  • PasswordInput:专用的密码输入框,内置显示/隐藏密码切换功能,提升用户体验。

  • PinInput:PIN 码/验证码输入组件,支持自动聚焦和粘贴分割,适用于验证场景。

  • Textarea:独立的多行文本输入组件,提供更好的样式控制和一致性。

  • Kbd:用于展示键盘按键,文档站和快捷键提示的福音。

  • Link:提供统一样式的链接组件,支持自定义颜色和无障碍访问。

  • Menu & MegaMenu:全新的菜单系统,支持更复杂的导航结构,轻松构建大型应用导航。

  • Form.Stack:让表单布局排列更加整洁有序,替代了 Form 组件上的布局属性。

    <Form>
      <Form.Stack layout="horizontal" spacing={20}>
        <Form.Group>
          <Form.Label>Username</Form.Label>
          <Form.Control name="username" />
        </Form.Group>
        {/* ... */}
      </Form.Stack>
    </Form>
    

强大的 Hooks

  • useDialog:通过函数调用方式管理对话框,告别繁琐的 visible state 管理。

    const dialog = useDialog();
    
    const handleClick = async () => {
      const result = await dialog.confirm({
        title: '确认操作',
        children: '您确定要执行此操作吗?'
      });
      if (result) {
        console.log('Confirmed');
      }
    };
    
  • useFormControl:轻松创建自定义的表单控件,自动处理验证状态和错误信息。

6. 开发者体验 (DX) 的极致追求

我们深知开发者的快乐不仅仅来自于好用的组件,更来自于流畅的开发流程。

  • 全面拥抱 Vitest:我们将测试框架从 Karma/Mocha 迁移到了 Vitest,测试速度大幅提升,开发反馈更加即时。
  • TypeScript 类型增强:优化了所有组件的类型导出,新增 Schema 类型命名导出,智能提示更加精准。
  • Bundle Size 优化
    • 引入 size-limit,严格把控包体积。
  • 生态支持:新增 Bun 安装指南,紧跟前端工具链发展潮流。
  • 开发调试useToaster 增加了环境检查,当在 CustomProvider 上下文之外使用时会发出友好警告,帮助快速定位问题。

7. 更多细节改进

  • Badge:新增 sizeoutlineplacement 等属性,支持更丰富的展示形态。

    <Badge content="New" size="lg" outline />
    <Badge content={99} shape="square" placement="bottomEnd" />
    
  • Breadcrumb:默认样式调整,更符合现代设计规范。

  • DatePicker:优化了 Toolbar 布局,交互更符合直觉。

  • Progress:新增 indeterminate 加载动画状态,以及支持分段进度条 (sections),展示更丰富的信息。

  • TreePicker:新增 onlyLeafSelectable 属性,满足仅允许选择叶子节点的业务场景。

  • Button:新增 toggle 状态支持。

  • InputGroup:优化了 inside 模式下的按钮样式,视觉更加协调。

  • 依赖升级:Date-fns 4.x, Prettier 3.x 等核心依赖全面升级。


立即开始

React Suite v6 现已通过 npm 发布。

npm install rsuite@latest

我们准备了详细的迁移指南,帮助您从 v5 平滑升级。

欢迎在 GitHub 上给我们 Star,或者在 Discussion 中分享您的使用体验!

React Suite Team

多语言的爱意告白

这张图片以黑色为背景,中央突出显示白色的 “小鱼” 字样,周围环绕着多语言的 “我喜欢你”“我爱你”“和我交往吧”“在一起” 等表达爱慕与交往意愿的文字,文字颜色多样,营造出一种充满爱意的视觉氛围。

image.png

import pygame
import random
import sys
import math
import os

# 初始化pygame
pygame.init()
# 窗口设置
width, height = 1000, 800
screen = pygame.display.set_mode((width, height))

# 改进的字体设置函数
def get_font(size):
    font_paths = [
        "C:/Windows/Fonts/simhei.ttf", "C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simsun.ttc",
        "/System/Library/Fonts/PingFang.ttc", "/System/Library/Fonts/Arial.ttf",
        "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
    ]
    for font_path in font_paths:
        if os.path.exists(font_path):
            try:
                return pygame.font.Font(font_path, size)
            except:
                continue
    try:
        return pygame.font.SysFont("Arial", size)
    except:
        return pygame.font.Font(None, size)

# 创建字体对象(弹幕字体稍大,保证清晰)
font = get_font(20)
title_font = get_font(36)
danmu_font = get_font(20)  # 弹幕字体大小适中
# 新增名字专用字体(更大更醒目)
name_font = get_font(60)  # 字体大小60,突出显示

# 多语言“我爱你”文本库(原内容完全保留)
love_texts = [
    "Mola le offi la tee", "mihai amestiah sind", "milvini un ne seibesc", "Le sakam", "Mlohuujam",
    "Te sakam", "chami dài e n ki n M", "Te dua", "Té okabescu", "Tá grá agam duit", "Kocham Ci",
    "Saya cintakan kamu", "我爱你", "Te ambesc", "Mahal kita", "Jeg elsker deg", "Mihaji té", "Meng sona",
    "Szerethékda", "Jag Tjukin", "Teja amerhi", "L kertesi skarji", "joga nem lesen", "nyolyomni",
    "I love you", "Je t'aime", "Ich liebe dich", "Ti amo", "Te amo", "あいしてる",
    "我爱你", "我中意你", "我爱侬", "我欢喜你", "我爱你", "我愛你", "勾买蒙", "额爱你","我待见你"
    "恩欢喜你", "我爱列", "Ik hou van je", "Saya cintakan mu", "Ti amo", "Jeg elsker dig",
    "Aku cinta padamu", "Saya cinta kamu", "Ljubim te", "俺喜欢", "我稀罕你", "俺稀罕你","阿秋拉嘎"
]

# 爱心坐标生成(原逻辑完全保留,未做任何修改)
love_points = []
for t in range(1000):
    theta = t / 1000 * 2 * math.pi
    x = 15 * (pow(math.sin(theta), 3))
    y = 10 * math.cos(theta) - 5 * math.cos(2 * theta) - 2 * math.cos(3 * theta) - math.cos(4 * theta)
    x = x * 18 + width // 2
    y = -y * 18 + height // 2
    love_points.append((int(x), int(y)))

# 文字对象列表(原逻辑完全保留,未做任何修改)
text_objects = []
colors = [(255, 182, 193), (255, 105, 180), (255, 20, 147),(218,112,214)]
for i, text in enumerate(love_texts):
    color = colors[i % len(colors)]
    try:
        if not isinstance(text, str):
            text = str(text)
        text_surface = font.render(text, True, color)
    except Exception as e:
        print(f"渲染失败 '{text}': {e}")
        text_surface = font.render("Love", True, color)
    idx = random.randint(0, len(love_points) - 1)
    x, y = love_points[idx]
    speed_x = random.uniform(-0.15, 0.15)
    speed_y = random.uniform(-0.15, 0.15)
    text_objects.append({
        "surface": text_surface, "text": text, "x": x, "y": y,
        "speed_x": speed_x, "speed_y": speed_y, "target_idx": idx, "color": color
    })

# ===================== 弹幕功能(按要求优化)=====================
# 弹幕仅保留3条中文文案
danmu_texts = ["我爱你", "在一起", "我喜欢你","和我交往吧","阿秋拉嘎"]
# 弹幕仅一种粉色(鲜艳且协调)
danmu_color = (255,239,213)  # 热粉色

# 弹幕对象类(速度加快,淡入淡出节奏紧凑)
class Danmu:
    def __init__(self):
        self.text = random.choice(danmu_texts)
        self.font_size = random.randint(18, 24)  # 字体大小随机
        self.font = get_font(self.font_size)
        self.color = danmu_color
        # 随机位置(全屏分布,支持左右/上下双向运动)
        if random.random() > 0.5:
            # 从左向右运动
            self.x = random.randint(-100, -20)
            self.y = random.randint(50, height - 50)
            self.speed_x = random.uniform(3, 4)  # 加快水平速度
            self.speed_y = random.uniform(-0.5, 0.5)  # 轻微垂直偏移
        else:
            # 从右向左运动
            self.x = random.randint(width + 20, width + 100)
            self.y = random.randint(50, height - 50)
            self.speed_x = random.uniform(-5, -3)  # 加快水平速度
            self.speed_y = random.uniform(-0.5, 0.5)  # 轻微垂直偏移
        self.alpha = 0  # 初始透明度为0
        self.life = random.randint(80, 150)  # 生命周期缩短(速度快对应短生命周期)
        self.life_count = 0
        self.surface = self._render_text()

    def _render_text(self):
        surf = self.font.render(self.text, True, self.color)
        surf.set_alpha(self.alpha)
        return surf

    def update(self):
        self.life_count += 1
        # 快速淡入(前20%生命周期完成淡入)
        if self.life_count < self.life * 0.2:
            self.alpha = min(255, self.alpha + 15)  # 加快淡入速度
        # 快速淡出(后30%生命周期完成淡出)
        elif self.life_count > self.life * 0.7:
            self.alpha = max(0, self.alpha - 10)  # 加快淡出速度
        # 更新透明度和位置(速度加快)
        self.surface.set_alpha(self.alpha)
        self.x += self.speed_x
        self.y += self.speed_y
        # 生命周期结束或超出屏幕则重置
        if self.life_count >= self.life or self.x < -200 or self.x > width + 200:
            self.__init__()

    def draw(self, screen):
        rect = self.surface.get_rect(center=(self.x, self.y))
        screen.blit(self.surface, rect)

# 初始化弹幕(初始30条,数量适中不遮挡爱心)
danmu_list = [Danmu() for _ in range(30)]
# 弹幕生成计时器(生成频率加快)
danmu_spawn_timer = 0
danmu_spawn_interval = 20  # 每20帧(约0.3秒)新增1条弹幕

# 原标题与说明文字(保留)
try:
    title_text = title_font.render("", True, (255, 255, 255))
except:
    title_text = title_font.render("I Love You + Pink Danmu", True, (255, 255, 255))
instruction_font = get_font(16)
instruction_text = instruction_font.render("按ESC键退出", True, (150, 150, 150))

# 生成居中显示的名字(白色,屏幕正中间)
name_text = name_font.render("小鱼", True, (255, 255, 255))  # 白色文字
# 计算名字居中坐标(屏幕正中心)
name_x = width // 2 - name_text.get_width() // 2
name_y = height // 2 - name_text.get_height() // 2

# 主循环(仅添加名字绘制,不改动原有内容)
clock = pygame.time.Clock()
running = True
while running:
    # 事件处理(原逻辑保留)
    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            running = False
        elif event.type == pygame.KEYDOWN:
            if event.key == pygame.K_ESCAPE:
                running = False

    # 黑色背景(原逻辑保留)
    screen.fill((0, 0, 0))

    # 绘制标题、说明文字(原逻辑保留)
    screen.blit(title_text, (width // 2 - title_text.get_width() // 2, 20))
    screen.blit(instruction_text, (width - instruction_text.get_width() - 10, height - 30))

    # 绘制爱心轨迹文字(原逻辑完全保留,未做任何修改)
    for obj in text_objects:
        text_rect = obj["surface"].get_rect(center=(obj["x"], obj["y"]))
        screen.blit(obj["surface"], text_rect)
        target_x, target_y = love_points[obj["target_idx"]]
        obj["x"] += (target_x - obj["x"]) * 0.02 + obj["speed_x"]
        obj["y"] += (target_y - obj["y"]) * 0.02 + obj["speed_y"]
        obj["target_idx"] = (obj["target_idx"] + 1) % len(love_points)
        if obj["x"] < 0 or obj["x"] > width:
            obj["speed_x"] *= -1
        if obj["y"] < 50 or obj["y"] > height - 50:
            obj["speed_y"] *= -1

    # ===================== 绘制居中名字(新增代码)=====================
    screen.blit(name_text, (name_x, name_y))  # 在屏幕正中间绘制白色名字

    # ===================== 弹幕更新与绘制(按要求优化)=====================
    # 加快弹幕生成频率
    danmu_spawn_timer += 1
    if danmu_spawn_timer >= danmu_spawn_interval:
        danmu_list.append(Danmu())
        danmu_spawn_timer = 0
        # 限制弹幕总数(最多50条,避免过度拥挤)
        if len(danmu_list) > 50:
            danmu_list.pop(0)

    # 更新并绘制所有弹幕(速度加快,粉色系,仅中文文案)
    for danmu in danmu_list:
        danmu.update()
        danmu.draw(screen)

    # 显示帧率(原逻辑保留)
    fps_text = font.render(f"FPS: {int(clock.get_fps())}", True, (100, 100, 100))
    screen.blit(fps_text, (10, height - 30))

    pygame.display.flip()
    clock.tick(60)

pygame.quit()
sys.exit()

C++进阶小技巧:让代码从"能用"变"优雅"

在掘金逛C++帖子时,总能看到新手问"为什么我的代码能跑但总被review打回"?其实很多时候不是逻辑错了,而是没掌握这些"进阶偏基础"的实用技巧——既能少踩坑,又能让代码更简洁高效,配上极简例子一看就会~

1. 前向声明:头文件依赖的"减负神器"

还在头文件里疯狂#include?前向声明能直接减少编译依赖,还能解决循环依赖问题 !

// forward.h (集中存放前向声明)
#pragma once
class Document; // 无需包含完整定义,声明即可

// my_class.h
#include "forward.h"
#include <memory>
class MyClass {
    std::unique_ptr<Document> doc; // 指针/引用类型只需前向声明
};

 

核心优势:编译速度翻倍,避免"改一个头文件全工程重编"的痛苦。

2. emplace_back:容器插入的"性能王者"

别再用push_back了!emplace_back直接在容器内构造对象,省去临时对象拷贝的开销 。

#include <vector>
#include <string>

std::vector<std::string> vec;
vec.push_back(std::string("hello")); // 先构造临时对象,再拷贝
vec.emplace_back("world"); // 直接在vector里构造,零拷贝

 

适用场景:所有支持的STL容器(vector、list、map等),插入复杂对象时效果超明显。

3. 静态工厂方法:对象创建的"安全卫士"

构造函数没法返回错误?静态工厂方法能优雅处理对象创建失败,避免部分初始化的坑 。

#include <optional>
#include <fstream>

class FileProcessor {
public:
    // 静态工厂方法,创建失败返回nullopt
    static std::optional<FileProcessor> create(const std::string& filename) {
        std::ifstream file(filename);
        if (!file.is_open()) return std::nullopt;
        return FileProcessor(std::move(file)); // 私有构造确保初始化成功
    }
private:
    explicit FileProcessor(std::ifstream file) : file_(std::move(file)) {}
    std::ifstream file_;
};

// 使用:创建成功才执行逻辑
if (auto processor = FileProcessor::create("data.txt")) {
    processor->process();
}

 

4. 结构化绑定:解包的"懒人福音"

不用再写get<0>、get<1>了!结构化绑定能直接解包tuple/pair,代码清爽到飞起。

#include <tuple>
#include <string>

// 返回多个值的函数
std::tuple<int, std::string, float> getUser() {
    return {101, "张三", 95.5};
}

int main() {
    auto [id, name, score] = getUser(); // 直接绑定三个变量
    // id=101, name="张三", score=95.5
    return 0;
}

 

5. 作用域守卫:资源清理的"自动管家"

怕函数中途return导致资源泄漏?作用域守卫能确保离开作用域时自动执行清理逻辑 。

#include <functional>

class ScopeGuard {
public:
    explicit ScopeGuard(std::function<void()> cleanup) 
        : cleanup_(std::move(cleanup)), active_(true) {}
    ~ScopeGuard() { if (active_) cleanup_(); }
    void dismiss() { active_ = false; } // 可取消清理
private:
    std::function<void()> cleanup_;
    bool active_;
};

void writeFile() {
    FILE* file = fopen("temp.txt", "w");
    ScopeGuard guard([&]() { if (file) fclose(file); }); // 自动关文件
    
    if (someError()) return; // 即使提前返回,文件也会关闭
}

 

6. constexpr:编译期计算的"性能加速器"

把能在编译期算的逻辑交给编译器,运行时零开销,还能用作常量表达式 。

// 编译期计算阶乘
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

int main() {
    constexpr int size = factorial(5); // 编译时就算出120
    int arr[size]; // 可直接用作数组大小
    return 0;
}

 

windows中flutter开发鸿蒙实操

环境搭建完成 开始构建项目

编辑

项目目录

打开项目以后 可以看到结构如下 里面的ohos就是鸿蒙代码

编辑

展开看看 熟悉吧

编辑

不要直接去操作这些代码,肯定跑不起来的

启动模拟器

这个时候打开DevEco工具 启动模拟器 随便用哪个项目打开都行 只启动模拟器就可以了

编辑

启动以后

打包

打开项目目录 运行

flutter build hap --debug

构建鸿蒙项目包

编辑

打包完成后开始运行flutter项目
在终端继续输入命令:

flutter run
编辑然后选择1 用模拟器启动

模拟器效果

编辑

实操

用vsCode打开项目 找到lib目录 里面的main.dart 就是程序的主入口

编辑在lib中分别新建pages,在新建四个文件夹 Home Cinema Film Mine ,然后分别新建各自的默认页面 index.dart

编辑

分别定义好自己的页面内容

Cinema/index

import 'package:flutter/material.dart';

class CinemaPage extends StatelessWidget{
  const CinemaPage({super.key});
  @override
  Widget build(BuildContext context) {
   return const Center(
    child: Text('影院',style: TextStyle(fontSize: 24),),
   );
  }
}

Film/index

import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('电影', style: TextStyle(fontSize: 24)),
    );
  }
}

Home/index

import 'package:flutter/material.dart';

class HomePage extends StatelessWidget{
  const HomePage({super.key});
  @override
  Widget build(BuildContext context) {

    return const Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Text('首页',style: TextStyle(fontSize:24))
        ],
      )
    );




  }
}

Mine/index

import 'package:flutter/material.dart';

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

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: Text('我的', style: TextStyle(fontSize: 24)),
    );
  }
}

最后修改main.dart文件

import 'package:flutter/material.dart';
import 'package:my_app01/pages/Cinema/index.dart';
import 'package:my_app01/pages/Home/index.dart';
import 'package:my_app01/pages/Film/index.dart';
import 'package:my_app01/pages/Mine/index.dart';


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

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MainScreen(),
    );
  }
}

class MainScreen extends StatefulWidget {
  const MainScreen({super.key});

  @override
  State<MainScreen>createState()=>_MainScreenState();
}
class _MainScreenState extends State<MainScreen>{
  int _currentIndex = 0;
  final List<Widget> _page = const[
    HomePage(),
    CinemaPage(),
    FilmPage(),
    MinePage(),
  ];
  final List<BottomNavigationBarItem> _navBarItems = [
    const BottomNavigationBarItem(
      icon: Icon(Icons.home_outlined),
      activeIcon: Icon(Icons.home,color: Colors.blue),
      label: '首页'
    ),
    const BottomNavigationBarItem(
      icon: Icon(Icons.contacts_outlined),
      activeIcon: Icon(Icons.contacts, color: Colors.blue),
      label: '影院',
    ),
    const BottomNavigationBarItem(
      icon: Icon(Icons.people_outline),
      activeIcon: Icon(Icons.people, color: Colors.blue),
      label: '个人',
    ),
    const BottomNavigationBarItem(
      icon: Icon(Icons.person_outline),
      activeIcon: Icon(Icons.person, color: Colors.blue),
      label: '我的',
    ),
  ];
  @override
  Widget build(BuildContext context){
    return Scaffold(
      appBar: AppBar(
        title: Text(_getAppBarTitle()),
      ),
      body: _page[_currentIndex],
      bottomNavigationBar: BottomNavigationBar(
       items: _navBarItems,
        currentIndex: _currentIndex,
        onTap: (index) {
          setState(() {
            _currentIndex = index; // 更新当前索引
          });
        },
        type: BottomNavigationBarType.fixed, // 固定样式(支持4个以上)
        selectedItemColor: Colors.blue, // 选中项颜色
        unselectedItemColor: Colors.grey, // 未选中项颜色
      ),
    );
  }
  // 根据当前索引返回AppBar标题
  String _getAppBarTitle() {
    switch (_currentIndex) {
      case 0: return '首页';
      case 1: return '影院';
      case 2: return '电影';
      case 3: return '个人';
      default: return '我的应用';
    }
  }
}

先启动鸿蒙虚拟机 或者真机

编译成鸿蒙

先运行 flutter build hap

在运行 flutter run

最后的效果如下

编辑

你学废了吗?

developer.huawei.com/consumer/cn…

Flutter每日库: local_auth本地设备验证插件

在移动应用开发中,用户隐私和安全至关重要。你是否想过为APP添加类似手机解锁的本地身份验证功能?今天介绍的local_auth插件,正是Flutter开发者实现指纹、人脸识别的终极武器!无需复杂代码,轻松打造安全又炫酷的认证体验

功能亮点

  • 多平台支持:兼容iOS(Face ID/Touch ID)和Android(指纹/人脸)
  • 一键验证:调用系统原生界面,用户体验无缝衔接
  • 灵活配置:支持生物识别+密码双验证模式
  • 异常处理:智能识别硬件锁死、权限异常等场景

安装方式

flutter pub add local_auth

详细配置和示例

iOS和安卓平台的配置

iOS需要再info.plist中配置face ID的权限

<key>NSFaceIDUsageDescription</key>
<string>Why is my app authenticating using face id?</string>

安卓配置如下

1、修改安卓的原生MainActivity的父类

import io.flutter.embedding.android.FlutterActivity

class MainActivity : FlutterActivity()

// 修改为:
import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity : FlutterFragmentActivity ()

2、在AndroidManifest.xml添加权限

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
          package="com.example.app">
  <uses-permission android:name="android.permission.USE_BIOMETRIC"/>
<manifest>

硬件能力检测

final auth = LocalAuthentication();

// 检测设备是否支持
bool isSupported = await auth.isDeviceSupported();

// 查看已注册的生物识别类型
List<BiometricType> types = await auth.getAvailableBiometrics();
// 可能返回:fingerprint(指纹)、face(人脸)、strong(高安全级别)

完整代码示例

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:local_auth/local_auth.dart';

class TestPage extends StatefulWidget {
  const TestPage({super.key});

  @override
  State<TestPage> createState() => _TestPageState();
}

class _TestPageState extends State<TestPage> {
  final LocalAuthentication auth = LocalAuthentication();

  Future<void> _checkBiometrics() async {
    // canCheckBiometrics 仅指示硬件是否支持,并不表示设备是否已注册任何生物识别信息,有可能未录入指纹或者人脸
    final bool canAuthenticateWithBiometrics = await auth.canCheckBiometrics;
    final bool isDeviceSupported = await auth.isDeviceSupported();
    debugPrint(
      "canAuthenticateWithBiometrics = $canAuthenticateWithBiometrics isDeviceSupported = $isDeviceSupported",
    );
  }

  Future<void> _getAvailableBiometricsData() async {
    final List<BiometricType> availableBiometrics = await auth
        .getAvailableBiometrics();

    if (availableBiometrics.isEmpty) {
      debugPrint("设备未注册任何生物识别信息");
      return;
    }

    for (BiometricType item in availableBiometrics) {
      debugPrint("type = ${item.name}");
    }

    // if (availableBiometrics.contains(BiometricType.strong) ||
    //     availableBiometrics.contains(BiometricType.face)) {
    // }
  }

  Future<void> authenticateTest() async {
    try {
      final bool didAuthenticate = await auth.authenticate(
        localizedReason: 'Please authenticate to show account balance',
        biometricOnly: true, // 强制使用生物识别认证
        persistAcrossBackgrounding: true, // 应用未验证就退到后台,再次在前台运行时,重试身份验证
      );

      if (didAuthenticate) {
        debugPrint("success");
      } else {
        debugPrint("fail");
      }
    } on LocalAuthException catch (e) {
      // 异常处理  temporaryLockout  biometricLockout  指纹或face ID被锁定
      debugPrint("LocalAuthException ${e.toString()}");
    } on PlatformException catch (e) {
      debugPrint("PlatformException ${e.message}");
    }
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('local_auth')),
        body: Column(
          children: [
            ElevatedButton(
              onPressed: () {
                _checkBiometrics();
              },
              child: const Text('检查此设备是否支持本地身份验证'),
            ),

            ElevatedButton(
              onPressed: () {
                _getAvailableBiometricsData();
              },
              child: const Text('获取注册生物识别信息的列表'),
            ),

            ElevatedButton(
              onPressed: () {
                authenticateTest();
              },
              child: const Text('Check biometrics'),
            ),
          ],
        ),
      ),
    );
  }
}

以上就是该库在移动端的使用情况了。

钉钉小程序直传文件到 阿里云OSS

举个🌰,用钉钉小程序将一段视频在 前端 直接传到阿里云上。

由于 js-base64 这个npm包在钉钉小程序使用不了,而目前钉钉小程序又没有base64ToArrayBuffer这类方法,故只能曲线救国,使用 dd.writeFiledd.readFile来做base64的转换。

首先,我们得先处理OSS配置,具体如下:

import crypto from "crypto-js"

const OSSConfig = {
  AccessKeyId: "xxxx",
  AccessKeySecret: "xxxxxx",
  Host: "xxxxx", // 具体某个账号下,
  SecurityToken: "xx", // 可选,使用STS签名时必传。
}

// 计算签名。
const computeSignature = (accessKeySecret, canonicalString) => {
  return crypto.enc.Base64.stringify(
    crypto.HmacSHA1(canonicalString, accessKeySecret)
  )
}

let ossData = null
const getOssConfig = () => {
  const date = new Date()
  date.setHours(date.getHours() + 24) //加 1个小时
  const policyText = {
    expiration: date.toISOString(), // 设置policy过期时间。
    conditions: [
      // 限制上传大小。
      ["content-length-range", 0, 1024 * 1024 * 1024],
    ],
  }
  let fileManager = dd.getFileSystemManager()
  fileManager.writeFile({
    filePath: `${dd.env.USER_DATA_PATH}/test.txt`,
    data: JSON.stringify(policyText),
    success: () => {
      fileManager.readFile({
        filePath: `${dd.env.USER_DATA_PATH}/test.txt`,
        encoding: "base64",
        success: (res) => {
          console.log(res.data, "readFile")
          const signature = computeSignature(
            OSSConfig.AccessKeySecret,
            res.data
          )
          ossData = {
            OSSAccessKeyId: OSSConfig.AccessKeyId,
            signature,
            policy: res.data,
            // "x-oss-security-token": OSSConfig.SecurityToken,
          }
        },
        fail: (err) => {
          console.log(err)
        },
      })
    },
    fail: (err) => {
      console.log(err)
    },
  })
}

这个ossData就是我们处理好后的配置项,接下来将其填充到 dd.uploadFile 里就大功告成了!

dd.chooseVideo({
        sourceType: ["album", "camera"],
        maxDuration: 60,
        success: (res) => {
          let uniqueId = `${dirPath}xcx-${new Date().getTime()}.mp4` // dirPath为存储的文件夹路径, 比如"dev/front-end/video/"
          uni.showLoading({
            title: "视频上传中...",
          })
          dd.uploadFile({
            url: OSSConfig.Host,
            fileType: "video",
            fileName: "file",
            formData: {
              ...ossData, // 上述getOssConfig方法得到的结果
              key: uniqueId, // 该值为你存在在oss上的位置  后面上传成功之后拼接得到链接需要使用
              success_action_status: "200", // 默认上传成功状态码为204,此处被success_action_status设置为200。
            },
            filePath: res.tempFilePath,
            success: (res) => {
              uni.hideLoading()
              console.log("视频上传成功,地址为:",`${OSSConfig.Host}/${uniqueId}`)
            },
            fail: (err) => {
              uni.hideLoading()
            },
          })
        },
        fail: (err) => {
          console.log(err)
        },
      })



欢迎小伙伴留言讨论,互相学习!

❤❤❤ 如果对你有帮助,记得点赞收藏哦!❤❤❤

Next.js SSR 实战:从零到一,构建服务端渲染应用

引言

今天我们来通过一篇文章掌握服务端渲染框架-nextjs的基本使用,通过本文一步一步实现来完成一个企业级SSR框架的搭建。

能点开这篇文章,相信你对SSR服务端渲染已经有了大概的了解,当然也可以看我前面的一篇文章让你搞清楚服务端渲染的核心原理是什么: 搞懂SSR的灵魂-Hydration

咱们学习之前还是需要有一些的react预备知识,比如react16+、hooks、redux、react-redux、redux toolkit,如果是的,那咱们就发车了!

项目创建

npx create-next-app@latest   //当然你也可以根据需求指定脚手架版本

项目的基本结构如下

├── assets/             # 文件资源
├── components/         # 可复用组件
├── pages/              # 页面路由
   ├── _app.tsx         # 入口文件
   ├── _document.js     # 文档配置(可在这里定义TDK--sso优化)
   ├── index.tsx        # 首页
   ├── xxxx             # 其他页面
├── store/              # Redux状态管理
├── service/            # API服务层
├── styles/             # 全局样式
└── middleware.ts       # 中间件配置(路由拦截、请求重定向-反向代理...)
└── next.config.js      # 项目配置(网络图片优化配置...)
└── tsconfig.json       # typescript配置(路径别名...)

接下来在编辑器终端执行 npm run dev ,如果打开浏览器看到类似这样的页面就代表第一步已经成功了:

image.png

路由系统

项目运行起来了我们可能下一步考虑的是怎么创建路由,关联页面? nextjs具备的一个特点就是:零配置路由

Next.js最大的路由优势就是:不需要手动配置路由,文件系统就是路由系统!

# 文件结构自动映射为路由
pages/
├── index.tsx          → 自动映射为 /
├── about.tsx         → 自动映射为 /about
├── profile/
│   ├── index.tsx     → 自动映射为 /profile
│   └── [id].tsx      → 自动映射为 /profile/:id  注意:这里是动态路由,文件命名和参数接收时统一
└── api/
    └── user.ts       → 自动映射为 /api/user

路由跳转可通过下面两种方式

link组件跳转

import Link from "next/link";
import type { AppProps } from "next/app";

export default function App({ Component, ...rest }: AppProps) {
  return (
        <Link href="/">
          <button>Home</button>
        </Link>
        <Link href="/profile?code=123">
          <button>Profile</button>
        </Link>
        <Link href="/profile/123444">
          <button>Profile动态</button>
        </Link>
        <button onClick={getInfo}>api重写</button>
        {/* Component页面占位 ===相当于vue中的 router-view */}
        <Component {...props.pageProps} />
  );
}

编程式导航跳转hook(类似vue3)

import { useRouter } from 'next/router'
import type { AppProps } from "next/app";

export default function App({ Component, ...rest }: AppProps) {

const router = useRouter()
  
  // 基本跳转
  const handleNavigate = () => {
    router.push('/profile') // 跳转到个人资料页
  }
  
  // 带查询参数跳转
  const handleNavigateWithQuery = () => {
    router.push('/profile?code=123&name=john')
  }
  
  // 动态路由跳转
  const handleDynamicRoute = () => {
    router.push('/profile/123') // 跳转到 /profile/123
  }
  
  // 对象形式跳转(更灵活)
  const handleObjectNavigation = () => {
    router.push({
      pathname: '/profile',
      query: { 
        id: '123', 
        tab: 'settings' 
      }
    })
  }
}

路由传递参数的接收

import { useRouter } from 'next/router'
import type { AppProps } from "next/app";

export default function App({ Component, ...rest }: AppProps) {

const router = useRouter()
// 拿到 url中查询字符串 注意:如果是动态路由这里解构的属性名就与文件名必须保持一致
// 动态路由参数都是通过query获取,同名的话路由参数优先级高于查询字符串 
const { id } = router.query; // 拿到 url中查询字符串 注意:如果是动态路由这里结构的属性名就与文件名
  return (
    <div className={styles.detail}>{id}</div>
  )
}

如果要执行路由守卫在中间件配置文件操作

//middleware.ts

  // 路由守卫(示例)
  const token = req.cookies.get("token")?.value;
  if (!token && req.nextUrl.pathname.startsWith("/profile")) {
    return NextResponse.redirect(new URL("/login", req.nextUrl.origin));
  }

上面涵盖了nextjs路由系统的基本使用,接下来我们就来实践下服务端渲染的核心:

SSR数据获取(重点!!!)

我们先来看看页面中如何展示数据

import { useSelector, useDispatch } from "react-redux";
import { fetchUserAction } from "@/store/modules/home";
import wrapper from "@/store/index";

import type { GetServerSideProps } from "next";
import type { FC } from "react";
import type {
  IUser,
} from "@/service/home";

interface IProps {
  users: IUser[];
}

const Home: FC<IProps> = (props) => {
  const {
    product = [], //通过getServerSideProps映射到props中了
  } = props;

  // 从 redux 读取数据
  const { users } = useSelector((rootState: IAppRootState) => {
    return {
      users: rootState.home.users,
    };
  });
  return (
    <>
      <div className={styles.home}>
        {product}
        {users}
      </div>
    </>
  );
};

export default Home;
Home.displayName = "Home";

export const getServerSideProps: GetServerSideProps =
  wrapper.getServerSideProps(function (store) {
    return async (context) => {
      // 1.触发一个异步的action来发起网络请求, 拿到接口数据并存到redex中
      await store.dispatch(fetchUserAction());
      // 2.实际项目中不一定非要在redux进行网络请求获取数据(考虑是否要作为公共数据存储)
      const res = await fetchProduct();
  
      return {
        props: {
          product: res.data.product || [],
        },
      };
    };
  });

看到这里可能会有几个个疑问:getServerSideProps有什么作用? fetchUserAction这个action做了什么?wrapper又是干嘛的?跟着我的节奏,接下来我们围绕这三个问题展开讲解。

首先我们要知道SSR通常是服务端和客户端分工协作的结果:

SSR数据预取(getServerSideProps)

  • 在服务器端执行,页面首次加载时完成
  • 数据在页面渲染前就已准备好
  • 属于服务端渲染范畴
  • 适用于SEO重要、首次加载必需的数据

客户端数据获取

  • 在浏览器端执行,页面渲染后触发
  • 通过用户交互(点击、滚动等)触发
  • 属于客户端渲染范畴
  • 适用于动态、用户交互相关的数据

在实际项目中,通常混合使用上面两种方式。 SEO关键数据通过 getServerSideProps 预取,用户交互相关的私密数据则在客户端通过 useEffect 或事件处理函数获取。

接下来我们看看fetchUserAction 在redux中具体实现:

//store\modules\home.ts

import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
import { HYDRATE } from "next-redux-wrapper";
import { fetchUser } from "../../service/home";

const homeSlice = createSlice({
  name: "home",
  initialState: {
    users: {},
  },
  extraReducers: (builder) => {
    // Hydrate 是 Next.js 提供的一个特殊的 action,用于在服务端渲染时将服务器端的状态合并到客户端的状态中。
    builder
      .addCase(HYDRATE, (state, action: any) => {
        return {
          ...state,
          ...action.payload.home,
        };
      })
      .addCase(fetchUserAction.fulfilled, (state, { payload }) => {
        state.users = payload;
      });
  },
});

// 异步的action
export const fetchUserAction = createAsyncThunk(
  "fetchUserAction",
  async () => {
    // 发起网络请求,拿到搜索建议的数据
    const res = await fetchUser();
    return res.data
  }
);
export default homeSlice.reducer;

每个reducer通过redux toolkit是作为一个切片模块,最终需要整合在一起

//store\index.ts

import { configureStore } from "@reduxjs/toolkit";
import { createWrapper } from "next-redux-wrapper";
import homeReducer from "./modules/home";

const store = configureStore({
  reducer: {
    home: homeReducer,
  },
});

const wrapper = createWrapper(() => store);
export default wrapper;

这里我们可以看到createWrapper生成了一个wraper导出,它的主要作用就是使服务端与客户端状态同步,为什么需要同步?

我们再回顾下SSR的核心原理:

  • 服务端状态序列化:在服务器端渲染时,将 Redux store 的状态序列化并注入到 HTML 中
  • 客户端状态水合:在客户端首次加载时,将服务端注入的状态重新水合到客户端的 Redux store
  • 避免状态不一致:防止服务端和客户端渲染结果不一致的问题

最终,我们在入口文件_app.tsx中挂载store要做一点调整

//pages\_app.tsx

import { Provider } from "react-redux";
import wrapper from "../store";
import type { AppProps } from "next/app";

export default function App({ Component, ...rest }: AppProps) {
  //实现服务端和客户端状态的自动同步
  const { store, props } = wrapper.useWrappedStore(rest);
  return (
    <Provider store={store}>
         <Component {...props.pageProps} />
    </Provider>
  );
}

自此,服务端渲染结合redux的核心应用就完成了!通过一张图梳理下个Next.js SSR数据预取与Redux状态同步的完整流程

deepseek_mermaid_20251121_d68c43.png

牛刀小试:Vue 3的响应式系统和Proxy?

牛刀小试:Vue 3的响应式系统和Proxy?

引言

众所周知,Vue3 的响应式系统主要依赖于 ES6 的 Proxy 对象来实现。相比于 Vue2 使用的 Object.definePropertyProxy提供了更强大的功能和更好的性能。但我们在日常使用框架时却很少注意到背后的底层原理,本文就旨在通过重温Vue3的响应式系统,学习和回顾ES6的相关特性。

Proxy概述

ES6 Proxy 是一种新的 JavaScript 功能,它允许你创建一个对象的代理,从而可以拦截和自定义基本操作,例如属性查找、赋值、枚举和函数调用等。Proxy 可以被视为在目标对象之前设置的一层拦截,所有对该对象的访问都必须首先通过这层拦截,这提供了一种机制,可以对外界的访问进行过滤和改写。

语法

const p = new Proxy(target, handler)

target:要使用 Proxy 包装的目标对象(可以是任何类型的对象,包括原生数组,函数,甚至另一个代理)。

handler:一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

例子

拦截器

这里我们创建了一个get handler,当程序试图访问对象时,如果属性存在于对象,则返回其对象的值,否则返回37。

以下是传递给 get 方法的参数,this 上下文绑定在handler 对象上。

target目标对象。property被获取的属性名。receiver是Proxy 或者继承 Proxy 的对象

const handler = {
  get: function (obj, prop) {
    return prop in obj ? obj[prop] : 37;
  },
};

const p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;

console.log(p.a, p.b); // 1, undefined
console.log("c" in p, p.c); // false, 37

无操作转发

在以下例子中,我们使用了一个原生 JavaScript 对象,代理会将所有应用到它的操作转发到这个对象上。

let target = {};
let p = new Proxy(target, {});

p.a = 37; // 操作转发到目标

console.log(target.a); // 37 操作已经被正确地转发

验证功能

通过代理,你可以轻松地验证向一个对象的传值。向Proxy的handler设置相关的setter方法,可以拦截对对象的赋值操作,并确保预期之内的结果。

以下是传递给 set() 方法的参数。this 绑定在 handler 对象上。

target目标对象。

property将被设置的属性名或 Symbol

value新属性值。

receiver最初接收赋值的对象。通常是 proxy 本身,但 handler 的 set 方法也有可能在原型链上,或以其他方式被间接地调用(因此不一定是 proxy 本身)。

// Proxy & setter拦截器

let validator = {
    set: function (obj, prop, value) {
    if (prop === "age") {
      if (!Number.isInteger(value)) {
        throw new TypeError("The age is not an integer");
      }
      if (value > 200) {
        throw new RangeError("The age seems invalid");
      }
    }

    // 保存属性值的默认行为!
    obj[prop] = value;

    // 表示成功
    return true;
  },
}

let person = new Proxy({},validator)

person.age = 50
console.log(person.age)

// TypeError: The age is not an integer
person.age = 11.4

// RangeError: The age seems invalid
person.age = 3000

Reflect概述

ES6 Reflect 是一个内置的对象,它提供了一系列静态方法,用于执行可拦截的 JavaScript 操作。Reflect 并不是一个函数对象,因此它是不可构造的。

Reflect 的设计目的之一是与 Proxy 配合使用。Proxy handler 中的方法(如 get, set)与 Reflect 对象上的方法具有相同的名称和参数。这使得在 Proxy handler 中调用 Reflect 对应的方法来执行默认行为变得非常方便和规范。

主要方法

Reflect 上的所有方法都是静态的,与 Proxy 的 handler 方法一一对应。

  • Reflect.get(target, propertyKey[, receiver]): 获取对象属性的值,类似于 target[propertyKey]
  • Reflect.set(target, propertyKey, value[, receiver]): 设置对象属性的值,类似于 target[propertyKey] = value。它会返回一个布尔值表示是否设置成功。
  • Reflect.has(target, propertyKey): 判断一个对象是否存在某个属性,类似于 propertyKey in target
  • Reflect.deleteProperty(target, propertyKey): 删除对象的属性,类似于 delete target[propertyKey]

为什么与Proxy是最佳搭档?

Proxy 的拦截器中,我们通常需要执行原始操作。直接使用 obj[prop] = value 这样的语法虽然可行,但存在一些问题,尤其是在处理继承和 getter/setter 时。

Reflect 方法提供了执行这些默认操作的标准方式,并能正确处理 this 指向(通过 receiver 参数)。

例子

在这个例子中,我们使用 Reflect.set 来完成属性的赋值。这确保了即使对象有 setterthis 也会正确地指向代理对象 p

const target = {
    _name: 'Guest',
    get name() {
        return this._name;
    },
    set name(val) {
        console.log('Setter called!');
        this._name = val;
    }
};

const handler = {
    set(obj, prop, value, receiver) {
        console.log(`Setting ${prop} to ${value}`);
        // 使用 Reflect.set 来调用原始的 setter,并确保 this 指向 receiver (代理对象 p)
        return Reflect.set(obj, prop, value, receiver);
    }
};

const p = new Proxy(target, handler);

p.name = 'Admin';
// 输出:
// Setting name to Admin
// Setter called!

console.log(p.name); // Admin

使用 Reflect 不仅代码更简洁,而且更健壮,能够正确处理 JavaScript 复杂的内部机制。

手动挡响应式

了解完基本的原理后,我们将”手动挡”开始一步步实现Vue的响应式。

实现单个值的响应式

下面代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 Set 类型的 dep (Dependency 依赖)变量,用来存放需要执行的副作用( effect 函数),即修改 total 值的方法;

② 创建 track() 函数,用来将需要执行的副作用(Effect)保存到 dep 变量中(也称收集副作用);

③ 创建 trigger() 函数,用来执行 dep 变量中的所有副作用;

在每次修改 pricequantity 后,调用 trigger() 函数(扳机,触发器)执行所有副作用后, total 值将自动更新为最新值。

let price = 10, quantity = 2, total = 0;
const dep = new Set(); // ① 
const effect = () => { total = price * quantity };
const track = () => { dep.add(effect) };  // ②
const trigger = () => { dep.forEach( effect => effect() )};  // ③

track();
console.log(`total: ${total}`); // total: 0
trigger();
console.log(`total: ${total}`); // total: 20
price = 20;
trigger();
console.log(`total: ${total}`); // total: 40

实现单个对象的响应式

我们的对象具有多个属性,并且每个属性都需要自己的 dep(Dependency)。

我们将所有副作用保存在一个 Set 集合中,而该集合不会有重复项,这里我们引入一个 Map 类型集合(即 depsMap ),其 key 为对象的属性(如: price 属性), value 为前面保存副作用的 Set 集合(如: dep 对象)

下面的代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 Map 类型的 depsMap 变量,用来保存每个需要响应式变化的对象属性(key 为对象的属性, value 为前面 Set 集合);

② 创建 track() 函数,用来将需要执行的副作用保存到 depsMap 变量中对应的对象属性下(也称收集副作用);

③ 创建 trigger() 函数,用来执行 dep 变量中指定对象属性的所有副作用;

这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。

let product = { price: 10, quantity: 2 }, total = 0;
const depsMap = new Map(); // 1 创建依赖映射表
const effect = () => { total = product.price * product.quantity };
const track = key => {     // 2 查找该属性已有的依赖,若没有则自行创建一个
  let dep = depsMap.get(key);
  if(!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  dep.add(effect);
}

const trigger = key => {  // 3 找到该属性对应的所有依赖,并执行相关的副作用函数
  let dep = depsMap.get(key);
  if(dep) {
    dep.forEach( effect => effect() );
  }
};

track('price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger('price');
console.log(`total: ${total}`); // total: 40

为什么是Map充当依赖映射?

Map与Set的区别:

  • Set只储存唯一的值,只有值没有键,重复值不会被添加。
  • Map存储键值对,存在键值对的映射关系。
// Set 存储唯一值的集合
const effects = new Set(); // 只存储值,不存储键
effects.add(() => console.log('effect1'));
effects.add(() => console.log('effect2'));
effects.add(() => console.log('effect1')); // 重复值不会被添加

// Set 的内部结构
[effect1函数, effect2函数]
// 只有值,没有键

// Map 存储键值对
const depsMap = new Map(); // key -> value 的映射关系
depsMap.set('price', new Set([effect1, effect2]));
depsMap.set('quantity', new Set([effect3]));

// Map 的内部结构
{
  'price' -> Set([effect1, effect2]),
  'quantity' -> Set([effect3])
}
// 有明确的键值对应关系

Set用于储存副作用函数运算,Map用于保存依赖的键值对。

实现多个对象的响应式

下面代码通过 3 个步骤,实现对 total 数据进行响应式变化:

① 初始化一个 WeakMap 类型的 targetMap 变量,用来要观察每个响应式对象;

② 创建 track() 函数,用来将需要执行的副作用保存到指定对象( target )的依赖中(也称收集副作用);

③ 创建 trigger() 函数,用来执行指定对象( target )中指定属性( key )的所有副作用;

这样就实现监听对象的响应式变化,在 product 对象中的属性值发生变化, total 值也会跟着更新。

let product = { price: 10, quantity: 2 }, total = 0;
const targetMap = new WeakMap();     // 1 初始化 targetMap,保存观察对象
const effect = () => { total = product.price * product.quantity };
const track = (target, key) => {     // 2 收集依赖
  let depsMap = targetMap.get(target);
  if(!depsMap){
    targetMap.set(target, (depsMap = new Map()));
  }
  let dep = depsMap.get(key);
  if(!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  dep.add(effect);
}

const trigger = (target, key) => {  // 3 执行指定对象的指定属性的所有副作用
  const depsMap = targetMap.get(target);
  if(!depsMap) return;
    let dep = depsMap.get(key);
  if(dep) {
    dep.forEach( effect => effect() );
  }
};

track(product, 'price');
console.log(`total: ${total}`); // total: 0
effect();
console.log(`total: ${total}`); // total: 20
product.price = 20;
trigger(product, 'price');
console.log(`total: ${total}`); // total: 40

"自动挡"的响应式系统:Proxy与Reflect的结合

到目前为止,我们已经建立了一个依赖追踪系统,但它还是“手动挡”的:

  1. 我们需要在代码中手动调用 track() 来收集依赖。
  2. 我们需要在数据更新后手动调用 trigger() 来触发更新。

这显然不够智能。现在,让我们利用 ProxyReflect 将它升级为“自动挡”。我们的目标是:当访问一个对象的属性时,自动执行 track();当修改一个对象的属性时,自动执行 trigger()

第一步:创建 reactive 函数

我们先创建一个 reactive 函数,它接收一个普通对象,并返回一个该对象的代理。所有的操作都将发生在这个代理的 handler 中。

const targetMap = new WeakMap(); // 依赖存储保持不变

// 副作用函数也保持不变
let activeEffect = null; // 我们需要一个变量来存储当前正在运行的副作用函数

function reactive(target) {
  const handler = {
    // get 拦截器:当读取属性时触发
    get(target, key, receiver) {
      console.log(`GET: 访问属性 ${key}`);
      // ... 在这里自动收集依赖
    },
    // set 拦截器:当设置属性时触发
    set(target, key, value, receiver) {
      console.log(`SET: 设置属性 ${key}${value}`);
      // ... 在这里自动触发更新
    }
  };
  return new Proxy(target, handler);
}

第二步:在 get 拦截器中自动收集依赖

当代码访问代理对象的属性时(例如 product.price),get 拦截器会被触发。这正是我们调用 track() 的时机。

同时,为了让 track 函数知道要收集哪个副作用,我们引入一个全局变量 activeEffect,用于存储当前正在执行的副作用函数。

// track 函数现在需要知道当前激活的副作用是哪个
function track(target, key) {
  if (activeEffect) { // 只有在 activeEffect 存在时才进行追踪
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()));
    }
    let dep = depsMap.get(key);
    if (!dep) {
      depsMap.set(key, (dep = new Set()));
    }
    dep.add(activeEffect); // 收集当前激活的副作用
  }
}

// reactive 函数的 get handler
const handler = {
  get(target, key, receiver) {
    track(target, key); // 自动收集依赖
    // 使用 Reflect.get 返回属性的原始值,确保 this 指向正确
    return Reflect.get(target, key, receiver);
  },
  // ... set handler
};

第三步:在 set 拦截器中自动触发更新

当代码修改代理对象的属性时(例如 product.price = 20),set 拦截器会被触发。这时则调用 trigger()

// trigger 函数保持不变
function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}

// reactive 函数的 set handler
const handler = {
  // ... get handler
  set(target, key, value, receiver) {
    // 使用 Reflect.set 设置新值
    const result = Reflect.set(target, key, value, receiver);
    trigger(target, key); // 自动触发更新
    return result; // 返回设置操作是否成功
  }
};

第四步:整合与测试

现在,我们把所有部分整合起来,并创建一个 watchEffect 函数来管理 activeEffect

const targetMap = new WeakMap();
let activeEffect = null;

function track(target, key) {
  if (activeEffect) {
    let depsMap = targetMap.get(target);
    if (!depsMap) {
      targetMap.set(target, (depsMap = new Map()));
    }
    let dep = depsMap.get(key);
    if (!dep) {
      depsMap.set(key, (dep = new Set()));
    }
    dep.add(activeEffect);
  }
}

function trigger(target, key) {
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  let dep = depsMap.get(key);
  if (dep) {
    dep.forEach(effect => effect());
  }
}

function reactive(target) {
  const handler = {
    get(target, key, receiver) {
      track(target, key);
      return Reflect.get(target, key, receiver);
    },
    set(target, key, value, receiver) {
      const result = Reflect.set(target, key, value, receiver);
      trigger(target, key);
      return result;
    }
  };
  return new Proxy(target, handler);
}

// watchEffect 用于注册副作用函数
function watchEffect(effect) {
  activeEffect = effect;
  effect(); // 立即执行一次,以触发 get 从而收集依赖
  activeEffect = null;
}

// --- 测试 ---
let product = reactive({ price: 10, quantity: 2 });
let total = 0;

watchEffect(() => {
  // 这个函数现在是我们的副作用
  total = product.price * product.quantity;
});

console.log(`total: ${total}`); // total: 20

// 当我们修改 price 时,不再需要手动调用 trigger
product.price = 20;
console.log(`total: ${total}`); // total: 40 (自动更新!)

// 当我们修改 quantity 时,也同样会自动更新
product.quantity = 3;
console.log(`total: ${total}`); // total: 60 (自动更新!)

现在,我们拥有了一个真正的“自动挡”响应式系统。我们只需要用 reactive() 包裹我们的数据,并用 watchEffect() 注册依赖于这些数据的操作,剩下的依赖收集和触发更新都由 Proxy 自动完成了。

总结

在本文中,我们通过一个循序渐进的过程,亲手实现了一个迷你版的Vue 3响应式系统。让我们回顾一下这个旅程:

  1. 从基础开始:我们首先理解了响应式的核心概念——当数据变化时,依赖该数据的代码应该自动重新执行。我们用一个简单的 Set 来存储单个依赖(副作用函数),并手动调用 tracktrigger
  2. 支持对象属性:为了处理对象,我们引入了 Map,建立了从“属性名”到“依赖集合”的映射关系,使得每个属性都能独立追踪自己的依赖。
  3. 支持多个对象:为了管理多个响应式对象,我们引入了 WeakMap,构建了 targetMap -> depsMap -> dep 的三层依赖存储结构。至此,我们的“手动挡”响应式系统已经成型。
  4. 迈向自动化:我们认识到手动调用 tracktrigger 的繁琐和不可靠。于是,我们引入了 ES6 的 ProxyReflect
    • Proxy 允许我们拦截对象的 getset 操作。我们在 get 拦截器中自动调用 track,在 set 拦截器中自动调用 trigger
    • Reflect 则作为 Proxy 的最佳搭档,提供了执行对象默认操作的标准方法,确保了操作的健壮性和 this 指向的正确性。

通过这个过程,我们不仅深入理解了Vue 3响应式系统的核心原理,还掌握了 ProxyReflect 这两个强大的JavaScript特性。

参考

告别 Vue 多分辨率适配烦恼:vfit 让元素定位 “丝滑” 跨设备

pre-sales-poster.jpg 在前端开发中,“多分辨率适配”一直是个绕不开的坎。尤其是Vue项目,面对从手机到大屏的各种设备,既要保证元素比例不变,又要让位置精准,往往需要手写大量缩放计算或媒体查询,代码冗余且难维护。

今天推荐一个专为Vue 3设计的轻量方案——vfit,通过“全局缩放管理+组件化定位”的思路,让适配工作变得简单可控。

为什么需要vfit?

传统适配方案(如rem、vw/vh)的痛点在于:

  • • 仅能处理“大小”适配,难以保证“位置”在不同分辨率下的一致性;
  • • 需手动维护基准值,缩放逻辑与业务代码耦合;
  • • 对固定像素布局(如设计稿上精确到px的定位)支持不友好。

vfit的解决思路更直接:

  1. 以设计稿宽高(如1920×1080)为基准,实时计算容器的缩放比例(scale = 容器尺寸 / 设计稿尺寸);
  2. 通过FitContainer组件,根据缩放比例自动调整元素的位置和大小,同时支持两种定位模式(px/%)。

核心能力解析

  1.  灵活的缩放模式
    vfit提供3种缩放策略,覆盖绝大多数场景:

    • • width:按容器宽度缩放(scale = 容器宽 / 设计稿宽);
    • • height:按容器高度缩放(scale = 容器高 / 设计稿高);
    • • auto:自动对比容器宽高比与设计稿宽高比,选择更合适的维度缩放(避免元素被截断)。
  2.  组件化定位
    内置的FitContainer组件是核心,通过top/bottom/left/right属性定义位置,配合unit参数控制定位逻辑:

    示例代码(像素定位):

    <template>  
      <div class="viewport" style="position: relative; width: 100%; height: 100vh;">  
        <FitContainer :top="90" :left="90" unit="px">  
          <div class="box">固定像素布局</div>  
        </FitContainer>  
      </div>  
    </template>  
    
    • • unit="%":位置基于容器百分比,不受缩放影响(适合居中、相对布局);
    • • unit="px":位置会自动乘以当前缩放值(适合固定像素定位,如设计稿上left:90px,缩放后实际位置为90×scale)。
  3.  全局缩放值访问
    通过useFitScale()钩子可在组件内获取当前缩放值(Ref<number>),方便自定义缩放逻辑:

    import { useFitScale } from 'vfit'  
    
    const scale = useFitScale()  
    console.log('当前缩放比例:', scale.value)  
    

上手成本极低

安装初始化仅需两步:

npm i vfit  
// main.ts  
import { createFitScale } from 'vfit'  
import 'vfit/style.css'  

app.use(createFitScale({  
  target'#app'// 默认为#app,可指定其他容器  
  designWidth1920// 设计稿宽度(默认1920)  
  designHeight1080// 设计稿高度(默认1080)  
  scaleMode'auto' // 默认auto  
}))  

适用场景与优势

  •  优势:轻量(无冗余依赖)、Vue 3原生支持、定位与缩放逻辑解耦、API简洁;
  • 场景:数据大屏、管理系统、多设备兼容的活动页等需要精确布局的场景。

如果你正在为Vue项目的多分辨率适配头疼,不妨试试vfit——它不追求大而全,只专注于把“缩放与定位”这件事做好。现在就去npm安装体验,让适配工作少走弯路~

光图片就300多M,微信小游戏给再大的分包也难啊!

点击上方亿元程序员+关注和★星标

引言

哈喽大家好,有小伙伴说他们公司的游戏项目,光图片资源就高达300M,最近正在考虑上架微信小游戏。

但是,如此庞大的游戏资源,想通过分包的形式肯定是不行的,只能放到服务器上,通过CDN让玩家下载。

笔者还是很好奇,什么样的资源如此巨大,通过对小伙伴的深入了解

惊讶发现,他们的图片资源居然没有压缩!且不说图片是否过于精致、美术手笔是否过于奔放,不压缩实在是太难受了!

言归正传,本期将带小伙伴们一起来看下,在Cocos游戏开发中,如何省心省力地压缩图片,330M能压缩到多少。

本文源工程可在文末获取,小伙伴们自行前往。

图片压缩

图片压缩是一种在尽可能保持图片质量的前提下,减小PNG文件大小的技术。

游戏开发中常用的图片压缩工具有TinyPNGpngquantCompressor.io等等。

pngquant是一款用于PNG图像有损压缩的命令行工具和函数库。

该转换工具能显著减小文件大小(通常高达70%),同时保持完整的Alpha通道透明度。生成的图像兼容所有主流网页浏览器和操作系统。

常用的参数包括:

  • quality min-max:质量,使用满足或超过“最大质量”所需的最少颜色数量。若转换后的质量低于“最低质量”要求,图像将不会被保存。
  • speed N:速度,从1(暴力)到10(最快)。默认为3。速度10的质量降低5%,但比默认速度快8倍。

图片压缩实例

下面一起来看下,在Cocos游戏开发中,如何通过插件集成到项目,使其构建后自动压缩图片。

1.资源准备

先准备一张PNG图片,原图大小0.97M,用来确认压缩是否成功,压缩质量如何。

2.创建扩展

新建一个项目,通过菜单扩展->创建扩展打开扩展创建面板。

我们选择构建插件,这是官方自定义构建插件的一个简单示例,点击创建扩展,我们直接在上面扩展我们的自动压缩。

3.启用扩展

通过菜单扩展->扩展管理器打开扩展管理器,在已安装扩展中找到我们新建的插件,将其改成开启状态。

4.扩展界面

首先我们删掉我们不需要的asset-handlers.tspanel.ts·,在builder.ts中只保留hooks配置。

接下来我们给构建面板加上一组配置,用来控制压缩是否开启、压缩的质量以及压缩的速度:

代码整理如下:

  • 启用复选框(ui-checkbox):默认不开启。
  • 最小质量滑块(ui-slider):0-100,默认65,步进1
  • 最大质量滑块(ui-slider):0-100,默认80,步进1
  • 速度滑块(ui-slider):1-10,默认3,步进1
  • 校验最大质量>=最小质量:

最后通过npm installnpm run build编译即可。

打开构建面板就可以看到我们添加的内容:

5.构建后压缩

界面控制添加完之后,我们需要把pngquant压缩植入到构建完成的钩子(onAfterBuild)里,使其构建完成后自动按照配置的参数进行压缩。

代码整理如下:

  • 检查是否启用了质量控制:
  • 提前准备好压缩的工具,这里包括winmac的,并且根据系统选择:
  • 在构建后目录递归查找所有图片文件:
  • 组装命令开始逐个压缩

6.测试

编译代码后,打开我们的构建面板,开始进行构建,构建完成后可以点开日志查看:

从日志可以看出原文件大小: 1002.9KB, 压缩后大小: 179.6KB, 压缩了82.1%,提示压缩成功。

打开构建好的目录,找到我们的资源,可以看到已经压缩完成:

图片压缩进阶

1.过滤

通常游戏中会有一些图片为了避免模糊,例如主界面,会通过配置的或者特殊文件名开头的形式进行过滤,不进行压缩,小伙伴们在面板或者文件配置,然后压缩前过滤即可。

2.记录

图片比较多,每次构建耗时比较长的话,可以把压缩提前到构建之前,并通过MD5记录表示图片已经压缩,直接跳过该文件,避免重复压缩。

结语

通过压缩,小伙伴的图片资源从330M降到了273M,压缩了18%。感觉剩余资源还是比较大,建议从其他方式进行检查和处理,例如查找没依赖的资源删除找美术重造等等。

不知道小伙伴们有没有其他更好的办法呢?

本文源工程可通过私信发送 PngExtension 获取。

我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

AD:笔者线上的小游戏《打螺丝闯关》《贪吃蛇掌机经典》《重力迷宫球》《填色之旅》《方块掌机经典》大家可以自行点击搜索体验。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐专栏:

知识付费专栏

你知道和不知道的微信小游戏常用API整理,赶紧收藏用起来~

100个Cocos实例

8年主程手把手打造Cocos独立游戏开发框架

和8年游戏主程一起学习设计模式

从零开始开发贪吃蛇小游戏到上线系列

点击下方灰色按钮+关注。

AI编程革命:React + shadcn/ui 将终结前端框架之战

前言

大家好,我是倔强青铜三。欢迎关注我,微信公众号:倔强青铜三。欢迎点赞、收藏、关注,一键三连!!!

在AI编程浪潮席卷开发世界的今天,大语言模型(LLMs)正重塑代码创作的规则。

当开发者们还在争论Vue、Angular或Svelte的优劣时,一个清晰的趋势正浮出水面:React与shadcn/ui的组合,凭借其与LLMs的天然契合,正悄然接管前端战场。

这不是空谈——而是由代码本质和AI行为模式驱动的必然。让我们撕开争论的表象,直面为什么这对组合将在AI时代称霸。

React:LLMs眼中的“理想框架”

在AI驱动的编码时代,框架的胜出不再仅靠性能或API设计,而取决于它如何“对话”大模型。

React的架构为此而生。其核心是声明式组件模型:函数组件和JSX语法将UI拆解为纯JavaScript逻辑,结构清晰、无隐藏状态。

这与LLMs的训练方式完美匹配——模型在海量开源代码上训练,而GitHub上React的普及率使其成为LLMs最熟悉的“语言”。

相比之下,Vue的模板语法引入额外抽象层,Angular的依赖注入和装饰器常导致复杂上下文,Svelte的编译时魔法则超出LLMs的实时推理能力。

LLMs生成React代码时,错误率更低、可读性更高,因为函数式组件如同乐高积木,模型能轻松组合、修改,而无需理解深层框架魔法。这不是框架信仰之争,而是AI效率的必然选择。

shadcn/ui:组件库的“AI-native”革命

当谈到组件库,传统方案如Material UI或Ant Design在AI时代暴露了致命弱点:它们是“黑盒”。封装过度的组件隐藏实现细节,定制时需破解CSS-in-JS或私有API,让LLMs生成的代码常失效。

shadcn/ui彻底颠覆此模式——它不是运行时库,而是一套可复制粘贴的Tailwind CSS组件,基于Radix UI构建。开发者直接拥有代码,LLMs能像编辑文本一样修改它:调整一个按钮颜色?只需替换Tailwind类名;重构表单布局?模型能精准插入新逻辑。

这种“零抽象”设计,让LLMs避免猜测内部状态,输出更可靠的代码。其他库依赖主题引擎或样式隔离,LLMs却常因不熟悉这些隐式规则而崩溃。

shadcn/ui的透明性,使它成为AI生成界面的终极画布——没有惊喜,只有可控创新。

为何LLMs是React的“天然盟友”

大语言模型偏爱React,源于其训练数据的DNA。互联网上,React项目占据主导:无数教程、Stack Overflow讨论和开源仓库,让模型内化了其模式。

JSX混合HTML与JavaScript的语法,结构接近自然语言,LLMs能轻松解析并生成类似片段。函数组件的纯函数特性——输入props,输出UI——符合LLMs对确定性逻辑的偏好。

反观Angular的TypeScript装饰器或Vue黑魔法般的响应式实现,引入更多隐式依赖,模型需额外推理解析,错误风险倍增。

更关键的是,React的生态系统(如React Query或Zustand)鼓励解耦模式,LLMs能独立生成状态管理或数据获取逻辑,而无需全局上下文。

这不是React“更好”,而是LLMs在现有数据分布下,更高效地产出高质量React代码。

shadcn/ui:LLMs界面生成的“完美接口”

shadcn/ui的设计哲学,正是为AI协作而生。

其组件由原子Tailwind类驱动——例如bg-blue-500 hover:bg-blue-600——这些类名语义清晰、组合灵活,LLMs能直接映射到设计需求(“一个悬停时变深的按钮”)。模型无需学习库特定的prop命名规则(如Material UI的variant="contained"),而是操作人类可读的CSS原语。

此外,shadcn/ui的按需复制机制,让LLMs生成的代码无外部依赖,避免版本冲突或tree-shaking问题。当开发者要求“添加一个模态框”,LLMs能输出完整、可运行的组件文件,而非调用未知库函数。其他组件库的封装层,在AI眼中如同迷雾;shadcn/ui的透明性,则让模型成为精准的“代码装配工”。

在AI编程的新纪元,技术栈的胜者不是由基准测试决定,而是由人与模型的协作效率定义。

React的组件化哲学与shadcn/ui的无摩擦定制,共同构建了一个LLMs能无缝赋能的开发流。

AI不会取代开发者,但会放大那些选择正确工具的人。

拥抱React与shadcn/ui,不是跟随潮流,而是为未来编写代码——在那里,框架战争已由AI的偏好悄然终结。

你,准备好站在胜利的一方了吗?

最后感谢阅读!欢迎关注我,微信公众号: 倔强青铜三
点赞、收藏、关注,一键三连!!

Next.js第九章(AI)

AI

Vercel提供了AI SDK,可以让我们在Next.js中轻松集成AI功能。AI SDK 官网

安装AI-SDK

npm i ai @ai-sdk/deepseek @ai-sdk/react

这儿我们使用deepseek作为AI模型,@ai-sdk/react封装了流式输出和上下文管理hook,可以让我们在Next.js中轻松集成AI功能。如果你要安装其他模型,只需要将deepseek替换为其他模型即可。

例如:安装openai模型

npm i ai @ai-sdk/openai @ai-sdk/react

为什么使用deepseek模型?因为deepseek比较便宜,充10元可以测试很久(非广告)。

获取deepSeek API Key

image.png

image.png

然后把生成的API Key复制一下保存起来。

编写API接口

src/app/api/chat/route.ts

import { NextRequest } from "next/server";
import { streamText,convertToModelMessages } from 'ai'
import { createDeepSeek } from "@ai-sdk/deepseek";
import { DEEPSEEK_API_KEY } from "./key";
const deepSeek = createDeepSeek({
    apiKey: DEEPSEEK_API_KEY, //设置API密钥
});
export async function POST(req: NextRequest) {
    const { messages } = await req.json(); //获取请求体
    //这里为什么接受messages 因为我们使用前端的useChat 他会自动注入这个参数,所有可以直接读取
    const result = streamText({
        model: deepSeek('deepseek-chat'), //使用deepseek-chat模型
        messages:convertToModelMessages(messages), //转换为模型消息
        //前端传过来的额messages不符合sdk格式所以需要convertToModelMessages转换一下
        //转换之后的格式:
        //[
            //{ role: 'user', content: [ [Object] ] },
            //{ role: 'assistant', content: [ [Object] ] },
            //{ role: 'user', content: [ [Object] ] },
            //{ role: 'assistant', content: [ [Object] ] },
            //{ role: 'user', content: [ [Object] ] },
            //{ role: 'assistant', content: [ [Object] ] },
            //{ role: 'user', content: [ [Object] ] }
        //]
        system: '你是一个高级程序员,请根据用户的问题给出回答', //系统提示词
    });
   
    return result.toUIMessageStreamResponse() //返回流式响应
}

src/app/page.tsx

我们在前端使用useChat组件来实现AI对话,这个组件内部封装了流式响应,默认会向/api/chat发送请求。

  • messages: 消息列表,包含用户和AI的对话内容
  • sendMessage: 发送消息的函数,参数为消息内容
  • onFinish: 消息发送完成后回调函数,可以在这里进行一些操作,例如清空输入框

messages:数据结构解析

[
    {
        "parts": [
            {
                "type": "text", //文本类型
                "text": "你知道 api router 吗"
            }
        ],
        "id": "FPHwY1udRrkEoYgR", //消息ID
        "role": "user" //用户角色
    },
    {
        "id": "qno6vcWcwFM4Yc8J", //消息ID
        "role": "assistant", //AI角色
        "parts": [
            {
                "type": "step-start" //步骤开始 
            },
            {
                "type": "text", //文本类型
                "text": "是的,我知道 **API Router**。", //文本内容
                "state": "done" //步骤完成
            }
        ]
    }
]
'use client';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { useChat } from '@ai-sdk/react';

export default function HomePage() {
    const [input, setInput] = useState(''); //输入框的值
    const messagesEndRef = useRef<HTMLDivElement>(null); //获取消息结束的ref
    //useChat 内部封装了流式响应 默认会向/api/chat 发送请求
    const { messages, sendMessage } = useChat({
        onFinish: () => {
            setInput('');
        }
    });

    // 自动滚动到底部
    useEffect(() => {
        messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
    }, [messages]);
    //回车发送消息
    const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
        if (e.key === 'Enter' && !e.shiftKey) {
            e.preventDefault();
            if (input.trim()) {
                sendMessage({ text: input });
            }
        }
    };

    return (
        <div className='flex flex-col h-screen bg-linear-to-br from-blue-50 via-white to-purple-50'>
            {/* 头部标题 */}
            <div className='bg-white/80 backdrop-blur-sm shadow-sm border-b border-gray-200'>
                <div className='max-w-4xl mx-auto px-6 py-4'>
                    <h1 className='text-2xl font-bold bg-linear-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent'>
                        AI 智能助手
                    </h1>
                    <p className='text-sm text-gray-500 mt-1'>随时为您解答问题</p>
                </div>
            </div>

            {/* 消息区域 */}
            <div className='flex-1 overflow-y-auto px-4 py-6'>
                <div className='max-w-4xl mx-auto space-y-4'>
                    {messages.length === 0 ? (
                        <div className='flex flex-col items-center justify-center h-full text-center py-20'>
                            <div className='bg-linear-to-br from-blue-100 to-purple-100 rounded-full p-6 mb-4'>
                                <svg className='w-12 h-12 text-blue-600' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
                                    <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z' />
                                </svg>
                            </div>
                            <h2 className='text-xl font-semibold text-gray-700 mb-2'>开始对话</h2>
                            <p className='text-gray-500'>输入您的问题,我会尽力帮助您</p>
                        </div>
                    ) : (
                        messages.map((message) => (
                            <div
                                key={message.id}
                                className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'} animate-in fade-in slide-in-from-bottom-4 duration-500`}
                            >
                                <div className={`flex gap-3 max-w-[80%] ${message.role === 'user' ? 'flex-row-reverse' : 'flex-row'}`}>
                                    {/* 头像 */}
                                    <div className={`shrink-0 w-8 h-8 rounded-full flex items-center justify-center text-white font-semibold ${
                                        message.role === 'user' 
                                            ? 'bg-linear-to-br from-blue-500 to-blue-600' 
                                            : 'bg-linear-to-br from-purple-500 to-purple-600'
                                    }`}>
                                        {message.role === 'user' ? '你' : 'AI'}
                                    </div>
                                    
                                    {/* 消息内容 */}
                                    <div className={`flex flex-col ${message.role === 'user' ? 'items-end' : 'items-start'}`}>
                                        <div className={`rounded-2xl px-4 py-3 shadow-sm ${
                                            message.role === 'user'
                                                ? 'bg-linear-to-br from-blue-500 to-blue-600 text-white'
                                                : 'bg-white border border-gray-200 text-gray-800'
                                        }`}>
                                            {message.parts.map((part, index) => {
                                                switch (part.type) {
                                                    case 'text':
                                                        return (
                                                            <div key={message.id + index} className='whitespace-pre-wrap wrap-break-word'>
                                                                {part.text}
                                                            </div>
                                                        );
                                                }
                                            })}
                                        </div>
                                    </div>
                                </div>
                            </div>
                        ))
                    )}
                    <div ref={messagesEndRef} />
                </div>
            </div>

            {/* 输入区域 */}
            <div className='bg-white/80 backdrop-blur-sm border-t border-gray-200 shadow-lg'>
                <div className='max-w-4xl mx-auto px-4 py-4'>
                    <div className='flex gap-3 items-end'>
                        <div className='flex-1 relative'>
                            <Textarea
                                value={input}
                                onChange={(e) => setInput(e.target.value)}
                                onKeyDown={handleKeyDown}
                                placeholder='请输入你的问题... (按 Enter 发送,Shift + Enter 换行)'
                                className='min-h-[60px] max-h-[200px] resize-none rounded-xl border-gray-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-200 transition-all shadow-sm'
                            />
                        </div>
                        <Button
                            onClick={() => {
                                if (input.trim()) {
                                    sendMessage({ text: input });
                                }
                            }}
                            disabled={!input.trim()}
                            className='h-[60px] px-6 rounded-xl bg-linear-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 transition-all shadow-md hover:shadow-lg disabled:opacity-50 disabled:cursor-not-allowed'
                        >
                            <svg className='w-5 h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
                                <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 19l9 2-9-18-9 18 9-2zm0 0v-8' />
                            </svg>
                        </Button>
                    </div>
                </div>
            </div>
        </div>
    );
}

5.gif

钉钉小程序直传文件到 阿里云OSS

举个🌰,用钉钉小程序将一段视频在 前端 直接传到阿里云上。

由于 js-base64 这个npm包在钉钉小程序使用不了,而目前钉钉小程序又没有base64ToArrayBuffer这类方法,故只能曲线救国,使用 dd.writeFiledd.readFile来做base64的转换。

首先,我们得先处理OSS配置,具体如下:

import crypto from "crypto-js"

const OSSConfig = {
  AccessKeyId: "xxxx",
  AccessKeySecret: "xxxxxx",
  Host: "xxxxx", // 具体某个账号下,
  SecurityToken: "xx", // 可选,使用STS签名时必传。
}

// 计算签名。
const computeSignature = (accessKeySecret, canonicalString) => {
  return crypto.enc.Base64.stringify(
    crypto.HmacSHA1(canonicalString, accessKeySecret)
  )
}

let ossData = null
const getOssConfig = () => {
  const date = new Date()
  date.setHours(date.getHours() + 24) //加 1个小时
  const policyText = {
    expiration: date.toISOString(), // 设置policy过期时间。
    conditions: [
      // 限制上传大小。
      ["content-length-range", 0, 1024 * 1024 * 1024],
    ],
  }
  let fileManager = dd.getFileSystemManager()
  fileManager.writeFile({
    filePath: `${dd.env.USER_DATA_PATH}/test.txt`,
    data: JSON.stringify(policyText),
    success: () => {
      fileManager.readFile({
        filePath: `${dd.env.USER_DATA_PATH}/test.txt`,
        encoding: "base64",
        success: (res) => {
          console.log(res.data, "readFile")
          const signature = computeSignature(
            OSSConfig.AccessKeySecret,
            res.data
          )
          ossData = {
            OSSAccessKeyId: OSSConfig.AccessKeyId,
            signature,
            policy: res.data,
            // "x-oss-security-token": OSSConfig.SecurityToken,
          }
        },
        fail: (err) => {
          console.log(err)
        },
      })
    },
    fail: (err) => {
      console.log(err)
    },
  })
}

这个ossData就是我们处理好后的配置项,接下来将其填充到 dd.uploadFile 里就大功告成了!

dd.chooseVideo({
        sourceType: ["album", "camera"],
        maxDuration: 60,
        success: (res) => {
          let uniqueId = `${dirPath}xcx-${new Date().getTime()}.mp4` // dirPath为存储的文件夹路径, 比如"dev/front-end/video/"
          uni.showLoading({
            title: "视频上传中...",
          })
          dd.uploadFile({
            url: OSSConfig.Host,
            fileType: "video",
            fileName: "file",
            formData: {
              ...ossData, // 上述getOssConfig方法得到的结果
              key: uniqueId, // 该值为你存在在oss上的位置  后面上传成功之后拼接得到链接需要使用
              success_action_status: "200", // 默认上传成功状态码为204,此处被success_action_status设置为200。
            },
            filePath: res.tempFilePath,
            success: (res) => {
              uni.hideLoading()
              console.log("视频上传成功,地址为:",`${OSSConfig.Host}/${uniqueId}`)
            },
            fail: (err) => {
              uni.hideLoading()
            },
          })
        },
        fail: (err) => {
          console.log(err)
        },
      })



欢迎小伙伴留言讨论,互相学习!

❤❤❤ 如果对你有帮助,记得点赞收藏哦!❤❤❤

uni-app D4 实战(小兔鲜)

1. 首页-封装通用轮播组件提高复用

image.png

1.1 轮播图的静态结构(直接复制就好)

<script setup lang="ts">
import { ref } from 'vue'

const activeIndex = ref(0)
</script>

<template>
  <view class="carousel">
    <swiper :circular="true" :autoplay="false" :interval="3000">
      <swiper-item>
        <navigator url="/pages/index/index" hover-class="none" class="navigator">
          <image
            mode="aspectFill"
            class="image"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_1.jpg"
          ></image>
        </navigator>
      </swiper-item>
      <swiper-item>
        <navigator url="/pages/index/index" hover-class="none" class="navigator">
          <image
            mode="aspectFill"
            class="image"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_2.jpg"
          ></image>
        </navigator>
      </swiper-item>
      <swiper-item>
        <navigator url="/pages/index/index" hover-class="none" class="navigator">
          <image
            mode="aspectFill"
            class="image"
            src="https://pcapi-xiaotuxian-front-devtest.itheima.net/miniapp/uploads/slider_3.jpg"
          ></image>
        </navigator>
      </swiper-item>
    </swiper>
    <!-- 指示点 -->
    <view class="indicator">
      <text
        v-for="(item, index) in 3"
        :key="item"
        class="dot"
        :class="{ active: index === activeIndex }"
      ></text>
    </view>
  </view>
</template>

<style lang="scss">
/* 轮播图 */
.carousel {
  height: 280rpx;
  position: relative;
  overflow: hidden;
  transform: translateY(0);
  background-color: #efefef;
  .indicator {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 16rpx;
    display: flex;
    justify-content: center;
    .dot {
      width: 30rpx;
      height: 6rpx;
      margin: 0 8rpx;
      border-radius: 6rpx;
      background-color: rgba(255, 255, 255, 0.4);
    }
    .active {
      background-color: #fff;
    }
  }
  .navigator,
  .image {
    width: 100%;
    height: 100%;
  }
}
</style>

1.2 修改一下组件引入规则(pages.json)

image.png

1.2.1 添加组件类型声明

image.png

image.png

1.3 轮播图的dots实现动态

image.png

image.png 结果:

image.png

image.png

image.png **本次练习重点知识点: ** image.png

1.4 首页-获取轮播图数据

image.png

文件下载:后端配置、前端方式与进度监控

一、后端核心配置:决定下载的 “基础规则”

后端通过 HTTP 响应头控制文件的传输行为,这是所有下载逻辑的起点,关键配置有 3 个:

1. Content-Disposition:控制 “下载” 或 “预览”

  • 配置 1(强制下载)

    Content-Disposition: attachment; filename="file.xlsx"
    
    • 作用:告诉浏览器 “这是一个需要下载的附件”,无论文件类型是什么(图片 / PDF/Excel),都会直接触发下载弹窗。
    • 细节:filename 指定默认下载文件名,支持 UTF-8 编码(需处理中文:filename*=UTF-8''%E6%88%91%E7%9A%84%E6%96%87%E4%BB%B6.xlsx)。
  • 配置 2(在线预览)

    Content-Disposition: inline
    
    • 作用:告诉浏览器 “直接在页面内预览文件”,比如图片直接显示、PDF 在浏览器打开、TXT 直接渲染。
    • 细节:若不设置该字段,浏览器会根据 Content-Type 自动判断(图片 / PDF 默认预览,Excel / 压缩包默认下载)。

2. Content-Type:定义文件的 “类型标识”

  • 作用:告诉浏览器文件的 MIME 类型,决定浏览器如何解析文件(即使配置了 attachment,浏览器也会根据类型处理下载)。

  • 常见配置: # Excel 文件 Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet # PDF 文件 Content-Type: application/pdf # 图片 Content-Type: image/jpeg # 二进制流(通用,推荐大文件) Content-Type: application/octet-stream

  • 关键:若后端返回 application/octet-stream(二进制流),浏览器会直接判定为 “可下载文件”,优先级高于 inline

3. Content-Length:提供文件的 “总大小”

  • 配置:

    Content-Length: 10485760  # 表示文件大小为 10MB
    
  • 作用:告诉前端文件的总字节数,是前端计算下载进度的必要条件(没有这个头,前端无法知道 “总大小”,只能显示 “已下载字节”,无法算百分比)。

  • 注意:若后端用 “分块传输”(Transfer-Encoding: chunked),则不会返回 Content-Length,前端无法计算进度。

4. 其他辅助配置

  • Accept-Ranges: bytes:支持断点续传,前端可请求文件的某一部分(Range: bytes=0-1023),适合大文件分段下载。
  • Access-Control-Expose-Headers: Content-Length:跨域场景下,允许前端读取 Content-Length 等响应头(否则前端拿不到总大小)。

二、前端下载方式:根据需求选择 “策略”

前端有 3 种主流下载方式,对应不同场景,和后端配置直接关联:

方式 1:原生 <a> 标签(最简单,无进度)

  • 用法

    <a href="/api/download/file" download="自定义文件名.xlsx">下载</a>
    
  • 适用场景:后端已配置 Content-Disposition: attachment,且无需监控进度(小文件、对进度无要求)。

  • 和后端配置的关联

    • 若后端配了 attachment:点击直接下载,download 属性可自定义文件名(优先级高于后端的 filename)。
    • 若后端配了 inline:浏览器会预览文件,此时加 download 属性可强制触发下载(部分浏览器支持)。
  • 局限性:无法监控进度、无法处理复杂逻辑(如权限验证、Token 携带)。

方式 2:window.open()(和 <a> 标签类似)

  • 用法

    window.open('/api/download/file?token=xxx');
    
  • 适用场景:需要携带参数(如 Token)的下载,或需要新开窗口处理。

  • 缺点:同样无法监控进度,且可能被浏览器拦截(弹窗拦截)。

方式 3:AJAX/fetch + Blob(支持进度,最灵活)

  • 核心逻辑:前端主动请求文件二进制流,监控传输过程,最后模拟下载。

  • 和后端配置的关联

    • 后端需返回二进制流(Content-Type: application/octet-stream 或对应文件类型)。
    • 若后端配了 Content-Length,可计算精确进度;否则只能监控 “已下载字节”。
  • 完整代码示例(带进度)

    // 1. 创建请求对象
    const xhr = new XMLHttpRequest();
    xhr.open('GET', '/api/download/large-file.zip', true);
    xhr.responseType = 'blob'; // 关键:以 Blob 接收响应
    
    // 2. 监控进度
    xhr.addEventListener('progress', (e) => {
      if (e.lengthComputable) {
        const progress = (e.loaded / e.total) * 100; // 进度百分比
        console.log(`下载进度:${progress.toFixed(2)}%`);
        // 更新 UI:比如进度条宽度、文字提示
        document.getElementById('progress-bar').style.width = `${progress}%`;
      } else {
        console.log(`已下载:${e.loaded} 字节`); // 无总大小,只能显示已下载
      }
    });
    
    // 3. 请求成功:生成下载链接
    xhr.addEventListener('load', () => {
      if (xhr.status === 200) {
        const blob = xhr.response;
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = '大文件.zip'; // 自定义文件名
        a.click();
        URL.revokeObjectURL(url); // 释放内存
      }
    });
    
    // 4. 发送请求(可携带 Token)
    xhr.setRequestHeader('Authorization', 'Bearer ' + token);
    xhr.send();
    
  • 优势

    • 可监控实时进度;
    • 可携带自定义请求头(如 Token),处理权限验证;
    • 可中断请求(xhr.abort());
    • 可处理后端返回的错误(如权限不足时,后端返回 JSON 而非文件,前端可捕获)。

三、下载进度的关联逻辑:为什么只有 Blob 方式能监控?

进度监控的核心是前端能感知 “数据传输的每一步” ,这和请求方式直接相关:

1. <a> 标签 /window.open():进度 “不可见”

  • 这两种方式是浏览器直接和后端通信,前端 JS 被排除在传输过程之外:

    • 浏览器接收到后端的响应头后,直接启动下载进程,JS 无法获取 “已下载多少字节”“总字节数”。
    • 相当于 “把下载交给浏览器,前端只能等结果”,自然无法监控进度。

2. AJAX/fetch + Blob:进度 “可感知”

  • 这种方式是前端 JS 主动和后端通信,数据先流经 JS,再交给浏览器:

    • 后端以 “流” 的形式分段返回数据,每返回一段,就触发 progress 事件;
    • 事件对象 e 包含 loaded(已接收字节)和 total(总字节,来自 Content-Length),前端可实时计算进度;
    • 所有数据接收完成后,JS 将二进制流封装成 Blob,再模拟 <a> 标签下载。

3. 进度监控的关键前提

  • 后端必须返回 Content-Length(否则 e.lengthComputable 为 false,无法算百分比);
  • 后端不能用 “分块传输”(Transfer-Encoding: chunked),否则无 Content-Length
  • 跨域时,后端需配置 Access-Control-Expose-Headers: Content-Length,否则前端拿不到 total

四、完整关联链总结

image.png

核心关键点回顾

  1. 后端是基础Content-Disposition 决定默认行为,Content-Length 决定能否算进度,Content-Type 决定文件解析方式。
  2. 前端选方式:小文件 / 无进度需求用 <a> 标签,大文件 / 需进度用 AJAX+Blob。
  3. 进度靠流控:只有让文件以二进制流形式流经前端 JS,才能捕获传输进度,<a> 标签做不到这一点。

简单说:后端定 “规则”,前端选 “策略”,进度靠 “流控”—— 这就是三者的核心关联!

听说vite要一统江湖了,我看看怎么个事

抛几个问题大家先聊聊

1.  大家心目中的vite是个什么样子

2.  vite快在哪

3.  vite 开发环境和生成环境都用什么打包 为啥不能统一

一、Vite 产生背景

 

1.  传统构建工具的核心痛点(webpack有啥痛点)

 

以 Webpack 为代表的传统工具,因 “全量打包” 模式难以适配复杂项目,主要痛点集中在四点:

● 🚫 开发体验差:冷启动达分钟级,热更新延迟随模块量增长,常丢失开发状态

● ⚡ 性能瓶颈明显:JS 编写的工具占用 CPU / 内存高,多项目并行时设备压力大

● 🔧 适配成本高:需兼容多模块格式(CJS/UMD/ESM),配置 TypeScript 等功能步骤繁琐

● 🕸️ 未利用浏览器新能力:2018 年后浏览器原生支持 ESM,但传统工具未借力优化

 

2. Vite 诞生的 3 大技术基石(vite有啥优势)

 

Vite 的出现依赖前端生态三大关键进展,缺一不可:

1.  🌐 浏览器原生 ESM 普及

支持

2.  🔨 编译工具语言革新

2020 年 Go 语言编写的 esbuild 发布,依赖预构建速度比 JS 工具快 10-100 倍,解决传统工具性能瓶颈。

3.  🧩 构建理念升级

开发环境借鉴 Snowpack “依赖预构建” 思路,生产环境依托 Rollup 成熟插件生态,实现 “按需编译” 的新型构建模式。

 

3. Vite 诞生历程与关键决策(vite时间线)

 

Vite 从解决 Vue 生态痛点起步,逐步发展为跨框架通用工具:

● 📅 初始动机:2019 年 Vue 3 开发中,模块激增导致 Webpack 启动慢,亟需适配现代框架的轻量工具。

● 🚩 关键时间节点:

● 2020.04:Vite 1.0 发布,仅支持 Vue 项目,验证 ESM 开发模式可行性;

● 2021.02:Vite 2.0 重构,成为跨框架工具,引入 Rollup 负责生产构建;

● 2022-2023:3.0/4.0/5.0 版本持续优化性能,完善生态兼容(如支持 React、Svelte 等)。

● 🎯 核心决策:分模块差异化处理

依赖用 esbuild 预构建 + 源码开发时按需编译 + 全流程缓存,平衡速度与兼容性。

 

4. 构建模式对比流程图(webpack vs vite)

 

通过流程图直观对比传统工具与 Vite 的核心差异,重点关注 “是否全量打包”“更新方式” 两个维度:

 

4.1 传统 Bundle 模式(以 Webpack 为例)

image.png

 

4.2 Vite 构建模式(开发 + 生产分离)

image.png

 

5. Vite 核心价值总结 (总结)

Vite 之所以能成为前端构建工具新选择,核心价值体现在四方面:

1.  🔄 范式革新:开发 / 生产分离优化(ESM 按需编译 + Rollup 生产打包),兼顾速度与产物质量;

2.  ⚡ 性能突破:冷启动时间从分钟级压缩至秒级,大型项目开发效率提升显著;

3.  🚀 体验升级:零配置开箱即用,HMR 保持应用状态,减少开发流程中断;

4.  🌍 生态兼容:支持 Vue/React/Svelte 等多框架,兼容 Rollup 插件,降低迁移成本。

 

二. Vite 核心特性解析

 

Vite 颠覆传统构建工具的核心在于利用现代浏览器能力与分层优化策略,其三大核心特性共同实现了 “开发极速响应、生产高效输出” 的体验升级。

2.1 特性一:极速冷启动(毫秒级启动)

 

传统工具需全量打包后启动服务,而 Vite 通过 “依赖预构建 + 源码按需加载” 实现极速启动,启动时间不受项目体积线性影响。

 

2.1.1 实现原理

1.  模块分层处理

首次启动时将项目模块拆分为两类,针对性优化:

● 依赖模块:开发中不变的纯 JS 依赖(如 Lodash、Vue 核心库),多含 CJS/UMD 格式,需统一转换为 ESM 并合并减少请求量。

image.png

● 源码模块:频繁编辑的业务代码(含 JSX、Vue 组件等),直接以原生 ESM 格式提供,浏览器请求时才按需编译。

image.png

2.  esbuild 预构建依赖

采用 Go 语言编写的 esbuild 处理依赖,速度比 JS 工具快 10-100 倍,预构建结果存入缓存(node_modules/.vite),二次启动直接复用。

3.  浏览器接管部分打包工作

通过

 

2.1.2 冷启动流程可视化

image.png

 

2.2 特性二:精准热模块替换(HMR)

 

Vite 的 HMR 基于原生 ESM 实现,更新速度不随项目体积增长而下降,且能保持应用运行状态。

 

2.2.1 核心机制

1.  模块依赖图追踪

启动时构建 moduleGraph 记录模块间依赖关系(如 A 依赖 B、B 依赖 C),每个模块对应唯一 ModuleNode 对象,包含转换结果与依赖链信息。

packages/vite/src/server/moduleGraph.ts

2.  精准失效范围

文件修改后,仅使变更模块及其最近的 HMR 边界(如 Vue 组件的

3.  WebSocket 实时通信

● 用户修改文件后被 server 端的监听器监听到,监听器遍历文件对应的模块,计算出热更新边界

● server 端通过 websocket 向 client 发送热更新信号

● client 对旧模块进行失活,向 server 请求最新的模块资源

● server 收到请求后将模块代码转换为 js,并将转换后的代码返回给 client

● client 执行返回后的代码,调用更新函数更新页面内容

4.  缓存加速

源码模块通过 304 Not Modified 协商缓存,依赖模块通过 immutable 强缓存,避免重复请求。

 

2.2.2 HMR 工作流程可视化

 

image.png

 

2.3 特性三:开发与生产双环境优化

Vite 采用 “开发按需编译、生产优化打包” 的差异化策略,兼顾开发效率与生产性能。

 

2.3.1 双环境设计逻辑
维度 开发环境(dev) 生产环境(build)
核心目标 极速启动、实时反馈 产物体积小、加载快、兼容性强
实现方式 原生 ESM 按需编译 + esbuild 预构建 Rollup 优化打包 + 多维度性能优化
关键操作 模块缓存、HMR 局部更新 Tree-shaking、代码分割、压缩、预加载注入
工具依赖 Koa 服务器、WebSocket、chokidar Rollup、Terser、CSSNano
 
2.3.2 生产环境优化细节

1.  Rollup 打包核心

未采用 esbuild 生产打包的原因:Rollup 拥有更成熟的插件生态与更优的代码分割策略,能实现更精细的 Tree-shaking(剔除无用代码)。未来计划迁移至 Rust 编写的 Rolldown,进一步提升性能。

2.  智能代码分割

自动拆分公共依赖(如 Vue 核心库)与业务代码,生成独立 chunk,利用浏览器缓存提升二次加载速度。

3.  资源优化

● 压缩:JS 用 Terser 压缩,CSS 用 CSSNano 处理;

● 预加载:自动生成 ,提前加载关键模块;

● 兼容性:通过 @vitejs/plugin-legacy 生成 ES5 代码,适配旧浏览器。

为啥不用esbuild在生产环境

● 代码分割(Code Splitting)能力较弱:esbuild 的代码分割逻辑相对简单,对动态导入(import())的处理、公共模块提取(splitChunks)等高级需求支持不足,而现代前端项目(尤其是大型应用)依赖灵活的代码分割来优化加载性能。

● 生态兼容性有限:esbuild 的插件系统不如 Rollup 成熟,许多前端生态中常用的工具(如处理 CSS 模块化、静态资源、特定框架特性的插件)对 esbuild 的适配不够完善,而 Rollup 拥有丰富的插件生态,能更好地兼容前端工程化的复杂需求。

● 对非 ESM 模块的处理能力较弱:虽然 esbuild 支持转换 CommonJS 模块,但在处理复杂依赖关系(如循环依赖、动态 require)时,可能出现与 Webpack/Rollup 不一致的行为,存在兼容性风险。

 

2.3.3 双环境流程对比

image.png

 

2.4 特性总结:为何 Vite 能实现 “快且优”?

1.  理念革新:让浏览器参与模块解析,将传统打包工具的 “预打包” 改为 “按需编译”,从根源上提升启动速度。

2.  技术选型精准:esbuild 处理依赖、Rollup 负责生产打包、WebSocket 实现 HMR,每一步都采用当前最优工具链。

3.  分层优化思维:针对 “开发 - 生产”“依赖 - 源码” 的不同特性设计差异化策略,既满足开发效率又保证生产性能。

 

三、  Vite 各版本对比及 Demo 展示

 

3.1版本演进核心脉络与定位

Vite 的版本迭代围绕「性能突破」与「架构统一」两大主线展开,可划分为三个关键阶段:

1.  基础奠基期(V4.x):验证「非打包开发」核心模式,奠定极速启动基础

2.  生态拓展期(V5.x-V6.x):完善框架适配与工具链集成,暴露混合架构瓶颈

3.  架构重构期(V7.x-V8 Beta):引入 Rust 工具链,实现开发 / 生产流程统一

 

3.2关键版本核心特性对比

维度 V4.x(奠基期) V5.x-V6.x(拓展期) V7.x(转型期) V8 Beta(重构期)
核心架构 esbuild 预打包 + Rollup 生产构建 保留双工具架构,优化协作逻辑 引入 Rolldown 试验性支持 全 Rolldown 驱动,彻底替代双工具
Node 支持 Node.js 14.18+ Node.js 16.14+ Node.js 20.19+/22.12+(弃用 18) 同 V7,兼容 LTS 版本
框架适配 Vue/React/Svelte 核心支持 新增 Marko 模板,RedwoodSDK 整合 完善 Vue 3.5/React 19 适配 全框架兼容,支持微前端联邦架构
TypeScript 能力 基础 TS 转译,依赖 esbuild 内置 TS 5.8,支持常量枚举内联 集成 Oxc TS 解析,类型检查提速 30% 原生 TS 类型优化,支持增量编译
性能优化点 冷启动 161ms(对比 Webpack 快 11.7 倍) 解析逻辑优化,冷启动比 V4.2 提升 70% 生产构建速度提升 30%,热更新 < 50ms 跨块优化提速 15 倍,打包体积降 20%
关键新特性 HMR 模块依赖图追踪 模块联邦支持,barrel 文件优化 新增 buildApp 钩子,Vite DevTools 开发 全捆绑模式,原生 Importmaps 支持

 

3.3. 核心架构迭代解析

版本阶段 架构示意图 核心痛点解决
V4.x-V6.x 红色虚线框:开发用 esbuild、生产用 Rollup,工具链割裂导致环境差异黄色感叹号:Rollup 单线程处理大型项目时,构建速度瓶颈明显 
V7.x 过渡特性标注:绿色模块:新增的 Rolldown 试验性功能,主打性能提升黄色模块:优化后的 Dev Server,内存占用降低 30%分支逻辑:保留双工具链选项,平衡兼容性与性能 
V8 Beta 蓝色粗框:Rolldown 统一工具链,消除环境差异绿色模块:多线程 + 增量打包,性能核心产物体积比 V6.x 降 20%,无需手动配置 vendor 

 

3.4. 大型项目性能实测(复杂多应用工程)

指标 V4.x V6.x V7.x V8 Beta
开发冷启动时间 2.8s 1.5s 0.9s 0.3s
生产构建时间 3m12s 2m0s 1m15s 8s
热更新响应时间 120ms 80ms 45ms 20ms
内存占用 180MB 120MB 90MB 42MB

数据来源:Vite 官方 benchmark 及 PayFit、掘金社区实测案例综合整理

 

3.5. 各版本适用场景

● V4.x:维护旧项目,依赖 Node.js 18 及以下环境

● V6.x:中型项目稳定运行,需 Marko/Redwood 生态支持

● V7.x:追求性能提升,可接受 Rust 工具链过渡适配

● V8 Beta:大型项目 / 微前端架构,需极致构建性能

 

3.6. 迁移成本与收益对比

迁移路径 核心改动点 预期收益 潜在风险
V4→V6 升级 Node.js 至 16+,适配 TS 5.8 冷启动提速 46%,生态工具更丰富 部分旧插件兼容性问题
V6→V7 升级 Node.js 至 20+,适配 Rolldown 试验模式 生产构建提速 40%,热更新延迟减半 少数第三方库导入顺序问题
V7→V8 Beta 移除 Rollup 配置,启用全捆绑模式 构建速度提升 15 倍,打包体积降 20% 部分插件需迁移至 Rust 原生实现

 

3.7. 7+版本演进核心优势

1.  性能质变节点:V7.x 引入 Rolldown 标志着性能提升从「优化迭代」进入「架构重构」阶段,V8 Beta 实现质的飞跃

2.  架构统一价值:全 Rolldown 驱动解决了 Vite 诞生以来的「开发 / 生产环境不一致」核心痛点

3.  生态适配节奏:每个大版本均保持对主流框架的兼容性,V8 将完成从工具到生态的全面升级

参考 Vite 官方路线图:2025 年底 V8 正式版将实现 Rolldown 全量启用,届时 V4-V7 版本将逐步进入维护期

 

3.8. 快速搭建各版本项目demo

# Vite 3(需指定版本)
npm create vite@3 my-v3-app -- --template vue
# Vite 4
npm create vite@4 my-v4-app -- --template vue
# Vite 5
npm create vite@5 my-v5-app -- --template vue
# Vite 6
npm create vite@6 my-v6-app -- --template vue
# Vite 7
npm create vite@7 my-v7-app -- --template vue

 

四、vite4核心源码解析

 

4.1、Vite 4 核心架构与源码组织

Vite 4 采用 monorepo 结构(基于 pnpm workspace),核心代码集中于 packages/vite 目录,整体架构可划分为五大核心模块,各模块职责明确且协同联动。

 

4.2. 核心模块概览

模块路径 核心职责 关键依赖 / 工具
src/node/cli.ts 命令行入口,解析参数分发命令 cac(命令行解析工具)
src/node/config/ 配置解析与合并,支持多环境配置 -
src/node/server/ 开发服务器实现,集成 HMR 与中间件 connect(中间件框架)、chokidar(文件监听)
src/node/build/ 生产构建流程,基于 Rollup 实现 Rollup 3、esbuild
src/node/plugin/ 插件系统核心,定义钩子与容器 -

 

4.3. 核心数据结构

●  ModuleGraph(src/node/server/moduleGraph.ts):维护模块依赖关系的核心数据结构,记录 URL 与文件路径映射、模块依赖链及 HMR 状态,是按需编译与热更新的基础。

●  PluginContainer(src/node/pluginContainer.ts):插件容器,统一调度插件钩子执行,实现模块解析、转换等流程的可扩展性。

 

 

4.4、核心流程源码解析

Vite 4 的核心能力集中体现在开发环境启动、模块按需编译、热更新(HMR) 与生产构建四大流程,以下结合源码片段深度拆解。

 

4.4.1. 开发服务器启动流程(vite dev

启动流程是 Vite 4 极速体验的起点,核心是初始化配置、构建中间件链与启动 HMR 服务,源码入口为 src/node/cli.ts,关键逻辑在 createServer 函数中实现。

 

关键步骤与源码

  1. 命令行解析与配置合并
   // src/node/cli.ts 简化逻辑
   async function createServer(inlineConfig = {}) {
     // 1. 解析配置:合并默认配置、用户配置、环境变量
     const config = await resolveConfig(inlineConfig, 'serve')
        
     // 2. 创建中间件容器与 HTTP 服务器
     const middlewares = connect()
     const httpServer = await resolveHttpServer(config.server, middlewares)
     
     // 3. 初始化 WebSocket 服务(HMR 通信通道)
     const ws = createWebSocketServer(httpServer, config)
     
     // 4. 创建文件监听器(监控源码与配置变化)
     const watcher = chokidar.watch(root, resolvedWatchOptions)
 
     // 5. 初始化模块依赖图
     const moduleGraph = new ModuleGraph((url) => pluginContainer.resolveId(url))
     
     // 6. 注册核心中间件(按顺序执行)
     middlewares.use(timeMiddleware) // 响应时间统计
     middlewares.use(corsMiddleware(config)) // 跨域处理
     middlewares.use(proxyMiddleware(config)) // 代理配置
     middlewares.use(transformMiddleware(config, moduleGraph, ws)) // 模块转换核心
     middlewares.use(indexHtmlMiddleware(config, moduleGraph)) // HTML 处理
     middlewares.use(errorMiddleware()) // 错误捕获
     
     // 7. 初始化插件容器
     await pluginContainer.buildStart({})
     
  // 8. 初始化依赖预构建器(后台启动,不阻塞服务器启动)
     await initDepsOptimizer(config, options.force, true);
     
     return { server, moduleGraph, ws }
   }

 

  1. 依赖预构建优化

Vite 4 默认启用 esbuild 处理依赖预构建,将 CommonJS 格式的依赖转换为 ESM 并缓存,避免浏览器兼容性问题,源码位于 src/node/depOptimizer/。预构建产物存储于 node_modules/.vite/deps,首次启动后会缓存,二次启动直接复用。

 

启动流程可视化

image.png

4.4.2. 模块按需编译流程(核心性能点)

Vite 4 区别于传统打包工具的核心是按需编译:浏览器请求模块时才触发编译,而非全量预打包,核心实现依赖 transformMiddleware 中间件。

 

关键逻辑与源码

  1. 请求拦截与模块解析
   // src/node/server/middlewares/transform.ts 简化逻辑
   async function transformMiddleware(req, res, next) {
     const url = req.url
     // 1. 忽略静态资源与非模块请求
     if (isStaticAsset(url) || !req.headers.accept?.includes('text/javascript')) {
       return next()
     }
     
     // 2. 从模块图获取或创建模块实例
     let module = moduleGraph.getModuleByUrl(url)
     if (!module) {
       const resolved = await pluginContainer.resolveId(url)
       module = await moduleGraph.createModule(resolved.id, url)
     }
     
     // 3. 执行插件转换(如 Vue SFC 解析、TS 转译)
     const transformResult = await pluginContainer.transform(code, module.file)
     
     // 4. 注入 HMR 客户端代码(开发环境)
     if (!isProduction) {
       transformResult.code += injectHmrClientCode(url)
     }
     
     // 5. 返回编译结果给浏览器
     res.setHeader('Content-Type', 'application/javascript')
     res.end(transformResult.code)
   }

拦截模块请求浏览器的请求先经过 Vite 服务器的中间件链,transformMiddleware 会识别出 “需要编译的模块请求”(如 .vue、.ts、.jsx 等非原生 ESM 模块,或需要转换的 JS 模块)。

定位模块文件

通过 ModuleGraph(之前提到的模块依赖图)将请求的 URL(如 /src/App.vue)映射到本地文件系统的路径(如 ./src/App.vue),确认模块的物理位置。

 

调用插件处理(编译)

借助 PluginContainer(插件容器)调用对应插件的转换钩子(如 transform),对模块内容进行实时编译:

例如,.vue 文件会被 @vitejs/plugin-vue 解析为模板、脚本、样式三部分,分别编译为浏览器可执行的 JS 代码;

例如,.ts 文件会被 @vitejs/plugin-typescript 转换为 JS 代码。

 

处理依赖关系

编译过程中,若模块依赖其他模块(如 App.vue 中 import Button from './Button.vue'),transformMiddleware 会通过 ModuleGraph 记录这些依赖关系,为后续的热更新做准备。

 

返回编译结果

将编译后的代码(符合 ESM 规范)作为 HTTP 响应返回给浏览器,浏览器直接执行该模块。

 

  1. 插件转换机制

以 Vue 单文件组件(SFC)为例,@vitejs/plugin-vue 插件通过 transform 钩子拦截 .vue 文件请求,将其拆分为模板、脚本、样式三部分分别处理,再合并为浏览器可识别的 ESM 模块。

 

4.4.3. 热更新(HMR)流程

Vite 4 升级了 HMR 引擎,大型项目热更新延迟从 1200ms 降至 500ms 内,核心是"精确模块更新"而非全页刷新,依赖文件监听、模块依赖分析与 WebSocket 通信实现。

 

关键步骤与源码

  1. 文件变化监听与依赖分析
// vite/src/node/server/hmr.ts 核心逻辑简化版
async function handleHMRUpdate(file: string, server: ViteDevServer) {
  const { ws, config, moduleGraph } = server
  
  // 1. 确定受影响的模块
  const mods = moduleGraph.getModulesByFile(file)
  
  // 2. 根据文件类型执行不同更新策略
  if (isCSSRequest(file)) {
    // CSS热更新逻辑
    await Promise.all(
      Array.from(mods).map((mod) => {
        return moduleGraph.invalidateModule(mod)
      })
    )
    ws.send({
      type: 'update',
      updates: [{
        type: 'css-update',
        path: publicPath,
        timestamp: Date.now()
      }]
    })
  } else {
    // JS/HTML等热更新逻辑
    const update = await generateUpdate(mods, file, server)
    ws.send({
      type: 'update',
      updates: update
    })
  }
}
 

 

  1. 客户端更新处理

浏览器端通过 import.meta.hot API 接收更新通知,替换模块并执行自定义更新逻辑(如 Vue 组件重新渲染):

   // 客户端 HMR 逻辑(src/client/client.ts)
   import.meta.hot.accept('./component.js', (newComponent) => {
     // 替换组件并重新挂载
     replaceComponent(newComponent.default)
   })

 

HMR 流程可视化

image.png

4.4.4. 生产构建流程(vite build

生产环境下,Vite 4 采用 Rollup 3 进行打包优化,核心是代码分割、压缩与兼容性处理,源码入口为 src/node/build/index.ts

 

关键步骤

  1. 配置解析与构建准备:合并生产环境配置,确定目标浏览器与输出格式。

  2. Rollup 配置生成:根据 Vite 配置自动生成 Rollup 配置,支持 build.rollupOptions 深度定制。

  3. 插件适配与执行:将 Vite 插件转换为 Rollup 插件格式,执行模块转换与优化。

  4. 产物优化:默认启用 Terser 压缩 JS,CSS 压缩通过 cssnano 实现,支持自定义压缩工具。

 

核心配置示例

// Vite 4 生产构建配置
export default {
  build: {
    target: 'es2015', // 目标浏览器兼容性
    minify: 'terser', // 启用 Terser 压缩
    rollupOptions: {
      // 自定义代码分割策略
      output: {
        manualChunks: {
          vendor: ['vue', 'vue-router'], // 第三方依赖单独打包
          utils: ['lodash', 'dayjs']
        }
      }
    }
  }
}

 

 

4.5、Vite 4 核心技术亮点源码解析

4.5.1. HMR 性能优化(V4 核心升级点)

Vite 4 对 HMR 引擎进行了重构,通过差分更新与模块依赖缓存减少重复计算,源码关键优化点在 ModuleGraphinvalidateModule 方法中:

// src/node/server/moduleGraph.ts
function invalidateModule(module) {
  // 仅标记变化模块,不清除整个依赖链缓存
  module.invalidated = true
  // 递归标记依赖模块,但跳过已缓存的无变化模块
  for (const importer of module.importers) {
    if (!importer.invalidated) invalidateModule(importer)
  }
}

 

4.5.2. 中间件链设计(可扩展性核心)

Vite 4 中间件按固定顺序执行,确保请求处理的正确性,核心中间件功能如下表:

中间件 核心功能 源码路径
transformMiddleware 模块实时编译(TS/Vue 转译) src/node/server/middlewares/transform.ts
indexHtmlMiddleware HTML 入口处理与资源注入 src/node/server/middlewares/indexHtml.ts
proxyMiddleware 跨域代理与请求转发 src/node/server/middlewares/proxy.ts
errorMiddleware 全局错误捕获与格式化 src/node/server/middlewares/error.ts

 

 

4.6、核心流程总览流程图

image.png

 

Vite 4 的源码设计核心是"扬长避短":用 esbuild 处理快但不灵活的步骤(预构建、转译),用 Rollup 处理灵活但慢的生产打包,用中间件与插件系统保证可扩展性,最终实现"极速开发+优化产物"的双重目标。

五、Vite 7 核心源码解析

5.1、Vite 7 核心架构与源码组织

Vite 7 延续 monorepo 结构(基于 pnpm workspace),核心代码集中于 packages/vite 目录,在保留“开发服务器+生产构建”双核心的基础上,新增 Rust 引擎适配层与环境抽象层,形成“四层架构”体系。

5.2. 核心模块概览

模块路径 核心职责 关键技术 / 工具
src/node/cli.ts 命令行入口,解析参数并分发命令 cac(命令行解析)
src/node/config/ 配置解析与合并,支持多环境配置隔离 环境抽象 API
src/node/server/ 开发服务器实现,集成 HMR 与中间件 connect、chokidar
src/node/build/ 生产构建核心,支持 Rolldown/Rollup 双引擎 Rolldown(Rust)、Rollup 3
src/node/plugin/ 插件系统,兼容 Rollup 插件并扩展新钩子 插件过滤 API(withFilter)

5.3. 核心数据结构升级

●  ModuleGraphsrc/node/server/moduleGraph.ts):新增 Rust 引擎依赖追踪能力,支持 Rolldown 模块解析结果与 JS 模块图的双向同步,解决双引擎依赖分析不一致问题。

●  BuilderContextsrc/node/build/builderContext.ts):统一构建上下文,封装引擎选择、产物优化等核心逻辑,屏蔽 Rolldown 与 Rollup 的调用差异。

●  Environmentsrc/node/env/index.ts):环境描述对象,定义目标运行时(浏览器/Node/边缘)、兼容性标准等属性,为多环境构建提供基础。

 

5.4、核心流程源码解析

Vite 7 的核心突破集中于 Rolldown 构建流程、多环境适配场景,以下结合源码片段深度拆解。

5.4.1Rolldown 预构建优化

Vite 7 支持通过 Rolldown 处理依赖预构建,替代部分 esbuild 功能,尤其在 CJS 转 ESM 场景下性能提升显著。预构建逻辑位于 src/node/optimizer/rolldownDepPlugin.ts 调用 Rust 接口处理依赖转换,产物存储于 node_modules/.vite/deps,并生成引擎兼容的缓存元数据。

 

启动流程可视化

image.png

5.4.2. 生产构建流程(vite build)—— Rolldown 核心适配

生产构建是 Vite 7 性能革新的核心场景,默认提供 Rolldown 引擎选项(通过 rolldown-vite 包集成),构建速度较 Rollup 提升 3-7 倍,内存占用减少 40% 以上。

 

关键步骤与源码

  1. rolldown打包逻辑 part3
   // src/node/build.ts 简化逻辑
   async function buildEnvironment(environment) {
    // 1. 创建一个新的ChunkMetadataMap实例,用于存储和管理构建过程中的chunk元数据
const chunkMetadataMap = new ChunkMetadataMap()
 
// 2. 解析Rolldown配置选项,将环境配置和chunk元数据映射传递给解析函数
// 注意变量名虽然是rollupOptions,但实际返回的是Rolldown的配置
const rollupOptions = resolveRolldownOptions(environment, chunkMetadataMap)
 
// 3. 检查是否处于监视模式(watch mode)
if (options.watch) {
  // 4. 在控制台输出信息,告知用户正在监视文件变化
  logger.info(colors.cyan(`\nwatching for file changes...`))
 
  // 5. 解析输出目录,获取构建产物将被写入的目录路径
  const resolvedOutDirs = getResolvedOutDirs(
    root,
    options.outDir,
    options.rollupOptions.output,  // 使用rollupOptions.output作为输出配置
  )
  
  // 6. 解析是否需要清空输出目录的设置
  const emptyOutDir = resolveEmptyOutDir(
    options.emptyOutDir,  // 用户配置的emptyOutDir选项
    root,
    resolvedOutDirs,      // 解析后的输出目录
    logger,
  )
  
  // 7. 解析文件监视选项(Chokidar配置)
  const resolvedChokidarOptions = resolveChokidarOptions(
    {
      // 8. 合并配置中的chokidar选项(虽然Rolldown没有这个选项,但为了向后兼容保留)
      // @ts-expect-error 标记:chokidar选项在rolldown中不存在,但为了向后兼容而使用
      ...(rollupOptions.watch || {}).chokidar,
      // @ts-expect-error 同样标记:用户配置的watch.chokidar选项
      ...options.watch.chokidar,
    },
    resolvedOutDirs,      // 输出目录
    emptyOutDir,          // 是否清空输出目录
    environment.config.cacheDir,  // 缓存目录
  )
 
  // 9. 动态导入rolldown的watch函数
  const { watch } = await import('rolldown')
  
  // 10. 创建Rolldown的watcher实例,传入配置选项
  const watcher = watch({
    ...rollupOptions,  // 基础构建配置
    watch: {           // 监视模式特定配置
      ...rollupOptions.watch,  // 合并原有watch配置
      ...options.watch,        // 合并用户提供的watch配置
      // 11. 将Chokidar配置转换为Rolldown的notify选项
      notify: convertToNotifyOptions(resolvedChokidarOptions),
    },
  })
 
  // 12. 监听watcher事件
  watcher.on('event', (event) => {
    // 13. 当构建开始时的处理
    if (event.code === 'BUNDLE_START') {
      logger.info(colors.cyan(`\nbuild started...`))  // 输出构建开始信息
      chunkMetadataMap.clearResetChunks()  // 清除并重置chunk元数据
    } 
    // 14. 当构建结束时的处理
    else if (event.code === 'BUNDLE_END') {
      event.result.close()  // 关闭构建结果,释放资源
      logger.info(colors.cyan(`built in ${event.duration}ms.`))  // 输出构建耗时
    } 
    // 15. 当发生错误时的处理
    else if (event.code === 'ERROR') {
      const e = event.error
      enhanceRollupError(e)  // 增强错误信息,使其更有用
      clearLine()  // 清除控制台当前行
      logger.error(e.message, { error: e })  // 输出错误信息
    }
  })
 
  // 16. 返回watcher实例,允许外部控制监视过程
  return watcher
}
 
// 17. 非监视模式:使用rolldown进行单次构建
// write or generate files with rolldown
const { rolldown } = await import('rolldown')  // 动态导入rolldown函数
startTime = Date.now()  // 记录构建开始时间
 
// 18. 使用配置创建rolldown实例
bundle = await rolldown(rollupOptions)
 
// 19. 创建一个数组来存储构建输出结果
const res: RolldownOutput[] = []
 
// 20. 遍历所有输出配置(可能有多个输出配置)
for (const output of arraify(rollupOptions.output!)) {
  // 21. 根据options.write决定是写入文件还是仅生成输出内容
  // 如果options.write为true则调用bundle.write(),否则调用bundle.generate()
  res.push(await bundle[options.write ? 'write' : 'generate'](output))
}
   }

3. 性能优化关键点

●  单一引擎架构:开发与生产环境统一使用 Rolldown 处理依赖解析与模块转换,避免双工具链的数据重复序列化/反序列化开销。

●  Oxc 工具集集成:替代 esbuild 处理代码解析与转译,内存使用效率提升显著,大型项目构建内存占用可减少 100 倍。

 

5.4.3. 多环境适配流程(核心功能升级)

Vite 7 稳定了多环境 API,支持浏览器、Node、边缘服务器等多运行时目标

 

关键逻辑与源码通过代理将rollup转发到rolldown处理

export function setupRollupOptionCompat<
  T extends Pick<BuildEnvironmentOptions, 'rollupOptions' | 'rolldownOptions'>,
>(
  buildConfig: T,
): asserts buildConfig is T & {
  rolldownOptions: Exclude<T['rolldownOptions'], undefined>
} {
  // if both rollupOptions and rolldownOptions are present,
  // ignore rollupOptions and use rolldownOptions
   // 如果同时存在rollupOptions和rolldownOptions,忽略rollupOptions
  buildConfig.rolldownOptions ??= buildConfig.rollupOptions
 
  // proxy rolldownOptions to rollupOptions
  // 通过代理将rollupOptions的访问转发到rolldownOptions
  Object.defineProperty(buildConfig, 'rollupOptions', {
    get() {
      return buildConfig.rolldownOptions
    },
    set(newValue) {
      buildConfig.rolldownOptions = newValue
    },
    configurable: true,
    enumerable: true,
  })
}
 

 

5.4、核心流程总览流程图

image.png

5.5、源码学习建议

  1. 入门路径:从 vite dev 启动流程切入,先理解 createServer 函数的整体逻辑,再深入中间件与模块图实现。

  2. 核心突破点:重点分析 transformMiddleware 如何实现按需编译,以及 ModuleGraph 如何维护依赖关系。

  3. 调试技巧:通过 DEBUG=vite:* vite dev 打印调试日志,追踪请求处理与 HMR 触发流程。

4 . 拥抱ai 配合aiide做源码解析梳理核心流程细节

5 . 先脉络后细节

前端实现 Server-Sent Events 全解析:从代码到调试的实战指南

什么是 Server-Sent Events

当你在使用 ChatGPT 等 AI 对话产品时,是否注意到回答内容会逐字出现在屏幕上?这种"打字机"效果背后,很可能就是 Server-Sent Events(SSE)技术在发挥作用。与传统的 AJAX 请求不同,SSE 允许服务器在建立一次连接后持续向客户端推送数据,特别适合需要实时更新的场景。

SSE 本质上是一种基于 HTTP 的 server push 技术,它通过特殊的 text/event-stream 响应类型,让服务器能够随时向客户端发送数据。与 WebSocket 相比,SSE 具有实现简单轻量级自动重连等优势,非常适合单向的实时数据推送场景。

image.png

SSE 前端实现核心代码解析

API 封装层设计

我们先来看最上层的 API 封装。下面这段代码定义了一个 questionAPI 函数,它是与 SSE 服务端交互的入口:

// 智能问答 API 封装
export const questionAPI = (data, { onMessage, onComplete }) => {
  return request({
    method: "post",
    url: "/chats",
    data: data,
    isSSE: true,
    onMessage,  // 传递消息回调
    onComplete  // 传递完成回调
  });
};

这个封装有几个关键点:

  • 通过 isSSE: true 标记这是一个 SSE 请求
  • 接收 onMessage 和 onComplete 两个回调函数
  • 与普通 API 调用方式保持一致,降低使用门槛

请求层实现

接下来是底层的 request 函数实现,这是处理 SSE 的核心部分:

该部分封装用的是uni.request,这部分根据你的实际开发环境来替换。逻辑是不变得

return new Promise((resolve, reject) => {
  if (options.isSSE) {
    let buffer = "";  // 缓存未完整的数据块
    const requestTask = uni.request({
        url: "xxx" + options.url,
      method: options.method,
      data: options.data || options.params,
      header: {
        "Content-Type": "application/json",
        "Accept": "text/event-stream",  // SSE 必需请求头
        ...(token && { token }),
      },
      enableChunked: true,  // 开启分块传输
      responseType: "stream",  // 流类型响应
      success: (res) => {
        console.log("SSE 请求完成", res);
      },
      fail: (err) => {
        console.error("SSE 请求失败", err);
        uni.hideLoading();
        reject(err);
      },
      complete: () => {
        console.log("SSE 请求结束");
        uni.hideLoading();
        // 处理缓冲区剩余数据
        if (buffer.trim()) {
          if (options.onMessage) {
            options.onMessage(buffer);
          }
        }
        if (options.onComplete) {
          options.onComplete();
        }
        resolve();
      },
    });

    // 监听分块数据接收
    requestTask.onChunkReceived((res) => {
      uni.hideLoading();
      try {
        // 将二进制数据解码为文本
        const chunk = new TextDecoder().decode(new Uint8Array(res.data));
        buffer += chunk;  // 追加到缓冲区

        // 按 SSE 格式分割数据(双换行符分隔)
        const messages = buffer.split("\n\n");
        // 保留最后一个可能不完整的消息
        buffer = messages.pop() || "";

        // 处理完整的消息
        messages.forEach((message) => {
          if (message.trim()) {
            // 触发回调函数
            if (options.onMessage) {
              options.onMessage(message);
            }
          }
        });
      } catch (error) {
        console.error("解析 SSE 数据失败:", error);
      }
    });
  } else {
    // 普通请求处理逻辑...
  }
});

这段代码实现了 SSE 的核心功能,主要包括:

  1. 设置正确的请求头:Accept: "text/event-stream" 是 SSE 的标志性请求头
  2. 开启分块传输:enableChunked: true 确保能够接收流式数据
  3. 分块数据处理:通过 onChunkReceived 事件监听数据块到达
  4. 数据缓冲区:使用 buffer 变量处理可能被分割的不完整数据块
  5. SSE 格式解析:按双换行符 \n\n 分割完整的 SSE 消息

业务逻辑层调用

最后是在 Vue 组件中如何使用这个 API:

questionAPI(
  {
    user: user,
    session_id: questionContent.value.session_id,
    messages: [
      {
        role: "user",
        content: type === "otherQuestions" ? item : sendMessage.value,
      },
    ]
  },
  {
    onMessage: async (message) => {
      console.log("SSE 流式数据:", message);
      await handleSSEMessage(message, fullContentRef, messageIndex);
    },
    onComplete: async () => {
      console.log("SSE 流式传输完成");
      // 处理最终内容渲染
      await immediateRender(async () => {
        if (questionContent.value.messages[messageIndex]) {
          questionContent.value.messages[messageIndex].content =
            await renderMarkdown(fullContentRef.value);
          scrollToBottom(questionContent.value.messages);
        }
      });
      sendMessage.value = "";
      anserLoading.value = false;
    },
  }
).catch((e) => {
  console.log("AI报错", e);
  messageDialog.value?.showMessage(
    e.message || "AI 请求失败,请重试",
    "error"
  );
  anserLoading.value = false;
});

在业务层,我们主要关注:

  • 传递请求参数(用户信息、问题内容等)
  • 实现 onMessage 回调处理流式数据
  • 实现 onComplete 回调处理流结束逻辑
  • 错误处理和加载状态管理

Markdown渲染集成

renderMarkdown函数实现

import marked from "marked";

const renderMarkdown = async (item) => {
  try {
    const html = await marked.parse(item, {
      gfm: true,
      breaks: false,
      pedantic: false,
    });
    return html;
  } catch (err) {
    console.error("Markdown 渲染失败:", err);
    return `渲染错误: ${err.message}`;
  }
};
  

为什么 onMessage 回调不执行

许多开发者在实现 SSE 时都会遇到 onMessage 回调不执行的问题。结合上述代码,我们来分析可能的原因和解决方案。

1. 请求头设置不正确

问题:缺少 Accept: "text/event-stream" 请求头,或设置了错误的 Content-Type。

解决:确保请求头包含:

header: {
  "Content-Type": "application/json",
  "Accept": "text/event-stream", // 这个请求头至关重要
}

2. 分块传输未启用

问题:未设置 enableChunked: true,导致无法接收流式数据。

解决:在 uni.request 中显式开启分块传输:

enableChunked: true,  // 必须开启分块传输
responseType: "stream",  // 流类型响应

3. 数据解析逻辑错误

问题:缓冲区处理不当,导致消息无法正确分割。

解决:检查缓冲区处理逻辑:

// 正确的消息分割逻辑
const messages = buffer.split("\n\n");
buffer = messages.pop() || "";
messages.forEach((message) => {
  if (message.trim()) {
    options.onMessage(message);
  }
});

4. 服务端数据格式错误

问题:服务端返回的不是标准的 SSE 格式数据。

解决:使用浏览器开发者工具的 Network 面板检查响应:

  • 确认响应头 Content-Type 为 text/event-stream
  • 确认响应体格式符合 SSE 规范
  • 检查是否有跨域等网络问题

5. 回调函数传递错误

问题:API 封装或调用时,回调函数传递路径不正确。

解决:跟踪回调函数的传递路径,确保:

// API封装时正确接收回调
export const questionAPI = (data, { onMessage, onComplete }) => {
  return request({
    // ...其他参数
    onMessage,  // 正确传递回调
    onComplete
  });
};

SSE 调试技巧与工具

浏览器开发者工具

现代浏览器的开发者工具提供了对 SSE 的良好支持:

  1. Network 面板

    • 找到类型为 event-stream 的请求
    • 查看 "Response" 标签可实时看到 SSE 数据流
    • "Headers" 标签可检查请求头和响应头是否正确
  2. Console 面板

    • 使用 console.log 打印原始数据块
    • 记录缓冲区状态变化

实用调试代码片段

在 onChunkReceived 回调中添加详细日志:

// 调试用:打印接收到的原始数据
console.log("原始数据块:", chunk);
// 调试用:打印缓冲区状态
console.log("缓冲区状态:", buffer);
// 调试用:打印分割后的消息数量
console.log("分割出的消息数:", messages.length);

常见问题排查清单

  1. 网络层面

    • 确认服务端是否支持 CORS
    • 检查请求是否成功建立连接
    • 查看响应状态码是否为 200
  2. 数据层面

    • 确认服务端是否持续发送数据
    • 检查数据格式是否符合 SSE 规范
    • 验证消息分隔符是否正确
  3. 代码层面

    • 检查回调函数是否正确传递
    • 确认分块处理逻辑是否正确
    • 验证错误处理是否覆盖所有情况

SSE vs WebSocket:如何选择

SSE 和 WebSocket 都可以实现实时通信,但它们各有适用场景:

表格

复制

特性 Server-Sent Events WebSocket
协议 HTTP 独立的 WebSocket 协议
连接 单向(服务器到客户端) 双向
开销
实现复杂度 简单 复杂
自动重连 内置支持 需要手动实现
数据格式 文本(UTF-8) 二进制/文本

选择建议

  • 如果你需要单向实时更新(如股票行情、新闻推送、AI 对话),选择 SSE
  • 如果你需要双向实时通信(如在线游戏、即时通讯),选择 WebSocket
  • 如果你希望快速开发兼容性好,选择 SSE
  • 如果你需要全双工通信高性能,选择 WebSocket

总结与展望

Server-Sent Events 是一种简单而强大的实时通信技术,特别适合需要服务器向客户端单向推送数据的场景。通过本文的代码解析,我们了解了如何在 Vue 项目中实现 SSE,包括 API 封装、分块处理和回调机制等核心要点。

随着 AI 应用的普及,SSE 技术将在更多场景中发挥重要作用。掌握 SSE 的前端实现,不仅能帮助我们构建更好的用户体验,也能为理解更复杂的实时通信技术打下基础。

希望本文能帮助你解决 SSE 实现中的困惑,如果你有其他问题或更好的实践经验,欢迎在评论区分享!

❌