阅读视图

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

uni-app x 蒸汽模式 性能测试基准报告 Benchmark

背景

uni-app x 蒸汽模式,是DCloud于2026年推出的跨平台开发框架新版本。

该产品的特点是:比原生更快

本基准测试的目标,即为了真实呈现主要性能指标,并确保开发者可自行重现本基准测试,并得出相近结论。

先简要介绍 uni-app x 及 蒸汽模式

  • uni-app x 使用vue语法,并在蒸汽模式中去除了虚拟DOM
  • 蒸汽模式中,模板和样式编译为c或c++代码(在Android也会编译出部分kotlin代码),script仍然为uts语言
  • uni-app x 基于原生渲染管线,可融合原生组件生态,并占用更小的内存
  • 蒸汽模式提供了大量自研高性能组件,如view、text、image、list、rich-text、swiper、slider、picker等

测试指标

UI系统的核心性能指标是:渲染速度和帧率

追求渲染速度更快、掉帧更少。

人工体感可以录像,但测试指标必须可精准度量,需要准确的度量方案。

环境声明

目前 uni-app x 蒸汽模式,仅在鸿蒙平台公开发布,本测试报告仅包含鸿蒙设备的测试。

本Benchmark使用了2台鸿蒙系统在售的最低端机型 nove 12,具体信息如下:

  • 设备型号:nova 12 (不是pro) 运行内存8G
  • OS版本:6.0.0.130(截止测试时间的最新版OS,patch02)
  • 全部使用release方式运行
  • 电量90%左右,未开启节能模式。该设备仅支持普通模式和节能模式
  • 屏幕的刷新率设置为高,即120hz
  • 测试前所有设备重启,并静置2分钟。除关于本机的界面外,杀掉所有其他App的进程

view和text渲染速度测试

view和text是渲染引擎的核心基础,大量组件基于这2个基础组件构建。这2个基础组件的渲染速度是一套渲染引擎最核心的性能指标。

验证一个view和text创建速度是否足够快,可靠的方式是在同一个屏幕内创建大量view和text组件,计算耗时。

测试方法

点击按钮后,在屏幕上创建2000个view,每个view有一个背景色,每个view中再套入一个text组件。

2000个view需在同一屏幕区显示,view不设宽高,text字体较小。view们被分为50行,每行40个view,同时每行外层再套一个view。

即,一共4050的元素,其中2050个view和2000个text。

对比使用 uni-app x 蒸汽模式arkUI原生,进行创建速度的测试。

首先看录屏对比。 左边为arkUI原生,右边为uni-app x 蒸汽模式

IMG_3827.GIF

高清视频,可到 B 站观看:www.bilibili.com/video/BV1Rp…

界面中弹出的toast显示了耗时,单位为ms。arkUI为804ms、uni-app x蒸汽模式为267ms。计时说明:

  • 开始时间为按钮的click事件触发时间
  • 结束时间为主线程渲染指令已全部送达OS渲染进程时间。此时主线程已经完成本次渲染所需的工作,处于空闲状态。

该结束时间并非肉眼所见的屏幕显示时间,实际上渲染进程和GPU仍需一定时间工作才能让屏幕显示图像,但后续时间段无法通过编程打点计时。

经过录屏和计时的粗略对比,发现arkUIuni-app x 蒸汽模式在渲染进程和GPU的耗时接近,都在1帧左右,故在后续精准比较中忽略这段时间,保留目前的结束时间定义。

该实验重复5次。每次均杀掉应用进程重新进入,精准计算的耗时如下:

arkUI uni-app x蒸汽模式
791 243
805 244
811 244
771 243
810 242

平均值:

arkUI uni-app x蒸汽模式
797.6 243.2

以上数据单位均为ms。

测试结论

结论:在4050 view和text同屏渲染测试中,uni-app x 蒸汽模式的渲染速度是 arkUI 3.3倍

复现工程源码和体验方式

上述2个示例,源码如下:

arkUI版本,需要自行编译原始工程。

uni-app x 蒸汽模式,可以在HBuilderX 5.0以上版本编译运行(注意选用release方式运行,或者发行为正式包安装)。

hello uni-app x示例应用已经在鸿蒙应用商店上架,可以搜索“DCloud开发者中心系统”,或使用鸿蒙手机扫描下方二维码: 

图片

安装 hello uni-app x 后,点击右下角模板 -> 顶部有 view和text性能测试。

uni-app x 作为通用引擎,未对该示例做任何定制优化,没有诸如预加载、预测量等影响实验结果的行为。

长列表掉帧测试

list组件的地位,在渲染引擎中仅次于view和text。

现代渲染引擎,都采用复用技术实现长列表,确保持续滑动长列表后,内存没有持续增长。

使用复用技术的长列表进入速度都很快,因为只加载了一部分数据,但在滚动过程中持续加载数据并复用已存在视图时,如果列表复杂,会发生滚动掉帧。

测试方法

设计一个非常复杂的“死亡长列表”:

  • 加载4000行数据,7.4M的JSON
  • 每行超过40+元素,包括文字、图片、视频、自定义vue组件
  • 每行嵌套10+层
  • 渲染2万个元素,占据普通手机1333屏左右
  • 列表中还有大量的阴影、圆角、边框等复杂渲染样式

在人工体验中,用户可以体验加载速度、快速滑动时的流畅度,但在严谨的Benchmark中,需要精准的对比数据。

首先需要制作一个fps组件,监听系统的帧回调,在120Hz高刷屏上,每8.33ms会触发一次帧回调。如果2个帧回调的代码响应时长超过了8.33ms,就意味着掉帧。

该fps组件需要使用同样的逻辑分别实现arkUI版本和uni-app x版本。源码见后续 复现工程 章节。

同时死亡长列表的代码,也需要在arkUI和uni-app x中使用相同逻辑实现。

arkUI中使用lazy foreach,uni-app x中使用list-view。

使用arkUI版本和uni-app x版本分别进入长列表,滚动到底部,加载完4000行数据,然后点击鸿蒙手机的顶部状态栏,此时会滚动回到列表顶部。

两个版本回滚速度一样,均为1秒,在这个回滚到顶部的过程中,计算帧率,验证掉帧情况。同时从录像视觉上进行直观感受。

首先看录屏对比。 左边为arkUI原生,右边为uni-app x蒸汽模式

deadlist.GIF

高清视频,可移步B站观看,uni-app x vs arkUI 原生 长列表掉帧对比视频

视觉体验中可看出,arkUI的fps组件数字在1秒的动画期间更低,在回滚过程中很多视频呈现黑块。

该实验重复5次,每次均杀掉应用重新进入,重新滚动到顶部。

5次的测试数据如下:

  • arkUI:
    • 回滚过程中的平均FPS: 19.67, 最高FPS: 49, 最低FPS: 13
    • 回滚过程中的平均FPS: 24.14, 最高FPS: 61, 最低FPS: 15
    • 回滚过程中的平均FPS: 19.50, 最高FPS: 43, 最低FPS: 13
    • 回滚过程中的平均FPS: 20.67, 最高FPS: 53, 最低FPS: 13
    • 回滚过程中的平均FPS: 21.67, 最高FPS: 56, 最低FPS: 13
  • uni-app-x 蒸汽模式:
    • 回滚过程中的平均FPS: 78.29, 最高FPS: 100, 最低FPS: 52
    • 回滚过程中的平均FPS: 112.30, 最高FPS: 120, 最低FPS: 90
    • 回滚过程中的平均FPS: 106.33, 最高FPS: 120, 最低FPS: 45
    • 回滚过程中的平均FPS: 88.43, 最高FPS: 120, 最低FPS: 29
    • 回滚过程中的平均FPS: 104.50, 最高FPS: 120, 最低FPS: 55

arkUI的5次平均fps为 21.13,最高fps为61,最低fps为13。

uni-app x蒸汽模式的5次平均fps为 97.97,最高fps为120,最低为29。

测试结论

结论:在长列表帧率测试中,uni-app x蒸汽模式的平均帧率是 arkUI 4.6倍

实测发现arkUI版本的长列表中的video,无法记忆video的播放进度,即播放A视频到5s时,滚动到其他地方,然后再滚回来显示A视频,A视频会重头播放。

uni-app x的版本记忆了播放进度。除了功能的不同外,此差异也需要考虑到帧率对比中,记忆播放进度本身也耗费时间,也就是如果uni-app x取消记忆播放进度,帧率还能再提升。

复现工程源码和体验方式

上述2个示例,源码如下:

arkUI版本,需要自行编译原始工程。

uni-app x蒸汽模式,可以在HBuilderX 5.0以上版本编译运行(注意选用release方式运行,或者发行为正式包安装)。

hello uni-app x示例应用已经在鸿蒙应用商店上架,可以搜索“DCloud开发者中心系统”,或使用鸿蒙手机扫描下方二维码: 

图片

安装hello uni-app x后,点击右下角模板 -> 顶部有 死亡长列表。

uni-app x作为通用引擎,未对该示例做任何定制优化,没有诸如预加载、预测量等影响实验结果的行为。

此示例中7M多的4000行数据并非静态数据存在本地,而是由代码生成的数据,生成数据的代码是预执行的,在arkUI版和uni-app x版均如此。

其他组件

一套渲染引擎,除了view、text、list外,还需要更多高性能的组件。

uni-app x中对各种组件都做了极限性能测试,但受限于精力,未对arkUI组件全面做性能测试。

开发者可以在 hello uni-app x 中体验各种组件的性能测试,几乎每个组件的示例中,都单独提供了 组件性能测试。

  • rich-text组件:App平台的rich-text过去一直没有太好的解决方案。鸿蒙自身的richText组件也是基于webview渲染的,存在加载慢、内存占用高、快滑白屏等问题。uni-app x 蒸汽模式 提供更快的rich-text组件。以下测试,加载5万字长文、59张插图。可以看到无等待进入页面。上下快滑不掉帧、除了联网图片加载外滑不出白屏。高清演示视频,可移步 B 站观看:uni-app x rich-text 演示

     注:鸿蒙手机录屏时帧率只能为60Hz,实际使用时是完整的120Hz。下同。

  • swiper组件:加载100个item。无等待进入页面。在上述5万字长文中点击图片,可以看到swiper中无等待呈现59张图片,左右切换图片无延迟

  • picker组件:加载省市区4000条数据,无等待弹出组件。高清视频,可移步 B 站观看:uni-app x picker 组件演示

  • slide组件:拖动100个slider,丝滑流畅。高清演示视频,可移步 B 站观看:uni-app x slide组件演示

  • loading组件:屏幕上同时旋转100个loading不掉帧(录屏后从120掉帧到60)。高清视频,可移步 B 站观看:uni-app x loading组件演示

  • canvas组件:屏幕上同时移动数百个小球不掉帧。高清演示视频,可移步 B 站观看:uni-app x canvas组件演示

  • 众多表单组件均有100或200个创建速度测试监控。hello uni-app x 模板中还提供了日历、竖滑视频、侧滑删除长列表、ai chat的流式打字机等性能考验示例。高清演示视频,可移步 B 站观看:uni-app x 侧滑删除长列表演示uni-ai x 流式打字演示

在ai时代,很多App都需要内嵌一个开源的AI对话聊天库,能流式解析markdown,解析过程不掉帧。为此DCloud推出开源的uni-ai x,详见ext.dcloud.net.cn/plugin?id=2…

没有用户喜欢等待、没有用户喜欢卡顿掉帧。

从2007年iPhone发布后,手机用户每天都要为每次页面转场等待300ms。uni-app x 将支撑这个时间大幅缩短。hello uni-app x的蒸汽模式中已默认改为150ms,这150ms更多是留给网络。

如果开发者使用h3等新兴网络技术,优化好服务器速度,还可以把等待时间缩的更短。

FAQ

uni-app x 的App平台到底是自渲染还是原生渲染?

是原生渲染。准确的讲,是在原生渲染管线上自己做几乎所有组件。

如果使用xComponent自渲染,会因为2条渲染管线并存额外消耗硬件资源。

并且鸿蒙有很多原生组件,比如权限按钮、map地图以及三方生态中大量arkUI原生组件,自渲染方案在与原生生态融合时问题较多。两条渲染管线的滚动同步、资源消耗均导致这一路线不是最佳方案。

站在宏观视角,在原生渲染管线中优化,提供更快的核心组件,兼容所有原生组件,比自立一套组件生态对产业更有意义。

为什么都是原生渲染,uni-app x的蒸汽模式比原生渲染更快?

这里面涉及数千项工程优化,举例一些:

  1. Android的compose ui也是基于原生渲染管线的,但没有使用Android自带的view、textview,而是实现了自己的组件系统。

    这条路可行,只不过compose ui没有成为一个好标杆,它实际渲染速度比view体系更慢。(在上述4050示例对比中,有原生view和compose ui的测试例,详见

    uni-app x 蒸汽模式,也几乎没有使用系统自带的组件,不管是textView、recycleView、viewPage...,或者是鸿蒙的arkUI相关组件,基本都没用。全新研发的组件做到了性能更高。

  2. vue里template和style里的代码,被直接编译为优化度非常高的C代码。它的运行速度远快于arkts、kotlin及k/n。

    当然它的副作用就是编译速度很慢,开发C的工程师应该知道编译大型C工程是一件耗时工作。

    后续DCloud也会提供开发期间的热刷新方案。

在uni-app x的示例中发现了拍平。如果不拍平的话,uni-app x蒸汽模式中渲染速度还会比原生快吗?

如果不拍平的话,同屏创建4050个view和text的示例的平均耗时为467ms。仍远快于arkUI的797.6ms。

k/n驱动c层渲染,是否也快过arkUI或uni-app x蒸汽模式?

截止到目前(2026年2月初),基于k/n的开源跨平台框架,在上述基准测试中的表现均比arkUI差很多。更无法与uni-app x蒸汽模式相比。

uni-app x蒸汽模式在Android和iOS是否也快过原生?

uni-app x蒸汽模式的iOS版和Android版的渲染引擎已开发完毕,但产品化还有一些工作要做。预计分别在2026年Q1和Q2发布。

不管在iOS还是Android,均比原生快2~3倍,均基于原生渲染管线。

已公开如下预览版对比测试例:

上述示例在华为mate30 Android版上,对比数据如下:

5次冷启动平均耗时(单位:ms)
原生view 436
原生compose ui 673.2
原生compose ui aot 544.2
uni-app x 蒸汽模式 224

即跨平台,又比原生性能更高,曾经被认为是天方夜谭。

在 uni-app x蒸汽模式 发布前,DCloud曾给行业内小范围演示,也被认为不可能。特将本文赠予哪些不相信这件事的人。

在中国,因为小程序和鸿蒙等多平台现状,一个优秀的跨平台框架对于产业有巨大的意义。

在中国,被封锁高端芯片技术的鸿蒙系统,更需要高性能的软件框架来支撑用户体验。

继续前行!

🚀 两年小程序开发,我把踩过的坑做成了开源 Skills

还记得第一次用 miniprogram-automator 写自动化测试时的绝望吗?

  • querySelector() 死活选不到自定义组件里的元素
  • waitFor() 不知道该等什么,测试时好时坏
  • wx.request Mock 不生效,每次测试都打真实接口
  • 截图对比总是因为时间戳、动画而误报

还有 CI 发布时的噩梦:

  • 上传到微信后台经常超时,CI 流水线红成一片
  • pnpm 项目的 npm 包打不进去,shamefully-hoist 是什么鬼?
  • GitHub Actions 里 secrets 配置错了,排查半天才发现是 IP 白名单问题

我决定不再让其他人重复踩这些坑。


📦 wechat-miniprogram-skills

这是我开源的微信小程序 AI 编程技能包,基于 Skills 规范,支持 40+ AI 编程工具(Claude Code、Cursor、Windsurf、Continue、Copilot 等)。

包含两个核心 Skill:

1️⃣ miniprogram-automation - 自动化测试不再是玄学

核心能力:

  • ✅ 生成可复用的 Node.js 自动化脚本(不强制 Jest)
  • ✅ 处理自定义组件边界问题(.shadow()>>> 选择器)
  • ✅ 智能 waitFor 策略(等待元素、网络、页面栈)
  • ✅ wx.request Mock 方案(mockWxMethod / restoreWxMethod)
  • ✅ 截图 + 回归验证(console / exception 监听)
  • ✅ 基于官方 miniprogram-demo 实际验证

常见触发词: "小程序自动化"、"automator"、"E2E 测试"、"选不到元素"、"mock wx.request"

实际案例:

// 自动生成的脚本会处理这些细节:
const input = await page.$('.custom-input >>> input')
await input.input('测试内容')
await page.waitFor(500) // 智能等待策略

2️⃣ miniprogram-ci - 让 CI/CD 稳如老狗

核心能力:

  • ✅ 生成 pack-npm / preview / upload 独立脚本
  • ✅ 内置超时重试机制(3次重试,每次等待 5 秒)
  • ✅ 完整 GitHub Actions 模板(npm / pnpm 双版本)
  • ✅ pnpm 兼容性配置(shamefully-hoist / public-hoist-pattern)
  • ✅ secrets 管理 + IP 白名单检查
  • ✅ 自动创建 GitHub Release

常见触发词: "上传小程序"、"CI 部署"、"miniprogram-ci"、"预览二维码"、"自动化发布"

实际案例:

# 自动生成的 GitHub Actions 会帮你:
- name: Upload to WeChat
  run: node scripts/upload.js
  env:
    PRIVATE_KEY: ${{ secrets.WX_PRIVATE_KEY }}

🎯 为什么要做成 Skills?

1. AI 编程时代,让 AI 直接生成正确的代码

  • 不用再翻文档、查 Stack Overflow
  • AI 知道如何处理自定义组件、如何配置 CI

2. 知识可复用、可迭代

  • 把经验固化成 Skill,团队共享
  • 遇到新坑就更新 Skill,让 AI 帮后来者避坑

3. 支持 40+ AI 工具

  • 不绑定特定 IDE 或 AI 助手
  • Claude、Cursor、Copilot 都能用

📥 如何使用?

# 安装整个仓库
npx skills add whinc/wechat-miniprogram-skills

# 只安装自动化测试 Skill
npx skills add whinc/wechat-miniprogram-skills --skill miniprogram-automation

# 只安装 CI 发布 Skill
npx skills add whinc/wechat-miniprogram-skills --skill miniprogram-ci

安装后,直接在 AI 编程工具中说:

  • "帮我生成小程序自动化测试脚本"
  • "配置小程序 CI 自动上传"

AI 会基于这些 Skills 生成经过实战验证的代码


🤝 欢迎贡献

如果你也在小程序开发中踩过坑、总结过经验,欢迎提交 PR 补充新的 Skill!

比如:

  • 小程序性能监控
  • 分包加载优化
  • 云开发自动化部署
  • ...

让我们一起让小程序开发不再痛苦 💪


🔗 链接

ahooks useRequest 深度解析:一个 Hook 搞定所有请求

二、核心功能详解

1. 自动管理请求状态

const { data, loading, error, run, refresh, cancel } = useRequest(
  fetchUserList,
  {
    manual: false,  // 自动执行
    defaultParams: [{ page: 1 }],  // 默认参数
  }
);

// run: 手动触发
// refresh: 使用上次参数重新请求
// cancel: 取消当前请求

2. 防抖与节流

// 搜索场景:防抖
const { data, loading } = useRequest(searchAPI, {
  debounceWait: 300,  // 300ms 防抖
  manual: true,
});

// 滚动加载:节流
const { run } = useRequest(loadMore, {
  throttleWait: 1000,  // 1s 节流
  manual: true,
});

3. 轮询

// 每 3 秒轮询一次
const { data } = useRequest(getStatus, {
  pollingInterval: 3000,
  pollingWhenHidden: false,  // 页面隐藏时停止轮询
});

// 条件轮询
const { data } = useRequest(getJobStatus, {
  pollingInterval: 2000,
  pollingErrorRetryCount: 3,  // 错误重试次数
  onSuccess: (result) => {
    if (result.status === 'completed') {
      // 完成后停止轮询
      return false;
    }
  }
});

4. 依赖刷新

const [userId, setUserId] = useState('1');

const { data } = useRequest(
  () => fetchUser(userId),
  {
    refreshDeps: [userId],  // userId 变化时自动重新请求
  }
);

5. 缓存机制

// SWR 模式:先返回缓存,后台更新
const { data, loading } = useRequest(fetchUser, {
  cacheKey: 'user-data',
  staleTime: 5000,  // 5s 内认为数据新鲜
  cacheTime: 300000,  // 缓存保留 5 分钟
});

// 清除缓存
import { clearCache } from 'ahooks';
clearCache('user-data');

6. 错误重试

const { data, error, retry } = useRequest(unstableAPI, {
  retryCount: 3,  // 失败后重试 3 次
  retryInterval: 1000,  // 重试间隔 1s
  onError: (error, params) => {
    console.log('请求失败', error);
  }
});

三、进阶场景

并行请求

const user = useRequest(fetchUser);
const posts = useRequest(fetchPosts);
const comments = useRequest(fetchComments);

const loading = user.loading || posts.loading || comments.loading;

串行请求

const { data: user } = useRequest(fetchUser);

const { data: posts } = useRequest(
  () => fetchUserPosts(user.id),
  {
    ready: !!user,  // user 存在时才执行
    refreshDeps: [user],
  }
);

分页加载

function UserList() {
  const { data, loading, loadMore, loadingMore, noMore } = useRequest(
    (d) => fetchList({ page: d?.nextPage || 1 }),
    {
      loadMore: true,
      isNoMore: (d) => !d?.hasMore,
    }
  );
  
  return (
    <>
      {data?.list.map(item => <Item key={item.id} {...item} />)}
      {!noMore && (
        <Button onClick={loadMore} loading={loadingMore}>
          加载更多
        </Button>
      )}
    </>
  );
}

乐观更新

const { run: deleteItem } = useRequest(deleteAPI, {
  manual: true,
  onBefore: (params) => {
    // 立即更新 UI
    setList(list => list.filter(item => item.id !== params[0]));
  },
  onError: (error, params) => {
    // 失败时回滚
    message.error('删除失败');
    refresh();
  }
});

四、与其他方案对比

特性 useRequest React Query SWR
学习成本
功能完整度 很高
包体积 较大
防抖节流 内置 需自己实现 需自己实现
轮询 内置 内置 需配置
TypeScript 良好 优秀 良好

五、最佳实践

  1. 合理使用缓存:列表、详情等读多写少的数据适合缓存
  2. 设置合适的防抖时间:搜索建议 300-500ms
  3. 避免过度轮询:根据业务需求设置合理的轮询间隔
  4. 善用 ready 参数:避免无效请求
  5. 统一错误处理:在全局配置中处理通用错误
// 全局配置
import { configResponsive } from 'ahooks';

configResponsive({
  onError: (error) => {
    if (error.code === 401) {
      // 统一处理未登录
      redirectToLogin();
    }
  }
});

六、源码解析(简化版)

useRequest 的核心实现思路:

function useRequest(service, options) {
  const [state, setState] = useState({
    data: undefined,
    loading: false,
    error: undefined,
  });
  
  const run = useCallback(async (...params) => {
    setState(s => ({ ...s, loading: true }));
    
    try {
      const data = await service(...params);
      setState({ data, loading: false, error: undefined });
    } catch (error) {
      setState(s => ({ ...s, loading: false, error }));
    }
  }, [service]);
  
  useEffect(() => {
    if (!options.manual) {
      run(...(options.defaultParams || []));
    }
  }, []);
  
  return { ...state, run };
}

实际实现还包括:

  • 防抖节流的 debounce/throttle 包装
  • 轮询的 setInterval 管理
  • 缓存的 Map 存储
  • 依赖追踪的 useEffect
  • 请求取消的 AbortController

总结

useRequest 是一个功能强大且易用的请求管理 Hook,它封装了日常开发中 90% 的请求场景。通过合理使用其提供的能力,可以大幅减少样板代码,提升开发效率。

推荐在中小型项目中直接使用 useRequest,大型项目可以考虑 React Query 获得更强的数据管理能力。

如果这篇文章对你有帮助,欢迎点赞收藏!

React Suspense 从入门到实战:让异步加载更优雅

二、代码分割场景

这是 Suspense 最成熟的应用场景,配合 React.lazy 实现组件懒加载。

import { lazy, Suspense } from 'react';

const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));

