普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月20日掘金 前端

Windows 下执行 pnpm install 报 EBUSY: resource busy or locked,我最后用这一招解决了

2026年4月20日 11:00

大家好我是舒一笑不秃头,喜欢写作和分享,更多精彩内容~

一次看起来像“依赖安装失败”的问题,最后定位下来,其实不是依赖冲突,也不是版本不兼容,而是 Windows 文件锁 + pnpm 链接机制 触发的经典问题。
如果你也遇到过下面这种报错,这篇文章可以帮你少走很多弯路。


一、问题现场

最近我在本地初始化一个前端 monorepo 项目,执行:

pnpm i

结果安装过程前面都很顺利,依赖解析、下载、写入基本都完成了,最后却在收尾阶段直接翻车:

EBUSY: resource busy or locked, symlink 
'E:\WebstormProjects\rag_web_ais\node_modules.pnpm\vue-eslint-parser@10.4.0_eslint@8.57.1\node_modules\vue-eslint-parser' 
-> 
'E:\WebstormProjects\rag_web_ais\node_modules\vue-eslint-parser'

完整报错堆栈里还能看到:

pnpm: EBUSY: resource busy or locked, symlink ...
at async Object.symlink ...
at async forceSymlink ...
at async symlinkHoistedDependency ...

看到这里,很多人第一反应可能是:

  • 是不是依赖冲突了?
  • 是不是 vue-eslint-parser 版本不对?
  • 是不是 lock 文件坏了?
  • 是不是 eslint 版本不兼容?

但实际上,这些方向大概率都不是根因。


二、先说结论:这不是“安装失败”,而是“链接失败”

这个问题的关键点在于:

pnpm 并不是像 npm/yarn 那样简单地把所有依赖平铺到 node_modules
它会先把真实依赖存到 .pnpm 目录,再通过符号链接或类似链接机制,把包映射到根 node_modules

也就是说,这次失败不是:

  • 包没下载下来
  • 依赖没解析成功
  • 某个包本身装不上

而是:

.pnpm 里的包已经准备好了,但 pnpm 在把它链接到根 node_modules 时,被 Windows 拦住了。

这个认知很重要。
因为如果你一开始方向就错了,后面会一直在版本、锁文件、依赖树上浪费时间。


三、我怎么判断它不是依赖问题?

我当时进一步做了几步验证。

1. 先看根目录目标是否存在

dir node_modules\vue-eslint-parser

结果:

File Not Found

这说明什么?

说明 pnpm 想创建的目标路径 node_modules\vue-eslint-parser,压根还没创建成功。


2. 再看 .pnpm 里面的真实包是否已经存在

dir node_modules.pnpm | findstr vue-eslint-parser

结果能看到:

vue-eslint-parser@10.4.0_eslint@8.57.1
vue-eslint-parser@9.4.3_eslint@8.57.1

这一步几乎已经把问题坐实了:

  • 包已经在 .pnpm
  • 根节点链接没建出来
  • 报错点正好是 symlink

所以根因很清楚:

不是依赖装不上,而是 Windows 在创建链接这一刻返回了 EBUSY


四、为什么 Windows 更容易出现这个问题?

这个问题在 Windows 上很典型,尤其是下面这些场景叠加时:

  • WebStorm / VS Code 正在索引项目
  • TypeScript / ESLint 后台服务扫描 node_modules
  • Windows Defender 实时查杀正在扫描新创建的目录和链接
  • 资源管理器正打开项目目录
  • 某些同步软件或文件监控工具正在监听变更

你表面上看到的是:

EBUSY: resource busy or locked

翻译成人话其实就是:

“我现在想动这个文件/目录/链接,但有别的进程正在碰它。”

这也是为什么很多人反复执行 pnpm install,永远解决不了。
因为你没有处理“占用者”,只是不断重试同一个动作。


五、我一开始也走了几条弯路

一开始我也试过这些常规动作:

1. 杀 node.exe

taskkill /F /IM node.exe

结果:

ERROR: The process "node.exe" not found.

2. 杀 WebStorm64.exe

taskkill /F /IM WebStorm64.exe

结果:

ERROR: The process "WebStorm64.exe" not found.

这说明两件事:

  • 当前不是前台 Node 进程在占用
  • 也不一定是 WebStorm 主进程名直接锁住

也就是说, “占用者存在”不代表你一定能第一时间猜对进程名。


六、真正关键的排查思路

当时我把问题拆成了两部分去判断:

第一类:是不是残留目录导致的?

比如之前失败后留下了半成品目录,下一次安装冲突。

但检查后发现:

  • 根节点 node_modules\vue-eslint-parser 不存在
  • 删除它时提示找不到文件

说明不是“旧目标没删干净”。


第二类:是不是在创建链接时被锁住了?

这一类和错误现象完全吻合:

  • .pnpm 内部包存在
  • 根层链接不存在
  • 失败点是 symlink
  • 报错是 EBUSY

所以我最后把排查重点从“依赖本身”切换到了:

Windows 文件锁 + pnpm 链接机制兼容性


七、最后真正解决我的方案:切换为 hoisted linker

我最后采用的方式非常简单:

在项目根目录 .npmrc 中加一行:

node-linker=hoisted

然后删掉 node_modules,重新安装:

rmdir /S /Q node_modules
pnpm install

结果:直接通过。


八、为什么这个方案有效?

因为默认情况下,pnpm 更依赖它自己的链接式 node_modules 结构。
而我这次出问题的恰恰就是“根层链接创建”这一步。

切换成:

node-linker=hoisted

之后,node_modules 的组织方式会更接近传统的扁平安装结构,很多 Windows 下的链接创建问题就被绕开了。

你可以把它理解成:

  • 默认模式:更严格、更节省空间、链接更多
  • hoisted 模式:兼容性更强,尤其适合某些 Windows 环境

所以这不是“乱改配置”,而是一个很典型的环境兼容性兜底策略


九、哪些信息看起来很吓人,但其实不是主因?

安装日志里还有很多 warning,比如:

  • DeprecationWarning: url.parse()
  • deprecated eslint
  • deprecated vue-i18n
  • deprecated subdependencies found

这些都很容易把人带偏。

但这类 warning 的特点是:

  • 它们是告警,不是中断点
  • 它们不会直接导致 EBUSY
  • 它们和“资源被占用”不是一个问题域

真正导致安装终止的,是最后那个:

EBUSY ... symlink ...

所以遇到这类日志时,一定要学会抓主因,不要被“满屏 warning”带跑。


十、给大家一个最短解决路径

如果你在 Windows 下执行 pnpm install,遇到类似这种错误:

EBUSY: resource busy or locked, symlink ...

我建议你直接按下面顺序处理。

方案一:先尝试常规清理

rmdir /S /Q node_modules
pnpm store prune
pnpm install

如果不行,再继续。


方案二:直接切换为 hoisted linker

.npmrc 中加:

node-linker=hoisted

然后重新安装:

rmdir /S /Q node_modules
pnpm install

这个是我最终解决问题的方案。


方案三:检查是否有后台进程占用

重点怀疑这些:

  • Windows Defender
  • IDE 索引进程
  • 资源管理器
  • 同步软件
  • 文件监控工具

如果你想更严谨地查,可以用资源监视器搜项目目录名,或者搜报错里的包名。


十一、我的建议:什么时候该用这个方案?

适合直接上 node-linker=hoisted 的情况

  • 你在 Windows 环境开发
  • 项目是 monorepo
  • pnpm install 经常在链接阶段报错
  • 你当前目标是先把环境稳定装起来

先别急着改的情况

  • 你在 Linux / macOS
  • 团队对 pnpm 默认结构依赖很强
  • 当前只是偶发一次文件锁问题
  • 你更想优先治理 IDE/杀毒/同步软件占用

也就是说:

node-linker=hoisted 更像是一种工程上的稳定性兜底方案,而不是唯一正确答案。


十二、这次问题给我的最大启发

很多安装问题,表面上看是“包管理器报错”,但本质上可能是:

  • 操作系统文件锁
  • 工具链目录结构机制
  • IDE/安全软件/同步进程的干扰
  • 环境兼容性问题

真正的排障思路应该是:

1. 先判断失败发生在哪一层

  • 依赖解析?
  • 包下载?
  • 文件写入?
  • 链接创建?

2. 再判断是“内容问题”还是“机制问题”

  • 是版本冲突?
  • 还是文件系统行为?

3. 最后再决定是“修根因”还是“换路径”

  • 治理 Defender / IDE 占用
  • 或者改用 hoisted 这种更兼容的模式

这比一上来就删锁文件、换镜像、降版本,要高效得多。


十三、最终结论

这次问题的本质不是:

  • vue-eslint-parser 有问题
  • eslint 有问题
  • pnpm 坏了
  • 依赖冲突了

而是:

Windows 环境下,pnpm 在创建根层链接时被系统占用机制拦住了。

我最终通过下面这行配置解决:

node-linker=hoisted

然后重新安装,问题消失。


十四、给同样踩坑同学的一句话建议

如果你在 Windows 下遇到:

EBUSY: resource busy or locked, symlink ...

不要一上来怀疑依赖版本。

先想一件事:

“是不是包已经装好了,只是在建立链接的时候被系统锁住了?”

一旦你从这个角度切进去,定位速度会快很多。


十五、可直接复制的最终解决方案

# .npmrc
node-linker=hoisted
rmdir /S /Q node_modules
pnpm install

十六、结尾

如果你也在 Windows + pnpm + monorepo 环境里踩过类似的坑,欢迎在评论区聊聊你遇到的是:

  • EBUSY
  • EPERM
  • symlink
  • rename
  • unlink

这类问题我后面也可以继续整理一篇 Windows 前端工程环境疑难杂症排障手册

【节点】[Lerp节点]原理解析与实际应用

作者 SmalBox
2026年4月20日 10:59

【Unity Shader Graph 使用与特效实现】专栏-直达

在 Unity URP 渲染管线中,ShaderGraph 作为一款可视化着色器编辑工具,使开发者无需编写复杂代码即可实现高级材质效果。Lerp 节点作为 ShaderGraph 的核心数学运算节点之一,承担线性插值的关键功能,广泛应用于颜色过渡、纹理混合与动画控制等场景。本文将从原理、应用及实战技巧三个维度深入解析 Lerp 节点,并通过新增的案例分析帮助开发者掌握这一重要工具。

Lerp 节点核心原理

线性插值数学基础

Lerp 节点基于线性插值公式 a + (b - a) * t 实现平滑过渡,其中:

  • ab 为输入值(可为颜色、向量、浮点数等)
  • t 为插值器(取值范围为 0 到 1 的浮点数,用于控制权重分配)

t = 0 时输出 at = 1 时输出 bt = 0.5 时输出 ab 的中间值。这一特性使其成为参数平滑调节的理想选择。

节点功能特性

  • 多维度支持:可处理颜色(RGBA)、向量(2D/3D/4D)及浮点数等多种数据类型
  • 动态控制:通过外部参数(如粒子系统、顶点颜色)实时调整 t
  • 性能优化:底层通过 HLSL 代码实现高效计算,适用于移动端及主机平台
  • 精度控制:支持半精度和全精度浮点运算,可根据目标平台灵活调整

Lerp 节点应用场景

颜色过渡动画

昼夜交替效果:通过随时间变化的 t 值,实现天空颜色从日落到黑夜的平滑过渡。例如:

  • 输入 a 为白天蓝色(0, 0.8, 1)
  • 输入 b 为夜晚深蓝色(0.05, 0.1, 0.2)
  • 使用 Time 节点驱动 t 值,实现自动渐变

角色生命值指示器:在游戏UI或角色材质中,通过 Lerp 节点实现生命值颜色从绿色(满血)到红色(低血量)的直观变化,t 值由角色当前生命值比例驱动,增强游戏反馈的视觉表现力。

环境氛围调节:在开放世界游戏中,通过 Lerp 节点实现不同生物群系间的颜色过渡,例如从森林的翠绿色渐变到沙漠的金黄色,t 值由玩家位置与区域边界的距离决定,创造无缝的世界体验。

纹理混合

