阅读视图

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

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

项目概述

味寻纪-元服务是一款基于鸿蒙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

每日一题-使所有元素都可以被 3 整除的最少操作数🟢

给你一个整数数组 nums 。一次操作中,你可以将 nums 中的 任意 一个元素增加或者减少 1 。

请你返回将 nums 中所有元素都可以被 3 整除的 最少 操作次数。

 

示例 1:

输入:nums = [1,2,3,4]

输出:3

解释:

通过以下 3 个操作,数组中的所有元素都可以被 3 整除:

  • 将 1 减少 1 。
  • 将 2 增加 1 。
  • 将 4 减少 1 。

示例 2:

输入:nums = [3,6,9]

输出:0

 

提示:

  • 1 <= nums.length <= 50
  • 1 <= nums[i] <= 50

多语言的爱意告白

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

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安装体验,让适配工作少走弯路~

3190. 使所有元素都可以被 3 整除的最少操作数

解法一

思路和算法

对于数组 $\textit{nums}$ 中的每个元素,可以根据元素除以 $3$ 的余数计算使元素可以被 $3$ 整除的最少操作次数。

  • 如果一个元素除以 $3$ 的余数是 $0$,则不需要执行操作。

  • 如果一个元素除以 $3$ 的余数是 $1$,则需要将元素减少 $1$ 才能使元素可以被 $3$ 整除,最少操作次数是 $1$。

  • 如果一个元素除以 $3$ 的余数是 $2$,则需要将元素增加 $1$ 才能使元素可以被 $3$ 整除,最少操作次数是 $1$。

上述情况可以概括成两种情况。

  • 能被 $3$ 整除的元素的最少操作次数是 $0$。

  • 不能被 $3$ 整除的元素的最少操作次数是 $1$。

因此,使数组 $\textit{nums}$ 中所有元素都可以被 $3$ 整除的最少操作次数等于数组 $\textit{nums}$ 中的不能被 $3$ 整除的元素个数。

代码

###Java

class Solution {
    public int minimumOperations(int[] nums) {
        int operations = 0;
        for (int num : nums) {
            if (num % 3 != 0) {
                operations++;
            }
        }
        return operations;
    }
}

###C#