function App() {
  return (
    <Suspense fallback={<PageLoader />}>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Suspense>
  );
}

多层 Suspense 边界:

<Suspense fallback={<AppShell />}>
  <Layout>
    <Suspense fallback={<SidebarSkeleton />}>
      <Sidebar />
    </Suspense>
    <Suspense fallback={<ContentSkeleton />}>
      <Content />
    </Suspense>
  </Layout>
</Suspense>

三、数据请求场景

React 18 开始,Suspense 可以配合支持的数据请求库使用。

使用 SWR:

import useSWR from 'swr';

function User({ id }) {
  const { data } = useSWR(`/api/user/${id}`, fetcher, {
    suspense: true  // 开启 Suspense 模式
  });
  
  return <div>{data.name}</div>;
}

<Suspense fallback={<UserSkeleton />}>
  <User id={123} />
</Suspense>

使用 React Query:

import { useQuery } from '@tanstack/react-query';

function Posts() {
  const { data } = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
    suspense: true
  });
  
  return data.map(post => <Post key={post.id} {...post} />);
}

四、与 Error Boundary 配合

Suspense 只处理"挂起"状态,错误需要 Error Boundary 捕获。

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  
  static getDerivedStateFromError(error) {
    return { hasError: true };
  }
  
  render() {
    if (this.state.hasError) {
      return <ErrorFallback />;
    }
    return this.props.children;
  }
}

<ErrorBoundary>
  <Suspense fallback={<Loading />}>
    <DataComponent />
  </Suspense>
</ErrorBoundary>

五、与并发特性配合

配合 useTransition 避免不必要的 loading 状态:

function SearchResults() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  
  const handleSearch = (value) => {
    startTransition(() => {
      setQuery(value);  // 低优先级更新
    });
  };
  
  return (
    <>
      <input onChange={e => handleSearch(e.target.value)} />
      {isPending && <InlineSpinner />}
      <Suspense fallback={<ResultsSkeleton />}>
        <Results query={query} />
      </Suspense>
    </>
  );
}

六、最佳实践

  1. 合理划分边界:按路由或功能模块设置 Suspense,避免过细或过粗
  2. 提供有意义的 fallback:使用骨架屏而非简单的 loading 文字
  3. 避免瀑布流:并行发起请求,而非串行等待
  4. 配合预加载:在用户交互前提前触发数据请求
// 预加载示例
const resource = fetchUser(id);  // 提前发起

function Profile() {
  const user = use(resource);  // 直接使用
  return <div>{user.name}</div>;
}

七、注意事项

  • Suspense 在服务端渲染(SSR)中需要特殊处理,React 18 提供了流式 SSR 支持
  • 不是所有数据请求库都支持 Suspense,使用前查看文档
  • fallback 组件应该是轻量的,避免在其中执行副作用
  • 嵌套 Suspense 时,内层边界会优先生效

总结

Suspense 让异步操作的处理更加优雅和声明式。从代码分割到数据请求,它都能提供更好的开发体验和用户体验。配合 React 18 的并发特性,Suspense 将成为构建现代 React 应用的重要工具。

如果这篇文章对你有帮助,欢迎点赞收藏!

ahooks useRequest 深度解析:一个 Hook 搞定所有请求

二、核心功能详解

1. 自动管理请求状态

const { data, loading, error, run, refresh, cancel } = useRequest(
  fetchUserList,
  {
    manual: false,  // 自动执行
    defaultParams: [{ page: 1 }],  // 默认参数
  }
);

// run: 手动触发
// refresh: 使用上次参数重新请求
// cancel: 取消当前请求

2. 防抖与节流

// 搜索场景:防抖
const { data, loading } = useRequest(searchAPI, {
  debounceWait: 300,  // 300ms 防抖
  manual: true,
});

// 滚动加载:节流
const { run } = useRequest(loadMore, {
  throttleWait: 1000,  // 1s 节流
  manual: true,
});

3. 轮询

// 每 3 秒轮询一次
const { data } = useRequest(getStatus, {
  pollingInterval: 3000,
  pollingWhenHidden: false,  // 页面隐藏时停止轮询
});

// 条件轮询
const { data } = useRequest(getJobStatus, {
  pollingInterval: 2000,
  pollingErrorRetryCount: 3,  // 错误重试次数
  onSuccess: (result) => {
    if (result.status === 'completed') {
      // 完成后停止轮询
      return false;
    }
  }
});

4. 依赖刷新

const [userId, setUserId] = useState('1');

const { data } = useRequest(
  () => fetchUser(userId),
  {
    refreshDeps: [userId],  // userId 变化时自动重新请求
  }
);

5. 缓存机制

// SWR 模式:先返回缓存,后台更新
const { data, loading } = useRequest(fetchUser, {
  cacheKey: 'user-data',
  staleTime: 5000,  // 5s 内认为数据新鲜
  cacheTime: 300000,  // 缓存保留 5 分钟
});

// 清除缓存
import { clearCache } from 'ahooks';
clearCache('user-data');

6. 错误重试

const { data, error, retry } = useRequest(unstableAPI, {
  retryCount: 3,  // 失败后重试 3 次
  retryInterval: 1000,  // 重试间隔 1s
  onError: (error, params) => {
    console.log('请求失败', error);
  }
});

三、进阶场景

并行请求

const user = useRequest(fetchUser);
const posts = useRequest(fetchPosts);
const comments = useRequest(fetchComments);

const loading = user.loading || posts.loading || comments.loading;

串行请求

const { data: user } = useRequest(fetchUser);

const { data: posts } = useRequest(
  () => fetchUserPosts(user.id),
  {
    ready: !!user,  // user 存在时才执行
    refreshDeps: [user],
  }
);

分页加载

function UserList() {
  const { data, loading, loadMore, loadingMore, noMore } = useRequest(
    (d) => fetchList({ page: d?.nextPage || 1 }),
    {
      loadMore: true,
      isNoMore: (d) => !d?.hasMore,
    }
  );
  
  return (
    <>
      {data?.list.map(item => <Item key={item.id} {...item} />)}
      {!noMore && (
        <Button onClick={loadMore} loading={loadingMore}>
          加载更多
        </Button>
      )}
    </>
  );
}

乐观更新

const { run: deleteItem } = useRequest(deleteAPI, {
  manual: true,
  onBefore: (params) => {
    // 立即更新 UI
    setList(list => list.filter(item => item.id !== params[0]));
  },
  onError: (error, params) => {
    // 失败时回滚
    message.error('删除失败');
    refresh();
  }
});

四、与其他方案对比

特性 useRequest React Query SWR
学习成本
功能完整度 很高
包体积 较大
防抖节流 内置 需自己实现 需自己实现
轮询 内置 内置 需配置
TypeScript 良好 优秀 良好

五、最佳实践

  1. 合理使用缓存:列表、详情等读多写少的数据适合缓存
  2. 设置合适的防抖时间:搜索建议 300-500ms
  3. 避免过度轮询:根据业务需求设置合理的轮询间隔
  4. 善用 ready 参数:避免无效请求
  5. 统一错误处理:在全局配置中处理通用错误
// 全局配置
import { configResponsive } from 'ahooks';

configResponsive({
  onError: (error) => {
    if (error.code === 401) {
      // 统一处理未登录
      redirectToLogin();
    }
  }
});

六、源码解析(简化版)

useRequest 的核心实现思路:

function useRequest(service, options) {
  const [state, setState] = useState({
    data: undefined,
    loading: false,
    error: undefined,
  });
  
  const run = useCallback(async (...params) => {
    setState(s => ({ ...s, loading: true }));
    
    try {
      const data = await service(...params);
      setState({ data, loading: false, error: undefined });
    } catch (error) {
      setState(s => ({ ...s, loading: false, error }));
    }
  }, [service]);
  
  useEffect(() => {
    if (!options.manual) {
      run(...(options.defaultParams || []));
    }
  }, []);
  
  return { ...state, run };
}

实际实现还包括:

  • 防抖节流的 debounce/throttle 包装
  • 轮询的 setInterval 管理
  • 缓存的 Map 存储
  • 依赖追踪的 useEffect
  • 请求取消的 AbortController

总结

useRequest 是一个功能强大且易用的请求管理 Hook,它封装了日常开发中 90% 的请求场景。通过合理使用其提供的能力,可以大幅减少样板代码,提升开发效率。

推荐在中小型项目中直接使用 useRequest,大型项目可以考虑 React Query 获得更强的数据管理能力。

如果这篇文章对你有帮助,欢迎点赞收藏!

React Native中创建自定义渐变色

前言

所谓自定义渐变色就是允许用户按需选择颜色以及颜色的位置,一个基本的CSS渐变色配置是这样的: experimental_backgroundImage: 'linear-gradient(135deg, #e2d7c8 0%, #d1c3a6 25%, #bdac86 50%, #a79368 75%, #8e794d 100%)', 之前的文章提到过RN中实现渐变色的两种个方案,分别是RN的experimental_backgroundImage和使用expo-linear-gradient依赖库,以experimental_backgroundImage为例,如果我们想让用户实现自定义的渐变色方案应该允许用户配置 颜色颜色停留的位置渐变朝向角度。下面是界面样式:

微信图片_20260309095613_268_23.jpg微信图片_20260309095614_269_23.jpg

微信图片_20260309095615_270_23.jpg微信图片_20260309095615_271_23.jpg

微信图片_20260309095616_272_23.jpg微信图片_20260309095617_273_23.jpg

依赖库选择

因为我们需要一个调色板供用户选择颜色,因此我们需要一个依赖库来提供颜色选择功能:

reanimated-color-picker

它提供了多种颜色形式面板,比如圆形,条形,方形等选择界面UI组件,透明度控制和多种颜色格式获取,比如rgba格式和#ccc这种hex格式等,通常我们需要hex格式即可。该依赖库功能强大但是它没有详细文档,使用的最好方式是去它的项目中查看示例代码,非常详尽。由于我们想要创建渐变色效果应用于部分页面的渐变色背景,如果使用渐变色背景的页面是transparent_modal形式则不推荐使用透明度,将透明度始终设置为1,来避免显示底部页面内容。 以上我们创建了一个调色盘和颜色条,这二者便是以上依赖库为我们提供的样式UI:

import ColorPicker, { colorKit, HueSlider, Panel3, type ColorFormatsObject } from 'reanimated-color-picker';
    /**
     * ColorPick向外暴露onChange它是ui线程执行
     * onChangeJS和onCompleteJS都是在js线程执行的
     * ColorPick颜色选择行为会在点击调色盘和调整透明度时
     * 均会产生一个新颜色结果,而且渐变色的配置不适合调整透明度
     * 否则对于弹窗页面底部会显示出来
     * 它接收色彩对象,按需取制定格式即可
     */

                <ColorPicker
                    value={resultColor}
                    sliderThickness={16}
                    thumbSize={16}
                    thumbShape='circle'
                    onCompleteJS={onColorPick}
                    style={styles.picker}
                    boundedThumb
                >
                    <View
                        style={styles.panel}
                    >
                        <Panel3
                            style={styles.panelStyle}
                        />
                    </View>

                    <HueSlider
                        style={styles.sliderStyle}
                    />
                </ColorPicker>

它接收onChange事件函数以获取修改后的颜色,它内部使用react-native-reanimated依赖库因此动画控制以及值的变化有UI线程和JS线程之分,在这里我们使用js线程即可,因为我们要在值变化后修改组件接收的样式动态更新。

颜色位置

我们通常使用%百分比来控制颜色位置,这里我们使用一个slider,限定值的范围01或者0100,它的值在每次新增颜色后更新位置则与新的颜色配置绑定,因为每个颜色都应该有一个不同的位置,所以每一个渐变色存储对象中都应该有一个颜色和位置

渐变角度

渐变角度则是整个渐变色的配置,因此整个渐变色结果对象中只需要一个角度值。因此我们需要在创建渐变色时应该保存:渐变颜色n个,颜色位置n个,渐变角度1个:

单个颜色的基本配置字段,它同时也是底部已选择的颜色栏元素所需字段:

export interface ColorConfig {
    color: string,
    pos: number,
    id: string
};

当用户选择颜色超过1个时就可以创建渐变色,构造linear-gradient(135deg, #e2d7c8 0%, #d1c3a6 25%, #bdac86 50%, #a79368 75%, #8e794d 100%)

    const defaultColor = colorKit.randomRgbColor().hex();
    /** 记录当前选择的颜色项 */
    const [currentId, setCurrentId] = useState<string>('');
    /** 已选择的颜色数组 */
    const [colors, setColors] = useState<ColorConfig[]>([]);
    /** 删除模式还是新增模式 */
    const [mode, setMode] = useState<LinearMode>('select');
    /** 渐变色结果字符串,直接赋值给指定组件的样式 */
    const [experimental_backgroundImage, setExperimental_backgroundImage] = useState<string>('');

    const onColorPick = (color: ColorFormatsObject) => {
        setResultColor(color.hex);
        let obj = { color: color.hex, pos: sliderValue, id: generateId() };
        if (mode === 'select') {
            if (currentId) {
                setColors(prev => {
                    const newList = prev.map(el => {
                        if (el.id === currentId) {
                            return { ...el, color: color.hex };
                        };
                        return el;
                    });
                    if (newList.length > 1) {
                        const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                        const result = `linear-gradient(${angle}deg, ${stopsStr})`
                        setExperimental_backgroundImage(result);
                        if (viewItemsCount < newList.length) {
                            flatListRef.current?.scrollToOffset({ offset: newList.length * 55, animated: true });
                        };
                    };
                    return newList;
                });
            } else {
                setColors(prev => [...prev, obj]);
                setCurrentId(obj.id);
            };
        };
    };

以上有一个细节:在调色板组件中,底部颜色条,顶部圆盘以及颜色透明度的变化都视为颜色变化,触发onColorPick并生成一个新的颜色值结果,因此我们应该区分用户到底是在新增一个颜色还是在修改当前这个颜色,因此在颜色位置的Slider变化时应该有相同的逻辑处理:

    const handleSliderChange = (val: number) => {
        setSliderValue(Number(val.toFixed(1)));
        if (currentId) {
            setColors(prev => {
                const newList = prev.map(el => {
                    if (el.id === currentId) {
                        return { ...el, pos: val }
                    };
                    return el;
                });
                const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                const result = `linear-gradient(${angle}deg, ${stopsStr})`
                setExperimental_backgroundImage(result);
                return newList;
            });

        };
    };

颜色值和位置值的变化最终会影响渐变色,因此需要重新生成experimental_backgroundImage

角度值变化时也应该重新生成experimental_backgroundImage:

    /**
     * 角度值发生变化时执行
     * 添加节流处理,角度变化后
     * 会在已选颜色两种以上的情况下重新想修改
     * 渐变色
     */
    const handleAngelChange = (val: number) => {
        setAngle(Number(val.toFixed(0)));
        if (colors.length > 1) {
            const stopsStr = colors.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
            const result = `linear-gradient(${angle}deg, ${stopsStr})`
            setExperimental_backgroundImage(result);
        };
    };

完成以上三个控制后就是收获结果了将其转化为linear-gradient(135deg, #e2d7c8 0%, #d1c3a6 25%, #bdac86 50%, #a79368 75%, #8e794d 100%)格式保存,也没什么好说的,具体你想以哪种方式存储问题都不大,因为核心问题就是调色板,解决了它就是数据的修改保存,完整代码如下:

import { type FC, useState, useCallback, useRef } from 'react';
import PageTitleBar from '@/components/ui/PageTitleBar';
import StyledText from '@/components/ui/StyledText';
import { ThemedIonicons, ThemedView } from '@/components/theme/ThemedComponents';
import { View, StyleSheet, ScrollView, Platform, Pressable, TextInput, useWindowDimensions, KeyboardAvoidingView } from 'react-native';
import { useLocalSearchParams, router } from 'expo-router';
import type { ThemeType } from '@/types';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import CustomButton from '@/components/ui/CustomButton';
import ColorPicker, { colorKit, HueSlider, Panel3, type ColorFormatsObject } from 'reanimated-color-picker';
import Slider from '@react-native-community/slider';
import Switch from '@/components/ui/Switch';
import type { LinearMode } from '@/types/gradient';
import { useThemeConfig, useThemeNotification } from '@/hooks/useTheme';
import { generateId } from '@/utils';
import { insertGradient } from '@/libs/sqlite'
import { FlashList, type FlashListRef } from '@shopify/flash-list';
import ColorItem, { type ColorConfig } from '@/components/linearcolorselectpage/ColorItem';
import useProMusicStore from '@/store/proMusic';
const defaultColor = colorKit.randomRgbColor().hex();
const ColorPickerPage: FC = () => {
    const { type } = useLocalSearchParams<{ type: ThemeType }>();
    const { top, bottom } = useSafeAreaInsets();
    const [resultColor, setResultColor] = useState(defaultColor);
    const [colors, setColors] = useState<ColorConfig[]>([]);
    const flatListRef = useRef<FlashListRef<ColorConfig>>(null);
    const [sliderValue, setSliderValue] = useState<number>(0);
    const { text } = useThemeConfig();
    const { width } = useWindowDimensions()
    const showNotification = useThemeNotification();
    const [experimental_backgroundImage, setExperimental_backgroundImage] = useState<string>('');
    const [angle, setAngle] = useState<number>(0);
    const [mode, setMode] = useState<LinearMode>('select');
    const [title, setTitle] = useState<string>('')
    const [currentId, setCurrentId] = useState<string>('');
    const viewItemsCount = Math.floor((width - 40) / 55);
    /**
     * ColorPick向外暴露onChange它是ui线程执行
     * onChangeJS和onCompleteJS都是在js线程执行的
     * ColorPick颜色选择行为会在点击调色盘和调整透明度时
     * 均会产生一个新颜色结果,而且渐变色的配置不适合调整透明度
     * 否则对于弹窗页面底部会显示出来
     * 它接收色彩对象,按需取制定格式即可
     */
    const onColorPick = (color: ColorFormatsObject) => {
        setResultColor(color.hex);
        let obj = { color: color.hex, pos: sliderValue, id: generateId() };
        if (mode === 'select') {
            if (currentId) {
                setColors(prev => {
                    const newList = prev.map(el => {
                        if (el.id === currentId) {
                            return { ...el, color: color.hex };
                        };
                        return el;
                    });
                    if (newList.length > 1) {
                        const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                        const result = `linear-gradient(${angle}deg, ${stopsStr})`
                        setExperimental_backgroundImage(result);
                        if (viewItemsCount < newList.length) {
                            flatListRef.current?.scrollToOffset({ offset: newList.length * 55, animated: true });
                        };
                    };
                    return newList;
                });
            } else {
                setColors(prev => [...prev, obj]);
                setCurrentId(obj.id);
            };
        };
    };
    const handleBack = () => router.dismiss();
    const handleConfirm = async () => {
        if (colors.length < 2) {
            showNotification({ tip: '请至少选择两个颜色', type: 'warning' });
            return
        };
        if (!title) {
            showNotification({ tip: '请填写渐变色标题', type: 'warning' });
            return;
        };
        try {
            const configId = await insertGradient({
                id: generateId(),
                theme_type: type,
                title,
                is_active: 1,
                sort_order: 0,
                stops: colors.map(({ color, pos }, index) => ({
                    color,
                    position: pos,
                    id: generateId(),
                    sort_order: index,
                    config_id: '' // 会在 insertGradient 内部统一设置
                })),
                metadata: {
                    config_id: '', // 会在 insertGradient 内部统一设置
                    gradient_type: 'linear',
                    angle,
                },
            });
            if (configId) {
                showNotification({ tip: '保存成功', type: 'success' });
                const { setSignal } = useProMusicStore.getState();
                setSignal('RNLinearColorSelectPage');
                // 可以在这里执行保存后的操作,如返回上一页
                handleBack();
            } else {
                showNotification({ tip: '保存失败', type: 'error' })
            }
        } catch (error) {
            showNotification({ tip: '请稍后重试', type: 'warning' })
        };
    };
    const handleChangeMode = () => {
        setMode(prev => prev === 'select' ? 'delete' : 'select');
    };
    const handlePressColorItem = useCallback((item: ColorConfig, mode: LinearMode) => {
        if (mode === 'delete') {
            setColors(prev => prev.filter(({ id }) => id !== item.id));
        } else {
            const { id, color, pos } = item;
            setCurrentId(id);
            setResultColor(color);
            setSliderValue(pos);
        };
    }, []);
    const handleSliderChange = (val: number) => {
        setSliderValue(Number(val.toFixed(1)));
        if (currentId) {
            setColors(prev => {
                const newList = prev.map(el => {
                    if (el.id === currentId) {
                        return { ...el, pos: val }
                    };
                    return el;
                });
                const stopsStr = newList.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
                const result = `linear-gradient(${angle}deg, ${stopsStr})`
                setExperimental_backgroundImage(result);
                return newList;
            });

        };
    };
    /**
     * 角度值发生变化时执行
     * 添加节流处理,角度变化后
     * 会在已选颜色两种以上的情况下重新想修改
     * 渐变色
     */
    const handleAngelChange = (val: number) => {
        setAngle(Number(val.toFixed(0)));
        if (colors.length > 1) {
            const stopsStr = colors.slice().sort((a, b) => a.pos - b.pos).map(({ color, pos }) => `${color} ${(pos * 100).toFixed(0)}%`).join(', ');
            const result = `linear-gradient(${angle}deg, ${stopsStr})`
            setExperimental_backgroundImage(result);
        };
    };
    const handleTextChange = (val: string) => setTitle(val.trim());
    const themeType = type === 'light';
    const handleAddColor = useCallback(() => {
        const id = generateId();
        setCurrentId(id);
        setColors(prev => {
            const newItem = { ...prev[prev.length - 1], id };
            return ([...prev, newItem]);
        });
    }, []);
    return (<ThemedView
        style={[styles.container, { paddingBottom: bottom + 10, experimental_backgroundImage }]}
    >
        <PageTitleBar
            leftText='配置颜色'
            onPressLeft={handleBack}
        />
        <KeyboardAvoidingView
            behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
            style={[styles.container, { paddingTop: top + 50 }]}
        >
            <ScrollView
                showsVerticalScrollIndicator={false}
            >
                <View
                    style={styles.colorPicker}
                >
                    <ThemedIonicons
                        name='color-palette-outline'
                        size={15}
                    />
                    <StyledText
                        size='SM'
                        weight='BOLD'
                    >颜色选择器</StyledText>
                </View>
                <ColorPicker
                    value={resultColor}
                    sliderThickness={16}
                    thumbSize={16}
                    thumbShape='circle'
                    onCompleteJS={onColorPick}
                    style={styles.picker}
                    boundedThumb
                >
                    <View
                        style={styles.panel}
                    >
                        <Panel3
                            style={styles.panelStyle}
                        />
                    </View>

                    <HueSlider
                        style={styles.sliderStyle}
                    />
                </ColorPicker>
                <View
                    style={styles.titleBar}
                >
                    <View
                        style={styles.title}
                    >
                        <ThemedIonicons
                            name='pin-sharp'
                            size={14}
                        />
                        <StyledText
                            size='SM'
                            weight='BOLD'
                        >颜色位置</StyledText>
                    </View>
                    <Slider
                        style={styles.slider}
                        value={sliderValue}
                        step={.01}
                        minimumValue={0}
                        maximumValue={1}
                        onSlidingComplete={handleSliderChange}
                        maximumTrackTintColor='#ccc'
                    />
                    <StyledText
                        size='XXS'
                        textAlign='right'
                        weight='BOLD'
                        style={styles.sliderText}
                    >{`${Number(sliderValue.toFixed(2)) * 100}%`}</StyledText>
                </View>
                <View
                    style={styles.titleBar}
                >
                    <View
                        style={styles.title}
                    >
                        <ThemedIonicons
                            name='compass-outline'
                            size={15}
                        />
                        <StyledText
                            size='SM'
                            weight='BOLD'
                        >渐变角度</StyledText>
                    </View>
                    <Slider
                        style={styles.slider}
                        value={angle}
                        step={1}
                        minimumValue={0}
                        maximumValue={360}
                        maximumTrackTintColor='#ccc'
                        onSlidingComplete={handleAngelChange}
                    />
                    <StyledText
                        size='XXS'
                        textAlign='right'
                        weight='BOLD'
                        style={styles.sliderText}
                    >{angle.toFixed(0)}deg</StyledText>
                </View>
                <View
                    style={styles.titleBar}
                >
                    <View
                        style={styles.title}
                    >
                        <ThemedIonicons
                            name={themeType ? 'sunny-outline' : 'moon-outline'}
                            size={themeType ? 16 : 14}
                        />
                        <StyledText
                            weight='BOLD'
                            size='SM'
                        >{`已选颜色(${colors.length})`}</StyledText>
                    </View>
                    <View
                        style={styles.title}
                    >
                        <StyledText
                            size='SM'
                            onPress={handleChangeMode}
                            color={mode === 'select' ? text : '#e44444'}
                        >{mode === 'select' ? '选择模式' : '删除模式'}</StyledText>
                        <Switch
                            style={styles.switch}
                            active={mode === 'select'}
                            onChange={handleChangeMode}
                        />
                    </View>
                </View>
                <FlashList
                    ref={flatListRef}
                    style={styles.list}
                    data={colors}
                    keyExtractor={({ id }) => id}
                    horizontal
                    showsHorizontalScrollIndicator={false}
                    contentContainerStyle={styles.colorContent}
                    renderItem={({ item }) => <ColorItem
                        mode={mode}
                        item={item}
                        active={currentId === item.id}
                        onPress={handlePressColorItem}
                    />}
                    ListFooterComponent={<ColorItem
                        onPress={handleAddColor}
                        isEmpty
                    />}
                />
                <View
                    style={styles.colorPicker}
                >
                    <ThemedIonicons
                        name='create-outline'
                        size={16}
                    />
                    <StyledText
                        weight='BOLD'
                        size='SM'
                    >颜色名称:</StyledText>
                </View>
                <View
                    style={styles.inputArea}
                >
                    <TextInput
                        value={title}
                        onChangeText={handleTextChange}
                        placeholder='请输入颜色标题'
                        placeholderTextColor={text}
                        style={[styles.input, { color: text }]}
                        maxLength={15}
                    />
                    <Pressable
                        style={{ display: title ? 'flex' : 'none' }}
                        onPress={() => handleTextChange('')}
                    >
                        <ThemedIonicons
                            name='close-circle'
                            size={18}
                        />
                    </Pressable>
                </View>
            </ScrollView>
        </KeyboardAvoidingView>
        <View
            style={styles.buttonArea}
        >
            <CustomButton
                type='primary'
                text='取消'
                onPress={handleBack}
                style={styles.buttonStyle}
            />
            <CustomButton
                type='success'
                text='保存'
                onPress={handleConfirm}
                style={styles.buttonStyle}
            />
        </View>
    </ThemedView>);
};
const shadow = Platform.select({
    web: { boxShadow: 'rgba(0, 0, 0, 0.3) 0px 0px 2px' },
    default: {
        shadowColor: '#000',
        shadowOffset: {
            width: 0,
            height: 1,
        },
        shadowOpacity: 0.2,
        shadowRadius: 1.41,
        elevation: 2,
    },
});
const styles = StyleSheet.create({
    container: {
        flex: 1
    },
    input: {
        flex: 1,
        height: 30,
        paddingVertical: 2
    },
    slider: {
        flex: 1,
    },
    sliderText: {
        width: 40
    },
    list: {
        height: 65,
        width: '100%',
        paddingHorizontal: 20
    },
    titleBar: {
        width: '100%',
        paddingHorizontal: 20,
        flexDirection: 'row',
        justifyContent: 'space-between',
        alignItems: 'center',
        paddingVertical: 15
    },
    colorPicker: {
        paddingHorizontal: 20,
        flexDirection: 'row',
        gap: 4,
        alignItems: 'center',
        paddingVertical: 10
    },
    inputArea: {
        flex: 1,
        flexDirection: 'row',
        alignItems: 'center',
        gap: 2,
        borderBottomColor: '#ccc',
        borderBottomWidth: 1,
        marginHorizontal: 20,
    },
    title: {
        flexDirection: 'row',
        alignItems: 'center',
        gap: 4
    },
    previewContainer: {
        alignItems: 'center',
        paddingVertical: 10
    },
    colorContent: {
        gap: 5,
    },
    colorCard: {
        width: 40,
        height: 40,
        borderRadius: 10,
        borderWidth: 1
    },
    buttonArea: {
        flexDirection: 'row',
        justifyContent: 'space-evenly',
        alignContent: 'center',
        paddingTop: 20,
        width: '100%'
    },
    card: {
        borderRadius: 10,
        overflow: 'hidden'
    },
    tip: {
        position: 'absolute',
        right: 1,
        top: 1
    },
    switch: {
        width: 28,
        height: 14
    },
    buttonStyle: {
        width: 120
    },
    picker: {
        paddingHorizontal: 20,
        gap: 10,
        paddingBottom: 20
    },
    panel: {
        width: '100%',
        alignItems: 'center'
    },
    panelStyle: {
        width: 260,
        ...shadow,
    },
    sliderStyle: {
        borderRadius: 20,
        ...shadow,
    },
    sliderVerticalStyle: {
        borderRadius: 20,
        height: 300,
        ...shadow,
    },
    previewTxt: {
        color: '#707070',
        fontFamily: 'Quicksand',
    },
    content: {

        padding: 20,

    }

});
export default ColorPickerPage;

有任何疑问可以查看[项目代码](expo rn: expo创建的react native的音乐播放器应用,专注视频转歌和本地歌曲播放)

用 Three.js 和 D3 在 Vue 中打造 3D 苏州地图

前言

地理信息可视化一直是前端领域的热门话题。传统的 2D 地图已经无法满足我们对于视觉效果和交互体验的追求,而 3D 地图则可以提供更直观、更震撼的空间认知。本文将带你从零开始,在 Vue 项目中结合 Three.js 和 D3.js 的地理投影模块,将一个普通的 GeoJSON 文件转化为可交互的 3D 挤出地图,并支持旋转、缩放等操作。最终效果是一个具有立体感和边缘高亮的苏州各区地图。

本文所有代码均基于 Vue 3 + Three.js + d3-geo 实现,你可以直接复制代码运行体验。

image.png

原理:从经纬度到 3D 几何体

要将平面地图“立”起来,我们需要解决两个核心问题:

  1. 坐标转换:地理坐标(经纬度)无法直接在 Three.js 的笛卡尔坐标系中使用。我们需要使用地图投影(如墨卡托投影)将经纬度转换为平面上的 x、y 坐标。这里我们选择 D3.js 提供的 geoMercator 投影,它可以精确地将球面坐标映射到平面,并且可以通过 center 和 scale 参数将地图定位到场景中心。
  2. 三维挤出:有了平面轮廓后,我们可以利用 Three.js 的 ExtrudeGeometry 将平面形状挤出厚度,从而形成立体感。挤出的几何体可以赋予半透明的材质,使其看起来像一块块漂浮的玻璃板。同时,为了增强轮廓的清晰度,我们还可以在边缘绘制线条,让每个区域的分界更加明显。

整个流程可以概括为:
加载 GeoJSON → 解析几何类型(Polygon/MultiPolygon)→ 投影坐标 → 创建 Shape → 挤出 Mesh → 添加边缘 Line。

技术教程

1. 环境准备

首先创建一个 Vue 3 项目(如果你还没有),然后安装必要的依赖:

bash

npm install three d3-geo

注意:d3-geo 是 D3 的地理投影模块,我们只需要它,无需安装整个 D3。

2. 基础场景搭建

在 Vue 组件中,我们先初始化 Three.js 的核心组件:场景、相机、渲染器、轨道控制器。相机使用透视相机,并设置一个较远的初始位置(比如 z=300),以便后续加载的地图能够完整显示。

为了让画面更清晰,我们关闭阴影,限制像素比,并设置深色背景以减少视觉闪烁。

javascript

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1a1a1a);

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 10000);
camera.position.set(0, 0, 300);
camera.lookAt(0, 0, 0);

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.shadowMap.enabled = false; // 关闭阴影
document.body.appendChild(renderer.domElement);