基于遮罩的混合:利用黑白纹理作为 t 值,实现两幅纹理的像素级混合:

  • 输入 a 为基础纹理(如草地)
  • 输入 b 为叠加纹理(如雪地)
  • 输入 t 为遮罩纹理(白色区域显示 b,黑色区域显示 a
  • 通过 Remap 节点将纹理灰度值转换为 0-1 范围

动态水面反射:结合法线贴图与反射纹理,使用 Lerp 节点根据视角角度混合水面高光与反射细节,t 值由视角向量与水面法线的点积计算得出,实现更真实的水面光学效果。

材质磨损效果:在写实类游戏中,通过 Lerp 节点混合新旧两种材质状态,t 值由使用时间或物理碰撞次数驱动,实现武器、装备的自然磨损表现。

位置动画

物体移动控制:使物体从起始位置线性移动到目标位置:

  • 输入 a 为起始位置(如 (0, 0, 0)
  • 输入 b 为目标位置(如 (5, 0, 0)
  • 输入 t 为动画进度(0-1 的浮点数)
  • 输出位置可驱动物体变换组件实现平滑移动

摄像机轨道运动:在过场动画中,通过多个 Lerp 节点串联实现摄像机沿预定路径的平滑移动,每个节点控制一段路径的过渡,结合 Smoothstep 节点优化运动曲线,消除机械感。

布料模拟辅助:在角色服装系统中,通过 Lerp 节点在布料物理模拟的关键点之间进行插值,减少计算开销的同时保持视觉上的自然摆动。

进阶技巧与优化

与自定义节点配合

通过创建 Custom 节点扩展功能:

  • 复刻蓝图节点:编写 HLSL 代码实现 lerp(A, B, L) 功能,保留参数化接口
  • 模糊效果:结合 SceneColor 节点实现热扭曲效果,t 值控制扭曲强度
  • 高级混合模式:通过自定义节点实现非线性插值,如正弦曲线过渡,为特定艺术风格提供支持

性能优化策略

  • 预计算序列图:将多张模糊纹理合成为 2×2 序列图,减少采样次数
  • 动态参数控制:使用粒子系统 Custom 节点或顶点颜色单通道驱动 t 值,降低计算开销
  • 条件优化:在移动端项目中,对于不需要实时变化的插值效果,可将 t 值烘焙为常量或使用预计算纹理,显著减少片元着色器的动态分支开销。
  • LOD系统集成:根据物体与摄像机的距离,使用不同复杂度的 Lerp 节点网络,远距离时使用简化的单次插值,近距离时启用多层混合,实现性能与质量的智能平衡。

常见问题解决

  • 透明材质兼容性:启用 RenderPipelineAssess 中的 OpaqueTexture 选项,确保 SceneColor 节点正确采样
  • UV 偏移控制:通过 Gather Texture 2D 节点获取相邻像素,实现精细的纹理过渡效果
  • HDR颜色处理:当处理高动态范围颜色时,建议在插值前对输入颜色进行 Tonemapping 处理,避免插值过程中出现不自然的过曝或色偏现象。
  • 伽马校正:在涉及颜色插值时,需注意线性空间与伽马空间的转换,确保插值结果在视觉上的准确性。

实战案例:景深模糊效果

实现步骤

  1. 创建材质:新建 URP 材质,选择 PBR Graph 模板
  2. 构建节点网络
    • 使用 TexObject 节点采样主纹理
    • 创建四个 Texture Sample 节点,分别采样不同模糊程度的序列图
    • 通过 Lerp 节点实现多级模糊过渡,t 值由距离参数驱动
  3. 参数化控制:将 t 值暴露为材质属性,支持运行时动态调整

效果对比

  • 近距离t 值 0.2,模糊强度弱
  • 中距离t 值 0.5,模糊强度中
  • 远距离t 值 0.8,模糊强度强

扩展应用:动态天气系统

通过组合多个 Lerp 节点,实现雨雪天气的渐进变化:

  • 第一级 Lerp 控制降水强度,t 值由天气系统参数驱动
  • 第二级 Lerp 混合干湿路面材质,t 值由降水强度和持续时间共同决定
  • 第三级 Lerp 调整环境光颜色,模拟阴天到晴天的过渡

总结与拓展

Lerp 节点作为 ShaderGraph 的基石,其应用远不止于基础过渡。开发者可结合以下方向深入探索:

  • 高级混合技术:与 Gather Texture 2D 节点配合实现边缘检测
  • 动态效果:通过 Time 节点驱动 t 值,创建周期性动画
  • 跨平台优化:针对移动端简化插值计算,保持性能与质量的平衡
  • VR/AR适配:在虚拟现实和增强现实应用中,通过 Lerp 节点实现虚实融合的平滑过渡,增强沉浸感
  • 性能分析工具:结合 Unity Profiler 监控 Lerp 节点在不同硬件上的执行效率,为优化提供数据支持

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

管理后台框架 AI 时代的版本答案,Fantastic-admin 6.0 它来了!

作者 Hooray
2026年4月20日 10:24

之前写了一篇《AI 时代的管理后台框架,应该是什么样子?》文章,我的一些见解得到了蛮多人的认同。

如果说那篇文章是我对 AI 时代管理后台框架的全部理解,那这篇文章就是从理论到落地的一份完美答卷。

那就少废话,直接看东西。

文章内包含部分专业版特性。

v6-released.png

AI Skills

Fantastic-admin 6.0 最核心的一点,是把后台开发里常见的高频操作沉淀成了一套可复用的 AI Skills,这是一套和 Fantastic-admin 目录结构、组件用法、路由方式、设置体系绑定的工作流,让 AI 从一开始就按框架规则工作。

更重要的是,我想解决的不是“AI 能不能写后台页面”,而是“当你在使用 Fantastic-admin 时,AI 能不能像我(作者)一样,熟悉并按照框架的规则稳定交付”。这是两个完全不同的问题,而前者是一个概率问题,后者才是真正能落地的生产力。

以下是目前提供的 Skills :

  • fa-crud-page-generator:生成完整 CRUD 模块
  • fa-form-builder:生成独立表单页
  • fa-framework-settings:修改框架设置
  • fa-i18n-manager:管理国际化
  • fa-page-optimizer:优化页面并替换为框架内建组件
  • fa-route-generator:创建或修改路由
  • fa-slot-creator:创建布局插槽
  • fa-store-generator:生成 Store 模块
  • fa-theme-customizer:定制主题配色

你可以非常直接地告诉它:

  • 主题切换成蓝色,默认深色模式,不需要圆角;导航菜单改为顶部模式,风格为圆点;启用标签栏,风格选择现代,并且要在工具栏下方展示;工具栏开启收藏夹;最后开启页面水印
  • 生成一个黑客帝国风格的主题,创建好后直接使用,同时默认为深色模式
  • 做一个商品管理模块,支持搜索、分页、新增、编辑、删除,并使用假数据,最后配置一个可访问的一级路由
  • 给xx页面增加国际化支持

通常 AI 会根据你的描述信息,自动调用相关的 skill ,当然你也可以更明确的告诉 AI 使用哪一个 skill ,就像这样:

  • claude code:/fa-framework-settings 改为顶部导航栏模式
  • codex:$fa-framework-settings 改为顶部导航栏模式

这里我也上传了几个视频,方便大家能直观的看到使用 skill 的方式和效果:

fa-framework-settings 演示视频 fa-theme-customizer 演示视频 fa-crud-page-generator 演示视频 fa-i18n-manager 演示视频

Monorepo

Fantastic-admin 6.0 采用了 pnpm monorepo 架构。

这么做有工程治理层面的考虑,但更重要的是,我希望“代码、文档、约定、技能”能够在同一个仓库里形成闭环。对于人来说,这是更清楚的工程边界;对于 Agent 来说,这是一张更完整的信息地图。

fantastic-admin/
├── apps/              # 应用目录
│   ├── core           # 应用源码
│   └── example        # 示例应用
├── packages/          # 公共包目录
├── docs/              # 文档站点
├── scripts/           # 脚本工具
├── skills/            # AI 技能
└── package.json       # 根目录 package.json

这样的结构对长期项目特别重要,因为它天然更适合多应用扩展、公共能力沉淀和后续维护,也更方便 AI 理解“哪些是业务代码,哪些是框架能力”。

了解更多点这里

80+ 内建组件

Fantastic-admin 6.0 给 5.0 的内建组件做了全方位的重构,并且新增了以下组件:

至此,Fantastic-admin 的内建组件数量也来到了 80+ ,即便你不使用 Element-plus / Ant Design Vue / NaiveUI 这些第三方 UI 组件库,仅靠框架提供的内建组件,也能构建出大部分业务页面。

并且更重要的一点是,比起第三方组件库的“可调用”,内建组件是“可修改”的,并且每个组件目录内都有完整的 markdown 使用文档。

因为在 AI 时代,一个被黑盒包裹得太深的组件体系,长期价值其实会下降。并且 AI 擅长的也不是调用 API ,而是:

  • 阅读现有代码
  • 理解现有代码
  • 修改现有代码
  • 基于现有代码继续延展

组件满足不了业务需求?随时可以让 AI 来先读再改,分分钟定制一份专属组件,这是使用第三方组件库基本不敢想的事。

说明:Fantastic-admin 内建组件的定位并不是代替第三方组件库,而是提供了一些更贴合业务场景、美化视觉交互、风格尽量和框架保持一致的组件,通常是作为第三方组件库的补充。

了解更多点这里

19 个预留插槽

Fantastic-admin 6.0 增加了布局顶部和底部插槽,支持的插槽数量也来到了 19 个。

你问什么是预留插槽?就是允许开发者在一定限度内满足客制化的需求,并且无需修改框架核心部分源码,这也是大部分后台框架没有提供的能力。

而通过这个能力,可以在框架各个区域扩展属于自己产品的内容,比如:

  • 网站顶部的横幅公告

  • 标题右侧的切换组织功能

  • 网站底部支持伸缩的站点地图

了解更多点这里

锁屏

了解更多点这里

多账号管理

了解更多点这里

路由级的页面布局配置

除了全局设置页面布局,现在可以针对每个路由单独设置页面布局。

了解更多点这里

区域权限控制

在做国际化业务场景时,可以对某个路由做区域访问限制。例如某个模块,只允许中文用户访问,其他语言则无法访问。

了解更多点这里

RTL 模式跟随语言设置

在 v5.x 里,RTL 模式是一个配置项,可以开启或关闭,但这其实并不合理,因为可能会出现明明是中文界面,却误开启了 RTL 。

现在将 RTL 这个开关移除并收纳进了语言信息中,也就是当用户切换语言的时候,如果该语言是需要 RTL 的,框架会自动开启。

了解更多点这里

偏好设置支持更细粒度的自定义

几乎所有同类的后台框架都没有提供偏好设置这个能力,而是固定将几个配置项做了本地存储,例如主题、导航栏模式。

而我在 v5.x 里就已经提供了一份偏好设置的方案,只不过当时的方案并不完美,需要通过注释或取消注释代码的方式,才能将部分框架能力开放给用户自定义,并且也不支持更细粒度的自定义。

但在 6.0 里一切都解决了,除应用配置外,框架其余 40+ 个配置项(涵盖主题、导航菜单、顶栏、标签栏、工具栏、页面),均可以轻松开启偏好设置,开启的配置项则用户可以根据使用习惯自行调整。

了解更多点这里

还有吗?

没有了,6.0 的新特性大概就是以上这些。

但考虑到大部分人可能是第一次了解到 Fantastic-admin ,我再介绍几个 6.0 版本之前就有提供,并且也是广受好评的特性。

7 款导航菜单模式

自由选择 UI 组件库

框架提供了 Ant Design Vue / Antdv Next / Arco Design Vue / Naive UI / Tdesign / Vexip UI 6 款组件库的预设模版,开箱即用,免去你自己集成。

当然你也可以自行集成其他的 UI 组件库,比如公司内部的,框架提供了统一的接入入口,方便快速更换。

了解更多点这里

可控的保活策略

页面保活这件事,很多框架都做得太粗糙了,通常只提供一个 keepAlive: true 的开关,虽然能解决一部分问题,但真实后台项目的诉求往往更复杂:

  • 从列表进详情,希望列表保活
  • 从列表跳其他模块,希望列表不保活
  • 标签页合并(Fantastic-admin专有功能)后,进入某些页面要保活,进入某些页面又必须释放保活

框架提供了一套精细化的保活策略配置,满足复杂业务场景。

了解更多点这里

标签页合并

提供了两种合并模式:

  1. 根据 routeName 合并,比如反复从列表页多次打开详情页,始终保持一个详情页(标签页)

  1. 根据 activeMenu 合并,比如一个模块内,列表、详情、编辑页(或者更多相关页面),始终保持只有一个标签页

标签页行为和路由行为保持一致

后台框架通常会提供一些标签页的 API ,比如打开、关闭等,但在 Fantastic-admin 里,提供了进一步的加强。

  • 后退自动关闭标签页,调用 router.go(delta) 时会关闭当前标签页,通常在详情页返回列表页时会用到
  • 替换当前标签页,调用 router.replace(to) 时会直接更新当前标签页,而不是新打开一个
  • 关闭标签页,扩展了一个路由的 API ,调用 router.close(to) 时会关闭当前标签页,并新打开一个目标路由的标签页

这3个行为和路由的行为预期保持了一致,优势就是开发者通常不再需要关注标签页的 API 了,正常处理路由跳转时,标签栏会自动做处理。

了解更多点这里

所以,为什么说 Fantastic-admin 是 AI 时代的版本答案?

相信看到这里,答案已经不言而喻了。

围绕着 monorepo 搭建的工程底座,让“代码、文档、约定、技能”能够在同一个仓库里形成闭环,从而实现长期演进。

结合 AGENTS.md 和 Skills ,让 AI 每次执行任务不再是重新了解,而是有明确的指导方针。

最后搭配上 Fantastic-admin 出色的系统设计,兼顾“人类开发者的效率”和“AI 协作的稳定性”。

fantastic-admin.hurui.me_.png

如果你需要一个要长期维护、持续扩展、并且希望真正把 AI 引入开发流程的项目,那么 Fantastic-admin 6.0 全新版本值得你看看。

ArkUI List 图片拖动排序最佳实践

2026年4月20日 10:15

ArkUI List 拖动排序最佳实践

在 ArkUI 中实现列表拖动排序,主要依赖 List 组件的 onMove 回调。结合 @Local 的响应式更新,几行代码就能实现功能。

数据层

@Local imageUris: Array<string> = [];

渲染层

List() {
  ForEach(this.imageUris, (uri: string, index: number) => {
    ListItem() {
      Stack() {
        Image(uri)
          .width('100%')
          .height(150)
          .objectFit(ImageFit.Cover)
          .borderRadius(10)

        Stack()
          .width('100%')
          .height(150)
      }
    }
    .margin(10)
    .borderRadius(10)
    .backgroundColor('#FFFFFFFF')
  }, (uri: string) => uri)
    .onMove((from: number, to: number) => {
      let tmp = this.imageUris.splice(from, 1);
      this.imageUris.splice(to, 0, tmp[0]);
    })
}

onMove 回调

onMove 是整个拖动排序的核心。当手指长按某个 ListItem 并拖动到新位置时,系统会触发这个回调,并传入两个索引:被拖动项的原位置 from,和目标位置 to

这里的实现利用了数组的 splice 方法两步走:

  1. splice(from, 1) — 从原位置切出被拖动的元素
  2. splice(to, 0, ...) — 将其插入到目标位置

由于数组引用没有变化(splice 是 in-place 操作),@Local 的响应式驱动需要依赖 ArkUI 的代理对象机制。在 ForEach 中使用稳定的 key 配合 splice 操作,框架能正确追踪数组变化并触发最小粒度的 UI 更新。

如果需要保留排序结果到持久化存储,比如 UserInfo 或数据库,可以在 onMove 回调末尾追加对应的保存逻辑。

图片选择

配合系统 PhotoViewPicker,最多选 20 张:

async selectImages(): Promise<void> {
  const photoPicker = new photoAccessHelper.PhotoViewPicker();
  const photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
  photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
  photoSelectOptions.maxSelectNumber = 20;

  const photoSelectResult = await photoPicker.select(photoSelectOptions);
  if (photoSelectResult?.photoUris?.length > 0) {
    this.imageUris = [...this.imageUris, ...photoSelectResult.photoUris];
  }
}

组件层级

List (onMove 接收拖动事件)
├── ListItem (长按触发拖动)
│   └── Stack (布局容器)
│       ├── Image (图片展示)
│       └── Stack (透明覆盖层,透传手势)
└── ListItem ...

注意事项

  • 透明覆盖层 — 当 ListItem 内只包含 Image 时,Image 组件会优先处理手势,导致 onMove 无法触发。在 Image 上方添加一个透明 Stack 铺满父容器尺寸,将手势透传给 ListItem。Stack 默认裁剪超出内容,圆角设置不受影响。
  • ForEach 需要稳定的 key — 使用 (uri: string) => uri 作为唯一标识,避免排序时列表项渲染错乱。

你的 Vue KeepAlive 组件,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月20日 10:11

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中内置的 <KeepAlive> 组件经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 <KeepAlive> 组件的用法。

编译对照

KeepAlive:组件缓存

<KeepAlive> 是 Vue 中用于缓存组件实例的内置组件,可以在动态切换组件时保留组件状态,避免重新渲染和数据丢失。

基础 KeepAlive 使用

  • Vue 代码:
<template>
  <KeepAlive>
    <component :is="currentView" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
import { KeepAlive } from '@vureact/runtime-core';

<KeepAlive>
  <Component is={currentView} />
</KeepAlive>

从示例可以看到:Vue 的 <KeepAlive> 组件被编译为 VuReact Runtime 提供的 KeepAlive 适配组件,可理解为「React 版的 Vue KeepAlive」。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue <KeepAlive> 的行为,实现组件实例缓存
  2. 状态保持:缓存被移除的组件实例,避免状态丢失
  3. 性能优化:减少不必要的组件重新渲染
  4. React 适配:在 React 环境中实现 Vue 的缓存语义

带 key 的 KeepAlive

为了确保缓存正确工作,建议为动态组件提供稳定的 key

  • Vue 代码:
<template>
  <KeepAlive>
    <component :is="currentComponent" :key="componentKey" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive>
  <Component is={currentComponent} key={componentKey} />
</KeepAlive>

key 的重要性

  1. 缓存标识key 用于标识和匹配缓存实例
  2. 稳定切换:确保组件切换时能正确命中缓存
  3. 性能优化:避免不必要的缓存创建和销毁
  4. 最佳实践:始终为动态组件提供稳定的 key

包含与排除控制

<KeepAlive> 支持通过 includeexclude 属性精确控制哪些组件需要缓存。

include:包含特定组件

  • Vue 代码:
<template>
  <KeepAlive :include="['ComponentA', 'ComponentB']">
    <component :is="currentView" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive include={['ComponentA', 'ComponentB']}>
  <Component is={currentView} />
</KeepAlive>

exclude:排除特定组件

  • Vue 代码:
<template>
  <KeepAlive :exclude="['GuestPanel', /^Temp/]">
    <component :is="currentView" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive exclude={['GuestPanel', /^Temp/]}>
  <Component is={currentView} />
</KeepAlive>

匹配规则

  1. 字符串匹配:精确匹配组件名
  2. 正则表达式:匹配符合模式的组件名
  3. 数组组合:支持字符串和正则的数组组合
  4. key 匹配:同时尝试匹配组件名和缓存 key

最大缓存实例数

通过 max 属性可以限制最大缓存数量,避免内存过度使用。

  • Vue 代码:
<template>
  <KeepAlive :max="3">
    <component :is="currentTab" />
  </KeepAlive>
</template>
  • VuReact 编译后 React 代码:
<KeepAlive max={3}>
  <Component is={currentTab} />
</KeepAlive>

缓存淘汰策略

  1. LRU 算法:淘汰最久未访问的缓存实例
  2. 内存管理:自动清理超出限制的缓存
  3. 性能平衡:在内存使用和性能之间取得平衡
  4. 智能管理:根据访问频率智能管理缓存

缓存生命周期

<KeepAlive> 缓存的组件有特殊的生命周期,可以通过相应的 Hook 监听。

激活与停用生命周期

  • Vue 代码:
<script setup>
import { onActivated, onDeactivated } from 'vue';

onActivated(() => {
  console.log('组件被激活');
});

onDeactivated(() => {
  console.log('组件被停用');
});
</script>
  • VuReact 编译后 React 代码:
import { useActived, useDeactivated } from '@vureact/runtime-core';

function MyComponent() {
  useActived(() => {
    console.log('组件被激活');
  });

  useDeactivated(() => {
    console.log('组件被停用');
  });

  return <div>组件内容</div>;
}

生命周期事件

  1. useActived:组件从缓存中恢复显示时触发
  2. useDeactivated:组件被缓存时触发
  3. 首次渲染:组件首次渲染时也会触发 activated
  4. 最终卸载:组件最终被销毁时触发 deactivated

编译策略总结

VuReact 的 KeepAlive 编译策略展示了完整的组件缓存转换能力

  1. 组件直接映射:将 Vue <KeepAlive> 直接映射为 VuReact 的 <KeepAlive>
  2. 属性完全支持:支持 includeexcludemax 等所有属性
  3. 生命周期适配:将 Vue 生命周期 Hook 转换为 React Hook
  4. 缓存语义保持:完全保持 Vue 的缓存行为和语义

KeepAlive 的工作原理

  1. 实例缓存:组件切出时保留实例在内存中
  2. 状态保持:保持组件的所有状态和数据
  3. DOM 保留:保留组件的 DOM 结构
  4. 智能恢复:切回时快速恢复之前的实例

性能优化策略

  1. 按需缓存:只缓存真正需要的组件
  2. 内存管理:智能管理缓存内存使用
  3. 快速恢复:优化缓存恢复性能
  4. 垃圾回收:及时清理不再需要的缓存

注意事项

  1. 单一子节点<KeepAlive> 只能有一个直接子节点
  2. 组件类型:只能缓存组件元素,不能缓存普通元素
  3. key 要求:缺少稳定 key 时会降级为非缓存渲染

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动实现组件缓存逻辑。编译后的代码既保持了 Vue 的缓存语义和性能优势,又符合 React 的组件设计模式,让迁移后的应用保持完整的组件缓存能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

在NFT项目中集成IPFS:从Pinata上传到前端展示的完整踩坑指南

作者 竹林818
2026年4月20日 10:02

背景

上个月,我接了一个生成式NFT项目的活。简单说,就是让用户在前端组合不同的图层(背景、角色、道具),生成一张独特的图片,然后把它铸造成NFT。功能做起来挺顺,直到卡在最后一步:怎么把用户生成的图片和对应的元数据(比如名称、描述、属性)存起来?

直接存服务器肯定不行,项目一停,所有NFT就成“死图”了。必须用去中心化存储。IPFS(星际文件系统)是标准答案,文件上传后会得到一个唯一的CID(内容标识符),只要网络上有一个节点存着这份文件,它就能被访问。但问题来了,怎么让文件在IPFS网络上“钉住”(Pin),确保它不因为没人访问而被垃圾回收?自己搭节点维护成本太高,所以得找个靠谱的“钉住”服务。一番调研后,我选了 Pinata。它提供了简单的API和不错的免费额度,正好适合这个项目。

我的任务很明确:在前端实现用户图片上传到IPFS(通过Pinata),拿到CID,然后构造出符合ERC-721标准的元数据JSON文件,再把这个JSON文件也上传到IPFS,最后将JSON的CID作为tokenURI传给智能合约。听起来链路清晰,但实现时每一步都遇到了意想不到的坑。

问题分析

我最开始的思路特别“直男”:

  1. 前端用fetchaxios把图片File对象直接POST到Pinata的API。
  2. 拿到返回的CID,拼接到ipfs://后面。
  3. 用这个链接去铸币。

结果第一步就失败了。浏览器直接报了CORS错误。我查了Pinata文档,发现他们的上传API确实对前端直接调用不太友好,主要推荐用他们的SDK或者通过服务端中转。但我不想为了这个功能再搭个后端,增加复杂度和成本。

然后我尝试用他们的SDK @pinata/sdk。在React项目里装好,导入,调用,结果在构建时直接报错——这个SDK严重依赖Node.js的核心模块(比如fs, path),在前端浏览器环境里根本跑不起来。这条路也堵死了。

这时候我才意识到,从前端安全、直接地上传文件到IPFS,需要一种专门为浏览器设计的方法。我得重新规划技术路线。

核心实现

1. 放弃官方SDK,改用更轻量的上传方式

既然@pinata/sdk行不通,我转而研究Pinata的API文档。他们提供了一个名为 pinFileToIPFS 的接口,支持通过multipart/form-data格式上传文件。关键点在于认证:需要在请求头里带上一个JWT格式的Bearer Token

这个Token需要在Pinata官网的开发者面板里生成,是专为前端设计的,权限可以限制为仅上传(相比API Key更安全)。有了这个思路,我决定直接用浏览器的FormData API配合fetch来上传。

这里有个大坑:Pinata的pinFileToIPFS接口一次只能上传一个文件。但我的需求里,用户最终可能同时上传图片和元数据JSON文件(两步上传)。不过,对于单张图片上传,这个接口足够了。

// utils/pinata.ts
const PINATA_JWT = process.env.NEXT_PUBLIC_PINATA_JWT; // 注意:前端环境变量需以NEXT_PUBLIC_开头(如果你用Next.js)

export const uploadFileToIPFS = async (file: File): Promise<string> => {
  // 1. 构建FormData对象
  const formData = new FormData();
  formData.append('file', file);

  // 2. 添加可选的元数据,方便在Pinata面板管理。这里我把文件名加进去。
  const metadata = JSON.stringify({
    name: file.name,
  });
  formData.append('pinataMetadata', metadata);

  // 3. 设置Pinata的选项,这里我们设置不重复上传相同内容(节省空间)
  const options = JSON.stringify({
    cidVersion: 0,
  });
  formData.append('pinataOptions', options);

  try {
    const res = await fetch('https://api.pinata.cloud/pinning/pinFileToIPFS', {
      method: 'POST',
      headers: {
        // 关键:使用Bearer Token认证
        Authorization: `Bearer ${PINATA_JWT}`,
      },
      body: formData,
    });

    const data = await res.json();
    if (!res.ok) {
      throw new Error(`Pinata上传失败: ${data.error?.details || data.error}`);
    }
    // 返回IPFS CID (Content Identifier)
    return data.IpfsHash;
  } catch (error) {
    console.error('上传文件到IPFS失败:', error);
    throw error;
  }
};

2. 构建并上传NFT元数据JSON

拿到图片的CID(假设为imageCid)后,下一步是构建NFT的元数据。这是一个符合特定格式的JSON对象,ERC-721标准通常期望它包含namedescriptionimageattributes等字段。其中,image字段的值应该是图片的URI。

这里有个至关重要的细节:image字段的URI格式。 我一开始直接用了ipfs://${imageCid}。后来发现,很多钱包和平台(如OpenSea)对这种原生IPFS URI的支持并不一致。更通用、更推荐的做法是使用经过网关代理的HTTPS链接,比如https://gateway.pinata.cloud/ipfs/${imageCid} 或公共网关 https://ipfs.io/ipfs/${imageCid}。为了确保最大兼容性,我决定在元数据里存储网关链接。

// utils/pinata.ts
export interface NFTMetadata {
  name: string;
  description: string;
  image: string; // 使用HTTPS网关链接
  attributes: Array<{
    trait_type: string;
    value: string | number;
  }>;
}

export const uploadMetadataToIPFS = async (metadata: NFTMetadata): Promise<string> => {
  // 将元数据对象转换为JSON字符串
  const jsonString = JSON.stringify(metadata);

  // 创建一个File对象,代表我们的元数据JSON“文件”
  const metadataFile = new File([jsonString], 'metadata.json', { type: 'application/json' });

  // 复用上面的上传函数,将这个“文件”上传到IPFS
  const metadataCid = await uploadFileToIPFS(metadataFile);
  return metadataCid;
};

3. 在前端React组件中串联整个流程

现在有了上传图片和上传元数据两个工具函数,我需要在用户交互的组件里把它们串起来。场景是:用户点击“生成并铸造”按钮后,前端合成图片(得到一个Blob或DataURL),然后执行上传流程。

// components/MintButton.tsx
import React, { useState } from 'react';
import { uploadFileToIPFS, uploadMetadataToIPFS, NFTMetadata } from '../utils/pinata';
import { useContractWrite } from 'wagmi'; // 假设使用wagmi与合约交互
import abi from '../abis/MyNFT.json';

const MintButton: React.FC = () => {
  const [isMinting, setIsMinting] = useState(false);
  const { writeAsync: mint } = useContractWrite({
    address: '0xYourContractAddress',
    abi: abi,
    functionName: 'safeMint',
  });

  const handleMint = async () => {
    setIsMinting(true);
    try {
      // 1. 假设这是用户生成的图片Blob
      const imageBlob = await generateUserImage(); // 你的图片生成函数
      const imageFile = new File([imageBlob], 'nft-image.png', { type: 'image/png' });

      // 2. 上传图片到IPFS
      console.log('正在上传图片到IPFS...');
      const imageCid = await uploadFileToIPFS(imageFile);
      const imageUrl = `https://gateway.pinata.cloud/ipfs/${imageCid}`;
      console.log('图片上传成功,URL:', imageUrl);

      // 3. 构建并上传元数据
      const metadata: NFTMetadata = {
        name: '我的生成式NFT #1',
        description: '这是一个由用户生成的独特NFT。',
        image: imageUrl, // 使用网关链接!
        attributes: [
          { trait_type: '背景', value: '星空' },
          { trait_type: '角色', value: '战士' },
        ],
      };
      console.log('正在上传元数据到IPFS...');
      const metadataCid = await uploadMetadataToIPFS(metadata);
      // 构造最终传给合约的tokenURI。这里我选择将网关链接存储到链上,确保任何地方都能直接读取。
      const tokenURI = `https://gateway.pinata.cloud/ipfs/${metadataCid}`;
      console.log('元数据上传成功,tokenURI:', tokenURI);

      // 4. 调用智能合约的mint函数
      console.log('正在调用合约进行铸造...');
      const tx = await mint({
        args: [tokenURI], // 将tokenURI作为参数传入
      });
      await tx.wait();
      console.log('NFT铸造成功!');

    } catch (error) {
      console.error('铸造过程失败:', error);
      alert(`铸造失败: ${error.message}`);
    } finally {
      setIsMinting(false);
    }
  };

  return (
    <button onClick={handleMint} disabled={isMinting}>
      {isMinting ? '铸造中...' : '生成并铸造NFT'}
    </button>
  );
};

// 模拟图片生成函数
async function generateUserImage(): Promise<Blob> {
  // 这里应该是你的实际图片合成逻辑,例如用canvas绘图
  // 返回一个Blob对象
  const canvas = document.createElement('canvas');
  canvas.width = 500;
  canvas.height = 500;
  const ctx = canvas.getContext('2d');
  // ... 绘图操作
  return new Promise((resolve) => {
    canvas.toBlob((blob) => resolve(blob!), 'image/png');
  });
}

export default MintButton;

完整代码

以下是一个更完整、独立的工具函数文件示例,包含了错误处理的增强和类型定义:

// lib/ipfs.ts
export interface PinataResponse {
  IpfsHash: string;
  PinSize: number;
  Timestamp: string;
}

export interface NFTMetadata {
  name: string;
  description: string;
  image: string;
  external_url?: string;
  attributes: Array<{
    trait_type: string;
    value: string | number;
    display_type?: string;
  }>;
}

const PINATA_GATEWAY = 'https://gateway.pinata.cloud';
const PINATA_UPLOAD_URL = 'https://api.pinata.cloud/pinning/pinFileToIPFS';

/**
 * 上传任意文件到IPFS (通过Pinata)
 * @param file 要上传的File对象
 * @returns 文件的CID (IpfsHash)
 */
export const uploadToIPFS = async (file: File): Promise<string> => {
  // 环境变量检查
  const pinataJwt = process.env.NEXT_PUBLIC_PINATA_JWT;
  if (!pinataJwt) {
    throw new Error('缺少Pinata JWT环境变量配置');
  }

  const formData = new FormData();
  formData.append('file', file);

  // 添加元数据帮助识别
  const pinataMetadata = JSON.stringify({
    name: `Upload_${file.name}`,
  });
  formData.append('pinataMetadata', pinataMetadata);

  // 设置CID版本为0(默认,更广泛兼容)
  const pinataOptions = JSON.stringify({
    cidVersion: 0,
  });
  formData.append('pinataOptions', pinataOptions);

  const response = await fetch(PINATA_UPLOAD_URL, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${pinataJwt}`,
    },
    body: formData,
  });

  const data: PinataResponse & { error?: any } = await response.json();

  if (!response.ok) {
    const errorMsg = data.error?.details || data.error?.message || `HTTP ${response.status}`;
    throw new Error(`IPFS上传失败: ${errorMsg}`);
  }

  return data.IpfsHash;
};

/**
 * 上传NFT元数据到IPFS
 * @param metadata NFT元数据对象
 * @returns 元数据JSON文件的CID
 */
export const uploadNFTMetadata = async (metadata: NFTMetadata): Promise<string> => {
  const jsonString = JSON.stringify(metadata, null, 2); // 美化输出,方便调试
  const metadataFile = new File([jsonString], 'metadata.json', {
    type: 'application/json',
  });

  const metadataCid = await uploadToIPFS(metadataFile);
  return metadataCid;
};

/**
 * 根据CID生成Pinata网关URL
 * @param cid 文件CID
 * @returns 完整的网关访问URL
 */
export const getPinataGatewayUrl = (cid: string): string => {
  return `${PINATA_GATEWAY}/ipfs/${cid}`;
};

/**
 * 完整的NFT铸造预处理流程
 * 1. 上传图片
 * 2. 构建元数据
 * 3. 上传元数据
 * @param imageFile 图片文件
 * @param metadataBase 不包含image字段的基础元数据
 * @returns 最终用于合约的tokenURI (网关链接)
 */
export const prepareNFTForMinting = async (
  imageFile: File,
  metadataBase: Omit<NFTMetadata, 'image'>
): Promise<{ tokenURI: string; imageUrl: string }> => {
  // 1. 上传图片
  console.log('📤 上传图片中...');
  const imageCid = await uploadToIPFS(imageFile);
  const imageUrl = getPinataGatewayUrl(imageCid);
  console.log('✅ 图片上传成功:', imageUrl);

  // 2. 构建完整元数据
  const fullMetadata: NFTMetadata = {
    ...metadataBase,
    image: imageUrl, // 使用网关链接
  };

  // 3. 上传元数据
  console.log('📤 上传元数据中...');
  const metadataCid = await uploadNFTMetadata(fullMetadata);
  const tokenURI = getPinataGatewayUrl(metadataCid);
  console.log('✅ 元数据上传成功,tokenURI:', tokenURI);

  return { tokenURI, imageUrl };
};

踩坑记录

  1. CORS错误与SDK环境不匹配:这是开头最大的拦路虎。直接调用Pinata API遇到CORS,用官方Node.js SDK又无法在浏览器运行。解决方案:仔细阅读API文档,发现支持前端JWT Token认证的pinFileToIPFS接口,并改用FormData进行multipart/form-data格式的上传。

  2. image字段的URI格式兼容性问题:最初使用ipfs://协议头,在部分钱包内显示为空白。解决方案:在存储到元数据image字段时,统一使用Pinata或公共IPFS网关的HTTPS链接(如https://gateway.pinata.cloud/ipfs/${cid}),极大提升了跨平台的显示成功率。

  3. 上传大文件超时或失败:用户生成的图片分辨率高时,文件可能较大,上传过程中可能失败。解决方案:在前端实现上传进度提示(通过axiosonUploadProgressfetchReadableStream可以做到,但上述示例未展开),并考虑在UI上设置文件大小限制。对于极端情况,可以提示用户或考虑分片上传,但Pinata免费版有单文件大小限制,需要注意。

  4. 元数据JSON格式错误导致OpenSea解析失败:一开始attributes里的value用了复杂对象,或者JSON字符串里有非法字符。解决方案:严格遵循OpenSea等主流市场的元数据标准,确保value是字符串或数字。在上传前用JSON.stringifyJSON.parse做一次校验,确保格式正确。

小结

这次集成让我彻底搞懂了从前端到IPFS的“最后一公里”:关键在于选择正确的API接口(Pinata的pinFileToIPFS)、使用安全的认证方式(JWT Token)、以及为最大兼容性始终使用HTTPS网关链接。下一步可以探索更去中心化的方案,比如用ipfs-http-client直接连接公共网关或自己的节点,或者集成Arweave来做真正永久的存储。

你的 Vue slot 插槽,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月20日 09:17

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 <slot> 插槽经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的插槽用法。

编译对照

默认插槽:<slot>

默认插槽是 Vue 中最基本的插槽形式,用于接收父组件传递的默认内容。

  • Vue 代码:
<!-- 子组件 Child.vue -->
<template>
  <div class="container">
    <slot></slot>
  </div>
</template>

<!-- 父组件使用 -->
<Child>
  <p>这是插槽内容</p>
</Child>
  • VuReact 编译后 React 代码:
// 子组件 Child.jsx
function Child(props) {
  return (
    <div className="container">
      {props.children}
    </div>
  );
}

// 父组件使用
<Child>
  <p>这是插槽内容</p>
</Child>

从示例可以看到:Vue 的 <slot> 元素被编译为 React 的 children prop。VuReact 采用 children 编译策略,将插槽出口转换为 React 的标准 children 接收方式,完全保持 Vue 的默认插槽语义——接收父组件传递的子内容并渲染。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue 默认插槽的行为,实现内容分发
  2. React 原生支持:使用 React 标准的 children 机制,无需额外适配
  3. 语法简洁:Vue 的 <slot> 简化为 {children} 表达式
  4. 性能优化:直接使用 React 的原生机制,无运行时开销

具名插槽:<slot name="xxx">

具名插槽允许组件定义多个插槽出口,父组件可以通过名称指定内容插入位置。

  • Vue 代码:
<!-- 子组件 Layout.vue -->
<template>
  <div class="layout">
    <header>
      <slot name="header"></slot>
    </header>
    <main>
      <slot></slot>
    </main>
    <footer>
      <slot name="footer"></slot>
    </footer>
  </div>
</template>

<!-- 父组件使用 -->
<Layout>
  <template #header>
    <h1>页面标题</h1>
  </template>
  
  <p>主要内容</p>
  
  <template #footer>
    <p>版权信息</p>
  </template>
</Layout>
  • VuReact 编译后 React 代码:
// 子组件 Layout.jsx
function Layout(props) {
  return (
    <div className="layout">
      <header>{props.header}</header>
      <main>{props.children}</main>
      <footer>{props.footer}</footer>
    </div>
  );
}

// 父组件使用
<Layout
  header={<h1>页面标题</h1>}
  footer={<p>版权信息</p>}
>
  <p>主要内容</p>
</Layout>

从示例可以看到:Vue 的具名插槽 <slot name="xxx"> 被编译为 React 的 props。VuReact 采用 props 编译策略,将具名插槽出口转换为组件的命名 props,完全保持 Vue 的具名插槽语义——通过不同的 prop 名称区分不同的插槽内容。

编译规则

  1. 插槽名映射<slot name="header">header prop
  2. 默认插槽<slot>children prop
  3. props 接收:在组件函数参数中解构接收所有插槽 props

作用域插槽:<slot :prop="value">

作用域插槽允许子组件向插槽内容传递数据,实现更灵活的渲染控制。

  • Vue 代码:
<!-- 子组件 List.vue -->
<template>
  <ul>
    <li v-for="(item, i) in props.items" :key="item.id">
      <slot :item="item" :index="i"></slot>
    </li>
  </ul>
</template>

<!-- 父组件使用 -->
<List :items="users">
  <template v-slot="slotProps">
    <div class="user-item">
      {{ slotProps.index + 1 }}. {{ slotProps.item.name }}
    </div>
  </template>
</List>
  • VuReact 编译后 React 代码:
// 子组件 List.jsx
function List(props) {
  return (
    <ul>
      {props.items.map((item, index) => (
        <li key={item.id}>
          {props.children?.({ item, index })}
        </li>
      ))}
    </ul>
  );
}

// 父组件使用
<List 
  items={users}
  children={(slotProps) => (
    <div className="user-item">
      {slotProps.index + 1}. {slotProps.item.name}
    </div>
  )}
/>

从示例可以看到:Vue 的作用域插槽被编译为 React 的函数 children。VuReact 采用 函数 children 编译策略,将作用域插槽出口转换为接收参数的函数,完全保持 Vue 的作用域插槽语义——子组件通过函数调用向父组件传递数据,父组件通过函数参数接收数据并渲染。

编译规则

  1. 插槽属性转换<slot :item="item" :index="i"> → 函数参数 { item, index }
  2. 函数调用:在渲染位置调用 children() 函数并传递数据
  3. 可选链保护:使用 ?. 避免未提供插槽内容时的错误

具名作用域插槽:<slot name="xxx" :prop="value">

具名作用域插槽结合了具名插槽和作用域插槽的特性。

  • Vue 代码:
<!-- 子组件 Table.vue -->
<template>
  <table>
    <thead>
      <tr>
        <slot name="header" :columns="props.columns"></slot>
      </tr>
    </thead>
    <tbody>
      <tr v-for="row in props.data" :key="row.id">
        <slot name="body" :row="row" :columns="props.columns"></slot>
      </tr>
    </tbody>
  </table>
</template>

<!-- 父组件使用 -->
<Table :columns="tableColumns" :data="tableData">
  <template #header="headerProps">
    <th v-for="col in headerProps.columns" :key="col.id">
      {{ col.title }}
    </th>
  </template>
  
  <template #body="bodyProps">
    <td v-for="col in bodyProps.columns" :key="col.id">
      {{ bodyProps.row[col.field] }}
    </td>
  </template>
</Table>
  • VuReact 编译后 React 代码:
// 子组件 Table.jsx
function Table(props) {
  return (
    <table>
      <thead>
        <tr>
          {props.header?.({ columns: props.columns })}
        </tr>
      </thead>
      <tbody>
        {props.data.map((row) => (
          <tr key={row.id}>
            {props.body?.({ row: props.row, columns: props.columns })}
          </tr>
        ))}
      </tbody>
    </table>
  );
}

// 父组件使用
<Table
  columns={tableColumns}
  data={tableData}
  header={(headerProps) => (
    <>
      {headerProps.columns.map((col) => (
        <th key={col.id}>{col.title}</th>
      ))}
    </>
  )}
  body={(bodyProps) => (
    <>
      {bodyProps.columns.map((col) => (
        <td key={col.id}>{bodyProps.row[col.field]}</td>
      ))}
    </>
  )}
/>

编译策略

  1. 具名函数 props:具名作用域插槽转换为函数 props
  2. 参数传递:正确传递作用域参数
  3. Fragment 包装:多个元素使用 Fragment 包装
  4. 类型安全:保持 TypeScript 类型定义的完整性

插槽默认内容

Vue 支持在插槽定义处提供默认内容,当父组件没有提供插槽内容时显示。

  • Vue 代码:
<!-- 子组件 Button.vue -->
<template>
  <button class="btn">
    <slot>
      <span class="default-text">点击我</span>
    </slot>
  </button>
</template>
  • VuReact 编译后 React 代码:
// 子组件 Button.jsx
function Button(props) {
  return (
    <button className="btn">
      {props.children || <span className="default-text">点击我</span>}
    </button>
  );
}

默认内容处理规则

  1. 条件渲染:使用 || 运算符检查 children 是否存在
  2. 默认值提供:当 children 为 falsy 值时渲染默认内容
  3. React 模式:使用标准的 React 条件渲染模式

动态插槽名

Vue 支持动态的插槽名称,用于更灵活的插槽选择。

  • Vue 代码:
<!-- 子组件 DynamicSlot.vue -->
<template>
  <div>
    <slot :name="dynamicSlotName"></slot>
  </div>
</template>
  • VuReact 编译后 React 代码:
// 子组件 DynamicSlot.jsx
function DynamicSlot(props) {
  return (
    <div>
      {props[dynamicSlotName]}
    </div>
  );
}

动态插槽处理

  1. 计算属性名:使用对象计算属性语法接收动态插槽
  2. 运行时确定:插槽名在运行时确定

编译策略总结

VuReact 的 <slot> 编译策略展示了完整的插槽系统转换能力

  1. 默认插槽:转换为 React 的 children
  2. 具名插槽:转换为组件的命名 props
  3. 作用域插槽:转换为函数 children 或函数 props
  4. 默认内容:支持插槽默认内容
  5. 动态插槽:支持动态插槽名称

插槽类型映射表

Vue 插槽类型 React 对应形式 说明
<slot> children 默认插槽,作为组件的子元素
<slot name="xxx"> xxx prop 具名插槽,作为组件的属性
<slot :prop="value"> 函数 children 作用域插槽,作为接收参数的函数
<slot name="xxx" :prop="value"> 函数 xxx prop 具名作用域插槽,作为函数属性

性能优化策略

  1. 静态插槽优化:对于静态插槽内容,编译为静态 JSX
  2. 函数缓存:对于作用域插槽,智能缓存渲染函数
  3. 按需生成:根据实际使用情况生成最简化的代码
  4. 类型推导:支持在 TypeScript 中智能推导插槽的类型定义

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写插槽逻辑。编译后的代码既保持了 Vue 的语义和灵活性,又符合 React 的组件设计模式,让迁移后的应用保持完整的内容分发能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Claude Code REPL.tsx 架构深度解析

作者 毛骗导演
2026年4月19日 21:32

从一个 5000 行组件透视现代终端 AI 交互应用的工程哲学


写在前面

当你打开 Claude Code,在终端里键入一条消息,看着模型流式输出回复、工具调用弹窗逐一出现、错误恢复自动重试——这一切背后,只有一个文件在统筹全局src/src/screens/REPL.tsx,一个超过 5000 行、体积近 900KB 的 React 组件。

它是整个 Claude Code 终端 UI 的中枢神经:负责接收用户输入、调度 API 查询、管理工具权限、渲染消息列表、处理键盘快捷键、协调远程会话、控制 ~60 种对话框的优先级、展示 ~50 个 React Hook 的副作用状态。理解了这个组件,就理解了这套系统 80% 的运行逻辑。

本文从资深前端架构师视角出发,深入浅出地剖析这个巨型组件的设计哲学、核心模式、状态管理策略和性能优化手段。

前置说明:本文源码基于 Claude Code v2.1.88 反编译版本。文中引用的行号均为该文件实际位置,所有代码模式均有源码依据。


一、项目全景与技术栈

在深入 REPL.tsx 之前,先建立全局坐标系。

Claude Code 运行在一个相当复杂的技术栈上:

层次 技术选型 职责
运行时 Bun + Node.js ≥18 程序入口、模块加载
UI 渲染 React 18 + React Compiler 组件化 UI,编译器自动优化
终端框架 Ink(自定义 Fork) React 渲染到终端字符界面
布局引擎 Yoga Layout(C++) Ink 底层 flexbox 引擎
状态管理 Zustand 全局状态(AppState)
AI 通信 @anthropic-ai/sdk Claude API 调用、流式响应
构建工具 Bun bundler + feature() 编译常量 死码消除(Tree Shaking)
样式 Unicode + ANSI 控制码 全终端兼容

这是一个将 React 的声明式 UI 编程模型强行塞入终端环境的系统。Ink 通过重写 React DOM 层,用字符和 ANSI 控制码替代了 HTML/CSS,模拟了一套完整的 flexbox 布局系统——在 80×24(或更大)的字符网格上,渲染出一个交互式 AI 终端界面。

REPL.tsx 就是在这种异构环境下的"超级大国"组件。


二、Props 接口:对外契约的精妙设计

REPL 组件的 Props 类型定义了它与父组件之间的全部通信通道,共 23 个字段,分成 7 个逻辑组:

export type Props = {
  // 核心资源
  commands: Command[];           // 可用斜杠命令注册表
  initialTools: Tool[];         // 初始工具集
  initialMessages?: MessageType[];// 初始消息(resume 时填充)

  // Agent 配置
  mainThreadAgentDefinition?: AgentDefinition;

  // MCP(Model Context Protocol)
  mcpClients?: MCPServerConnection[];
  dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>;

  // 钩子回调
  pendingHookMessages?: Promise<HookResultMessage[]>;
  onBeforeQuery?: (input: string, newMessages: MessageType[]) => Promise<boolean>;
  onTurnComplete?: (messages: MessageType[]) => void | Promise<void>;

  // 远程模式
  remoteSessionConfig?: RemoteSessionConfig;  // --remote 模式
  directConnectConfig?: DirectConnectConfig;    // claude connect 模式
  sshSession?: SSHSession;                      // claude ssh 模式

  // UI 控制
  disabled?: boolean;
  disableSlashCommands?: boolean;
  thinkingConfig: ThinkingConfig;
  systemPrompt?: string;
  appendSystemPrompt?: string;

  // 任务模式
  taskListId?: string;
}

架构观察:Props 设计体现了依赖注入(DI) 思想。REPL 本身不直接 import 命令注册表、工具集、MCP 客户端等资源,而是通过 props 接收——这使得同一个 REPL 组件可以服务于:

  • 普通交互会话(main.tsx 传入本地工具)
  • 远程执行模式(--remote 模式传入 RemoteSessionConfig)
  • 直接连接模式(claude connect 传入 DirectConnectConfig)
  • SSH 隧道模式(claude ssh 传入 SSHSession)

同一个渲染树,多种执行模式,全部通过 props 组合实现,这是 React 组合模式的教科书级应用


三、三层状态架构:架构的核心

这是 REPL.tsx 最值得学习的部分——它使用了一种三层状态架构,在 React 的并发渲染模型下实现了既安全又高效的状态管理。

第一层:Zustand 全局状态(慢速、持久)

const store = useAppStateStore();        // Zustand store
const toolPermissionContext = useAppState(s => s.toolPermissionContext);
const mcp = useAppState(s => s.mcp);
const plugins = useAppState(s => s.plugins);
const agentDefinitions = useAppState(s => s.agentDefinitions);

AppState 中存储的是会话级别的持久状态:工具权限上下文、MCP 连接、插件列表、Agent 定义、会话 ID、对话 ID 等等。这是所有组件共享的真相单一来源(Single Source of Truth)。

第二层:useSyncExternalStore 同步流(高速、原子)

// QueryGuard — 查询生命周期的同步状态机
const queryGuard = React.useRef(new QueryGuard()).current;
const isQueryActive = React.useSyncExternalStore(
  queryGuard.subscribe,
  queryGuard.getSnapshot,
);

QueryGuard 是 REPL.tsx 中最精妙的设计之一。它是一个三态同步状态机

idle → dispatching → running → idle
         ↑____________↓ (cancelReservation)
  • idle:没有进行中的查询,可以出队处理新请求
  • dispatching:一个条目已出队,异步链尚未到达 onQuery(防止重入)
  • running:查询正在执行

这个状态机与 React 的 useSyncExternalStore 配对使用——这是一种同步读取外部状态但参与 React 并发模式的标准方式。它解决了旧的 isLoading + isQueryRunning 双状态模式会出现的"状态不一致"问题:React 的异步批处理导致 isLoading(React state)和 isQueryRunning(ref,sync)可能短暂不同步,而 QueryGuard 通过单一布尔值 isActive = status !== 'idle' 消除了这种可能。

第三层:useRef 突变引用(零开销、高速)

const messagesRef = useRef(messages);
const inputValueRef = useRef(inputValue);
const abortControllerRef = useRef<AbortController | null>(null);
const lastUserScrollTsRef = useRef(0);

Refs 用于高频更新的临时状态,它们:

  • 修改不触发重渲染
  • 闭包可以同步读取最新值(通过 ref.current
  • 通过精心设计的同步包装函数(如 setMessages)保持与 React state 的一致性

Zustand 模式的精妙运用

setMessages 是一个典型的 Zustand 写模式

const setMessages = useCallback((action: React.SetStateAction<MessageType[]>) => {
  const prev = messagesRef.current;
  const next = typeof action === 'function' ? action(messagesRef.current) : action;
  messagesRef.current = next;     // ← 同步更新 ref(真相)
  rawSetMessages(next);            // ← 异步更新 React state(渲染投影)
}, []);

工作原理

  1. messagesRef.current 在函数返回前就已是新值——所有同步读取(回调、事件处理器中的 messagesRef.current)永远拿到最新数据
  2. rawSetMessages(next) 让 React state 异步追赶——渲染层保持最终一致
  3. 如果有函数式更新(setMessages(prev => [...prev, newMsg])),先在 ref 上执行得到 next,再同步写入 ref,再提交给 React

这解决了 React 函数式更新中常见的"闭包陈旧"问题:在同一个调用栈里,prev 直接取自 messagesRef.current 而非 React 闭包捕获的旧值。


四、消息流:REPL 的数据血管

消息状态的核心变量

const [messages, rawSetMessages] = useState<MessageType[]>(initialMessages ?? []);
const [deferredMessages, setDeferredMessages] = useState<MessageType[]>(messages);

// 流式渲染时的临时文本(流式输出逐字符/逐行追加,不形成完整消息对象)
const [streamingText, setStreamingText] = useState<string | null>(null);

// 流式工具调用(工具名和参数正在实时显示)
const [streamingToolUses, setStreamingToolUseIDs] = useState<StreamingToolUse[]>([]);

// 流式思考内容(extended thinking 模式的思考过程)
const [streamingThinking, setStreamingThinking] = useState<StreamingThinking | null>(null);

useDeferredValue:保持输入响应的秘密

const deferredMessages = useDeferredValue(messages);
const deferredBehind = messages.length - deferredMessages.length;
if (deferredBehind > 0) {
  logForDebugging(`[useDeferredValue] Messages deferred by ${deferredBehind}`);
}

useDeferredValue 是 React 18 的并发特性。它告诉 React:如果渲染压力太大,可以先不更新这个值messages 数组变化时,React 可以延迟更新 deferredMessages,让 PromptInput(输入框)始终优先获得渲染机会,保证用户输入不卡顿。

messagesdeferredMessages 多时,说明渲染暂时落后——日志记录这个数字用于调试。

消息事件处理管道

onQueryEvent 是消息进入的主入口,它处理多种消息类型:

const onQueryEvent = useCallback((event) => {
  handleMessageFromStream(event, newMessage => {
    if (isCompactBoundaryMessage(newMessage)) {
      // 紧凑化边界消息:全屏模式下保留历史,向后追加
      setMessages(old => [
        ...getMessagesAfterCompactBoundary(old, { includeSnipped: true }),
        newMessage
      ]);
    } else if (newMessage.type === 'progress' && isEphemeralToolProgress(...)) {
      // 短暂进度消息(如 Sleep 工具的每秒心跳):替换而非追加
      setMessages(oldMessages => {
        const last = oldMessages.at(-1);
        if (last?.type === 'progress' && sameIdentity) {
          const copy = oldMessages.slice();
          copy[copy.length - 1] = newMessage;
          return copy;  // 替换,保持数组长度不变
        }
        return [...oldMessages, newMessage];
      });
    } else {
      setMessages(oldMessages => [...oldMessages, newMessage]);
    }
  }, ...);
}, [...]);

关键优化:短暂的进度消息(如 Sleep 工具每秒发出的心跳)使用原地替换而非数组追加。如果用追加方式,Sleep 运行 1 小时会在 messages 数组中积累 3600 个进度对象——这会直接导致渲染和序列化性能崩溃。原地替换让 messages.length 保持稳定。


五、查询生命周期:QueryGuard 与并发控制

QueryGuard 的完整状态机实现在 src/src/utils/QueryGuard.ts(122 行),它是整个 REPL 状态管理的核心。

状态转换图

┌─────────────────────────────────────────────────────────┐
│                      QueryGuard                         │
│                                                         │
│  idle ───reserve()──→ dispatching                      │
│    ↑         │              │                           │
│    │         ↓ cancelReservation() │                   │
│    │                          │                         │
│    │     tryStart() ◄─────────┘                         │
│    │         │                                         │
│    │         ↓                                         │
│    │      running ────end()──→ idle (正常结束)         │
│    │         │                                         │
│    │         └──forceEnd()──→ idle (用户中断)          │
│    └─────────────────────────────────────────────────────┘

与 React 的集成

const isQueryActive = React.useSyncExternalStore(
  queryGuard.subscribe,   // 订阅变化
  queryGuard.getSnapshot  // 获取快照
);
  • subscribe:通过一个简单的 signal 对象(轻量发布-订阅)通知所有订阅者
  • getSnapshot:返回 status !== 'idle'(布尔值)
  • 由于 useSyncExternalStore 保证同步读取,isQueryActive 在任何时候都是 React render tree 中可信赖的当前状态

generation 机制:防止陈旧的 finally 块

// tryStart 时递增 generation
++this._generation;

// end() 时检查是否仍是当前 generation
end(generation: number): boolean {
  if (this._generation !== generation) return false; // 跳过陈旧清理
  this._status = 'idle';
  return true;  // 执行清理
}

当用户快速取消并重新提交时,第一个查询的异步 finally 块可能比第二个查询更晚完成。generation 机制确保陈旧的清理代码不会覆盖新查询的状态。

对比旧的 dual-state 模式

// ❌ 旧模式(已废弃)
const [isLoading, setIsLoading] = useState(false);
const isQueryRunningRef = useRef(false);

// 危险:React 批处理导致 isLoading 和 isQueryRunningRef 可能短暂不一致
// 在高优先级渲染期间,isLoading 可能还没更新,但 isQueryRunningRef 已为 false
// ✅ 新模式(QueryGuard)
const isQueryActive = useSyncExternalStore(queryGuard.subscribe, queryGuard.getSnapshot);

一个布尔值,替代了旧的复杂 dual-state,双端始终一致。


六、Hook 系统:60+ 钩子的编排艺术

REPL.tsx 使用了数量惊人的自定义 Hook。如果把它们展开,逻辑可以绘制成一张复杂的依赖图。将其分类整理:

资源合并类 Hook(抽象底层差异)

const mergedTools = useMergedTools(combinedInitialTools, mcp.tools, toolPermissionContext);
const mcpClients = useMergedClients(initialMcpClients, mcp.clients);
const commandsWithPlugins = useMergedCommands(localCommands, plugins.commands);
const mergedCommands = useMergedCommands(commandsWithPlugins, mcp.commands);

这些 Hook 将本地资源、MCP 资源、插件资源统一合并,给上层组件提供单一的、合并后的数据视图。好处是:无论工具来自本地还是远程,REPL 的渲染逻辑都是统一的。

远程会话抽象

const remoteSession = useRemoteSession({ config: remoteSessionConfig, ... });
const directConnect = useDirectConnect({ config: directConnectConfig, ... });
const sshRemote = useSSHSession({ session: sshSession, ... });
const activeRemote = sshRemote.isRemoteMode ? sshRemote :
                     directConnect.isRemoteMode ? directConnect : remoteSession;

三种远程模式(--remote、WebSocket 直连、SSH 隧道)被抽象成统一接口,上层代码不需要关心底层传输协议——activeRemote 暴露统一的 isRemoteModecancelRequest()sendMessage() 接口。

通知与状态推送

useModelMigrationNotifications();
useCanSwitchToExistingSubscription();
useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus });
useMcpConnectivityStatus({ mcpClients });
usePluginInstallationStatus();
usePluginAutoupdateNotification();
useSettingsErrors();
useRateLimitWarningNotification(mainLoopModel);
useTeammateLifecycleNotification();
// ... 还有更多

这展示了 Claude Code 作为复杂企业级应用的一面:需要同时处理 API 密钥状态、IDE 连接状态、MCP 服务器状态、插件安装状态、模型迁移、速率限制等数十种异步事件。通知系统被分解成独立的 Hook,每个 Hook 负责一种通知类型,保持关注点分离。

自动化与 Agent 系统

useSwarmInitialization(setAppState, initialMessages, { enabled: !isRemoteSession });
useTaskListWatcher();
useInboxPoller();
useMailboxBridge();
useTeammateViewAutoExit();

这些 Hook 支撑着 Claude Code 的多 Agent 系统(Swarm):初始化队友会话、监听任务队列变化、通过邮箱机制跨进程通信、自动退出队友视图等。


七、渲染架构:FullscreenLayout 的分层布局

REPL.tsx 的渲染树分为两种模式

模式 A:Transcript 模式(只读、搜索)

TranscriptSearchBar(/ 搜索栏)
    
FullscreenLayout
    ├── scrollable: Messages(只读,30条限制或虚拟滚动)
    └── bottom: TranscriptModeFooter(导航提示)

Transcript 模式通过 Ctrl+O 进入,提供只读的历史记录视图,支持全文搜索(/)、按 n/N 跳转匹配项。这个模式的设计很精妙:

  • 虚拟滚动模式(FullscreenLayout + ScrollBox):支持数万行历史
  • dump 模式(跳过 AlternateScreen):30 条消息上限,适合小终端,直接使用终端原生滚动

模式 B:主交互模式(完整 UI)

KeybindingSetup(键盘快捷键根上下文)
    
    ├── AnimatedTerminalTitle(标题动画,960ms 间隔刷新)
    ├── GlobalKeybindingHandlers(全局快捷键)
    ├── CommandKeybindingHandlers(命令快捷键)
    ├── ScrollKeybindingHandler(滚动键盘导航)
    ├── CancelRequestHandler(Ctrl+C/Esc 中断处理)
    
    └── MCPConnectionManager
        
        └── FullscreenLayout(主布局容器)
            
            ├── overlay: PermissionRequest(工具权限覆盖层)
            ├── modal: CenteredModal(局部命令弹窗,如 /config)
            ├── scrollable:
               ├── TeammateViewHeader(队友视图)
               ├── Messages(主消息列表,虚拟滚动)
               ├── UserTextMessage(处理中占位符)
               ├── ToolJSX(工具输出 UI)
               └── Spacer + SpinnerWithVerb
            
            └── bottom:
                ├── TaskListV2(任务列表)
                ├── PermissionRequest / PromptDialog / CostThresholdDialog
                ├── PromptInput(核心输入组件)
                └── SessionBackgroundHint

FullscreenLayout 是整个 UI 的骨架。它将终端划分为 5 个语义区域(overlay/modal/scrollable/spacer/bottom),每个区域按需渲染。消息列表在 scrollable 中,PromptInput 在 bottom 中——这个布局设计确保了输入框始终固定在底部,而消息列表可以独立滚动

AnimatedTerminalTitle:隔离动画 tick 的优化

function AnimatedTerminalTitle({ isAnimating, title, disabled, noPrefix }) {
  const [frame, setFrame] = useState(0);
  useEffect(() => {
    if (disabled || noPrefix || !isAnimating || !terminalFocused) return;
    const interval = setInterval(() => setFrame(f => (f + 1) % 2), 960);
    return () => clearInterval(interval);
  }, [disabled, noPrefix, isAnimating, terminalFocused]);
  useTerminalTitle(disabled ? null : ...);
  return null;  // 纯副作用组件
}

这是一个纯副作用组件:它返回 null,但通过 useTerminalTitle(一个命令终端设置标签页标题的 Ink hook)产生可见效果。每 960ms 的定时器刷新 frame → 触发组件重新渲染 → setFrame 被调用 → 如果这个 hook 在 REPL 内部实现,REPL 的整个 render 树每秒会重渲染一次

将这个逻辑提取为独立组件后,960ms 的 tick 只导致这个叶子组件重渲染,REPL 的 render 树保持稳定。这是一个典型的提取纯副作用逻辑到叶子组件的优化模式。


八、输入处理:从击键到 API 调用的完整链路

输入处理主入口:onSubmit

onSubmit 是 PromptInput 提交时的回调,是用户输入进入系统的第一道门。它处理以下逻辑:

onSubmit(input)
    │
    ├─→ [立即命令检查]
    │   └─→ 如果是 "/" 开头且 command.immediate === true
    │       └─→ 执行 local-jsx 命令(如 /btw、/config)
    │           └─→ setToolJSX() 显示弹窗 UI,REPL 继续运行
    │
    ├─→ [空输入检查](远程模式下提前返回)
    │
    ├─→ [空闲返回检测]
    │   └─→ 如果用户离开超过 75 分钟 + token 数超过阈值
    │       └─→ 显示空闲返回对话框
    │
    ├─→ [队列命令检查]
    │   └─→ 如果已有命令在队列中,追加而非覆盖
    │
    └─→ [正常提交]
        ├─→ repinScroll()(滚动到底部)
        └─→ onQuery([userMessage], ...)

即时命令系统:本地 JSX 弹窗

Claude Code 的斜杠命令分为两类:

1. 即时命令(immediate):在模型处理期间也能执行

const shouldTreatAsImmediate = queryGuard.isActive &&
  (matchingCommand?.immediate || options?.fromKeybinding);
if (matchingCommand && shouldTreatAsImmediate && matchingCommand.type === 'local-jsx') {
  // /btw(顺便说一句):用户在 Claude 输出时快速记录想法
  // /config:显示配置面板
  void executeImmediateCommand();
  return; // 不加入队列,立即执行
}

2. 排队命令:等待当前查询完成后执行

关键设计点:localJSXCommandRef 跟踪当前活动的本地命令,当工具输出到达时忽略更新(除非显式清除),这允许 immediate 命令的 UI 在模型输出期间保持稳定。

onQuery:查询执行的核心

const onQuery = useCallback(async (input, helpers, speculationAccept?, options?) => {
  // 1. 状态前置检查(IDLE 检查)
  const gen = queryGuard.tryStart();
  if (gen === null) return;

  try {
    // 2. 添加用户消息
    setMessages(prev => [...prev, userMessage]);

    // 3. 等待 hook 消息(SessionStart hooks)
    await awaitPendingHooks();

    // 4. 执行查询核心
    await onQueryImpl(messagesIncludingNew, newMessages, abortController,
      shouldQuery, additionalAllowedTools, model);

  } finally {
    // 5. 重置状态(generation 检查防止陈旧)
    if (queryGuard.end(gen)) {
      resetLoadingState();
      void mrOnTurnComplete(messagesRef.current, false);
    }
  }
}, [...]);

九、键盘快捷键:四层 Keybinding 架构

Claude Code 的键盘处理分为四个层次,每层有不同的职责:

第一层:useInput(底层)
    └─→ Ink 的原始键盘事件捕获

第二层:KeybindingSetup(根上下文)
    └─→ 提供 keybinding 上下文,给所有子层共享
    └─→ 注册 "app:toggleTranscript" 等全局快捷键

第三层:GlobalKeybindingHandlers
    └─→ 全局快捷键(Ctrl+O、Ctrl+B、Ctrl+Shift+P 等)
    └─→ 独立于当前焦点,任何时候都响应

第四层:CommandKeybindingHandlers
    └─→ 命令快捷键(如 /doctor 的 Ctrl+Shift+D)
    └─→ 只在非命令弹窗激活时响应

第五层:ScrollKeybindingHandler
    └─→ 滚动相关:j/k/g/G/PageUp/PageDown
    └─→ 只在虚拟滚动启用时挂载

第六层:CancelRequestHandler
    └─→ Ctrl+C、Esc 中断处理
    └─→ Ctrl+C 带选中文本时复制而非取消

这个分层设计的精妙之处在于:快捷键的优先级和上下文敏感性完全由组件树的位置决定。顶层注册最通用的快捷键,子层注册特定上下文的快捷键——React 的组件树就是天然的优先级系统。


十、对话框系统:20 种对话框的优先级管理

REPL.tsx 实现了惊人的 20 种对话框类型,通过 getFocusedInputDialog() 函数进行集中管理:

function getFocusedInputDialog():
  | 'message-selector'           // 最高优先级:历史消息选择器
  | 'sandbox-permission'        // 沙箱权限请求
  | 'tool-permission'           // 工具使用确认
  | 'prompt'                    // 模型 Prompt 请求
  | 'worker-sandbox-permission' // Swarm worker 权限
  | 'elicitation'               // MCP 询问
  | 'cost'                      // 费用警告
  | 'idle-return'              // 空闲返回提示
  | 'ide-onboarding'            // IDE 引导
  | 'model-switch'              // 模型切换(ant-only)
  | 'undercover-callout'
  | 'effort-callout'
  | 'remote-callout'
  | 'lsp-recommendation'
  | 'plugin-hint'
  | 'desktop-upsell'
  | 'ultraplan-choice'
  | 'ultraplan-launch'
  | undefined

优先级规则

  1. 退出流程(isExiting)优先于一切
  2. 消息选择器(用户正在选历史消息)其次
  3. 输入压制isPromptInputActive)时阻止中断类对话框——用户正在打字时,权限弹窗不应该意外弹出
  4. 剩余对话框按类型逐一检查

这个函数在每次 render 时执行,返回当前应该显示的对话框类型——这是一个纯函数驱动的声明式对话框管理


十一、性能优化:5000 行不卡顿的秘密

1. 虚拟滚动(Virtual Scrolling)

对于包含数万条消息的长会话,逐行渲染所有消息会直接导致终端崩溃。Claude Code 使用了自定义虚拟滚动实现:

  • VirtualMessageList:只渲染当前视口中的消息
  • 支持数千条历史消息,DOM 节点数量始终保持在 ~50-100 个
  • Jump-to-URL 索引:搜索时使用预建索引而非全量扫描

2. Lazy Ref 初始化

// ❌ useRef 在每次渲染时求值(虽然 React 忽略,但计算仍执行)
const contentReplacementStateRef = useRef(
  provisionContentReplacementState(initialMessages, ...)
);

// ✅ useState 的 lazy initializer:只在首次渲染时执行一次
const [contentReplacementStateRef] = useState(() => ({
  current: provisionContentReplacementState(initialMessages, ...)
}));

provisionContentReplacementState 对大型会话执行 O(messages × blocks) 的重建工作——这在有数千条消息时可能耗时数百毫秒。lazy initializer 确保这个计算只发生一次。

3. Ref 镜像模式(避免重渲染链)

const streamModeRef = useRef(streamMode);
streamModeRef.current = streamMode;

streamMode 在一次查询中可能翻转 10+ 次(requesting → responding → tool-use → responding → ...)。如果 onSubmit 的依赖数组包含 streamMode,每次翻转都会重建 onSubmit,进而引发下游 PromptInput 的 props 变化和重新渲染。

通过 streamModeRef.current 的镜像模式,onSubmit 始终使用最新的 streamMode(同步读取),但依赖数组稳定不变——闭包陈旧 vs. 渲染开销的天平,向渲染侧倾斜了一个刻度

4. 流式文本节流

// Ink 的默认 render 节流是 16ms(~60fps)
// 流式 token 到达速率可能远高于此
const [streamingText, setStreamingText] = useState<string | null>(null);

// visibleStreamingText 只显示到最后一个完整行
const visibleStreamingText = streamingText && showStreamingText
  ? streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null
  : null;

流式文本只显示到上一个完整行(lastIndexOf('\n')),避免在逐字输出时出现"光标在字符间跳动"的现象,提升视觉稳定性。


十二、死码消除:feature() 编译时常量的艺术

Claude Code 使用 Bun 的 feature() 函数实现编译时特性开关,在构建阶段彻底删除未启用的代码:

// VOICE_MODE:语音集成(仅在启用时编译)
const useVoiceIntegration = feature('VOICE_MODE')
  ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration
  : () => ({
      stripTrailing: () => 0,
      handleKeyEvent: () => {},
      resetAnchor: () => {}
    });

// COORDINATOR_MODE:多智能体协调模式
const getCoordinatorUserContext = feature('COORDINATOR_MODE')
  ? require('../coordinator/coordinatorMode.js').getCoordinatorUserContext
  : () => ({});

// Ant-only:frustration detection(仅 Anthropic 内部 dogfooding)
const useFrustrationDetection = "external" === 'ant'
  ? require('../components/FeedbackSurvey/useFrustrationDetection.js')
      .useFrustrationDetection
  : () => ({ state: 'closed', handleTranscriptSelect: () => {} });

这不仅仅是为了"代码清洁",而是有实际安全价值:Ant-only 分支中包含敏感字符串(如组织 UUID),通过编译时消除,这些字符串永远不会出现在外部构建中。

每个 feature flag 都在构建配置中设置feature() 调用被 Bun 识别为编译时常量,任何不可达的代码块都会被完整删除。


十三、Swarm 系统:多 Agent 架构的协调机制

Claude Code 支持多 Agent 并行工作(Swarm),REPL.tsx 中有专门的协调机制:

// 追踪当前是否有正在运行的队友任务
const hasRunningTeammates = useMemo(() =>
  getAllInProcessTeammateTasks(tasks).some(t => t.status === 'running'),
  [tasks]
);

// 等待所有队友完成后显示汇总消息
useEffect(() => {
  if (!hasRunningTeammates && swarmStartTimeRef.current !== null) {
    const totalMs = Date.now() - swarmStartTimeRef.current;
    setMessages(prev => [
      ...prev,
      createTurnDurationMessage(totalMs, ...)
    ]);
  }
}, [hasRunningTeammates, setMessages]);
  • 领队(Leader) 可以发起队友任务,并将工具确认请求通过 registerLeaderToolUseConfirmQueue 传递给队友
  • 队友的沙箱权限请求通过 registerSandboxPermissionCallback 回传给领队
  • useMailboxBridge 处理跨进程通信

十四、关键设计哲学总结

回顾 REPL.tsx 的设计,我们可以提炼出几个值得借鉴的架构哲学:

1. 状态分层而非状态集中

Claude Code 没有把所有状态塞进一个巨大的 Zustand store。状态被分为三层:

  • Zustand:慢速、会话级别、多组件共享
  • useSyncExternalStore:中速、同步原子操作
  • useRef:高速、频繁更新、单组件内部

每层状态使用最合适的工具,不追求统一。

2. 声明式优先于命令式

对话框系统由 getFocusedInputDialog() 这个纯函数驱动,返回当前应显示的对话框类型。组件渲染部分完全是声明式的——不需要手动 show()/hide(),只需要根据状态计算应该渲染什么

3. 闭包安全性通过架构而非约定

messagesRef.current 同步更新模式,解决了 React 闭包捕获陈旧值的问题——不是靠 lint 规则或 code review,而是靠代码结构保证(同步写 ref,异步写 state)。

4. 隔离是性能优化的核心手段

AnimatedTerminalTitle 返回 null 而非渲染任何 UI——这是把副作用隔离为独立组件的极端形式。这种模式在 React 应用中经常被忽视,但它在高频更新的场景下(即使是 1fps 的定时器)也能显著减少重渲染范围。

5. 特性开关作为产品矩阵管理

feature() 编译时常量 + conditional require 模式,使得同一个代码库可以构建出功能差异巨大的多个变体(ant/internal/external),而不引入任何运行时条件判断开销。


结语

REPL.tsx 是一个在极端约束条件下(终端字符界面、React 运行时、无 DOM)建造的超大规模交互式应用。它的 5000 行代码不是为了炫耀复杂性,而是因为这个系统的语义本身就是复杂的:用户可以在任意时刻取消、在任意时刻切换模式、在任意时刻响应权限请求、在任意时刻查看队友进度、在任意时刻搜索历史。

好的架构,不是消除复杂性,而是管理复杂性。REPL.tsx 通过清晰的状态分层、同步状态机、声明式渲染、闭包安全模式和对 React 新特性的充分运用,在这样的复杂度下依然保持了代码的可理解性和可维护性。

理解这个文件,你就不只是理解了 Claude Code 的前端架构——你学到的是一套在复杂交互应用中组织状态、管理并发、处理副作用的工程哲学

React新手小白:如何入门 React 响应式交互与 JSX 艺术

作者 暗不需求
2026年4月19日 21:04

一 什么是React?? 他是基于什么的? 学了它有什么用呢??

1. 核心定义:声明式与组件化

React 的核心定位是 “用于构建用户界面的 JavaScript 库” 。它主要关注 MVC 架构中的 V(View,视图层)

  • 声明式编程 (Declarative): 在 React 中,你只需要描述界面在某种“状态”下应该长什么样,而不需要手动操作 DOM 去更新界面。当数据变动时,React 会自动处理界面的高效更新。
  • 组件化 (Component-Based): 这是 React 的灵魂。你可以将复杂的 UI 拆分成一个个独立、可复用的“组件”(Component)。每个组件拥有自己的逻辑和样式,最终像搭积木一样拼成完整的应用。

完整项目链接:gitee.com/hong-strong…


2. 三大核心技术支柱

虚拟 DOM (Virtual DOM)

传统的网页操作(真实 DOM)非常昂贵且缓慢。React 在内存中维护了一份 UI 的轻量级副本,即“虚拟 DOM”。

  1. 当状态发生变化时,React 先更新虚拟 DOM。
  2. 通过 Diff 算法 对比新旧虚拟 DOM 的差异。
  3. 仅将真正发生变化的部分更新到真实网页上(这一过程称为 Reconciliation)。

JSX 语法

React 引入了 JSX(JavaScript XML),允许你在 JavaScript 代码中直接编写类似 HTML 的结构。这使得 UI 逻辑与标记语言高度耦合,代码直观且易于维护。

JavaScript

function Welcome() {
  return <h1>Hello, React!</h1>;
}

单向数据流 (One-Way Data Flow)

在 React 中,数据总是从父组件通过 props 流向子组件。这种单向的数据流动让应用的逻辑变得可预测,调试时也更容易追踪数据的源头。


3. 为什么 React 如此受欢迎?

  • 极高的性能: 得益于虚拟 DOM 和优秀的渲染机制。
  • 强大的生态: 拥有庞大的开源社区,无论是状态管理(Redux, Zustand)、路由(React Router),还是 UI 组件库(Ant Design, MUI),都能找到成熟的方案。
  • 跨平台能力: 学习了 React 之后,你可以通过 React Native 构建原生移动应用(iOS/Android),实现“一次学习,随处编写”。
  • Hooks 革命: 自 React 16.8 引入 Hooks 以来,函数式组件(Functional Components)成为了主流,极大地简化了状态管理和副作用处理的复杂性。

二 那作为一个小白 如何初始化一个react项目呢?

我这边选择使用的是Vite,因为 Vite 是目前前端工程化的首选工具。它启动极快,热更新(HMR)几乎是瞬间完成。

步骤:

  1. 打开终端,输入以下命令:

    npm create vite
    
  2. 按照提示进行选择:

    • Select a framework: 选择 React
    • Select a variant: 选择 JavaScriptTypeScript(根据情况选择语言)
  3. 进入目录并启动:

    cd my-react-app
    npm install
    npm run dev
    
  4. 得到网址: 运行上述代码后,你会在终端得到一个类似于http://localhost:5173 网址,这样你就成功运行了你的第一个React项目 项目结构如下图所展示:

df1b4f4bbd30dbe57ece32553b1a07d5.png

三: React 的核心用法。

1:理解“挂载” —— 应用的起点

每个 React 应用都有一个入口文件(通常是 main.jsx),它的任务是将我们写的 React 组件“挂载”到真实的 HTML 页面上。

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'

// 使用 createRoot 找到 HTML 中的 root 节点,并将根组件 <App /> 渲染进去
createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

2:组件化开发 —— 像搭积木一样写网页

在 React 中,函数就是组件。组件是开发的基本单位,它将 HTML、CSS 和 JS 逻辑封装在一起,完成独立的功能。

我们可以将页面拆分成多个子组件,然后在根组件中组合它们:

// 定义子组件:头部
function JuejinHeader() {
  return (
    <header><h1>掘金首页</h1></header>
  )
}

// 定义子组件:列表
const Articles = () => <div>文章列表内容</div>;

// 在 App 根组件中组合它们
function App() {
  return (
    <div>
      <JuejinHeader />
      <main>
        <Articles />
      </main>
    </div>
  )
}

3:掌握 JSX —— 在 JS 中书写 UI

JSX(XML in JS)是 React 的模板语法,它让我们能在 JavaScript 里直接写 HTML 结构。

  • 语法糖:JSX 最终会被转化为 createElement 渲染函数。
  • 规则:JSX 最外层只能有一个根元素(可以使用空标签 <></> 作为文档碎片)。
  • 属性名:由于 class 是 JS 关键字,在 JSX 中定义类名要使用 className

第四步:响应式状态 —— 让页面“动”起来

React 的核心特性之一是响应式(Reactive) 。我们使用 useState 来定义数据状态,当状态改变时,React 会自动更新 UI。

1. 定义与更新状态

import { useState } from 'react';

function App() {
  // name 是状态值,setName 是更新它的函数
  const [name, setName] = useState("vue");

  // 3秒后自动将 "vue" 改为 "react"
  setTimeout(() => {
    setName("react"); 
  }, 3000);

  return <h1>Hello {name}!</h1>;
}

2. 条件渲染与列表渲染

你可以利用原生 JS 的逻辑(如三元运算符或 map 函数)来控制界面的显示:

{/* 列表渲染:记得给每个子项添加唯一的 key */}
<ul>
  {todos.map(todo => (
    <li key={todo.id}>{todo.title}</li>
  ))}
</ul>

{/* 条件渲染:登录逻辑切换 */}
{isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
<button onClick={() => setIsLoggedIn(!isLoggedIn)}>
  {isLoggedIn ? "退出" : "登录"}
</button>

四 React基本知识总结

核心维度 知识要点 代码示例 / 实现细节
组件定义 组件是 React 的基本开发单位,通常表现为返回 JSX 的 JavaScript 函数 function App() { return <div>...</div> }
JSX 语法 XML in JS。一种在 JS 中描述 UI 结构的语法扩展,本质是 createElement 的语法糖。 const element = <h2>JSX 语法扩展</h2>;
JSX 约束 1. 必须有且仅有一个根元素; 2. 标签名大写为组件,小写为原生 HTML。 return (<> ... </>) (使用 Fragments 文档碎片)
属性命名 由于 JS 关键字限制,HTML 的 class 属性需写作 className <span className="title">...</span>
组件化思维 像“搭积木”一样。通过组件树嵌套子组件来构建复杂页面,代替传统的 DOM 树。 <main> <Articles /> <aside><Checkin /></aside> </main>
响应式状态 使用 useState 定义数据。当状态改变时,React 会自动触发界面更新(数据驱动视图)。 const [name, setName] = useState("vue");
列表渲染 使用原生 JS 的 .map() 方法循环数据,且每个子项必须提供唯一的 key todos.map(todo => <li key={todo.id}>{todo.title}</li>)
条件渲染 在 JSX 中使用 三元运算符 或逻辑运算符根据状态显示不同的内容。 {isLoggedIn ? <div>已登录</div> : <div>未登录</div>}
事件处理 使用驼峰式命名的属性绑定交互函数(如 onClick)。 <button onClick={toggleLogin}>登录</button>
项目挂载 应用的入口。使用 createRoot 找到容器并调用 render 挂载根组件。 createRoot(document.getElementById('root')).render(<App />)

总结:欢迎来到 React 的世界

学习React其实不难,只要你登上了这几个台阶,一步一个脚印,视野就会豁然开朗:

“数据是灵魂,组件是肉体,JSX 是灵魂与肉体对话的诗篇。”

你已经掌握了现代前端最强大的武器,请记住这三条锦囊:


1. 核心心法的内化

  • 状态即真相:通过 useState 让数据驱动视图,数组返回的状态值与更新函数是你操控页面的唯一魔法。
  • 组件即模块:像搭积木一样,将复杂的页面拆解成 HeaderArticlesCheckin 等独立单元,这会让你从“搬砖工”晋升为“包工头”。
  • JSX 即桥梁:这种将 XML 融入 JS 的语法,是你描述用户界面最直观、最高效的方式。

2. 给新手的进阶建议

  • 拥抱 Vite 的速度:不要在环境配置上浪费太多时间,利用 Vite 的极速热更新去快速验证你的每一个奇思妙想。
  • 尊重单向数据流:数据总是从父组件流向子组件,这种“长幼有序”的传递方式会让你的代码逻辑极其清晰。
  • 报错是你的导师:React 的报错信息往往非常直观,它们不是阻碍,而是指引你优化代码的地图。

3. 最后的行动指南

与其在文档里反复徘徊,不如在编辑器里反复横跳。

  • 多拆分:如果一个组件超过了 100 行,试着把它拆成两个。
  • 多联想:看到任何一个网站,试着在脑海中用组件树去拆解它。
  • 多实践:React 的魅力不在于“看懂”,而在于当你写下 setName 时,页面如你所愿跳动的那一瞬间。

多敲代码 多学知识 多上手实践 我相信你我都能做得更好!

Vue v-slot → 用 VuReact 转换后变成这样的 React 代码

作者 Ruihong
2026年4月19日 20:39

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-slot 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-slot 指令用法。

编译对照

v-slot / #:基础插槽使用

v-slot(简写为 #) 是 Vue 中用于定义和使用插槽的指令,用于实现组件的内容分发和复用。

默认插槽

  • Vue 代码:
<!-- 父组件 -->
<MyComponent>
  <template #default>
    <p>默认插槽内容</p>
  </template>
</MyComponent>

<!-- 或简写 -->
<MyComponent>
  <p>默认插槽内容</p>
</MyComponent>
  • VuReact 编译后 React 代码:
// 父组件
<MyComponent>
  <p>默认插槽内容</p>
</MyComponent>

从示例可以看到:Vue 的默认插槽被直接编译为 React 的 children。VuReact 采用 children 编译策略,将模板插槽转换为 React 的标准 children 传递方式,完全保持 Vue 的默认插槽语义——将内容作为子元素传递给组件。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue 默认插槽的行为,实现内容分发
  2. React 原生支持:使用 React 标准的 children 机制,无需额外适配
  3. 语法简化:Vue 的 <template #default> 简化为直接传递子元素
  4. 性能优化:直接使用 React 的原生机制,无运行时开销

具名插槽

Vue 支持多个具名插槽,用于更灵活的内容分发。

基础具名插槽

  • Vue 代码:
<!-- 父组件 -->
<Layout>
  <template #header>
    <h1>页面标题</h1>
  </template>
  
  <template #main>
    <p>主要内容区域</p>
  </template>
  
  <template #footer>
    <p>页脚信息</p>
  </template>
</Layout>
  • VuReact 编译后 React 代码:
// 父组件
<Layout 
  header={<h1>页面标题</h1>}
  main={<p>主要内容区域</p>}
  footer={<p>页脚信息</p>}
/>

从示例可以看到:Vue 的具名插槽被编译为 React 的 props。VuReact 采用 props 编译策略,将具名插槽转换为组件的 props 属性,完全保持 Vue 的具名插槽语义——通过不同的 prop 名称区分不同的插槽内容。


作用域插槽

Vue 的作用域插槽允许子组件向父组件传递数据,实现更灵活的渲染控制。

基础作用域插槽

  • Vue 代码:
<!-- 父组件 -->
<DataList :items="users">
  <template #item="slotProps">
    <div class="user-item">
      <span>{{ slotProps.user.name }}</span>
      <span>{{ slotProps.user.age }}岁</span>
    </div>
  </template>
</DataList>

<!-- 子组件 DataList.vue -->
<template>
  <ul>
    <li v-for="item in props.items" :key="item.id">
      <slot name="item" :user="item"></slot>
    </li>
  </ul>
</template>
  • VuReact 编译后 React 代码:
// 父组件
<DataList 
  items={users}
  item={(slotProps) => (
    <div className="user-item">
      <span>{slotProps.user.name}</span>
      <span>{slotProps.user.age}岁</span>
    </div>
  )}
/>

// 子组件 DataList.jsx
function DataList(props) {
  return (
    <ul>
      {props.items.map((itemData) => (
        <li key={itemData.id}>
          {props.item?.({ user: itemData })}
        </li>
      ))}
    </ul>
  );
}

从示例可以看到:Vue 的作用域插槽被编译为 React 的函数 props。VuReact 采用 函数 props 编译策略,将作用域插槽转换为接收参数的函数 prop,完全保持 Vue 的作用域插槽语义——子组件通过函数调用向父组件传递数据,父组件通过函数参数接收数据并渲染。


动态插槽名

Vue 支持动态的插槽名称,用于更灵活的插槽选择。

  • Vue 代码:
<BaseLayout>
  <template #[dynamicSlotName]>
    动态插槽内容
  </template>
</BaseLayout>
  • VuReact 编译后 React 代码:
<BaseLayout 
  {...{ [dynamicSlotName]: "动态插槽内容" }}
/>

编译策略

  1. 计算属性名:使用对象计算属性语法 { [key]: value }
  2. 对象展开:通过对象展开语法应用到组件上
  3. 运行时处理:动态插槽名需要在运行时确定

插槽默认内容

Vue 支持在插槽定义处提供默认内容,当父组件没有提供插槽内容时显示。

  • Vue 代码:
<!-- 子组件 Button.vue -->
<template>
  <button class="btn">
    <slot>
      <span>默认按钮文本</span>
    </slot>
  </button>
</template>
  • VuReact 编译后 React 代码:
// 子组件 Button.jsx
function Button(props) {
  return (
    <button className="btn">
      {props.children || <span>默认按钮文本</span>}
    </button>
  );
}

默认内容处理规则

  1. children 检查:检查 children 是否存在
  2. 默认值渲染:当 children 为 falsy 值时渲染默认内容
  3. React 兼容:使用标准的 React 条件渲染模式

编译策略总结

VuReact 的 v-slot 编译策略展示了完整的插槽系统转换能力

  1. 默认插槽:转换为 React 的 children
  2. 具名插槽:转换为组件的 props
  3. 作用域插槽:转换为函数 props
  4. 动态插槽:支持动态插槽名称
  5. 默认内容:支持插槽默认内容

插槽类型映射表

Vue 插槽类型 React 对应形式 说明
默认插槽 children 作为组件的子元素
具名插槽 prop 作为组件的属性
作用域插槽 函数prop 作为接收参数的函数属性
动态插槽 计算属性 使用对象计算属性语法

性能优化策略

  1. 静态插槽优化:对于静态插槽内容,编译为静态 JSX
  2. 函数缓存:对于作用域插槽,智能缓存渲染函数
  3. 按需生成:根据实际使用情况生成最简化的代码
  4. 类型推导:智能推导插槽的类型定义

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写插槽逻辑。编译后的代码既保持了 Vue 的语义和灵活性,又符合 React 的组件设计模式,让迁移后的应用保持完整的内容分发能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

你的 Vue v-model,VuReact 会编译成什么样的 React 代码?

作者 Ruihong
2026年4月19日 20:19

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 v-model 指令经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中的 v-model 指令用法。

编译对照

v-model:基础表单双向绑定

v-model 是 Vue 中用于实现表单输入元素双向数据绑定的语法糖,它结合了 v-bindv-on 的功能。

文本输入框

  • Vue 代码:
<input v-model="keyword" />
  • VuReact 编译后 React 代码:
<input
  value={keyword.value}
  onChange={(value) => {
    keyword.value = value;
  }}
/>

从示例可以看到:Vue 的 v-model 指令被编译为 React 的受控组件模式。VuReact 采用 受控组件编译策略,将模板指令转换为 valueonChange 的组合,完全保持 Vue 的双向绑定语义——实现数据与视图的同步更新。

这种编译方式的关键特点在于:

  1. 语义一致性:完全模拟 Vue v-model 的行为,实现双向数据绑定
  2. 受控组件模式:使用 React 标准的受控组件实现
  3. 事件处理:自动处理输入事件和值更新
  4. 响应式集成:与 Vue 的响应式系统无缝集成

不同输入类型的 v-model

Vue 的 v-model 会根据输入元素的类型自动适配,VuReact 也保持了这种智能适配能力。

复选框

  • Vue 代码:
<input type="checkbox" v-model="checked" />
<input type="checkbox" value="vue" v-model="frameworks" />
  • VuReact 编译后 React 代码:
<input
  type="checkbox"
  checked={checked.value}
  onChecked={(e) => {
    checked.value = e.target.checked;
  }}
/>
<input
  type="checkbox"
  value="vue"
  checked={frameworks.value}
  onChange={(e) => {
    frameworks.value = e.target.checked;
  }}
/>

单选按钮

  • Vue 代码:
<input type="radio" value="male" v-model="gender" />
<input type="radio" value="female" v-model="gender" />
  • VuReact 编译后 React 代码:
<input
  type="radio"
  value="male"
  checked={gender.value === 'male'}
  onChange={() => { gender.value = 'male' }}
/>

<input
  type="radio"
  value="female"
  checked={gender.value === 'female'}
  onChange={() => { gender.value = 'female' }}
/>

下拉选择框

  • Vue 代码:
<select v-model="selected">
  <option value="a">选项A</option>
  <option value="b">选项B</option>
</select>
  • VuReact 编译后 React 代码:
<select
  value={selected.value}
  onChange={(e) => {
    selected.value = e.target.value;
  }}
>
  <option value="a">选项A</option>
  <option value="b">选项B</option>
</select>

v-model 修饰符

Vue 的 v-model 支持多种修饰符,用于控制数据更新的时机和格式。

.lazy 修饰符

  • Vue 代码:
<input v-model.lazy="message" />
  • VuReact 编译后 React 代码:
<input
  value={message.value}
  onBlur={(e) => {
    message.value = e.target.value;
  }}
/>

.number 修饰符

  • Vue 代码:
<input v-model.number="age" />
  • VuReact 编译后 React 代码:
<input
  value={age.value}
  onChange={(e) => {
    age.value = Number(e.target.value);
  }}
/>

.trim 修饰符

  • Vue 代码:
<input v-model.trim="username" />
  • VuReact 编译后 React 代码:
<input
  value={username.value}
  onChange={(e) => {
    username.value = e.target.value?.trim();
  }}
/>

修饰符组合

  • Vue 代码:
<input v-model.lazy.trim="search" />
  • VuReact 编译后 React 代码:
<input
  value={search.value}
  onBlur={(e) => {
    search.value = e.target.value?.trim();
  }}
/>

组件 v-model

Vue 3 对组件的 v-model 进行了重大改进,支持多个 v-model 绑定和自定义修饰符。

基础组件 v-model

  • Vue 代码:
<!-- 父组件 -->
<CustomInput v-model="inputValue" />

<!-- 子组件 CustomInput.vue -->
<script setup lang="ts">
  const props = defineProps(['modelValue']);
  const emits = defineEmits(['update:modelValue']);
</script>

<template>
  <input :value="props.modelValue" @input="(e) => emits('update:modelValue', e.target.value)" />
</template>
  • VuReact 编译后 React 代码:
// 父组件
<CustomInput
  modelValue={inputValue.value}
  onUpdateModelValue={(value) => {
    inputValue.value = value;
  }}
/>;

// 子组件 CustomInput.tsx
type ICustomInputProps = {
  modelValue?: any;
  onUpdateModelValue?: (...args: any[]) => any;
}

function CustomInput(props: ICustomInputProps) {
  return (
    <input value={props.modelValue} onChange={(e) => props.onUpdateModelValue?.(e.target.value)} />
  );
}

带参数的 v-model

  • Vue 代码:
<UserForm v-model:name="userName" v-model:email="userEmail" />
  • VuReact 编译后 React 代码:
<UserForm
  name={userName.value}
  onUpdateName={(value) => {
    userName.value = value;
  }}
  email={userEmail.value}
  onUpdateEmail={(value) => {
    userEmail.value = value;
  }}
/>

编译策略总结

VuReact 的 v-model 编译策略展示了完整的双向绑定转换能力

  1. 基础表单元素:将各种输入类型的 v-model 转换为对应的受控组件
  2. 修饰符支持:完整支持 .lazy.number.trim 等修饰符
  3. 组件 v-model:支持组件级别的双向绑定,包括多个 v-model 和自定义修饰符
  4. 事件映射:智能映射 Vue 事件到 React 事件(inputonChange 等)
  5. 类型安全:保持 TypeScript 类型定义的完整性

不同类型元素的编译映射

元素类型 Vue 事件 React 事件 值属性
input[type="text"] input onChange value
textarea input onChange value
input[type="checkbox"] change onChange checked
input[type="radio"] change onChange checked
select change onChange value

VuReact 的编译策略确保了从 Vue 到 React 的平滑迁移,开发者无需手动重写表单绑定逻辑。编译后的代码既保持了 Vue 的语义和便利性,又符合 React 的表单处理最佳实践,让迁移后的应用保持完整的表单交互能力。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

Go语言:并发编程的艺术与实践

作者 Lee川
2026年4月19日 18:32

Go语言:并发编程的艺术与实践

引言

在当今高并发、高性能的互联网时代,选择一门适合的编程语言变得尤为重要。Go语言(又称Golang)自2009年由Google推出以来,凭借其简洁的语法、强大的并发能力和出色的性能,迅速成为开发者的新宠。本文将结合实际代码示例,深入探讨Go语言的核心特性,特别是其独特的协程(Goroutine)和通道(Channel)机制。

Go语言的基本特性

Go语言的设计理念是"简单胜于复杂",这在其语法结构中体现得淋漓尽致。让我们先从基础语法开始了解:

// 声明模块
package main

// 内置模块 fmt 格式化输出
import "fmt"

func add(a int, b int) int {
    return a + b
}

// func 函数
func main() {
    fmt.Println("hello world")
    // 变量声明
    age := 18 // 这里age 已被推断为整形
    
    if age >= 18 {
        fmt.Println("成年人")
    }
    
    // 循环 没有while循环,只有for循环
    for i := 0; i<10; i++ {
        // fmt.Println(i)
    }
    
    // 切片 动态数组
    slice := []int{1,2,3}
    slice = append(slice, 4)
    fmt.Println(slice)

    // 映射
    m := map[string]int{"a":1, "b":2, "c":3}
    fmt.Println(m)
    fmt.Println(m["a"])
    
    // 结构体
    type User struct {
        Name string
        Age int
        Gender string
    }

    u := User{Name:"张三", Age:18, Gender:"男"}
}

从这段代码中,我们可以看到Go语言的几个显著特点:

  1. 简洁的语法:代码结构清晰,没有冗余的括号和分号。
  2. 类型推断:使用:=操作符可以让编译器自动推断变量类型,减少代码冗余。
  3. 强大的内置数据结构:如切片(动态数组)和映射(键值对),提供了灵活的数据存储方式。
  4. 结构体:虽然Go语言没有类的概念,但通过结构体可以实现类似的功能。
  5. 指针:支持指针操作,如代码末尾的updateAge函数所示。

协程:Go的并发利器

在Go语言中,协程(Goroutine)是其并发模型的核心。与传统的线程相比,协程是一种轻量级的执行单元,由Go运行时管理,而非操作系统。这使得Go可以轻松创建成千上万个协程而不会导致系统资源耗尽。

让我们看看协程的基本使用:

package main

import (
    "fmt"
    "time"
)

func sayHello() {
    // 假设这是耗时性的任务
    fmt.Println("hello world")
}

func main() {
    // go 关键字 告诉go 运行时,
    // 在后台开启一个新的轻量级(协程)来执行sayHello函数
    go sayHello()
    fmt.Println("main")
    // 主线程
    // 阻塞主线程 等待协程执行完毕
    time.Sleep(time.Second * 10)
}

在这段代码中,我们通过go关键字启动了一个协程来执行sayHello函数。需要注意的是,主线程不会等待协程执行完毕,因此我们需要使用time.Sleep来阻塞主线程,否则程序可能会在协程执行前就退出。

协程的优势

  1. 轻量级:协程的创建和切换成本远低于线程,每个协程只占用几KB的内存。
  2. 调度灵活:由Go运行时调度,而非操作系统,能更有效地利用系统资源。
  3. 简化并发编程:通过go关键字即可创建协程,无需复杂的线程管理代码。

通道:协程间的通信桥梁

虽然协程使并发编程变得简单,但协程间的通信却是一个挑战。Go语言通过通道(Channel)解决了这个问题,提供了一种安全、高效的协程间通信机制。

让我们看看通道的使用:

package main

import (
    "fmt"
)

func main() {
    // chan 通道 主线程和协程之间通信的通道
    // 传递数据的类型是整型
    ch := make(chan int)

    go func() { // 匿名函数
        ch <- 100
    }()
    // 从通道中接收数据
    // 阻塞主线程 等待协程执行完毕
    num := <- ch
    fmt.Println(num)
}

在这段代码中,我们创建了一个整型通道ch,然后在协程中通过ch <- 100向通道发送数据,最后在主线程中通过num := <- ch从通道接收数据。

通道的特性

  1. 阻塞性:当向通道发送数据时,如果通道已满,发送操作会阻塞;当从通道接收数据时,如果通道为空,接收操作会阻塞。
  2. 类型安全:通道只能传输指定类型的数据,确保了数据的类型安全。
  3. 同步机制:通道不仅是数据传输的媒介,也是一种同步机制,可以用来协调协程的执行。

Go语言的Web开发能力

Go语言不仅在并发编程方面表现出色,在Web开发领域也有不俗的表现。标准库提供了net/http包,使创建HTTP服务器变得非常简单:

package main

import (
    "fmt"
    "net/http"
)

func handler(w http.ResponseWriter, r *http.Request) {
    // 输出写入网络或文件输出流
    fmt.Fprintf(w,"hello world")
}

func main() {
    http.HandleFunc("/",handler)
    http.ListenAndServe(":8080",nil)
}

此外,Go语言还有许多优秀的Web框架,如Gin,它提供了更丰富的功能和更好的性能:

package main

import "github.com/gin-gonic/gin"

func main() {
    r := gin.Default()

    r.GET("/hello",func(c *gin.Context) {
        c.JSON(200, gin.H{
            "message":"hello",
        })
    })

    r.Run()
}

协程与通道的最佳实践

协程的使用场景

  1. I/O密集型任务:如网络请求、文件操作等,协程可以在等待I/O操作完成时让出CPU,提高系统利用率。
  2. 并行计算:对于可以并行处理的任务,如数据处理、图像处理等,可以使用多个协程同时执行,提高处理速度。
  3. 后台任务:如定时任务、监控任务等,可以在后台运行,不影响主业务流程。

通道的使用技巧

  1. 缓冲通道:对于非阻塞的场景,可以使用缓冲通道,如ch := make(chan int, 10),可以存储10个元素。
  2. 关闭通道:当不再需要通道时,应该关闭通道,如close(ch),以避免资源泄漏。
  3. select语句:可以同时监听多个通道的操作,提高代码的灵活性。

协程与通道的实际应用

让我们通过一个简单的例子来展示协程和通道的实际应用:

假设我们需要从多个API获取数据,然后汇总结果。使用协程和通道可以大大提高效率:

func fetchData(url string) int {
    // 模拟网络请求
    time.Sleep(time.Second)
    return len(url)
}

func main() {
    urls := []string{
        "https://api.example.com/data1",
        "https://api.example.com/data2",
        "https://api.example.com/data3",
    }
    
    ch := make(chan int)
    
    for _, url := range urls {
        go func(u string) {
            ch <- fetchData(u)
        }(url)
    }
    
    total := 0
    for range urls {
        total += <-ch
    }
    
    fmt.Printf("总数据长度: %d\n", total)
}

在这个例子中,我们为每个URL启动一个协程来获取数据,然后通过通道收集结果。这样可以并行处理多个请求,大大减少总执行时间。

结论

Go语言以其简洁的语法、强大的并发能力和出色的性能,为现代软件开发提供了一种新的选择。特别是其独特的协程和通道机制,使得并发编程变得简单而优雅,让开发者能够更专注于业务逻辑的实现,而不是底层的并发控制。

正如Go语言的设计哲学所说:"不要通过共享内存来通信,而是通过通信来共享内存"。这种基于通道的通信方式,避免了传统并发编程中的锁和竞争条件问题,使得代码更加安全、可靠。

无论是构建高性能的Web服务,还是处理大规模的并发任务,Go语言都展现出了其独特的优势。随着Go生态系统的不断完善和发展,相信它会在未来的软件开发中发挥越来越重要的作用。

延伸阅读

通过本文的介绍,希望您对Go语言的协程和通道有了更深入的了解。如果您有任何问题或建议,欢迎在评论区留言讨论。

深入理解React Fiber架构:渲染流程与双缓冲机制全解析

作者 前端缘梦
2026年4月19日 18:28

在React开发中,我们常听说“Fiber架构”“渲染流程”“双缓冲”这些概念,很多开发者只知其名,却不解其理——为什么React 16要引入Fiber?渲染流程的两大阶段有何区别?双缓冲机制又如何提升渲染效率?

本文将结合底层原理与实际应用,把React整体架构、渲染流程、Fiber核心概念及双缓冲机制彻底揉碎讲透,搭配通俗解释和可视化图表,帮你从根源上理解React的底层工作逻辑,面试时也能从容应对。

一、为什么需要Fiber?—— 从Stack架构的痛点说起

在React 16之前,React采用的是Stack架构,其核心是Stack Reconciler(栈协调器)。这种架构的最大问题的是:渲染过程同步且不可中断

当组件树较深时,React会递归遍历整个组件树进行虚拟DOM比对,这个过程会一直占用主线程。如果遍历耗时超过16.6ms(浏览器每秒刷新60帧的时间),就会导致浏览器无法响应用户输入、滚动、动画等操作,出现视觉卡顿、掉帧等问题——这就是旧架构的致命痛点,也是Fiber架构诞生的核心原因。

为了解决这个问题,React 16彻底重构了底层架构,引入了Fiber架构,核心目标是实现“可中断、可恢复、带优先级”的渲染机制,让React能够在复杂应用中依然保持流畅的交互体验。

二、Fiber核心解析:三个维度读懂它的本质

很多人误以为Fiber是一个API或工具,其实它是React底层的架构重构,同时兼具数据结构和工作单元的属性。我们可以从三个维度,彻底理解Fiber的本质:

1. 维度一:Fiber是一种架构

Fiber架构是React 16后的核心底层架构,替代了之前的Stack架构,核心由三大组件组成,三者协同工作,实现高效渲染:

image.png

这三大组件的分工清晰,层层递进,共同解决了旧架构的性能瓶颈:

  • Scheduler(调度器) :解决“I/O瓶颈”。负责给所有更新任务排序优先级,让紧急任务(如用户输入、动画)优先进入协调器,避免低优任务阻塞高优任务,确保交互响应流畅。
  • Reconciler(协调器) :解决“CPU瓶颈”。负责实现虚拟DOM,将更新流程从“不可中断的递归”改为“可中断的循环”,计算出UI的变化,标记需要更新的节点。
  • Renderer(渲染器) :负责将协调器计算出的UI变化,同步渲染到宿主环境(比如浏览器的DOM中),确保UI与数据一致。

2. 维度二:Fiber是一种数据类型

Fiber本质上是一个JavaScript对象,可以理解为“增强版的虚拟DOM节点”——每个Fiber对象对应一个DOM节点(或组件),不仅包含了组件的类型、DOM相关信息,还新增了用于调度和渲染的关键属性。

与旧架构的虚拟DOM不同,Fiber对象之间通过链表的方式串联,形成一棵Fiber树,而非递归树。核心链表指针如下:

  • child:指向当前Fiber节点的第一个子Fiber节点;
  • sibling:指向当前Fiber节点的下一个兄弟Fiber节点;
  • return:指向当前Fiber节点的父Fiber节点(回溯指针);
  • alternate:指向另一棵Fiber树中的对应节点(用于双缓冲机制)。

image.png

这种链表结构的核心优势的是:支持中断和恢复。递归调用一旦开始就无法中断,但链表遍历可以随时停止,只需记录当前遍历的Fiber节点,下次恢复时从该节点继续即可,这是时间切片实现的基础。

3. 维度三:Fiber是一个动态工作单元

每个Fiber节点不仅是数据载体,还是一个“工作单元”——它保存了本次更新中该节点的变化数据、需要执行的工作(如新增、删除、更新DOM)以及副作用信息(如useEffect的执行)。

React会将渲染任务拆分成一个个小的工作单元(每个Fiber节点就是一个工作单元),每次只处理一个工作单元,处理完后检查是否有剩余时间或更高优先级任务,若没有再继续处理下一个——这种“化整为零”的方式,就是Fiber解决卡顿的关键。

三、React整体渲染流程:两大阶段,三大组件协同

理解了Fiber的核心,再看React的整体渲染流程就会非常清晰。React的渲染流程本质上是“计算UI变化”到“渲染UI”的过程,可分为render阶段commit阶段两大核心阶段,对应三大组件的协同工作,核心公式可总结为:

state = reconcile(update) // 协调器计算最新状态

UI = commit(state) // 渲染器渲染最终UI

image.png

1. Render阶段:异步可中断,内存中完成计算

Render阶段由调度器(Scheduler)协调器(Reconciler) 共同完成,核心是“计算出最终要渲染的UI”,这个过程完全在内存中进行,异步、可中断,不会影响页面显示。

(1)调度器的工作:给任务排优先级

调度器的核心作用是“任务调度”,给所有更新任务(如setState、useState触发的更新)分配优先级,避免高优任务被低优任务阻塞。

这里有个小细节:浏览器原生有一个API——requestIdleCallback,可以在浏览器空闲时执行任务,这与调度器的逻辑类似。但由于该API的兼容性较差,且无法满足React的精细优先级控制需求,React团队自己实现了一套调度机制,未来还计划将Scheduler单独发布为独立包,供其他需要任务调度的项目使用。

调度器的核心逻辑的是“时间切片”:将每帧(16.6ms)的剩余时间分配给任务,每次执行一个工作单元后,调用shouldYield()方法判断是否有剩余时间,若没有则暂停任务,将主线程还给浏览器,等待下一个宏任务再继续执行。

(2)协调器的工作:生成Fiber树,标记变化

协调器是Render阶段的核心,负责接收调度器分配的任务,采用深度优先遍历的方式,遍历并创建Fiber节点,串联成Fiber树,同时执行Diff算法,标记节点的变化(用flags标记,如更新、删除、插入)。

遍历过程分为两个子阶段,也就是常说的“递”和“归”:

  • 递阶段:从根Fiber(HostRootFiber)开始,向下遍历每个节点,执行beginWork方法,根据当前Fiber节点创建下一级Fiber节点,同时进行Diff比对,标记节点变化。
  • 归阶段:当遍历到叶子节点后,开始回溯,执行completeWork方法,收集当前节点的副作用(如DOM操作、useEffect回调),然后通过sibling指针切换到兄弟节点,继续遍历。

整个过程可以随时被中断(如时间片耗尽、有更高优先级任务),中断后会保存当前遍历的Fiber节点,恢复时从该节点继续,不会重复已完成的工作——这就是Fiber架构解决CPU瓶颈的核心。

2. Commit阶段:同步不可中断,渲染到真实UI

Commit阶段由渲染器(Renderer) 负责,核心是“将Render阶段计算出的UI变化,同步渲染到宿主环境”,这个过程同步、不可中断——因为一旦中断,就会导致UI与数据不一致,出现页面闪烁、错乱等问题。

Commit阶段又分为三个子阶段,按顺序执行:

image.png

  • BeforeMutation阶段:执行DOM操作前的准备工作,比如读取当前DOM的属性(如scrollTop),为后续DOM操作做铺垫。
  • Mutation阶段:核心阶段,根据协调器标记的flags,执行真实的DOM操作(新增、删除、更新DOM),同时完成Fiber双缓冲树的切换(后续详细讲解)。
  • Layout阶段:DOM操作完成后,执行后续逻辑,比如更新ref引用、执行useEffect的回调函数,同时可以获取到更新后的DOM属性。

四、Fiber双缓冲机制:为什么能实现无闪烁更新?

Fiber双缓冲机制是React渲染优化的另一大核心,很多开发者对它的理解比较模糊,其实它的原理很简单:在内存中同时维护两棵Fiber树,通过树切换实现高效、无闪烁的更新

1. 两棵Fiber树的作用

React中存在两棵Fiber树,它们通过alternate指针相互指向,各司其职:

  • Current Fiber Tree(当前树) :与当前页面显示的真实DOM一一对应,是“已渲染”的树,用户能看到的UI就是基于这棵树渲染的。
  • WorkInProgress Fiber Tree(工作树) :在内存中构建的新树,用于处理本次更新。React会在WorkInProgress树上完成所有Fiber节点的创建、Diff比对和副作用收集,整个过程不会影响Current树和真实DOM。

2. 双缓冲的核心流程

暂时无法在飞书文档外展示此内容

image.png 具体流程可以拆解为3步:

  1. 触发更新(如setState)后,React会以Current树为模板,在内存中创建WorkInProgress树,开始遍历并更新节点;
  2. 在WorkInProgress树上完成所有计算(Diff、副作用收集),此时WorkInProgress树是“最新的、完整的”;
  3. 进入Commit阶段的Mutation子阶段,React会将Current树和WorkInProgress树的指针互换(通过alternate指针),此时WorkInProgress树变为新的Current树,原Current树变为下一次更新的WorkInProgress树;
  4. 最后,渲染器根据新的Current树,同步更新真实DOM,用户看到最新的UI。

3. 双缓冲的优势

为什么需要双缓冲?核心是避免页面闪烁。如果直接在Current树上修改节点,修改过程中会导致DOM处于“不完整”状态,用户会看到页面闪烁;而WorkInProgress树在内存中完成所有计算,只有当它完全准备好后,才会与Current树切换,一次性更新DOM,确保用户看到的始终是完整的UI。

同时,双缓冲机制还能复用Fiber节点——通过alternate指针,React可以复用之前的Fiber节点,减少重复创建节点的开销,提升渲染性能。

五、面试高频题解析

结合上面的内容,我们来解析两道高频面试题,帮你快速掌握答题要点:

面试题1:是否了解过React的整体渲染流程?里面主要有哪些阶段?

参考答案:

React的整体渲染流程分为Render阶段Commit阶段两大核心阶段,由调度器、协调器、渲染器三大组件协同完成:

  1. Render阶段:由调度器和协调器负责,在内存中异步、可中断地执行。调度器负责排序任务优先级,高优任务优先进入协调器;协调器采用深度优先遍历,创建Fiber树,执行Diff算法,标记节点变化和副作用。
  2. Commit阶段:由渲染器负责,同步、不可中断地执行。核心是将Render阶段计算出的UI变化渲染到真实DOM,分为BeforeMutation、Mutation、Layout三个子阶段,分别负责DOM操作前准备、执行DOM操作、DOM操作后逻辑。

关键要点:Render阶段异步可中断,Commit阶段同步不可中断;三大组件的分工;Render阶段的“递”“归”过程。

面试题2:谈谈你对React中Fiber的理解以及什么是Fiber双缓冲?

参考答案:

Fiber是React 16引入的核心架构,同时兼具数据结构和工作单元的属性,可从三个维度理解:

  1. 架构层面:Fiber架构替代了旧的Stack架构,由调度器、协调器、渲染器组成,实现了可中断、可恢复的渲染机制,解决了旧架构的卡顿问题。
  2. 数据结构层面:Fiber是一个JavaScript对象,对应一个DOM节点或组件,通过child、sibling、return指针串联成链表结构的Fiber树,支持中断和恢复遍历。
  3. 工作单元层面:每个Fiber节点保存了本次更新的变化数据、需要执行的工作和副作用信息,是React拆分渲染任务的最小单元。

Fiber双缓冲机制是React的渲染优化手段:

React在内存中同时维护两棵Fiber树——Current树(对应真实DOM)和WorkInProgress树(内存中构建的新树),通过alternate指针相互指向。更新时,在WorkInProgress树上完成所有计算,然后切换两棵树的指针,一次性更新DOM,避免页面闪烁,提升渲染效率。

六、总结

React Fiber架构的核心,是通过“可中断的工作单元”“优先级调度”和“双缓冲机制”,解决了旧架构的卡顿问题,让React能够高效处理复杂应用的渲染需求。

我们可以用一句话总结:Fiber是架构、是数据结构、是工作单元,React的渲染流程是“Render阶段异步计算变化,Commit阶段同步渲染UI”,而双缓冲机制则是实现无闪烁更新的关键。

Flutist - Flutter 模块化架构管理框架

作者 JarvanMo
2026年4月20日 09:16

简单而系统地管理你的 Flutter 模块化结构


简介

随着我们的项目增长,模块化似乎势在必行,而我最近发现了一个比较新的Flutter模块化管理框架——Flutist。它是一个专为 Flutter 应用设计的强大项目管理框架,灵感来源于 iOS 开发生态中的 Tuist[1]。它为管理大型 Flutter 项目提供了一套结构化的方法,具备模块化架构、集中式依赖管理和代码生成能力。

为什么选择 Flutist?

模块化是大型 Flutter 项目的标准方案——独立构建、并行开发、测试隔离,优势显而易见。但随着模块数量增长,管理开销也随之攀升。Flutist 通过自动化消除了这些开销。

类型安全的依赖管理

当模块超过 10 个时,包版本不一致的问题极易出现。在 package.dart 中声明一次版本,flutist generate 会自动生成 flutist_gen.dart,所有模块都能通过 IDE 自动补全和类型检查安全地引用依赖。


    
    
    
  // 1. package.dart — 只声明一次版本
Dependency(name: 'dio', version: '^5.3.0'),
Dependency(name: 'flutter_bloc', version: '^8.1.6'),

// 2. flutist_gen.dart — 由 flutist generate 自动生成
Dependency get dio         => dependencies.firstWhere((d) => d.name == 'dio');
Dependency get flutterBloc => dependencies.firstWhere((d) => d.name == 'flutter_bloc');
Module     get authDomain   => modules.firstWhere((m) => m.name == 'auth_domain');

// 3. project.dart — 类型安全引用(IDE 自动补全 ✅)
Module(
  name: 'auth_data',
  dependencies: [package.dependencies.dio, package.dependencies.flutterBloc],
  modules: [package.modules.authDomain],
),

集中式 pubspec.yaml 管理

每增加一个模块就多一个 pubspec.yaml。升级一个包的版本意味着要手动编辑每个引用它的文件。

Flutist 根据 project.dart 的声明自动同步所有 pubspec.yaml 文件。开发者只需编辑一个文件——project.dart


    
    
    
  $ flutist generate
✓ pubspec.yaml synced: app, auth_domain, auth_data, auth_presentation,
                      product_interface, product_implementation ... (24 total)
✓ all architecture rules passed
✓ done (0.8s)

架构规则自动化

仅靠文档和代码审查很难持续保持架构规则的一致性。在开发压力下,domain 层最终会导入 http,或者一个功能模块直接引用了另一个功能模块的实现。这些违规很难被发现,等到发现时往往已经扩散开来。

Flutist 将架构规则转化为可执行代码。在 strictMode: true(默认值)下,任何违规都会立即终止 generate。曾经只存在于文档中的原则,现在变成了构建关卡。


    
    
    
  $ flutist generate
✗ [B4] auth_domain → auth_data: 检测到反向依赖 — domain 不应依赖 data
   → 请从 auth_domain 中移除 Dio 导入,仅声明 Repository 接口
✗ generate 已终止(strictMode: true

即使是刚加入团队、对架构理解不深的新成员,在违反规则的那一刻也能获得清晰的反馈。架构违规不再依赖人工审查,工具会自动检查。

样板代码自动生成

模块化架构最大的痛点之一就是样板代码。每个新功能都需要创建 interfaceimplementationtestingtestsexample 包,每个包都有自己的 pubspec.yamllib/ 结构和 barrel 文件。

像 BLoC 这样的状态管理模式,每个功能都需要 event、state、BLoC、page 和 widget 文件。使用 flutist createflutist scaffold,一条命令就能生成所有这些内容。


    
    
    
  # 以 micro 类型创建 todos 功能 — 5 个包 + 完整结构自动化
$ flutist create --name todos --path features --options micro
✓ features/todos/todos_interface      已创建
✓ features/todos/todos_implementation 已创建
✓ features/todos/todos_testing        已创建
✓ features/todos/todos_tests          已创建
✓ features/todos/todos_example        已创建

# 使用 BLoC 脚手架生成所有文件
$ flutist scaffold --template bloc --name todos_overview --path features/todos/todos_implementation
✓ todos_overview_bloc.dart  已创建
✓ todos_overview_event.dart 已创建
✓ todos_overview_state.dart 已创建

核心特性

特性 说明
声明式 通过单一的 project.dart 文件声明整个项目结构
单一来源 所有依赖版本通过 package.dart 集中管理
规则即代码 架构违规会立即终止生成过程

安装


    
    
    
  dart pub global activate flutist

前置条件:Flutter SDK,并确保 ~/.pub-cache/bin 已添加到 PATH

快速开始

1. 初始化项目


    
    
    
  cd my_flutter_project
flutist init

Flutist 会根据上下文自动适配:

  • pubspec.yaml:询问是否创建新的 Flutter 项目
  • 存在 pubspec.yaml:询问是新建项目还是迁移现有项目
    • • 新项目:创建 app 模块,添加到工作区,生成 lib/main.dart
    • • 现有项目:仅创建配置文件,保留现有代码结构

2. 创建模块


    
    
    
  # 创建 Clean 架构模块
flutist create --name login --path features --options clean

# 创建 Microfeature 架构模块
flutist create --name network --path packages --options micro

# 创建 Lite 模块
flutist create --name auth --path packages --options lite

# 创建单一包
flutist create --name utils --path core

3. 管理依赖


    
    
    
  # 添加包(自动解析版本)
flutist pub add http bloc flutter_bloc

# 同步依赖到所有模块
flutist generate

4. 从自定义模板生成代码


    
    
    
  # 列出可用模板
flutist scaffold list

# 从模板生成
flutist scaffold feature --name login
flutist scaffold feature --name login --path lib/features

命令一览

命令 描述 用法
init 初始化新项目或现有项目 flutist init
create 创建新模块 flutist create --name <name> --path <path> [--options <type>]
generate 同步依赖并重新生成文件 flutist generate
check 检查架构规则(CI 友好,不修改文件) flutist check
test 并行运行所有模块的测试 flutist test [-m <module>]
scaffold 从模板生成代码 flutist scaffold <template> --name <name>
pub 管理依赖 flutist pub add <package>
graph 可视化模块依赖关系 flutist graph [--format <format>]
help 显示帮助信息 flutist help [command]

核心文件

文件 说明
package.dart 外部包版本和模块名称的单一真实来源,多行格式为解析必需
project.dart 声明模块依赖和模块间关系,由 flutist generate 读取
flutist_gen.dart 自动生成的类型安全访问器,提供 IDE 自动补全支持

项目结构

典型的 Flutist 项目结构:


    
    
    
  my_project/
├── project.dart              # 项目配置
├── package.dart              # 集中式依赖管理
├── pubspec.yaml              # 工作区配置
├── lib/                      # 根应用代码
│   └── main.dart
├── app/                      # 主应用模块
│   ├── lib/
│   │   └── app.dart
│   └── pubspec.yaml
├── features/                 # 功能模块
│   └── auth/
│       ├── auth_domain/
│       ├── auth_data/
│       └── auth_presentation/
├── packages/                 # 库模块
│   └── network/
│       ├── network_interface/
│       ├── network_implementation/
│       ├── network_testing/
│       ├── network_tests/
│       └── network_example/
└── flutist/
    ├── templates/            # 脚手架模板
    └── flutist_gen.dart      # 生成的代码

模块类型

flutist create 会生成层级包并自动在 project.dart 中配置依赖关系

Clean 架构 (--options clean)

3 层 Clean Architecture,最适合需要清晰关注点分离的功能模块。


    
    
    
  features/login/
├── login_domain/          # 业务规则、实体、用例(无外部依赖)
├── login_data/            # 仓库、数据源、DTO
└── login_presentation/    # UI 和状态管理

自动配置依赖presentation → domaindata → domain

规则:所有依赖箭头指向 domain,domain 不依赖任何东西。

Microfeature 架构 (--options micro)

5 层 Microfeature Architecture,最适合跨功能共享的可复用库。


    
    
    
  packages/network/
├── network_interface/         # 公共 API(抽象类、模型)
├── network_implementation/    # 具体实现
├── network_testing/           # 测试辅助、模拟对象
├── network_tests/             # 单元测试和集成测试
└── network_example/           # 模块演示应用

自动配置依赖implementation/testing → interfacetests/example → implementation + testing

规则:消费者只依赖 interface,组合根注入实现。

Lite 架构 (--options lite)

4 层 Microfeature lite(无 example),最适合内部 API。


    
    
    
  packages/auth/
├── auth_interface/
├── auth_implementation/
├── auth_testing/
└── auth_tests/

单一包(省略 --options

无层级,最适合工具类、共享模型或应用外壳。


    
    
    
  core/utils/
├── lib/
│   └── utils.dart
└── pubspec.yaml

架构验证

flutist generateflutist check 自动执行以下规则:

规则 说明
实现引用 只有组合根(默认:app)和同功能测试/example 可以引用 _implementation
测试层隔离 _testing 包被排除在生产依赖之外
Example 独立性 _example 模块不能被任何生产代码引用
方向强制 同功能层级遵循声明的依赖方向
循环依赖 通过 DFS 遍历检测,绝不允许

配置选项


    
    
    
  // project.dart
ProjectOptions(
  strictModetrue,              // true(默认):违规时终止 / false:仅警告
  compositionRoots: ['app'],     // 允许引用 _implementation 的模块
)

Scaffold 脚手架模板

将重复性工作保存为模板,通过 flutist scaffold 自动化生成代码。

模板变量

.template 文件和 path 值中使用 {{变量}} 进行替换:

变量 输入 login_feature 输出
`{{name snake_case}}` login_feature login_feature
`{{name pascal_case}}` login_feature LoginFeature
`{{name camel_case}}` login_feature loginFeature
`{{name upper_case}}` login_feature LOGIN_FEATURE

template.yaml 结构


    
    
    
  description: "BLoC Feature Template"

attributes:
  - name: name
    required: true
  - name: path
    required: false
    default: "lib/features"

items:
  - type: file
    path"{{path}}/{{name | snake_case}}/{{name | snake_case}}_bloc.dart"
    templatePath: "bloc.dart.template"

  - typestring
    path"{{path}}/{{name | snake_case}}/README.md"
    contents: |
      # {{name | pascal_case}}

Item 类型

类型 说明
file 读取 .template 文件,替换变量后生成
string 使用内联内容直接生成文件
directory 复制整个模板目录

实战示例:BLoC Feature 模板

bloc.dart.template


    
    
    
  import 'package:bloc/bloc.dart';

part '{{name | snake_case}}_event.dart';
part '{{name | snake_case}}_state.dart';

class {{name | pascal_case}}Bloc
    extends Bloc<{{name | pascal_case}}Event, {{name | pascal_case}}State> {
  {{name | pascal_case}}Bloc() : super(const {{name | pascal_case}}Initial()) {
    on<{{name | pascal_case}}Started>(_onStarted);
  }

  Future<void_onStarted(
    {{name | pascal_case}}Started event,
    Emitter<{{name | pascal_case}}State> emit,
  ) async {}
}

event.dart.template


    
    
    
  part of '{{name | snake_case}}_bloc.dart';

sealed class {{name | pascal_case}}Event {
  const {{name | pascal_case}}Event();
}

final class {{name | pascal_case}}Started extends {{name | pascal_case}}Event {
  const {{name | pascal_case}}Started();
}

state.dart.template


    
    
    
  part of '{{name | snake_case}}_bloc.dart';

sealed class {{name | pascal_case}}State {
  const {{name | pascal_case}}State();
}

final class {{name | pascal_case}}Initial extends {{name | pascal_case}}State {
  const {{name | pascal_case}}Initial();
}

final class {{name | pascal_case}}Loading extends {{name | pascal_case}}State {
  const {{name | pascal_case}}Loading();
}

final class {{name | pascal_case}}Loaded<T> extends {{name | pascal_case}}State {
  final T data;
  const {{name | pascal_case}}Loaded(this.data);
}

final class {{name | pascal_case}}Error extends {{name | pascal_case}}State {
  final String message;
  const {{name | pascal_case}}Error(this.message);
}

运行生成


    
    
    
  $ flutist scaffold bloc_feature --name login --path lib/features

✓ lib/features/login/login_bloc.dart
✓ lib/features/login/login_event.dart
✓ lib/features/login/login_state.dart

实战示例:Riverpod Notifier 模板

notifier.dart.template


    
    
    
  import 'package:riverpod_annotation/riverpod_annotation.dart';
import '{{name | snake_case}}_state.dart';

part '{{name | snake_case}}_notifier.g.dart';

@riverpod
class {{name | pascal_case}}Notifier extends _${{name | pascal_case}}Notifier {
  @override
  {{name | pascal_case}}State build() => const {{name | pascal_case}}State.initial();

  Future<void> load() async {
    state = const {{name | pascal_case}}State.loading();
    try {
      state = const {{name | pascal_case}}State.loaded(null);
    } catch (e) {
      state = {{name | pascal_case}}State.error(e.toString());
    }
  }
}

state.dart.template


    
    
    
  import 'package:freezed_annotation/freezed_annotation.dart';

part '{{name | snake_case}}_state.freezed.dart';

@freezed
class {{name | pascal_case}}State with _${{name | pascal_case}}State {
  const factory {{name | pascal_case}}State.initial()            = _Initial;
  const factory {{name | pascal_case}}State.loading()            = _Loading;
  const factory {{name | pascal_case}}State.loaded(dynamic data) = _Loaded;
  const factory {{name | pascal_case}}State.error(String msg)    = _Error;
}

示例项目

Clean Architecture 示例

flutist_clean_architecture[2]

  • • Domain、Data、Presentation 三层 Clean Architecture
  • • 集中式依赖管理
  • • 大型 Flutter 应用最佳实践

Microfeature Architecture 示例

flutist_microfeature_architecture[3]

  • • Interface、Implementation、Tests、Testing 四层 Microfeature 架构
  • • 完全隔离的可复用库模块
  • • 集中式依赖管理

相关链接

资源 链接
📦 pub.dev pub.dev/packages/fl…
📖 文档网站 deepwiki.com/seonwooke/f…
💻 GitHub github.com/seonwooke/f…

引用链接

[1] Tuist: tuist.io/
[2] flutist_clean_architecture: github.com/seonwooke/f…
[3] flutist_microfeature_architecture: github.com/seonwooke/f…

6.响应式系统比对:通过 Vue3 响应式库写 React 应用

作者 Cobyte
2026年4月20日 09:04

前言

鉴于 Vue3 已经把响应式库进行了独立,也就是 @vue/reactivity,既然 Mobx 也是一个响应式库都可以应用在 React 上,那么 @vue/reactivity 可不可以也应用在 React 上呢?很显然是可以的,社区里也有很多关于这么方面的实践。那么我们这里也提供一个参考 Mobx 实现的版本。

跟 Mobx 对比的话,@vue/reactivity 就相当于 mobx 库,所以我们只需要参考 mobx-react-lite 实现一个 vue-react-lite 即可。

实现 vue-react-lite

我们通过上一篇文章可以知道 Mobx 是通过 mobx-react-lite 实现与 React 进行链接的,其中最重要的函数就是 observer,那么我们也在 vue-react-lite 中实现一个 observer 函数。根据我们前篇所学的知识知道 observer 是一个高阶函数,所以我们初步把 observer 的基础架构搭建出来。

function observer(baseComponent) {
    return (props) => {
        return baseComponent(props)
    }
}

接下来我们知道 Mobx 中是通过 Reaction 这个订阅者中介来实现不同组件函数的代理的,而在 @vue/reactivity 中的跟 Reaction 相同角色的的则是 ReactiveEffect,那么我们就可以通过它来实现我们想要的功能。

代码实现如下:

import { useState, useRef } from "react"
import { ReactiveEffect  } from "@vue/reactivity"
function observer(baseComponent) {
    return (props) => {
        const [, setState] = useState()
        const admRef = useRef(null)
        if (!admRef.current) {
            admRef.current = new ReactiveEffect(() => {
                return baseComponent(props)
            }, () => {
                setState(Symbol())
            })
        }
        const effect = admRef.current
        return effect.run()
    }
}

那么我们就通过 ReactiveEffect 实现了一个跟 mobx-react-lite 中的 observer 一样的功能的函数。

如果大家对 Vue3 的 effect 函数熟悉的话,我们上述 observer 的实现过程跟 Vue3 的 effect 实现很类似的。我们可以回顾一下 Vue3 的 ReactiveEffect 类的功能,它本质是一个订阅者中介,跟 Vue2 的 Watcher 类是一样的角色。ReactiveEffect 的第一个参数就是具体的订阅者函数,而第二个参数则是一个叫 scheduler 的回调函数,在更新的时候如果存在 scheduler 回调函数则执行 scheduler 回调函数,否则执行第一个参数的函数。基于这个原理,我们就在 ReactiveEffect 的第二个参数中设置执行 React 的更新 setState(Symbol()),同时 ReactiveEffect 上存在一个 run 方法,需要通过手动执行进行初始化。

应用 vue-react-lite

那么我们上面通过 ReactiveEffect 实现了 observer 函数,这样我们就可以在 React 中应用 Vue3 的数据响应式库了。下面我们来测试一下:

import { reactive } from "@vue/reactivity";
import { observer } from "./vue-react-lite"

const proxy = reactive({ name: 'Cobyte', secondsPassed: 0 })

const TimerView = observer(({ proxy }) => <span>the content run in `@vue/reactivity` is "Seconds passed: {proxy.secondsPassed}"</span>)

function App() {
  return (
    <TimerView proxy={proxy}></TimerView>
  );
}

setInterval(() => {
  proxy.secondsPassed +=1
}, 1000)

export default App;

打印结果如下:

tutieshi_640x195_5s.gif

我们发现已经成功把 @vue/reactivity 库应用到 React 中了。

根据 Mobx 的启发实现 Vue 数据响应式的 OOP

我们知道 Mobx 的写法是更倾向 OOP 的,同时是严格遵守单向数据流,所以我们也可以在通过 Vue 响应式库提供的 shallowRef API 实现 OOP。

import { reactive, shallowRef } from "@vue/reactivity"
import { observer } from "./vue-react-lite"

class DataService {
  constructor(val) {
    this.r = shallowRef(val)
  }
  get count() {
    return this.r.value
  }
  setCount(val) {
    this.r.value = val
  }
}
const dataService = new DataService(0)
const TimerView = observer(({ proxy }) => <span>the content run in @vue/reactivity is "Seconds passed: {proxy.count}"</span>)

function App() {
  return (
    <TimerView proxy={dataService}></TimerView>
  );
}

setInterval(() => {
  dataService.setCount(Date.now())
}, 1000)

export default App;

但上述方式还是不能堵住别人可以通过直接修改对象的方式更改响应式的值,从而打破单向数据流的规则。

例如下面的例子:

setInterval(() => {
    dataService.r.value = Date.now()
}, 1000)

那么为了堵住这个漏洞,我们可以通过私有变量来解决:

class DataService {
  #r
  constructor(val) {
    this.#r = shallowRef(val)
  }
  get count() {
    return this.#r.value
  }
  setCount(val) {
    this.#r.value = val
  }
}
const dataService = new DataService(0)

这个时候我们就不能通过直接修改对象的方式更改响应式的值了。

setInterval(() => {
    dataService.#r.value = Date.now()
}, 1000)

我们上述这种方式比较适合基本数据类型的情况,如果是引用类型的话,就不太适用了。如果是引用类型我们不可能在上面写那么多属性访问器,我们可以像 Vue2 那样把所有的响应式数据代理到 Vue 的实例对象上,然后可以通过 this 进行访问。

修改如下:

import { shallowRef } from "@vue/reactivity";
class DataService {
  #r
  constructor(val) {
    this.#r = shallowRef(val)
    // 像 Vue2 一样把响应式数据代理到实例对象上
    return new Proxy(this, {
      get(target, key) {
        // 如果是响应式数据就返回响应式数据
        if (target.#r.value[key]) {
          return target.#r.value[key]
        } else {
          // 如果是自身的属性就返回自身属性,例如 setState
          return target[key]
        }
      },
      set(target, key, val) {
        throw new Error('请通过 setState 方法进行更新')
      }
    })
  }
  setState(val) {
    this.#r.value = val
  }
}

const dataService = new DataService({ name: 'Cobyte', date: '2024-03-22', now: { time: 123 } })
const TimerView = observer(({ proxy, now }) => <span>the content run in @vue/reactivity "author: {proxy.name}, the date is: {proxy.date} now is {proxy.now.time}"</span>)

function App() {
  return (
    <TimerView proxy={dataService} now={dataService.now}></TimerView>
  );
}

setInterval(() => {
  dataService.setState({ name: '掘金签约作者', date: '2024年3月22日', now: { time: Date.now() }})
}, 1000)

export default App;

我们通过把响应式数据代理到实例对象上,优化了引用类型的使用方式。

tutieshi_640x284_4s.gif

至此,我们受 Mobx 的启发实现了在 React 中使用 Vue3 的响应式数据库,同时跟 Mobx、Flux、Redux 一样实现单向数据流。不过我们目前采用的是最新的技术私有变量,这个方案目前兼容性并不好,但作为技术交流也可以给大家一个启发。

为什么 Vue 可以通过重新运行组件 render 函数进行更新?

我们在前篇文章通过相对比较简洁的代码实现了 Mobx 的核心原理,同时对比了同时响应式的 Vue 和 Mobx 的最大设计区别,在 Vue 中创建的响应式数据,是可以随意在任何地方通过普通属性访问器进行修改的,但 Mobx 中则不提倡这种可以随意修改 state 的方式,在 Mobx 中希望开发者通过 actions 来改变 state,本质是像 React 那样通过一个函数来修改 state,或者说是遵循 Flux 和 Redux 的单向数据流思想。同时 Mobx 中的订阅者中介 Reaction 和 Vue 中的订阅者中介实现则有比较大的区别,主要是因为 Mobx 主要的设计受 React 的影响,在更新的时候需要特别的设置,而不像 Vue 那样直接重新运行副作用函数就可以了,这个说到底也是因为 React 不是靠依赖追踪来实现响应式的缘故。

那么问题就来了,为什么 Vue 可以通过重新运行组件 render 函数进行更新,而 React 则不行?当然 React 在普通情况下,你在更新的时候是不知道哪个组件函数需要更新,但我们通过 Mobx 就可以实现了依赖收集,就可以知道更新的时候那些组件函数需要重新执行,但即便这样 React 也不能通过重新执行组件函数来实现更新,这是为什么呢?

一个组件要渲染到页面上需要哪些必备条件呢?我们先看看下面的一个 React 应用的渲染例子:

ReactDOM.render(App, document.getElementById("root")

那么从上述的 React 应用渲染的例子我们可以知道,一个组件渲染到页面上是一定要知道渲染到哪个元素容器中的,这一点无论是 React 还是 Vue 都是一样的。如果仅仅只是执行一个组件函数是不能实现渲染的,所以在实现 Mobx 的 Reaction 的时候,不能像 Vue 的订阅者中介那样实现。那么为什么在 Vue 中可以通过重新运行组件 render 函数进行更新呢,或者是直接重新运行组件函数进行更新呢?

这是因为在 Vue 中被收集到订阅者记录变量中的函数,并不是组件的 render 函数,而是一个高阶函数,在高阶函数内部才最后执行组件的 render 函数。我们这里以 Vue3 中的情况进分析,在 Vue3 中最后处理组件 render 函数的地方是在 setupRenderEffect 函数中,下面是 setupRenderEffect 的简洁实现代码结构。

function setupRenderEffect(instance, initialVNode, container, anchor, parentSusp) {
    const componentUpdateFn = () => {
        if (!instance.isMounted) {
            // 初始化走这里
            const subTree = (instance.subTree = renderComponentRoot(instance))
            // 通过 patch 函数进行挂载,第三个参数就要挂载的HTML容器
            patch(
                null,
                subTree,
                container, // 目标挂载点
                anchor,
                instance,
                parentSuspense,
                isSVG
            )
            instance.isMounted = true
        } else {
            // 更新走这里
            // 重新执行组件 render 函数
            const nextTree = renderComponentRoot(instance)
            // 上一次的生成的虚拟DOM为旧的虚拟DOM
            const prevTree = instance.subTree
            instance.subTree = nextTree
            // 更新也是通过 patch 函数进行挂载,也同样需要提供挂载的HTML容器,也就是第三个参数
            patch(
                prevTree,
                nextTree,
                // parent may have changed if it's in a teleport
                hostParentNode(prevTree.el!)!, // 更新的时候也需要提供渲染的目标挂载HTML元素
                // anchor may have changed if it's in a fragment
                getNextHostNode(prevTree),
                instance,
                parentSuspense,
                isSVG
            )
        }
    }
    // 从这我们可以看到被收集的依赖并不是组件的 render 函数,而是一个包装函数 componentUpdateFn
    const effect = (instance.effect = new ReactiveEffect(
      componentUpdateFn,
      () => queueJob(update), // 调度函数 scheduler,最后还是执行 update 方法
      instance.scope // track it in component's effect scope
    ))
    // 初始化的时候需要执行 run 方法
    const update = (instance.update = () => effect.run())
    // 执行
    update()
}

我们从上面的 Vue3 的 setupRenderEffect 的简洁实现代码中可以看到在 Vue 中所谓收集依赖的依赖并不是组件的渲染函数,而是一个包装函数,在包装函数中在初始化和更新阶段都是通过执行组件的 render 函数获得组件的虚拟DOM,然后再通过 patch 函数进行渲染挂载到具体的元素节点下。而在 Vue 的内部中是可以获取到具体需要渲染挂载的元素节点的,而我们在 React 的应用层首先是无法通过组件函数获得需要挂载的元素节点的,其次 React 的更新流程本质上就跟 Vue 这类型通过依赖收集的数据响应式框架不一样。

总结

本文受 Mobx 启发,利用 @vue/reactivity 的 ReactiveEffect 实现了类似 mobx-react-lite 的 observer 高阶函数,成功将 Vue 响应式库集成到 React 中,实现了单向数据流和依赖追踪。同时,通过私有变量和 Proxy 代理优化了 OOP 风格下的响应式数据访问,避免了直接修改状态。最后,从底层机制解释了 Vue 能够直接重新运行组件 render 函数更新,而 React 不能的根本原因:Vue 的依赖收集针对的是包含 patch 挂载逻辑的包装函数,可获取具体渲染容器;React 的更新流程不依赖此类追踪,且组件函数层面无法获取挂载节点。这揭示了两种框架在设计哲学与实现机制上的本质差异。

我是程序员Cobyte,现在已转向研究 AI Agent,欢迎添加 v: icobyte,学习交流 AI Agent 应用开发。

THREE.JS实现一个魔法镜子!

作者 苏武难飞
2026年4月20日 08:44

分享一个THREE.JS实现的魔法镜子效果!

最近依然在学习THREE.JS发现了一个比较有意思的方法renderTarget,借助这个方法我们能实现很多有意思的效果

13

初始renderTarget

简单来说,RenderTarget(渲染目标) 就像是给相机准备的一张“离屏画布”或“隐藏显示器”。

以下是它的核心概念:

  1. 它是干什么的?

平时渲染时,相机拍到的画面直接显示在你的显示器屏幕上。 而使用 RenderTarget 时,相机拍到的画面被画在了一张内存中的纹理(Texture) 上。这被称为“离屏渲染”。

  1. 为什么要用它? 当你需要“在 3D 场景里显示 3D 场景”时,它是必不可少的。
  • 后处理效果:先把场景拍下来,加上模糊或调色滤镜,再贴到屏幕上。

  • 镜面与传送门:用一个虚拟相机拍下镜子背后的场景存入 RenderTarget,然后把这张图贴在镜子的平面上。

  • 动态贴图:比如做一个监控显示屏,画面是另一个房间的实时录像。

React Three Fiber中使用的方法如下

const mainRenderTarget = useFBO();

useFrame((state) => {
  const { gl, scene, camera } = state;

  gl.setRenderTarget(mainRenderTarget);
  gl.render(scene, camera);

  mesh.current.material.map = mainRenderTarget.texture;

  gl.setRenderTarget(null);
});

为什么要放到useFrame中呢,是因为我们需要绘制每一帧的状态并把当前的状态当作纹理传递给几何体

先从一个简单的例子开始🌰



const InfinityMirror = () => {
    const mesh = useRef<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>>(null);

    const renderTarget = useFBO();

    useFrame((state) => {
        const {gl, scene, camera} = state;

        if (mesh.current) {
            mesh.current.material.map = null;
        }

        gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

        if (mesh.current) {
            mesh.current.material.map = renderTarget.texture;
        }

        gl.setRenderTarget(null);
    });


    return (
        <>
            <Sky sunPosition={[10, 10, 0]}/>
            <directionalLight position={[10, 10, 0]} intensity={1}/>
            <ambientLight intensity={0.5}/>
            <Environment preset="sunset"/>
            <mesh position={[-2, 0, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh position={[0, 2, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh position={[2, 0, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh position={[0, -2, 0]}>
                <dodecahedronGeometry args={[1]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh ref={mesh} scale={1}>
                <planeGeometry args={[2, 2]}/>
                <meshBasicMaterial/>
            </mesh>
        </>
    );
};



function App() {

    return <Canvas camera={{position: [0, 0, 9]}} dpr={[1, 2]}>
        <InfinityMirror/>
        <OrbitControls autoRotate={false}/>
        <GizmoHelper alignment="bottom-right" margin={[80, 80]}>
            <GizmoViewport axisColors={['red', 'green', 'blue']} labelColor="white" />
        </GizmoHelper>
    </Canvas>
}

20260415143110

核心是利用renderTarget把当前的屏幕内容当作纹理传递给了我们的planeGeometry,逻辑图如下

01

画外渲染

目前我们已经知道renderTarget是使用渲染目标来拍摄当前场景的快照,并将结果用作纹理,那么我们是不是可以考虑用到createPortal来渲染一个不在当前屏幕中的场景呢!使用createPortal的基本语法如下

import { Canvas, createPortal } from '@react-three/fiber';
import * as THREE from 'three';

const Scene = () => {
  const otherScene = new THREE.Scene();

  return (
    <>
      <mesh>
        <planeGeometry args={[2, 2]} />
        <meshBasicMaterial />
      </mesh>
      {createPortal(
        <mesh>
          <sphereGeometry args={[1, 64]} />
          <meshBasicMaterial />
        </mesh>,
        otherScene
      )}
    </>
  );
};

还是通过一个例子来学习

const Portal = () => {

    const mesh = useRef<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>>(null);
    const otherMesh = useRef<THREE.Mesh<THREE.DodecahedronGeometry, THREE.MeshPhysicalMaterial>>(null);
    const otherCamera = useRef<THREE.PerspectiveCamera>(null);
    const otherScene = new THREE.Scene();

    const renderTarget = useFBO();


    useFrame((state) => {
        const {gl, clock, camera} = state;
        if (otherCamera.current) {
            otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);
        }

        gl.setRenderTarget(renderTarget);

        if (otherCamera.current) {
            gl.render(otherScene, otherCamera.current);
        }

        if (mesh.current) {
            mesh.current.material.map = renderTarget.texture;
        }

        if (otherMesh.current) {
            otherMesh.current.rotation.x = Math.cos(clock.elapsedTime / 2);
            otherMesh.current.rotation.y = Math.sin(clock.elapsedTime / 2);
            otherMesh.current.rotation.z = Math.sin(clock.elapsedTime / 2);
        }

        gl.setRenderTarget(null);
    });


    return (
        <>
            <PerspectiveCamera
                manual
                ref={otherCamera}
                aspect={1.5 / 1}
            />
            {createPortal(
                <>
                    <Sky sunPosition={[10, 10, 0]}/>
                    <Environment preset="sunset"/>
                    <directionalLight args={[10, 10, 0]} intensity={1}/>
                    <ambientLight intensity={0.5}/>
                    <ContactShadows
                        frames={1}
                        scale={10}
                        position={[0, -2, 0]}
                        blur={8}
                        opacity={0.75}
                    />
                    <group>
                        <mesh ref={otherMesh}>
                            <dodecahedronGeometry args={[1]}/>
                            <meshPhysicalMaterial
                                roughness={0}
                                clearcoat={1}
                                clearcoatRoughness={0}
                                color="#73B9ED"
                            />
                        </mesh>
                        <mesh position={[-3, 1, -2]}>
                            <dodecahedronGeometry args={[1]}/>
                            <meshPhysicalMaterial
                                roughness={0}
                                clearcoat={1}
                                clearcoatRoughness={0}
                                color="#73B9ED"
                            />
                        </mesh>
                        <mesh position={[3, -1, -2]}>
                            <dodecahedronGeometry args={[1]}/>
                            <meshPhysicalMaterial
                                roughness={0}
                                clearcoat={1}
                                clearcoatRoughness={0}
                                color="#73B9ED"
                            />
                        </mesh>
                    </group>
                </>,
                otherScene
            )}
            <mesh ref={mesh}>
                <planeGeometry args={[3, 2]}/>
                <meshBasicMaterial color="white"/>
            </mesh>
        </>
    );

}

02

这里有几个重点

  1. PerspectiveCamera因为是画外渲染所以我们必须再建立一个摄影机
  2. otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);新建立的摄影机视角和外部主视角保持一致
  3. <PerspectiveCamera aspect={1.5 / 1}/> 1.5 / 1 是为了和 <planeGeometry args={[3, 2]}/> 保持一致

我们这里讨论一下重点3,如果我们把画外场景的摄影机比例改变了呢!

// <PerspectiveCamera aspect={1.5 / 1}/>
<PerspectiveCamera aspect={1 / 1}/>

03

可以很明显的看到我们的纹理比例被压缩了,这也引申出了另一个问题

uv坐标 or 屏幕坐标

首先我们还是先看一张示意图

04

我们现在使用的就是默认的UV坐标,我们生成的纹理图会自动的根据几何体的宽高来压缩以适配我们的UV坐标

如果我们要实现一个屏幕坐标需要几个步骤

  1. 实现自定义着色器
  2. uniform来传递纹理和屏幕坐标

1. 实现自定义着色器


<mesh ref={mesh}>
                <planeGeometry args={[3, 2]}/>
                <meshBasicMaterial color="white"/>
</mesh>

// 👆之前我们一直用的是meshBasicMaterial现在需要改成 

<mesh ref={mesh}>
    <planeGeometry args={[3, 2]}/>
    {/*<meshBasicMaterial color="white"/>*/}

    <shaderMaterial
        fragmentShader={fragmentShader}
        vertexShader={vertexShader}
        uniforms={uniforms}/>
</mesh>


// 顶点着色器vertexShader
void main() {
    vec4 worldPos = modelMatrix * vec4(position, 1.0);
    vec4 mvPosition = viewMatrix * worldPos;
    gl_Position = projectionMatrix * mvPosition;
}

// fragmentShader
uniform vec2 winResolution;
uniform sampler2D uTexture;

void main() {
    vec2 uv = gl_FragCoord.xy / winResolution.xy;
    vec4 color = texture2D(uTexture, uv);
    gl_FragColor = color;
    #include <tonemapping_fragment>
    #include <colorspace_fragment>
}

这里我们的顶点着色器vertexShader就是默认的几何体空间定位设置,我们重点看一下fragmentShader

  • gl_FragCoord.xy 几何体在屏幕空间的位置
  • winResolution 通过uniform传递进来的屏幕大小
  • uTexture 通过uniform传递进来的纹理

所以我们现在uv计算逻辑就变成了几何体在屏幕空间中的坐标位置百分比!

2. 用uniform来传递纹理和屏幕坐标


    const uniforms = useMemo(() => ({
            uTexture: {value: null,},
            winResolution: {
                value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(Math.min(window.devicePixelRatio, 2)),
            },
        }),
        []
    );


    useFrame((state) => {
        const {gl, clock, camera} = state;
        if (otherCamera.current) {
            otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);
        }

        gl.setRenderTarget(renderTarget);

        if (otherCamera.current) {
            gl.render(otherScene, otherCamera.current);
        }

        if (mesh.current) {
            mesh.current.material.uniforms.uTexture.value = renderTarget.texture;
            mesh.current.material.uniforms.winResolution.value = new THREE.Vector2(
                window.innerWidth,
                window.innerHeight
            ).multiplyScalar(Math.min(window.devicePixelRatio, 2));
        }

        if (otherMesh.current) {
            otherMesh.current.rotation.x = Math.cos(clock.elapsedTime / 2);
            otherMesh.current.rotation.y = Math.sin(clock.elapsedTime / 2);
            otherMesh.current.rotation.z = Math.sin(clock.elapsedTime / 2);
        }

        gl.setRenderTarget(null);
    });


屏幕坐标

调整一下几何体和摄影机看的更明显

...
...
...

 <PerspectiveCamera
                        makeDefault
                        manual
                        ref={otherCamera}
                        position={[0, 0, 8]}
                    />

   <mesh ref={mesh}>
                <boxGeometry args={[3, 3,3]}/>
                {/*<meshBasicMaterial color="white"/>*/}

                <shaderMaterial
                    fragmentShader={fragmentShader}
                    vertexShader={vertexShader}
                    uniforms={uniforms}/>

   </mesh>

05

一起来使用魔法吧!

我们的基础已经打完了,接下来我们就正式开始我们的魔法教程!

06

之前我们都是把整个的纹理绘制到几何体中,在这一章节我们开始使用动态的纹理图并把其传递给几何体

const Lens2: React.FC = () => {

    const mesh1 = useRef<THREE.Mesh<THREE.DodecahedronGeometry, THREE.MeshPhysicalMaterial>>(null);
    const lens = useRef<THREE.Mesh<THREE.SphereGeometry, THREE.ShaderMaterial>>(null);
    const renderTarget = useFBO();

    const uniforms = useMemo(
        () => ({
            uTexture: {value: null,},
            winResolution: {
                value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(Math.min(window.devicePixelRatio, 2)),
            },
        }),
        []
    );

    useFrame((state) => {
        const {gl, clock, scene, camera, pointer} = state;

    });

    return (
        <>
            <Sky sunPosition={[10, 10, 0]}/>
            <Environment preset="sunset"/>
            <directionalLight position={[10, 10, 0]} intensity={1}/>
            <ambientLight intensity={0.5}/>
            <ContactShadows
                frames={1}
                scale={10}
                position={[0, -2, 0]}
                blur={4}
                opacity={0.2}
            />
            <mesh ref={lens} scale={0.5} position={[0, 0, 2.5]}>
                <sphereGeometry args={[1, 128]}/>
                <shaderMaterial
                    fragmentShader={fragmentShader}
                    vertexShader={vertexShader}
                    uniforms={uniforms}
                    wireframe={false}
                />
            </mesh>
            <group>
                <mesh ref={mesh1}>
                    <dodecahedronGeometry args={[1]}/>
                    <meshPhysicalMaterial
                        roughness={0}
                        clearcoat={1}
                        clearcoatRoughness={0}
                        color="#73B9ED"
                    />
                </mesh>
            </group>
        </>
    );
}

这是一个基础模板代码,此时的效果是

07

1. 使用 MeshTransmissionMaterial

MeshTransmissionMaterialThree.js 生态中(特别是在 react-three-drei 库中)非常受欢迎的一种高级材质,它主要用于模拟毛玻璃、塑料、水、或变色玻璃等具有物理感的高级透明效果。

相比于原生的 MeshPhysicalMaterial,它的性能更优化,且效果更具“数字美感”。


...
...

    useFrame((state) => {
        const {gl, clock, scene, camera, pointer} = state;

        const viewport = state.viewport.getCurrentViewport(state.camera, [0, 0, 2.5]);

        if (!lens.current) return;

        lens.current.position.x = THREE.MathUtils.lerp(
            lens.current.position.x,
            (pointer.x * viewport.width) / 2,
            0.1
        );
        lens.current.position.y = THREE.MathUtils.lerp(
            lens.current.position.y,
            (pointer.y * viewport.height) / 2,
            0.1
        );

        gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

        gl.setRenderTarget(null);

    });

...
...
...
 <mesh ref={lens} scale={0.5} position={[0, 0, 2.5]}>
                <sphereGeometry args={[1, 128]}/>
                <MeshTransmissionMaterial
                    buffer={renderTarget.texture}
                    ior={1.025}
                    thickness={0.5}
                    chromaticAberration={0.05}
                    backside/>
            </mesh>

08

2. 保存瞬时状态!

useFrame中我们是根据用户设备刷新率进行刷新的如1秒60次调用,而我们的

 gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

是保留当前帧的状态,所以我们可以利用这个特性做很多事情!

 mesh1.current.material.wireframe = true;

 // 👇保留线框状态
        gl.setRenderTarget(renderTarget);
        gl.render(scene, camera);

// 👇取消线框状态保证用户不能直接看到线框
        mesh1.current.material.wireframe = false;

        gl.setRenderTarget(null);

09

可以看到此时我们的效果看起来就像是鼠标滑过的地方直接展示了几何体的内部!!

3. 魔法筒

有了上面的基础我们再来画一个示意图看我们的魔法筒效果应该如何实现

图片来自Beautiful and mind-bending effects with WebGL Render Targets

图片来自Beautiful and mind-bending effects with WebGL Render Targets

也就是说我们利用两个圆柱体并且两个圆柱体的纹理分别使用小球和圆锥

  • 圆柱A-纹理使用小球,所以视角在圆柱A时看不到圆锥只能看到小球
  • 圆柱B-整体和圆柱A相反
 return <>
        <Sky sunPosition={[10, 10, 0]}/>
        <Environment preset="sunset"/>
        <directionalLight position={[10, 10, 0]} intensity={1}/>
        <ambientLight intensity={0.5}/>
        <group ref={groupRef}>
            <mesh
                ref={cylinder1}
                position={[0, 0, -4]}>
                <cylinderGeometry args={[3, 3, 8, 32]}/>
                <meshStandardMaterial
                    color="green"
                    transparent
                />
            </mesh>
            <mesh
                ref={cylinder2}
                position={[0, 0, 4]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <meshStandardMaterial
                    color="red"
                    transparent
                />
            </mesh>
            <mesh>
                <torusGeometry args={[3, 0.2, 16, 100]}/>
                <meshStandardMaterial color="#F9F9F9"/>
            </mesh>
        </group>
    </>

20260416173256

圆柱体本身是由以下形状组成的

  • 顶部
  • 柱体
  • 底部

所以我们可以分别设置纹理!

<mesh
                ref={cylinder2}
                position={[0, 0, 4]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <meshStandardMaterial
                    attach="material-0"
                    color="red"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-1"
                    color="green"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                />
            </mesh>

10

接下来旋转一下!

 <mesh
                ref={cylinder1}
                position={[0, 0, -4]}
                rotation={[-Math.PI / 2, 0, 0]}>
                <cylinderGeometry args={[3, 3, 8, 32]}/>
                <meshStandardMaterial
                    attach="material-0"
                    color="red"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-1"
                    color="green"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                />
            </mesh>
            <mesh
                ref={cylinder2}
                position={[0, 0, 4]}
                rotation={[Math.PI / 2, 0, 0]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <meshStandardMaterial
                    attach="material-0"
                    color="red"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-1"
                    color="green"
                    transparent
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                />
            </mesh>

11

接下来我们暂时先把这两个圆柱体隐藏,来做一个几何体的移动效果



    useFrame((state, delta) => {

        const {gl, scene, camera, clock} = state;

        const newPosZ = Math.sin(clock.elapsedTime) * 3.5;
        boxRef.current!.position.z = newPosZ;
        torusRef.current!.position.z = newPosZ;

        boxRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        boxRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        boxRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

        torusRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        torusRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        torusRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

    });



<mesh ref={torusRef} position={[0, 0, 0]}>
                <torusKnotGeometry args={[0.75, 0.3, 100, 16]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh ref={boxRef} position={[0, 0, 0]}>
                <boxGeometry args={[2, 2, 2]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"/>
            </mesh>

12

接下来就是见证奇迹的时候了!我们要运用我们之前保留关键帧的模式来动态的visible模型

const TransformPortal2: React.FC = () => {

    const groupRef = React.useRef<THREE.Group>(null)
    const boxRef = React.useRef<THREE.Mesh<THREE.BoxGeometry, THREE.MeshPhysicalMaterial>>(null)
    const torusRef = React.useRef<THREE.Mesh<THREE.TorusKnotGeometry, THREE.MeshPhysicalMaterial>>(null)
    const cylinder1 = React.useRef<THREE.Mesh<THREE.CylinderGeometry, THREE.ShaderMaterial[]>>(null)
    const cylinder2 = React.useRef<THREE.Mesh<THREE.TorusKnotGeometry, THREE.ShaderMaterial[]>>(null)


    const renderTarget1 = useFBO();
    const renderTarget2 = useFBO();

    const uniforms = useMemo(() => ({
        uTexture: {
            value: null,
        },
        winResolution: {
            value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(Math.min(window.devicePixelRatio, 2)),
        },
    }), []);


    useFrame((state, delta) => {

        const {gl, scene, camera, clock} = state;

        if (cylinder1.current) {
            cylinder1.current.material.forEach((material) => {
                if (material.type === "ShaderMaterial") {
                    material.uniforms.winResolution.value = new THREE.Vector2(
                        window.innerWidth,
                        window.innerHeight
                    ).multiplyScalar(Math.min(window.devicePixelRatio, 2));
                }
            });
        }


        cylinder2.current!.material.forEach((material) => {
            if (material.type === "ShaderMaterial") {
                material.uniforms.winResolution.value = new THREE.Vector2(
                    window.innerWidth,
                    window.innerHeight
                ).multiplyScalar(Math.min(window.devicePixelRatio, 2));
            }
        });

        if (torusRef.current) {
            torusRef.current.visible = false;
        }
        if (boxRef.current) {
            boxRef.current.visible = true;
        }
        gl.setRenderTarget(renderTarget1);
        gl.render(scene, camera);


        if (torusRef.current) {
            torusRef.current.visible = true;
        }
        if (boxRef.current) {
            boxRef.current.visible = false;
        }

        gl.setRenderTarget(renderTarget2);
        gl.render(scene, camera);

        gl.setRenderTarget(null);

        const newPosZ = Math.sin(clock.elapsedTime) * 3.5;
        boxRef.current!.position.z = newPosZ;
        torusRef.current!.position.z = newPosZ;

        boxRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        boxRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        boxRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

        torusRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
        torusRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
        torusRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);

    });


    return <>
        <Sky sunPosition={[10, 10, 0]}/>
        <Environment preset="sunset"/>
        <directionalLight position={[10, 10, 0]} intensity={1}/>
        <ambientLight intensity={0.5}/>
        <group ref={groupRef}>
            <mesh
                ref={cylinder1}
                position={[0, 0, -4]}
                rotation={[-Math.PI / 2, 0, 0]}>
                <cylinderGeometry args={[3, 3, 8, 32]}/>
                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget1.texture,
                        },
                    }}
                    attach="material-0"
                />
                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget1.texture,
                        },
                    }}
                    attach="material-1"
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                    opacity={0}
                />
            </mesh>
            <mesh
                ref={cylinder2}
                position={[0, 0, 4]}
                rotation={[Math.PI / 2, 0, 0]}
            >
                <cylinderGeometry args={[3, 3, 8, 32]}/>

                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget2.texture,
                        },
                    }}
                    attach="material-0"
                />
                <shaderMaterial
                    vertexShader={vertexShader}
                    fragmentShader={fragmentShader}
                    uniforms={{
                        ...uniforms,
                        uTexture: {
                            value: renderTarget2.texture,
                        },
                    }}
                    attach="material-1"
                />
                <meshStandardMaterial
                    attach="material-2"
                    color="blue"
                    transparent
                    opacity={0}
                />
            </mesh>
            <mesh>
                <torusGeometry args={[3, 0.2, 16, 100]}/>
                <meshStandardMaterial color="#F9F9F9"/>
            </mesh>
            <mesh ref={torusRef} position={[0, 0, 0]}>
                <torusKnotGeometry args={[0.75, 0.3, 100, 16]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"
                />
            </mesh>
            <mesh ref={boxRef} position={[0, 0, 0]}>
                <boxGeometry args={[2, 2, 2]}/>
                <meshPhysicalMaterial
                    roughness={0}
                    clearcoat={1}
                    clearcoatRoughness={0}
                    color="#73B9ED"/>
            </mesh>
        </group>
    </>


}

13

参考资料

Beautiful and mind-bending effects with WebGL Render Targets

昨天 — 2026年4月19日掘金 前端

2026 年前端工程师面试:一份来自面试官视角的真实复盘

作者 怕浪猫
2026年4月19日 23:09

前言:为什么我要写这篇文章

前两天和一个在高校和企业都面试过不少候选人的"面试官老炮"聊天,他听过太多候选人抱怨面试内容脱离实际、工作用不到。也听过面试官抱怨候选人只会背题、动手能力差。有意思的是,这两拨人的抱怨,往往都对。

今天我想换个视角——不站在候选人角度刷题,也不站在理论派角度讲八股文,而是站在有实际招聘需求、真正要带团队干活的面试官视角,聊聊 2026 年的前端工程师面试,到底在考什么、为什么这么考。

核心结论先行

2026 年的前端面试,考察维度已经发生了结构性变化:

维度 占比 变化趋势
AI 工程能力 20% 大幅上升,2024 年几乎不考
项目深度与结果 30% 持续核心,但问法变了
Coding 基本功 20% 稳定,需要证明你能写
框架原理(React 为主) 20% 稳定,但要理解本质
系统设计 10% 稳定,外企中大厂标配

一个重要变化:纯背题的通过率断崖式下跌。面试官开始问"这个方案你实际落地过吗""遇到什么问题""怎么取舍"。


一、AI 工程能力:这是 2026 年的新标配

为什么 AI 能力突然重要了?

原因很简单:团队里用 AI 的工程师,和不用的工程师,生产效率差 2-3 倍。不是 10-20%,是 2-3 倍。

任何一个正常的技术团队 Leader,只要用过了,都会想把 AI 用到团队里。所以面试 AI 能力,本质上是在判断:你能不能快速融入一个 AI-Augmented 的团队

面试怎么考?

通常分三个层次:

层次一:工具使用(基础分)

  • 你用哪些 AI 编程工具?
  • 你的日常 AI 工作流是什么?
  • 你如何保证 AI 生成代码的质量?

这三个问题几乎每场面试都会问到。如果你还在说"我就用 ChatGPT 写代码",那只能得基础分。

层次二:工程化落地(核心竞争力)

  • 你在公司里推动过 AI 工作流落地吗?
  • 团队如何统一 AI 工具配置?(Rule 文件、MCP 服务等)
  • 怎么量化 AI 提效的价值?
  • AI 生成的代码谁来 Review?流程是什么?

这部分是拉开差距的关键。很多候选人用 AI 用得很溜,但从来没想过如何让团队也用好

层次三:边界认知(加分项)

  • AI 能帮你做什么?不能帮你做什么?
  • 什么时候你选择不用 AI?
  • AI 生成的代码可能有哪些隐蔽的坑?

这部分考察的是你的判断力和工程素养。AI 不是万能的,知道它的边界在哪里,才是成熟工程师的标志。

我的 AI 工作流(可直接写在简历里的框架)

需求 → Context 构建 → AI 生成 → 质量保障 → 持续优化

1. Context 构建阶段

  • 维护项目 Rule 文件(代码规范、架构约束)
  • 配置 MCP 服务(提供项目特定上下文)
  • 沉淀 Skills(常见任务的最佳实践)
  • 持续更新 README 和 Onboarding 文档

2. AI 生成阶段

  • UI to Code:设计稿直接生成组件代码
  • 组件生成:可复用组件批量生产
  • 逻辑实现:业务逻辑 + 状态管理
  • 测试生成:单元测试 + 集成测试

3. 质量保障阶段

  • 静态检查:ESLint + TypeScript + Prettier
  • 自动测试:Jest + React Testing Library
  • CI Pipeline:自动化流水线
  • Code Review:AI 辅助 + 人工 Review 结合

4. 持续优化阶段

  • 收集 AI 生成代码的运行时反馈
  • 优化 Prompt 和上下文配置
  • 沉淀最佳实践到知识库

高质量 Prompt 的五要素

面试时经常会被问到"怎么写 Prompt",可以参考这个框架:

要素 说明 示例
目标 要实现什么功能 "帮我实现一个可复用的分页组件"
约束 技术栈、代码规范 "使用 React + TypeScript,遵循项目 ESLint 规则"
上下文 相关代码、接口定义 "已有基础的 Table 组件,路径在 src/components/Table"
输出 期望的交付物 "完整的 .tsx 组件 + 对应的单元测试"
质量要求 类型安全、错误处理、可访问性 "必须标注完整的 TypeScript 类型,处理 loading/error 状态"

二、项目深度:这是永远的压轴戏

变了什么?问法升级了

以前的项目问题:

"请介绍一下你做过的最有挑战性的项目。"

现在的问题:

"你在那个项目里遇到的最大技术挑战是什么?你尝试了几种方案?为什么最终选择了这一种?"

以前是描述题,现在是决策题

面试官不关心你做过什么,关心的是你怎么做决定

推荐的回答框架:决策链法则

每个项目准备一套"决策链":

背景 → 问题 → 约束 → 方案对比 → 最终选择 → 结果 → 复盘

背景:项目的业务背景是什么?你负责什么? 问题:核心挑战是什么?量化指标是什么? 约束:时间、技术栈、团队能力等限制条件 方案对比:你考虑了哪几种方案?各自的优劣? 最终选择:为什么选了这个?trade-off 是什么? 结果:最终的成果,用数据说话 复盘:如果重来一次,你会怎么做?

三个必杀项目类型

无论你有多少项目,建议准备这三类:

1. 性能优化项目(技术深度证明)

这是面试官的最爱,因为数据清晰、过程明确。

参考回答模板:

"我们有一个管理后台,包含 10 万条数据的表格,用户反馈滚动卡顿。

我先用 React DevTools Profiler 定位到问题是每帧渲染的行数太多,不是分页能解决的。

调研了三个方案:虚拟滚动(react-window)、分片渲染、骨架屏。最终选了虚拟滚动,因为这是唯一能满足无限滚动 + 搜索 + 排序三个需求的方案。

实现的时候遇到两个坑:动态行高滚动位置保持。行高问题通过预先测量+缓存解决,滚动位置用 key + scrollTop 记录。

结果:首屏从 3s 降到 0.3s,滚动帧率从 20fps 到 60fps,内存从 500MB 降到 50MB。"

2. 架构设计项目(系统思维证明)

可以是一次重构,也可以是新项目的架构选型。

关键不是选了什么框架,而是为什么这么选

"我们系统从 jQuery 迁移到 React,我主导了技术方案选型。

调研了三个方案:渐进式迁移(逐页替换)、微前端隔离、独立重写。

最终选了渐进式迁移,原因:

  • 独立重写风险太高,涉及 200+ 页面
  • 微前端适合多团队独立部署,我们团队就 3 个人
  • 渐进式迁移风险可控,同时能积累经验

迁移过程中设计了沙箱隔离层,让 React 和 jQuery 组件可以互相通信。

2 年时间完成了 100% 迁移,线上零事故。"

3. 失败/踩坑项目(工程成熟度证明)

面试官特别喜欢问:"你做过的项目里,有没有什么失败的经历?"

这不是送命题,这是送分题。关键是展示你如何从失败中学习。

"我曾经在一个项目里过度设计了状态管理。明明是一个简单的表单页面,我上了 Redux Toolkit。

结果:引入复杂度远超收益,团队其他成员维护成本很高。

我的复盘:状态管理方案应该由业务复杂度决定,不是技术炫技。

后来我总结了选型原则:能用 Context 就不用 Zustand,能用 Zustand 就不用 Redux。只有当团队超过 5 人、业务复杂度超过一定阈值时,才考虑引入全局状态管理库。


三、Coding 基本功:证明你能写代码

考什么?

2026 年的 Coding 环节大致分两类:

LeetCode 算法题(约 10%)

  • 高频题型:数组/字符串操作、DFS/BFS、基础动态规划
  • 难度:Medium 为主,偶尔 Easy 或 Hard
  • 时间控制:15 分钟以内,超时基本挂

手写代码(约 10%)

  • Promise 系列:Promise.all、Promise.race、Promise 并发控制
  • 数组操作:拍平、深拷贝、防抖/节流
  • 框架相关:简易版 useState、简易版 useEffect

为什么还要考算法?

这是面试官被"简历包装"坑怕了之后的保底手段。

你说你熟练使用 React,代码怎么写的?Promise 用得溜,实际能写一个 Promise.all 吗?

算法题的目的不是考你数据结构知识,而是看你在压力下思考和表达的能力。写不出来没关系,能讲清楚思路也算半个通过。

我的准备建议

  1. 刷题策略:刷 LeetCode Top 100 高频题足矣,不需要刷 500 道
  2. 手写题:一定要能讲清楚原理,不是背答案
  3. 善用 AI:用 AI 帮你理解算法思路,但一定要自己手写实现
  4. 时间管理:20 分钟内没思路,主动问面试官要提示,不丢人

四、框架原理:理解本质,而非背诵

React Fiber:必考,没有之一

关于 Fiber,我见过最离谱的候选人回答是:

"Fiber 是一个新的……React 版本。"

这是送命题。

Fiber 到底考什么?

问题层级 预期回答深度
Fiber 是什么? 一种链表结构的虚拟 DOM 描述对象
为什么要 Fiber? 解决大型应用更新时的卡顿问题,让渲染可中断
Fiber 的两个阶段? render 阶段(可中断) + commit 阶段(不可中断)
render 阶段做了什么? diff + 标记副作用(placement/update/deletion)
时间切片怎么实现? requestIdleCallback(现已被 MessageChannel 替代)
优先级调度? lanes 模型,不同更新有不同的优先级

最低要求:能说清楚 Fiber 解决了什么问题、两个阶段的区别。 加分项:能讲清楚 lanes/优先级调度的实现细节。

Hooks 原理:理解原理,而非 API

Hooks 的问题正在从"怎么用"升级到"为什么这么设计"。

问题 考察点
useState 的实现原理? 链表结构、current 指针、dispatch 闭包
为什么不能在条件语句中调用 Hook? 链表顺序对应,每次调用对应链表的一个节点
useEffect 的清理机制? 函数返回值作为清理函数,下次执行前调用
useMemo vs useCallback? 前者缓存值,后者缓存函数引用
什么时候不用 useMemo? 计算不耗时时、简单值,memo 本身的开销可能更大

性能优化:基于数据的优化

面试官最烦的答案是:

"用 React.memo 优化性能。"

面试官最喜欢的问题是:

"你在哪个场景下遇到了性能问题?怎么定位的?用的什么优化手段?效果如何?"

性能优化的正确打开方式:

Profile 定位瓶颈 → 假设原因 → 实施优化 → 数据验证效果

光优化没用,要能复现问题、定位原因、验证效果


五、系统设计:考的是权衡能力

常见题目类型

  • 设计一个支付页面
  • 设计一个实时协作编辑器
  • 设计一个图片上传和裁剪系统
  • 设计一个新闻推荐系统

答题框架:先问后画

第一步:需求澄清(必做)

"我想确认几个问题:

  1. 预期的用户规模是多少?(1 万 vs 1000 万,方案差异很大)
  2. 重点关注哪个方面?(性能、安全、可扩展性)
  3. 是纯前端系统设计还是包含后端?"

这一步做不做,差距非常大。不问就开始画的,往往答不到点子上。

第二步:从高层到细节

整体架构
    ↓
数据模型
    ↓
核心模块
    ↓
关键决策点(trade-off 讨论)

第三步:讲清楚权衡

"方案 A 的优势是 XXX,劣势是 YYY。 方案 B 的优势是 XXX,劣势是 YYY。 考虑到我们的场景是……,所以最终选了方案 B。"

面试官想听的不是"最佳方案",而是你如何权衡取舍


总结:面试的核心逻辑

2026 年的前端面试,核心考察的是四个能力层次:

层次 能力 对应的面试内容
能干活 Coding 基本功 LeetCode、手写代码
懂原理 框架深度理解 React Fiber、Hooks、性能优化
能扛事 项目落地能力 决策链、问题解决、技术选型
会协作 AI 工程能力 工作流、工具链、团队提效

这四个层次,层层递进。前两个是基础,后两个是拉开差距的关键。

一个忠告:不要把面试当成一场演技表演。真正的高手,面试时说的每一句话,都是自己真实做过的事情。与其花时间背题,不如花时间真正把项目做深、把问题想透。

面试只是开始,入职后的每一天才是真正的考验。

祝各位都能找到伯乐,也祝各位伯乐都能找到千里马。

构建无障碍组件之Window Splitter Pattern

作者 anOnion
2026年4月19日 22:02

Window Splitter Pattern 详解:构建可拖拽面板分割器

Window Splitter(窗口分割器,也称为 Resizable SplitterPane ResizerSplit PanelDivider)是一种可移动的分隔组件,用于调整两个相邻面板(pane)的相对大小。本文基于 W3C WAI-ARIA Window Splitter Pattern 规范,详解如何构建无障碍的窗口分割器组件。

一、Window Splitter 的定义与核心概念

1.1 什么是 Window Splitter

Window Splitter 是一种可移动的分隔条,位于两个面板之间,允许用户调整面板的相对大小。它具有以下特征:

  • 位于两个面板之间,作为可交互的分隔线
  • 支持拖拽调整面板大小
  • 可以是**可变(variable)固定(fixed)**类型
    • 可变分割器:可以在允许范围内调整到任意位置
    • 固定分割器:在两个固定位置之间切换
  • 具有表示**主面板(primary pane)**大小的数值

1.2 核心术语

术语 说明
Primary Pane 主面板,分割器的值表示该面板的大小
Secondary Pane 次面板,大小随主面板变化而调整
Variable Splitter 可变分割器,可在范围内任意调整
Fixed Splitter 固定分割器,只能在两个位置间切换
Value 分割器当前值,表示主面板的大小(通常为 0-100)
┌─────────────────────────────────────────────────────────────────┐
│                                                                 │
│  ┌──────────────────┬──────────────────────────────────────┐    │
│  │                  │                                      │    │
│  │   Primary Pane   │          Secondary Pane              │    │
│  │                  │                                      │    │
│  │  ┌────────────┐  │  ┌────────────────────────────────┐  │    │
│  │  │            │  │  │                                │  │    │
│  │  │  Content   │  │  │         Content                │  │    │
│  │  │            │  │  │                                │  │    │
│  │  └────────────┘  │  └────────────────────────────────┘  │    │
│  │                  │                                      │    │
│  └──────────────────┼──────────────────────────────────────┘    │
│                     │                                           │
│              ┌──────┴──────┐                                    │
│              │  Splitter   │  <-- draggable separator           │
│              │  (separator)│      role="separator"              │
│              └─────────────┘      aria-valuenow                 │
│                                                                 │
│  Value = 30 (Primary: 30%, Secondary: 70%)                      │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

注意:"主面板"仅表示该面板的大小由分割器控制,不表示其内容更重要。

1.3 典型应用场景

  • 代码编辑器:左侧文件树,右侧代码编辑区
  • 阅读应用:左侧目录,右侧正文内容
  • 邮件客户端:左侧邮件列表,右侧邮件详情
  • 设计工具:左侧工具栏,右侧画布

二、WAI-ARIA 角色与属性

2.1 基本角色

Window Splitter 使用 role="separator" 标记。从 ARIA 1.1 开始,当 separator 元素可聚焦时,它被视为一个控件(widget)

<div
  role="separator"
  aria-label="目录"
  aria-valuenow="30"
  aria-valuemin="0"
  aria-valuemax="100"
  aria-controls="primary-pane"
  tabindex="0">
</div>

2.2 必需属性

属性 说明 示例值
role="separator" 标记为分隔符角色 -
aria-valuenow 当前值,表示主面板大小 "30"
aria-valuemin 最小值,主面板最小时的位置 "0"
aria-valuemax 最大值,主面板最大时的位置 "100"
aria-controls 指向主面板元素 "primary-pane"
aria-labelaria-labelledby 可访问标签,应与主面板名称匹配 "目录"

2.3 属性详解

aria-valuenow

表示分割器的当前位置,通常映射为主面板的百分比大小:

  • 0:主面板完全折叠(最小)
  • 100:主面板完全展开(最大)
  • 30:主面板占 30%,次面板占 70%
aria-controls

指向主面板元素,让辅助技术知道分割器控制哪个面板:

<div id="primary-pane" role="region" aria-label="目录">
  <!-- 主面板内容 -->
</div>

<div
  role="separator"
  aria-controls="primary-pane"
  ...>
</div>
aria-label

标签应与主面板名称匹配,帮助用户理解分割器的作用:

<!-- 好的示例 -->
<div role="region" aria-label="目录" id="toc-pane">...</div>
<div role="separator" aria-label="目录" aria-controls="toc-pane">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="分割器">...</div>

三、键盘交互规范

3.1 基本键盘交互

按键 功能
← Left Arrow 垂直分割器向左移动
→ Right Arrow 垂直分割器向右移动
↑ Up Arrow 水平分割器向上移动
↓ Down Arrow 水平分割器向下移动
Enter 切换主面板的展开/折叠状态
Home(可选) 将分割器移到最小位置(可能完全折叠主面板)
End(可选) 将分割器移到最大位置(可能完全展开主面板)
F6(可选) 在窗口面板之间循环切换焦点

3.2 Enter 键行为详解

Enter 键用于切换主面板的折叠状态

  • 如果主面板未折叠:折叠主面板(分割器移到最小值)
  • 如果主面板已折叠:恢复分割器到之前的位置
function handleEnter(splitter) {
  const currentValue = parseInt(splitter.getAttribute('aria-valuenow'));
  const minValue = parseInt(splitter.getAttribute('aria-valuemin'));
  
  if (currentValue > minValue) {
    // 主面板未折叠,保存当前位置并折叠
    splitter.dataset.previousValue = currentValue;
    setSplitterValue(splitter, minValue);
  } else {
    // 主面板已折叠,恢复到之前的位置
    const previousValue = parseInt(splitter.dataset.previousValue || '50');
    setSplitterValue(splitter, previousValue);
  }
}

3.3 固定分割器的键盘交互

固定分割器只支持 Enter 键,不支持方向键:

  • 在两个固定位置之间切换
  • 例如:折叠/展开侧边栏

四、鼠标交互规范

4.1 拖拽行为

  • 鼠标按下:开始拖拽,记录起始位置
  • 鼠标移动:实时更新分割器位置和面板大小
  • 鼠标释放:结束拖拽,保存最终位置

4.2 视觉反馈

  • 悬停状态:鼠标悬停时显示可拖拽的视觉提示(如改变光标为 col-resizerow-resize
  • 拖拽状态:拖拽过程中显示视觉反馈(如半透明遮罩)
  • 焦点状态:键盘聚焦时显示清晰的焦点指示器
[role="separator"] {
  cursor: col-resize; /* 垂直分割器 */
}

[role="separator"][aria-orientation="horizontal"] {
  cursor: row-resize; /* 水平分割器 */
}

[role="separator"]:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

五、实现方式

5.1 基础 Window Splitter 结构

<!-- 窗口容器 -->
<div class="window-container">
  <!-- 主面板 -->
  <div
    id="primary-pane"
    class="primary-pane"
    role="region"
    aria-label="目录">
    <!-- 主面板内容 -->
    <nav>
      <h2>目录</h2>
      <ul>
        <li><a href="#ch1">第一章</a></li>
        <li><a href="#ch2">第二章</a></li>
      </ul>
    </nav>
  </div>

  <!-- 分割器 -->
  <div
    role="separator"
    class="splitter"
    aria-label="目录"
    aria-valuenow="30"
    aria-valuemin="0"
    aria-valuemax="100"
    aria-controls="primary-pane"
    tabindex="0">
  </div>

  <!-- 次面板 -->
  <div
    class="secondary-pane"
    role="region"
    aria-label="内容">
    <!-- 次面板内容 -->
    <article>
      <h1>文章标题</h1>
      <p>文章内容...</p>
    </article>
  </div>
</div>

5.2 CSS 样式

.window-container {
  display: flex;
  height: 100vh;
}

.primary-pane {
  width: 30%; /* 初始宽度对应 aria-valuenow="30" */
  min-width: 0;
  overflow: auto;
}

.splitter {
  width: 4px;
  background-color: #e5e7eb;
  cursor: col-resize;
  transition: background-color 0.2s;
}

.splitter:hover,
.splitter:focus {
  background-color: #3b82f6;
}

.splitter:focus {
  outline: 2px solid #3b82f6;
  outline-offset: 2px;
}

.secondary-pane {
  flex: 1;
  overflow: auto;
}

5.3 JavaScript 实现

class WindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    this.container = this.splitter.parentElement;
    
    this.isDragging = false;
    this.startX = 0;
    this.startWidth = 0;
    
    this.init();
  }

  init() {
    // 鼠标事件
    this.splitter.addEventListener('mousedown', this.handleMouseDown.bind(this));
    document.addEventListener('mousemove', this.handleMouseMove.bind(this));
    document.addEventListener('mouseup', this.handleMouseUp.bind(this));
    
    // 键盘事件
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleMouseDown(e) {
    this.isDragging = true;
    this.startX = e.clientX;
    this.startWidth = this.primaryPane.offsetWidth;
    this.container.style.userSelect = 'none';
  }

  handleMouseMove(e) {
    if (!this.isDragging) return;
    
    const delta = e.clientX - this.startX;
    const newWidth = this.startWidth + delta;
    const containerWidth = this.container.offsetWidth;
    const percentage = Math.round((newWidth / containerWidth) * 100);
    
    this.setValue(percentage);
  }

  handleMouseUp() {
    this.isDragging = false;
    this.container.style.userSelect = '';
  }

  handleKeyDown(e) {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    const step = 5; // 每次移动 5%

    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        this.setValue(Math.max(minValue, currentValue - step));
        break;
      case 'ArrowRight':
        e.preventDefault();
        this.setValue(Math.min(maxValue, currentValue + step));
        break;
      case 'Home':
        e.preventDefault();
        this.setValue(minValue);
        break;
      case 'End':
        e.preventDefault();
        this.setValue(maxValue);
        break;
      case 'Enter':
        e.preventDefault();
        this.toggleCollapse();
        break;
    }
  }

  setValue(value) {
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    const maxValue = parseInt(this.splitter.getAttribute('aria-valuemax'));
    
    // 限制在范围内
    value = Math.max(minValue, Math.min(maxValue, value));
    
    // 更新 ARIA 属性
    this.splitter.setAttribute('aria-valuenow', value);
    
    // 更新视觉
    this.primaryPane.style.width = value + '%';
  }

  toggleCollapse() {
    const currentValue = parseInt(this.splitter.getAttribute('aria-valuenow'));
    const minValue = parseInt(this.splitter.getAttribute('aria-valuemin'));
    
    if (currentValue > minValue) {
      // 保存当前值并折叠
      this.splitter.dataset.previousValue = currentValue;
      this.setValue(minValue);
    } else {
      // 恢复之前的位置
      const previousValue = parseInt(this.splitter.dataset.previousValue || '30');
      this.setValue(previousValue);
    }
  }
}

// 初始化
const splitter = document.querySelector('[role="separator"]');
new WindowSplitter(splitter);

5.4 固定分割器实现

固定分割器只支持 Enter 键切换:

class FixedWindowSplitter {
  constructor(splitterElement) {
    this.splitter = splitterElement;
    this.primaryPane = document.getElementById(
      splitterElement.getAttribute('aria-controls')
    );
    
    this.positions = [0, 30]; // 两个固定位置:折叠、展开
    this.currentIndex = 1; // 默认展开
    
    this.init();
  }

  init() {
    this.splitter.addEventListener('keydown', this.handleKeyDown.bind(this));
  }

  handleKeyDown(e) {
    if (e.key === 'Enter') {
      e.preventDefault();
      this.togglePosition();
    }
  }

  togglePosition() {
    this.currentIndex = (this.currentIndex + 1) % this.positions.length;
    const value = this.positions[this.currentIndex];
    
    this.splitter.setAttribute('aria-valuenow', value);
    this.primaryPane.style.width = value + '%';
  }
}

六、最佳实践

6.1 提供清晰的标签

分割器的标签应与主面板名称匹配:

<!-- 好的示例 -->
<div role="region" aria-label="文件树" id="file-tree">...</div>
<div role="separator" aria-label="文件树" aria-controls="file-tree">...</div>

<!-- 不好的示例 -->
<div role="separator" aria-label="拖拽调整">...</div>

6.2 确保键盘可访问

  • 分割器必须可聚焦(tabindex="0"
  • 支持方向键调整位置
  • 支持 Enter 键折叠/展开

6.3 提供视觉反馈

  • 悬停时改变光标样式
  • 焦点状态清晰可见
  • 拖拽过程中实时更新面板大小

6.4 限制调整范围

设置合理的 aria-valueminaria-valuemax,防止面板过小或过大:

<!-- 主面板最小 15%,最大 50% -->
<div
  role="separator"
  aria-valuemin="15"
  aria-valuemax="50"
  ...>
</div>

6.5 保存用户偏好

记住用户调整后的面板大小,下次访问时恢复:

// 保存
localStorage.setItem('splitter-value', splitter.getAttribute('aria-valuenow'));

// 恢复
const savedValue = localStorage.getItem('splitter-value');
if (savedValue) {
  splitter.setAttribute('aria-valuenow', savedValue);
  primaryPane.style.width = savedValue + '%';
}

6.6 响应式设计考虑

在小屏幕上,考虑禁用分割器或提供替代方案:

@media (max-width: 768px) {
  [role="separator"] {
    display: none; /* 小屏幕隐藏分割器 */
  }
  
  .primary-pane {
    width: 100% !important; /* 全宽显示 */
  }
}

七、常见错误

7.1 忘记设置 aria-controls

<!-- 错误 -->
<div role="separator" aria-label="目录"></div>

<!-- 正确 -->
<div role="separator" aria-label="目录" aria-controls="primary-pane"></div>

7.2 标签与主面板不匹配

<!-- 错误 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="调整大小">...</div>

<!-- 正确 -->
<div role="region" aria-label="目录">...</div>
<div role="separator" aria-label="目录">...</div>

7.3 忽略键盘交互

只实现鼠标拖拽,不实现键盘支持,导致键盘用户无法调整面板大小。

八、总结

构建无障碍的 Window Splitter 组件需要关注:

  1. 正确的角色:使用 role="separator"
  2. 必需的属性aria-valuenow aria-valuemin aria-valuemax aria-controls aria-label
  3. 完整的键盘支持:方向键调整、Enter 键折叠、Home/End 快捷键
  4. 鼠标拖拽支持:mousedown/mousemove/mouseup 事件
  5. 清晰的标签:标签与主面板名称匹配
  6. 视觉反馈:悬停、焦点、拖拽状态的视觉提示

遵循 W3C Window Splitter Pattern 规范,我们能够创建既实用又无障碍的面板分割器,提升所有用户的操作体验。

文章同步于 an-Onion 的 Github。码字不易,欢迎点赞。

PDF无限制预览!Jit-Viewer V1.5.0开源文档预览神器正式发布

作者 徐小夕
2026年4月19日 21:30

下面和大家分享一下最近我们开源的文档预览SDK——Jit-Viewer,昨天刚发布 1.5.0 版本,和大家分享一下最新的功能更新。

图片

如果你是开发文档预览功能的开发者,一定经历过这种崩溃:txt文档预览乱码、PDF只能看前5页、大文件加载卡顿,代码文件预览毫无章法。

为了帮大家解决这些真实的使用痛点,提升开发体验,我们这段时间优化了 Jit-Viewer 开源文档预览SDK。上周刚帮不少开发者解决了PDF预览受限的问题——终于能完整查看所有PDF文档了。

今天,Jit-Viewer V1.5.0 正式发布,4大核心更新,让文档预览开发更高效、更省心。

文档地址:jitword.com/jit-viewer.…

开源地址:github.com/jitOffice/j…

这次更新,我们重点带来了以下功能:

1. 支持txt多编码格式预览兼容  

图片

之前很多开发者反馈,txt文档预览经常出现乱码,尤其是非UTF-8编码的文件,调试起来特别麻烦,浪费大量时间。

这次更新,我们优化了txt文档解析逻辑,全面兼容ANSI、UTF-8、GBK等多种常见编码格式,不管你导入的txt文件是什么编码,都能正常显示,再也不用手动转换编码、反复调试,帮大家节省更多开发时间。

2. 支持PDF文件完整预览,告别5页限制  

图片

这是本次更新最受期待的功能!之前版本的Jit-Viewer,PDF文件只能预览前5页,对于需要完整预览长文档的开发者来说,实用性大打折扣,很多场景下根本无法满足需求。

图片

这次我们彻底突破了这个限制,底层重构了PDF渲染能力,支持PDF文件全页完整预览,不管是几页的PDF,都能一次性加载完成,搭配原有缩放、翻页功能,完美适配各类PDF预览场景,再也不用为了查看完整PDF额外集成其他工具。

3. 优化SDK预览性能,搭载高性能文件预览引擎  

我们知道,开发者在集成文档预览SDK时,最在意的就是性能——大文件加载慢、切换页面卡顿,都会影响产品体验。这次更新,我们重新设计了文件预览引擎,优化了文件加载、渲染的全流程,大幅提升了预览速度和稳定性,即使是大文档,也能快速加载、流畅切换,不会出现卡顿、崩溃的情况,同时降低了资源占用,让你的应用运行更流畅。

4. 支持代码文件高亮预览  

针对开发类场景,我们新增了代码文件高亮预览功能。不管是Java、Python、JavaScript,还是HTML、CSS等常见编程语言,导入后都能自动识别语言类型,实现语法高亮,代码结构清晰可见,再也不用看着杂乱无章的纯文本代码发愁,尤其适合需要在应用中集成代码预览功能的开发者,大幅提升使用体验。

市面上很多商业文档预览SDK,只解决“能预览”的问题,而 Jit-Viewer 想解决的是“好用、省心、适配多场景”。

这次V1.5.0的更新,本质上是在“轻量高效”的核心定位上,进一步突破场景限制、优化使用体验——让复杂的文档预览开发,变得更简单,让不同需求的开发者,都能快速集成、高效使用,不用再为各类预览问题额外消耗精力。

简单来说,Jit-Viewer 是一个纯前端的文件预览引擎。不需要后端转换服务,不需要安装任何插件,几行代码就能让浏览器具备"专业软件"的预览能力。图片目前 jit-viewer 已经支持了:

  • docx / ppt / pdf / excel
  • csv
  • html
  • markdown
  • txt
  • 代码文件(如js,css, java, go, c#, php, ts等)
  • 音频 / 视频
  • CAD
  • 3D模型
  • OFD(国产格式)

同时我们还在持续迭代优化,帮助大家仅通过几行代码,就能让自己的web系统轻松拥有多种文档预览的能力。

github:github.com/jitOffice/j…

❌
❌