普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月2日首页

最懂 iPhone 相机的人,回到苹果了

作者 周奕旨
2026年2月2日 10:29

最懂 iPhone 相机的人,回到苹果了。

据 Sebastiaan de With 本人披露,他将加入苹果 Human Interface Design (HID) Team,这是苹果的核心设计团队。

Sebastiaan 是谁?你可能会对这个名字感到陌生,但你熟悉 iPhone 摄影,那大概率听过甚至用过他开发的应用——Halide。

Sebastiaan 早年就是苹果的员工,曾参与 MobileMe 到 iCloud 的转型工作,也参与了查找我的 iPhone 和查找我的朋友等功能的设计工作,后者演变成现在的「查找(Find My)」应用。

再之后,他遇到从 Twitter 离职的工程师 Ben Sandofsky,两人都对摄影很感兴趣,一拍即合!于是,2017 年,专业摄影应用 Halide 正式推出。

两年后,由 AI 驱动的长曝光摄影软件 Spectre Camera(Lux Optics)发布,这两个应用先后获得了 App Store Award 2019 年度最佳应用奖项与 Apple Design Award 2022 的最佳视觉与图形大奖。

在 Lux Optics 的底部,陈列着这个小团队迄今为止开发的四个应用,并骄傲地写道:

Made with love in California and New York by two dads. 由两位父亲在加州和纽约倾注爱意创造而成。

在推出 Halide 与 Specter Camera 以后,时间来到 2020 年,Lux Optics 工作室发布了一篇文章,宣布一位 iOS 开发者 Rebecca Slatkin 的加入,将团队规模扩展至三人,随后就迎来了 Kino 的面世与获奖——这也是一款摄像 app,与 Blackmagic Camera 的极其专业不同,Kino 主打上手友好,并获得了 App Store Award 2024 年度应用的荣誉。

我们曾经详细介绍过 Kino,点击这里,了解 iPhone 上最友好的拍电影 app:

iPhone 年度最佳应用,2 人团队打造出最合适小白的视频神器

从开发 Halide 开始,Sebastiaan de With 就保持着每年评测 iPhone 影像旗舰的习惯,今年也毫不例外,在苹果秋季发布会后,Sebastiaan de With 第一时间发布了 iPhone 17 Pro 系列的影像评测,犀利地指出了苹果影像目前的巅峰与隐疾,读懂了这篇评测,你也就读懂了 iPhone 影像的未来走向。

随着他重返苹果,这很可能是他最后一次亲自操刀评测——在加入苹果之后,受限于保密协议,恐怕他再也不会以第三方的客观视角,如此详细地剖析 iPhone 影像。

如果你感兴趣的话,也可以直接访问他的网站,以查看评测原文和无压缩样片:iPhone 17 Pro Camera Review:Rule of Three

以下是 Sebastiaan de With 对 iPhone 17 Pro 深度体验的全文编译,由于原文逻辑与论述较散,我们根据适当的顺序和逻辑进行了二次整理:

每年看苹果发布会,我都有点心疼他们的相机团队。因为他们背着一个年更的 KPI,却被全世界期待着拿出「惊天动地」的大招。

这一代的 iPhone 17 Pro,第一眼看去确实大不相同。它是自 iPhone 11 Pro 确立「浴霸」风格以来,在相机模组和整体设计上更新最大的一代。背面依旧是三摄,正面一颗单摄,实体的相机控制按键还在(甚至更为纤薄的 iPhone Air 也配备了纤细的控制按键),长焦镜头也更长了。

为了搞清楚这些配置背后的意义,我们在 5 天内带着它跑了纽约、伦敦和冰岛。我们没有拿到媒体评测机,这是我们自费购买的机器,所以,这会是一篇不加滤镜的真实评测,所有样片都使用 iPhone 原生相机或是我们正在开发的 Halide Mark III 拍摄。

在这趟旅程中,iPhone 17 Pro 给了我不少惊喜。

这一次,有不少新东西

iPhone 17 Pro 的相机系统,苹果称之为「终极 Pro 相机系统」。

最大的变化在于那颗长焦镜头——自从 iPhone 11 Pro 引入三摄以来,大家都习惯了 1200 万像素,继去年超广角升级到 4800 万像素后,苹果终于把长焦镜头也升级了——传感器面积大了 56%,像素干到了 4800 万。并利用中心裁切技术,提供一个 1200 万像素的「光学级」8 倍变焦。

作为一个长焦爱好者,这让我感到兴奋。

但今年最隐蔽、最「苹果式」的升级,其实在前置摄像头。

苹果把那颗万年不变的自拍镜头,换成了一颗方形传感器,这在相机界可是稀罕物。

但别误会,苹果不是为了让你拍正方形照片。你在相机里甚至找不到利用整块 CMOS 拍摄方形照片的选项。这颗 2400 万像素的方形传感器的唯一使命,是为了服务「人物居中(Center Stage)」功能。无论你横着拿还是竖着拿手机,它都能利用传感器的余量进行 1800 万像素的裁切,自动把你框在画面中心,而不需要你费劲地伸长胳膊去找角度。

这是一项极其出色的工程设计,也是经典的苹果式创新。

前面是派对,后面是生意

说回后置摄像头,这无疑是一套强大的系统。既然三颗镜头都是 4800 万像素,你的创作自由度是巨大的。苹果说这「就像口袋里装了八颗镜头」,虽然有点夸张,但数一数:微距、0.5x、1x、2x、4x、8x,确实感觉像是带了半个镜头包。

虽然影像系统在今年迎来了大升级,但其中依然有些我们熟悉的硬件——主摄源于 iPhone 15 Pro 的更新,规格没有发生变化,超广角镜头则源于 iPhone 16 Pro 的更新,这两者非常稳定。

但我必须得吐槽一个越来越严重的问题:主摄的最近对焦距离。

最近对焦距离的问题真的很严重,对于大多数人来说,拍个手里的饮料、桌上的食物、家里的猫狗,是非常高频的场景。但在 iPhone 17 Pro 上,你会发现它为了对焦,疯狂地在主摄和超广角微距模式之间反复横跳——前者画质好、虚化自然;后者虽然能对上焦,但画质明显下降。相比之下,更不注重影像系统的 iPhone Air,最近对焦距离反而近了整整 5 厘米。

这也是为什么在我们开发的 Halide 中,坚决不支持自动切换镜头的原因,而这个问题已经好几年了,希望苹果能在未来修复这个问题。

iPhone 17 Pro 的主摄在 2 倍模式下的画质有了肉眼可见的提升。不仅更清晰,而且那种「数码味」和「过度锐化」的感觉少了很多。

苹果在其主题演讲中表示,深度学习用于从传感器的四像素中解码原始数据,以获得每张图像中更自然的细节和色彩,这也意味着 2× 和 8× 镜头背后的 AI 升频技术得到了显著提升,但在我来看,结果就是让照片看起来少了更多的数码锐化痕迹。

而在今年,整个影像系统的匹配度非常好,不同焦段间的色彩和色温一致性很强,变焦也比以往更平滑。

4 倍正好,8 倍惊艳

我以前给 iPhone 13 Pro 的 3 倍长焦写过不少「情书」,因为 75mm 焦段真的很迷人。后来的 5 倍潜望式长焦虽然望得远,但在 3 倍到 4 倍这个常用焦段里,出现了明显的画质断层。

iPhone 17 Pro 的 4 倍长焦是一个优雅的解决方案,长短皆宜,画质适中,非常适合拍人像和特写,大底传感器带来了惊人的细节。

即便在低光环境下,得益于顶级的传感器位移防抖和软件算法,建筑的细节完全被降噪处理得更平滑。

升级后的长焦有 4800 万像素,这个升级体感明显吗?图片在这里,你可以给出自己的评价:

对于我来说,这颗 4800 万像素的长焦分辨率很好,但镜头成像有点「软」。

同时,在使用 Halide 的 Process Zero 模式或 iPhone 原相机的 ProRAW 模式拍摄时,我发现成像相当柔和感,也没有过度锐化,带来非常强的氛围感,如果你喜欢锐化的感觉,可以在拍摄完成后再编辑。

凭借这些额外的像素和处理,iPhone 17 Pro 系列还提供了一个新的功能:通过裁剪图像中心 1200 万像素区域解锁额外镜头。这个功能的效果如何呢?

首先带给我惊喜的,是稳定性。

苹果在 iPhone 17 Pro 上采用了最先进的硬件防抖和软件防抖,你会注意到在使用相机时,画面有时会在取景器某些区域出现变形,或者稍微滞后于你的移动。

真正能凸显 iPhone 17 Pro 防抖性能的唯一方法,就是拿起一个 200mm 镜头手持拍摄,你会发现越长的焦段意味着会放大细微的抖动,除非你使用三脚架等工具固定相机,否则很难拍出画面。

接下来,让我们说回由 4 倍镜头中心部分 1200 万像素裁切而来的画面。

这对于我来说印象深刻,甚至有些喜剧效果,因为我意识到在使用这个功能时,正通过长焦镜头看到一些从来没注意到或无法用眼睛看清楚的东西。

我在多云的天气里拍了一只快速移动的鸟,放大 500% 看细节,虽然称不上「刀锐奶化」,但考虑到这是手机裁切出来的,效果简直神奇。

我之前提过,但想再次强调——这对所有使用这款手机的人而言都是极富趣味的创作练习:我认为镜头焦距越长,对你构图与摄影技巧的挑战就越大。

这绝非易事,但也意味着你会在原本单一的画面中,突然发现截然不同的美丽影像:

这种长焦能力,实际上是在强迫你对画面做减法。

当镜头变长,你必须学会做减法,去选择画面里留什么、不留什么。比如拍大本钟,在 4 倍焦段下,这是一个标准的打卡照;但在 8 倍焦段下,你可以选择只拍塔尖,或者只拍桥上被夕阳照亮的尘埃和人群。

这就是长焦的魅力,它让你在同一个场景里,发现了完全不同的故事。

有了这颗 8 倍镜头的加入,整体影像系统的覆盖范围也达到了一个新的高度。

无论如何,这都是一次极大的长焦升级,如果你像我一样喜欢长焦,这本身就足以成为升级的理由。

相信算法,但也别全信

我们常说,现在的手机摄影,算法比镜头更重要,这儿有个关键词:处理。

我们非常清楚,很多人有时会对 iPhone 对图像的处理感到沮丧。这种现象源于一种极致的奢侈:若没有其强大先进的图像处理能力,iPhone在多数拍摄场景下所呈现的图像质量将远低于人们的预期。

我认为挫败感往往源于图像处理中决策的智能机制——这种机制可能让你觉得处理手法过于粗暴。有时,仅仅是降噪操作就足以让低光环境下的图像显得模糊不清。

▲ 比如这张照片,就在后期处理中出现了差错

图像处理是手机完胜专业相机的领域,原因很简单:手机拥有更强大的处理能力,且需要通过更复杂的运算从极其微小的图像传感器中获取优质画面。因此我们对其进行的评测,其严苛程度不亚于对全新硬件设备的检验。今年手机的成像表现究竟如何?

情况有些变化。

▲ 上:iPhone 17 Pro / 下:iPhone 16 Pro

主摄像头方面,别指望会有巨大变化。我发现超广角摄像头的细节表现似乎更自然些,但即便如此,其成像效果是否真正稳定提升仍显得有些随机。总体而言,如今图像处理流程极其复杂,仅凭一周时间很难准确把握变化,不过整体成像确实更自然了——尽管若有选择,我仍更倾向于拍摄原生 RAW 格式和 Process Zero 模式的照片。

正如我在前文所述,主摄像头的 2× 模式表现确实显著提升。成像不仅更锐利,视觉上也更少处理痕迹——考虑到苹果宣称这其实是源于更复杂的图像处理,这无疑是个真正的胜利!

最后,你可能会疑惑:既然这些图像经过了更精细的处理,而且这一切都只是软件层面的优化,为什么不直接将这些功能推送到旧款iPhone上呢?苹果是否刻意将最佳图像质量限制在最新款iPhone上?

答案是肯定的,但并非通过不作为或某种恶意狡诈的资本主义手段迫使你升级。软件本身或许能轻松移植到不同设备,但像 iPhone 17 Pro 上那样的图像处理管道却高度集成且经过深度优化。很可能芯片本身以及芯片与传感器之间的硬件都是为处理该系列独特的图像处理而专门设计的。

仅凭这一点,将其移植到旧款手机上就几乎不可能实现。

视频:当之无愧的 Pro

虽然我主攻摄影,但也越来越多地开始拍摄视频,并为此打造了一个 app,iPhone 17 Pro 的视频功能,完全值得 Pro 的定位。

Genlock、ProRes RAW、Open Gate,这些词汇对于普通用户来说简直是天书,通常只出现在好莱坞的片场。

过高的规格看似与普通用户关系不大,但实际上并非如此,对于我们这种开发 Kino 一类 app 的人来说,这是强大的潜力,让 iPhone 真正具备了挑战专业电影机的潜力,而借助这一类 app,普通用户也能轻松上手,调用强大的视频性能和规格,同时也保持了它作为智能手机的灵活性。

添加此类技术不仅使 iPhone 成为真正的专业相机,而且由于它是一个开发平台,还为这些技术创造了在传统摄影和摄像设备中无法实现的应用场景。

这是令人振奋的消息,我认为整个行业都将因此迎来重大变革。凭借这套全新功能——开放式门控录制、ProRes RAW格式、Apple Log 2——苹果持续构建着令人惊叹的技术体系,使其得以与专业电影摄影机抗衡,同时又不牺牲 iPhone 最核心的优势:它本质上仍是一款智能手机,能够成为你想要的任何形态。

材质与软件:一个惊喜,一个惊吓

外观上,铝金属回归了,按照苹果的说法,这种改变的目的是达成更好的散热。

在我的实际体验中也是如此,当高强度拍摄时,能明显感觉到新设计散热效率极高,手感也变好了,希望它能像我其他苹果铝制主力机型那样经久耐用。苹果甚至将其宣传为特别坚固耐用的机型。

另一方面,其面向用户的另一个方面,iOS 本身也经历了新的实质性转变。

Liquid Glass 随 iOS 26 登场,它带来了全新的相机应用设计、备受期待的照片应用改进,以及操作系统的整体焕新。虽然这不是一篇 iOS 评测,但必须承认其美感令人惊艳,我本人也是 Liquid Glass 的拥趸。

不过 iOS 26 的开局略显坎坷:即便在 iPhone 17 Pro 上安装了最新更新,我仍遭遇大量漏洞——从性能不佳(尚可接受)到照片长时间无法显示,再到图像失真、相机应用卡死或无法使用(难以接受)。

▲ 所有以原生 RAW 格式拍摄的长焦照片,画面左侧似乎都存在这种亮带伪影

重大版本更新往往雄心勃勃,却难以完美落地。苹果团队能同步推出iOS 26和新款设备,我对此给予高度赞赏,但在日常使用中,它确实像在用测试版系统。频繁出现的各种问题,让我完全感受不到这是操作系统的候选版本。

总结:从柯达 Brownie 到单反

我认为 iPhone Air 的出现具有非常重要的意义:它让苹果能够打造两款截然不同的手机——一款如珠宝般精致美观,宛若玻璃面板;另一款则明显类似 Apple Watch Ultra:更大、更厚重且更坚固耐用。

多年来,我对 Pro 系列那闪闪发亮、宛如珠宝般的质感总有些不以为然,但说实话,如今我确实有点怀念它了。这款设备无论性能还是外观都堪称猛兽,甚至让人感觉它几乎不像苹果的产品。不过我认为,这样的方向是正确且意义重大的。

如今手机已成为我们生活的核心部分,能够选择一款真正重视专业的产品,感觉意义重大——就像 MacBook Pro 以更厚重、更笨重的 M1 系列所做的那样。

那么,如果将普通 iPhone 比作简易的柯达布朗尼相机,这款或许就是 iPhone 家族中首款专业单反。由此,iPhone 17 Pro 系列中,令人欣喜的简约特质或许有所缺失,但承认专业的复杂性并非产品的敌人,反而是一种重要而积极的进步。

作为相机,它首先是创意表达的工具:获得为创作目的进行精细调校的许可,才真正彰显其强大实力。

它是一台强大的创作工具,至于你能用它创造出什么,那就看你的本事了。

让我有个美满旅程

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

爱范儿 | 原文链接 · 查看评论 · 新浪微博


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

作者 SmalBox
2026年2月2日 10:28

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

在Unity的Shader Graph中,ViewVector节点是一个基础且重要的工具节点,它提供了从网格顶点或片元指向摄像机的方向向量。这个节点返回的是未标准化的原始向量值,保留了原始的长度信息,为着色器编程提供了更多的灵活性和控制能力。

ViewVector节点的核心概念