const controls = new OrbitControls(camera, renderer.domElement);
controls.update();

3. 加载并解析 GeoJSON

GeoJSON 是一种常用的地理数据格式。我们准备了一份苏州市区的 GeoJSON 文件(可以在网上寻找或自行制作),其中包含了各区(姑苏区、虎丘区、吴中区等)的边界坐标。由于网络请求可能失败,我们添加了错误处理,并使用默认多边形作为备用。

javascript

async function loadGeoJSON() {
  try {
    const response = await fetch('/苏州市区.geojson');
    const geojson = await response.json();
    processGeoJSON(geojson);
  } catch (error) {
    console.error('加载失败,使用默认数据', error);
    const defaultGeoJSON = {
      type: "FeatureCollection",
      features: [{
        type: "Feature",
        properties: { name: "默认区域" },
        geometry: {
          type: "Polygon",
          coordinates: [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]]
        }
      }]
    };
    processGeoJSON(defaultGeoJSON);
  }
}

4. 投影转换

在 processGeoJSON 中,我们需要遍历每一个 Feature,根据几何类型(Polygon 或 MultiPolygon)提取坐标环。使用 D3 的墨卡托投影将经纬度转换为平面坐标:

javascript

import { geoMercator } from 'd3-geo';

const projection = geoMercator()
  .center([120.41453, 31.342948]) // 苏州市中心经纬度
  .translate([0, 0])
  .scale(10000);

center 设置地图中心点,scale 控制缩放比例,translate 偏移设为 [0,0] 意味着投影后的坐标原点位于 (0,0),这样我们可以直接将坐标用于 Three.js。

5. 绘制挤出几何体

对于每一个坐标环(多边形轮廓),我们创建一个 THREE.Shape,然后通过 ExtrudeGeometry 挤出厚度。这里我们使用半透明的黄色材质,并开启一定的透明度,让内部结构隐约可见。

javascript

function drawExtrudeMesh(polygon, districtName) {
  const shape = new THREE.Shape();
  polygon.forEach((point, index) => {
    const [x, y] = projection(point);
    if (index === 0) shape.moveTo(x, y);
    else shape.lineTo(x, y);
  });

  const extrudeSettings = {
    depth: 10,
    bevelEnabled: false,
    steps: 1
  };

  const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
  const material = new THREE.MeshBasicMaterial({
    color: 'yellow',
    transparent: true,
    opacity: 0.5
  });
  return new THREE.Mesh(geometry, material);
}

注意:这里的 depth 控制挤出高度,可以根据视觉效果调整。

6. 添加边缘线条

为了区分不同区域并增强轮廓,我们在每个多边形边缘绘制一条线。线条的 z 坐标稍微抬高(比如设为 9),使其浮在挤出体的上方,避免被遮挡。

javascript

function lineEdge(polygon) {
  const points = polygon.map(point => {
    const [x, y] = projection(point);
    return new THREE.Vector3(x, y, 9);
  });
  const geometry = new THREE.BufferGeometry().setFromPoints(points);
  const material = new THREE.LineBasicMaterial({ color: 'yellow' });
  return new THREE.Line(geometry, material);
}

7. 处理 MultiPolygon

GeoJSON 中可能存在 MultiPolygon(多个多边形构成一个区域)。我们需要递归处理,将每个子多边形分别转为 Mesh 和 Line。

javascript

if (feature.geometry.type === 'MultiPolygon') {
  coordinates.forEach(coordinate => {
    coordinate.forEach(rows => {
      map.add(drawExtrudeMesh(rows, districtName));
      map.add(lineEdge(rows));
    });
  });
} else if (feature.geometry.type === 'Polygon') {
  coordinates.forEach(rows => {
    map.add(drawExtrudeMesh(rows, districtName));
    map.add(lineEdge(rows));
  });
}

将所有生成的物体添加到一个 THREE.Object3D(即 map)中,最后将这个组添加到场景。

8. 添加辅助和光照

为了让空间感更强,我们添加了坐标轴辅助线,并设置环境光(虽然 MeshBasicMaterial 不需要光照,但为了扩展性保留)。

javascript

const axes = new THREE.AxesHelper(700);
scene.add(axes);

const light = new THREE.AmbientLight(0xffffff);
scene.add(light);

9. 启动动画循环

最后,在数据加载完成后启动动画循环,不断渲染场景。

javascript

async function init() {
  await loadGeoJSON();
  animate();
}

function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

init();

10. 完整代码整合

将上述所有片段整合到一个 Vue 组件的 <script setup> 中,即可得到一个完整的 3D 地图应用。记得将 GeoJSON 文件放置在 public 目录下。

效果预览与优化方向

运行项目后,你会看到一个悬浮在黑暗空间中的黄色半透明苏州地图,每个区都有清晰的边缘线条,你可以使用鼠标旋转、缩放查看各个角度。

下章优化点:

  • 为不同区域赋予不同颜色,提高辨识度。
  • 添加鼠标悬停效果,高亮当前区域并显示名称。
  • 加入底图或街道标签,丰富信息层次。
  • 使用 ShaderMaterial 实现发光边缘等特效。
  • 添加飞线效果

SSE(Server-Sent Events)流式传输原理和XStream实践

1. 背景:SSE 数据格式 {背景}

在理解代码之前,必须先理解 SSE 协议的原始数据格式。

1.1 服务端发送的原始字节流

服务端发送:
POST /api/ai/chat → 服务端开始流式返回

原始字节流(服务端连续发送):
┌─────────────────────────────────────────────┐
│ event: delta\n                              │
│ data: {"content":"你","done":false}\n       │
│ \n                                          │  ← 空行,表示一个事件结束
│ event: delta\n                              │
│ data: {"content":"好","done":false}\n       │
│ \n                                          │
│ event: delta\n                              │
│ data: {"content":"","done":true}\n          │
│ \n                                          │
└─────────────────────────────────────────────┘

1.2 SSE 协议规范

┌─────────────────────────────────────────────────┐
│ SSE 事件格式(RFC 规范):                         │
│                                                 │
│ [field]: [value]\n                              │
│ [field]: [value]\n                              │
│ \n                                              │ ← 空行 = 事件分隔符
│                                                 │
│ field 只有 4 种:data / event / id / retry      │
│                                                 │
│ 示例:                                          │
│   event: delta\n            ← event 字段        │
│   data: {"content":"你"}\n  ← data 字段         │
│   \n                        ← 事件结束           │
└─────────────────────────────────────────────────┘

1.3 浏览器接收到的是什么

HTTP 响应体(ReadableStream<Uint8Array>):

chunk 1: [101 118 101 110 116 ...]  ← Uint8Array 字节数组
chunk 2: [100 97 116 97 58 ...]
chunk 3: [123 34 99 111 110 116 ...] ← 可能被任意截断!

注意:TCP 数据包的边界与 SSE 事件边界完全无关

这就是为什么需要 手动——将乱序、任意截断的字节流,解析成业务可用的结构化数据

2. EventSource 的工作方式

EventSource 是浏览器内置的专用 API,专门用来消费 SSE。EventSource浏览器专为 SSE 封装的高级 API,能够自动解析data/event/id/retry、自动分行、自动解码,服务器可指定重连间隔,断网自动重试。

简单示例

const es = new EventSource('/time');
es.onmessage = (e) => console.log('收到时间:', e.data);
es.onerror = (e) => console.log('出错了');

你只需要关心业务逻辑,连接管理和协议解析都由浏览器搞定。

但是有一个关键的缺点,不支持传递自定义请求头,只能用 GET。因此,对于大型项目,我们只能使用更加原生流式请求fetch + ReadableStream的方式。fetch通用流式传输,不仅能做 SSE,还能处理文件下载、视频流、自定义二进制流。

3. fetch + ReadableStream 的工作方式

首先来一段代码,下面是使用了EventSource实现的流式对话的一个方法,我们直接监听onmessage方法取到数据,这个数据已经是格式化后的数据,将这个数据追加到message中就可以实现流式的效果。现在,我们需要将它改造为fetch + ReadableStream的方式。

export const connectSSE = (message: string, options: SSEOptions) => {
  let currentAttempt = 0;
  let delay = 1000;
  let timeoutId: NodeJS.Timeout | null = null;
  let isClosed = false;
  let eventSource: EventSource | null = null;

  const closeConnection = () => {
    isClosed = true;
    if (eventSource) {
      eventSource.close();
      eventSource = null;
    }
    if (timeoutId) {
      clearTimeout(timeoutId);
      timeoutId = null;
    }
  };

  const connect = () => {
    eventSource = new EventSource(
      `/api/ai/chat?message=${encodeURIComponent(message)}`,
    );

    eventSource.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);

        if (data.done) {
          eventSource?.close();
          options.onDone();
          closeConnection();
        } else {
          options.onChunk(data.content);
        }
      } catch (error) {
        options.onError(new Error("解析SSE数据失败"));
      }
    };

    eventSource.onerror = () => {
      if (isClosed) return;

      eventSource?.close();
      options.onError(new Error("SSE连接错误"));

      if (currentAttempt < DEFAULT_RETRY_CONFIG.maxRetries) {
        currentAttempt++;
        timeoutId = setTimeout(() => {
          connect();
        }, delay);
      } else {
        options.onError(new Error(`SSE连接失败,已重试${currentAttempt}次`));
        closeConnection();
      }
    };
  };

  connect();

  return closeConnection;
};

fetch 返回的 Response 对象中的 body 是一个 ReadableStream(可读流)。它只提供最原始的字节数据,不解析任何格式,你需要手动处理:

  • 发起请求:可以自定义 method、headers、body,不限于 GET。
  • 获取流response.body 是一个 ReadableStream<Uint8Array>
  • 读取数据:通过 getReader() 获得读取器,循环读取数据块(chunk)。
  • 解析协议:需要自己将二进制 chunk 解码为字符串,按行分割,检测 SSE 格式(data:\n\n 等)。
  • 处理重连:如果需要自动重连,必须自己实现逻辑。
  • 取消连接:可以调用 reader.cancel() 或 abortController.abort()

简单示例

const response = await fetch('/time', { method: 'GET' });
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  // 解析 buffer 中的 SSE 消息...
  // 检测 data: ...\n\n,提取数据,然后清空已处理部分
}

上面的value就是最新返回的数据流,通过解码后就是我们的文本块了,应该怎么处理呢。

EventSource 方式:
  Server SSEEventSource 解析 → onmessage 回调
  
Fetch + Stream 方式:
  Server SSEReadableStreamTextDecoder(字节→文本)
              ↓
           行缓冲(处理分割)
              ↓
           SSE 行解析(data: 前缀)
              ↓
           JSON.parse
              ↓
           onChunk 回调

首先,解码后的值赋值给buffer,此时buffer是一个字符串,它可能含有多条语句,因此需要进行切割。buffer.split("\n")将buffer分割为含有多个语句的数组,需要注意的是数组的最后一个元素可能并不是以\n结束的因此先不要处理。

为了处理这种分块,我们需要维护一个累积的缓冲区buffer 变量),将每次新收到的 chunk 追加到缓冲区末尾,然后再一起处理。

export const connectSSEFetch = async (
  message: string,
  options: SSEOptions
) => {
  const abortController = new AbortController();

  try {
    const response = await fetch(
      `/api/ai/chat?message=${encodeURIComponent(message)}`,
      { signal: abortController.signal }
    );

    const reader = response.body!.getReader();
    const decoder = new TextDecoder();
    let buffer = "";

    while (true) {
      const { done, value } = await reader.read();

      if (done) {
        options.onDone();
        break;
      }

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");
      buffer = lines.pop() || ""; // 保留最后一行

      for (const line of lines) {
        if (line.startsWith("data: ")) {
          const data = JSON.parse(line.slice(6));
          if (data.done) {
            options.onDone();
            return;
          }
          options.onChunk(data.content);
        }
      }
    }
  } catch (err) {
    options.onError(err as Error);
  }

  return () => abortController.abort();
};

举个例子- 例如,当前缓冲区内容为:

  data: Hello\n
  data: Wor

这里 "data: Wor" 没有换行符结尾,它只是单词 "World" 的一部分,下一个 chunk 可能会带来 "ld\n\n"split('\n') 会得到:

  ["data: Hello", "data: Wor"]

其中 "data: Wor" 是最后一项,它不是以换行符结尾的完整行,而是一个不完整的行。如果现在处理这一行,就会得到错误的数据。

所以,我们不能处理最后一行,必须把它留到下一次,等后续数据到来拼完整后再处理。

到现在为止,逻辑还是比较清晰的,对比之下,Fetch只是多了一些流数据处理的逻辑,是不是可以把这部分抽取出来呢。


// 高阶函数:创建 SSE 行处理器
  // buffer 和 decoder 在闭包中维持状态,跨 chunk 使用
  const createSSELineProcessor = () => {
    let buffer = "";
    const decoder = new TextDecoder();

    return {
      // 处理单个数据 chunk
      processChunk: (chunk: Uint8Array): boolean => {
        // if (dataEndReceived) return false;

        // 1. 转换字节为文本(stream: true 表示可能是多字节字符的中间部分)
        buffer += decoder.decode(chunk, { stream: true });

        // 2. 按换行符分割成行
        const lines = buffer.split("\n");

        // 3. 最后一行可能不完整,保留到下一个 chunk
        buffer = lines.pop() || "";

        // 4. 处理完整的行
        for (const line of lines) {
          // 空行是 SSE 中的消息分隔符,跳过
          if (!line.trim()) continue;

          const data = parseSSELine(line);
          if (!data) continue; // 不是 data 行,跳过
          if (data.done) {
            options.onDone();
            return false; // 返回 false 表示应该停止处理
          }

          // 处理数据块
          options.onChunk(data.content);
        }

        return true; // 返回 true 表示继续处理
      },

      // 处理流结束时的剩余数据
      flush: (): void => {
        // 最终解码(处理任何待处理的多字节字符)
        const finalText = decoder.decode();
        if (finalText.trim()) {
          const data = parseSSELine(finalText);
          if (data && !data.done) {
            options.onChunk(data.content);
          }
        }
      },
    };
  };

这里我们对流数据处理逻辑进行了抽取,buffer作为闭包保存,使用的时候只需要p=createSSELineProcessor,然后调用p.processChunk即可。依旧是每轮判断有没有done,有的话就退出。

 while (true) {
    const { done, value } = await reader.read();

    if (done) {
      break;
    }

    const shouldContinue = processor.processChunk(value);
    if (!shouldContinue) {
      break;
    }
  }
}

还有两个细节,原来的函数有flush,这个函数是干嘛的,什么时候调用呢。