public class Solution {
    public int MinimumOperations(int[] nums) {
        int operations = 0;
        foreach (int num in nums) {
            if (num % 3 != 0) {
                operations++;
            }
        }
        return operations;
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。需要遍历数组一次。

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

解法二

思路和算法

考虑更一般的情形,对于正整数 $d$,计算使数组 $\textit{nums}$ 中所有元素都可以被 $d$ 整除的最少操作次数。

对于正整数元素 $\textit{num}$,当 $\textit{num} \bmod d = 0$ 时最少操作次数是 $0$,当 $\textit{num} \bmod d \ne 0$ 时可以将 $\textit{num}$ 减少或增加使更新后的 $\textit{num}$ 可以被 $d$ 整除。

  • 将 $\textit{num}$ 减少到可以被 $d$ 整除,最少操作次数是 $\textit{num} \bmod d$。

  • 将 $\textit{num}$ 增加到可以被 $d$ 整除,最少操作次数是 $d - \textit{num} \bmod d$。

为了使操作次数最少,应取两种情况中的最小值,因此使 $\textit{num}$ 可以被 $d$ 整除的最少操作次数是 $\min(\textit{num} \bmod d, d - \textit{num} \bmod d)$。

当 $\textit{num} \bmod d = 0$ 时,$\min(\textit{num} \bmod d, d - \textit{num} \bmod d) = 0$,最少操作次数也是 $\min(\textit{num} \bmod d, d - \textit{num} \bmod d)$。因此对于任意元素 $\textit{num}$,使 $\textit{num}$ 可以被 $d$ 整除的最少操作次数是 $\min(\textit{num} \bmod d, d - \textit{num} \bmod d)$。

遍历数组 $\textit{nums}$ 分别计算使每个元素可以被 $d$ 整除的最少操作次数之后,即可得到使数组 $\textit{nums}$ 中所有元素都可以被 $d$ 整除的最少操作次数。

代码

###Java

class Solution {
    static final int DIVISOR = 3;

    public int minimumOperations(int[] nums) {
        int operations = 0;
        for (int num : nums) {
            operations += Math.min(num % DIVISOR, DIVISOR - num % DIVISOR);
        }
        return operations;
    }
}

###C#

public class Solution {
    const int DIVISOR = 3;

    public int MinimumOperations(int[] nums) {
        int operations = 0;
        foreach (int num in nums) {
            operations += Math.Min(num % DIVISOR, DIVISOR - num % DIVISOR);
        }
        return operations;
    }
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是数组 $\textit{nums}$ 的长度。需要遍历数组一次。

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

不是 3 的倍数的元素个数(Python/Java/C++/C/Go/JS/Rust)

遍历 $\textit{nums}$,按照元素模 $3$ 的余数分类:

  • 如果 $\textit{nums}[i] = 3k$,无需操作。
  • 如果 $\textit{nums}[i] = 3k+1$,减一得到 $3$ 的倍数。
  • 如果 $\textit{nums}[i] = 3k+2$,加一得到 $3$ 的倍数。

由此可见,对于不是 $3$ 的倍数的元素,只需操作一次就可以变成 $3$ 的倍数。

所以答案为不是 $3$ 的倍数的元素个数。

###py

class Solution:
    def minimumOperations(self, nums: List[int]) -> int:
        return sum(x % 3 != 0 for x in nums)

###java

class Solution {
    public int minimumOperations(int[] nums) {
        int ans = 0;
        for (int x : nums) {
            ans += x % 3 != 0 ? 1 : 0;
        }
        return ans;
    }
}

###cpp

class Solution {
public:
    int minimumOperations(vector<int>& nums) {
        int ans = 0;
        for (int x : nums) {
            ans += x % 3 != 0;
        }
        return ans;
    }
};

###c

int minimumOperations(int* nums, int numsSize) {
    int ans = 0;
    for (int i = 0; i < numsSize; i++) {
        ans += nums[i] % 3 != 0;
    }
    return ans;
}

###go

func minimumOperations(nums []int) (ans int) {
for _, x := range nums {
if x%3 != 0 {
ans++
}
}
return
}

###js

var minimumOperations = function(nums) {
    return _.sumBy(nums, x => (x % 3 !== 0 ? 1 : 0));
};

###rust

impl Solution {
    pub fn minimum_operations(nums: Vec<i32>) -> i32 {
        nums.into_iter().filter(|&x| x % 3 != 0).count() as _
    }
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $\textit{nums}$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

思考题

把题目中的 $3$ 改成 $4$ 呢?改成 $m$ 呢?

请看 视频讲解,欢迎点赞关注!

分类题单

如何科学刷题?

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

我的题解精选(已分类)

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

光图片就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

百度大数据成本治理实践

导读

本文概述了在业务高速发展和降本增效的背景下百度MEG(移动生态事业群组)大数据成本治理实践方案,主要包含当前业务面临的主要问题、计算数据成本治理优化方案、存储数据成本治理优化方案、数据成本治理成果以及未来治理方向的一个思路探讨,为业界提供可参考的治理经验。

01 背景

随着百度各业务及产品的快速发展,海量的离线数据成本在持续地增长。在此背景下,通过大数据治理技术来帮助业务降本增效,实现业务的可持续增长变得至关重要。我们通过对当前资源现状、管理现状以及成本现状三个角度进行分析:

  • 资源现状:各个产品线下业务类型繁多,涉及的离线AFS(百度公司分布式文件存储:Appendonly File Storage)存储账号和EMR(百度公司全托管计算集群:E-MapReduce EMR+)队列数量非常多,成百上千,什么时候启动治理,采用什么手段治理,并没有明确规划,且各业务间缺少统一的治理标准。

  • 管理现状:针对离线资源的使用参差不齐,存储账号和计算队列资源的管理和使用较为混乱,有的使用率高,有的使用率低;此外,业务间的离线作业管理并不统一且不完全规范,没有完善的流程机制以及规范来对离线资源以及作业进行管理管控,并且计算任务的执行效率较低,整体运维难度较大。

  • 成本现状:MEG各个产品线离线计算资源达数千万核,存储资源达数千PB,各产品线的离线计算和存储资源成本每年可达数亿元,随着业务的增长,如果不进行成本治理和优化,离线资源成本还会持续增加。

整体来说,目前主要面临数据散乱、资源浪费、成本增加等问题。基于以上存在的问题,我们通过构建统一的治理标准,并利用大数据资源管理平台搭建各产品线下的的离线存储资源视图、计算资源视图、任务视图以及成本视图,基于引擎能力对存储和计算进一步优化,帮助MEG下各产品线下的业务持续进行数据成本治理,接下来将具体阐述我们在大数据成本治理过程的实践方案。

图片

△ 数据成本治理现状

02 数据成本治理实践方案

2.1 数据成本治理总体框架


针对目前存在的问题,我们主要围绕数据资产度量、平台化能力以及引擎赋能三个方面构建数据成本治理总体框架,实现对计算和存储两大方向的治理,来达到降本增效的目的,具体如下图所示,接下来将进行具体地介绍。

图片

△ 数据成本治理总体框架

2.1.1 数据资产健康度量

为了对当前各个业务的计算和存储资源进行合理的评估和治理,我们采用统一的标准:健康分来进行衡量,而健康分计算的数据指标来源依赖于离线数据采集服务,该服务通过对当前计算队列,计算任务,存储账号,数据表等元数据信息进行例行采集,然后再进一步对于采集的数据进行分析和挖掘,形成一个个计算治理项和存储治理项,比如计算治理项可包含:使用率不均衡的计算队列个数、长耗时高资源消耗的计算任务、数据倾斜的任务以及无效任务等;存储治理项可包含1年/2年/N年未访问的冷数据目录、存储目录生命周期异常、inode占比过低以及未认领目录等。通过产生的数据治理项信息汇聚形成计算健康分和存储健康分两大类,如下:

  • 计算健康分:基于队列使用平均水位+队列使用均衡程度+计算治理项进行加权计算获取。

  • 存储健康分:基于存储账号使用平均水位+存储账号使用峰值+冷数据占比+治理项进行加权计算获取。

最终,通过统一规范的健康分来对当前各产品线下业务所属的数据资产进行度量,指导业务进行规范化治理。

2.1.2 平台化能力

此外,为了完成对当前产品线下离线计算和存储资源的全生命周期管理,我们通过搭建大数据资源管理平台,完成对各个产品线的离线计算资源和存储资源的接入,并基于平台能力为业务构建统一的计算视图、存储视图以及离线成本视图,整合离线计算任务需要的存储和计算资源,并将各类工具平台化,帮助业务快速发现和解决各类数据成本治理问题,具体如下:

  • 计算视图:包含各个计算队列资源使用概览和计算治理项详情信息,并提供计算任务注册、管控、调度执行以及算力优化全生命周期管理的能力。

  • 存储视图:汇聚了当前所有存储账号资源使用详情以及各类存储治理项信息,并提供给用户关于存储目录清理、迁移以及冷数据挖掘相关的存储管理以及治理能力。

  • 成本视图:构建各个产品线下关于离线存储和计算资源总成本使用视图,通过总成本使用情况,更直观地展示治理成果。

2.1.3 引擎赋能

在实际离线大数据业务场景中,很多业务接口人对于大数据计算或者存储引擎的原理和特性不是非常熟悉,缺乏或者没有调优意识,通常在任务提交时没有根据任务的实际数据规模、计算复杂度以及集群资源状况进行针对性的参数调整,这种情况就会使得任务执行效率无法达到最优,且计算和存储资源不能得到充分的应用,进而影响业务迭代效率。针对上述计算和存储资源浪费的问题,我们结合大数据引擎能力,来实现对于计算和存储进一步地优化,助力业务提效,为业务的持续发展提供有力的支持。主要包含以下两个场景:

  • 计算场景:结合任务运行历史信息以及机器学习算法模型能力,建立一套完善的智能调参机制,对于提交的任务参数进行动态调整,最大程度保障任务在较优的参数下执行,进一步提升任务执行效率,并高效利用当前计算资源。

  • 存储场景:针对海量的存储数据,我们通过不同类型数据进行深入的分析和特征挖掘,实现了对存储数据智能压缩的能力,从而在不影响业务数据写入和查询的前提下,完成对现有数据存储文件的压缩,帮助业务节约存储资源和成本。

03 计算&存储数据成本治理优化

3.1 计算成本治理

在计算成本治理方向,我们主要基于平台和引擎能力,通过管理管控,混合调度以及智能调参三大方面对现有的计算资源和计算任务进行治理和优化。

3.1.1 管理管控

在MEG离线大数据场景下,主要涉及对上千EMR计算队列、以及上万Hadoop和Spark两大类型的计算任务管理。

  • 一方面,我们针对各个业务的计算队列和计算任务的管理,通过平台能力实现了从计算资源的注册接入,到计算队列和任务数据的采集,再到离线数据分析和挖掘,最终形成如使用率不均衡的计算队列、长耗时高资源消耗的计算任务、数据倾斜的任务、无效任务以及相似任务等多个计算治理项,并基于统一规范的健康分机制来对业务计算资产进行度量,指导业务对计算进行治理。

  • 另一方面,在离线混部场景,可能会存在部分用户对于任务使用不规范,影响离线例行任务或者造成资源浪费,我们针对Hadoop和Spark不同任务类型,分别建立了任务提交时和运行时管控机制,并结合业务实际场景,实现了并发限制、基本参数调优,队列资源限制以及僵尸任务等30+管控策略,对于天级上万的任务进行合理的管理管控,并及时挖掘和治理相关异常任务。目前运行的管控策略已经覆盖多个产品线下离线EMR计算队列上千万核,每天任务触发各种管控次数20万+。

通过对计算资源全生命周期的管理和管控,我们可以及时有效地发现可治理的队列或者任务,并推进业务进行治理。

图片

△ 任务管理管控流程

3.1.2 混合调度

通过对于平台接入的队列资源使用情况以及任务执行情况的深度分析,我们发现当前各个业务使用的计算资源存在以下几个问题:

1. 不同产品线业务特点不同,存在Hadoop和Spark两种类型计算任务,并且Hadoop任务CPU使用较多、内存使用较低,而Spark任务CPU使用较低,内存使用较高。

2. 有些队列整体资源使用率不高,但是存在部分时间段资源使用很满,不同队列资源使用波峰不完全一致,有的高峰在夜间,有的高峰在白天。

3. 存在队列碎片化问题,一些小队列不适合提交大作业且部分使用率不高。

为解决上述问题,我们建设Hadoop和Spark混合调度机制。针对公司不同业务来源的任务,基于Hadoop调度引擎以及Spark调度引擎完成各自任务的智能化调度,并通过调度策略链在多个候选队列中选择最优队列,最终实现任务提交到EMR计算集群上进行执行。具体流程如下:

  • 任务提交:针对不同产品线下的业务提交的Hadoop或者Spark任务,服务端会通过不同任务类型基于优先级、提交时间以及轮数进行全局加权排序,排序后的每个任务会分发到各自的任务调度池中,等待任务调度引擎拉取提交。

  • 任务调度:该阶段,调度引擎中不同任务类型的消费线程,会定时拉取对应任务调度池中的任务,按照FIFO的策略,多线程进行消费调度。在调度过程中,每个任务会依次通过通用调度策略链和专有调度策略链来获取该任务最优提交队列,其中,通用和专有调度策略主要是计算队列资源获取、候选队列过滤、队列排序(数据输入输出地域,计算地域)、队列资源空闲程度以及高优任务保障等20+策略。比如某任务调度过程中,请求提交的队列是A,调度过程中存在三个候选队列A、B、C,其中候选队列A使用率很高,B使用率中等且存储和计算地域相差较远,C使用率低且距离近,最终通过智能调度可分配最优队列C进行提交。

  • 任务执行:通过调度引擎获取到最优队列的任务,最终会提交到对应的EMR计算集群队列上进行执行,进而实现各个队列的使用率更加均衡,并提高低频使用队列的资源使用率。

图片

△ 任务混合调度流程

3.1.3 智能调参

在数据中心业务场景,多以Spark任务为主,天级提交的Spark任务5万+,但这些任务执行过程中,会存在计算资源浪费的情况,具有一定的优化空间,我们通过前期数据分析,发现主要存在以下两类问题:

1. 用户没有调优意识,或者是缺乏调优经验,会造成大量任务资源配置不合理,资源浪费严重,比如并发和内存资源配置偏大,但实际可以继续调低,如case示例1所示;

2. 在Spark计算引擎优化器中, 只有RBO(Rule-Based Optimizer)和CBO(Cost-Based Optimizer)优化器, 前者基于硬规则,后者基于执行成本来优化查询计划,但对于例行任务, 只有RBO和CBO会忽略一些能优化的输入信息,任务性能存在一定的瓶颈,如case示例2所示。

图片

△ 任务参数配置case示例

针对第一类问题,我们实现了对Spark任务基本参数智能调优的能力,在保证任务SLA的情况下,结合模型训练的方式,来支持对例行任务长期调优并降低任务资源消耗。每轮任务例行会推荐一组参数并获取其对应性能,通过推荐参数、运行并获取性能、推荐参数的周期性迭代,在多轮训练迭代后,提供一组满足任务调优目标并且核时最少的近似最优参数,其中涉及的参数主要有spark.executor.instances, spark.executor.cores, spark.executor.memory这三类基本参数。具体实现流程如下:

1. 任务提交流:任务提交过程中,会从调优服务的Web Controller模块获取当前生成的调优参数并进行下发;

2. 结果上报流:通过任务状态监控,在任务执行完成后,调优服务的Backend模块会定时同步更新任务实际运行配置和执行耗时等执行历史数据信息到数据库中;

3. 模型训练流:调优服务的Backend模块定时拉取待训练任务进行数据训练,通过与模型交互,加载历史调优模型checkpoint,基于最新样本数据进行迭代训练,生成新的训练模型checkpoint以及下一轮调优参数,并保存到数据库中。

4. 任务SLA保障:通过设置运行时间上限、超时兜底、限制模型调优范围,以及任务失败兜底等策略来保障任务运行时间以及任务执行的稳定性。

最终,通过任务提交流程、结果上报流程、训练流程实现任务运行时需要的并发和内存基本参数的自动化调优,并基于运行时间保障和任务稳定性保障策略,确保任务的稳定性,整体流程框架如下图。

图片

△ 基本参数智能调优流程

针对第二类问题,我们构建HBO(History Based Optimization)智能调优模块实现对复杂参数场景的任务自动调优能力。首先,通过性能数据收集器完成对运行完成的Spark任务History的详情数据采集和AMP任务画像,然后在任务执行计划阶段和提交阶段,基于任务历史执行的真实运行统计数据来优化未来即将执行的任务性能,从而弥补执行之前预估不精确的问题,具体如下:

  • 执行计划调优阶段:主要进行Join算法动态调整、Join数据倾斜调整、聚合算法动态调整以及Join顺序重排等调优;

  • 任务提交阶段:基于任务运行特点智能添加或者改写当前提交的Spark任务运行参数,比如Input输入、合并小文件读、Output输出、拆分大文件写、Shuffle分区数动态调整以及大shuffle开启Kryo Serialization等参数,从而实现对运行参数的调优;

通过数据采集反馈和动态调参,不断循环,进而完成对于复杂参数场景的智能调优能力,让任务在执行资源有限的条件下,跑得更稳健,更快。整体实现流程图如下:

图片

△ HBO智能调优流程

3.2 存储成本治理


在百度MEG大数据离线场景下,底层存储主要是使用AFS,通过梳理我们发现目前针对离线使用的各个存储账号,缺乏统一管理和规范,主要存在以下几个核心问题:

  • AFS存储账号多且无归属:离散账号繁多,涉及目录数量多且大部分无Quota限制甚至找不到相关负责人,缺乏统一管理和规范;

  • AFS存储不断增加:不少业务对于数据存储缺少优化治理措施,且存在很多历史的无用数据,长期存放,导致数据只增不减;

  • 安全风险:各个账号使用过程中,数据随意读写甚至跨多个账户读写,安全无保障,并且缺少监控报警。

3.2.1 存储生命周期管理

针对上述问题,我们基于平台能力构建存储一站式治理能力,将存储资源的全生命周期管理分为五层:接入层、服务层、存储层、执行层以及用户层,通过建立存储资源使用规范,并基于采集的相关存储元数据,深度分析业务的离线AFS存储账号使用现状,将用户存储相关的问题充分挖掘和暴露出来,针对各种问题提供简单易用的通用化工具来帮助用户快速进行治理和解决,整体实现了各个集群存储账号的存储数据接入,采集,挖掘和分析,自动清理,监控预警全生命周期的管理。整体流程架构图如下:

图片

△ 存储生命周期管理流程

  • 接入层:通过建立规范的存储资源管理机制,比如存储的接入和申请规范、目录的创建规范、使用规范、利用率考核规范(Quota回收规范)以及冷数据的处理规范等通用化的规范,进而来完成用户从存储资源的接入-申请(扩容)、审核、交付、资源的例行审计的整体流程。

  • 服务层:基于离线服务完成对各集群存储目录&存储冷数据Quota采集,然后进一步对数据进行深入分析和挖掘,包含但不限于冷数据、异常目录使用、存储变化趋势以及成本数据等分析。

  • 存储层:建立账号、产品线、目录、任务、负责人以及账号的基本使用信息的元数据存储,通过Mysql进行存储,确保每个集群存储账号有对应归属;对于各个集群目录数据使用详情信息,选择Table(百度公司大规模分布式表格系统)进行存储。

  • 执行层:基于存储管理规范,对于各个集群存储账号进行每天例行的存储自动清理,数据转储和压缩,并提供完善的存储使用监控报警机制。

  • 用户层:通过平台,为用户构建不同维度的AFS存储现状概览视图,以及整合现有数据,对于各个集群的存储账号或目录进行分析,提供优化建议、存储工具以及API接口,帮助业务快速进行存储相关治理和存储相关问题的解决。

3.2.2 存储基础治理

在AFS存储资源的生命周期管理过程中,我们主要基于服务层和执行层为用户提供一套基础的存储AFS账号数据基础治理能力。通过离线解析Quota数据和冷数据目录相关的基础数据,完成对其计算、分析、聚合等处理,实现存储趋势变化、成本计算、异常目录分析、冷数据分析、数据治理项和治理建议等多方面能力支持。之后,用户便可结合存储数据全景视图分析和相关建议,进行存储路径配置、转储集群目录配置、压缩目录配置以及监控账号配置等多维度配置。基于用户的配置,通过后台离线服务定时执行,完成对用户存储的数据清理、空间释放和监控预警,保障各个业务存储账号的合理使用以及治理优化。

图片

△ AFS存储数据基础治理流程

3.2.3 智能压缩

平台侧管理的MEG相关AFS存储数据上千PB,存在一部分数据,是没有进行相应的压缩或者压缩格式设置的并不是非常合理,我们通过结合业务实际使用情况,针对业务存储数据进行智能压缩,同时不影响数据读写效率,进一步优化降低业务存储成本,主要实现方案流程如下图。由于业务场景不同,我们采用不同的压缩方案。

  • 针对数仓表存储数据场景:首先是通过对采集的数仓表元数据信息进行数据画像,完成表字段存储占比和数据分布情况分析,之后基于自动存储优化器,实现对数仓表分区数据读取、压缩规则应用以及分区数据重写,最终完成对数仓表数据的自动压缩,在保证数仓表读写效率的前提下,进一步提升数据压缩效率,降低存储数据成本。其中压缩规则应用主要包含:可排序字段获取、重排序优化、ZSTD压缩格式、PageSize大小以及压缩Leve调整等规则。

  • 针对非数据仓表存储数据场景:在该场景下的存储数据,一般是通过任务直接写入AFS,写入方式各种各样,因此,需要直接对AFS存储数据进行分析和挖掘。我们首先对这部分数据进行冷热分层,将其分为冷数据、温数据以及热数据,并挖掘其中可进行压缩和压缩格式可进一步优化的数据,以及压缩配置可进一步调整的任务;之后,通过自动存储优化器,针对增量热数据,基于例行写入任务历史画像选择合适的压缩参数进行调优,并记录压缩效果;针对存量温冷数据,定期执行离线压缩任务进行自动压缩;最终我们对热数据进行压缩提醒,温冷数据进行自动压缩,从而实现该类型存储数据的压缩智能优化。

图片

△ 智能压缩流程

04 治理成果

通过数据成本治理,我们取得了一些不错的优化和实践效果,主要包含数据开发和成本优化方面以及治理资产两大方面:

1. 数据开发和成本优化

  • 数据开发提效:基于离线资源的全生命周期管理,计算和存储资源交付效率从月级或者周级缩短至天级,效率大幅提升,进而降低数据开发周期,此外基于混合调度和智能调参等能力,任务排队情况大幅降低,数据产出时效性平均提升至少一倍,大幅提高数据开发效率。

  • 计算成本优化:实现了MEG下上千个队列使用更加均衡,并完成了千万核EMR计算队列资源平均使用率提升30%+,增量供给日常业务需求数百万核资源的同时,优化退订数百万核计算资源,年化成本可降低数千万元。

  • 存储运维提效:通过利用存储数据基础治理等能力,完成了对上千个AFS存储账号管理、无用数据挖掘和清理,以及监控预警等,使得存储账号的运维更加可控,效率大幅提升。

  • 存储成本优化:实现了对MEG下上千PB存储资源整体使用率平均提升20%+,增量供给日常业务需求数百PB资源的同时,优化退订数百PB存储资源,年化成本同样降低数千万元。

2. 治理资产

  • 数据开发规范:逐步完善了资源交付规范、计算任务开发规范、存储和计算资源使用规范、以及数据质量和安全规范等多种规范流程。

  • 计算&存储资源成本:形成各个产品线下关于计算资源、存储资源以及成本使用详情的概览视图,对于资源使用和成本变化趋势清晰可见。

  • 数据任务资产:基于任务历史画像,构建任务从提交到运行再到完成的全生命周期的执行详情数据概览视图,帮助业务高效进行任务管理。

  • 数据治理项:通过数据挖掘和分析形成的计算任务,计算队列和存储账号相关的治理项详情数据看板,助力业务快速发现可治理的数据问题。

05 未来规划

目前,通过标准化、平台化以及引擎化的技术能力,进一步完成了对MEG下离线存储和计算资源管理和数据成本治理,并取得一定治理成果,但数据成本治理作为一个长期且持续的一项工作,我们将持续完善和挖掘数据成本治理技术方案,并结合治理过程中的经验、流程和标准,实现更规范、更智能化的治理能力。

❌