ViewVector节点计算的是从当前处理的顶点或片元位置指向摄像机位置的向量。这个向量在计算机图形学中被称为视图方向向量或视线向量,是许多光照和渲染效果的基础计算要素。

未标准化向量的特点

ViewVector节点输出的向量是未标准化的,这意味着向量保留了其原始的长度信息。这与标准化向量(单位向量)有显著区别:

  • 未标准化向量包含距离信息,向量的长度等于从表面点到摄像机的实际距离
  • 标准化向量的长度始终为1,方向信息被保留但距离信息丢失
  • 未标准化向量在需要距离计算的效果中特别有用,如雾效、距离衰减等

节点在渲染管线中的作用

在URP(Universal Render Pipeline)渲染流程中,ViewVector节点为着色器提供了关键的视角相关信息。它使得材质能够根据观察角度和距离产生动态变化,是实现许多高级视觉效果的基础。

端口配置与数据流

ViewVector节点仅包含一个输出端口,设计简洁但功能强大。

输出端口详解

  • 名称:Out
  • 方向:输出
  • 类型:Vector 3
  • 绑定:无
  • 描述:网格顶点/片元的View Vector

这个三维向量输出包含了X、Y、Z三个分量,分别代表了在选定坐标空间中的方向分量。向量的方向始终是从表面点指向摄像机,这一特性在所有坐标空间中保持一致。

数据流处理机制

当Shader Graph处理材质时,ViewVector节点会在每个顶点或片元着色器阶段计算相应的视图向量:

  • 在顶点着色器中,计算基于顶点位置
  • 在片元着色器中,计算基于插值后的片元位置
  • 计算基于当前渲染摄像机的变换矩阵

空间坐标系选择

ViewVector节点提供了四种不同的坐标空间选项,每种空间都有其特定的应用场景和计算特性。

Object空间

Object空间也称为模型空间或局部空间,这是3D模型自身的坐标系系统。

坐标系特性

  • 原点位于模型的轴心点(Pivot)
  • 坐标轴与模型的本地方向对齐
  • 不受模型变换(位置、旋转、缩放)影响

数学计算原理

在Object空间中,View Vector的计算基于以下公式:

ViewVector = inverse(UNITY_MATRIX_M) × (CameraPos - VertexPos)

应用场景

  • 需要基于模型自身方向的效果
  • 模型局部空间的特效
  • 与模型几何结构紧密相关的效果

示例应用

假设创建一个随着观察角度变化而变形的材质,在Object空间中使用ViewVector可以确保变形效果始终基于模型自身坐标系,不受模型在世界中旋转的影响。

View空间

View空间也称为摄像机空间或眼睛空间,这是以摄像机为原点的坐标系。

坐标系特性

  • 原点位于摄像机位置
  • Z轴指向摄像机的观察方向
  • X轴向右,Y轴向上

数学计算原理

在View空间中,View Vector的计算简化为:

ViewVector = -VertexViewPos

应用场景

  • 屏幕空间效果
  • 与摄像机直接相关的特效
  • 景深和雾效计算

示例应用

在实现边缘光效果时,使用View空间的ViewVector可以更直接地计算表面法线与视线角度,因为两者在同一坐标系中。

World空间

World空间是场景的全局坐标系,所有对象都以此空间为参考。

坐标系特性

  • 原点位于场景的世界原点
  • 坐标轴方向固定
  • 受模型变换影响

数学计算原理

在World空间中,View Vector计算为:

ViewVector = CameraWorldPos - VertexWorldPos

应用场景

  • 需要世界坐标一致性的效果
  • 全局光照计算
  • 环境效果如雾、大气散射

示例应用

创建距离雾效时,使用World空间的ViewVector可以准确计算表面点与摄像机的实际距离,实现基于真实距离的雾浓度变化。

Tangent空间

Tangent空间是基于表面法线和切线定义的局部坐标系。

坐标系特性

  • 原点位于表面点
  • Z轴与表面法线方向一致
  • X轴与切线方向一致,Y轴与副切线方向一致

数学计算原理

在Tangent空间中,View Vector需要通过变换矩阵计算:

ViewVector = TBN × (CameraWorldPos - VertexWorldPos)

其中TBN是从世界空间到切线空间的变换矩阵

应用场景

  • 法线贴图相关效果
  • 各向异性材质
  • 复杂的表面光照模型

示例应用

在实现各向异性高光时,使用Tangent空间的ViewVector可以确保高光方向正确跟随表面方向,不受模型整体旋转影响。

实际应用案例

基础边缘光效果

边缘光(Rim Light)是ViewVector节点最典型的应用之一,它能够在物体边缘创建发光效果。

实现原理

边缘光效果基于表面法线与视线方向的夹角。当表面几乎垂直于视线方向时(即边缘区域),应用较强的光照;当表面正对摄像机时,效果减弱。

Shader Graph设置步骤

  • 添加ViewVector节点,空间设置为World
  • 添加Normal Vector节点,空间设置为World
  • 使用Dot Product节点计算法线与视线方向的点积
  • 使用One Minus节点反转结果(使边缘值大,中心值小)
  • 使用Power节点控制边缘宽度
  • 使用Color节点定义边缘光颜色
  • 使用Multiply和Add节点混合到最终颜色

参数调节技巧

  • 点积结果控制边缘位置:值越小边缘越明显
  • Power节点指数控制边缘锐度:值越大边缘越锐利
  • 颜色强度控制发光强度

基于距离的透明效果

利用ViewVector的未标准化特性,可以创建基于距离的透明渐变效果。

实现原理

通过计算ViewVector的长度获取表面点与摄像机的实际距离,根据距离值控制材质透明度。

Shader Graph设置步骤

  • 添加ViewVector节点,空间设置为World
  • 使用Length节点计算向量长度(距离)
  • 使用Remap节点将距离映射到0-1范围
  • 使用Saturate节点钳制数值范围
  • 将结果连接到Alpha通道

高级应用变体

  • 非线性距离衰减:使用曲线节点控制透明度变化
  • 距离阈值:使用Step或SmoothStep节点创建硬边或柔边过渡
  • 多层透明度:结合多个距离区间创建复杂透明效果

反射强度控制

根据观察角度动态调整反射强度,模拟菲涅尔效应。

实现原理

菲涅尔效应描述了表面反射率随观察角度变化的物理现象。在掠射角(视线与表面几乎平行)时反射最强,正对表面时反射最弱。

Shader Graph设置步骤

  • 添加ViewVector节点和Normal Vector节点
  • 使用Dot Product节点计算两者点积
  • 使用One Minus节点反转结果
  • 使用Power节点控制菲涅尔效应强度
  • 将结果作为反射强度的乘数

物理准确性考虑

  • 使用Schlick近似公式提高物理准确性
  • 考虑材质折射率对菲涅尔效应的影响
  • 结合粗糙度调整菲涅尔效应范围

各向异性材质模拟

各向异性材质在不同方向上表现出不同的光学特性,如拉丝金属、光盘表面等。

实现原理

使用Tangent空间的ViewVector,结合切线方向计算各向异性高光。

Shader Graph设置步骤

  • 添加ViewVector节点,空间设置为Tangent
  • 使用Tangent Vector节点获取切线方向
  • 基于ViewVector的X分量和切线方向计算各向异性高光
  • 使用Noise节点或Texture节点添加方向性纹理
  • 结合光照模型计算最终高光

高级技巧

  • 使用多个切线方向模拟复杂各向异性
  • 结合视差效果增强立体感
  • 使用时间变量创建动态各向异性效果

性能优化与最佳实践

坐标空间选择策略

不同的坐标空间选择对性能有直接影响,需要根据具体需求权衡。

性能考虑因素

  • Object空间:需要矩阵逆运算,计算成本较高
  • View空间:计算简单,性能最佳
  • World空间:需要世界位置计算,中等成本
  • Tangent空间:需要TBN矩阵计算,成本最高

选择指南

  • 优先考虑View空间,特别是屏幕空间效果
  • 需要世界一致性时选择World空间
  • 仅在必要时使用Object或Tangent空间

计算优化技巧

向量标准化控制

由于ViewVector节点输出未标准化向量,在不需要距离信息时应手动标准化:

  • 添加Normalize节点标准化向量
  • 仅在需要距离信息时保留原始向量

节点组合优化

  • 避免重复计算相同空间下的ViewVector
  • 使用Branch节点避免不必要的计算
  • 合理使用LOD(Level of Detail)控制计算复杂度

平台兼容性考虑

移动平台优化

  • 避免在片元着色器中频繁使用复杂ViewVector计算
  • 在顶点着色器中预计算并插值
  • 使用精度修饰符优化计算(half、fixed)

跨平台一致性

  • 测试不同坐标系在不同平台上的行为
  • 注意左右手坐标系差异
  • 验证矩阵变换的一致性

高级技术与创意应用

动态变形效果

结合ViewVector与顶点偏移,创建基于观察角度的动态几何变形。

实现方法

  • 使用ViewVector方向驱动顶点偏移
  • 结合噪声纹理增加自然感
  • 使用距离控制变形强度

应用场景

  • 鼠标悬停效果
  • 魔法力场变形
  • 热浪扭曲效果

高级光照模型

将ViewVector集成到自定义光照模型中,实现更真实的材质表现。

镜面反射改进

  • 使用ViewVector计算半角向量
  • 实现各向异性高光模型
  • 创建基于视角的镜面反射衰减

次表面散射模拟

  • 使用ViewVector计算背面透光
  • 结合厚度图实现真实散射
  • 创建皮肤、蜡质等材质效果

投影与阴影技术

利用ViewVector增强投影和阴影效果的真实感。

柔和阴影优化

  • 基于视角角度调整阴影柔和度
  • 实现透视正确的阴影变形
  • 创建接触硬化阴影效果

投影纹理改进

  • 使用ViewVector校正投影透视
  • 实现基于视角的投影淡化
  • 创建全息投影效果

故障排除与常见问题

向量方向错误

问题表现

效果方向与预期相反或错乱。

解决方案

  • 检查坐标系选择是否正确
  • 验证向量计算顺序(指向摄像机)
  • 检查摄像机变换矩阵

性能问题

问题表现

着色器编译缓慢或运行时帧率下降。

优化策略

  • 简化不必要的ViewVector计算
  • 在低端设备上降低计算精度
  • 使用更高效的坐标空间

平台特异性问题

问题表现

在不同平台或渲染管线上效果不一致。

解决思路

  • 测试所有目标平台
  • 使用URP内置函数确保兼容性
  • 检查渲染管线设置和配置

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

“千寻AI聘”获千万级天使+轮融资

2026年2月2日 10:26
36氪获悉,近日,AI智能招聘平台“千寻AI聘”(深圳市屿智同行科技有限公司)宣布完成千万级天使+轮融资,本轮融资由正轩投资领投。此前,公司已成功完成数百万元人民币的天使轮融资,投资方为前海信诺。星辰资本担任长期财务顾问。本轮融资将重点用于自研AI Agent的底层技术迭代、核心产品体验升级、全球顶尖AI人才引进,以及深耕B端企业与产业场景的规模化落地。

1个月双线作战:AI公文助手从0到1开发,与国产化适配的"踩坑"全记录

作者 徐小夕
2026年2月2日 10:18

全职创业2年零1个月,接下来和大家分享一下我们最近做了AI协同产品 JitWord 的研发历程。

为什么要在1个月内攻坚两个硬骨头?

说实话,启动这次迭代前,团队内部是有分歧的。

2026-01-30 21.09.31.gif

一方面,AI公文助手 是很多政企客户反复提的需求——他们想要 Word 那种严谨的公文排版,又想要AI的生成能力,还要能在线协同;另一方面,国产化适配是信创大背景下的必选项,涉及国产操作系统、国产浏览器、甚至国产芯片的兼容性问题。

这两个需求,任何一个单独做都要扒层皮。但市场不等人,我们决定在1个月内"双线作战"。

这篇文章记录了我们如何从0搭建AI公文助手模块,以及在国产化适配过程中遇到的那些让人头秃的坑。希望能给同样面临信创改造或富文本技术选型的同学一些参考。


JitWord 是什么?(如果你第一次听说)

2026-01-29 11.02.48.gif

简单给新朋友介绍一下。JitWord 是我们团队开发的协同AI文档引擎,定位是"让Web文档拥有桌面级体验",打造“云端Office”办公体验。

核心能力包括:

  • 多人实时协同:基于CRDT算法,支持Word级别的冲突解决
  • AI辅助创作:内置AI续写、润色、总结,支持自定义Prompt
  • 数学公式渲染:自研公式引擎,支持LaTeX到Word的无损转换(之前文章有详细讲过)
  • 一键导出Word:不只是PDF,是真正的.docx格式,导出后还能在Office里二次编辑
项目 描述
产品名称 JitWord 协同AI文档
技术栈 Vue3 + NestJS + CRDT + WebSocket
核心功能 实时协同、AI写作、公文处理、Word导出
适用场景 企业文档中台、科研协作、政务办公
版本状态 V2.1(AI公文助手 + 国产化适配版)

最近我们也开源了一版sdk,大家可以轻松本地使用和集成:

github地址:github.com/MrXujiang/j…


第一部分:AI公文助手从0到1

1.1 需求拆解:公文场景的残酷现实

做传统富文本编辑器的朋友可能不知道,公文排版是中文排版的地狱模式

  • 红头文件:要严格遵循 GB/T 9704-2012 国家标准,版头、发文字号、签发人都有固定位置
  • 多层嵌套结构:一、(一)、1.(1)、①,这五种层级格式不能乱
  • 表格与附件:公文里的表格必须能跨页重复表头,附件说明有特定格式
  • 严格的页面设置:A4纸张、上白边37mm±1mm、下白边35mm±1mm...

我们调研了市面上几乎所有的Web Office方案,发现要么是简单的表单模板(灵活性不够),要么是把PDF转图片(无法二次编辑)。所以决定自己实现一套结构化公文引擎

image.png

1.2 技术架构:如何把AI塞进公文流程?

我们采用了 模板引擎 + AI生成 + 人工调整 的三段式架构:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│ 公文模板库  │────▶│  AI解析层   │────▶│ 编辑渲染层  │
│ (.docx解析) │     │ (LLM+规则)  │     │ (结构化编辑)│
└─────────────┘     └─────────────┘     └─────────────┘
       │                                        │
       │    ┌─────────────┐                     │
       │◄───│  导出引擎   │◄────────────────────┘
       │    │(Word/PDF)   │
       │    └─────────────┘

关键技术决策:

  • 模板解析:目前了复用 mammoth.js,但是它的样式映射太粗粒度,后面规划重写。
  • AI提示词工程:公文写作不是 Creative Writing,而是 Constraint Writing。我们让AI先分析模板结构,再填充内容,最后做格式合规性检查(比如检查发文字号是否符合"国发〔2024〕1号"这种格式)。
  • 编辑器选型:基础还是ProseMirror,但重写了NodeSpec来支持"公文块"(OfficialBlock)概念。每个公文块是一个不可随意拆分的逻辑单元,比如"主送机关"是一个块,"正文"是一个块。

1.3 核心代码:公文模板的JSON Schema设计

这是我们定义的公文结构规范(节选):

// types/document.ts
export interface OfficialDocument {
  version: 'GB/T-9704-2012';
  header: {
    issuingBody: string;     // 发文机关
    documentNumber: string;  // 发文字号
    urgencyLevel: '特急' | '加急' | '平急';
  };
  body: OfficialBlock[];  // 正文,由多个公文块组成
  attachments?: Attachment[];
}

export interface OfficialBlock {
  type: 'redHeader' | 'recipient' | 'text' | 'level1' | 'level2' | 'table';
  content: string | TableContent;
  style: {
    fontFamily: '仿宋_GB2312' | '黑体' | '楷体_GB2312';  // 信创字体
    fontSize: number;       // 三号=16pt,小三=15pt...
    lineHeight: number;     // 28-30磅固定值
  };
}

遇到的坑: 仿宋_GB2312 这个字体在Mac和Linux上表现差异巨大。Windows上看着好好的文档,在国产系统(基于Linux)上打开后行高会乱掉。解决方案是用CSS的line-height: fixed + 字体fallback栈,并且在导出Word时重新计算行高。

1.4 AI生成流程的优化

image.png

最初我们直接把"写一篇关于XX的通知"扔给GPT-5,结果生成的内容总是太口语化,而且格式经常出错。

优化后的流程是:

  1. 模板匹配:先根据用户选择的公文类型(通知、通报、请示、报告等),加载对应的Prompt模板
  2. 结构化生成:要求AI输出JSON格式,而不是Markdown
  3. 规则校验层:用正则表达式校验公文要素是否齐全(比如通知必须有"特此通知"结尾,请示必须有"妥否,请批复")