TextDecoder 是浏览器原生 API,用于将 二进制字节流(Uint8Array 解码为文本字符串,它有两种解码模式,对应两种调用方式:

带参数 + stream: true(流式解码,核心用法)

decoder.decode(chunk, { stream: true })
  • 作用分片、连续解码二进制流(比如 SSE / 文件流,数据是一块块传输的);
  • 关键特性:如果遇到不完整的多字节字符(比如中文、 emoji,UTF-8 占 3 字节),解码器不会强行解码,而是把残留字节缓存在解码器内部,等待下一块数据拼接完整后再解码;
  • 不会出现乱码,专门处理流式分片数据。

不带参数(刷新 / 收尾解码,最终调用)

decoder.decode()
  • 官方定义结束流式解码,刷新解码器内部的所有残留字节
  • 作用:把之前流式解码时缓存的未完成字节,一次性全部解码成字符串;
  • 副作用:清空解码器的内部缓冲区,标志着整个解码流程结束。 SSE 流是分块传输的,当服务器关闭流、传输彻底结束时,会存在两个残留数据问题
  1. 解码器内部残留TextDecoderstream: true 缓存了不完整的多字节字符,没有新 chunk 了,必须手动调用 decode() 刷新;
  2. 缓冲区残留:闭包中的 buffer 可能还剩最后一行不完整文本,没有后续换行符触发处理。

如果不调用 flush(),这部分数据会直接丢失flush()流传输结束后的收尾方法,专门处理最后残留的、未被解码 / 未被处理的数据,防止数据丢失。

还有一个细节,reader最后注意释放, cancel主动终止整个可读流(ReadableStream)。调用后,流会被关闭,后续无法再从该流读取任何数据,并且任何挂起的读请求都会立即完成或失败。 releaseLock释放当前读取器对流的锁定,但不关闭流。调用后,该读取器不再关联任何流,流可以被其他读取器(或异步迭代器)再次锁定。

比如

// 假设已经用 reader 读了一部分
const firstChunk = await reader.read();
// 处理 firstChunk...

// 释放锁,然后才能用 for await 或 XStream
reader.releaseLock();

// 现在可以安全地使用 for await
for await (const chunk of response.body) {
  // ...
}

如果中间没有释放,机会报错,一个流被两个代码读取了。

再补充一点,mdn对于流的读取还提到了这样一个例子:

const response = await fetch("https://www.example.org");
let total = 0;

// Iterate response.body (a ReadableStream) asynchronously
for await (const chunk of response.body) {
  // Do something with each chunk
  // Here we just accumulate the size of the response.
  total += chunk.length;
}

// Do something with the total
console.log(total);

for await...of 本质上就是异步迭代的语法糖,它与手动 while 循环实现的是完全相同的流式处理效果。ReadableStream2024年开始逐步实现了异步可迭代协议。Chrome 124(2024年4月稳定版)正式增加了对ReadableStream的异步迭代支持。区别在于代码风格:for await...of 更简洁、更符合直觉,而手动循环则暴露了更多的底层控制细节。 手动 while 循环通常是这样写的:

const reader = stream.getReader();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  // 处理 value
}

现在还可以采用更加优雅的方式。

if (stream[Symbol.asyncIterator]) {
  for await (const chunk of stream) {
    const shouldContinue = processor.processChunk(chunk);
    // 如果接收到 done 信号,继续消耗流直到结束
    if (!shouldContinue ) {
      break;
    }
  }
} else {
  throw new Error("浏览器不支持 for await...of");
}

4. XStream 整体架构与逻辑链路

根据官网,@ant-design/x-sdk 提供了一系列的工具API,旨在帮助开发人员开箱即用的管理AI对话应用数据流,其中的XStream用于转换可读数据流。先看看用法

import { XStream } from '@ant-design/x';

async function request() {
  const response = await fetch();
  // .....

  for await (const chunk of XStream({
    readableStream: response.body,
  })) {
    console.log(chunk);
  }
}

是不是跟上面的很像,其实就是做了进一步的底层封装和优化,逻辑都差不多,下方看看怎么实现的。 源代码

4.1 数据流转图

服务端 HTTP 响应
        │
        ▼
ReadableStream<Uint8Array>          ← 原始字节流(TCP 数据包)
        │
        │  .pipeThrough(decoderStream)
        ▼
ReadableStream<string>              ← UTF-8 文本流(可能在字符中间截断)
        │
        │  .pipeThrough(splitStream('\n\n'))
        ▼
ReadableStream<string>              ← SSE 事件字符串流(按 \n\n 分割)
        │
        │  .pipeThrough(splitPart('\n', ':'))
        ▼
ReadableStream<SSEOutput>           ← 结构化 SSE 对象流
        │
        │  stream[Symbol.asyncIterator]
        ▼
AsyncGenerator<SSEOutput>           ← 支持 for await...of 消费

4.2 实际数据转换示例

输入(Uint8Array 字节):
[101, 118, 101, 110, 116, 58, 32, 100, 101, 108, 116, 97, ...]

─── decoderStream ───→

输入(string,可能不完整):
"event: delta\ndata: {\"con"    chunk 1
"tent\":\"你好\"}\n\n"           chunk 2

─── splitStream('\n\n') ───→

输出(完整事件,按 \n\n 分割):
"event: delta\ndata: {\"content\":\"你好\"}"

─── splitPart('\n', ':') ───→

输出(SSEOutput 对象):
{
  event: "delta",
  data: "{\"content\":\"你好\"}"
}

5. 核心 API 讲解

5.1 ReadableStream

ReadableStream 是浏览器中表示"可读数据流"的原生 API。

// 创建一个自定义 ReadableStream
const stream = new ReadableStream<string>({
  start(controller) {
    // 流启动时调用
    controller.enqueue('Hello');   // 推入数据
    controller.enqueue(' World');
    controller.close();            // 关闭流
  },
  cancel(reason) {
    // 消费者取消时调用
    console.log('流被取消:', reason);
  }
});

// 消费方式1: getReader()
const reader = stream.getReader();
const { done, value } = await reader.read();  // { done: false, value: 'Hello' }
reader.releaseLock();

// 消费方式2: for await...of(需要 asyncIterator 支持)
for await (const chunk of stream) {
  console.log(chunk);  // 'Hello', ' World'
}

关键特性

  • 流只能被读取一次(single-consumer)
  • 一次只能有一个 reader
  • enqueue 将数据推入内部队列
  • close 发出 done 信号

5.2 TransformStream

TransformStream 是可读流和可写流的组合,专门用来转换数据

// 基本结构
const transform = new TransformStream<Input, Output>({
  transform(chunk, controller) {
    // 每次有 chunk 时调用
    // controller.enqueue() 将转换后的数据推入输出流
    // controller.error() 报错
  },
  flush(controller) {
    // 流结束时调用,处理剩余缓冲数据
    // 对应 TextDecoder 的 decode() 无参版本
  },
  start(controller) {
    // 初始化时调用(可选)
  }
});

// 使用示例:大写转换器
const upperCaseTransform = new TransformStream<string, string>({
  transform(chunk, controller) {
    controller.enqueue(chunk.toUpperCase());
  }
});

// 消费
const writer = upperCaseTransform.writable.getWriter();
const reader = upperCaseTransform.readable.getReader();

writer.write('hello');
const { value } = await reader.read();  // 'HELLO'

与 上文 中 createSSELineProcessor 的对比:

// sse.ts:手动管理状态的处理器(命令式)
const createSSELineProcessor = () => {
  let buffer = '';
  const decoder = new TextDecoder();

  return {
    processChunk(chunk: Uint8Array): boolean {
      buffer += decoder.decode(chunk, { stream: true });
      // ... 手动处理 buffer 和行分割
      return true;
    },
    flush(): void {
      // 手动处理剩余数据
    }
  };
};

// XStream:使用 TransformStream(声明式,职责更清晰)
new TransformStream<string, string>({
  transform(streamChunk, controller) {
    // 只关注转换逻辑,不关注如何读写流
    buffer += streamChunk;
    // ...
  },
  flush(controller) {
    // 框架自动调用,无需手动触发
  }
});

5.3 pipeThrough

pipeThrough 将一个 ReadableStream 接入 TransformStream,返回新的 ReadableStream。

// 基本用法
const outputStream = inputStream.pipeThrough(transformStream);

// 链式调用(XStream 的精华)
const processedStream = rawStream
  .pipeThrough(decoderStream)     // Uint8Array → string
  .pipeThrough(splitStream())     // string → SSE事件块
  .pipeThrough(splitPart());      // SSE事件块 → {event, data}

// 类比:Linux 管道
// cat file.txt | grep "error" | awk '{print $1}'
//       ↓              ↓              ↓
// ReadableStream  pipeThrough    pipeThrough

关键特性

  • 自动处理背压(backpressure):下游消费慢时,自动暂停上游写入
  • 惰性求值:不消费就不处理
  • 返回新的 ReadableStream,原始流不可再用
// pipeThrough 内部等价于:
readable
  .pipeTo(transform.writable)   // 原流连接到 transform 的可写端
  .then(/* 完成 */);
return transform.readable;      // 返回 transform 的可读端

5.4 TextDecoderStream

专门用于字节→字符串转换的原生 TransformStream。

// 原生 TextDecoderStream(现代浏览器支持)
const nativeDecoder = new TextDecoderStream('utf-8');
// 等价于:
const polyfillDecoder = new TransformStream({
  transform(chunk, controller) {
    controller.enqueue(decoder.decode(chunk, { stream: true }));
  },
  flush(controller) {
    controller.enqueue(decoder.decode());  // 处理剩余字节
  }
});

// 使用场景:正确处理被切断的多字节字符
const chineseChar = '你';  // UTF-8: [E4 BD A0](3字节)

// 不用 stream: true 的问题:
decode([0xE4])              // 错误:'?'(字节不完整)
decode([0xBD, 0xA0])        // 错误:'??'

// 用 stream: true 的正确处理:
decode([0xE4], { stream: true })        // ''(等待后续字节)
decode([0xBD, 0xA0], { stream: true })  // '你'(完整输出)

createDecoderStream 的兼容处理

function createDecoderStream() {
  // 优先使用原生 API(性能更好)
  if (typeof TextDecoderStream !== 'undefined') {
    return new TextDecoderStream();
  }
  // 降级到 polyfill(Safari 旧版等不支持的环境)
  const decoder = new TextDecoder('utf-8');
  return new TransformStream({ /* ... */ });
}

5.5 Symbol.asyncIterator

让对象支持 for await...of 语法。

// 基本用法
const asyncIterable = {
  [Symbol.asyncIterator]: async function*() {
    yield 1;
    yield 2;
    yield 3;
  }
};

for await (const value of asyncIterable) {
  console.log(value);  // 1, 2, 3
}

// XStream 的实现:给 ReadableStream 附加异步迭代器
stream[Symbol.asyncIterator] = async function*() {
  const reader = this.getReader();  // this = stream
  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    if (!value) continue;
    yield value;  // 每次 yield 一个已转换好的 SSEOutput
  }
};

// 使用效果:
const xStream = XStream({ readableStream: response.body });
for await (const event of xStream) {
  console.log(event);  // { event: 'delta', data: '{"content":"你好"}' }
}

6. XStream 各函数详解

6.1 splitStream:SSE 事件块分割器

function splitStream(streamSeparator = '\n\n') {
  let buffer = '';  // 闭包维持跨 chunk 状态

  return new TransformStream<string, string>({
    transform(streamChunk, controller) {
      buffer += streamChunk;
      const parts = buffer.split(streamSeparator);  // 按 \n\n 分割
      
      parts.slice(0, -1).forEach(part => {
        if (isValidString(part)) controller.enqueue(part);  // 推出完整事件
      });
      
      buffer = parts[parts.length - 1];  // 保留最后不完整的部分
    },
    flush(controller) {
      if (isValidString(buffer)) controller.enqueue(buffer);  // EOF 时推出最后数据
    }
  });
}

执行过程追踪

输入 chunk 1: "event: delta\ndata: {\"con"
  buffer = "event: delta\ndata: {\"con"
  parts = ["event: delta\ndata: {\"con"]    只有1部分,无完整事件
  enqueue: 无
  buffer = "event: delta\ndata: {\"con"

输入 chunk 2: "tent\":\"\"}\n\nevent: de"
  buffer = "event: delta\ndata: {\"content\":\"\"}\n\nevent: de"
  parts = [
    "event: delta\ndata: {\"content\":\"\"}",   完整事件!
    "event: de"                                   不完整
  ]
  enqueue: "event: delta\ndata: {\"content\":\"\"}"  
  buffer = "event: de"

flush 时:
  buffer = "event: de"(如有剩余)
  enqueue: "event: de"