// ai/officialWriter.ts
async function generateOfficialDoc(params: GenerationParams) {
  const template = loadTemplate(params.type);
  
  const structuredPrompt = `
  你是一个严谨的公文写作助手。请根据以下信息,生成符合GB/T 9704-2012的公文内容。
  必须输出为JSON格式,字段定义如下:${JSON_SCHEMA}
  
  用户输入:${params.topic}
  要求:${params.requirements}
  `;
  
  const raw = await llm.generate(structuredPrompt);
  const doc = JSON.parse(raw);
  
  // 规则校验
  if (!validateOfficialFormat(doc)) {
    throw new Error('生成内容不符合公文规范,请重试');
  }
  
  return doc;
}

效果: 生成一份标准通知的时间从人工30分钟缩短到AI 10秒 + 人工审核2分钟,效率提升90%


第二部分:国产化适配的"踩坑"全记录

image.png

如果说AI公文助手是"从0到1的创造",那国产化适配就是"从能用到好用的磨砺"。

我们的目标是让 JitWord 能在统信UOS麒麟OS等国产操作系统,以及360安全浏览器奇安信可信浏览器等国产Chromium内核浏览器上稳定运行。

2.1 踩坑一:WebSocket连接的诡异断开

现象: 在麒麟V10系统上,协同编辑总是过几分钟就断开,提示"网络异常",但用户明明能正常刷网页。

排查过程:

  1. 首先排查Nginx配置,以为是proxy_read_timeout太短,改成3600秒,无效。
  2. 检查浏览器Network面板,发现国产浏览器的某些安全策略会主动断开静默的WebSocket连接
  3. 最后发现是奇安信可信浏览器内置了"长连接保护"策略,超过5分钟没有数据交互就会自动断开。

解决方案:

// 心跳机制加强版
export class ReliableWebSocket {
  private ws: WebSocket;
  private heartbeatInterval: NodeJS.Timer;
  
  // 国产浏览器的心跳间隔要更短
  private heartbeatDelay = isDomesticBrowser() ? 10000 : 30000; 
  
  connect() {
    this.ws = new WebSocket(url);
    
    this.heartbeatInterval = setInterval(() => {
      // 发送空操作或ping帧,保持连接活性
      this.send({ type: 'heartbeat', timestamp: Date.now() });
    }, this.heartbeatDelay);
  }
}

2.2 踩坑二:富文本编辑器的输入法冲突

协同.png

这是让我最想骂街的坑。

现象: 在统信UOS + 搜狗输入法(国产版)下,输入中文时,编辑器光标会乱跳,甚至吞字。

根因分析: 国产操作系统的输入法架构和Windows差异很大。我们用的ProseMirror在处理beforeinput事件时,和一些国产输入法的Composition事件冲突。具体表现为:输入法开始合成(compositionstart)时,ProseMirror尝试更新选区,导致输入法丢失了上下文。

解决方案: 不得不patch了ProseMirror的view模块,在合成输入期间暂停所有远程协同更新

// patches/prosemirror-view.ts
let isComposing = false;

editorView.dom.addEventListener('compositionstart', () => {
  isComposing = true;
  // 暂停接收远程操作,避免光标跳动
  collaboration.pauseSync();
});

editorView.dom.addEventListener('compositionend', (e) => {
  isComposing = false;
  const finalData = e.data;
  
  // 延迟恢复同步,等待输入法插入完成
  setTimeout(() => {
    collaboration.resumeSync();
  }, 100);
});

2.3 踩坑三:字体渲染与导出

现象: 同样的"仿宋",在Windows上叫"仿宋",在国产系统上可能叫"FangSong"、"Fangsong"、或者"Source Han Serif CN"。公文要求必须用仿宋_GB2312,但这个字体在某些国产系统上没有预装。

解决方案三部曲:

  1. 前端降级方案:CSS设置font-family: 'FangSong_GB2312', 'Source Han Serif CN', 'Noto Serif CJK SC', serif;
  2. 后端字体嵌入:导出Word时,如果检测目标系统缺少字体,用Java操作POI把字体文件嵌入到生成的docx中
  3. Web字体预加载:在编辑器初始化时,异步加载WOFF2格式的仿宋字体文件,确保所见即所得
// 导出Word时的字体嵌入逻辑(Java实现)
public void embedFonts(XWPFDocument doc, String[] requiredFonts) {
    for (String fontName : requiredFonts) {
        if (!systemHasFont(fontName)) {
            InputStream fontStream = getClass().getResourceAsStream("/fonts/" + fontName + ".ttf");
            doc.embedFont(fontName, fontStream);
        }
    }
}

性能优化:让国产硬件也能流畅运行

说实话,很多国产终端的硬件配置(特别是信创笔记本)不如主流Windows本。我们在1个月内做了以下针对性优化:

虚拟滚动 + 分层渲染

公文通常很长(几十页很正常),我们在ProseMirror基础上实现了虚拟滚动,只渲染可视区域内的DOM节点。同时把静态内容(已经定稿的段落)标记为contenteditable: false,减少MutationObserver的开销。

AI生成的防抖处理

02.gif

当AI生成大段文本时,不能直接一次性插入编辑器(会导致卡顿)。我们改成了逐句插入 + requestAnimationFrame

async function insertAIGeneratedContent(content: string) {
  const sentences = content.split(/([。!?])/);  // 按句分割
  
  for (let i = 0; i < sentences.length; i += 2) {
    const sentence = sentences[i] + (sentences[i+1] || '');
    
    await new Promise(resolve => {
      requestAnimationFrame(() => {
        editor.insertText(sentence);
        resolve(null);
      });
    });
    
    // 每5句暂停一下,让UI线程喘息
    if (i % 5 === 0) await sleep(10);
  }
}

最终效果与场景展示

2026-01-15 10.52.12.gif

公文助手实际应用场景

jitword-gw.png

场景1:政府机关的请示报告

  • 输入:"关于申请信息化建设经费的请示"
  • AI生成:自动匹配"请示"模板,生成红头、发文字号、正文、结尾语
  • 人工调整:只需填写具体金额和项目明细
  • 导出:直接生成符合省级办公厅格式要求的Word文件

场景2:国企的发文通知

  • 协同:办公室主任起草,分管领导在线批注修改,法务审核合规性
  • 留痕:所有修改记录保存,满足公文归档的审计要求
  • 套红:一键生成带红色抬头的正式公文版式

国产化适配验证环境

我们在以下环境完成了完整测试:

  • 操作系统:统信UOS 1060、麒麟V10 SP1、中科方德
  • CPU架构:x86_64、ARM64(鲲鹏920、飞腾2000)
  • 浏览器:360安全浏览器v13、奇安信可信浏览器、火狐中国浏览器

技术总结与反思

这1个月的"双线作战",最大的收获不是功能本身,而是对信创环境下的Web开发有了更深理解:

  1. 不要相信浏览器的UserAgent:国产浏览器都伪装成Chrome,但行为可能完全不同。必须做特性检测(feature detection)而非浏览器嗅探。

  2. 富文本编辑器要"防御性编程":输入法、选区、滚动这些在标准浏览器上稳定的功能,在特殊环境下可能有各种奇奇怪怪的表现。代码要更保守,try-catch要更密集。

  3. AI生成必须后接规则校验:大模型有幻觉,公文又是极其严谨的体裁。AI负责"快",规则引擎负责"准",两者结合才能实用。

  4. 字体和排版是信创隐形大坑:中西文混排、行高计算、字体回退,这些细节决定了产品看起来是"业余demo"还是"正式产品"。


如何集成和体验?

JitWord 目前主要面向企业级用户开发者集成

  • 在线演示:如果你想看看AI公文助手的实际效果,可以访问我们的演示环境(文中不放链接了,掘金私信我或评论获取)
  • 私有化部署:支持国产服务器私有化部署,适配信创环境
  • SDK集成:提供JavaScript SDK,可以Embed到你的业务系统中

如果你也是正在做信创改造的技术负责人,或者需要公文处理能力的产品经理,欢迎评论区交流踩坑经验。国产化这条路,大家互相搀扶才能走得快一点。

我们也开源了一版sdk,大家可以轻松本地使用和集成:

github地址:github.com/MrXujiang/j…


未来规划

这1个月的攻坚只是开始,接下来的 roadmap 包括:

  • 智能校对:接入NLP模型,自动检查公文中的政治术语准确性、数字逻辑一致性(比如"2024年"不能写成"2024年度"在某些语境下)
  • 手写签批:对接国产手写板和签章系统,实现移动端批公文
  • 更多公文类型:从现在的通知、请示、报告,扩展到会议纪要、函、议案等15种法定公文

技术栈彩蛋 🎯

如果你在关注相关技术方向,这是我们用的核心栈,也是目前市面上比较热门的技术方向:

  • Vue3 + Vite + TypeScript(前端)
  • NestJS + TypeORM(后端,支持国产数据库适配)🚀
  • ProseMirror(编辑器内核,深度定制)
  • Yjs(CRDT协同算法)🧩
  • Docker + K8s(部署)

觉得有用的话,点个赞或者收藏吧。信创适配这条路很长,希望这篇文章能帮你少走些弯路。有任何技术问题,评论区留言,我看到都会回复。

Revolut计划未来三年将新加坡员工人数增至3倍,突破300人

2026年2月2日 10:17
据报道,英国金融科技公司Revolut2月2日表示,计划在未来三年扩大在新加坡的业务规模。该公司称,其新加坡员工数量在2024至2025年间已实现翻番,未来三年内计划进一步增至三倍,突破300人。报道称,Revolut近期已在菲律宾设立技术中心,并正“积极评估”向亚洲其他多个市场拓展业务。(界面)

学习Three.js--烟花

2026年2月2日 10:16

学习Three.js--烟花

前置核心说明

开发目标

基于Three.js实现带拖尾渐隐效果的3D烟花,核心能力包括:

  1. 鼠标点击任意位置发射烟花,同时支持自动定时发射;
  2. 烟花粒子具备物理运动特性(重力+空气阻力),模拟真实爆炸扩散;
  3. 粒子带拖尾效果,拖尾从亮到暗渐隐,烟花整体采用发光叠加效果;
  4. 夜空雾效氛围营造,适配全屏黑色背景,视觉效果更逼真。

9529c66d-56b1-47c3-9d71-9736f8ca88ba.png

核心技术栈