6.2 splitPart:键值对解析器 {#splitpart}

function splitPart(partSeparator = '\n', kvSeparator = ':') {
  return new TransformStream<string, SSEOutput>({
    transform(partChunk, controller) {
      // 输入: "event: delta\ndata: {\"content\":\"你好\"}"
      const lines = partChunk.split(partSeparator);

      const sseEvent = lines.reduce<SSEOutput>((acc, line) => {
        const separatorIndex = line.indexOf(kvSeparator);  // 找第一个 ':'
        if (separatorIndex === -1) return acc;

        const key = line.slice(0, separatorIndex).trim();
        if (!isValidString(key)) return acc;  // 跳过注释行(: 开头)

        const value = line.slice(separatorIndex + 1).trim();
        return { ...acc, [key]: value };
      }, {});

      if (Object.keys(sseEvent).length === 0) return;
      controller.enqueue(sseEvent);
    }
  });
}

细节:为什么用 indexOf 而不是 split(':')

// 假设 data 内容中包含冒号:
const line = 'data: {"url":"https://example.com"}';

// ❌ split(':') 会把 URL 中的冒号也切割
line.split(':')  // ['data', ' {"url"', '"https', '//example.com"}']

// ✅ indexOf 只找第一个冒号
const i = line.indexOf(':');  // 4
const key = line.slice(0, 4).trim();   // 'data'
const value = line.slice(5).trim();    // '{"url":"https://example.com"}'

执行过程追踪

输入: "event: delta\ndata: {\"content\":\"你好\"}"

lines = [
  "event: delta",
  "data: {\"content\":\"你好\"}"
]

reduce 过程:
  line "event: delta":
    separatorIndex = 5
    key = "event"
    value = "delta"
    acc = { event: "delta" }

  line "data: {\"content\":\"你好\"}":
    separatorIndex = 4
    key = "data"
    value = "{\"content\":\"你好\"}"
    acc = { event: "delta", data: "{\"content\":\"你好\"}" }

enqueue: { event: "delta", data: "{\"content\":\"你好\"}" }

6.3 createDecoderStream:兼容性解码器 {#createdecoderstream}

function createDecoderStream() {
  // 优先使用原生 API
  if (typeof TextDecoderStream !== 'undefined') {
    return new TextDecoderStream();
  }

  // 降级 polyfill:手动实现相同逻辑
  const decoder = new TextDecoder('utf-8');
  return new TransformStream({
    transform(chunk, controller) {
      controller.enqueue(decoder.decode(chunk, { stream: true }));
    },
    flush(controller) {
      controller.enqueue(decoder.decode());  // 确保最后字节被正确输出
    },
  });
}

兼容性说明:

  • TextDecoderStream:Chrome 71+, Firefox 105+, Safari 14.1+
  • polyfill:理论上覆盖所有支持 TransformStream 的浏览器

6.4 XStream 主函数 {#xstream-main}

function XStream<Output = SSEOutput>(options: XStreamOptions<Output>) {
  const { readableStream, transformStream, streamSeparator, partSeparator, kvSeparator } = options;

  const decoderStream = createDecoderStream();

  // 构建管道链(两种模式)
  const stream = (
    transformStream
      ? // 模式A:自定义 transformStream(用户完全自控解析逻辑)
        readableStream
          .pipeThrough(decoderStream)      // Uint8Array → string
          .pipeThrough(transformStream)    // string → Output(用户定义)
      : // 模式B:默认 SSE 解析(三段式管道)
        readableStream
          .pipeThrough(decoderStream)      // Uint8Array → string
          .pipeThrough(splitStream(...))   // string → SSE事件块
          .pipeThrough(splitPart(...))     // SSE事件块 → SSEOutput
  ) as XReadableStream<Output>;

  // 给流对象附加 AsyncIterator,让其支持 for await...of
  stream[Symbol.asyncIterator] = async function*() {
    const reader = this.getReader();
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;
      if (!value) continue;
      yield value;
    }
  };

  return stream;
}

自定义 TransformStream 的场景

// 场景:服务端返回 JSON Lines 格式而非标准 SSE
// {"content":"Hello","type":"text"}\n
// {"content":"World","type":"text"}\n

const jsonLinesTransform = new TransformStream<string, MyOutput>({
  transform(chunk, controller) {
    // 手动处理每行 JSON
    chunk.split('\n').filter(Boolean).forEach(line => {
      try {
        controller.enqueue(JSON.parse(line));
      } catch {}
    });
  }
});

const stream = XStream({
  readableStream: response.body,
  transformStream: jsonLinesTransform,  // 替换默认的 SSE 解析管道
});

7. 与 上文实现 的对比分析

7.1 架构对比

自己实现 (命令式)                    XStream (声明式/管道式)
─────────────────────────           ──────────────────────────
connectSSE()                        XStream()
  │                                   │
  ├─ EventSource (高层API)             ├─ ReadableStream (底层)
  │   └─ 自动处理SSE协议               │
  │                                   │  .pipeThrough(decoderStream)
  └─ connectSSEWithFetch()            │  .pipeThrough(splitStream)
      │                               │  .pipeThrough(splitPart)
      ├─ fetch()                      │
      ├─ createSSELineProcessor()     │  for await...of
      │   ├─ buffer (闭包)            │
      │   ├─ decoder (闭包)           └─ 返回流对象(延迟消费)
      │   ├─ processChunk()
      │   └─ flush()
      └─ readStream()

7.2 数据处理对比

自己实现(命令式,紧耦合业务逻辑):

// 解析和业务回调混在一起
const processChunk = (chunk: Uint8Array) => {
  buffer += decoder.decode(chunk, { stream: true });
  const lines = buffer.split('\n');
  buffer = lines.pop() || '';

  for (const line of lines) {
    if (!line.trim()) continue;
    const data = parseSSELine(line);
    if (!data) continue;

    if (data.done) {
      options.onDone();    // ← 业务逻辑耦合在解析中
      return false;
    }
    options.onChunk(data.content);  // ← 业务回调耦合在解析中
  }
};

XStream(声明式,关注点分离):

// splitStream 只关心按 \n\n 分割
new TransformStream({ transform(chunk) { /* 只分割 */ } })

// splitPart 只关心解析键值对
new TransformStream({ transform(chunk) { /* 只解析 */ } })

// 业务逻辑完全在消费侧
for await (const event of stream) {
  if (event.data) {
    const data = JSON.parse(event.data);
    if (data.done) onDone();
    else onChunk(data.content);
  }
}

7.3 buffer 处理对比

// sse.ts:手动管理 buffer(需要理解流式处理细节)
const createSSELineProcessor = () => {
  let buffer = '';                          // 跨 chunk 维持
  const decoder = new TextDecoder();        // 手动创建

  return {
    processChunk(chunk: Uint8Array) {
      buffer += decoder.decode(chunk, { stream: true });   // 手动解码
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';           // 手动保留最后行
      // ...
    },
    flush() {
      decoder.decode();                     // 手动 flush
    }
  };
};

// XStream:TransformStream 框架自动处理 flush
new TransformStream({
  transform(chunk, controller) {
    buffer += chunk;
    // ...buffer 处理...
  },
  flush(controller) {
    // 框架在流结束时自动调用这个方法,不需要手动触发
    if (buffer) controller.enqueue(buffer);
  }
});

7.4 主要差异表

维度 自己实现 XStream
设计思想 命令式,手动控制流程 声明式,管道组合
关注点分离 解析与业务耦合 每层只做一件事
可组合性 低,硬编码解析逻辑 高,可替换任意管道环节
可测试性 需模拟整个连接 每个 TransformStream 可单独测试
背压处理 手动/无 pipeThrough 自动处理
重连逻辑 内置 不含(需外部处理)
格式灵活性 固定 SSE 格式 支持自定义 transformStream
学习曲线 低,容易理解 较高,需理解流 API
flush 触发 手动调用 框架自动调用

7.5 联系:相同的核心解题思路

两者都解决了同一个核心问题:TCP 数据包不按照 SSE 事件边界切割

同一 buffer 策略:

sse.ts:                          XStream:
─────────────────                ─────────────────────────────
buffer = ''                      let buffer = ''   (在 splitStream 中)
buffer += decode(chunk)          buffer += streamChunk
lines = buffer.split('\n')       parts = buffer.split('\n\n')
buffer = lines.pop()             buffer = parts[parts.length - 1]

7.6 结合使用建议

实际上,可以把 XStream 用于 sse.ts 中:

// 用 XStream 替代 createSSELineProcessor
export const connectSSEWithFetch = (message, options) => {
  // ... 重连逻辑保留 ...

  const connect = async () => {
    const response = await fetch(url, { signal: abortController.signal });

    // ✅ 用 XStream 处理解析,用 sse.ts 处理重连
    const stream = XStream({ readableStream: response.body });

    for await (const event of stream) {
      if (!event.data) continue;
      const data = JSON.parse(event.data);

      if (data.done) {
        options.onDone();
        closeConnection();
        break;
      }
      options.onChunk(data.content);
    }
  };
};

8. 总结

8.1 流 API 知识体系

浏览器 Streams API 体系:

ReadableStream              可读流(数据来源)
  ├─ .getReader()           获取 reader(锁定流)
  │   ├─ .read()            逐块读取
  │   └─ .releaseLock()     释放锁
  ├─ .pipeThrough(ts)       接入 TransformStream
  └─ .pipeTo(ws)            接入 WritableStream

TransformStream<I, O>       转换流(数据中转站)
  ├─ .readable              可读端(输出侧)
  ├─ .writable              可写端(输入侧)
  └─ new TransformStream({
       transform(chunk, ctrl) { ctrl.enqueue(data) }
       flush(ctrl)            { /* EOF 处理 */ }
     })

WritableStream               可写流(数据消费者)

管道链:
ReadableStream
  .pipeThrough(TransformStream1)   → ReadableStream
  .pipeThrough(TransformStream2)   → ReadableStream
  .pipeTo(WritableStream)          → Promise<void>

8.2 XStream 管道链的优雅之处

// 每一层只做一件事,清晰可维护

readableStream                         // Uint8Array(二进制)
  .pipeThrough(createDecoderStream())  // → string(解码)
  .pipeThrough(splitStream('\n\n'))    // → string(按事件分割)
  .pipeThrough(splitPart('\n', ':'))   // → SSEOutput(解析键值)

// 想换成 JSON Lines 格式?只替换最后两层
  .pipeThrough(jsonLinesTransform)

// 想加压缩?在最前面加一层
readableStream
  .pipeThrough(decompressionStream)    // → Uint8Array(解压)
  .pipeThrough(createDecoderStream())
  // ...

8.3 快速参考

// ① 创建最简单的 XStream 消费
const stream = XStream({ readableStream: response.body });
for await (const event of stream) {
  console.log(event);  // { event: 'delta', data: '...' }
}

// ② 自定义格式
const stream = XStream({
  readableStream: response.body,
  transformStream: myCustomTransform,
});

// ③ 自定义分隔符
const stream = XStream({
  readableStream: response.body,
  streamSeparator: '\r\n\r\n',  // Windows 换行
  kvSeparator: '=',             // key=value 格式
});

// ④ 使用 splitStream/splitPart 单独测试
const splitTransform = splitStream('\n\n');
const writer = splitTransform.writable.getWriter();
const reader = splitTransform.readable.getReader();

writer.write("event: test\n\n");
const { value } = await reader.read();  // "event: test"

9. 感想

其实一步步做下来会发现,sse看起来只是一个协议,但是涉及到的前端知识挺多的,从浏览器自己封装的EventSource接口再到自己基于fetch封装更加灵活的接口,再到xStream的源码实现,其中有许多流的相关概念,同时还涉及到了闭包、断连重试等JS基础,总而言之,好好看看还是有不少收获的。

React Native 物理按键扫码监听终极方案:从冲突到完美共存

React Native 物理按键扫码监听终极方案:从冲突到完美共存

写给所有被 PDA 扫码折磨的开发者,以及未来的自己。

如果你正在开发 PDA(手持终端)应用,并且遇到了“全局监听和页面监听打架”、“扫码结果在这个页面能收到,在那个页面就收不到”的问题,那么这篇文章就是为你准备的。

1. 遇到的问题

在开发仓库管理系统(WMS)或类似 PDA 应用时,物理扫码键是最常用的交互方式。我们通常会有两种需求:

  1. 全局监听:不管在哪个页面,我都要记录扫码历史,或者做一些全局的日志记录。
  2. 页面监听:在具体的业务页面(比如入库单、盘点单),我需要拿到扫码结果去请求接口、查询商品。

最初的痛点: 当我们使用原生的 DeviceEventEmitter 或者简单的封装时,往往会遇到“单播”的尴尬——一旦我在具体的业务页面开始监听扫码,全局的那个监听器就被“顶”掉了,失效了;或者反过来,全局监听器把事件拦截了,业务页面收不到。

2. 解决方案的核心思想:多播(Multicast)

要解决这个问题,我们需要一个“中间人”(Manager)。

  • 以前的模式(单播):原生事件 -> Manager -> 唯一的监听者(谁最后注册谁就赢)。
  • 现在的模式(多播):原生事件 -> Manager -> 监听者列表(Set) -> 分发给所有注册的人。

这样,无论是全局的 Context,还是具体的页面组件,只要向 Manager 注册了,大家都能收到通知,互不干扰!

3. 代码实现全解析

3.1 底层管理者:PhysicalKeyScanManager

这是最核心的部分。它负责跟原生模块打交道,并维护一个监听者列表。

关键点:

  • 使用 Set 来存储回调函数,自动去重。
  • startListening 不再覆盖旧的回调,而是 add 进列表。
  • stopListening 只移除指定的回调,而不是清空所有。
// src/utils/PhysicalKeyScanManager.js

class PhysicalKeyScanManager {
  constructor() {
    // ...
    this.listeners = new Set(); // 核心:存放所有监听者的集合
    // ...
  }

  // 收到原生事件后的处理
  _handleScanResult = (result) => {
    // ... 包装数据 ...
  
    // 核心:遍历列表,人人有份
    this.listeners.forEach(callback => {
        if (callback) callback(scanData);
    });
  };

  startListening(callback) {
    // 1. 把新来的监听者加入集合
    if (callback) this.listeners.add(callback);

    // 2. 如果是第一个监听者,才真正去建立原生连接(省资源)
    if (!this.scanSubscription) {
      this.scanSubscription = this.scanEventEmitter.addListener(
        'onScanResult',
        this._handleScanResult
      );
    }
  
    // 3. 返回一个取消函数,方便 useEffect 清理
    return () => this.stopListening(callback);
  }

  stopListening(callback) {
    // 1. 只移除这一个监听者
    if (callback) this.listeners.delete(callback);

    // 2. 如果人走茶凉(列表空了),就把原生连接也断了
    if (this.listeners.size === 0 && this.scanSubscription) {
      this.scanSubscription.remove();
      this.scanSubscription = null;
    }
  }
}

3.2 全局大管家:ScanContext

我们在 App 的最顶层(App.js)包裹这个 Provider。它的作用是从 App 启动那一刻起,就占一个坑位

它负责:

  • 初始化扫码服务(autoInit)。
  • 记录所有的扫码历史(history)。
  • 提供全局状态。
// src/context/ScanContext.js

useEffect(() => {
  physicalKeyScanManager.autoInit();

  // 注册全局监听,因为 Manager 支持多播,这里注册了也不会影响别的页面
  const unsubscribe = physicalKeyScanManager.startListening(result => {
    console.log('[全局记录] 收到扫码:', result.code);
    setHistory(prev => [result, ...prev]);
  });

  return () => unsubscribe();
}, []);

3.3 页面级的 Hook:usePhysicalKeyScan

这是给普通业务页面用的。它的特点是智能管理生命周期

  • 页面获得焦点时:自动开始监听。
  • 页面失去焦点时:自动停止监听。

这样能保证用户不在当前页面时,不会意外触发当前页面的逻辑。

注意:为了防止 React Hooks 的闭包陷阱导致监听器重复注册(出现收一次码打印两次日志的 Bug),我们在实现时使用了局部变量锁定的技巧,确保清理函数总是清理当前周期创建的那个监听器。

// src/hooks/usePhysicalKeyScan.js

useFocusEffect(
  useCallback(() => {
    let unsubscribe = null; // 局部变量,锁定当前周期的监听器

    if (autoStart) {
      // 页面来了,注册监听,并赋值给局部变量
      unsubscribe = physicalKeyScanManager.startListening(handleScanResult);
      // 同步到 ref 供外部(如卸载时)使用
      unsubscribeRef.current = unsubscribe;
      // ...
    }

    return () => {
      // 页面走了,使用局部变量进行清理,精准打击
      if (unsubscribe) {
        unsubscribe();
      }
      // ...
    };
  }, [autoStart])
);

3.4 进阶 Hook:useContextualPhysicalKeyScan

这是最强大的 Hook,专门解决**“我要把这个码扫给谁?”**的问题。

比如在一个物料列表中,点击某一行,然后扫码,把条码填入该行。

  • setContext(item):设置当前正在操作的对象(上下文)。
  • onScan(result, context):回调里会把当时的上下文带回来给你。
// src/hooks/useContextualPhysicalKeyScan.js

const setContext = useCallback((context) => {
  contextRef.current = context; // 存起来
  // 可以设置个超时,比如30秒后自动清除,防止误操作
}, []);

const handleScanResult = useCallback((result) => {
  // 触发回调时,把上下文也传出去
  onScan(result, contextRef.current);
}, []);

4. 如何使用?(小白看这里)

场景一:我就想在页面里拿扫码结果

直接用 usePhysicalKeyScan

import usePhysicalKeyScan from '@/hooks/usePhysicalKeyScan';

const MyPage = () => {
  usePhysicalKeyScan({
    onScan: (result) => {
      alert(`扫到了:${result.code}`);
      // 这里调用接口查询...
    }
  });

  return <View>...</View>;
};

场景二:我有好几个输入框/列表项,我要区分扫给谁

useContextualPhysicalKeyScan

import useContextualPhysicalKeyScan from '@/hooks/useContextualPhysicalKeyScan';

const ListPage = () => {
  const { setContext } = useContextualPhysicalKeyScan({
    onScan: (result, context) => {
      if (context) {
        console.log(`把条码 ${result.code} 赋值给商品 ${context.name}`);
        // 更新列表数据...
      } else {
        console.log('没选中商品,扫码无效或作为通用查询');
      }
    }
  });

  return (
    <View>
      {items.map(item => (
        <TouchableOpacity 
          key={item.id} 
          onPress={() => setContext(item)} // 点击选中,告诉 Hook “接下来扫码是给它的”
        >
          <Text>{item.name}</Text>
        </TouchableOpacity>
      ))}
    </View>
  );
};

5. 总结

通过改造 PhysicalKeyScanManager 为多播模式,我们完美实现了:

  1. 全局不掉线:ScanContext 里的历史记录永远在记录。
  2. 页面互不扰:A 页面监听扫码,不会影响 B 页面;离开 A 页面自动停止监听。
  3. 上下文可追踪:清楚地知道当前这一次扫码是为了哪个业务对象。

这就是 PDA 物理按键扫码的“终极解决方案”。🚀

主动取消的防抖

支持主动取消的防抖:两种 API 设计对比(写法一 vs 写法二)

本文对比两种「可取消防抖」的封装方式:Lodash 风格(单函数 + .cancel)与 双方法返回({ run, cancel }),并给出实现与选型建议。

一、为什么需要「可取消」的防抖?

防抖(debounce)大家都很熟:在连续触发时只执行最后一次。但有一种场景,仅「延迟执行」不够,还需要主动取消

  • 输入校验 + 异步请求:用户输入金额 → 400ms 防抖后请求「计算手续费」。若用户在这 400ms 内把金额删成 0 或改成非法值,我们希望在校验失败时取消这次待执行的请求,而不是等 400ms 后仍用旧值或 0 去请求接口。

此时就需要:在错误分支里主动取消防抖,避免无效请求。
VueUse 的 useDebounceFn 没有暴露 .cancel(),所以我们可以自己封装一个「支持取消」的防抖,并在设计 API 时面临两种风格:写法一(单函数 + .cancel)写法二(返回两个方法)


二、写法一:Lodash 风格 —— 一个函数 + .cancel

思路

返回一个函数,既可正常调用(触发防抖),又挂载 .cancel() 方法,用于取消当前等待中的执行。和 Lodash 的 debounce 返回的 API 一致。

实现

/**
 * 类 lodash 的防抖,支持主动取消防抖(.cancel())
 * @param fn 要防抖的函数
 * @param delay 延迟毫秒数
 * @returns 防抖后的函数,带 .cancel() 方法
 */
export function useDebounceWithCancel<T extends (...args: any[]) => any>(
  fn: T,
  delay: number,
): ((...args: Parameters<T>) => void) & { cancel: () => void } {
  let timer: ReturnType<typeof setTimeout> | null = null;

  function cancel() {
    if (timer !== null) {
      clearTimeout(timer);
      timer = null;
    }
  }

  function run(...args: Parameters<T>) {
    cancel();
    timer = setTimeout(() => {
      fn(...args);
      timer = null;
    }, delay);
  }

  run.cancel = cancel;
  return run;
}

使用方式

const debouncedCalculateFee = useDebounceWithCancel(calculateFee, 400);

// 触发防抖
debouncedCalculateFee();

// 在错误分支等场景下主动取消
debouncedCalculateFee.cancel();

特点

优点 缺点
只维护一个变量,心智负担小 类型要写交叉类型 Fn & { cancel: () => void }
与 Lodash / 社区常见 API 一致 有人不习惯「函数上挂方法」
便于传递:把「防抖函数」当整体传参时,对方也能 .cancel()

三、写法二:返回两个方法 —— { run, cancel }

思路

不返回「带属性的函数」,而是直接返回两个方法:一个负责触发防抖(run),一个负责取消(cancel)。职责分离,一眼能看出是两个能力。

实现

/**
 * 防抖,支持主动取消。返回两个方法:触发防抖 / 取消防抖
 */
export function useDebounceWithCancel<T extends (...args: any[]) => any>(
  fn: T,
  delay: number,
): { run: (...args: Parameters<T>) => void; cancel: () => void } {
  let timer: ReturnType<typeof setTimeout> | null = null;

  function cancel() {
    if (timer !== null) {
      clearTimeout(timer);
      timer = null;
    }
  }

  function run(...args: Parameters<T>) {
    cancel();
    timer = setTimeout(() => {
      fn(...args);
      timer = null;
    }, delay);
  }

  return { run, cancel };
}

使用方式

const { run: debouncedCalculateFee, cancel: cancelCalculateFee } =
  useDebounceWithCancel(calculateFee, 400);

// 触发防抖
debouncedCalculateFee();

// 取消防抖
cancelCalculateFee();

特点

优点 缺点
「触发」和「取消」职责分离,语义清晰 需要维护两个名字(或解构时起别名)
类型简单,就是普通对象 { run, cancel } 与 Lodash 等单函数 + .cancel 的形态不一致
解构时可自由命名(如 run → debouncedCalculateFee) 若要把防抖「整体」传给子组件,需要传 run + cancel 两个
闭包逻辑与写法一完全一致

四、闭包:两种写法是同一套逻辑

无论写法一还是写法二,防抖和取消能生效,靠的都是闭包

  • runcancel 都在 useDebounceWithCancel 内部定义,共享同一份 timer(以及 fndelay)。
  • 多次调用 run() 会先 cancel() 再设新的 setTimeout,所以「只执行最后一次」。
  • 在任意时机调用 cancel()(或 debouncedFn.cancel()),清掉的都是这一份 timer

所以:返回一个函数再挂 .cancel,还是返回 { run, cancel },闭包行为相同;差异只在 API 形态和调用方式。


五、对比与选型建议

维度 写法一(单函数 + .cancel) 写法二({ run, cancel })
变量数量 一个 两个(或解构出两个名字)
类型写法 交叉类型稍复杂 普通对象,简单
与 Lodash 一致性 一致 不一致
语义 「一个防抖函数,附带取消」 「两个独立能力」
传递/复用 传一个引用即可,对方可 .cancel() 需传 run + cancel

选型建议

  • 更看重和 Lodash / 社区习惯统一,或需要把「防抖」作为整体传递(如传给子组件、工具函数)→ 优先 写法一
  • 更看重职责分离、类型简单、命名灵活,且多在本组件内使用 → 写法二 也很合适。

没有绝对优劣,按团队习惯和具体场景选即可。


六、小结

  • 可取消防抖在「输入校验 + 延迟请求」场景里很实用,能避免无效请求。
  • 写法一:返回带 .cancel() 的单个函数,Lodash 风格,便于传递和统一心智。
  • 写法二:返回 { run, cancel },职责清晰,类型简单,闭包逻辑与写法一相同。
  • 两种写法都依赖同一套闭包(共享 timer),可按团队偏好和场景在两种 API 间选择。

如果你也在做表单防抖、搜索防抖或类似「延迟执行 + 需要取消」的逻辑,不妨试试自己封装一个「支持 cancel 的防抖」,并在这两种 API 风格里选一种落地到项目里。


Nginx 部署 Vue3 项目完整指南

Nginx 部署 Vue3 项目完整指南

本文档详细说明如何使用 Nginx 部署 Vue3 单页应用,包括本地开发环境和服务端生产环境的配置差异。


目录

  1. Nginx 基础知识
  2. 本地开发环境配置
  3. 服务器生产环境配置
  4. 本地与服务器的配置差异
  5. 完整部署流程
  6. 常用命令速查
  7. 常见问题排查

1. Nginx 基础知识

1.1 什么是 Nginx?

Nginx 是一个高性能的 HTTP 和反向代理服务器,也是一个 IMAP/POP3/SMTP 服务器。在 Web 开发中,主要用途:

  • 静态资源服务器:托管 HTML、CSS、JS、图片等静态文件
  • 反向代理:将请求转发到后端服务器
  • 负载均衡:将请求分发到多台服务器

1.2 Nginx 目录结构

nginx-1.24.0/
├── conf/               # 配置文件目录
│   ├── nginx.conf      # 主配置文件(最重要)
│   ├── mime.types      # 文件类型映射
│   └── ...
├── html/               # 默认静态文件目录
│   ├── index.html
│   └── 50x.html
├── logs/               # 日志目录
│   ├── access.log      # 访问日志
│   └── error.log       # 错误日志
├── temp/               # 临时文件目录
├── contrib/            # 扩展模块
├── docs/               # 文档
└── nginx.exe           # Windows 可执行文件

1.3 配置文件基本结构

# 全局块 - 影响 Nginx 整体运行
worker_processes  1;  # 工作进程数

# events 块 - 影响网络连接
events {
    worker_connections  1024;  # 每个进程最大连接数
}

# http 块 - Web 服务器配置
http {
    # http 全局配置
    include       mime.types;

    # server 块 - 虚拟主机配置
    server {
        listen       80;        # 监听端口
        server_name  localhost; # 域名/IP

        # location 块 - 路由匹配
        location / {
            root   html;        # 静态文件目录
            index  index.html;  # 默认首页
        }
    }
}

2. 本地开发环境配置

2.1 当前项目配置

配置文件位置C:\Users\EDY\Downloads\nginx-1.24.0\nginx-1.24.0\conf\nginx.conf

#user  nobody;
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;

    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       80;
        server_name  localhost;

        # 你的 Vue3 项目打包后的目录
        root   D:/test/vue3-h5/dist;
        index  index.html index.htm;

        location / {
            # Vue 是单页应用,所有路由都要返回 index.html
            try_files $uri $uri/ /index.html;
        }

        # 开启 gzip 压缩,加快加载速度
        gzip on;
        gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
        gzip_min_length 1000;

        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }
    }
}

2.2 配置详解

配置项 说明
listen 80 监听端口,本地访问用 localhost:80
server_name localhost 本地测试用 localhost
root D:/test/vue3-h5/dist 指向本地打包目录
try_files uriuri uri/ /index.html Vue SPA 路由支持
gzip on 开启压缩,提升加载速度

2.3 启动步骤

# 1. 进入 Nginx 目录
cd C:\Users\EDY\Downloads\nginx-1.24.0\nginx-1.24.0

# 2. 启动 Nginx
start nginx

# 3. 访问测试
# 浏览器打开 http://localhost

3. 服务器生产环境配置

3.1 完整的服务器配置

# 生产环境配置示例

# 根据 CPU 核心数设置工作进程
worker_processes  auto;

# 错误日志级别设为 warn
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    # 最大连接数,根据服务器配置调整
    worker_connections  2048;
    # 提高并发性能
    use epoll;
    multi_accept on;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # 日志格式
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    # 访问日志
    access_log  /var/log/nginx/access.log  main;

    # 性能优化
    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;
    keepalive_timeout  65;
    types_hash_max_size 2048;

    # 开启 gzip 压缩
    gzip  on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 1000;
    gzip_types
        text/plain
        text/css
        text/xml
        application/json
        application/javascript
        application/xml
        application/xml+rss
        text/javascript
        image/svg+xml;

    # 安全头配置
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    # 上传文件大小限制(如果需要)
    client_max_body_size 10M;

    # Vue 应用服务器配置
    server {
        listen       80;
        server_name  your-domain.com;  # 替换为你的域名

        # 静态文件目录(服务器上的路径)
        root   /var/www/vue3-h5/dist;
        index  index.html index.htm;

        # Vue Router history 模式支持
        location / {
            try_files $uri $uri/ /index.html;
        }

        # 静态资源缓存(js、css、图片等)
        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }

        # 禁止访问隐藏文件
        location ~ /\. {
            deny all;
        }

        # 错误页面
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/share/nginx/html;
        }
    }

    # HTTPS 配置(推荐使用)
    # server {
    #     listen       443 ssl http2;
    #     server_name  your-domain.com;
    #
    #     # SSL 证书配置
    #     ssl_certificate      /etc/nginx/ssl/your-domain.com.pem;
    #     ssl_certificate_key  /etc/nginx/ssl/your-domain.com.key;
    #
    #     # SSL 优化配置
    #     ssl_session_timeout  1d;
    #     ssl_session_cache    shared:SSL:50m;
    #     ssl_session_tickets  off;
    #
    #     # 现代 SSL 配置
    #     ssl_protocols TLSv1.2 TLSv1.3;
    #     ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
    #     ssl_prefer_server_ciphers  on;
    #
    #     # HSTS
    #     add_header Strict-Transport-Security "max-age=31536000" always;
    #
    #     root   /var/www/vue3-h5/dist;
    #     index  index.html;
    #
    #     location / {
    #         try_files $uri $uri/ /index.html;
    #     }
    # }

    # HTTP 自动跳转 HTTPS
    # server {
    #     listen 80;
    #     server_name your-domain.com;
    #     return 301 https://$server_name$request_uri;
    # }
}

3.2 反向代理配置(如果需要调用后端 API)

server {
    listen       80;
    server_name  your-domain.com;

    root   /var/www/vue3-h5/dist;
    index  index.html;

    # 前端路由
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 后端 API 代理
    location /api/ {
        proxy_pass http://backend-server:8080/;  # 后端服务地址
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # 超时设置
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
    }
}

4. 本地与服务器的配置差异

4.1 差异对比表

配置项 本地开发环境 服务器生产环境 说明
worker_processes 1 auto 生产环境根据 CPU 核心数自动设置
worker_connections 1024 2048+ 服务器并发要求更高
server_name localhost your-domain.com 本地用 localhost,服务器用域名
root 路径 D:/test/vue3-h5/dist /var/www/vue3-h5/dist Windows 和 Linux 路径格式不同
error_log 注释掉 /var/log/nginx/error.log warn 生产环境需要记录日志
access_log 注释掉 /var/log/nginx/access.log main 生产环境需要访问日志
gzip 基础配置 完整配置 生产环境需要更细致的压缩配置
缓存配置 生产环境需要静态资源缓存
安全头 生产环境需要安全防护
HTTPS 推荐 生产环境强烈推荐 HTTPS
反向代理 通常不需要 常需要 生产环境常需要代理后端 API

4.2 路径格式差异

Windows 本地路径

root   D:/test/vue3-h5/dist;    # 使用正斜杠 /
root   D:\\test\\vue3-h5\\dist;  # 或使用双反斜杠转义

Linux 服务器路径

root   /var/www/vue3-h5/dist;   # Linux 标准路径格式

4.3 域名配置差异

本地开发

server_name  localhost;          # 本机访问
# 或
server_name  127.0.0.1;         # 本机 IP

服务器生产

server_name  your-domain.com;    # 你的域名
server_name  www.your-domain.com; # 多个域名
server_name  192.168.1.100;      # 或直接用服务器 IP

4.4 端口配置差异

本地开发(80 端口可能被占用):

listen  8080;   # 如果 80 被占用,可以用其他端口

服务器生产

listen  80;     # HTTP 默认端口
listen  443 ssl http2;  # HTTPS 端口

5. 完整部署流程

5.1 本地部署步骤

# 1. 打包 Vue 项目
cd D:/test/vue3-h5
npm run build
# 打包后会生成 dist 目录

# 2. 修改 Nginx 配置
# 编辑 C:\Users\EDY\Downloads\nginx-1.24.0\nginx-1.24.0\conf\nginx.conf
# 设置 root 指向 dist 目录

# 3. 启动 Nginx
cd C:\Users\EDY\Downloads\nginx-1.24.0\nginx-1.24.0
start nginx

# 4. 访问测试
# 浏览器打开 http://localhost

5.2 服务器部署步骤

第一步:准备服务器环境
# 以 Ubuntu/Debian 为例
# 1. 更新系统
sudo apt update && sudo apt upgrade -y

# 2. 安装 Nginx
sudo apt install nginx -y

# 3. 检查 Nginx 状态
sudo systemctl status nginx

# 4. 设置开机自启
sudo systemctl enable nginx
第二步:上传打包文件
# 方式一:使用 scp 上传
scp -r D:/test/vue3-h5/dist user@server-ip:/var/www/vue3-h5/

# 方式二:使用 FTP 工具(如 FileZilla)上传

# 方式三:在服务器上直接打包
# 先上传源码,在服务器上运行 npm run build
第三步:配置 Nginx
# 1. 创建配置文件
sudo nano /etc/nginx/sites-available/vue3-h5

# 2. 写入配置内容(参考上面的生产环境配置)

# 3. 创建软链接启用配置
sudo ln -s /etc/nginx/sites-available/vue3-h5 /etc/nginx/sites-enabled/

# 4. 测试配置是否正确
sudo nginx -t

# 5. 重载 Nginx
sudo systemctl reload nginx
第四步:配置域名(如果有)
# 1. 在域名服务商处添加 DNS 解析
#    类型: A
#    主机: @
#    值: 服务器 IP

# 2. 等待 DNS 生效(几分钟到几小时)

# 3. 测试访问
curl -I http://your-domain.com
第五步:配置 HTTPS(推荐)
# 使用 Let's Encrypt 免费证书

# 1. 安装 Certbot
sudo apt install certbot python3-certbot-nginx -y

# 2. 自动配置 HTTPS
sudo certbot --nginx -d your-domain.com -d www.your-domain.com

# 3. 测试自动续期
sudo certbot renew --dry-run

# Certbot 会自动修改 Nginx 配置,添加 SSL 相关配置

5.3 部署检查清单

  • 项目已打包(npm run build)
  • dist 目录已上传到服务器
  • Nginx 已安装并运行
  • Nginx 配置文件已正确设置
  • root 路径指向正确的 dist 目录
  • server_name 已设置正确的域名/IP
  • 防火墙已开放 80/443 端口
  • 域名 DNS 已解析到服务器
  • HTTPS 证书已配置(推荐)
  • 网站可以正常访问

6. 常用命令速查

6.1 Windows 本地命令

# 进入 Nginx 目录
cd C:\Users\EDY\Downloads\nginx-1.24.0\nginx-1.24.0

# 启动 Nginx
start nginx

# 停止 Nginx
nginx -s stop          # 快速停止
nginx -s quit          # 优雅停止(处理完当前请求)

# 重载配置(修改配置后)
nginx -s reload

# 重新打开日志文件
nginx -s reopen

# 测试配置文件语法
nginx -t

# 查看 Nginx 版本
nginx -v

# 查看 Nginx 进程
tasklist | findstr nginx

# 强制结束所有 Nginx 进程
taskkill /F /IM nginx.exe

6.2 Linux 服务器命令

# 启动 Nginx
sudo systemctl start nginx

# 停止 Nginx
sudo systemctl stop nginx

# 重启 Nginx
sudo systemctl restart nginx

# 重载配置(不中断服务)
sudo systemctl reload nginx

# 查看 Nginx 状态
sudo systemctl status nginx

# 设置开机自启
sudo systemctl enable nginx

# 取消开机自启
sudo systemctl disable nginx