技术点 作用
THREE.BufferGeometry 手动构建顶点/颜色缓冲区,高效管理大量粒子(性能优于普通几何体)
THREE.LineSegments 基于顶点数据绘制线段,实现粒子拖尾效果(每段拖尾由多条短线组成)
粒子物理系统 自定义粒子位置、速度、生命周期,模拟重力、空气阻力等物理现象
顶点颜色(vertexColors: true 为每个粒子顶点单独设置颜色,实现拖尾渐隐、粒子发光效果
加法混合(AdditiveBlending 粒子颜色叠加发光,模拟烟花的明亮光晕效果
THREE.Fog 营造夜空雾效,远处粒子渐隐于深色背景,提升空间层次感
屏幕坐标→世界坐标转换 实现鼠标点击位置与3D场景坐标的映射,点击哪里发射哪里
HSL颜色模式 统一烟花色调,生成协调且鲜艳的烟花颜色,避免色彩杂乱

分步开发详解

步骤1:基础环境搭建(场景/相机/渲染器/雾效)

1.1 核心代码
// 1. 场景初始化(添加雾效营造夜空氛围)
const scene = new THREE.Scene();
// 雾效:颜色#000022(深夜空蓝),近裁切面100,远裁切面800
scene.fog = new THREE.Fog(0x000022, 100, 800);

// 2. 透视相机(模拟人眼视角,适配3D场景)
const camera = new THREE.PerspectiveCamera(
  60, // 视角(FOV)
  window.innerWidth / window.innerHeight, // 宽高比
  0.1, // 近裁切面
  2000 // 远裁切面
);
camera.position.set(0, 50, 300); // 高位俯视视角,清晰观察烟花爆炸

// 3. 渲染器(抗锯齿+透明背景)
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio); // 高清适配
document.body.appendChild(renderer.domElement);

// 4. 环境光(微弱补光,避免纯黑,不影响烟花视觉)
scene.add(new THREE.AmbientLight(0x222222));
1.2 关键说明
  • 雾效配置THREE.Fog 是线性雾,参数分别为「雾颜色」「开始生效距离」「完全遮蔽距离」,这里用深夜空蓝,让远处烟花粒子自然融入背景;
  • 渲染器alpha: true:开启透明背景,配合HTML的body { background: #000; },实现纯黑夜空效果,避免渲染器默认白色背景;
  • 相机位置(0, 50, 300) 采用高位俯视,既可以看到烟花的3D扩散效果,又不会让视角过于陡峭,符合人眼观察烟花的习惯。

步骤2:核心粒子系统初始化

这是烟花效果的核心,需要手动构建粒子的顶点、颜色缓冲区,以及存储粒子物理状态的数据结构。

2.1 核心代码
// 粒子系统核心参数(集中管理,方便调整)
const MAX_PARTICLES = 8000; // 最大粒子数(限制性能开销)
const TRAIL_LENGTH = 4; // 每个粒子的拖尾长度(4个顶点=3段短线)
const totalVertices = MAX_PARTICLES * TRAIL_LENGTH; // 总顶点数

// 1. 初始化缓冲区数据(Float32Array存储顶点/颜色数据,高效)
const positions = new Float32Array(totalVertices * 3); // 顶点坐标:每个顶点3个值(x,y,z)
const colors = new Float32Array(totalVertices * 3); // 顶点颜色:每个顶点3个值(r,g,b)
const alives = new Uint8Array(MAX_PARTICLES); // 粒子存活状态(0=空闲,1=活跃)
const particleData = []; // 存储每个粒子的完整物理状态(自定义数据结构)

// 2. 初始化每个粒子的物理状态数据
for (let i = 0; i < MAX_PARTICLES; i++) {
  particleData.push({
    pos: new THREE.Vector3(), // 当前位置
    vel: new THREE.Vector3(), // 当前速度
    color: new THREE.Color(), // 粒子颜色
    size: 0, // 粒子尺寸(此处用于后续扩展,当前拖尾效果暂未用到)
    life: 0, // 粒子生命周期(0~1,1=消亡)
    history: [ // 拖尾历史位置(FIFO队列,存储最近TRAIL_LENGTH个位置)
      new THREE.Vector3(),
      new THREE.Vector3(),
      new THREE.Vector3(),
      new THREE.Vector3()
    ]
  });
}

// 3. 构建BufferGeometry(手动绑定缓冲区数据)
const geometry = new THREE.BufferGeometry();
// 绑定顶点位置缓冲区
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
// 绑定顶点颜色缓冲区
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

// 4. 自定义材质(支持顶点颜色+拖尾渐隐+发光叠加)
const material = new THREE.LineBasicMaterial({
  vertexColors: true, // 启用顶点颜色(优先使用缓冲区的color数据,而非材质统一颜色)
  transparent: true, // 启用透明(支持渐隐效果)
  depthWrite: false, // 关闭深度写入(避免粒子之间互相遮挡,提升叠加效果)
  blending: THREE.AdditiveBlending // 加法混合(颜色叠加,实现烟花发光效果)
});

// 5. 创建LineSegments(基于顶点数据绘制拖尾线段)
const lines = new THREE.LineSegments(geometry, material);
scene.add(lines);
2.2 关键技术点解析
  • 缓冲区数据(Float32ArrayBufferGeometry 是Three.js中高效的几何体类型,直接操作二进制数组存储顶点数据,比普通Geometry性能更高,适合大量粒子场景;
  • 拖尾实现原理:每个粒子存储TRAIL_LENGTH个历史位置(这里是4个),通过LineSegments连接相邻位置,形成3段短线,视觉上就是拖尾;
  • alives数组:标记粒子是否空闲,避免重复创建/销毁粒子(对象池模式),提升性能,Uint8Array 占用内存小,适合存储二值状态;
  • 材质核心参数
    • AdditiveBlending:加法混合,每个粒子的颜色会与背景和其他粒子颜色叠加,越密集的地方越亮,完美模拟烟花的发光光晕;
    • depthWrite: false:关闭深度写入,粒子之间不会互相遮挡,所有粒子都能正常叠加发光,避免拖尾被遮挡的问题;
    • vertexColors: true:启用后,材质会忽略自身的color参数,转而使用BufferGeometry中的color缓冲区数据,实现每个顶点的独立颜色。

步骤3:烟花发射函数(分配粒子+初始化物理状态)

3.1 核心代码
/**
 * 发射烟花函数
 * @param {Number} x - 发射位置X
 * @param {Number} y - 发射位置Y
 * @param {Number} z - 发射位置Z
 * @param {Boolean} isChild - 是否为子粒子(用于二次爆炸,当前暂为单级爆炸)
 */
function launchFirework(x, y, z, isChild = false) {
  // 1. 确定粒子数量(子粒子少,主烟花粒子多,模拟真实爆炸)
  const count = isChild ? (30 + Math.random() * 60) : (300 + Math.random() * 400);
  // 2. 确定烟花色调(HSL模式,保证同批次烟花颜色协调)
  const hue = isChild ? (Math.random() * 0.1 + 0.95) : (Math.random() * 0.3);

  for (let p = 0; p < count; p++) {
    // 3. 查找空闲粒子(对象池模式,复用空闲粒子,提升性能)
    let idx = -1;
    for (let i = 0; i < MAX_PARTICLES; i++) {
      if (!alives[i]) {
        idx = i;
        break;
      }
    }
    if (idx === -1) break; // 无空闲粒子,终止本次发射

    const particle = particleData[idx];
    
    // 4. 初始化粒子初始位置
    particle.pos.set(x, y, z);
    
    // 5. 初始化粒子爆炸速度(极坐标转换,360°扩散)
    const baseSpeed = isChild ? (10 + Math.random() * 15) : (30 + Math.random() * 30);
    const theta = Math.random() * Math.PI * 2; // 水平方向角度(0~360°)
    const phi = Math.random() * Math.PI; // 垂直方向角度(0~180°)
    particle.vel.set(
      baseSpeed * Math.sin(phi) * Math.cos(theta),
      baseSpeed * Math.sin(phi) * Math.sin(theta),
      baseSpeed * Math.cos(phi)
    ).add(new THREE.Vector3().random().subScalar(0.5).multiplyScalar(8)); // 加入微小扰动,避免扩散过于规则

    // 6. 初始化粒子颜色和尺寸(HSL模式,鲜艳且协调)
    particle.color.setHSL(hue, 0.85, isChild ? 0.9 : 0.65); // 色相统一,亮度/饱和度微调
    particle.size = isChild ? (0.3 + Math.random() * 0.7) : (0.4 + Math.random() * 0.9);
    particle.life = 0; // 重置生命周期
    
    // 7. 初始化粒子拖尾历史位置(所有历史位置与当前位置一致,避免初始拖尾偏移)
    for (let h = 0; h < TRAIL_LENGTH; h++) {
      particle.history[h].copy(particle.pos);
    }

    // 8. 标记粒子为活跃状态
    alives[idx] = 1;
  }
}
3.2 关键技术点解析
  • 对象池模式:通过alives数组查找空闲粒子,复用已有粒子对象,避免频繁创建/销毁对象带来的性能开销,这是大量粒子场景的最佳实践;
  • 极坐标转换:使用theta(水平角度)和phi(垂直角度)生成3D空间中的扩散速度,实现烟花向四面八方均匀爆炸的效果;
  • HSL颜色模式setHSL(hue, saturation, lightness) 中,hue(色相)决定烟花的主颜色,同批次烟花使用相同/相近的hue,保证颜色协调,避免杂乱;saturation(饱和度)设为0.85,保证颜色鲜艳;lightness(亮度)微调,实现粒子间的细微颜色差异;
  • 速度扰动new THREE.Vector3().random().subScalar(0.5).multiplyScalar(8) 生成一个微小的随机向量,叠加到基础速度上,避免烟花扩散过于规则,更贴近真实效果。

步骤4:交互绑定(鼠标点击+自动发射)

4.1 核心代码
// 1. 鼠标点击事件:点击屏幕任意位置发射烟花
window.addEventListener('click', (e) => {
  // 步骤1:屏幕坐标转换为NDC坐标(归一化设备坐标,-1~1)
  const x = (e.clientX / window.innerWidth) * 2 - 1;
  const y = -(e.clientY / window.innerHeight) * 2 + 1;
  
  // 步骤2:NDC坐标转换为世界坐标(通过相机反投影)
  const vector = new THREE.Vector3(x, y, 0.5); // z=0.5 取视口中间深度
  vector.unproject(camera); // 反投影:NDC → 世界坐标
  
  // 步骤3:计算射线方向,确定3D场景中的发射位置
  const dir = vector.sub(camera.position).normalize(); // 相机到点击点的方向向量
  const distance = (200 - camera.position.z) / dir.z; // 固定深度距离,避免发射位置过远/过近
  const pos = camera.position.clone().add(dir.multiplyScalar(distance)); // 最终发射位置
  
  // 步骤4:发射烟花
  launchFirework(pos.x, pos.y, pos.z);
});

// 2. 自动发射:每隔1秒随机发射一次烟花(增加场景活力)
setInterval(() => {
  if (Math.random() > 0.8) { // 20%概率发射,避免过于密集
    launchFirework(
      (Math.random() - 0.5) * 600, // X轴随机范围(-300~300)
      100 + Math.random() * 200, // Y轴随机范围(100~300)
      200 + Math.random() * 300 // Z轴随机范围(200~500)
    );
  }
}, 1000);
4.2 关键技术点解析
  • 屏幕坐标→世界坐标转换:这是Three.js中实现「点击3D场景」的核心逻辑,步骤为「屏幕坐标→NDC坐标→世界坐标」:
    1. 屏幕坐标(clientX/clientY)是像素值,范围为(0,0)(window.innerWidth, window.innerHeight),转换为NDC坐标后范围为(-1,-1)(1,1)
    2. vector.unproject(camera):将NDC坐标转换为世界坐标,需要指定一个z值(此处为0.5),表示视口的中间深度;
    3. 计算射线方向并确定距离,最终得到3D场景中的发射位置,避免烟花发射到相机后方或过远的位置;
  • 自动发射逻辑:使用setInterval定时执行,配合Math.random() > 0.8实现20%的发射概率,避免烟花过于密集,平衡视觉效果和性能。

步骤5:动画循环(粒子更新+拖尾渲染+场景渲染)

这是烟花动起来的核心,每帧更新粒子的物理状态、拖尾历史位置,并更新缓冲区数据,实现流畅的动画效果。

5.1 核心代码
const clock = new THREE.Clock(); // 时钟,用于获取每帧时间增量

function animate() {
  requestAnimationFrame(animate); // 绑定浏览器刷新率,实现流畅动画
  const delta = Math.min(clock.getDelta(), 0.05); // 获取时间增量,限制最大为0.05(防止帧率波动导致动画跳变)

  // 1. 遍历所有粒子,更新活跃粒子的状态
  for (let i = 0; i < MAX_PARTICLES; i++) {
    if (!alives[i]) continue; // 跳过空闲粒子

    const p = particleData[i];
    // 步骤1:更新粒子生命周期,判断是否消亡
    p.life += delta * 0.8; // 生命周期增速(0.8为调节系数,越大消亡越快)
    if (p.life > 1.0) { // 生命周期超过1,标记为空闲
      alives[i] = 0;
      continue;
    }

    // 步骤2:更新粒子物理状态(重力+空气阻力)
    p.vel.y -= 45 * delta; // 重力:Y轴速度递减(模拟地球重力,向下拉)
    p.vel.multiplyScalar(0.985); // 空气阻力:速度整体衰减(模拟空气阻力,粒子逐渐减速)
    p.pos.add(p.vel.clone().multiplyScalar(delta)); // 根据速度更新当前位置

    // 步骤3:更新粒子拖尾历史位置(FIFO先进先出,实现拖尾移动)
    for (let h = TRAIL_LENGTH - 1; h > 0; h--) {
      p.history[h].copy(p.history[h - 1]); // 后一个位置复制前一个位置的数据
    }
    p.history[0].copy(p.pos); // 最新位置写入历史队列的第一个位置

    // 步骤4:更新缓冲区数据(顶点位置+顶点颜色,实现拖尾渲染+渐隐)
    const baseIdx = i * TRAIL_LENGTH; // 当前粒子的顶点起始索引
    for (let h = 0; h < TRAIL_LENGTH; h++) {
      const posIdx = (baseIdx + h) * 3; // 当前顶点的位置索引
      const colIdx = (baseIdx + h) * 3; // 当前顶点的颜色索引
      
      // 更新顶点位置
      const histPos = p.history[h];
      positions[posIdx] = histPos.x;
      positions[posIdx + 1] = histPos.y;
      positions[posIdx + 2] = histPos.z;

      // 更新顶点颜色(拖尾渐隐+生命周期渐隐)
      const fade = 1.0 - (h / (TRAIL_LENGTH - 1)) * 0.7; // 拖尾渐隐:越旧的位置越暗
      const alphaFactor = 1.0 - p.life; // 生命周期渐隐:粒子越接近消亡越暗
      colors[colIdx] = p.color.r * fade * alphaFactor;
      colors[colIdx + 1] = p.color.g * fade * alphaFactor;
      colors[colIdx + 2] = p.color.b * fade * alphaFactor;
    }
  }

  // 2. 标记缓冲区数据需要更新(Three.js才会重新渲染)
  geometry.attributes.position.needsUpdate = true;
  geometry.attributes.color.needsUpdate = true;

  // 3. 渲染场景
  renderer.render(scene, camera);
}

// 启动动画循环
animate();
5.2 关键技术点解析
  • clock.getDelta():获取上一帧到当前帧的时间增量(单位:秒),使用时间增量更新动画,保证动画速度与帧率无关,无论高帧率还是低帧率,烟花行进速度一致;
  • 物理模拟逻辑
    • 重力:p.vel.y -= 45 * delta,只在Y轴施加重力,模拟地球重力,让粒子逐渐下落,更贴近真实烟花;
    • 空气阻力:p.vel.multiplyScalar(0.985),每帧让速度乘以一个小于1的系数,实现速度衰减,粒子逐渐减速,拖尾也会随之变短;
  • 拖尾FIFO队列:拖尾历史位置数组采用「先进先出」模式,每帧将前一个位置的数据复制到后一个位置,最新位置写入数组头部,实现拖尾的移动效果,视觉上就是粒子带着尾巴前进;
  • 双重渐隐逻辑
    • 拖尾渐隐(fade):1.0 - (h / (TRAIL_LENGTH - 1)) * 0.7,拖尾中越旧的位置(索引h越大),fade值越小,颜色越暗,实现拖尾从亮到暗的渐变;
    • 生命周期渐隐(alphaFactor):1.0 - p.life,粒子越接近消亡(life越接近1),alphaFactor值越小,颜色越暗,实现粒子从亮到暗的消亡效果;
  • needsUpdate = trueBufferGeometry的缓冲区数据更新后,必须将对应的needsUpdate设为true,告诉Three.js缓冲区数据已变更,需要重新渲染,否则修改不会生效。

步骤6:窗口适配(响应式调整)

6.1 核心代码
window.addEventListener('resize', () => {
  // 1. 更新相机宽高比
  camera.aspect = window.innerWidth / window.innerHeight;
  // 2. 更新相机投影矩阵(必须调用,否则宽高比修改不生效)
  camera.updateProjectionMatrix();
  // 3. 更新渲染器尺寸
  renderer.setSize(window.innerWidth, window.innerHeight);
});
6.2 关键说明
  • 窗口大小变化时,需要同步更新相机的宽高比和渲染器的尺寸,保证烟花效果在不同屏幕尺寸下都能全屏显示;
  • camera.updateProjectionMatrix():相机参数修改后,必须调用该方法更新投影矩阵,否则相机的宽高比修改不会生效,场景会出现拉伸变形。

核心参数速查表(快速调整效果)

参数名 取值 作用 修改建议
MAX_PARTICLES 8000 最大粒子数,限制性能开销 配置低的设备可改为4000,减少卡顿;高性能设备可改为16000,提升烟花密集度
TRAIL_LENGTH 4 每个粒子的拖尾长度(顶点数) 改为2,拖尾变短更锐利;改为6,拖尾变长更柔和(注意:会增加顶点数,影响性能)
baseSpeed(主烟花) 30~60 烟花爆炸初始速度 改为2040,爆炸范围变小;改为4080,爆炸范围更大更壮观
p.vel.y -= 45 * delta 45 重力系数 改为20,重力更弱,烟花停留时间更长;改为60,重力更强,烟花下落更快
p.vel.multiplyScalar(0.985) 0.985 空气阻力系数 改为0.97,阻力更大,粒子减速更快;改为0.995,阻力更小,粒子飞行更远
hue(主烟花) 0~0.3 烟花主色调(HSL) 改为0.30.6,呈现绿色/青色系;改为0.60.9,呈现红色/粉色系
AdditiveBlending 混合模式 粒子发光叠加 改为NormalBlending,关闭发光效果,呈现普通粒子;改为MultiplyBlending,呈现暗色调叠加效果

完整代码

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <title>Three.js 烟花带拖尾效果</title>
  <style>
    body { margin: 0; overflow: hidden; background: #000; }
    #info {
      position: absolute;
      top: 20px;
      width: 100%;
      text-align: center;
      color: white;
      font-family: Arial, sans-serif;
      pointer-events: none;
      text-shadow: 0 0 8px rgba(255,255,255,0.7);
    }
  </style>
</head>
<body>
  <div id="info">点击任意位置发射烟花(带拖尾)</div>

<script type="module">
  import * as THREE from 'https://esm.sh/three@0.174.0';

  // ========== 1. 基础环境初始化(场景/相机/渲染器/雾效) ==========
  const scene = new THREE.Scene();
  // 夜空雾效:深蓝黑色,远处粒子自然融入背景
  scene.fog = new THREE.Fog(0x000022, 100, 800);

  // 透视相机:高位俯视,清晰观察烟花爆炸
  const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 2000);
  camera.position.set(0, 50, 300);

  // 渲染器:抗锯齿+透明背景,适配纯黑夜空
  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
  renderer.setSize(window.innerWidth, window.innerHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
  document.body.appendChild(renderer.domElement);

  // 微弱环境光:补充暗部,不影响烟花视觉效果
  scene.add(new THREE.AmbientLight(0x222222));

  // ========== 2. 粒子系统核心参数与缓冲区初始化 ==========
  const MAX_PARTICLES = 8000; // 最大粒子数(平衡效果与性能)
  const TRAIL_LENGTH = 4; // 拖尾长度(4个顶点=3段短线)
  const totalVertices = MAX_PARTICLES * TRAIL_LENGTH; // 总顶点数

  // 缓冲区数据:存储顶点坐标和颜色
  const positions = new Float32Array(totalVertices * 3);
  const colors = new Float32Array(totalVertices * 3);
  const alives = new Uint8Array(MAX_PARTICLES); // 粒子存活状态(0=空闲,1=活跃)
  const particleData = []; // 粒子物理状态数据池

  // 初始化粒子物理状态
  for (let i = 0; i < MAX_PARTICLES; i++) {
    particleData.push({
      pos: new THREE.Vector3(), // 当前位置
      vel: new THREE.Vector3(), // 当前速度
      color: new THREE.Color(), // 粒子颜色
      size: 0, // 粒子尺寸(预留扩展)
      life: 0, // 生命周期(0~1)
      history: [ // 拖尾历史位置(FIFO队列)
        new THREE.Vector3(),
        new THREE.Vector3(),
        new THREE.Vector3(),
        new THREE.Vector3()
      ]
    });
  }

  // 构建BufferGeometry:高效管理大量粒子顶点数据
  const geometry = new THREE.BufferGeometry();
  geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
  geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));

  // 自定义材质:支持顶点颜色+发光叠加+渐隐
  const material = new THREE.LineBasicMaterial({
    vertexColors: true, // 启用顶点独立颜色
    transparent: true, // 启用透明,支持渐隐
    depthWrite: false, // 关闭深度写入,粒子正常叠加
    blending: THREE.AdditiveBlending // 加法混合,实现烟花发光效果
  });

  // 创建LineSegments:绘制粒子拖尾线段
  const lines = new THREE.LineSegments(geometry, material);
  scene.add(lines);

  // ========== 3. 烟花发射函数(分配粒子+初始化物理状态) ==========
  function launchFirework(x, y, z, isChild = false) {
    // 确定粒子数量:主烟花多,子烟花少(预留二次爆炸扩展)
    const count = isChild ? (30 + Math.random() * 60) : (300 + Math.random() * 400);
    // 确定烟花主色调:HSL模式,保证颜色协调
    const hue = isChild ? (Math.random() * 0.1 + 0.95) : (Math.random() * 0.3);

    for (let p = 0; p < count; p++) {
      // 查找空闲粒子(对象池复用,提升性能)
      let idx = -1;
      for (let i = 0; i < MAX_PARTICLES; i++) {
        if (!alives[i]) {
          idx = i;
          break;
        }
      }
      if (idx === -1) break; // 无空闲粒子,终止本次发射

      const particle = particleData[idx];
      // 初始化粒子位置
      particle.pos.set(x, y, z);

      // 初始化爆炸速度(极坐标转换,360°扩散)
      const baseSpeed = isChild ? (10 + Math.random() * 15) : (30 + Math.random() * 30);
      const theta = Math.random() * Math.PI * 2; // 水平角度
      const phi = Math.random() * Math.PI; // 垂直角度
      particle.vel.set(
        baseSpeed * Math.sin(phi) * Math.cos(theta),
        baseSpeed * Math.sin(phi) * Math.sin(theta),
        baseSpeed * Math.cos(phi)
      ).add(new THREE.Vector3().random().subScalar(0.5).multiplyScalar(8)); // 速度扰动,避免规则扩散

      // 初始化粒子颜色(HSL模式,鲜艳协调)
      particle.color.setHSL(hue, 0.85, isChild ? 0.9 : 0.65);
      particle.size = isChild ? (0.3 + Math.random() * 0.7) : (0.4 + Math.random() * 0.9);
      particle.life = 0; // 重置生命周期

      // 初始化拖尾历史位置(避免初始偏移)
      for (let h = 0; h < TRAIL_LENGTH; h++) {
        particle.history[h].copy(particle.pos);
      }

      // 标记粒子为活跃状态
      alives[idx] = 1;
    }
  }

  // ========== 4. 交互绑定(鼠标点击+自动发射) ==========
  // 鼠标点击:屏幕坐标→世界坐标,发射烟花
  window.addEventListener('click', (e) => {
    // 步骤1:屏幕坐标转NDC坐标(-1~1)
    const x = (e.clientX / window.innerWidth) * 2 - 1;
    const y = -(e.clientY / window.innerHeight) * 2 + 1;

    // 步骤2:NDC坐标转世界坐标
    const vector = new THREE.Vector3(x, y, 0.5);
    vector.unproject(camera);

    // 步骤3:计算3D场景发射位置
    const dir = vector.sub(camera.position).normalize();
    const distance = (200 - camera.position.z) / dir.z;
    const pos = camera.position.clone().add(dir.multiplyScalar(distance));

    // 步骤4:发射烟花
    launchFirework(pos.x, pos.y, pos.z);
  });

  // 自动发射:每隔1秒,20%概率发射烟花
  setInterval(() => {
    if (Math.random() > 0.8) {
      launchFirework(
        (Math.random() - 0.5) * 600,
        100 + Math.random() * 200,
        200 + Math.random() * 300
      );
    }
  }, 1000);

  // ========== 5. 动画循环(粒子更新+拖尾渲染) ==========
  const clock = new THREE.Clock();

  function animate() {
    requestAnimationFrame(animate);
    const delta = Math.min(clock.getDelta(), 0.05); // 限制最大时间增量,避免动画跳变

    // 遍历更新所有活跃粒子
    for (let i = 0; i < MAX_PARTICLES; i++) {
      if (!alives[i]) continue;

      const p = particleData[i];
      // 更新生命周期,判断是否消亡
      p.life += delta * 0.8;
      if (p.life > 1.0) {
        alives[i] = 0;
        continue;
      }

      // 更新物理状态(重力+空气阻力)
      p.vel.y -= 45 * delta; // 重力:Y轴速度递减
      p.vel.multiplyScalar(0.985); // 空气阻力:速度整体衰减
      p.pos.add(p.vel.clone().multiplyScalar(delta)); // 更新当前位置

      // 更新拖尾历史位置(FIFO先进先出)
      for (let h = TRAIL_LENGTH - 1; h > 0; h--) {
        p.history[h].copy(p.history[h - 1]);
      }
      p.history[0].copy(p.pos);

      // 更新缓冲区数据(顶点位置+颜色)
      const baseIdx = i * TRAIL_LENGTH;
      for (let h = 0; h < TRAIL_LENGTH; h++) {
        const posIdx = (baseIdx + h) * 3;
        const colIdx = (baseIdx + h) * 3;

        // 更新顶点位置
        const histPos = p.history[h];
        positions[posIdx] = histPos.x;
        positions[posIdx + 1] = histPos.y;
        positions[posIdx + 2] = histPos.z;

        // 更新顶点颜色(双重渐隐:拖尾+生命周期)
        const fade = 1.0 - (h / (TRAIL_LENGTH - 1)) * 0.7;
        const alphaFactor = 1.0 - p.life;
        colors[colIdx] = p.color.r * fade * alphaFactor;
        colors[colIdx + 1] = p.color.g * fade * alphaFactor;
        colors[colIdx + 2] = p.color.b * fade * alphaFactor;
      }
    }

    // 标记缓冲区数据需要更新,Three.js重新渲染
    geometry.attributes.position.needsUpdate = true;
    geometry.attributes.color.needsUpdate = true;

    // 渲染场景
    renderer.render(scene, camera);
  }

  // 启动动画循环
  animate();

  // ========== 6. 窗口适配(响应式调整) ==========
  window.addEventListener('resize', () => {
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
  });
</script>
</body>
</html>

总结与扩展建议

核心总结

  1. 粒子系统核心:采用BufferGeometry+对象池模式,高效管理大量粒子,平衡视觉效果与性能,是大量粒子场景的最佳实践;
  2. 拖尾效果实现:通过存储粒子历史位置(FIFO队列),配合LineSegments绘制线段,再通过顶点颜色实现渐隐,视觉效果流畅自然;
  3. 物理模拟:手动添加重力和空气阻力,让粒子运动更贴近真实,速度扰动避免规则扩散,提升真实感;
  4. 视觉优化:采用AdditiveBlending加法混合实现烟花发光效果,THREE.Fog营造夜空氛围,HSL颜色模式保证颜色协调鲜艳;
  5. 交互核心:屏幕坐标→世界坐标转换,实现鼠标点击发射烟花,自动发射增加场景活力。

扩展建议

  1. 二次爆炸效果:在粒子生命周期接近0.5时,调用launchFirework发射子粒子,实现烟花爆炸后再分裂的效果;
  2. 声音效果:添加音频文件,在发射烟花时播放爆炸声,提升沉浸感;
  3. 鼠标跟随:改为鼠标移动时发射烟花,或让烟花跟随鼠标位置爆炸;
  4. 颜色渐变:粒子生命周期中动态修改hue值,实现烟花颜色从亮到暗、从暖到冷的渐变;
  5. 粒子尺寸变化:利用particle.size,在生命周期中动态修改粒子尺寸,实现烟花爆炸后粒子逐渐变大再消亡的效果;
  6. 轨迹优化:增加粒子旋转效果,或让拖尾带有轻微弯曲,更贴近真实烟花轨迹;
  7. 性能优化:使用InstancedMesh替代LineSegments,进一步减少DrawCall,支持更多粒子数。

前端向架构突围系列 - 编译原理 [6 - 3]:ESLint 原理、自定义规则与 Codemod

2026年2月2日 10:11

写在前面

很多团队面临这样的困境: 架构师制定了规范:“所有业务组件禁止直接引用 lodash,必须引用 src/utils。” 结果呢?文档写在 Wiki 里吃灰,新同事照样写 import _ from 'lodash'。Code Review 时如果你没看出来,这代码就溜上线了。

口头规范是软弱的,代码规范才是强硬的。

真正的高手,会把架构规范写成 ESLint 插件。这一节,我们将把“文档里的规范”变成“编辑器里的红色波浪线”。

image.png


一、 ESLint 的原理:找茬的艺术

ESLint 的工作流程和 Babel 惊人地相似,只有最后一步不同。

1.1 流程对比

  • Babel: Parse -> Transform (修改 AST) -> Generate (生成新代码)
  • ESLint: Parse -> Traverse (遍历 AST) -> Report (报告错误)

ESLint 默认使用 Espree 作为解析器(Parser)。它遍历 AST,当遇到不符合规则的节点时,不是去修改它,而是记录一个“错误对象”(包含行号、列号、错误信息)。

1.2 Fix 的原理

你一定用过 eslint --fix。既然 ESLint 不生成新代码,它是怎么修复错误的? 其实,ESLint 的规则在报错时,可以提供一个 fixer 对象。

context.report({
  node: node,
  message: "缺少分号",
  fix: function(fixer) {
    // 告诉 ESLint:在当前节点后面插入一个 ";"
    return fixer.insertTextAfter(node, ";");
  }
});

ESLint 收集所有的 fix 操作,最后在源码字符串上进行字符串拼接(而不是重新 Generate),从而保留原本的格式(空格、注释)。


二、 实战:编写你的第一条 ESLint 规则

假设你的团队有一个死规定:代码中禁止使用 var,必须用 letconst 虽然现有的规则集里有 no-var,但为了学习,我们自己写一个。

2.1 规则结构

一个 ESLint 规则就是一个导出的对象,包含 meta(元数据)和 create(访问者)。

// eslint-plugin-no-var-custom.js
module.exports = {
  meta: {
    type: "suggestion",
    docs: {
      description: "禁止使用 var",
    },
    fixable: "code", // 表示这个规则支持自动修复
  },
  create(context) {
    return {
      // 监听 VariableDeclaration 节点
      VariableDeclaration(node) {
        // 如果声明类型是 "var"
        if (node.kind === "var") {
          // 报警!
          context.report({
            node,
            message: "大清亡了,别用 var 了!",
            // 自动修复逻辑
            fix(fixer) {
              // 把 "var" 替换成 "let"
              // sourceCode.getFirstToken(node) 获取到的就是 "var" 这个关键词
              const varToken = context.getSourceCode().getFirstToken(node);
              return fixer.replaceText(varToken, "let");
            }
          });
        }
      }
    };
  }
};

2.2 架构级应用:防腐层治理

架构师可以利用自定义规则做更高级的事情。 场景: 项目中分层架构,UI 层(src/components)严禁直接导入数据库层(src/db)。

// rule: no-ui-import-db.js
create(context) {
  return {
    ImportDeclaration(node) {
      const importPath = node.source.value; // e.g., '@/db/user'
      const currentFilename = context.getFilename(); // 当前正在检查的文件

      // 如果当前文件在 components 目录下,且引用了 db 目录
      if (currentFilename.includes('/src/components/') && importPath.includes('/db/')) {
        context.report({
          node,
          message: "架构报警:UI 组件禁止直接触碰数据库层!请通过 Service 层调用。"
        });
      }
    }
  };
}

把这个规则加入 CI/CD,你的架构分层就有了强制力


三、 Codemod:自动化重构的核武器

ESLint 的 fix 适合修补小问题。但如果你面临的是大规模破坏性重构,比如:

  • 把项目中 5000 个文件的 React.createClass 全部重写为 class extends React.Component
  • 把所有的 import { Button } from 'my-ui' 变成 import Button from 'my-ui/button'

这时候,你需要 Codemod。最著名的工具是 Facebook 推出的 jscodeshift

3.1 jscodeshift 的优势

它不仅仅是 AST 解析器,它提供了一套类似 jQuery 的 API 来操作 AST。你不需要关心复杂的节点结构,只需要链式调用。

3.2 实战:API 签名变更

需求: 旧的 API myApi.get(id, type) 升级了,参数变了,必须改成对象传参 myApi.get({ id, type })

Codemod 脚本:

// transformer.js
export default function(file, api) {
  const j = api.jscodeshift; // 获取 jscodeshift 实例
  
  return j(file.source) // 1. 解析源码
    .find(j.CallExpression, { // 2. 查找所有的函数调用
      callee: {
        object: { name: 'myApi' },
        property: { name: 'get' }
      }
    })
    .forEach(path => { // 3. 遍历找到的节点
      const args = path.node.arguments;
      
      // 如果参数数量是 2 个,说明是旧代码
      if (args.length === 2) {
        // 创建一个新的对象表达式 { id: arg0, type: arg1 }
        const newObjArg = j.objectExpression([
            j.property('init', j.identifier('id'), args[0]),
            j.property('init', j.identifier('type'), args[1])
        ]);
        
        // 替换参数
        path.node.arguments = [newObjArg];
      }
    })
    .toSource(); // 4. 生成新代码
}

运行:

npx jscodeshift -t transformer.js src/**/*.js

瞬间,你完成了全项目几千个文件的 API 升级。这就是架构师的效率。


四、 总结:架构师的“法治”思维

这一节我们从“写代码”进阶到了“管代码”。

  1. ESLint 是日常执勤的警察,通过 Linting(检查)和 Fixing(微修补)维持代码风格和架构边界。
  2. Codemod 是特种部队,通过 AST Transformation 解决大规模的技术债务和破坏性升级。

架构师不应该仅仅是那个“写文档告诉大家怎么做”的人,而应该是那个“提供工具让大家没法做错”的人。

Next Step: 我们已经把 AST 在工具链(Babel, ESLint)中的应用学完了。 最后,我们要看看 AST 是如何在现代前端框架中发挥作用的。Vue 的 <template> 是怎么变成 JS 的?React 的 JSX 到底是怎么回事? 下一节,我们将揭秘**《第四篇:应用——框架的魔法:Vue 模板编译与 React JSX 转换背后的编译艺术》**。

Vue-异步更新机制与 nextTick 的底层执行逻辑

2026年2月2日 10:09

前言

在 Vue 开发中,你是否遇到过“修改了数据但立即获取 DOM 元素,拿到的却是旧值”的情况?这背后涉及 Vue 的异步更新策略。理解 nextTick,就是理解 Vue 如何与浏览器的事件循环(Event Loop)“握手”。

一、 为什么需要 nextTick?

1. 概念定义

nextTick 的核心作用是:在修改数据之后立即使用这个方法,获取更新后的 DOM。因为在vue里面当监听到我们的数据发送变化时,vue会开启一个异步更新队列,视图需要等待队列里面的所有数据变化完成后,再进行统一的更新。

2. Vue 的异步更新策略

Vue 的响应式并不是数据一变,DOM 就立刻变。

  • 当数据发生变化时,Vue 会开启一个异步更新队列
  • 如果同一个 watcher 被多次触发,只会被推入队列一次(去重优化)。
  • 这种机制避免了在一次同步操作中,因为多次修改数据而导致的重复渲染,极大的提高了性能。

二、 核心原理:基于事件循环(Event Loop)

nextTick 的实现逻辑紧密依赖于 JavaScript 的执行机制。

1. 任务调度逻辑

  1. 数据变更:修改响应式数据,Vue 将 DOM 更新任务推入一个异步队列(微任务)。
  2. 注册回调:调用 nextTick(callback),Vue 将该回调推入一个专用的 callbacks 队列。
  3. 执行时机:Vue 优先尝试创建一个微任务(Microtask) ,通常使用 Promise.then。如果环境不支持,则降级为宏任务(如 setTimeout)。
  4. 顺序保证:Vue 内部通过代码执行顺序,确保 DOM 更新任务先于 nextTick 的回调任务 执行。

2. 宏任务与微任务的演进

  • 优先选择Promise.thenMutationObserver(微任务)。
  • 降级选择:如果上述不可用,则降级为宏任务 setImmediatesetTimeout(fn, 0)

三、 使用示例:

1. 在setup中操作 DOM

setup 阶段,组件尚未挂载,DOM 不存在。只有在onMounted中才会创建, 所以无法直接操作,需要通过nextTick()来完成。

<script setup lang="ts">
import { ref, nextTick, onMounted } from 'vue';

const message = ref<string>('初始内容');
const divRef = ref<HTMLElement | null>(null);

// 模拟 setup 阶段(相当于 Vue 2 的 created)
nextTick(() => {
  // 此时 DOM 可能已挂载(取决于具体执行时机),但在 setup 同步代码中无法直接访问
  console.log('setup 中的 nextTick 回调');
});
</script>

2. 数据更新后获取最新的视图信息

这是最常见的场景:例如根据动态内容计算容器高度。

<template>
  <div ref="listRef" class="list">
    <div v-for="item in list" :key="item">{{ item }}</div>
  </div>
  <button @click="addItem">新增条目</button>
</template>

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

const list = ref<string[]>(['Item 1', 'Item 2']);
const listRef = ref<HTMLElement | null>(null);

const addItem = async () => {
  list.value.push(`Item ${list.value.length + 1}`);
  
  // ❌ 此时获取的高度是更新前的
  console.log('更新前高度:', listRef.value?.offsetHeight);

  // ✅ 等待 DOM 更新
  await nextTick();

  // 此时可以获取到新增条目后的真实高度
  console.log('更新后高度:', listRef.value?.offsetHeight);
};
</script>

四、 总结:nextTick 的“避坑”锦囊

  • 同步逻辑 vs 异步逻辑:修改数据是同步的,但 DOM 变化是异步的。所有紧随数据修改后的 DOM 操作,都应该放进 nextTick

  • Promise 语法糖:在 Vue 3 中,nextTick 返回一个 Promise。你可以使用 await nextTick() 代替传统的 nextTick(() => { ... }),使代码更具可读性。

  • 性能注意:虽然 nextTick 很好用,但不要滥用。频繁的 DOM 查询依然会带来性能开销,能通过数据驱动(数据绑定)解决的问题,尽量不要手动操作 DOM。

市场监管总局:2025年累计帮扶中小企业超12万家

2026年2月2日 10:07
市场监管总局2025年组织开展“计量服务中小企业行”活动, 各地市场监管部门累计帮扶中小企业超过12万家,解决计量技术问题3.6万多个,针对中小企业计量需求与产业发展瓶颈,制定并实施扩大二级标准物质定级鉴定审批改革试点、缩短审批时限以及减免检定校准费用等一系列计量惠企政策措施。针对重点产业链和特色产业,组织开展精准计量帮扶,提供全链条计量支撑。(央视新闻)

React中的useDeferredValue与防抖和节流之间有什么不同?

2026年2月2日 10:05

react原文:

在上述的情景中,你可能会使用这两种常见的优化技术:

  • 防抖 是指在用户停止输入一段时间(例如一秒钟)之后再更新列表。
  • 节流 是指每隔一段时间(例如最多每秒一次)更新列表。

虽然这些技术在某些情况下是有用的,但 useDeferredValue 更适合优化渲染,因为它与 React 自身深度集成,并且能够适应用户的设备。

与防抖或节流不同,useDeferredValue 不需要选择任何固定延迟时间。如果用户的设备很快(比如性能强劲的笔记本电脑),延迟的重渲染几乎会立即发生并且不会被察觉。如果用户的设备较慢,那么列表会相应地“滞后”于输入,滞后的程度与设备的速度有关。

此外,与防抖或节流不同,useDeferredValue 执行的延迟重新渲染默认是可中断的。这意味着,如果 React 正在重新渲染一个大型列表,但用户进行了另一次键盘输入,React 会放弃该重新渲染,先处理键盘输入,然后再次开始在后台渲染。相比之下,防抖和节流仍会产生不顺畅的体验,因为它们是阻塞的:它们仅仅是将渲染阻塞键盘输入的时刻推迟了。

如果你要优化的工作不是在渲染期间发生的,那么防抖和节流仍然非常有用。例如,它们可以让你减少网络请求的次数。你也可以同时使用这些技术。

关于这句话的理解

相比之下,防抖和节流仍会产生不顺畅的体验,因为它们是阻塞的:它们仅仅是将渲染阻塞键盘输入的时刻推迟了。

这句话解释了 useDeferredValue 和传统防抖/节流的关键区别。我来详细解析:

核心区别:可中断 vs 不可中断

场景设定

假设用户快速输入“hello”,你有一个需要渲染大型列表的组件。


1. 防抖/节流的工作方式(有问题的)

// 防抖示例:延迟500ms后执行
const debouncedSearch = debounce((value) => {
  // 渲染大型列表
  renderLargeList(value);
}, 500);

时间线模拟:

时间 0ms: 用户输入 "h" → 启动500ms计时器
时间 100ms: 用户输入 "he" → 重置计时器
时间 200ms: 用户输入 "hel" → 重置计时器
...
时间 600ms: 用户输入 "hello" 完成
时间 1100ms: 500ms后,终于开始渲染"hello"的列表

问题

  • 在这 500ms的等待期,用户继续输入是顺畅的(因为没在渲染)
  • 1100ms时开始渲染大型列表,可能需要200ms
  • 如果用户在 1150ms时又想输入,会卡住!因为渲染正在进行,无法响应键盘

这就是 “将渲染阻塞键盘输入的时刻推迟了”

  • 阻塞没有消失,只是推迟到防抖延迟结束后
  • 当阻塞发生时,整个UI线程都会被占用

2. useDeferredValue 的工作方式(可中断的)

function SearchComponent() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);
  
  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />
      {/* 这个大型列表会延迟渲染 */}
      <LargeList query={deferredQuery} />
    </>
  );
}

时间线模拟:

时间 0ms: 用户输入 "h" → 立即更新输入框显示"h"
                     → 开始后台渲染"h"的列表
时间 50ms: 用户输入 "he" → React **中断**正在渲染的"h"的列表
                     → 立即更新输入框显示"he"
                     → 开始后台渲染"he"的列表
时间 100ms: 用户输入 "hel" → 再次中断"he"的渲染
                     → 更新输入框 → 开始渲染"hel"
...
时间 200ms: 用户停止输入,最终渲染"hello"的完整列表

优势

  • 用户输入始终立即响应(输入框内容实时更新)
  • 大型渲染可以被中断,让位给更重要的用户交互
  • 没有固定的“阻塞窗口期”

可视化对比

防抖/节流:
[用户输入][等待延迟][无法中断的阻塞渲染][卡顿风险]

useDeferredValue:
[用户输入][立即显示][可中断的后台渲染]
     ↑         随时可插入新输入
     └────── 中断当前渲染 ──┘

关键区别表格

特性 防抖/节流 useDeferredValue
用户输入响应 延迟期间响应快,但渲染时阻塞 始终立即响应
渲染过程 不可中断,独占线程 可中断,时间切片
阻塞时机 推迟到延迟结束后 分散成可中断的小块
实现层面 JavaScript 定时器控制 React 调度器控制
用户体验 可能突然卡顿 持续流畅