# 测试配置文件
sudo nginx -t

# 查看 Nginx 版本
nginx -v

# 查看错误日志
sudo tail -f /var/log/nginx/error.log

# 查看访问日志
sudo tail -f /var/log/nginx/access.log

6.3 Vue 项目相关命令

# 开发环境运行
npm run dev

# 生产环境打包
npm run build

# 预览打包结果
npm run preview

7. 常见问题排查

7.1 页面空白

可能原因

  1. 路由模式问题
  2. 静态资源路径问题
  3. 打包配置问题

排查步骤

# 1. 检查 dist 目录是否有 index.html
ls dist/index.html

# 2. 检查浏览器控制台错误
# F12 打开开发者工具,查看 Console 和 Network

# 3. 检查 vite.config.ts 的 base 配置
# 如果部署在子路径,需要设置 base

解决方案

// vite.config.ts
export default defineConfig({
  // 部署在根路径
  base: '/',

  // 如果部署在子路径(如 http://domain.com/app/)
  // base: '/app/',
})

7.2 404 Not Found

可能原因

  1. root 路径配置错误
  2. 静态文件未正确上传

排查步骤

# 1. 检查 root 路径是否正确
ls /var/www/vue3-h5/dist/index.html

# 2. 检查 Nginx 配置
sudo nginx -t

# 3. 检查文件权限
ls -la /var/www/vue3-h5/dist

解决方案

# 修复文件权限
sudo chown -R www-data:www-data /var/www/vue3-h5
sudo chmod -R 755 /var/www/vue3-h5

7.3 刷新页面 404

原因:Vue Router 使用 history 模式,需要 Nginx 配置支持

解决方案:确保 Nginx 配置中有:

location / {
    try_files $uri $uri/ /index.html;
}

7.4 端口被占用

Windows 排查

# 查看端口占用
netstat -ano | findstr :80

# 结束占用进程(PID 是上面查到的进程 ID)
taskkill /PID <进程ID> /F

Linux 排查

# 查看端口占用
sudo lsof -i :80

# 或
sudo netstat -tlnp | grep :80

# 结束占用进程
sudo kill -9 <PID>

7.5 Nginx 配置修改不生效

# 1. 测试配置是否正确
nginx -t

# 2. 重载配置
nginx -s reload      # Windows
sudo systemctl reload nginx  # Linux

# 3. 清除浏览器缓存后刷新页面
# Ctrl + F5 强制刷新

7.6 跨域问题

问题现象:API 请求报 CORS 错误

解决方案一:Nginx 反向代理

location /api/ {
    proxy_pass http://backend-server:8080/;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

解决方案二:添加 CORS 头

location /api/ {
    add_header 'Access-Control-Allow-Origin' '*';
    add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
    add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';

    if ($request_method = 'OPTIONS') {
        return 204;
    }

    proxy_pass http://backend-server:8080/;
}

附录:配置文件模板

A. 本地开发配置模板

# 简化版本地配置
worker_processes  1;

events {
    worker_connections  1024;
}

http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;

    server {
        listen       80;
        server_name  localhost;

        root   D:/test/vue3-h5/dist;
        index  index.html;

        location / {
            try_files $uri $uri/ /index.html;
        }

        gzip on;
        gzip_types text/plain text/css application/json application/javascript;
    }
}

B. 服务器生产配置模板

# 生产环境完整配置
worker_processes  auto;
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  2048;
    use epoll;
    multi_accept on;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent"';

    access_log  /var/log/nginx/access.log  main;

    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;
    keepalive_timeout  65;

    gzip  on;
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_min_length 1000;
    gzip_types text/plain text/css application/json application/javascript text/xml application/xml;

    server {
        listen       80;
        server_name  your-domain.com;

        root   /var/www/vue3-h5/dist;
        index  index.html;

        location / {
            try_files $uri $uri/ /index.html;
        }

        location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
            expires 1y;
            add_header Cache-Control "public, immutable";
        }

        location ~ /\. {
            deny all;
        }

        error_page 500 502 503 504 /50x.html;
        location = /50x.html {
            root /usr/share/nginx/html;
        }
    }
}

总结

环境 关键配置 访问方式
本地 localhost + 本地路径 http://localhost
服务器 域名/IP + 服务器路径 + 优化配置 your-domain.com

部署核心流程:

  1. 本地打包 npm run build
  2. 上传 dist 到服务器
  3. 配置 Nginx 指向 dist 目录
  4. 配置 try_files 支持 Vue Router
  5. 重载 Nginx 配置
  6. 测试访问

如有问题,优先查看 Nginx 错误日志进行排查。

跨语言移植手记:把 TypeScript 的 Codex SDK 请进 .NET 世界

这事儿得从我们在 HagiCode 项目里遇到的一个实际困境说起。我们需要在一个纯 .NET 环境——包括后端服务和桌面客户端——里调用 Codex 的能力。Codex 是 OpenAI 那个挺实用的 AI Agent 命令行工具,官方给了 TypeScript SDK,封装在 @openai/codex 包里。它干活的方式是调用 codex exec 命令,然后解析吐出来的 JSONL 事件流。

直接想法?在 .NET 进程里启动 Node.js 运行时去跑这个 TypeScript SDK,通过进程间通信搭个桥。但稍微琢磨一下就知道,这路子太折腾了:引入巨大的运行时依赖、跨进程通信的稳定性和性能损耗、还有那复杂的错误处理……一套下来,维护成本怕是要起飞。

所以,我们决定走另一条路:把官方的 TypeScript SDK 完整地“移植”成一份原生的 C# SDK。 说是“移植”,其实更像是一次在两个不同语言生态和设计哲学之间的翻译与重建。两种语言的“脾气”确实不太一样,关键是怎么让它们在 .NET 的世界里,依然能把活儿干得漂亮。

一、架构设计的“神”与“形”

动手之前,得先吃透 TypeScript SDK 的骨架。它的核心层次很清晰:

Codex (入口类) → CodexExec (执行器,管理子进程) → Thread (对话线程) → run()/runStreamed() (执行) 和 事件流解析

我们的目标不是简单翻译代码,而是让 C# SDK “神似”而非“形似”。也就是说,对外暴露的 API 要保持一致,让熟悉 TypeScript 版本的开发者能零成本上手;但在内部实现上,得充分利用 C# 的语言特性和 .NET 生态的优势。

二、类型系统的映射:从灵活到严谨

这是最基础也最考验细节的工作。TypeScript 的类型系统以灵活著称,C# 则更强调严谨和确定性。怎么找到那个合适的映射点?

先看一个表格,这是两种语言核心类型映射的对照表:

TypeScript 类型 C# 类型 映射说明
interface / type record record 实现不可变的数据传输对象(DTO),契合函数式编程风格。
string | null string? 直接映射为 C# 8.0 的可空引用类型,语义清晰。
boolean | undefined bool? 用可空布尔值表示“未定义”状态。
AsyncGenerator<T> IAsyncEnumerable<T> .NET Core 3.0+ 的标准异步流处理接口,完美对应。

事件类型的处理是个典型例子。TypeScript 用联合类型(Union Type)来定义多种事件结构:

export type ThreadEvent =
  | ThreadStartedEvent
  | TurnStartedEvent
  | TurnCompletedEvent
  // ... 其他事件

这种“或”的关系,在 C# 里最自然的映射就是继承层次结构 + 模式匹配

// 抽象基类,包含一个用于运行时类型识别的鉴别器属性
public abstract record ThreadEvent(string Type);

// 具体事件类型,继承自基类,并携带各自的数据
public sealed record ThreadStartedEvent(string ThreadId) : ThreadEvent("thread.started");
public sealed record TurnStartedEvent() : ThreadEvent("turn.started");
public sealed record TurnCompletedEvent(Usage Usage) : ThreadEvent("turn.completed");
// ...

// 使用时,通过模式匹配优雅地分派
await foreach (var @event in thread.RunStreamedAsync(...))
{
    switch (@event)
    {
        case TurnCompletedEvent completed:
            Console.WriteLine($"本轮完成,消耗Token: {completed.Usage.InputTokens}");
            break;
        // ... 处理其他类型
    }
}

record 而非 class,是因为事件数据本质上是不可变的快照;用 sealed 则明确表示不会有进一步的派生,有利于编译器优化。

三、核心难点的攻克

1. 事件解析器:从 JSON.parseJsonDocument

TypeScript 里解析事件流就是一行 JSON.parse(line),但在 C# 里需要更精细地控制资源。我们用 System.Text.Json 实现了解析器:

public static ThreadEvent Parse(string line)
{
    // 用 using 确保 JsonDocument 在使用后立即释放非托管资源
    using var document = JsonDocument.Parse(line);
    var root = document.RootElement;
    var type = GetRequiredString(root, "type", "event.type");

    // 基于 type 字段进行模式匹配
    return type switch
    {
        "thread.started" => new ThreadStartedEvent(
            GetRequiredString(root, "thread_id", "...")),
        "turn.completed" => new TurnCompletedEvent(
            ParseUsage(GetRequiredProperty(root, "usage", "..."))),
        // ... 处理其他已知类型
        // 未知类型:克隆一份数据保留下来,因为 document 即将被释放
        _ => new UnknownThreadEvent(type, root.Clone())
    };
}

这里的 root.Clone() 是个关键细节:JsonDocumentusing 包裹,一旦离开作用域其内存就会被回收。对于未知的事件类型,我们需要保留原始数据,所以必须创建一个深层克隆。

2. 进程管理与取消机制

这是两个 SDK 差异最大的地方,也是移植工作的核心。

TypeScript 使用 Node.js 的 child_process.spawn(),配合 AbortSignal 实现优雅取消:

const controller = new AbortController();
const child = spawn(executablePath, args, { signal: controller.signal });
// 稍后 controller.abort() 即可取消进程

C# 里,我们使用 System.Diagnostics.Process,配合 CancellationToken

// 配置进程启动信息,关键是重定向标准输入输出
var startInfo = new ProcessStartInfo
{
    FileName = _executablePath,
    RedirectStandardInput = true,
    RedirectStandardOutput = true,
    RedirectStandardError = true,
    UseShellExecute = false, // 必须为 false 才能重定向流
    CreateNoWindow = true    // 不显示命令行窗口
};

using var process = new Process { StartInfo = startInfo };
process.Start();

// 异步读取输出的方法,接受 CancellationToken
public async IAsyncEnumerable<string> RunAsync(
    CodexExecArgs args,
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    // 启动后,需要手动管理三个流的读写
    _ = Task.Run(() => WriteStdinAsync(cancellationToken), cancellationToken);

    // 逐行读取 Stdout
    while (!cancellationToken.IsCancellationRequested)
    {
        var line = await process.StandardOutput.ReadLineAsync();
        if (line == null) break;
        yield return line;
    }

    // 如果取消被触发,需要主动终止进程树
    if (cancellationToken.IsCancellationRequested)
    {
        try
        {
            // 杀掉整个进程树,避免残留子进程
            process.Kill(entireProcessTree: true);
        }
        catch { /* 忽略 Kill 过程中的异常 */ }
    }
}

可以看到,C# 版本需要更细致地管理流和进程生命周期,CancellationToken 扮演了和 AbortSignal 类似的角色,但集成在 .NET 的异步编程模型里。

四、一些实践中的体悟

  1. API 一致性优先于实现细节:用户在意的不是你内部用 async/await 还是 Task,而是调用 thread.RunAsync() 的感觉是否和 TypeScript 版一样顺手。因此,我们尽量保持了命名、参数顺序和行为的一致性。

  2. 资源清理是“有始有终”的责任:.NET 是托管运行时,但对进程、文件句柄等非托管资源必须显式管理。我们让 CodexExec 实现 IDisposableOutputSchemaTempFile 实现 IAsyncDisposable,确保临时文件和子进程在任何情况下都能被清理干净。

  3. 拥抱平台差异:TypeScript 版会自动在 node_modules 里寻找 Codex 的可执行文件。但在 .NET 世界里,这是不合理的。我们选择通过环境变量或配置项让用户显式指定路径,虽然多了一步配置,但更符合 .NET 应用的部署习惯,也避免了隐含的副作用。这算是一种“因地制宜”吧。

五、总结

将一个成熟的 TypeScript SDK 移植到 C#,远不是逐行翻译那么简单。它要求你深入理解两种语言的设计哲学:TypeScript 的灵活与 JavaScript 生态的特性(如 AbortSignalAsyncGenerator),如何在 C# 这个更强调严谨、可控和编译时检查的环境中找到最对等的实现方案。

整个过程,是一次对两个技术世界异同的深度探索。最终交付的,不是一个完美的“复制品”,而是一个 “神似”的、能在 .NET 生态里安家落户的好公民。如果你也在进行类似的跨语言移植,我的建议是:先吃透架构,再攻克难点,最后用完整的测试用例锁死行为一致性。这事急不得,但走通了,收获绝对不止一个 SDK 本身。

项目免费体验: www.jnpfsoft.com/?from=001YH…

Elpis 全栈框架:从构建到发布的完整实践总结

一、 项目概览:不止于“又一个框架”

“Elpis”是我个人主导设计与实现的一个企业级Node.js全栈应用框架。它并非一个从零开始的轮子,而是一个经过深度实践、提炼、并最终产品化的解决方案集合。项目的核心目标,是为中后台管理系统、配置化运营页面等场景,提供一个高内聚、低耦合、可插拔的开发基座。

如今,这个基座的核心已从单体仓库中成功抽离,以 @choukunbc081/elpis-core等包的形式发布至NPM,标志着它从一个私有项目工具,正式转变为可供社区使用和共建的开源资产

二、 核心架构与设计哲学

Elpis 的后端架构基于 Koa2,并深度融合了洋葱圈模型分层设计思想,形成了一套清晰、可预测的请求生命周期管理机制。

  1. 分层架构 (Layer Architecture)

    • Controller 层:位于 app/controller/, 职责单一,仅处理HTTP请求的输入(参数校验、格式化)与输出(响应组装)。它不关心业务逻辑的具体实现。
    • Service 层:位于 app/service/, 是业务逻辑的核心载体。所有复杂的业务操作、事务管理、多Model协调均在此完成。Controller 通过调用 Service 方法来获取结果。
    • 数据访问层:通过 Sequelize/TypeORM 等ORM工具抽象,Service 层与之交互,实现了业务逻辑与数据库的直接解耦。
  2. “Loader” 自动化装配机制

    这是 Elpis 框架的点睛之笔。我不再需要手动在 app.jsapp.use(...)一个个中间件或引入一个个路由文件。

    • 实现原理:项目启动时,一个自定义的 Loader会扫描 app/目录下的特定结构(如 middleware/, controller/, router/等)。

    • 自动化挂载:Loader 自动将这些模块按预设规则(如中间件顺序、路由前缀)挂载到 Koa 应用实例上。这使得项目结构极度规范,新增功能模块只需遵循约定创建文件,无需修改主流程代码

    • 请求生命周期:结合 Koa 中间件的“洋葱圈”模型,一个请求的典型路径为:

      全局中间件(如错误处理、日志)-> 路由级中间件(如参数校验、签名验证)-> 匹配路由 -> 对应Controller -> 调用Service -> 返回数据 -> 逆序穿过中间件返回响应。

      这种设计使得横切关注点(如日志、鉴权、性能监控)能够以中间件形式优雅地插入任意环节。

  3. 配置驱动与DSL设计 (Configuration & DSL)

    为了支持快速的页面搭建,我设计了一套简单的 JSON DSL(领域特定语言) 用于描述页面布局和组件。

    • 前端通过配置解析引擎,能够将JSON配置动态渲染为真实的Vue/React组件页面。
    • 后端通过 router-schema等设计,将部分路由逻辑配置化,实现了页面路由与业务逻辑的松散耦合

三、 工程化基石:Webpack深度定制

工程化是保障大型前端项目可维护、可协作、高性能交付的关键。我对 Elpis 的前端构建进行了深度定制。

  1. 多环境构建配置:分离了 webpack.base.jswebpack.dev.jswebpack.prod.js,针对开发体验和生产性能分别优化。

  2. 模块热更新原理实践:不仅配置了 webpack-dev-serverHotModuleReplacementPlugin,更深入理解了其背后 WebSocket 通讯 + 内存编译 + 模块差异更新 的完整链条。这让我在解决一些棘手的HMR失效问题时游刃有余。

  3. 性能优化策略

    • 代码分割:利用 splitChunks将代码智能拆分为 vendor(第三方库)、common(公共业务代码)、async(异步路由组件),充分利用浏览器缓存,显著提升首屏和切换速度。
    • 构建提速:引入 HappyPackthread-loader进行多进程构建,优化 Loader 耗时。
    • 产物优化:生产环境使用 TerserWebpackPlugin压缩 JS,MiniCssExtractPlugin抽离并压缩 CSS,PurgeCSS删除未使用的 CSS。

最大的收获:我不再害怕复杂的构建配置。我认识到,Vite 或 Webpack 都只是工具,核心在于理解其要解决的模块化、依赖分析、打包、转换、优化等根本问题。掌握了这些,任何新的构建工具都能快速上手。

四、 产品化飞跃:NPM模块抽离与发布

这是将“项目代码”提升为“产品”的关键一步。目标是将 Elpis 框架的通用能力(Loader、Service基类、常用中间件、工具函数等)封装为独立的、可版本化的NPM包。

  1. 抽离策略

    • 识别核心:确定哪些是框架强相关的、通用的、不依赖具体业务逻辑的代码(如 loader目录、app/extend扩展、app/middleware中的通用中间件)。
    • 依赖管理:仔细梳理并声明 dependenciespeerDependencies。例如,koasequelize通常作为 peerDependencies,由使用方自行安装指定版本,避免版本冲突。
    • 构建打包:为库编写专用的 rollup.config.jswebpack.config.js,输出 UMD、CommonJS、ES Module 多种格式,确保其可在浏览器、Node.js 及各种打包工具中良好工作。
  2. 发布与文档

    • 通过 npm publish发布到公共或私有仓库。
    • 编写清晰的 README.md,包含安装、快速开始、API文档和示例。
    • 使用语义化版本控制 (semver) 进行版本迭代。

五、 全栈思维的蜕变

  1. “贯通”的体验:我从“前端开发者”或“Node.js脚本编写者”的角色,真正转变为“解决方案设计者”。我需要同时考虑API设计、数据流、状态管理、构建部署、性能监控,并让它们优雅地协同工作。
  2. 工具服务于思想:我不再争论“Vue好还是React好”,或“Webpack是否过时”。我深刻理解到,技术选型是权衡,框架和工具是思想的载体。Elpis 框架本身,就是我对“如何高效、规范地开发Web应用”这一问题的个人化回答与实践
  3. 克服未知的勇气:从最初面对庞大框架的畏惧,到拆解、理解、重构,再到最终抽离发布。这个过程赋予我的最大财富是自信——一种面对任何新技术、复杂系统时,相信自己有能力通过分析、学习和实践去掌握它的自信。

六、 未来展望

Elpis 的 NPM 发布只是一个新的起点。未来,我计划:

  • 实现模块动态化:将模块的静态代码配置,转化为可存储在数据库中并通过界面动态管理的、驱动功能运行的“可配置化元数据”。
  • 丰富生态:围绕核心包,开发更多场景化的插件和中间件(如全链路日志、APM监控、Admin后台插件)。
  • 强化低代码:进一步完善DSL和可视化渲染引擎,使其真正成为一个实用的低代码平台后端框架。
  • 社区共建:希望开源版本能够吸引开发者使用,在解决实际业务问题的过程中,吸收社区的智慧,共同迭代。

总结:Elpis 项目对我而言,是一次从“应用开发者”到“框架设计者”的完整蜕变。它不仅仅是代码的集合,更是我对现代Web全栈开发的架构思想、工程实践和产品思维的一次系统性的梳理与输出。

深度拆解 Web3 预测市场:基于 Solidity 0.8.24 与 UMA 乐观预言机的核心实现

前言

在 Web3 领域,Polymarket 的成功证明了“链上对冲+现实预测”模式的巨大潜力。不同于传统的博弈平台,Polymarket 的精髓在于利用乐观预言机(Optimistic Oracle) 将现实世界的非对称信息转化为链上可结算的资产。本文将从架构设计、核心代码到自动化测试,完整拆解一个去中心化预测市场的技术实现。

一、 核心架构:为何选择 UMA?

传统的预言机(如 Chainlink Price Feeds)擅长处理高频、标准化的数据(如币价)。但对于“2026年比特币是否突破20万美金”这类离散的、需人工核实的事件,UMA 乐观预言机提供了更优的方案:

  1. 断言机制(Assertion) :任何人都可以提交一个结果断言,并缴纳保证金。
  2. 挑战期(Liveness Period) :如果在特定时间内(如 2 小时)没人挑战,系统默认该断言为真。
  3. 博弈均衡:通过经济激励确保提交者不敢造假,因为挑战者可以推翻错误断言并赢得其保证金。

二、 核心智能合约实现

我们使用 Solidity 0.8.24 和 OpenZeppelin V5 编写了核心逻辑。合约实现了资产托管、双向对冲池和预言机异步结算。

注释:奖励瓜分算法

采用 Pari-mutuel(等额奖池)  机制:胜方根据其投入的份额,等比例瓜分败方的资金池。

𝑅𝑒𝑤𝑎𝑟𝑑=𝑈𝑠𝑒𝑟𝐵𝑒𝑡𝑇𝑜𝑡𝑎𝑙𝑊𝑖𝑛𝑛𝑖𝑛𝑔𝑃𝑜𝑜𝑙×𝑇𝑜𝑡𝑎𝑙𝑃𝑜𝑡𝑅𝑒𝑤𝑎𝑟𝑑=\frac{𝑈𝑠𝑒𝑟𝐵𝑒𝑡}{𝑇𝑜𝑡𝑎𝑙𝑊𝑖𝑛𝑛𝑖𝑛𝑔𝑃𝑜𝑜𝑙}×𝑇𝑜𝑡𝑎𝑙𝑃𝑜𝑡

1. SimplePolymarketWithUMA合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

interface IOptimisticOracleV3 {
    function assertTruthWithDefaults(bytes calldata claim, address callbackRecipient) external returns (bytes32);
    function getAssertion(bytes32 assertionId) external view returns (bool settled, bool domainResolved, address caller, uint256 expirationTime, bool truthValue);
}

contract SimplePolymarketWithUMA is ReentrancyGuard {
    IERC20 public immutable usdc;
    IOptimisticOracleV3 public immutable umaOO;

    struct Market {
        string description;
        uint256 totalYes;
        uint256 totalNo;
        uint8 outcome; // 0: Open, 1: Yes, 2: No
        bool resolved;
        bytes32 assertionId;
    }

    uint256 public marketCount;
    mapping(uint256 => Market) public markets;
    mapping(uint256 => mapping(address => uint256)) public yesBets;
    mapping(uint256 => mapping(address => uint256)) public noBets;

    constructor(address _usdc, address _umaOO) {
        usdc = IERC20(_usdc);
        umaOO = IOptimisticOracleV3(_umaOO);
    }

    function createMarket(string calldata _description) external {
        uint256 marketId = marketCount++;
        markets[marketId].description = _description;
    }

    function placeBet(uint256 _marketId, bool _isYes, uint256 _amount) external nonReentrant {
        Market storage m = markets[_marketId];
        require(!m.resolved, "Market resolved");
        require(usdc.transferFrom(msg.sender, address(this), _amount), "Transfer failed");

        if (_isYes) {
            yesBets[_marketId][msg.sender] += _amount;
            m.totalYes += _amount;
        } else {
            noBets[_marketId][msg.sender] += _amount;
            m.totalNo += _amount;
        }
    }

    // 向 UMA 提出断言:结果为 YES (1) 或 NO (2)
    function proposeOutcome(uint256 _marketId, uint8 _proposedOutcome) external {
        Market storage m = markets[_marketId];
        require(m.assertionId == bytes32(0), "Outcome already proposed");
        
        string memory claim = string(abi.encodePacked("Market:", m.description, " Result:", _proposedOutcome == 1 ? "YES" : "NO"));
        bytes32 aid = umaOO.assertTruthWithDefaults(bytes(claim), address(this));
        
        m.assertionId = aid;
    }

    // 挑战期结束后,根据 UMA 判定结果结算
    function settleMarket(uint256 _marketId) external {
        Market storage m = markets[_marketId];
        require(!m.resolved, "Already resolved");
        
        (bool settled, , , , bool truthValue) = umaOO.getAssertion(m.assertionId);
        require(settled, "UMA assertion not settled");

        // 若 UMA 判定断言为真,则接受提议的结果
        // 注意:这里简化逻辑,假设提议结果总是 1 (YES)
        m.outcome = truthValue ? 1 : 2;
        m.resolved = true;
    }

    function claimReward(uint256 _marketId) external nonReentrant {
        Market storage m = markets[_marketId];
        require(m.resolved, "Not resolved");

        uint256 reward;
        uint256 totalPool = m.totalYes + m.totalNo;

        if (m.outcome == 1) {
            uint256 userBet = yesBets[_marketId][msg.sender];
            require(userBet > 0, "No winning bet or already claimed"); // 增加此行效果更佳
            reward = (userBet * totalPool) / m.totalYes;
            yesBets[_marketId][msg.sender] = 0;
        } else {
            uint256 userBet = noBets[_marketId][msg.sender];
            reward = (userBet * totalPool) / m.totalNo;
            noBets[_marketId][msg.sender] = 0;
        }
        require(usdc.transfer(msg.sender, reward), "Reward failed");
    }
}

2. TestUSDT代币

//SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";

/**
 * @dev 测试网专用 USDT,任意人都能 mint
 */
contract TestUSDT is ERC20 {
    uint8 private _decimals;

    constructor(
        string memory name,
        string memory symbol,
        uint8 decimals_
    ) ERC20(name, symbol) {
        _decimals = decimals_;
    }

    function decimals() public view override returns (uint8) {
        return _decimals;
    }

    function mint(address to, uint256 amount) external {
        _mint(to, amount);
    }
}

3. MockOptimisticOracleV3合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract MockOptimisticOracleV3 {
    struct Assertion {
        bool settled;
        bool truthValue;
        uint256 expirationTime;
    }

    mapping(bytes32 => Assertion) public assertions;
    uint256 public constant LIVENESS = 7200; // 2小时挑战期

    function assertTruthWithDefaults(bytes calldata claim, address) external returns (bytes32) {
        bytes32 aid = keccak256(abi.encodePacked(claim, block.timestamp));
        assertions[aid] = Assertion(false, false, block.timestamp + LIVENESS);
        return aid;
    }

    // 模拟挑战期结束并手动结算
    function mockSettle(bytes32 aid, bool _truth) external {
        require(block.timestamp >= assertions[aid].expirationTime, "Liveness not met");
        assertions[aid].settled = true;
        assertions[aid].truthValue = _truth;
    }

    function getAssertion(bytes32 aid) external view returns (bool, bool, address, uint256, bool) {
        Assertion memory a = assertions[aid];
        return (a.settled, true, address(0), a.expirationTime, a.truthValue);
    }
}

四、 自动化测试:Viem + Hardhat

测试用例:

  • ✅ 经 UMA 判定后,胜方成功瓜分奖池
  • Polymarket + UMA 自动化集成测试
    • ✔ 完整流程:下注 -> UMA断言 -> 时间推进 -> 结算 -> 领奖
  • ✅ 第一次合法领取完成
  • ✅ 重复领取拦截成功
    • ✔ 安全性测试 (重复领取拦截)
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
import { network } from "hardhat"; 
import { type Address, parseUnits, decodeEventLog } from "viem";

describe("Polymarket + UMA 自动化集成测试", function () {
    let poly: any, usdc: any, uma: any;
    let admin: any, userYes: any, userNo: any;
    let vClient: any, pClient: any;
    let testClient: any;
    beforeEach(async function () {
        const { viem } = await (network as any).connect();
        vClient = viem;
        [admin, userYes, userNo] = await vClient.getWalletClients();
        pClient = await vClient.getPublicClient();
        testClient = await viem.getTestClient();
        // 1. 部署环境
        usdc = await vClient.deployContract("TestUSDT", ["USDC", "USDC", 6]);
        uma = await vClient.deployContract("MockOptimisticOracleV3");
        poly = await vClient.deployContract("SimplePolymarketWithUMA", [usdc.address, uma.address]);

        // 2. 准备资金
        const amount = parseUnits("1000", 6);
        for (const u of [userYes, userNo]) {
            await usdc.write.mint([u.account.address, amount], { account: admin.account });
            await usdc.write.approve([poly.address, amount], { account: u.account });
        }
    });

    it("完整流程:下注 -> UMA断言 -> 时间推进 -> 结算 -> 领奖", async function () {
        const marketId = 0n;
        await poly.write.createMarket(["Bitcoin > $100k?"], { account: admin.account });

        // 用户下注
        await poly.write.placeBet([marketId, true, parseUnits("100", 6)], { account: userYes.account });
        await poly.write.placeBet([marketId, false, parseUnits("50", 6)], { account: userNo.account });

        // 提出断言 (提议结果为 YES)
        await poly.write.proposeOutcome([marketId, 1], { account: admin.account });
        const mInfo = await poly.read.markets([marketId]);
        const aid = mInfo[5]; // 获取 assertionId

        // 模拟时间推进 (跳过 2 小时挑战期)
        // await network.provider.send("evm_increaseTime", [7201]);
        // await network.provider.send("evm_mine");
        await testClient.increaseTime({ seconds: 7201 });
        await testClient.mine({ blocks: 1 });
        // 模拟 UMA 结算该断言
        await uma.write.mockSettle([aid, true], { account: admin.account });

        // Polymarket 最终结算
        await poly.write.settleMarket([marketId], { account: admin.account });

        // 验证领奖:UserYes 投入 100,UserNo 投入 50,总池 150
        const balBefore = await usdc.read.balanceOf([userYes.account.address]);
        await poly.write.claimReward([marketId], { account: userYes.account });
        const balAfter = await usdc.read.balanceOf([userYes.account.address]);

        assert.strictEqual(balAfter - balBefore, parseUnits("150", 6), "奖池分配错误");
        console.log("✅ 经 UMA 判定后,胜方成功瓜分奖池");
    });
    it("安全性测试 (重复领取拦截)", async function () {
    const marketId = 0n;
    await poly.write.createMarket(["安全性攻击测试"], { account: admin.account });

    // 1. 下注
    await poly.write.placeBet([marketId, true, parseUnits("100", 6)], { account: userYes.account });
    await poly.write.proposeOutcome([marketId, 1], { account: admin.account });
    
    // 2. 推进时间
    await testClient.increaseTime({ seconds: 7201 });
    await testClient.mine({ blocks: 1 });
    
    // 3. 修复后的 aid 获取方式
    const mInfoBefore = await poly.read.markets([marketId]);
    const aid = mInfoBefore[5]; // 获取 bytes32 类型的 assertionId
    
    // 确保 aid 不是 undefined
    assert.ok(aid && aid !== '0x0000000000000000000000000000000000000000000000000000000000000000', "未获取到有效的 Assertion ID");

    await uma.write.mockSettle([aid, true], { account: admin.account });
    await poly.write.settleMarket([marketId], { account: admin.account });

    // 4. 第一次领取
    await poly.write.claimReward([marketId], { account: userYes.account });
    console.log("✅ 第一次合法领取完成");

    // 5. 第二次领取(预期失败)
    try {
        await poly.write.claimReward([marketId], { account: userYes.account });
        assert.fail("不应允许重复领取");
    } catch (err: any) {
        // Viem 的错误通常在 err.message 或 err.shortMessage 中
        assert.ok(err.message.includes("revert"), "应该触发合约 revert");
        console.log("✅ 重复领取拦截成功");
    }
});


});

部署脚本

// scripts/deploy.js
import { network, artifacts } from "hardhat";
import { parseUnits } from "viem";
async function main() {
  // 连接网络
  const { viem } = await network.connect({ network: network.name });//指定网络进行链接
  
  // 获取客户端
  const [deployer, investor] = await viem.getWalletClients();
  const publicClient = await viem.getPublicClient();
 
  const deployerAddress = deployer.account.address;
   console.log("部署者的地址:", deployerAddress);
  
  // 部署TestUSDTReceipt合约
  const TestUSDTArtifact = await artifacts.readArtifact("TestUSDT");
  // 1. 部署合约并获取交易哈希
  const TestUSDTHash = await deployer.deployContract({
    abi: TestUSDTArtifact.abi,
    bytecode: TestUSDTArtifact.bytecode,
    args: ["USDC", "USDC", 6],
  });
  const TestUSDTReceipt = await publicClient.waitForTransactionReceipt({ 
     hash: TestUSDTHash 
   });
   console.log("TestUSDTReceipt合约地址:", TestUSDTReceipt.contractAddress);
   // 部署MockOptimisticOracleV3合约
   const MockOptimisticOracleV3Artifact = await artifacts.readArtifact("MockOptimisticOracleV3");
   // 1. 部署合约并获取交易哈希
   const MockOptimisticOracleV3Hash = await deployer.deployContract({
     abi: MockOptimisticOracleV3Artifact.abi,
     bytecode: MockOptimisticOracleV3Artifact.bytecode,
     args: [],
   });
   const MockOptimisticOracleV3Receipt = await publicClient.waitForTransactionReceipt({ 
      hash: MockOptimisticOracleV3Hash 
    });
    console.log("MockOptimisticOracleV3合约地址:", MockOptimisticOracleV3Receipt.contractAddress);
    // SimplePolymarketWithUMA脚本
    const SimplePolymarketWithUMAArtifact=await artifacts.readArtifact("SimplePolymarketWithUMA");
    const SimplePolymarketWithUMAHash = await deployer.deployContract({
     abi: SimplePolymarketWithUMAArtifact.abi,
     bytecode: SimplePolymarketWithUMAArtifact.bytecode,
     args: [TestUSDTReceipt.contractAddress,MockOptimisticOracleV3Receipt.contractAddress],
   });
   const SimplePolymarketWithUMAReceipt = await publicClient.waitForTransactionReceipt({ 
      hash: SimplePolymarketWithUMAHash 
    });
    console.log("SimplePolymarketWithUMAReceipt合约地址",SimplePolymarketWithUMAReceipt.contractAddress)
}

main().catch(console.error);

总结

至此,简洁版 Polymarket 核心运行机制相关智能合约已完成开发、测试与部署全流程。期间完成了理论梳理、核心架构设计,并明确了 UMA 乐观预言机的选型依据,整体工作圆满落地。

Vitest 浏览器模式:别再用 jsdom 骗自己了

好的,我基于对 Vitest 浏览器模式的深入了解来写这篇文章。

Vitest 浏览器模式:别再用 jsdom 骗自己了

上周 code review 的时候,同事写了个组件测试,断言一个 tooltip 的定位是否正确。测试绿了,CI 也过了。结果上线后 tooltip 飞到了页面左上角。

原因很简单:jsdom 里 getBoundingClientRect() 永远返回全零。测试通过,只是因为你断言的对象压根不存在。

这事让我下定决心把组件测试迁到 Vitest 的浏览器模式。折腾了两周,踩了不少坑,这篇聊聊实际的迁移过程和关键决策。

jsdom 到底假在哪

先说清楚:jsdom 不是不能用,大量纯逻辑的测试它完全够了。但一旦涉及这些场景,它就开始"演"了:

// 在 jsdom 里跑这段
const el = document.createElement('div')
document.body.appendChild(el)
el.style.width = '200px'

console.log(el.getBoundingClientRect())
// → { x: 0, y: 0, width: 0, height: 0, ... }
// 全是零,因为 jsdom 根本没有布局引擎

console.log(getComputedStyle(el).width)
// → '200px'  ← 这个倒是能拿到,但只是字符串解析,不是真正计算的

还有一些更隐蔽的坑:

  • IntersectionObserverResizeObserver 不存在,得手动 mock
  • CSS 媒体查询不生效,matchMedia 返回的永远是你 mock 的值
  • focus()blur() 的行为和真实浏览器不一样
  • Canvas、WebGL 相关 API 全部缺失

你 mock 得越多,测试就越像在测你的 mock 而不是测你的组件。

Vitest 浏览器模式是什么

Vitest 从 1.x 开始提供了 browser 模式(2.x 起趋于稳定)。核心思路很直接:测试代码直接跑在真实浏览器里,而不是 Node.js + jsdom 的模拟环境。

它的架构大概是这样:

Vitest (Node.js 侧)
  ├── 启动浏览器实例(通过 Provider)
  ├── 把测试文件打包,注入浏览器页面
  ├── 测试在浏览器里执行
  └── 结果回传到 Node.js 侧汇总

Provider 目前支持三个:playwrightwebdriveriopreview。我选了 Playwright,原因后面说。

搭起来

安装:

# vitest 本体 + 浏览器模块 + playwright provider
pnpm add -D vitest @vitest/browser playwright

配置 vitest.config.ts

import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    browser: {
      enabled: true,
      provider: 'playwright',
      // 用 chromium 就够了,没必要三个都开
      instances: [
        { browser: 'chromium' },
      ],
    },
  },
})

跑一个最基础的测试验证环境:

// src/__tests__/sanity.test.ts
import { expect, test } from 'vitest'

test('真实浏览器环境验证', () => {
  const div = document.createElement('div')
  div.style.width = '100px'
  div.style.height = '50px'
  div.style.position = 'absolute'
  document.body.appendChild(div)

  const rect = div.getBoundingClientRect()
  // 在 jsdom 里这俩是 0,在真实浏览器里是正确值
  expect(rect.width).toBe(100)
  expect(rect.height).toBe(50)

  div.remove()
})

如果这个测试过了,环境就没问题。

为什么选 Playwright 而不是 WebDriverIO

这个选择其实没啥悬念:

  • Playwright 是 Chromium 内核直连,启动快、API 丰富、调试体验好。Vitest 团队自己也在推这个方向。
  • WebDriverIO 走的是 WebDriver 协议,多一层通信开销,在纯单元测试场景下没什么优势。
  • preview 模式跑在 iframe 里,没有真正隔离,不支持多浏览器。适合快速预览,不适合正经测试。

我在两个项目里分别试过 Playwright 和 WebDriverIO,前者在 200 个组件测试的场景下快了大概 40%。冷启动差距更明显。

组件测试怎么写

如果你用 React 或 Vue,需要装对应的渲染器。以 React 为例:

pnpm add -D @vitest/browser/context

Vitest 浏览器模式提供了一套内置的交互 API,不需要再装 @testing-library/user-event

// components/Counter.test.tsx
import { render } from 'vitest-browser-react' // React 用这个
import { expect, test } from 'vitest'
import { page } from '@vitest/browser/context'
import Counter from './Counter'

test('点击按钮后计数器加 1', async () => {
  render(<Counter initialCount={0} />)

  // page.getByRole 返回的是 Locator,跟 Playwright 的体验很像
  const button = page.getByRole('button', { name: '加一' })
  const display = page.getByTestId('count-display')

  await expect.element(display).toHaveTextContent('0')

  await button.click() // 真实的 click,不是 fireEvent 模拟的

  await expect.element(display).toHaveTextContent('1')
})

几个关键差异点:

// ❌ testing-library 风格(jsdom 时代)
// fireEvent.click(button) → 同步的,合成事件
// screen.getByText('xxx')  → 返回 DOM 元素

// ✅ vitest browser 风格
// await button.click()          → 异步的,真实浏览器事件
// page.getByRole(...)           → 返回 Locator,惰性求值
// await expect.element(el)      → 专用的元素断言,自带重试

注意那个 expect.element(),这东西自带 retry 机制,等元素出现再断言。不用自己写 waitFor 了。

处理样式和布局相关的测试

这才是浏览器模式真正值回票价的地方。

// 测试 Tooltip 定位 —— 这在 jsdom 里根本没法测
import { render } from 'vitest-browser-react'
import { page } from '@vitest/browser/context'
import Tooltip from './Tooltip'

test('tooltip 出现在触发元素的下方', async () => {
  render(
    <div style={{ paddingTop: '100px' }}>
      <Tooltip content="提示文字">
        <button>hover me</button>
      </Tooltip>
    </div>
  )

  const trigger = page.getByRole('button', { name: 'hover me' })
  await trigger.hover() // 真实的 hover,CSS :hover 伪类也会生效

  const tooltip = page.getByRole('tooltip')
  await expect.element(tooltip).toBeVisible()

  // 拿到真实的位置信息
  const triggerEl = trigger.element()
  const tooltipEl = tooltip.element()
  const triggerRect = triggerEl.getBoundingClientRect()
  const tooltipRect = tooltipEl.getBoundingClientRect()

  // tooltip 的顶部应该在 trigger 的底部附近
  expect(tooltipRect.top).toBeGreaterThanOrEqual(triggerRect.bottom)
})

类似的,CSS 动画测试、媒体查询响应式测试、滚动行为测试——这些之前要么 mock 一堆要么直接放弃的场景,现在都能正经测了。

和现有 jsdom 测试共存

大概率你不会一次性迁移所有测试。好消息是,Vitest 支持同一个项目里两套测试环境共存:

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    // 默认还是用 jsdom 跑(存量测试不受影响)
    environment: 'jsdom',

    browser: {
      enabled: true,
      provider: 'playwright',
      instances: [
        { browser: 'chromium' },
      ],
    },
  },
})

然后通过文件命名或目录来区分:

src/
  __tests__/
    utils.test.ts          ← 纯逻辑,jsdom 跑
  __browser_tests__/
    Tooltip.browser.test.tsx  ← 涉及布局,浏览器模式跑

在 Vitest workspace 配置里分别指定就行。我个人倾向于用 xxx.browser.test.ts 这种命名来区分,比放不同目录直观。

性能:绕不开的话题

实话说,浏览器模式比 jsdom 慢。启动一个 Chromium 实例是有代价的。

我在一个中型项目(约 150 个组件测试)里的实际数据:

指标 jsdom Browser (Playwright)
冷启动 ~2s ~5s
单个测试平均 ~50ms ~120ms
全量跑完 ~12s ~28s
CI 环境 ~18s ~45s

慢了一倍多。但这不是不能接受,尤其考虑到测试的可信度提升了一个量级。

几个优化手段:

// 1. 复用浏览器实例(默认行为,确认没关掉就行)
// vitest.config.ts
{
  test: {
    browser: {
      // headless 模式在 CI 里快不少
      headless: true, // 默认就是 true,确认一下
    },
  },
}
// 2. 不需要每个测试都用浏览器模式
// 纯逻辑的单元测试继续用 jsdom,只有涉及 DOM 交互/布局的用浏览器

// 3. 并行度调整
// Playwright 的 chromium 实例比较吃内存,CI 机器配置不够的话可能要降并行数
{
  test: {
    browser: {
      // 如果 CI 内存不够,适当降低
    },
    fileParallelism: true, // 文件级别并行
  },
}

调试体验

这是我没预料到的一个加分项。

vitest --browser 启动后会开一个调试页面,能直接在浏览器里看到测试的渲染结果。组件测试失败的时候,你可以打开 DevTools 直接审查 DOM,看看是布局问题还是逻辑问题。

比起 jsdom 模式下对着一堆 prettyDOM() 输出猜问题,体验好太多了。

# 开发时用 headed 模式,能看到实际渲染
npx vitest --browser.headless=false

之前有个 Dialog 组件的测试,在 jsdom 下一直报 focus 不符合预期。切到浏览器模式后,打开 DevTools 一看,原来是 tabindex 设错了,焦点根本没有落到目标元素上。这种问题在 jsdom 里是发现不了的,因为 jsdom 的 focus 模型本身就是简化过的。

几个坑

1. 全局变量要小心

浏览器模式下 windowdocument 是真实的,但 Vitest 的一些 API(比如 vi.mock)还是跑在一个特殊的上下文里。模块级别的 mock 有些情况会有问题,遇到了就看报错信息排查,多数是执行时序导致的。

2. 文件系统 API 不可用

测试跑在浏览器里,fspath 这些 Node.js API 用不了。如果你的组件测试辅助函数依赖了 Node 模块,需要拆分。

3. CSS 文件的处理

浏览器模式下 CSS 是真正被加载和应用的(而不是像 jsdom 那样被忽略或 mock),所以如果你的组件依赖全局样式,确保在测试环境里也能加载到。可以在 vitest.setup.ts 里 import 全局样式文件。

// vitest.setup.ts
import './src/styles/global.css'
// 浏览器模式下这个 import 会真正生效

4. CI 环境配置

GitHub Actions 里要装浏览器依赖:

# .github/workflows/test.yml
- name: Install Playwright browsers
  run: npx playwright install --with-deps chromium
  # 只装 chromium 就行,装全套太慢了

什么时候该用,什么时候没必要

并不是所有测试都得迁到浏览器模式。我的判断标准:

用浏览器模式:

  • 组件测试涉及布局、定位、滚动
  • 测试依赖 CSS 生效(比如 display: none 的可见性判断)
  • 测试涉及焦点管理、键盘导航
  • 测试用到了 Canvas、IntersectionObserver 等浏览器专有 API
  • 你在 jsdom 里 mock 了超过 3 个浏览器 API —— 这是个信号

继续用 jsdom:

  • 纯逻辑的 hooks 测试
  • 状态管理相关的单元测试
  • 简单的渲染快照测试
  • 不涉及任何浏览器特有行为的组件

两者不是替代关系,是互补的。

聊到这

Vitest 浏览器模式解决的核心问题就一个:让你的组件测试跑在跟用户一样的环境里

jsdom 用了这么多年,大家都习惯了"mock 一下就好"的心态。但 mock 多了以后,你测的到底是组件逻辑还是 mock 的正确性,有时候真说不清。

迁移成本确实有,速度也确实慢一些。但对于组件库、UI 密集型项目,或者你已经被 jsdom 的各种 mock 搞得头疼的场景,值得试试。

对了,Vitest 的浏览器模式 API 还在演进中,有些边界行为可能后续版本会调整。建议跟着 Vitest 的 release notes 走,别锁太老的版本。

JavaScript模块化深度解析:从CommonJS到ES Modules的演进之路

引言

JavaScript模块化是现代前端开发的基石,它让代码组织更加清晰、可维护性更强。从早期的全局变量污染,到CommonJS、AMD、CMD,再到如今的ES Modules,JavaScript模块化经历了漫长的演进过程。本文将深入探讨JavaScript模块化的发展历程、核心概念和最佳实践,帮助你全面掌握这个前端开发必备技能。

模块化的发展历程

1. 早期JavaScript的困境

在JavaScript早期,没有模块化概念,所有代码都在全局作用域中执行。

// 早期的问题代码
// utils.js
var name = '工具函数';

function formatDate() {
  // 格式化日期逻辑
}

// app.js
var name = '应用代码'; // 变量名冲突!

function formatDate() {
  // 与utils.js中的函数冲突
}

这种开发方式存在严重问题:

  • 全局变量污染
  • 命名冲突
  • 依赖关系混乱
  • 无法按需加载

2. 命名空间模式

为了解决全局污染问题,开发者开始使用命名空间。

// 使用对象作为命名空间
var MyApp = MyApp || {};

MyApp.utils = {
  formatDate: function(date) {
    return new Date(date).toLocaleDateString();
  },
  
  debounce: function(func, delay) {
    var timer;
    return function() {
      clearTimeout(timer);
      timer = setTimeout(func, delay);
    };
  }
};

MyApp.services = {
  userService: {
    getUser: function(id) {
      // 获取用户逻辑
    }
  }
};

// 使用
MyApp.utils.formatDate('2024-01-15');

虽然命名空间缓解了问题,但仍然存在依赖管理困难等问题。

CommonJS规范

3. CommonJS基础

CommonJS是Node.js采用的模块化规范,使用require和module.exports。

// math.js - 定义模块
function add(a, b) {
  return a + b;
}

function multiply(a, b) {
  return a * b;
}

// 导出多个函数
module.exports = {
  add: add,
  multiply: multiply
};

// 或者使用exports对象
exports.add = add;
exports.multiply = multiply;
// app.js - 使用模块
var math = require('./math');