这就是为什么说防抖/节流只是推迟阻塞,而 useDeferredValue避免长时间阻塞

总结

简单来说就是用防抖的话,等到输入结束后长列表开始渲染期间再次去输入内容还是会有交互卡顿的情况存在,防抖只是延迟了这种情况而已。而useDefferedValue却能一直确保用户输入时的交互流畅进行。小注:除了最后的总结,文章的内容是AI生成的,为了理解记录一下~~

闭包:从「能跑」到「可控」——用执行上下文把它讲透,再把泄漏风险压住

作者 swipe
2026年2月2日 09:58

引言:为什么团队里闭包总是「会用但讲不清」

闭包几乎是每个前端都“用过”的能力:回调、事件处理、节流防抖、柯里化、状态缓存……到处都是它。但一到排查线上内存飙升、解释“为什么变量没被回收”、或者评审里讨论“这个写法会不会泄漏”,就容易陷入两种极端:

  • 把闭包当玄学:只记住“函数套函数 + 引用外部变量”,但不知道底层到底发生了什么。
  • 把闭包当洪水猛兽:遇到闭包就怕泄漏,动不动“全局变量/单例就是坏”。

这篇文章的目标很明确:用“执行上下文 + AO/GO + 可达性”把闭包拆开讲清楚,再落到工程实践:哪些写法会导致内存常驻、怎么定位、怎么释放、怎么验证。很多内容需要配合内存图理解(图片必须保留),建议边看边对照图。


目录

    1. 脉络:为什么闭包在 JS 里如此关键
    1. 闭包到底是什么:定义、自由变量与词法绑定
    1. 从调用栈看闭包:高阶函数的执行过程(GO / AO / FEC)
    1. 闭包形成的关键:[[scope]] / parentScope 为什么能“跨出上下文”
    1. 内存视角:普通函数 vs 闭包函数,变量为什么回收/不回收
    1. 闭包与内存泄漏:什么时候是“正常驻留”,什么时候是“泄漏”
    1. 如何释放:最小化作用域、解除引用、弱引用思路
    1. 性能与排查:用浏览器工具定位闭包导致的内存/耗时
    1. 引擎优化:闭包里“没用到的变量”会怎样(V8 优化)
    1. 进阶边界:多个闭包实例彼此独立;为什么 null 能断引用而 undefined 不行
    1. 实战建议:团队落地清单 & 指标验证
    1. 总结:关键结论 + 下一步建议

f 脉络探索:闭包为什么值得被「认真对待」

闭包是 JavaScript 中一个非常容易让人迷惑的知识点:它既是语言表达力的源泉,也可能成为内存与可维护性的风险点。很多经典资料对它评价极高——因为它背后牵扯的是一整套“词法作用域 + 执行上下文 + 垃圾回收”的体系。

** 图7-1 《你不知道的JavaScript》上卷中对闭包的评语**

把这张图放在开头的意义是:闭包不是一个“语法点”,而是理解 JS 运行机制的入口。当你能把闭包讲清楚,很多“JS 为什么这样设计”的问题都会连起来。

本章小结(可迁移的经验)

  • 闭包不是“函数套函数”的表象,而是 词法作用域如何在运行时被保留
  • 闭包相关的争论,往往不是“对错”,而是 讨论口径(广义/狭义)不同
  • 真正工程风险来自:闭包让某些对象变成“长期可达” ,从而影响 GC。

一、闭包到底是什么:定义、自由变量与词法绑定

1.1 闭包的概念定义:把“感觉”变成“可证明”

闭包并不是 JavaScript 独有。计算机科学中,闭包(Closure)又称词法闭包(Lexical Closure),是一种在支持头等函数的语言中实现词法绑定的技术:闭包在实现上可以理解为“函数 + 关联环境(自由变量的绑定) ”的组合结构。

在 JavaScript 语境下,可以用更工程化的表达:

闭包 = 一个函数 + 该函数在定义时可访问的外层作用域引用(自由变量所在的词法环境)。

这里最关键的是两个词:

  • 自由变量:跨出自己作用域、来自外层作用域的变量(“不是我家里的变量,但我能用”)。
  • 词法解析阶段确定:函数“能访问哪些变量”,在**代码写出来那一刻(定义时)**就决定了,而不是调用时随机决定。

一个常用的工作记忆法: “函数定义时就把外层环境‘锁’住了” 。后续你把函数拿到哪里调用,它都沿着当初锁定的链去找变量。

1.2 广义 vs 狭义:团队沟通时要先统一口径

社区对“什么算闭包”常见两种口径:

  • 广义:JS 里“函数”几乎都带着词法作用域信息,因此都可以叫闭包。
  • 狭义(更严谨) :只有当函数实际捕获并使用外层变量,才讨论“闭包带来的效果”(比如变量驻留)。

下面这段代码就体现了差异点:它能访问 name,但是否把它视为“闭包”(严格意义)看你站哪种口径:

// 可以访问 name:test 算闭包(广义)
// 有访问到 name:test 算闭包(更严谨,讨论“闭包效果”更有意义)
var name = "放寒假了";
function test() {
  console.log(name);
}
test();

本章小结(落地清单)

  • 团队讨论闭包前,先明确口径:讨论“闭包机制”还是“闭包效果(变量驻留)”
  • 记住闭包核心:定义时锁定词法环境,不是调用时决定。
  • 所谓“自由变量”,本质就是:跨作用域访问的变量

二、从调用栈看闭包:高阶函数的执行过程(GO / AO / FEC)

闭包很容易被讲成“概念”,但工程上要做到“可控”,一定要落到执行过程:调用栈如何创建执行上下文?AO/GO 什么时候创建?引用链怎样形成可达性?

2.1 先看一个最小高阶函数:返回函数指针发生了什么

function foo() {
  // bar 预解析,前面有讲过
  function bar() {
    console.log("小吴");
  }
  return bar;
}

var fn = foo();

fn();
// 小吴

关键点:调用函数会创建执行上下文(Execution Context) 。执行上下文创建前,会先创建对应的 AO(Activation Object) :用于存放形参、局部变量、函数声明等。

2.2 内存图:从“栈/堆”视角理解 fn = foo() 的意义

建议把这一段当成闭包全篇的“底座”,后面所有闭包/泄漏/回收都在重复这个结构。

** 图7-2 高阶函数执行前**

** 图7-3 foo函数调用阶段内存图**

** 图7-4 foo函数中的bar函数调用阶段内存图**

把图 7-2 ~ 7-4 用一句话串起来:

  • foo() 调用时创建 foo 的执行上下文与 AO;
  • bar 函数对象存在于堆上,foo 的 AO 里保存了它的引用;
  • return bar 让全局 fn 指向 bar 的函数对象地址;
  • 后续 fn() 本质是在执行 bar()

你可以把它想象成:return 返回的不是函数体,而是“函数对象的指针”fn 接住这个指针后,就和 bar 的生命周期绑定了。

本章小结(落地清单)

  • AO 是“函数即将执行前”创建的,不是定义函数时就创建(避免无谓开销)。
  • return function 的本质是:返回堆上函数对象的引用
  • 后续 fn() 执行的是“那块函数对象”,而不是重新生成一份。

三、闭包形成的关键:为什么 [[scope]] / parentScope 能让变量跨出上下文

上一章只是“返回了函数”。闭包真正“神奇”的点在于:外层函数执行完了,内层函数还能访问外层变量

来看这个例子

function foo() {
  var name = "why";
  function bar() {
    console.log("小吴", name);
  }
  return bar;
}

var fn = foo();

fn();
// 小吴 why