console.log(math.add(2, 3));        // 5
console.log(math.multiply(4, 5));    // 20

4. CommonJS的特点

// CommonJS的核心特性

// 1. 同步加载
var fs = require('fs'); // 阻塞等待模块加载完成

// 2. 运行时加载
var module = require('./module'); // 在运行时动态加载

// 3. 值的拷贝
// counter.js
var count = 0;
module.exports = {
  count: count,
  increment: function() {
    count++;
  }
};

// app.js
var counter = require('./counter');
console.log(counter.count); // 0
counter.increment();
console.log(counter.count); // 仍然是0,因为是值的拷贝

// 4. 单例模式
// 多次require同一个模块,只加载一次
var module1 = require('./module');
var module2 = require('./module');
console.log(module1 === module2); // true

5. CommonJS的模块缓存机制

// 模块缓存示例
// cache.js
console.log('模块被加载');
module.exports = {
  data: 'cached data'
};

// app.js
require('./cache'); // 输出: 模块被加载
require('./cache'); // 不再输出,使用缓存
require('./cache'); // 不再输出,使用缓存

// 清除缓存
delete require.cache[require.resolve('./cache')];
require('./cache'); // 再次输出: 模块被加载

AMD规范

6. RequireJS与AMD

AMD(Asynchronous Module Definition)是异步模块定义规范,RequireJS是其最著名的实现。

// 定义模块
define(['dependency1', 'dependency2'], function(dep1, dep2) {
  // 模块代码
  var privateVar = 'private';
  
  function publicFunction() {
    return dep1.doSomething() + dep2.doSomething();
  }
  
  // 返回公共API
  return {
    publicFunction: publicFunction
  };
});

// 简单模块定义
define(function() {
  return {
    version: '1.0.0'
  };
});
// 使用模块
require(['math', 'utils'], function(math, utils) {
  var result = math.add(10, 20);
  console.log(utils.formatDate(result));
});

7. RequireJS配置

<!-- index.html -->
<script src="require.js" data-main="app"></script>
// require.config.js
require.config({
  // 基础路径
  baseUrl: 'js/lib',
  
  // 路径映射
  paths: {
    'jquery': 'jquery.min',
    'underscore': 'underscore.min',
    'backbone': 'backbone.min'
  },
  
  // 模块依赖
  shim: {
    'backbone': {
      deps: ['underscore', 'jquery'],
      exports: 'Backbone'
    }
  }
});

// 加载主模块
require(['app/main'], function(main) {
  main.init();
});

ES Modules(ESM)

8. ES Modules基础语法

ES Modules是JavaScript官方的模块化标准,现在已被所有现代浏览器和Node.js支持。

// 导出命名导出
// utils.js
export const formatDate = (date) => {
  return new Date(date).toLocaleDateString();
};

export const debounce = (func, delay) => {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => func.apply(this, args), delay);
  };
};

// 导出函数
export function greet(name) {
  return `Hello, ${name}!`;
}

// 导出类
export class User {
  constructor(name) {
    this.name = name;
  }
  
  sayHello() {
    return `Hello, I'm ${this.name}`;
  }
}
// 导入命名导出
import { formatDate, debounce } from './utils.js';
import { greet } from './utils.js';

// 使用
console.log(formatDate('2024-01-15'));
const debouncedSearch = debounce(search, 300);

9. 默认导出

// 默认导出
// api.js
export default class API {
  constructor(baseUrl) {
    this.baseUrl = baseUrl;
  }
  
  async get(endpoint) {
    const response = await fetch(`${this.baseUrl}${endpoint}`);
    return response.json();
  }
  
  async post(endpoint, data) {
    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(data)
    });
    return response.json();
  }
}
// 导入默认导出
import API from './api.js';

const api = new API('https://api.example.com');
const users = await api.get('/users');

10. 混合导出

// 混合导出示例
// index.js

// 命名导出
export { formatDate, debounce } from './utils.js';
export { User } from './models.js';

// 默认导出
export default {
  version: '1.0.0',
  author: 'Developer'
};

// 重新导出并重命名
export { formatDate as format } from './utils.js';

// 导出所有
export * from './constants.js';

11. ES Modules的特点

// ES Modules的核心特性

// 1. 静态分析
// 在编译时确定依赖关系,可以进行tree-shaking

// 2. 异步加载
import('./module.js').then(module => {
  // 动态导入
  console.log(module.default);
});

// 3. 值的引用
// counter.js
export let count = 0;
export function increment() {
  count++;
}

// app.js
import { count, increment } from './counter.js';
console.log(count); // 0
increment();
console.log(count); // 1,因为是引用

// 4. 严格模式
// ES Modules自动运行在严格模式下
// 不能使用未声明的变量
// this指向undefined

模块化最佳实践

12. 模块组织结构

// 推荐的模块组织结构
// src/
//   ├── api/
//   │   ├── index.js        // 统一导出
//   │   ├── user.js         // 用户相关API
//   │   └── product.js      // 产品相关API
//   ├── components/
//   │   ├── Button/
//   │   │   ├── index.js
//   │   │   ├── Button.vue
//   │   │   └── Button.test.js
//   │   └── Form/
//   ├── utils/
//   │   ├── index.js
//   │   ├── date.js
//   │   └── string.js
//   └── main.js

// api/index.js - 统一导出
export { default as userAPI } from './user.js';
export { default as productAPI } from './product.js';

// 使用
import { userAPI, productAPI } from '@/api';

13. 循环依赖处理

// 循环依赖问题
// moduleA.js
import { funcB } from './moduleB.js';

export function funcA() {
  console.log('funcA called');
  funcB();
}

// moduleB.js
import { funcA } from './moduleA.js';

export function funcB() {
  console.log('funcB called');
  funcA();
}

// 解决方案1:延迟导入
// moduleA.js
export function funcA() {
  console.log('funcA called');
  import('./moduleB.js').then(module => {
    module.funcB();
  });
}

// 解决方案2:重构代码结构
// 将共享逻辑提取到第三个模块中

14. 动态导入与代码分割

// 动态导入实现代码分割
// router.js
const routes = [
  {
    path: '/home',
    component: () => import('./views/Home.vue')
  },
  {
    path: '/about',
    component: () => import('./views/About.vue')
  },
  {
    path: '/admin',
    component: () => import('./views/Admin.vue')
  }
];

// 条件导入
async function loadFeature() {
  if (shouldLoadFeature) {
    const module = await import('./feature.js');
    module.init();
  }
}

// 预加载
const preloadModule = import('./heavy-module.js');

// 当需要时使用
async function useModule() {
  const module = await preloadModule;
  module.doSomething();
}

现代构建工具中的模块化

15. Vite中的模块处理

// vite.config.js
import { defineConfig } from 'vite';
import path from 'path';

export default defineConfig({
  // 解析配置
  resolve: {
    // 路径别名
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '@components': path.resolve(__dirname, 'src/components'),
      '@utils': path.resolve(__dirname, 'src/utils')
    },
    
    // 扩展名
    extensions: ['.js', '.json', '.vue']
  },
  
  // 构建优化
  build: {
    // 代码分割
    rollupOptions: {
      output: {
        manualChunks: {
          // 将node_modules中的代码打包到vendor
          'vendor': ['vue', 'vue-router', 'pinia'],
          // 第三方UI库
          'ui': ['element-plus']
        }
      }
    },
    
    // 压缩配置
    minify: 'terser',
    terserOptions: {
      compress: {
        drop_console: true,
        drop_debugger: true
      }
    }
  }
});

16. Webpack中的模块处理

// webpack.config.js
const path = require('path');

module.exports = {
  // 入口配置
  entry: {
    main: './src/main.js',
    vendor: './src/vendor.js'
  },
  
  // 输出配置
  output: {
    path: path.resolve(__dirname, 'dist'),
    filename: '[name].[contenthash].js',
    chunkFilename: '[name].[contenthash].js'
  },
  
  // 模块解析
  resolve: {
    extensions: ['.js', '.json', '.vue'],
    alias: {
      '@': path.resolve(__dirname, 'src'),
      '@components': path.resolve(__dirname, 'src/components')
    }
  },
  
  // 优化配置
  optimization: {
    // 代码分割
    splitChunks: {
      chunks: 'all',
      cacheGroups: {
        vendor: {
          test: /[\\/]node_modules[\\/]/,
          name: 'vendors',
          priority: 10
        },
        common: {
          name: 'common',
          minChunks: 2,
          priority: 5
        }
      }
    },
    
    // Tree Shaking
    usedExports: true,
    sideEffects: false
  }
};

性能优化

17. Tree Shaking优化

// utils.js
// 只导出需要的函数,避免打包无用代码
export const usedFunction = () => {
  console.log('这个函数会被使用');
};

export const unusedFunction = () => {
  console.log('这个函数不会被使用,会被tree-shaking移除');
};

// package.json
{
  "sideEffects": false,
  // 或者指定有副作用的文件
  "sideEffects": [
    "*.css",
    "./src/polyfill.js"
  ]
}

18. 模块预加载与预获取

// 预加载 - 高优先级
const preloadModule = () => {
  const link = document.createElement('link');
  link.rel = 'modulepreload';
  link.href = '/heavy-module.js';
  document.head.appendChild(link);
};

// 预获取 - 低优先级
const prefetchModule = () => {
  const link = document.createElement('link');
  link.rel = 'moduleprefetch';
  link.href = '/future-module.js';
  document.head.appendChild(link);
};

// 在路由导航时预加载
router.beforeEach((to, from, next) => {
  if (to.path === '/dashboard') {
    preloadModule();
  }
  next();
});

总结

JavaScript模块化经历了从无到有、从简单到复杂的演进过程:

发展历程

  1. 早期阶段:全局变量、命名空间
  2. CommonJS:Node.js服务端模块化
  3. AMD:浏览器端异步模块化
  4. ES Modules:官方标准,统一前后端

核心优势

  1. 代码组织:清晰的模块边界和职责
  2. 依赖管理:明确的依赖关系
  3. 可维护性:易于维护和重构
  4. 性能优化:支持tree-shaking和代码分割
  5. 开发体验:更好的IDE支持和类型推断

最佳实践

  1. 统一使用ES Modules:现代项目首选ESM
  2. 合理组织模块:按功能划分模块结构
  3. 避免循环依赖:重构代码结构解决循环依赖
  4. 利用动态导入:实现代码分割和懒加载
  5. 配置构建工具:充分利用tree-shaking等优化

学习建议

  1. 理解模块化的历史和演进
  2. 掌握ES Modules的语法和特性
  3. 学习构建工具的模块处理机制
  4. 实践性能优化技巧
  5. 关注模块化的最新发展

模块化是现代JavaScript开发的基础,掌握它对于成为一名优秀的前端开发者至关重要。随着JavaScript生态的不断发展,模块化技术也在不断演进,保持学习和实践是跟上技术发展的关键。


本文首发于掘金,欢迎关注我的专栏获取更多前端技术干货!

你的 Vue 组件正在偷偷吃掉内存!5 个常见的内存泄漏陷阱与修复方案

上周,我们收到用户反馈:“你们的后台系统,用一天后 Chrome 占了 4GB 内存!”

打开 DevTools 的 Memory 面板,一拍快照——
已分离的 DOM 节点(Detached DOM trees)堆积如山,组件实例成百上千……

问题不在业务逻辑,而在 “你以为组件销毁了,其实它还在”

今天,我就带你揪出 Vue 3 项目中 5 个最隐蔽的内存泄漏陷阱,并给出一行代码就能修复的方案。尤其第 3 个,90% 的人都中过招。


先搞懂:Vue 组件什么时候会“泄漏”?

理想情况下,组件卸载时:

  • 响应式数据自动清理
  • 事件监听器自动移除
  • 定时器/异步任务自动取消

但现实是:如果你手动绑定了外部资源,Vue 不会帮你清理!

记住:Vue 只管理“自己创建的东西”,不管理你“借来的资源”。


陷阱 1:忘记清理全局事件监听器

// 危险!组件卸载后,window.resize 依然触发 oldHandler
onMounted(() => {
  const handleResize = () => { /* ... */ };
  window.addEventListener('resize', handleResize);
});

修复:在 onUnmounted 中移除

onMounted(() => {
  const handleResize = () => { /* ... */ };
  window.addEventListener('resize', handleResize);
  
  onUnmounted(() => {
    window.removeEventListener('resize', handleResize);
  });
});

进阶技巧:封装成 composable

// composables/useEventListener.ts
export function useEventListener(target, event, handler) {
  onMounted(() => target.addEventListener(event, handler));
  onUnmounted(() => target.removeEventListener(event, handler));
}

陷阱 2:未取消的定时器 or 异步请求

// 组件销毁后,setTimeout 仍会执行,可能操作已销毁的 ref
onMounted(() => {
  setTimeout(() => {
    someRef.value = 'updated'; // Ref 已失效,但 JS 仍在跑
  }, 5000);
});

修复:用 AbortController 或 isMounted 标志

onMounted(() => {
  const timer = setTimeout(() => {
    if (!isUnmounted) someRef.value = 'updated';
  }, 5000);

  onUnmounted(() => {
    clearTimeout(timer);
    isUnmounted = true;
  });
});

更优雅:用 AbortSignal(适用于 fetch / WebSocket)

const controller = new AbortController();
fetch('/api/data', { signal: controller.signal });

onUnmounted(() => controller.abort());

陷阱 3:第三方库实例未销毁(最常见!)

比如 ECharts、Monaco Editor、Mapbox……

// 组件卸载了,但 echarts 实例还在内存中持有 DOM 引用
let chart;
onMounted(() => {
  chart = echarts.init(dom);
});

修复:调用库提供的 destroy 方法

onMounted(() => {
  chart = echarts.init(dom);
});

onUnmounted(() => {
  chart?.dispose(); // 关键!
  chart = null;
});

如果库没提供 destroy?用 markRaw + 手动置 null(见下文技巧)


陷阱 4:响应式对象持有外部引用

const state = reactive({
  element: document.getElementById('my-el') // 持有 DOM 引用
});

即使组件卸载,state 若被其他地方引用(如全局缓存),整个 DOM 树都无法 GC

修复:避免将非响应式对象(DOM、第三方实例)放入 reactive/ref

// 用 shallowRef 或普通变量
const element = document.getElementById('my-el'); // 普通变量,无响应式包裹
const chart = shallowRef(null); // 内部不递归响应式

原则:只有需要“驱动视图更新”的数据,才放进响应式系统。


陷阱 5:闭包导致的隐式引用

onMounted(() => {
  const largeData = new Array(100000).fill('data');
  
  const callback = () => {
    console.log(largeData.length); // 闭包持有 largeData
  };

  someGlobalEmitter.on('event', callback);
  
  // 忘记在 onUnmounted 中 off!
});

即使组件卸载,callback 仍被全局 emitter 持有 → largeData 无法释放。

修复:确保移除所有外部注册

onUnmounted(() => {
  someGlobalEmitter.off('event', callback);
});

自查清单:上线前必做 3 件事

  1. 打开 Chrome DevTools → Memory → 拍快照

    • 切换路由多次,看组件实例是否持续增长
    • 搜索 “Detached” 查看游离 DOM
  2. 审查所有 onMounted

    • 是否有 addEventListener / setInterval / 第三方 init?
    • 是否都有对应的 onUnmounted 清理?
  3. 避免在 reactive 中存非 UI 状态

    • 图表实例、WebSocket、大型配置 → 用 shallowRef 或普通变量

最后说两句

内存泄漏不像报错那样“大声提醒你”,
它像温水煮青蛙——等你发现时,用户已经流失了

但只要记住一句话:

“你借的资源,你负责还。”

Vue 会管好自己的事,剩下的,靠你。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

别再忽略 Promise 拒绝了!你的 Node.js 服务正在“静默自杀”

它不报错、不报警、不重启——直到凌晨三点用户投诉全线崩溃。

你是否写过这样的代码?

app.post('/api/notify', (req, res) => {
  sendEmail(req.body.email); // 忘记 await,也没 catch
  res.status(200).send('OK');
});

async function sendEmail(email) {
  await smtpClient.send({ to: email, subject: 'Welcome!' });
}

看起来一切正常?
但只要 smtpClient.send() 抛出异常(比如网络超时、邮箱无效),一个未处理的 Promise 拒绝(Unhandled Rejection)就诞生了

而在 Node.js 中,这颗“定时炸弹”可能直接导致进程退出——悄无声息,不留痕迹。


为什么 Unhandled Rejection 如此危险?

从 Node.js v15 开始,官方默认行为已改为:

任何未处理的 Promise 拒绝都会导致进程直接退出!

是的,你没看错——不是警告,不是日志,是直接 kill 掉整个服务

即使你用 PM2、Docker 或 Kubernetes 托管,服务也会不断重启 → 崩溃 → 再重启,形成“死亡循环”。

更可怕的是:

  • 错误可能发生在非主流程(如埋点、日志上报、异步通知);
  • 用户请求已返回成功(res.send 已调用),你以为“没问题”;
  • 实际后台任务失败,且无人知晓,直到数据丢失、订单漏发……

真实案例:一封邮件毁掉整站

某电商平台在用户下单后异步发送通知:

orderService.create(order);
sendNotification(order.userId); // 忘记处理异常

某天第三方通知服务宕机,sendNotification 抛出错误。
由于未捕获,Node.js 进程退出。
K8s 自动重启 Pod,但新请求进来又触发同样逻辑 → 全站每分钟崩溃一次
运维查了两小时日志才发现:根本没有 error 日志!只有进程退出记录

根源?一个被忽略的 await


三大常见“漏网之鱼”

场景一:忘记 await 且不 catch

// 危险!fire-and-forget 但未处理拒绝
fireAndForgetTask();

// 正确做法:至少 catch
fireAndForgetTask().catch(err => logger.warn('Task failed', err));

场景二:在 Promise.all 中部分失败

// 只要一个 reject,整个 Promise.all 就 reject
// 如果外层没 catch,就是 unhandled rejection!
await Promise.all([
  fetchA(),
  fetchB(), // 假设这个失败了
  fetchC()
]);

解决方案:用 Promise.allSettled 或单独 catch 每个任务。

场景三:在事件监听器或定时器中抛出异步错误

emitter.on('data', async (d) => {
  await process(d); // 如果 process 抛错,没人 catch!
});

这类错误完全脱离主调用栈,极易遗漏。


防御策略:四重保险,杜绝静默崩溃

第一重:全局监听(兜底)

在应用入口添加:

process.on('unhandledRejection', (reason, promise) => {
  console.error('Unhandled Rejection at:', promise, 'reason:', reason);
  // 发送告警(如 Sentry、企业微信)
  // 注意:不要在这里 exit!先记录,再优雅关闭
});

// 同样建议监听 uncaughtException(同步错误)
process.on('uncaughtException', (err) => {
  console.error('Uncaught Exception:', err);
});

全局监听只是“最后防线”,不能替代代码层面的错误处理


第二重:严格使用 await + try/catch

app.post('/api/notify', async (req, res) => {
  try {
    await sendEmail(req.body.email);
    res.send('OK');
  } catch (err) {
    logger.error('Send email failed', err);
    res.status(500).send('Failed');
  }
});

第三重:对“fire-and-forget”任务显式处理

如果确实不需要等待结果(如打点、日志),也要 .catch

// 明确表示“我知道可能失败,但我选择忽略”
sendAnalytics(event).catch(err => {
  // 至少记录,避免 unhandled rejection
  logger.debug('Analytics failed (ignored)', err);
});

第四重:ESLint + TypeScript 防呆

配置 ESLint 规则:

{
  "rules": {
    "require-await": "error",
    "no-void": "warn"
  }
}

或者用 TypeScript 的 Promise<void> 显式标注,配合 lint 工具提醒未处理的 Promise。


终极心法:所有异步操作,必须有“归宿”

无论是:

  • API 调用
  • 数据库写入
  • 消息队列投递
  • 文件读写

只要它返回 Promise,你就必须回答一个问题:

“如果它失败了,谁来负责?”

如果没有答案,那就是隐患。


结语

Node.js 的优雅在于异步非阻塞,
但它的脆弱也藏在每一个被忽略的 reject 里。

别让一个小小的 await 缺失,
毁掉你精心构建的高可用服务。

从今天起,没有“无所谓”的异步调用,只有“已处理”和“待修复”

转发给你团队里那个总说“异步不用 catch”的人吧!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

别再被setTimeout闭包坑了!90% 的人都写错过这个经典循环

你以为只是“延迟执行”?其实变量早就被偷换了!

在 JavaScript 中,setTimeout 是最常用的异步工具之一。但当它和 for 循环、闭包一起出现时,无数开发者都踩过同一个坑

for (var i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 你期待输出 0,1,2?实际却是 3,3,3!
  }, 100);
}

图片

为什么?
因为 var + setTimeout + 闭包 = 变量共享陷阱

今天我们就彻底拆解这个经典问题,并告诉你如何用现代 JS 写出正确、安全、可维护的延迟逻辑。


问题根源:var 的函数作用域 + 异步执行

关键点有二:

1. var 没有块级作用域

for 循环中的 var i 实际上是在整个函数(或全局)作用域中声明一次,所有循环迭代共享同一个 i

2. setTimeout 是异步的

setTimeout 的回调真正执行时,for 循环早已结束,此时 i 的值已经是 3(循环终止条件)。

所以三个回调都引用了同一个已经变成 3 的变量i


常见错误解法(别再用了!)

解法一:用 setTimeout 第三个参数传参(可行但不推荐)

for (var i = 0; i < 3; i++) {
  setTimeout((x) => {
    console.log(x);
  }, 100, i); // 把 i 作为参数传入
}

虽然能工作,但:

  • 语义不直观;
  • 回调函数签名被污染;
  • 在复杂逻辑中难以维护。

解法二:立即执行函数(IIFE)——过时方案

for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(() => {
      console.log(j);
    }, 100);
  })(i);
}

这确实能创建新作用域,但:

  • 代码冗长;
  • 阅读成本高;
  • ES6 之后已有更优雅方案

正确姿势:用 let 声明循环变量

这是最简单、最现代、最推荐的方式:

for (let i = 0; i < 3; i++) {
  setTimeout(() => {
    console.log(i); // 输出 0, 1, 2 
  }, 100);
}

图片

为什么 let 能解决?

  • let 具有块级作用域
  • 每次循环迭代都会创建一个新的绑定(binding)
  • 每个 setTimeout 回调捕获的是当前迭代的独立 i,互不干扰。

这不是“魔法”,而是 ES6 规范明确规定的语义。


更复杂的场景:循环中创建函数数组

陷阱不止出现在 setTimeout,任何异步回调或延迟执行的函数都可能中招:

const handlers = [];
for (var i = 0; i < 3; i++) {
  handlers.push(() => console.log(i));
}

handlers.forEach(fn => fn()); // 输出 3,3,3 

修复方式同样简单:

const handlers = [];
for (let i = 0; i < 3; i++) {
  handlers.push(() => console.log(i)); // 输出 0,1,2 
}

或者用 Array.map 等函数式写法,天然避免问题:

const handlers = [0, 1, 2].map(i => () => console.log(i));

特别提醒:Node.js 和浏览器都一样!

这个陷阱与运行环境无关,无论是:

  • 浏览器中的事件监听;
  • Node.js 中的定时任务;
  • React/Vue 中的副作用处理;

只要涉及 var + 异步 + 循环,就可能出错。


终极建议:彻底告别 var

在现代 JavaScript 工程中:

  • 默认使用const(不可变绑定);
  • 需要重赋值时用let
  • 永远不要用var(除非维护老代码)。

配合 ESLint 规则:

{
  "rules": {
    "no-var": "error"
  }
}

从源头杜绝此类问题。


结语

setTimeout 本身没有错,错的是我们对作用域和闭包的理解偏差。
let 的出现,正是为了终结这类“反直觉”的陷阱。

下次当你写循环+异步时,请记住:

不是代码跑错了,是你还在用十年前的变量声明方式。

升级你的语法,远离闭包陷阱!

转发给那个还在用 var 写循环的同事吧!


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

❌