如果只背概念会说:“bar 引用了 foo 的变量 name,所以形成闭包”。但工程上更重要的是 “它凭什么引用得到?” ——答案是:函数对象内部会保存定义时的外层作用域引用(常被描述为 [[scope]] / parentScope

3.1 发生了什么:把“访问外层变量”写成一条可执行的查找链

bar() 执行时要查 name

  1. 先查自己的 VO/AO(bar 的活动对象)——没有。
  2. 沿着函数对象记录的 parentScope(也就是 foo 的 AO)继续查——找到了。
  3. 输出 why

也就是说,闭包并不是“让变量不销毁”的魔法,而是:

bar 的函数对象握住了 foo 的 AO 引用,使得这块 AO 对 GC 来说一直是“可达的”。

** 图7-5 bar函数中的name形成闭包内存图**

3.2 常见误区:闭包 ≠ 执行上下文永远不销毁

一个容易混淆的点:执行上下文(FEC)会销毁,但 AO 是否可回收 取决于有没有被外界引用链保持可达。

  • foo() 的执行上下文从调用栈弹出,这是必然的;
  • foo 的 AO 如果被 barparentScope 引用着,并且 bar 又被 fn 引用着,那么它就仍然可达,无法回收;
  • “闭包效果”来自这条引用链,而不是来自“执行上下文不销毁”。

本章小结(落地清单)

  • 闭包的底层抓手是:函数对象持有 parentScope(词法环境引用)
  • “变量没被回收”不是因为执行上下文不弹栈,而是因为 对象仍可达
  • 解释闭包时,把“查找链”讲出来,团队沟通会更一致。

四、内存视角:普通函数 vs 闭包函数,变量为什么回收/不回收

4.1 普通函数:执行完就“自由变量不自由”了

function foo() {
  var name = "xiaowu";
  var age = 20;
}

function test() {
  console.log("test");
}

foo();
test();

** 图7-6 foo与test函数执行前的初始化表现**

这张图强调:全局 GO 中保存的是函数对象引用;函数对象里保存了 parentScope 指向 GO;调用时创建执行上下文与 AO。

** 图7-7 foo函数和test函数的内存图执行过程**

** 图7-8 foo的执行上下文销毁前后对比**

关键结论在图 7-8:foo 执行结束,AO 里 name/age 没有被任何外部引用链持有,于是变为不可达,被回收。这就是“自由变量没能真的自由”。

4.2 闭包函数:AO 被外部引用链锁住,变量驻留

function foo() {
  var name = "xiaowu";
  var age = 20;

  function bar() {
    // 引用了外层变量,形成闭包
    console.log("这是我的名字", name);
    console.log("这是我的年龄", age);
  }

  return bar;
}

var fn = foo();
fn();
// 这是我的名字 xiaowu
// 这是我的年龄 20

** 图7-9 闭包执行前内存图**

** 图7-10 foo函数执行内存图**

** 图7-11 bar的函数执行上下文**

** 图7-12 bar脱离捕捉时的上下文,自由变量依旧存在**

图 7-12 是闭包“可解释”的关键画面:

  • fn -> bar函数对象(全局根对象可达)
  • bar函数对象 -> parentScope -> foo 的 AO
  • 因为这条链存在,所以 foo AO 仍可达,name/age 仍可达

本章小结(落地清单)

  • 普通函数执行完:AO 通常不可达 → 回收。
  • 闭包能驻留变量:本质是 AO 被函数对象的 parentScope 引用,并且函数对象又被根对象引用
  • 是否回收,归根到底看:从根对象出发是否可达(标记清除的核心判断)。

五、闭包与内存泄漏:什么时候是“正常驻留”,什么时候是“泄漏”

闭包会让变量驻留,但驻留 ≠ 泄漏。工程上判断泄漏的标准非常朴素:

本该释放、却因为不必要的引用链而长期可达的内存,占用不断增长或长时间不下降。

在闭包语境里,常见泄漏模式就是:返回的函数被长期保存(全局数组/缓存/事件回调/定时器),导致其捕获的外层 AO 一直可达


六、如何释放:最小化闭包作用域、解除引用、弱引用思路

6.1 解决策略三件套

  1. 最小化闭包作用域:只捕获必要数据(不要把整坨对象/大数组/DOM 节点顺手闭包进去)。
  2. 解除引用:用完就断开引用链,让对象从根不可达。
  3. 弱引用(WeakMap/WeakSet) :对“缓存类”场景非常有效(不会阻止 GC)。

6.2 “解除引用”的标准写法:把 fn 指向 null

// 内存泄漏解决方法
function foo() {
  var name = "xiaowu";
  var age = 20;

  function test() {
    console.log("这是我的名字", name);
    console.log("这是我的年龄", age);
  }

  return test;
}

var fn = foo();
fn();

// 解除引用:断开 root -> fn -> 函数对象 -> AO 的链
fn = null; // 注意:置 null 不会立刻回收,会在后续 GC 周期中回收

** 图7-13 fn指向bar的指针**

** 图7-14 fn指向bar的指针置为null**

图 7-14 的“孤岛”是你需要在脑子里形成的肌肉记忆:
只要根对象到不了这块内存,它迟早会被回收(标记清除的可达性判断)。

本章小结(落地清单)

  • 释放闭包的核心动作:断开根对象到函数对象的引用链(常见是置 null / 移除监听 / 清理数组缓存)。
  • 设计闭包时先问一句: “我真的需要捕获整个对象/大数组/DOM 吗?”
  • 缓存场景优先考虑:WeakMap/WeakSet(避免“缓存越用越大”)。

七、闭包泄漏案例:大对象被闭包捕获,内存与耗时如何爆炸

为了把问题讲“刺痛”,用一个极端但很真实的例子:闭包捕获一个大数组。

function createFnArray() {
  // 创建一个长度为1024*1024的数组,往里面每个位置填充1.观察占了多少的内存空间(int类型,整数1占4个字节byte)
  // 4byte*1024=4kb,再*1024为4mb,占据的空间是4M × 100 + 其他的内存 = 400M+
  // 在js里面不管是整数类型还是浮点数类型,看起来都是数字类型,这个时候占据的都是8字节,但是js引擎为了提高空间的利用率,对很多小的数字是用不到8个字节(byte)的,8字节 = 2的64次方,所以8字节是很大的,现在的js引擎大多数都会进行优化,对小的数字类型,在V8中称为Smi,小数字 2的32次方
  var arr = new Array(1024 * 1024).fill(1);

  return function () {
    console.log(arr.length);
  };
}

var arrayFn = createFnArray();

** 图7-15 闭包泄露案例**

如果你把 createFnArray() 创建出来的函数持续保存(比如 push 进数组),引用链会不断叠加:

** 图7-16 引用叠加,闭包无法释放**

7.1 用性能工具看“泄漏”长什么样

在浏览器 Performance 面板勾选 Memory,刷新/执行后,你会看到脚本耗时显著升高:

** 图7-17 闭包的性能检测**

7.2 释放后的对比:不一定立刻回收,但趋势会回来

function createFnArray() {
  var arr = new Array(1024 * 1024).fill(1);

  return function () {
    console.log(arr.length);
  };
}

var arrayFns = [];
for (var i = 0; i < 100; i++) {
  // createFnArray() // 不接收就会很快变成不可达
  arrayFns.push(createFnArray());
}

setTimeout(() => {
  arrayFns = null; // 关键:断开引用链
}, 2000);

** 图7-18 性能提升效果**

你还能通过调用树看到耗时主要来源于闭包相关逻辑:

** 图7-19 闭包耗时来源**

本章小结(落地清单)

  • 闭包泄漏常见触发器:闭包捕获大对象 + 长期保存闭包引用(数组/缓存/事件/定时器)。
  • Performance 勾选 Memory:关注 脚本耗时 + 内存曲线是否持续上升
  • “置 null”不保证立刻回收,但能保证:后续 GC 周期具备回收条件

八、引擎优化:闭包里“没用到的变量”会怎样(V8)

一个很实用的问题:闭包让外层 AO 不回收,那 AO 里没用到的属性会不会也一直占着?

例子:闭包只用 name,没有用 age

function foo() {
  var name = "why";
  var age = 18;

  function bar() {
    debugger;
    console.log(name);
  }

  return bar;
}

var fn = foo();
fn();

** 图7-20 V8引擎优化效果(未使用变量被销毁)**

继续在 debugger 暂停时验证:name 能访问,age 可能因为未使用被优化掉:

** 图7-21 debugger检测未使用的age变量是否真被回收**

这点对工程实践的启示非常直接:

  • 规范上你可以认为“闭包会保留整个 AO”;
  • 但引擎实现会做逃逸分析/变量提升优化等,减少无用变量占用
  • 不要依赖这种优化写代码:它是实现细节,不是稳定契约(尤其跨引擎/跨版本)。

本章小结(落地清单)

  • V8 可能回收闭包外层 AO 中“未被使用的变量”(实现优化)。
  • 工程判断别靠“引擎可能帮我优化”,仍以 引用链是否可达 为主。
  • 评审时更关注:闭包是否捕获了不必要的大对象/DOM/业务上下文

九、进阶边界:多个闭包实例彼此独立;为什么 null 能断引用而 undefined 不行

9.1 多个闭包实例:互不影响,释放也只释放自己的那份

同一个 foo() 调两次,得到的是两套独立的 AO 与函数对象:

function foo() {
  var name = "小吴";
  var age = 18;

  function bar() {
    console.log(name);
    console.log(age);
  }

  return bar;
}

var fn = foo();
fn();

var baz = foo();

fn = null; // 只会释放 fn 对应的那一套引用链
baz();

这条结论在工程里特别重要:你清理了一个引用,不代表全局都释放了。如果你把闭包存进多个地方(例如多个数组、多个事件回调、多个缓存),就需要逐个断链。

9.2 为什么 null 可以解除引用,而 undefined 不行?

这里有一个值得思考的问题:为什么 null 可以解除引用,而 undefined 不行?

从“引用链”的角度看:

  • null 是一个明确的“空值”,把变量指向空处,等价于 把这条引用边砍掉
  • undefined 更多表达“未初始化/缺省值”,它依然是一个值;更关键的是,在很多语义下它并不被用作“主动断链”的表达(团队代码规范也通常不推荐用 undefined 表达释放)。

工程建议:释放引用请用 null(语义清晰、团队共识强、便于 code review 与静态检查)。

本章小结(落地清单)

  • 每次调用外层函数,都会创建一套新的 AO/函数对象:闭包实例彼此独立。
  • 释放引用只影响对应那条链:你清理一个,不会自动清理所有
  • 断链用 null:表达“我主动释放”,比 undefined 更清晰。

十、实战建议:把“闭包可控”落到团队工程规范里

下面给一份可以直接放进团队“代码评审 checklist / 性能排查 SOP”的清单。

10.1 评审 Checklist(闭包相关)

  • 捕获内容最小化:闭包里只引用必要字段,避免把整个 props/state/context/大对象 捕获进去。
  • 避免捕获 DOM 节点:尤其是长生命周期的闭包(事件回调/单例缓存)捕获 DOM,会让节点难以回收。
  • 长生命周期容器要可清理:全局数组、Map 缓存、事件总线、定时器回调——都要有对应的清理路径。
  • 组件/页面卸载必须断链:移除事件监听、取消订阅、清理定时器、清空缓存引用(= null)。
  • 缓存优先 WeakMap:key 是对象的缓存(如 DOM 节点、组件实例)优先 WeakMap,减少“缓存常驻”。

10.2 排查 SOP(内存/性能)

  1. Performance 勾选 Memory:复现操作,观察内存曲线是否持续上升(不回落)。
  2. 录制并看调用树:定位高耗时函数是否来自闭包创建/大对象捕获。
  3. 缩小复现:把闭包引用容器(数组/缓存)逐步置 null,观察趋势变化(不是立刻回收,但趋势会变)。
  4. 检查引用链:谁在持有闭包?(全局变量、单例模块、事件总线、定时器、DOM 监听器最常见)

10.3 指标验证(建议团队共用)

  • 内存指标:关键页面操作 5 分钟后,JS Heap 是否可稳定回落到阈值区间
  • 性能指标:关键交互的 Long Task 次数/总耗时是否下降
  • 回归验证:增加“卸载/切页/重复进入”压测脚本,验证引用链不会累积

总结:关键结论 + 团队落地建议

关键结论(背下来就够用)

  • 闭包的本质是:函数对象持有定义时的外层作用域引用(parentScope/词法环境) ,从而让外层 AO 继续可达。
  • 是否回收不看“函数执行没执行完”,只看 从根对象出发是否可达(标记清除的核心)。
  • 闭包造成的风险不是“用了闭包”,而是:闭包捕获了不该长期驻留的对象,并且闭包引用被长期持有
  • 释放闭包的关键动作是:断开引用链(置 null、移除监听、清空容器、取消订阅等)。
  • 引擎可能优化未使用变量(如 V8),但工程上不要依赖实现细节,仍以引用链分析为准。

下一步建议(怎么在团队里真正落地)

  1. 把“闭包评审 checklist”加入 PR 模板:涉及事件、缓存、定时器、订阅时必须勾选清理项。
  2. 建立 1~2 个“典型泄漏 demo”用于 onboarding:让新人用 Performance/Memory 亲手看见“可达性”是什么。
  3. 在关键业务页引入定期压测(重复进入/退出/滚动/筛选等),用指标验证“内存可回落”。
  4. 对缓存策略做统一约束:对象 key 的缓存优先 WeakMap;全局数组缓存必须提供清理 API。

只要团队能把闭包从“语法点”升级成“引用链与可达性”的共识,闭包就会从“玄学”变成“可控工具”。

花了两年用遍了 React 所有状态管理库,我选出了最现代化的 Signal 方案

作者 寅时码
2026年2月2日 09:58

花了两年用遍了 React 所有状态管理库,我选出了最现代化的 Signal 方案

我知道「React 孝子」很多,先别急着骂,看完再说

当你还在 useState 的闭包陷阱里跟 React 斗智斗勇,在 useEffect 依赖数组里当人肉编译器,在 Zustand 的 selector 里写到怀疑人生 —— 哥们,该换个活法了。

Signal,来自 Preact,本质就是 Vue 那套响应式的 React 版:对象引用读写,依赖自动追踪,不用你手动喂。接入 @preact/signals-react 之后你就会发现,原来「现代化」三个字可以这么写,而不是靠 React 那套「设计哲学」自嗨。


我知道「React 孝子」很多,每次讨论时他们总搬出两套话:

  1. 「React 手动挡、Vue 自动挡,老司机都是手动挡。」
    纯纯的逻辑谬误。开车的手动/自动和写代码的「要不要亲手管依赖」根本不是同一回事;把「控制欲」包装成「专业」是偷换概念。这比喻的荒谬程度,不亚于班主任那句「一个人浪费一分钟,全班四十个人就浪费四十分钟」 —— 时间不能那样线性叠给全班,框架优劣也不能用「手动/自动」一个轴判死刑。

  2. 「既然你这么讨厌 React,为什么还要用?」
    因为出现的早、生态繁荣,所以你必然要接触到。任何第三方前端库,第一个兼容目标都是 React; AI First 时代更狠

这些 AI 工具一打开,全是 React。用 React 不等于认同它每一处设计 —— 历史包袱和网络效应摆在那儿,换框架成本极高,而 AI 生成代码几乎全是 React,正反馈循环只会越滚越大。所以「讨厌」和「在用」可以同时成立,不矛盾。


不可否认的是,JSX 是划时代的

React 真正贡献的是:组件化、声明式 UI、单向数据流成了行业共识,后面 Vue、Svelte 的模板或语法糖,都是在「React 确立的范式」上做改进。

我写这篇不是为了当传教士,也不想一味骂街 —— 我只想安安静静分享自己的心得,讲述如何解决这些问题,顺便帮到被闭包和依赖数组折磨的人。

批评 useState 的闭包和 useEffect 的依赖数组,不等于否定整个 React;而是说这一块设计得反人类,值得用 Signal 之类的方式补上。


而且关于 React、Vue 我是有发言权的,两边源码都翻过一些,React 的很多坑我都自己填过

不是晒仓库,而是说明:我既在 React 里干活,又亲手绕过它的坑。所以下面聊 Signal 和状态管理,是在 React 生态里怎么活得更像人的实操结论,不是跟风捧新玩意儿,也不是键盘党嘴炮。


一、闭包陷阱:React 的「设计哲学」有多可笑

useState 有个祖传问题:setState 是异步的,你刚 set 完,下一秒换个函数读,拿到的还是旧值。逻辑一拆分,后面的函数永远活在「上一个渲染周期」的梦里。

// 问题:fn2 拿不到最新 count
const [count, setCount] = useState(0)

const fn1 = () => {
  setCount(count + 1)
  fn2() // count 仍是旧值,惊不惊喜?
}
const fn2 = () => {
  console.log(count) // 0,意不意外?
}

const handleXx = () => {
  fn1()
  fn2() // 这辈子都拿不到最新值
}

React 官方会说:这是「调度」「可预测性」「避免半成品 UI」 —— 翻译成人话就是:我让你拿不到你就是拿不到,你得按我的规矩来。一旦逻辑拆分、跨模块复用,你就得跟闭包斗智斗勇,写一堆 useCallbackuseRef 来擦屁股。这叫设计哲学?这叫甩锅给开发者。

Signal 不跟你玩这套。状态放在对象里,.value 读就是当前值,写就是立刻生效。没有快照,没有「下一次渲染才更新」,读到的永远是实时的。

import { signal } from '@preact/signals-react'

const count = signal(0)

const fn1 = () => {
  count.value += 1
  fn2() // 此时 count.value 已经是 1 了
}
const fn2 = () => {
  console.log(count.value) // 1,终于像个正常人该有的行为
}

暂时还得用 useState?行,用 useGetState 救个急,setCount.getLatest() 直接拿最新值,不用再传什么回调了。

// 救急方案:useGetState
import { useGetState } from 'hooks'

const [count, setCount] = useGetState(0)
const fn2 = () => {
  console.log(setCount.getLatest()) // 1
}

源码:github.com/beixiyo/rea…


二、useEffect 依赖数组:人肉编译器,你当定了

useEffect 的依赖数组,堪称 React 开发者的噩梦:漏写一个,闭包拿旧值;

多写一个,effect 跑成陀螺。复杂对象还得自己 useMemo 包一层,不然每次都是「新引用」,依赖数组形同虚设。

Signal 的 effectuseSignalEffect 直接自动追踪你在回调里读了哪些 signal,变了才跑。跟 Vue 的 watchEffect 一样,该有的智商它都有。

import { effect, signal } from '@preact/signals-react'

const count = signal(0)
const name = signal('Jane')

effect(() => {
  console.log(count.value, name.value)
  return () => console.log('cleanup')
})

不用写依赖数组,不用纠结「这个到底该不该塞进 deps」,不用当人肉依赖分析器。省下来的脑子,干点别的不好吗?


三、Signal API 简洁,没有废话

Signal 的 API 就四个:

  • signal(initial):建状态
  • computed(fn):派生
  • effect(fn):副作用
  • batch(fn):批量写,effect 只跑一次

没有 Action、Reducer、Slice,没有 Store 配置,没有中间件链。想用就写,写完就算。相比之下,下面那些「当红」库,个个都是模板代码生产器。

1. 渲染优化:直接传 signal 到 JSX,跳过组件重渲染

这是 Signal 最香的用法之一。count.value 会建立订阅,signal 一变组件就重渲染;但直接把 signal 丢进 JSX,Preact 会绑定到 DOM 文本节点,更新时只改 DOM,不触发组件重渲染。

const countOptimized = signal(0)

// ❌ 未优化:读 .value 会订阅,每次变化都重渲染
const UnoptimizedDisplay = memo(() => {
  useSignals()
  return <strong>{ countOptimized.value }</strong>  // 变色 = 重渲染
})

// ✅ 优化:直接传 signal,跳过 VDOM,只更新 DOM 文本
const OptimizedDisplay = memo(() => {
  useSignals()
  return (
    <strong><>{ countOptimized }</></strong>  // 不变色 = 无重渲染
  )
})

PixPin_2026-02-02_10-03-36.webp

点击 +1 时,未优化侧背景色会变(重渲染),优化侧可能不变色(直接改 DOM)。源码见 github.com/beixiyo/rea…

2. computed:派生状态,自动缓存

import { signal, computed } from '@preact/signals-react'

const count = signal(0)
const doubled = computed(() => count.value * 2)

// doubled.value 随 count 变,依赖自动追踪,不用写 useMemo 依赖数组

3. signals 原生 effect(推荐、最省脑)

import { signal, effect } from '@preact/signals-react'

export const count = signal(0)
export const doubled = signal(0)

// ✅ 自动依赖收集,不写依赖数组
effect(() => {
  doubled.value = count.value * 2
})

特点一句话说明:

  • 依赖是谁,运行时自动追踪
  • 读了 count.value,就只依赖 count
  • 重构安全,删代码=删依赖

4. Hook 版:useSignalEffect(组件内用)

适合必须写在组件里的副作用(比如依赖 props / 生命周期)。

import { signal, useSignalEffect } from '@preact/signals-react'

const count = signal(0)

export function Counter() {
  // ✅ 和 effect 一样:不用依赖数组
  useSignalEffect(() => {
    console.log('count changed:', count.value)
  })

  return (
    <button onClick={() => count.value++}>
      +1
    </button>
  )
}

你可以把它理解为:

useEffect + 自动依赖收集 + 无 deps

5. 对照:React useEffect 等价写法(反例)

useEffect(() => {
  console.log('count changed:', count)
}, [count]) // ❌ 人肉维护依赖

问题不在“能不能用”,而在:

  • 依赖要人想
  • 重构容易漏
  • 逻辑一复杂就开始糊 deps

在线体验

这是我部署的 Signal 示例 Demo,里面涵盖了几乎所有 API 用法,我就不啰嗦了,需要可以在线体验。

image.png

如果背景色变化代表重新渲染了


四、其他状态管理库:一个比一个离谱

1. Zustand:Selector 写到手酸,中间件叠成屎山

github.com/pmndrs/zust…

Zustand 本身不算复杂,但要按需订阅避免多余渲染?对不起,每个字段自己写 selector 去吧:

const useCount = () => useStore(state => state.count)
const useName = () => useStore(state => state.name)
// 字段一多,selector 写到腱鞘炎

更离谱的是中间件。persist、devtools、immer 一层套一层,套完就是这坨:

export const useCounterStore = create<typeof initState>()(immer(
  devtools(
    persist(
      () => initState,
      {
        name: 'counter',
        storage: createJSONStorage(() => sessionStorage),
        partialize: state => Object.fromEntries(
          Object.entries(state).filter(([key]) => !key.startsWith('user'))
        ),
      }
    ),
    { enabled: true }
  )
))

你觉得还能看?那是因为我特意格式化过了。真实项目里中间件一多,括号套括号,缩进套缩进,可读性直接归零。

这种层层嵌套的写法,纯纯一坨。你能确保每个同事代码都像我一样写得这么「讲究」?不能的话,这就是定时炸弹。


2. Jotai:原子化挺好,但 useAtom 要写吐了

github.com/pmndrs/jota…

Jotai 的原子化思路我认可,细粒度订阅、按需更新,没问题。但原版用法有个致命问题:每个 atom 都得单独 useAtom / useAtomValue,组件顶上一排 hook,字段一多直接爆炸。

const [count, setCount] = useAtom(countAtom)
const [name, setName] = useAtom(nameAtom)
const [age, setAge] = useAtom(ageAtom)
// 再来十个字段?继续往上叠呗

而且为了性能你得保证原子性,基础属性都得拆成独立 atom,每个组件每个属性都得来一遍 useAtom,繁琐到令人发指。

解决办法

我自己封装了 jotaiTool github.com/beixiyo/rea…

通过 createUseAtoms 传入 atom 对象,自动生成 useAtomsgetAtomsuseResetcreateReset 等,按需订阅、类型安全、组件外也能读写。算是给 Jotai 提供了点语法糖。

实现原理是通过 Proxy 返回,并且确保细粒度订阅和类型安全,支持 Reset 等高级特性。所有功能均以测试

我部属到了 CloudFlare,在线体验到 react-tool-70q.pages.dev/jotaiTest

如果背景色变化代表重新渲染了

image.png

示例:定义 atom 对象后调用 createUseAtoms,在组件里用 useAtoms() 拿到一个代理对象,直接读属性、写属性或调 setXxx,下划线开头的 key 会被自动过滤。

image.png

更多用法(含按需 selector、useReset、createReset)见仓库内 github.com/beixiyo/rea…

即便如此,你还是得理解 atom、selector、store 这一堆概念。Signal 呢?一个对象,.value 读写,完事。


3. Recoil:Jotai 的低配版,还要自己写 key

recoiljs.org/docs/introd…

和 Jotai 思路差不多,但每个 atom 都得手写唯一 key,既啰嗦又容易冲突。Recoil 还要你自己想字符串。太蠢了,不说了。


4. Valtio:曾经的最爱,但是有些问题无法解决

valtio.dev/

Valtio 用起来是真的爽,proxy 一包,改属性自动更新,Vue 那味。但有两个硬伤直接劝退:

  1. snap 返回 readonly,类型别扭得要死,和「改完直接用」的习惯完全不搭,类型安全天天跟你打架。
  2. input 组件有 bug,必须开 sync 模式才能正常用。底层和 React 的批量更新八字不合,这都能出问题,我也是服了。

5. Redux:沉浸式屎山,LSP 都救不了

redux.js.org/

Redux 的「单向数据流」「可预测」「规范化」 —— 翻译过来就是:写一堆 Action、Reducer、Slice、Middleware,模板代码堆成山。实现成本高到离谱,还衍生出了 Redux Toolkit 这个「简化版」屎山。懂的都懂。

最离谱的是找代码。你想找「用户列表」的数据定义在哪?Ctrl+点击、F12 跳转、LSP 智能导航 —— 通通没用。你只能看到一堆 Action、Reducer、Slice,真正的数据定义藏得跟谍战片一样。写这个库的人是不是不知道什么叫 LSP? 找代码纯靠全局搜索是吧?什么年代了,开发体验还停留在文本搜索时代,真是没救了。


五、React:2026 年了,还缺这缺那

React 19.2 终于上了 Keep-Alive,这么多年来难得干了件人事。但问题是:2025 年才上。这么基础的能力,社区自己 hack 了多少年?现在才官方支持,早干嘛去了。

目前 React 生态还缺啥:

  1. 官方好用的状态管理:Signal 这种「对象引用 + 自动依赖」才是现代前端的该有的样子。useState 的闭包陷阱、useEffect 的依赖数组,早该被扫进历史垃圾堆了。别让开发者再写屎山了。

  2. 官方 Router:第三方路由全都没 Keep-Alive。复杂应用只能自己造轮子,恶心到家了。所以我只能自己写一个 github.com/beixiyo/rea…

    内置页面缓存(Keep-Alive),LRU 策略 + include/exclude 白名单,再也不用切个 Tab 回来表单全丢。顺带还有全局守卫 beforeEach/afterEach、Vue 风格中间件、全局 navigate/replace/back,API 简洁无废话。

代码示例:github.com/beixiyo/rea…

import { lazy } from 'react'
import { RouterProvider, createBrowserRouter } from '@jl-org/react-router'

const router = createBrowserRouter({
  routes: [
    { path: '/', component: lazy(() => import('./views/home')) },
    {
      path: '/dashboard',
      component: lazy(() => import('./views/dashboard')),
      meta: { title: 'Dashboard', requiresAuth: true },
    },
    { path: '/list', component: lazy(() => import('./views/list')) },
  ],
  options: {
    // 页面缓存:只缓存指定路径,最多 5 个页面,LRU 淘汰
    cache: {
      limit: 5,
      include: ['/', '/dashboard', '/list'],  // 白名单,也可用 RegExp
    },
    beforeEach: async (to, _from, next) => {
      if (to.meta?.requiresAuth && !getUser()) {
        next('/login')
        return
      }
      next()
    },
    afterEach: (to) => {
      document.title = (typeof to.meta?.title === 'string' ? to.meta.title : 'App')
    },
  },
})
  1. 官方动画方案:还好有 Framer Motion 兜底。没有 Motion,有几个人知道 React 卸载动画咋写?官方文档有教吗?没有。全靠社区自救。

六、Signal 使用指南:安装与 Babel

1. 安装

pnpm i @preact/signals-react

跑业务用这四个 API 就够了:signalcomputedeffectbatch。若想少写一层订阅代码(见下文),再装 Babel 插件:

pnpm i @preact/signals-react-transform -D

2. Babel 插件:做了什么、和 React Compiler 冲突、不用时怎么写

Babel 做了什么

@preact/signals-react-transform 会在编译阶段扫描组件内对 signal.value读取,自动插入「订阅」逻辑。结果是:在 JSX 里写 count.value 时,不用 在组件里再调 useSignals(),组件也会在 signal 变化时正确重渲染。
换句话说,Babel 帮你把「谁在用这个 signal」分析好了,并注入订阅,所以你可以直接写:

const count = signal(0)
function Counter() {
  return <p>{count.value}</p>   // 不用 useSignals(),照样响应更新
}

官方文档是这么描述规则的:

  • 函数是组件吗?

  • 如果是的话,这个组件会使用信号吗?

  • 如果一个函数名称大写(例如函数 MyComponent() {})且包含 JSX,则称该函数为组件。

  • 如果函数的主体包含一个成员表达式引用 .value(即某某的值 ),我们假设它是信号。

如果你的函数/组件符合这些条件,这个插件会对它进行转换。

如果没有,就会被放任不管。

如果你有一个函数使用信号但不符合这些条件(例如手动调用 createElement 而不是使用 JSX),你可以添加带有字符串 @useSignals 的注释,指示该插件转换该函数。

你也可以手动选择不转换函数,方法是添加带有字符串 @noUseSignals 的注释。


和 React Compiler 冲突

同一份文件里,不能 同时启用 signals-react-transformbabel-plugin-react-compiler,否则响应式会乱(见 preactjs/signals#652)。

至于这个 issue 我是怎么找的,那当然不是人肉搜索,而是靠 AI + Bash + CLI

比如 Github 提供了 MCPgh CLI(Github CLI) 命令行工具,可以让你查找代码和 issue 等,下面再介绍如何使用 gh,这里先看效果

ai2.png

解决做法是按路径分流:例如只对 views/signals/ 下的文件用 transform,其它文件只用 react-compiler:

// vite.config.ts
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [
    react({
      babel: (id) => {
        const isSignals = /[\\/]views[\\/]signals[\\/]/.test(id)
        return {
          plugins: isSignals
            ? [['module:@preact/signals-react-transform']]
            : ['babel-plugin-react-compiler'],
        }
      },
    }),
  ],
})

不用 Babel 时代码怎么写

不装、或不用 signals-react-transform 时,在消费 signal 的组件里必须显式调用 useSignals(),否则读 count.value 不会建立订阅,界面不会更新:

import { useSignals } from '@preact/signals-react/runtime'
import { signal } from '@preact/signals-react'

const count = signal(0)

function Counter() {
  useSignals()   // 必须写:让组件订阅用到的 signal
  return <p>Value: {count.value}</p>
}

同时用 Signals 和 React Compiler 时,也只能用这种「手写 useSignals()」的方式,不能在同一文件上开 Babel transform。


七、gh CLI(Github CLI),让你的 AI 掌握整个 Github

GitHub CLI 是一个命令行工具,用于在终端中直接与 GitHub 交互。适合让 LLM 通过命令行查阅和阅读仓库内容,节省 MCP Token 消耗。

安装

# Windows (使用 winget 或 scoop)
# 或者 Github Releases 下载
winget install --id GitHub.cli
# 或
scoop install gh

# macOS
brew install gh

# Linux
# 参考:https://github.com/cli/cli/blob/trunk/docs/install_linux.md#debian
(type -p wget >/dev/null || (sudo apt update && sudo apt install wget -y)) \
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& sudo mkdir -p -m 755 /etc/apt/sources.list.d \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh -y

身份验证:

gh auth login              # 交互式登录
gh auth status             # 查看认证状态
gh auth refresh            # 刷新 token

仓库信息

# 建议优先使用 JSON 输出,避免 README 导致输出过长
gh repo view {owner}/{repo} --json name,description,primaryLanguage,stargazerCount,url

文件内容(最常用)

# 读取文件内容(需解码)
gh api "repos/{owner}/{repo}/contents/{path/to/file}" --jq '.content' | base64 -d

# 读取 README
gh api "repos/{owner}/{repo}/readme" --jq '.content' | base64 -d

# 列出目录(仅显示名称,节省 Token)
gh api "repos/{owner}/{repo}/contents/{path}" --jq '.[].name'

分支和提交

# 列出所有分支名称
gh api "repos/{owner}/{repo}/branches" --jq '.[].name'

# 获取最近 3 条提交记录(格式化输出)
gh api "repos/{owner}/{repo}/commits?per_page=3" --jq '.[] | {sha: .sha[0:7], message: .commit.message, author: .commit.author.name}'

Issue 和 PR

# 列表显示(带限制)
gh issue list --repo {owner}/{repo} --limit 5
gh pr list --repo {owner}/{repo} --limit 5

# 搜索特定关键词的 Issue
gh issue list --repo {owner}/{repo} --search "{keyword}" --limit 5

# 获取 PR 详情(JSON)
gh api "repos/{owner}/{repo}/pulls/{number}" --jq '{title, body, state, html_url}'

通用 API

gh api {endpoint} --jq '.field'              # 任意 API + jq 过滤
gh api -X POST {endpoint} -f key=value       # POST 请求

使用场景

  1. 快速查阅仓库结构:先用 gh repo view --json ... 了解概况,再用 gh api .../contents --jq '.[].name' 浏览根目录
  2. 深度查看代码:定位文件后,使用 gh api .../contents/path --jq '.content' | base64 -d 读取
  3. 排查历史:使用 gh api .../commits?per_page=5 查看最近变更

Vue-性能优化利器:Keep-Alive

2026年2月2日 09:35

前言

在后台管理系统或长列表页面中,我们经常遇到这样的需求:从列表进入详情页,返回时希望列表滚动位置、搜索条件都能完美保留。Vue 内置的 <KeepAlive> 正是为此而生。本文将带你从基础用法出发,直击其背后的缓存算法原理。


一、 什么是 Keep-Alive?

<KeepAlive> 是一个内置组件,用于缓存不活动的组件实例,而不是销毁它们。

  • 核心价值:保留组件状态、避免重复渲染 DOM、提升用户体验。
  • 应用场景:表单多步骤切换、列表页返回流、详情页页签切换。

二、 基础实战:结合 Vue Router 实现按需缓存

在 Vue 中,我们通常结合路由的 meta 字段和 <router-view> 的插槽语法来实现。

1. 路由配置

// src/router/index.ts
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';

const routes: Array<RouteRecordRaw> = [
    {
      path: '/your-path',
      name: 'YourComponentName',
      component: () => import('./views/YourComponent.vue'),
      meta: {
        keepAlive: true // 设置需要缓存
      }
    }
];

2. 宿主容器配置

App.vue 或主布局文件中,接着在对应<router-view>的中插入<keep-alive>,并设置include属性来匹配需要缓存的组件

代码段

  // includeComponents为对应的组件文件名称
  <router-view v-slot="{ Component }">
    <KeepAlive :include="includeComponents">
      <component :is="Component" />
    </KeepAlive>
  </router-view>

三、 特有的生命周期钩子

一旦组件被缓存,其正常的销毁流程将被“冻结”,取而代之的是两个专属钩子:

  • activated:组件被激活(初始化渲染或从缓存中恢复)时调用。此时可重新获取数据或重置滚动位置。
  • deactivated:组件被停用(离开当前路由)时调用。此时可清理定时器或取消未完成的请求。

⚠️ 注意:由于组件被缓存,onBeforeUnmountonUnmounted(Vue 2 中的 beforeDestroydestroyed不会被触发。


四、 深度进阶:Keep-Alive 的底层原理

<KeepAlive> 本质上是一个“无渲染组件”,它不渲染多余的 DOM,而是直接操作组件的 VNode。

1. 内存中的 Map 缓存

Keep-Alive 内部维护了一个 cache 对象(Map 结构)和一个 keys 队列(Array 结构):

  • Cache:键是组件的 key,值是组件的 vnode 实例。
  • Keys:记录缓存组件的顺序。

2. 渲染函数逻辑

render 函数执行时:

  1. 获取内部包裹的组件节点。
  2. 查找 cache 中是否存在该组件的实例。
  3. 存在:直接从缓存中获取实例,并更新该 key 在 keys 队列中的位置(移到最后)。
  4. 不存在:将其加入缓存。

3. LRU 缓存策略

如果缓存的组件过多,内存会爆炸吗?不会。 Vue 使用了 LRU (Least Recently Used) 最近最少使用 算法。当缓存数量超过 max 属性设定的阈值时,Vue 会自动销毁 keys 队列中最久没被访问过的那个组件实例。


五、 总结

  1. 组件名称 (name)include 匹配的是组件定义的 name 选项。在 Vue 3 <script setup> 中,如果你没有显式定义 name,Vue 会根据文件名自动生成,建议显式定义以防匹配失效。
  2. 多级嵌套路由:如果你的 <router-view> 层级很深,每一层都需要配置 <KeepAlive> 才能保证整条路径上的状态都被保留。
  3. Key 的重要性:在 <component :is> 上绑定正确的 :key,能有效防止在切换相同组件不同参数(如 /detail/1/detail/2)时出现缓存混乱。
❌
❌