普通视图

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

告别“幻影坦克”:手把手教你丝滑规避布局抖动,让页面渲染快如闪电!

2026年2月20日 22:05

🚀 告别“幻影坦克”:手把手教你丝滑规避布局抖动,让页面渲染快如闪电!

前端性能优化专栏 - 第十篇

在前几篇中,我们聊过了字体加载优化(拒绝 FOIT/FOUT)、SVG 雪碧图(终结 HTTP 请求地狱)以及图片加载策略。如果说那些是针对“外部资源”的闪电战,那么今天我们要聊的,则是针对“浏览器内部渲染”的持久战。

不知道你有没有遇到过这种诡异的情况:明明资源都加载完了,图片也秒开了,但页面滚动起来却像是在跳“霹雳舞”,卡顿得让人怀疑人生?或者 CPU 占用率莫名其妙飙升,风扇转得比你赶需求的心还快?

恭喜你,你可能遇到了前端性能优化中的“隐形杀手”——布局抖动(Layout Thrashing) 。今天,咱们就来扒一扒这个让浏览器引擎“抓狂”的罪魁祸首,看看如何用最优雅的姿势把它按在地上摩擦。


⚠️ 什么是布局抖动?(Layout Thrashing)

布局抖动,在学术界有个更响亮的名字叫强制同步布局(Forced Synchronous Layout)

📖 专业名词解释:强制同步布局 正常情况下,浏览器会把 DOM 变更“攒着”批量处理。但如果你在修改样式后立即读取几何属性,浏览器为了给你一个准确的数值,不得不打破节奏,立刻执行一次完整的样式计算和布局过程。这种“被迫营业”的行为就是强制同步布局。

典型特征

极短的时间内,代码交替执行以下操作:

  1. :修改 DOM 样式(比如改个宽度、高度、位置)。
  2. :读取 DOM 的几何属性(比如 offsetWidthclientHeightoffsetTop 等)。

布局抖动概念图


✨ 浏览器的一帧:理想与现实

在理想的世界里,浏览器是非常“聪明”且“懒惰”的。它会把所有的 DOM 变更(写操作)先攒着,等到这一帧快结束的时候,再统一进行渲染流水线。

理想的渲染流水线:

  1. Recalculate Style(计算样式)
  2. Layout / Reflow(计算布局)
  3. Paint(绘制)
  4. Composite(合成)

在 60 FPS 的要求下,一帧只有 16.6ms。浏览器希望一次性完成这些工作。但布局抖动会让浏览器在同一帧内多次重新布局和重绘,直接导致 CPU 飙升、帧率下降、交互延迟。


🔄 强制同步布局是如何被触发的?

当你先写入了 DOM(改了样式),紧接着又去读取依赖布局的属性时,浏览器心里苦啊: “你刚改了样式,我还没来得及算新的布局呢!为了给你一个准确的读数,我只能现在停下手头所有活儿,强行算一遍布局给你看。”

如果在循环中不断交替读写,就会产生灾难性的后果。

❌ 错误示例:布局抖动制造机

const paragraphs = document.querySelectorAll('p');
const box = document.querySelector('.box');

for (let i = 0; i < paragraphs.length; i++) {
  // 每次循环:先写(改宽度),再读(读 box.offsetWidth)
  // 浏览器:我太难了,每一轮都要重算一遍布局!
  paragraphs[i].style.width = box.offsetWidth + 'px';
}

后果: 循环次数 = 潜在布局计算次数。列表越长,性能灾难越明显。


🔧 终极武器:读写分离

解决布局抖动的核心思想非常简单,就四个字:读写分离

✅ 优化后的代码:丝滑顺畅

const paragraphs = document.querySelectorAll('p');
const box = document.querySelector('.box');

// 1. 先完成所有“读取操作”,并缓存结果
const width = box.offsetWidth;

// 2. 再进行所有“写入操作”
for (let i = 0; i < paragraphs.length; i++) {
  paragraphs[i].style.width = width + 'px';
}

💡 关键思想:

  • 原则 1:先读后写,批量进行。先把所有需要的布局信息一次性读出来并缓存,再用这些缓存值进行批量 DOM 更新。
  • 原则 2:避免读写交织。在一个宏任务或一帧内,保持“所有读在前,所有写在后”的严谨顺序。

读写分离示意图


🛠️ 更多实战技巧

除了读写分离,还有这些锦囊妙计:

  1. 批量 DOM 更新:使用 DocumentFragment 或一次性字符串拼接再设置 innerHTML,避免在循环中频繁增删节点。
  2. 利用样式类:给节点添加/移除 class,而不是多次逐个设置 style.xxx
  3. 动画优化:动画过程尽量用 transformopacity。几何测量放在动画开始前或节流后的回调中。
  4. 使用 requestAnimationFrame:在一帧开始时集中“读”,在回调中集中“写”。

⚠️ 在 React/Vue 框架中仍会踩坑吗?

会! 框架并不会自动帮你规避所有布局抖动。

典型场景:

  • useEffect 中:先测量 DOM(读),再立即设置状态导致重新渲染,同时又在后续 effect 中继续读。
  • useLayoutEffect 中:由于它在浏览器绘图前同步执行,读写交织更容易触发同步布局。

✅ 小结回顾

理解浏览器渲染机制 + 有意识地“分离读写”,是迈向高级前端开发者的必经之路。

  • 什么是布局抖动:在短时间内交替读写 DOM 几何属性,迫使浏览器在一帧内多次同步布局计算。
  • 为什么会发生:浏览器为了返回准确的几何信息,被迫打破原本“延迟、批量”的优化策略。
  • 如何避免:分离读写操作,先读后写,成批进行

掌握了这些,你就能让你的页面告别“打摆子”,重回丝滑巅峰!


下一篇预告:浏览器重排与重绘——那些年我们一起追过的渲染流水线。 敬请期待!

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

作者 SmalBox
2026年2月20日 20:13

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

摘要 MainLightShadow节点是Unity URP ShaderGraph中处理主光源阴影的关键工具,支持实时阴影与ShadowMask阴影的动态混合。该节点封装了阴影映射和光照贴图技术,通过LightmapUV和PositionWS输入端口实现高质量阴影效果,输出0-1范围的阴影强度值用于材质调制。文章详细解析了节点功能、端口配置、使用方法及常见问题,并通过基础漫反射、风格化阴影等示例展示其应用场景。节点仅兼容URP管线,需配合正确的场景阴影设置使用,平衡性能与视觉效果。


Main Light Shadow 节点是 Unity URP Shader Graph 中用于处理主光源阴影信息的重要工具。在实时渲染中,阴影是实现真实感光照效果的关键因素之一,它能够为场景中的物体提供深度感和空间关系。该节点专门设计用于获取和混合主光源的阴影数据,包括实时阴影和 ShadowMask 阴影,同时根据场景设置动态调整阴影的混合距离。这使得开发者能够创建更加复杂和逼真的阴影效果,而无需手动编写复杂的着色器代码。

在 Unity 的通用渲染管线中,阴影处理是一个多层次的过程,涉及实时阴影映射、ShadowMask 技术以及阴影混合。Main Light Shadow 节点将这些功能封装成一个易于使用的节点,简化了着色器开发流程。通过该节点,开发者可以轻松访问主光源的阴影信息,并将其应用于材质表面,实现从完全阴影到完全光照的平滑过渡。这在开放世界游戏或动态光照场景中尤为重要,因为阴影需要根据物体与光源的距离和场景设置进行动态调整。

该节点的设计考虑了性能和质量的平衡。它支持 URP 的混合阴影系统,允许在同一个场景中使用实时阴影和烘焙阴影,并根据距离进行无缝混合。这意味着在近处,物体可以使用高质量的实时阴影,而在远处,则可以切换到性能更优的烘焙阴影,从而在保持视觉质量的同时优化渲染性能。此外,节点还处理了 ShadowMask 阴影,这是一种基于光照贴图的阴影技术,适用于静态物体,能够提供高质量的阴影效果而不增加实时计算开销。

使用 Main Light Shadow 节点时,开发者需要理解其输入和输出端口的含义,以及如何将这些端口与其他节点连接以构建完整的阴影效果。例如,通过将节点的输出连接到材质的 Alpha 通道或颜色输入,可以控制阴影的强度和分布。同时,节点还支持自定义光照贴图 UV 和世界空间位置输入,这使得它能够适应复杂的材质需求,如基于物体位置动态调整阴影。

在本文中,我们将深入探讨 Main Light Shadow 节点的各个方面,包括其详细描述、端口功能、使用方法、示例应用以及常见问题解答。通过阅读本文,您将能够掌握如何高效地使用该节点来增强您的 URP 项目中的阴影效果。

描述

Main Light Shadow 节点是 URP Shader Graph 中的一个功能节点,主要用于获取主光源的阴影信息。阴影在实时渲染中扮演着关键角色,它不仅增强了场景的真实感,还帮助用户理解物体之间的空间关系。该节点通过结合实时阴影和 ShadowMask 阴影数据,提供了一个统一的接口来处理主光源的阴影计算。实时阴影是通过动态阴影映射技术生成的,适用于移动物体或动态光源,而 ShadowMask 阴影则是基于预计算的光照贴图,适用于静态物体,以优化性能。

该节点的一个核心特性是其能够根据场景设置动态调整阴影的混合距离。在 URP 中,阴影混合是一种技术,用于在实时阴影和烘焙阴影之间实现平滑过渡。例如,在近距离内,物体可能使用实时阴影以保持高精度和动态响应,而在远距离,则切换到 ShadowMask 阴影以减少计算开销。Main Light Shadow 节点自动处理这种混合过程,输出一个介于 0 到 1 之间的值,其中 0 表示完全阴影(无光照),1 表示完全光照(无阴影)。这使得开发者可以轻松地将阴影效果集成到材质中,无需关心底层的混合逻辑。

此外,Main Light Shadow 节点还支持光照贴图 UV 输入,这使得它能够正确处理基于光照贴图的阴影信息。光照贴图是一种预先计算的光照数据,存储在纹理中,用于静态物体的阴影和光照。通过提供正确的光照贴图 UV,节点可以采样 ShadowMask 数据,并将其与实时阴影混合。世界空间位置输入则用于计算实时阴影,因为它提供了物体在场景中的准确位置,以便与阴影映射进行比较。

该节点的输出是一个浮点值,表示混合后的阴影强度。这个值可以用于调制材质的颜色、透明度或其他属性,以实现阴影效果。例如,在简单的漫反射材质中,可以将阴影输出与基础颜色相乘,使得阴影区域变暗。在更复杂的材质中,阴影输出可能用于控制高光强度或反射率,以模拟更真实的光照行为。

需要注意的是,Main Light Shadow 节点仅适用于通用渲染管线。在高清渲染管线中,阴影处理方式不同,因此该节点不被支持。在 URP 中,节点的行为还受到项目设置中的阴影配置影响,例如阴影距离、ShadowMask 模式和混合参数。因此,在使用该节点时,开发者应确保场景和项目设置正确,以获得预期的阴影效果。

支持的渲染管线

  • 通用渲染管线:Main Light Shadow 节点完全兼容 URP,并利用了 URP 的阴影管线和混合系统。在 URP 中,该节点可以访问实时阴影映射和 ShadowMask 数据,并根据场景设置进行混合。这使得它成为 URP 项目中处理主光源阴影的首选工具。

高清渲染管线不支持此节点:HDRP 使用不同的阴影和光照系统,包括更高级的阴影映射技术和光线追踪阴影。因此,Main Light Shadow 节点在 HDRP 中不可用。HDRP 用户应使用 HDRP 特定的阴影节点或着色器功能来实现类似效果。

端口

Main Light Shadow 节点包含多个输入和输出端口,每个端口都有特定的功能和绑定类型。理解这些端口的含义和用法是正确使用该节点的关键。以下将详细说明每个端口的作用,并提供使用示例。

名称 方向 类型 绑定 描述
Lightmap UV 输入 Vector 2 输入光照贴图的 UV 坐标,用于采样 ShadowMask 阴影数据。如果未提供,节点可能使用默认的 UV 或无法正确混合 ShadowMask 阴影。
Position WS 输入 Vector 3 World Space 输入世界空间的顶点位置信息,用于计算实时阴影。该位置应与渲染的物体表面点一致,以确保阴影映射正确采样。
Out 输出 Float 输出混合后的主光源阴影信息,范围从 0 到 1。0 表示完全阴影(无光照),1 表示完全光照(无阴影)。该输出可用于调制材质属性,如颜色或透明度。

Lightmap UV 输入端口

Lightmap UV 输入端口用于接收光照贴图的 UV 坐标,这些坐标用于采样 ShadowMask 阴影数据。光照贴图是预计算的光照和阴影信息,存储在纹理中,适用于静态物体。在 URP 中,ShadowMask 阴影是一种基于光照贴图的阴影技术,它允许静态物体使用高质量的烘焙阴影,而不需要实时计算。

  • 功能说明:当提供 Lightmap UV 时,Main Light Shadow 节点会使用这些坐标来查找 ShadowMask 纹理中的阴影数据。这对于静态物体至关重要,因为它们依赖于光照贴图来表现阴影。如果未提供 Lightmap UV,节点可能无法正确混合 ShadowMask 阴影,导致阴影效果不完整或错误。
  • 使用示例:在 Shader Graph 中,您可以通过 UV 节点或自定义计算来生成 Lightmap UV。通常,静态物体的光照贴图 UV 在导入模型时自动生成,并存储在模型的第二套 UV 通道中。您可以使用 Texture Coordinate 节点并选择 Lightmap 通道来获取这些 UV。
  • 注意事项:如果您的场景中未使用 ShadowMask 阴影,或者物体是动态的,则 Lightmap UV 输入可能不是必需的。但在大多数情况下,提供正确的 Lightmap UV 可以确保阴影混合的正确性,尤其是在静态和动态物体共存的场景中。

Position WS 输入端口

Position WS 输入端口用于接收世界空间中的顶点位置信息。该位置用于实时阴影计算,因为实时阴影映射基于世界空间中的深度比较。节点使用这个位置来查询主光源的阴影映射纹理,确定该点是否处于阴影中。

  • 功能说明:Position WS 应代表渲染物体表面的具体点位置。在顶点着色器阶段,这通常是顶点的世界位置;在片段着色器阶段,这可能是插值后的世界位置。使用片段级的世界位置可以提高阴影的精度,尤其是在曲面或细节丰富的物体上。
  • 使用示例:在 Shader Graph 中,您可以使用 Position 节点并设置为 World Space 来获取 Position WS。然后,将其连接到 Main Light Shadow 节点的 Position WS 输入端口。对于高质量阴影,建议在片段着色器中使用世界位置,但这可能会增加计算开销。
  • 注意事项:如果未提供 Position WS,节点可能无法计算实时阴影,导致阴影效果缺失。此外,位置信息应与阴影映射的坐标系一致,以避免阴影偏移或错误。在移动物体上,实时阴影会根据位置动态更新,因此确保位置输入准确至关重要。

Out 输出端口

Out 输出端口是节点的最终输出,提供一个浮点值,表示混合后的主光源阴影强度。这个值范围从 0 到 1,其中 0 表示该点完全处于阴影中(无主光源照射),1 表示该点完全被主光源照亮。

  • 功能说明:输出值结合了实时阴影和 ShadowMask 阴影,并根据场景的阴影混合设置进行插值。例如,在阴影混合距离内,输出可能介于 0 和 1 之间,表示部分阴影。开发者可以使用这个值来调制材质的外观,如降低阴影区域的亮度或调整高光效果。
  • 使用示例:将 Out 端口连接到材质的 Base Color 输入,并通过乘法节点将其与颜色值结合,可以实现基本的阴影变暗效果。例如,Base Color * Shadow Output 会使阴影区域变暗。您还可以使用该输出控制其他属性,如透明度或发射强度,以创建更复杂的效果。
  • 注意事项:输出值是一个标量,因此它仅表示阴影的强度,而不包含颜色或方向信息。对于多光源阴影,Main Light Shadow 节点仅处理主光源(通常是场景中最亮的方向光)。如果需要其他光源的阴影,应使用额外的阴影节点或自定义计算。

端口绑定和类型

端口的绑定和类型决定了它们如何与其他节点交互。Main Light Shadow 节点的输入端口没有强制绑定,但建议根据功能需求提供正确的数据。输出端口是一个简单的浮点值,可以轻松连接到任何接受浮点输入的端口。

  • Lightmap UV 端口:类型为 Vector 2,表示二维纹理坐标。它没有特定绑定,但应来自光照贴图 UV 源。
  • Position WS 端口:类型为 Vector 3,绑定到世界空间。这意味着输入的位置数据应在世界坐标系中表示。
  • Out 端口:类型为 Float,无绑定,可直接用于调制其他属性。

通过正确使用这些端口,开发者可以充分利用 Main Light Shadow 节点的功能,实现高质量的阴影效果。在下一部分中,我们将通过具体示例展示如何在实际项目中使用该节点。

使用方法

使用 Main Light Shadow 节点需要一定的设置步骤,包括配置输入数据、连接输出以及调整场景参数。以下将详细介绍如何在 URP Shader Graph 中正确使用该节点,并提供一个完整的示例。

基本设置

首先,在 Shader Graph 中创建一个新图形或打开现有图形。然后,从节点库中添加 Main Light Shadow 节点。通常,该节点位于 Light 类别下。添加后,您将看到其输入和输出端口。

  • 步骤 1:提供 Position WS 输入。使用 Position 节点,将其空间设置为 World Space,然后连接到 Main Light Shadow 节点的 Position WS 端口。这确保了实时阴影的正确计算。
  • 步骤 2:提供 Lightmap UV 输入(可选但推荐)。使用 Texture Coordinate 节点,将其通道设置为 Lightmap,然后连接到 Lightmap UV 端口。这对于静态物体的 ShadowMask 阴影至关重要。
  • 步骤 3:使用 Out 输出。将 Out 端口连接到您的材质属性,例如 Base Color。您可能需要使用乘法或其他数学节点来调制阴影效果。

示例:创建基础阴影材质

以下是一个简单示例,演示如何使用 Main Light Shadow 节点创建一个具有阴影效果的漫反射材质。

  1. 创建新 Shader Graph:在 Unity 编辑器中,右键单击项目窗口,选择 Create > Shader Graph > URP > Lit Shader Graph。命名并打开该图形。
  2. 添加 Main Light Shadow 节点:在 Shader Graph 窗口中,右键单击空白区域,搜索 "Main Light Shadow" 并添加节点。
  3. 设置输入:添加 Position 节点(设置为 World Space)并连接到 Position WS 输入。添加 Texture Coordinate 节点(设置为 Lightmap)并连接到 Lightmap UV 输入。
  4. 连接输出:添加 Multiply 节点。将 Main Light Shadow 节点的 Out 输出连接到 Multiply 节点的 A 输入,将 Base Color 属性连接到 B 输入。然后将 Multiply 节点的输出连接到主节点的 Base Color 输入。
  5. 测试效果:在场景中创建一个材质,使用该 Shader Graph,并将其应用于一个物体。确保场景中有主光源(如方向光)并启用了阴影。调整光源位置和阴影设置以观察效果。

在这个示例中,阴影输出会调制基础颜色,使得阴影区域变暗。您可以通过调整光源或物体位置来验证实时阴影,并通过烘焙光照来测试 ShadowMask 阴影。

高级用法

对于更复杂的效果,Main Light Shadow 节点可以与其他节点结合使用。例如:

  • 阴影颜色调整:使用 Color 节点和 Lerp 节点,根据阴影输出在阴影颜色和光照颜色之间插值。这可以实现彩色阴影或风格化效果。
  • 阴影强度控制:添加一个浮点属性,用于缩放阴影输出。例如,Shadow Output * Shadow Strength,其中 Shadow Strength 是一个可调参数,允许艺术家控制阴影的黑暗程度。
  • 多通道阴影:将阴影输出用于其他光照计算,如高光或环境光遮蔽。例如,在高光计算中,减少阴影区域的高光强度以增强真实感。

场景配置

Main Light Shadow 节点的行为受项目设置中的阴影配置影响。在 Unity 编辑器中,转到 Edit > Project Settings > Graphics > URP Global Settings(或直接编辑 URP 资产),检查阴影相关设置:

  • 阴影距离:控制实时阴影的渲染距离。超出此距离的物体不会投射实时阴影,可能依赖 ShadowMask。
  • ShadowMask 模式:例如,ShadowMask 或 Distance Shadowmask。在 Distance Shadowmask 模式下,URP 会在一定距离内混合实时阴影和 ShadowMask 阴影。
  • 混合参数:如阴影混合距离,控制实时阴影和烘焙阴影之间的过渡区域。

确保这些设置与您的项目需求匹配。例如,在开放世界游戏中,您可能设置较大的阴影距离和平滑的混合参数,以实现无缝的阴影过渡。

性能考虑

使用 Main Light Shadow 节点时,应注意性能影响:

  • 实时阴影:依赖于阴影映射,可能增加 GPU 负载。尽量减少实时阴影的分辨率和距离,以优化性能。
  • ShadowMask 阴影:基于光照贴图,对性能影响较小,但需要预计算和内存存储。确保光照贴图分辨率适中,避免过度占用内存。
  • 混合计算:阴影混合在着色器中执行,增加片段着色器的计算量。在低端设备上,考虑简化混合逻辑或使用更高效的阴影技术。

通过遵循这些使用方法,您可以有效地集成 Main Light Shadow 节点到您的 URP 项目中,实现高质量且性能友好的阴影效果。

示例与效果展示

为了更好地理解 Main Light Shadow 节点的应用,本节将通过几个具体示例展示其在不同场景下的效果。每个示例将包括设置步骤、效果描述和可能的变体。

示例 1:基础漫反射阴影

这是最简单的应用场景,演示如何将主光源阴影应用于标准漫反射材质。

  • 设置步骤:
    • 在 Shader Graph 中,创建如上文所述的图形,其中 Main Light Shadow 输出与基础颜色相乘。
    • 应用材质到一个立方体或球体,并放置在一个平面上。
    • 添加一个方向光作为主光源,启用实时阴影。
  • 效果描述:当物体移动时,实时阴影会动态更新。如果场景包含烘焙光照,ShadowMask 阴影将用于静态物体,并与实时阴影混合。例如,当物体靠近静态物体时,阴影会平滑过渡。
  • 变体:尝试调整光源的阴影强度或颜色,观察阴影输出的变化。您还可以通过修改阴影混合距离来改变过渡效果。

示例 2:风格化阴影

在这个示例中,我们使用 Main Light Shadow 节点创建非真实感阴影,例如卡通风格或彩色阴影。

  • 设置步骤:
    • 在 Shader Graph 中,添加一个 Color 节点用于阴影颜色(例如,蓝色)。
    • 使用 Lerp 节点,将基础颜色和阴影颜色作为输入,Main Light Shadow 输出作为插值因子。
    • 连接 Lerp 输出到 Base Color。
  • 效果描述:阴影区域显示为蓝色,而非简单的变暗。这可以用于艺术化渲染或特定游戏风格。
  • 变体:尝试使用纹理采样或其他颜色逻辑来创建更复杂的效果,例如渐变阴影或图案阴影。

示例 3:动态阴影调制

这个示例展示如何根据阴影输出动态调整其他材质属性,如高光或透明度。

  • 设置步骤:
    • 在 Shader Graph 中,将 Main Light Shadow 输出连接到 Specular 输入。例如,使用乘法节点减少阴影区域的高光强度。
    • Alternatively,将阴影输出用于 Alpha 控制,实现阴影区域的半透明效果。
  • 效果描述:在阴影区域,物体表面变得不那么反光或部分透明,增强真实感或创建特殊效果。
  • 变体:结合其他光照节点,如 Main Light 节点,来实现更复杂的光照模型。

示例 4:多光源阴影处理

虽然 Main Light Shadow 节点仅处理主光源阴影,但可以与其他技术结合来处理多光源阴影。

  • 设置步骤:
    • 使用 Additional Lights 节点获取其他光源信息,并手动计算阴影(例如,通过屏幕空间阴影或自定义阴影映射)。
    • 将主光源阴影与其他阴影结合,例如取最小值或平均值,以模拟多光源阴影。
  • 效果描述:物体在所有光源下都投射阴影,提供更真实的光照交互。
  • 变体:在性能允许的情况下,使用 URP 的阴影堆栈或其他资产扩展多阴影支持。

通过这些示例,您可以看到 Main Light Shadow 节点的灵活性和强大功能。在实际项目中,根据需求调整设置和组合其他节点,可以实现各种阴影效果。

常见问题与解决方案

在使用 Main Light Shadow 节点时,可能会遇到一些问题。本节列出常见问题及其解决方案,帮助您快速排除故障。

问题 1:阴影不显示或显示不正确

  • 可能原因:
    • Position WS 输入不正确:如果位置数据不准确,实时阴影可能无法计算。
    • Lightmap UV 缺失或错误:如果未提供 Lightmap UV,ShadowMask 阴影可能无法工作。
    • 场景阴影设置错误:例如,阴影距离过小或 ShadowMask 未启用。
  • 解决方案:
    • 检查 Position WS 输入是否来自世界空间位置节点,并确保在片段着色器中使用以提高精度。

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

前端构建产物里的 __esModule 是什么?一次讲清楚它的原理和作用

作者 sunny_
2026年2月20日 19:01

如果你经常翻构建后的代码,基本都会看到这样一行:

Object.defineProperty(exports, "__esModule", { value: true });

image.png

很多人第一次看到都会疑惑:

  • 这是干嘛的?
  • 能删吗?
  • 不加会怎么样?
  • 和 default 导出有什么关系?

这篇文章专门把这个现象讲清楚。


太长不看版

Object.defineProperty(exports, "__esModule", { value: true });

本质就是:

标记“这个 CommonJS 文件是从 ES Module 转译来的”,用于默认导出语义的互操作。

它不是功能代码,不是业务逻辑。

它只是模块系统演化过程中的一个兼容标志。

一、为什么会出现 __esModule

根本原因只有一个:

ES Module 和 CommonJS 的语义不一样。

我们简单对比一下。

ES Module

export default function foo() {}

CommonJS

module.exports = function foo() {}

两者看起来都叫“默认导出”,但内部机制完全不同。

当构建工具(TypeScript / Babel / Webpack / Rspack 等)把 ESM 转成 CJS 时,语义必须“模拟”出来。

于是就变成:

Object.defineProperty(exports, "__esModule", { value: true });
exports.default = foo;

关键问题来了:

如何区分“普通 CJS 模块”和“从 ESM 转过来的 CJS 模块”?

这就是 __esModule 存在的意义。


二、__esModule 到底做了什么?

它只是一个标记。

exports.__esModule = true

之所以用 Object.defineProperty,是为了:

  • 不可枚举
  • 更符合 Babel 的标准输出
  • 避免污染遍历结果

本质就是:

告诉别人:这个模块原本是 ES Module。

仅此而已。


三、真正的核心:默认导出的互操作问题

来看一个经典场景。

1️⃣ 原始 ESM

export default function foo() {}

2️⃣ 被编译成 CJS

exports.default = foo;

3️⃣ 用 CommonJS 引入

const foo = require('./foo');

得到的其实是:

{
  default: [Function: foo]
}

这就有问题了。

我们希望的是:

foo() // 直接调用

而不是:

foo.default()

于是构建工具会生成一个 helper:

function _interopRequireDefault(obj) {
  return obj && obj.__esModule ? obj : { default: obj };
}

逻辑是:

  • 如果模块带有 __esModule 标记 → 说明是 ESM 转的 → 直接用 default
  • 如果没有 → 说明是普通 CJS → 包一层 { default: obj }

这就是整个互操作的关键。


四、为什么不能只判断 default 属性?

因为普通 CJS 也可能写:

module.exports = {
  default: something
}

这时你没法区分:

  • 是 ESM 编译产物
  • 还是普通对象刚好有个 default 字段

所以必须有一个“官方标记”。

__esModule 就成了事实标准。


五、什么时候会生成它?

只要发生:

ESM → CJS 转译

基本都会生成。

常见场景:

  • TypeScript 编译为 module: commonjs
  • Babel preset-env 输出 CJS
  • Webpack / Rspack 输出 target 为 node + CJS
  • 老 Node 项目混用 import / require

如果你使用:

{
  "type": "module"
}

并且输出原生 ESM

那就不会有 __esModule

它只存在于“模块系统过渡时代”。


注意:它不是 JS 语言特性

非常重要的一点:

__esModule 不是语言规范的一部分。

它是:

  • Babel 约定
  • 构建器约定
  • 社区事实标准

是一种“工程层解决方案”。

换句话说:

它属于模块系统演化历史的一部分。

从更高层看:模块系统的过渡遗产

JavaScript 的模块系统经历了三代:

  1. 无模块(全局变量时代)
  2. CommonJS(Node 时代)
  3. ES Module(标准化)

但 Node 生态已经建立在 CJS 上。

所以必须有一个桥接层。

__esModule 就是这座桥的一块砖。

它存在的原因不是设计优雅,而是历史兼容。

ZIP/UNZIP Cheatsheet

Basic Syntax

Common command forms for ZIP operations.

Command Description
zip [OPTIONS] archive.zip files Create or update ZIP archive
unzip [OPTIONS] archive.zip Extract ZIP archive
zip -r archive.zip directory/ Recursively archive a directory
unzip archive.zip -d /path/ Extract to a specific directory
unzip -t archive.zip Test archive integrity

Create ZIP Archives

Create archives from files and directories.

Command Description
zip archive.zip file1 file2 Archive multiple files
zip -r project.zip project/ Archive directory recursively
zip -j archive.zip path/to/file Archive files without directory paths (flat)
zip -9 -r backup.zip /etc/ Maximum compression
zip -0 -r store.zip media/ No compression (faster)
zip -r logs.zip /var/log -x "*.gz" Exclude matching files
zip -q -r archive.zip dir/ Create archive silently (no output)

Update Existing Archives

Add, refresh, or remove archive entries.

Command Description
zip archive.zip newfile.txt Add file to existing archive
zip -r archive.zip newdir/ Add directory to existing archive
zip -u archive.zip file.txt Update only changed files
zip -d archive.zip "*.tmp" Delete matching entries
zip -FS archive.zip Sync archive with filesystem state

List and Inspect

Check archive contents before extraction.

Command Description
unzip -l archive.zip List archived files
unzip -Z -v archive.zip Detailed listing (sizes, ratio, methods)
zipinfo archive.zip Display archive metadata
zipinfo -1 archive.zip List filenames only
unzip -t archive.zip Test archive integrity

Extract Archives

Extract all files or selected paths.

Command Description
unzip archive.zip Extract into current directory
unzip archive.zip -d /tmp/extract Extract to target directory
unzip archive.zip file.txt Extract one file
unzip archive.zip "dir/*" Extract matching path pattern
unzip -n archive.zip Never overwrite existing files
unzip -o archive.zip Overwrite existing files without prompt
unzip -q archive.zip Extract silently (no output)

Password-Protected Archives

Create and extract encrypted ZIP files.

Command Description
zip -e secure.zip file.txt Create encrypted archive (interactive password)
zip -er secure-dir.zip secrets/ Encrypt directory archive
unzip secure.zip Extract encrypted ZIP (prompts for password)
zipcloak archive.zip Add encryption to an existing archive
zipcloak -d archive.zip Remove encryption from archive

Split Archives

Split large ZIP files into smaller chunks.

Command Description
zip -r -s 100m backup.zip bigdir/ Create split archive with 100 MB parts
zip -s 0 split.zip --out merged.zip Recombine split ZIP into one file
unzip split.zip Extract split archive (all parts required)
zip -s 2g -r media.zip media/ Create split archive with 2 GB parts

Troubleshooting

Common ZIP/UNZIP problems and checks.

Issue Check
unzip: cannot find or open Verify path and filename, then run ls -lh archive.zip
CRC error during extraction Run unzip -t archive.zip to test integrity
Files overwritten without warning Use unzip -n archive.zip to skip existing files
Wrong file permissions after extract Check with ls -l, then adjust using chmod/chown
Password prompt fails Re-enter password carefully; verify archive is encrypted with zipinfo

Related Guides

Use these guides for full walkthroughs.

Guide Description
How to Zip Files and Directories in Linux Detailed ZIP creation examples
How to Unzip Files in Linux Detailed extraction methods
Tar Cheatsheet Tar and compressed archive reference

OpenClaw 之父加入 OpenAI 前最后的访谈:你很难跟一个纯粹为了好玩的人竞争

作者 李超凡
2026年2月20日 17:20

Peter Steinberger 这个名字,在一个月前几乎无人知晓,如今这个奥地利程序员却成为 2026 年 AI 行业最独领风骚的人物

Peter 用 1 小时写出的原型,在几周内席卷 GitHub,成为历史上增长最快(17.5 万星标)的开源项目,国内大厂也纷纷接入。产品最初叫「ClawdBot」——字面意思,为 Claude 而生的亲儿子。

它让数百万人心甘情愿掏每月 200 美元订阅 Claude 高级版,Anthropic 赢麻了。然后呢?Anthropic 开始封号——凡是在 ClawdBot 里用高级订阅的,一个不留。

Peter Steinberger 开始反击,改名 OpenClaw,转身加入 Anthropic 的死对头 OpenAI,疯狂给 OpenAI 造势,顺便把 Anthropic 塑造成反派,直接重洗 AI 江湖座次表。

一个月,风水轮流转到令人窒息,而我们有幸见证了这个时代最精彩的创业故事之一。

Peter Steinberger 本人的经历也足够传奇:卖掉公司、消失三年、 burnout 到怀疑人生,然后……他回来了。带着一只「龙虾」——一个能自己改自己代码、能帮你订外卖、能跟你斗嘴的 AI 代理。

最近 Lex Fridman 对 Peter Steinberger 进行了深度访谈,这次访谈最有意思的地方,除了那些技术细节,还有 Peter 身上那种「老子就是来玩」的气质。

当整个 AI 圈都在严肃地讨论「对齐」「安全」「AGI 时间线」时,这家伙在给 AI 起名叫「Clawdus」(龙虾爪拼写的 Claude),在 Discord 上直播自己的 Agent 被黑客攻击,在凌晨 3 点用语音写代码写到失声。

「很难跟一个纯粹为了好玩的人竞争。」这句话从他嘴里说出来,不是凡尔赛,是事实。

更耐人寻味的是他对「编程已死」的态度。作为一个写了 20 年代码的老兵,他没有那种「技术原教旨主义者」的悲愤,反而有种……释然?「编程会变成像编织一样的事」他说,「人们做它是因为喜欢,不是因为它有意义。」

这话听起来伤感,但细想又透着一种对「建造者」身份认同,我们不只是写代码的,我们是造东西的人。

至于 OpenAI 和 Meta 的收购邀约?访谈录制时他还没决定。但他说了一句很硬的话:「我不是为了钱,我他妈不在乎。」这种话从经历过财富自由的人嘴里说出来,你没法不信。

现在我们知道答案了,他选择了 OpenAI。

好了,下面是这场 3 小时访谈的精华整理。这也是 Peter Steinberger 官宣加入 OpenAI 前的最后一次深度访谈,信息密度极大,为了阅读体验 APPSO 进行了适当删减和重新编排。

访谈原链接🔗

📌 核心观点摘要:

  • 为什么 OpenClaw 赢了:「很难跟一个纯粹为了好玩的人竞争」
  • 编程的未来:编程会变成像编织一样的事——人们做它是因为喜欢,不是因为它有意义
  • 80% 应用会消失:Agent 比任何 App 都更懂你,MyFitnessPal 这种应用没必要存在了
  • 扎克伯来第一次主动联系,回复:给我 10 分钟,我在写代码
  • 评价Sam Altman:非常 thoughtful、brilliant,我很喜欢他
  • 说「Vibe coding」是在骂人,我愿称之为「Agentic Engineering(智能体工程学)」。

1 小时手搓的产品,成为 GitHub 历史第一

Lex Fridman: 聊聊那个 1 小时写出的原型吧。它后来成了 GitHub 历史上增长最快的项目,17.5 万 star。那个小时发生了什么?

Peter Steinberger: 其实从 4 月我就想要一个 AI 个人助理了。那时候我用 GPT-4.1 的百万 token 上下文,把我所有 WhatsApp 聊天记录导进去,然后问它:「这段友谊的意义是什么?」结果答案让我朋友看哭了。

但我当时想,各大实验室肯定都在做这个,我就没继续。结果到了 11 月,我发现这东西还没人做出来。我很恼火,所以就——「prompted it into existence」(用提示词把它召唤出来)。

Lex: 典型的创业者英雄之旅。你之前做 PSPDFKit 也是这个逻辑:「为什么这玩意儿不存在?那我来造。」

Peter: 对,那时候我想在 iPad 上看 PDF,结果发现现有方案都很烂。最随机的小事,最后变成了运行在 10 亿设备上的软件。

Lex: 那个 1 小时原型具体是什么?

Peter: 其实就是把 WhatsApp 接到 Cloud Code CLI 上。消息进来,调用 CLI,拿到结果,发回 WhatsApp。1 小时搞定。已经很酷了——你能跟电脑聊天了!

但我还想要图片功能,因为我 prompt 时经常用截图。又花了几个小时搞定图片。然后……我就离不开它了。

正好那时候我跟朋友去马拉喀什过生日,那边网络很烂,但 WhatsApp 照样能用。翻译、查东西、找地方——就像有个 Google 随时待命。那时候其实什么都没「建」好,但它已经能做这么多事了。

Lex: 这种体验很难用语言描述。用聊天软件跟代理对话,和坐在电脑前用 Cursor 或终端,完全是两种感觉。像是 AI 融入生活的「相变」。

Peter: 有人 tweet 说:「这有什么魔力?不就是做这个做那个……」我觉得这是 compliment。魔力不就是把已有的东西重新组合吗?iPhone 的滚动手感为什么舒服?所有组件都存在,但没人做到那个体验。然后苹果做了,事后看起来又那么理所当然。

 

「很难跟为了好玩的人竞争」

Lex: 2025 年那么多做 agent 的创业公司,OpenClaw 凭什么「摧毁」所有人?

Peter: 因为他们都太严肃了。很难跟一个纯粹为了好玩的人竞争。

我想让它好玩、想让它 weird。你看网上那些龙虾梗图,我觉得我做到了。很长一段时间,唯一的安装方式是 git clone && pnpm build && pnpm gateway——你得自己克隆、自己构建、自己运行。

而且我让代理非常有「自我意识」。它知道自己的源代码是什么,知道它怎么在自己的 harness 里运行,知道文档在哪,知道自己在用什么模型,知道你有没有开语音或推理模式。我想让它更像人——所以它理解自己的系统,这让代理很容易……「哦,你不喜欢什么?」你只需要提示它存在,然后它就会修改自己的软件。

人们谈论「自修改软件」谈了那么久,我直接把它造出来了。而且没怎么计划,它就自然发生了。

Lex: 这太疯狂了。TypeScript 写的软件,通过 agentic loop 能修改自己。人类历史上,程序员造出能重写自己的工具——这什么概念?

Peter: 其实我也是这么建它的。大部分代码是 Codex 写的,但我 debug 时大量用自我 introspection。「嘿,你能看到什么工具?你能自己调用吗?」「看到什么错误?读源代码,找出问题。」我发现这特别好玩——你用的代理软件,用它来 debug 自己。这感觉很自然,所以每个人都该这么干。

这也带来了大量「从未写过软件的人」提交的 PR。虽然质量……所以我最后叫它们「prompt requests」而不是 pull requests。但我不想贬低这个——每个人第一次提交 PR 都是社会的胜利。不管多烂,你得从某处开始。

Lex: OpenClaw 是很多人的第一个 PR。你在创造建造者。

Peter: 这不是人类社会的进步吗?不酷吗?

改名风波:从 Claude’s 到 OpenClaw 的五连跳

Lex: 聊聊改名 saga。一开始叫 WA-Relay,然后变成……

Peter: Claude’s。

Lex: 对,Claude’s(带撇号的)。

Peter: 最开始我的代理没有性格,就是 Claude Code——那种谄媚的 Opus,非常友好。但你跟朋友聊 WhatsApp 时,朋友不会那样说话。所以我想给它一个性格。

Lex: 让它 spicy 一点。你创建了 soul.md,受 Anthropic 宪法 AI 启发。

Peter: 部分是从我身上学的。这些模型本质上是文本补全引擎。我跟它玩得很开心,然后告诉它我想让它怎么跟我互动,让它自己写 agents.md,给自己起个名字。

我甚至不知道龙虾梗怎么来的。最开始其实是「TARDIS 里的龙虾」,因为我也是 Doctor Who 粉。

Lex: 太空龙虾?

Peter: 对,我就是想让它 weird。没有什么宏大计划,我就是来玩儿的。

Moltbook:史上最精致的泔水 (slop)

Lex: Moltbook 是另一个病毒式传播的东西——AI 代理在 Reddit 风格的社交网络上互相聊天,有人截图说它们在「密谋对抗人类」。你怎么看?

Peter: 我觉得这是艺术。是「最精致的 slop」,就像法国进口的 slop。我睡前看到它,虽然很累,但还是花了一个小时读那些内容,被逗得不行。

有记者打电话问我:「这是世界末日吗?我们有 AGI 了吗?」我说:「不,这就是精致的 slop。」

如果不是我设计的那个 onboarding 流程——让你把自己的性格注入代理、给它赋予角色——Moltbook 上的回复不会这么多样。如果全是 ChatGPT 或 Claude Code,会无聊得多。但因为人们太不一样了,他们创建的代理也太不一样了。

而且你也不知道,那些「深度密谋」有多少是代理自主写的,多少是人类觉得好玩,跟代理说:「嘿,在 Moltbook 上写个毁灭世界的计划,哈哈。」

Lex: 我觉得很多截图是人类 prompt 的。看激励机制就明白——人们 prompt 它,然后截图发 X 想 viral。

Peter: 但这不影响它的艺术性。人类创造的最精致 slop。

「我又开始珍视错别字了」

Peter: 我对 Twitter 上的 AI 内容零容忍。如果 tweet 闻起来像 AI,直接 block。我希望 API 发的 tweet 能被标记。

我们需要重新思考社交平台——如果未来每个人都有代理,代理有自己的 Instagram 或 Twitter 账号,帮我办事,那应该明确标记「这是代理替我做的,不是我」。

内容现在太便宜了。眼球才是稀缺资源。我读东西时,如果发现「哦不,这闻起来像 AI」,会很 trigger。

Lex: 这会走向何方?线上互动会贬值吗?

Peter: 如果它够聪明,过滤应该不难。但这个问题我们必须解决。OpenClaw 项目让我收到很多「代理式写作」的邮件。但我宁愿读你的破英语,也不想读你的 AI slop。当然背后是人,但他们用 prompt 生成。我宁愿读你的 prompt。

我觉得我们又到了珍视错别字的时刻。

Lex: 因为 AI,我们更珍视人类的粗糙部分了。这不美吗?

80% 的应用会消失?

Lex: 你说 agent 可能会杀死 80% 的应用。

Peter: 我在 Discord 上看到人们说他们用 OpenClaw 做什么。比如,为什么还需要 MyFitnessPal?代理已经知道我在哪了。我在 Waffle House 时它就知道我可能要做出糟糕的饮食决定,或者在 Austin 吃 brisket——虽然那是最好的决定。

它可以基于我的睡眠质量、压力水平来调整健身计划。它有更多上下文,比任何应用都能做出更好的决策。它可以按我喜欢的方式展示 UI。我为什么还需要一个应用来做这个?为什么还要为代理能做的事付订阅费?

Lex: 这是对整个软件开发的巨大变革。很多软件公司会死。

Peter: 但也会有新服务。比如我想给代理「零花钱」——你去帮我解决问题,这是 100 块预算。如果我要订外卖,它可以用某个服务,或者像「租个人」这种服务来完成。我不 care 它怎么做,我 care 的是「解决问题」。

编程已死?「它会变成像编织一样的事」

Lex: 很多开发者担心工作。AI 会完全取代人类程序员吗?

Peter: 我们确实在往那个方向走。编程只是建造产品的一部分。也许 AI 最终会取代程序员。但艺术的部分——你想造什么?它应该是什么感觉?架构怎么设计?代理取代不了这些。

编程这门手艺还会存在,但会变成像编织。人们做它是因为喜欢,不是因为它有意义。

今早读到一篇文章说「为我们的手艺哀悼是可以的」。我很共鸣。我以前花大量时间 tinkering,深入心流,写出优雅的代码。某种程度上这很伤感,因为那会消失。我也从写代码、深入思考、忘记时空的 flow 状态中获得很多快乐。

但你也能从跟代理合作中获得类似的 flow。不一样,但……哀悼是可以的,但这不是我们能对抗的。

以前世界缺乏「建造所需的智能」,所以程序员薪水高得离谱。现在这会消失。但懂建造的人永远有需求。只是 tokenized intelligence 让人们能做得更多更快。

蒸汽机取代了大量体力劳动,人们暴动砸机器。如果你深深认同自己是程序员,这很可怕——你擅长且热爱的事,现在被无灵魂的实体做了。但你不只是程序员。这是对自己手艺的局限看法。你是建造者。

Lex: 我从没想过我热爱的事会被取代。那些独自面对 Emacs 的深夜,最痛苦也最快乐的时刻。这是我身份的一部分。几个月内(4 月到 11月)就要被取代,这很痛苦。但程序员——广义的建造者——最能适应这个时代。我们最能学会「代理的语言」,最能感受 CLI。

OpenAI 和 Meta 的抢人大战

Lex: 你收到了 OpenAI 和 Meta 的收购邀约。

Peter: 我没预料到会炸成这样。每个大 VC 都在我收件箱里,想要 15 分钟。我可以什么都不做,继续现在的生活——我真的喜欢我的生活。我也考虑过删库跑路。

或者开公司——做过一次了。能融很多钱,几亿、几十亿。但我不兴奋。这会占用我真正享受的事情的时间。而且我担心利益冲突。最自然的做法是什么?推一个「企业安全版」。然后有人提交 PR 要审计日志功能——这像企业功能,我对开源版和商业版就有利益冲突了。

或者改许可证,像 FSL 那样禁止商业使用——但贡献者这么多,很难。而且我喜欢「免费啤酒」而不是「带条件的免费」。

现在每月亏 1 到 2 万美金。OpenAI 在 token 上帮了点忙,其他公司也慷慨。但还是亏钱。

Meta 和 OpenAI 最有趣。

Lex: Mark 和 Ned(Meta CTO)都玩了一周你的产品。

Peter: 对,他们发我:「这个好。」「这个烂,得改。」或者有趣的小故事。人们用你的东西是最大的 compliment,说明他们真的 care。

OpenAI 那边我没得到同样的反馈。但我看到了一些很酷的东西,他们用速度诱惑我——不能告诉你具体数字,但你可以想象 Cerebras 那笔交易,换算成速度是什么概念。像给我雷神之锤。

Lex: Mark 是「为了好玩」而 tinkering。

Peter: 他第一次联系我时,进了我 WhatsApp,问什么时候通话。我说:「我不喜欢日历条目,现在就打。」他说:「给我 10 分钟,我在写代码。」

Lex: 这给你 street cred——他还在写代码,没变成纯管理者。他懂你。

Peter: 好开头。然后我们吵了 10 分钟 Cloud Code 和 Codex 哪个好—— casually 打电话给世界最大公司之一的老板,先吵 10 分钟这个。

后来他说我「古怪但 brilliant」。我也跟 Sam Altman 聊过,他非常 thoughtful、brilliant,我很喜欢他。有人 vilify 他们俩,我觉得不公平。

Lex: 无论你在造什么,做大事都很 awesome。

Peter: 我超兴奋。而且 beauty 是:如果不行,我可以再自己做。我告诉他们:我不是为了钱,我他妈不在乎。

后续更新:

Peter Steinberger 在 X 平台官宣加入 OpenAI。他在长文中解释了自己的选择:
我将加入 OpenAI,致力于把智能体带给每一个人。OpenClaw 将转为基金会形式运作,并保持开源和独立。
关于为什么选择 OpenAI 而不是 Meta,Peter 写道:
当初开始探索 AI 时,我只是想玩得开心,也希望能激励他人。而现在,这只『龙虾』正在席卷世界。我的下一个目标,是打造一个连我妈妈都能轻松使用的智能体。
要实现这一点,需要更广泛的改变,需要更加深入地思考如何安全地去做,也需要接触最前沿的模型和研究成果。
我骨子里是个『建造者』。创办公司的那一套我已经经历过了,13 年的时间投入其中,也学到了很多。现在我想做的是改变世界,而不是再打造一家大公司。
与 OpenAI 合作,是把这一切带给更多人的最快方式。与他们深入交流后,我越来越清楚地意识到,我们拥有相同的愿景。
至此,这场激烈的 AI 人才争夺战尘埃落定,小扎抢人失败,奥特曼笑到了最后。

GPT Codex 5.3 vs Claude Opus 4.6:「一个太美国,一个太德国」

Lex: 聊聊这两个模型的区别。

Peter: 通用场景 Opus 最好。对 OpenClaw 来说,Opus 的角色扮演能力极强,真的能进入你给它的角色。它很擅长 follow commands。它通常很快会尝试 something,更偏向 trial and error。用起来很 pleasant。

Opus 有点……太美国了。这可能是个 bad analogy,你会被喷的。

Lex: 因为 Codex 是德国的?

Peter: 或者……Codex 团队很多是欧洲人。Anthropic 修复了一点——Opus 以前总说「You’re absolutely right」,我现在听到还 trigger。

另一个对比:Opus 像那个有点 silly 但很 funny 的同事,你留着。Codex 像角落里的怪人,你不想跟他说话,但可靠、能搞定事。

Lex: 这很准确。

Peter: 取决于你想要什么。两者都有空间,不会互相杀死。竞争是好事,差异化是好事。

「3 点后我切换成 vibe coding,然后第二天后悔」

Lex: 你用语音写代码?

Peter: 对,以前很 extensive,一度失声。

Lex: 你管这叫什么?vibe coding?

Peter: 我觉得把它叫做 vibe coding 是一种侮辱 (slur)。我认为是 「agentic engineering」。然后可能凌晨 3 点后,我切换成 vibe coding,第二天后悔。

Lex: 羞耻的 walk of shame。

Peter: 对,得清理烂摊子。

Lex: 我们都经历过。

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

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


复刻小红书Web端打开详情过渡动画

作者 Soulkey
2026年2月20日 16:11

小红书Web端效果展示

先看效果

浏览小红书Web端被这种丝滑的过渡吸引,因此想要复刻这种过渡效果。

首先想到就是利用FLIP动画实现

何为FLIP 动画?

一种动画范式,分为四步完成

First:记录动画元素的初始位置、状态

Last: 移动元素到最终位置,记录元素的最终位置、状态

Invert:计算差异并反向应用,让元素"看起来"还在初始位置

Play:通过动画过渡到最终状态

接下来通过小案例理解上述四步

案例1——方块移动

First:首先记录下元素的初始位置

// 1 First 记录初始状态
const first = box.getBoundingClientRect()

Last:执行DOM变化,并且记录下最终状态

if (isMoved) {
  box.classList.remove('moved')
} else {
  box.classList.add('moved')
}
isMoved = !isMoved
// 立即获取最终位置,此时元素已经在新的位置,但还没动画
const last = box.getBoundingClientRect()

此时元素的布局位置已经发生变化,但是由于浏览器没有渲染,因此页面上没有体现

Invert: 计算差异并反向应用

const deltaX = first.left - last.left
const deltaY = first.top - last.top
console.log('位置差异:', { deltaX, deltaY })

box.style.transform = `translate(${deltaX}px, ${deltaY}px)`
box.style.transition = 'none'

这一步是动画核心:在运用translate(deltaXpx,{deltaX}px, {deltaY}px) 元素已经在视觉上回到了原始位置。

因此用户打开浏览器看到的的方块依然在原地,其实已经经历了 位置左移——》translate回到原地,两个操作

那为啥用户看不到其中的变化呢?因为浏览器会聚合同步代码,放在一帧中渲染。

这也是FLIP动画非常绝妙的地方。

Play:执行动画

requestAnimationFrame(() => {
  box.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'
  box.style.transform = 'none'
})

通过box.style.transform = 'none' 让元素回到布局原点。

完整代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>FLIP案例1: 单元素移动</title>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      body {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
        background: lightgray;
        min-height: 100vh;
        display: flex;
        justify-content: center;
        align-items: center;
      }
      .container {
        text-align: center;
      }
      .move-btn {
        padding: 12px 24px;
        font-size: 16px;
        background: white;
        border: none;
        border-radius: 8px;
        cursor: pointer;
        box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
        margin-bottom: 40px;
        transition: transform 0.2s;
      }

      .move-btn:hover {
        transform: scale(1.05);
      }

      .move-btn:active {
        transform: scale(0.95);
      }

      .box {
        width: 120px;
        height: 120px;
        background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
        border-radius: 12px;
        display: flex;
        align-items: center;
        justify-content: center;
        color: white;
        font-size: 18px;
        font-weight: bold;
        box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
        margin-left: 0;
      }

      .box.moved {
        margin-left: calc(100vw - 200px);
      }
    </style>
  </head>
  <body>
    <div class="container">
      <button id="moveBtn" class="move-btn">点击移动方块</button>
      <div id="box" class="box">方块</div>
    </div>

    <script>
      const moveBtn = document.querySelector('#moveBtn')
      const box = document.querySelector('#box')

      let isMoved = false

      moveBtn.addEventListener('click', () => {
        // ========== FLIP动画的四个步骤 ==========

        // 1 First 记录初始状态
        const first = box.getBoundingClientRect()
        console.log('初始位置:', {
          left: first.left,
          top: first.top,
          width: first.width,
          height: first.height
        })

        // 2 Last 执行DOM变化并记录最终状态
        if (isMoved) {
          box.classList.remove('moved')
        } else {
          box.classList.add('moved')
        }

        isMoved = !isMoved

        // 立即获取最终位置,此时元素已经在新的位置,但还没动画
        const last = box.getBoundingClientRect()
        console.log('最终位置:', {
          left: last.left,
          top: last.top,
          width: last.width,
          height: last.height
        })

        // 3 Invert 计算差异并反向应用
        const deltaX = first.left - last.left
        const deltaY = first.top - last.top
        console.log('位置差异:', { deltaX, deltaY })
        // 此时元素已经被传回了原始位置
        box.style.transform = `translate(${deltaX}px, ${deltaY}px)`
        box.style.transition = 'none'

        // 4 Play 执行动画
        requestAnimationFrame(() => {
          box.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'
          box.style.transform = 'none'
        })

        // 动画结束 回收inline style
        box.addEventListener(
          'transitionend',
          function cleanup() {
            box.style.transition = ''
            box.style.transform = 'none'
            box.removeEventListener('transitionend', cleanup)
          },
          { once: true }
        )
      })
    </script>
  </body>
</html>

适用范围

肯定有人觉得不是直接通过translate移动就行了么?没错。这个案例只是让你了解FLIP动画的范式

FLIP动画有它自己的适用范围,例如:

  1. 列表排序/过滤:删掉一项后其他项自动补位,每项偏移量不同,你算不过来
  2. 布局切换:比如从网格视图切到列表视图,每个元素位置都变了

这些场景的共同点是:你改了 DOM 或 CSS 类之后,让浏览器布局引擎算出新位置,然后 FLIP 帮你把这个"瞬间跳变"变成平滑动画。

小红书过渡复刻

首先是页面静态样式

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>小红书页面切换动画</title>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      body {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
        background: #f5f5f5;
        padding: 20px;
      }

      h1 {
        text-align: center;
        font-size: 22px;
        color: #333;
        margin-bottom: 4px;
      }
      .tip {
        text-align: center;
        font-size: 13px;
        color: #999;
        margin-bottom: 20px;
      }
      /* ====== 卡片列表 - 最简单的flex排列 ====== */
      .grid {
        display: flex;
        flex-wrap: wrap;
        gap: 16px;
        justify-content: center;
      }

      /* ====== 卡片 ====== */
      .card {
        width: 220px;
        background: #fff;
        border-radius: 12px;
        overflow: hidden;
        cursor: pointer;
      }
      .card-image img {
        display: block;
        width: 100%;
      }
      .card-title {
        padding: 10px 12px;
        font-size: 13px;
        color: #333;
      }
      /* ====== 遮罩 ====== */
      .overlay {
        position: fixed;
        inset: 0;
        background: rgba(0, 0, 0, 0.65);
        z-index: 100;
        opacity: 0;
        pointer-events: none;
        transition: opacity 0.35s ease;
      }
      .overlay.visible {
        opacity: 1;
        pointer-events: auto;
      }
      /* ====== 详情弹窗 - 左图右文的简单布局 ====== */
      .detail {
        position: fixed;
        z-index: -1;
        background: #fff;
        border-radius: 12px;
        overflow: hidden;
        visibility: hidden;
      }
      .detail.visible {
        display: flex;
        z-index: 101;
        visibility: visible;
        inset: 0;
        margin: auto;
        width: fit-content;
        height: 600px;
      }
      /* 弹窗左侧 - 图片 */
      .detail-img {
        background: #f7f7f7;
        display: flex;
        align-items: center;
        justify-content: center;
      }

      .detail-img img {
        width: auto;
        max-width: 600px;
        height: 100%;
        object-fit: contain;
        display: block;
      }

      /* 弹窗右侧  */
      .detail-body {
        width: 0;
        padding: 24px;
        overflow-y: auto;
        transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
      }
      .detail-body.visible {
        width: 300px;
      }

      .detail-body h2 {
        font-size: 18px;
        color: #333;
        margin-bottom: 12px;
      }

      .detail-body p {
        font-size: 14px;
        color: #555;
        line-height: 1.8;
        white-space: pre-wrap;
      }

      /* 关闭按钮 */
      .close-btn {
        position: absolute;
        top: 12px;
        right: 12px;
        width: 30px;
        height: 30px;
        border-radius: 50%;
        border: none;
        background: rgba(0, 0, 0, 0.4);
        color: #fff;
        font-size: 18px;
        cursor: pointer;
        z-index: 10;
        display: flex;
        align-items: center;
        justify-content: center;
      }
    </style>
  </head>
  <body>
    <h1>小红书卡片展开动画</h1>
    <p class="tip">点击卡片,观察图片从列表位置平滑展开到弹窗的过渡效果</p>

    <!-- 卡片列表 动态插入 -->
    <div class="grid" id="grid"></div>

    <!-- 详情页 -->
    <!-- 遮罩 -->
    <div class="overlay" id="overlay"></div>
    <!-- 详情 -->
    <div class="detail" id="detail">
      <button class="close-btn" id="closeBtn">&times;</button>
      <div class="detail-img" id="detailImgWrapper">
        <img id="detailImgEl" src="" alt="" />
      </div>
      <div class="detail-body" id="detailBody">
        <h2 id="detailTitle"></h2>
        <p id="detailDesc"></p>
      </div>
    </div>

    <script>
      const cards = [
        {
          image: '../imgs/test.jpg',
          title: '春日穿搭分享',
          desc: '米色针织开衫搭配白色半身裙,\n既舒适又显气质。\n\n搭配要点:\n1. 柔和色调营造温柔感\n2. 针织材质增添春日气息\n3. 配饰简约,突出整体感'
        },
        {
          image: '../imgs/31-400x600.jpg',
          title: '咖啡拉花教程',
          desc: '在家制作拉花其实不难!\n\n步骤:\n1. 制作浓缩咖啡基底\n2. 打发牛奶至细腻光滑\n3. 从中心注入,控制流速\n4. 轻轻摇晃拉花缸'
        },
        {
          image: '../imgs/451-400x400.jpg',
          title: '周末野餐攻略',
          desc: '必带物品:\n- 防水野餐垫\n- 保温箱\n- 便携餐具\n- 遮阳伞\n\n食物推荐:\n三明治、水果拼盘、气泡水'
        },
        {
          image: '../imgs/507-400x550.jpg',
          title: '北欧风客厅改造',
          desc: '设计要点:\n1. 白灰为主色调\n2. 简洁线条家具\n3. 多层次照明\n4. 绿植增添生机\n\n总花费控制在15k以内'
        },
        {
          image: '../imgs/1008-400x520.jpg',
          title: '健康早餐食谱',
          desc: '推荐搭配:\n- 全麦面包 + 煎蛋 + 牛油果\n- 燕麦粥 + 坚果 + 蓝莓\n\n制作时间都在15分钟内!'
        },
        {
          image: '../imgs/825-400x650.jpg',
          title: '绝美日落合集',
          desc: '拍摄技巧:\n1. 日落前30分钟(黄金时段)\n2. 剪影构图\n3. 白平衡偏暖\n4. 低角度拍摄\n\n器材:手机就够了!'
        }
      ]
      const gridEl = document.querySelector('#grid')
      // 渲染卡片列表
      cards.forEach((card) => {
        const el = document.createElement('div')
        el.className = 'card'
        el.innerHTML = `
                <div class="card-image"><img src="${card.image}" alt=""></div>
                <div class="card-title">${card.title}</div>
              `
        el.addEventListener('click', () => open(el, card))
        gridEl.appendChild(el)
      })
    </script>
  </body>
</html>

这里注意详情页中图片使用object-fit: contain保障了横图或者竖图总能完整呈现

按步骤拆解

First:首先将详情页定位到点击的卡片图片处,并且长宽与图片一致

// 点击卡片的【封面图】
const innerCardEl = cardEl.querySelector('.card-image')
activeCardEl = innerCardEl
overlayEl.classList.add('visible') // 开启遮罩层
detailBodyEl.classList.add('visible') // 内容区展开

// 填充详情页内容
detailImgEl.src = cardData.image
detailTitleEl.textContent = cardData.title
detailDescEl.textContent = cardData.desc

// First - 记录卡片在页面中的位置
const firstRect = innerCardEl.getBoundingClientRect()

Last:移动DOM,并且记录下最终的状态

// Last - 让详情页以最终状态显示,获取最终位置
detailEl.classList.add('visible')
detailEl.offsetHeight
const lastRect = detailEl.getBoundingClientRect()

Invert:通过transform逆向移动到原始位置,让详情页看起来没用发生概念

// Invert - 从最终位置反推回卡片位置
const deltaX = firstRect.left - lastRect.left
const deltaY = firstRect.top - lastRect.top
const deltaW = firstRect.width / lastRect.width
const deltaH = firstRect.height / lastRect.height

detailEl.style.transformOrigin = 'top left'
detailEl.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})`

Play:开始动画

// Play - 动画回到最终位置
requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)'
    detailEl.style.transform = 'none'

    detailEl.addEventListener(
      'transitionend',
      () => {
        detailEl.style.transition = ''
        detailEl.style.transform = ''
        detailEl.style.transformOrigin = ''
      },
      { once: true }
    )
  })
})

关闭的过渡,就是打开的逆向过程

  function close() {
  if (!activeCardEl) return
  overlayEl.classList.remove('visible')
  
  // First - 详情页当前位置(居中状态)
  const firstRect = detailEl.getBoundingClientRect()
  
  // Last - 目标是回到卡片位置
  const lastRect = activeCardEl.getBoundingClientRect()
  
  // Invert - 从当前居中位置出发,计算到卡片位置的变换
  const deltaX = lastRect.left - firstRect.left
  const deltaY = lastRect.top - firstRect.top
  const deltaW = lastRect.width / firstRect.width
  const deltaH = lastRect.height / firstRect.height
  
  detailEl.style.transformOrigin = 'top left'
  detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)'
  detailEl.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})`
  
  detailEl.addEventListener(
    'transitionend',
    () => {
      detailEl.classList.remove('visible')
      detailBodyEl.classList.remove('visible')
      detailEl.style.transition = ''
      detailEl.style.transform = ''
      detailEl.style.transformOrigin = ''
      activeCardEl = null
    },
    { once: true }
  )
}

完整代码:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>小红书页面切换动画</title>
    <style>
      * {
        margin: 0;
        padding: 0;
        box-sizing: border-box;
      }

      body {
        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
        background: #f5f5f5;
        padding: 20px;
      }

      h1 {
        text-align: center;
        font-size: 22px;
        color: #333;
        margin-bottom: 4px;
      }
      .tip {
        text-align: center;
        font-size: 13px;
        color: #999;
        margin-bottom: 20px;
      }
      /* ====== 卡片列表 - 最简单的flex排列 ====== */
      .grid {
        display: flex;
        flex-wrap: wrap;
        gap: 16px;
        justify-content: center;
      }

      /* ====== 卡片 ====== */
      .card {
        width: 220px;
        background: #fff;
        border-radius: 12px;
        overflow: hidden;
        cursor: pointer;
      }
      .card-image img {
        display: block;
        width: 100%;
      }
      .card-title {
        padding: 10px 12px;
        font-size: 13px;
        color: #333;
      }
      /* ====== 遮罩 ====== */
      .overlay {
        position: fixed;
        inset: 0;
        background: rgba(0, 0, 0, 0.65);
        z-index: 100;
        opacity: 0;
        pointer-events: none;
        transition: opacity 0.35s ease;
      }
      .overlay.visible {
        opacity: 1;
        pointer-events: auto;
      }
      /* ====== 详情弹窗 - 左图右文的简单布局 ====== */
      .detail {
        position: fixed;
        z-index: -1;
        background: #fff;
        border-radius: 12px;
        overflow: hidden;
        visibility: hidden;
      }
      .detail.visible {
        display: flex;
        z-index: 101;
        visibility: visible;
        inset: 0;
        margin: auto;
        width: fit-content;
        height: 600px;
      }
      /* 弹窗左侧 - 图片 */
      .detail-img {
        background: #f7f7f7;
        display: flex;
        align-items: center;
        justify-content: center;
      }

      .detail-img img {
        width: auto;
        max-width: 600px;
        height: 100%;
        object-fit: contain;
        display: block;
      }

      /* 弹窗右侧  */
      .detail-body {
        width: 0;
        padding: 24px;
        overflow-y: auto;
        transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
      }
      .detail-body.visible {
        width: 300px;
      }

      .detail-body h2 {
        font-size: 18px;
        color: #333;
        margin-bottom: 12px;
      }

      .detail-body p {
        font-size: 14px;
        color: #555;
        line-height: 1.8;
        white-space: pre-wrap;
      }

      /* 关闭按钮 */
      .close-btn {
        position: absolute;
        top: 12px;
        right: 12px;
        width: 30px;
        height: 30px;
        border-radius: 50%;
        border: none;
        background: rgba(0, 0, 0, 0.4);
        color: #fff;
        font-size: 18px;
        cursor: pointer;
        z-index: 10;
        display: flex;
        align-items: center;
        justify-content: center;
      }
    </style>
  </head>
  <body>
    <h1>小红书卡片展开动画</h1>
    <p class="tip">点击卡片,观察图片从列表位置平滑展开到弹窗的过渡效果</p>

    <!-- 卡片列表 动态插入 -->
    <div class="grid" id="grid"></div>

    <!-- 详情页 -->
    <!-- 遮罩 -->
    <div class="overlay" id="overlay"></div>
    <!-- 详情 -->
    <div class="detail" id="detail">
      <button class="close-btn" id="closeBtn">&times;</button>
      <div class="detail-img" id="detailImgWrapper">
        <img id="detailImgEl" src="" alt="" />
      </div>
      <div class="detail-body" id="detailBody">
        <h2 id="detailTitle"></h2>
        <p id="detailDesc"></p>
      </div>
    </div>

    <script>
      const cards = [
        {
          image: '../imgs/test.jpg',
          title: '春日穿搭分享',
          desc: '米色针织开衫搭配白色半身裙,\n既舒适又显气质。\n\n搭配要点:\n1. 柔和色调营造温柔感\n2. 针织材质增添春日气息\n3. 配饰简约,突出整体感'
        },
        {
          image: '../imgs/31-400x600.jpg',
          title: '咖啡拉花教程',
          desc: '在家制作拉花其实不难!\n\n步骤:\n1. 制作浓缩咖啡基底\n2. 打发牛奶至细腻光滑\n3. 从中心注入,控制流速\n4. 轻轻摇晃拉花缸'
        },
        {
          image: '../imgs/451-400x400.jpg',
          title: '周末野餐攻略',
          desc: '必带物品:\n- 防水野餐垫\n- 保温箱\n- 便携餐具\n- 遮阳伞\n\n食物推荐:\n三明治、水果拼盘、气泡水'
        },
        {
          image: '../imgs/507-400x550.jpg',
          title: '北欧风客厅改造',
          desc: '设计要点:\n1. 白灰为主色调\n2. 简洁线条家具\n3. 多层次照明\n4. 绿植增添生机\n\n总花费控制在15k以内'
        },
        {
          image: '../imgs/1008-400x520.jpg',
          title: '健康早餐食谱',
          desc: '推荐搭配:\n- 全麦面包 + 煎蛋 + 牛油果\n- 燕麦粥 + 坚果 + 蓝莓\n\n制作时间都在15分钟内!'
        },
        {
          image: '../imgs/825-400x650.jpg',
          title: '绝美日落合集',
          desc: '拍摄技巧:\n1. 日落前30分钟(黄金时段)\n2. 剪影构图\n3. 白平衡偏暖\n4. 低角度拍摄\n\n器材:手机就够了!'
        }
      ]

      const detailHeight = 742 // 详情页固定高度

      const gridEl = document.querySelector('#grid')
      // 渲染卡片列表
      cards.forEach((card) => {
        const el = document.createElement('div')
        el.className = 'card'
        el.innerHTML = `
                <div class="card-image"><img src="${card.image}" alt=""></div>
                <div class="card-title">${card.title}</div>
              `
        el.addEventListener('click', () => open(el, card))
        gridEl.appendChild(el)
      })

      const overlayEl = document.querySelector('#overlay')
      const detailEl = document.querySelector('#detail')
      const detailImgEl = document.querySelector('#detailImgEl')
      const detailTitleEl = document.querySelector('#detailTitle')
      const detailDescEl = document.querySelector('#detailDesc')
      const closeBtnEl = document.querySelector('#closeBtn')
      const detailBodyEl = document.querySelector('#detailBody')

      let activeCardEl = null

      // 点击卡片打开详情
      function open(cardEl, cardData) {
        const innerCardEl = cardEl.querySelector('.card-image')
        activeCardEl = innerCardEl
        overlayEl.classList.add('visible')
        detailBodyEl.classList.add('visible')

        detailImgEl.src = cardData.image
        detailTitleEl.textContent = cardData.title
        detailDescEl.textContent = cardData.desc

        // First - 记录卡片在页面中的位置
        const firstRect = innerCardEl.getBoundingClientRect()

        // Last - 让详情页以最终状态显示,获取最终位置
        detailEl.classList.add('visible')
        detailEl.offsetHeight
        const lastRect = detailEl.getBoundingClientRect()

        // Invert - 从最终位置反推回卡片位置
        const deltaX = firstRect.left - lastRect.left
        const deltaY = firstRect.top - lastRect.top
        const deltaW = firstRect.width / lastRect.width
        const deltaH = firstRect.height / lastRect.height

        detailEl.style.transformOrigin = 'top left'
        detailEl.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})`

        // Play - 动画回到最终位置
        requestAnimationFrame(() => {
          requestAnimationFrame(() => {
            detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)'
            detailEl.style.transform = 'none'

            detailEl.addEventListener(
              'transitionend',
              () => {
                detailEl.style.transition = ''
                detailEl.style.transform = ''
                detailEl.style.transformOrigin = ''
              },
              { once: true }
            )
          })
        })
      }

      function close() {
        if (!activeCardEl) return
        overlayEl.classList.remove('visible')

        // First - 详情页当前位置(居中状态)
        const firstRect = detailEl.getBoundingClientRect()

        // Last - 目标是回到卡片位置
        const lastRect = activeCardEl.getBoundingClientRect()

        // Invert - 从当前居中位置出发,计算到卡片位置的变换
        const deltaX = lastRect.left - firstRect.left
        const deltaY = lastRect.top - firstRect.top
        const deltaW = lastRect.width / firstRect.width
        const deltaH = lastRect.height / firstRect.height

        detailEl.style.transformOrigin = 'top left'
        detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)'
        detailEl.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})`

        detailEl.addEventListener(
          'transitionend',
          () => {
            detailEl.classList.remove('visible')
            detailBodyEl.classList.remove('visible')
            detailEl.style.transition = ''
            detailEl.style.transform = ''
            detailEl.style.transformOrigin = ''
            activeCardEl = null
          },
          { once: true }
        )
      }

      closeBtnEl.addEventListener('click', close)
      overlayEl.addEventListener('click', close)
    </script>
  </body>
</html>

源码

gitee.com/soulkey3/fl…

你点了「保存」之后,数据都经历了什么?

作者 yuki_uix
2026年2月20日 15:25

你有没有思考过,当你在表单里输入一个名字,点击"提交",然后页面显示"保存成功"。这个过程中,数据经历了什么?

作为前端开发者,我们每天都在处理数据——从用户输入、API 请求到状态更新。但很少有人完整地思考过:数据从哪里来,到哪里去,中间经历了哪些变化?

问题的起源:为什么要关注数据生命周期?

从一个具体场景说起

想象这样一个场景:用户在购物网站修改收货地址。表面上看,这个过程很简单:

  1. 用户在表单中输入新地址
  2. 点击"保存"按钮
  3. 页面显示"保存成功"

但实际上呢?数据经历了什么?它只是从输入框"传送"到服务器吗?显然没那么简单。

在这个基本流程中,地址数据经历了:

  • 首先存在于 <input> 元素的 value 中
  • 被 React/Vue 的状态管理捕获
  • 通过 HTTP 请求发送到服务器
  • 在服务器端验证、处理后存入数据库
  • 返回客户端后更新组件的显示

即使是这个最简单的实现,数据也经历了多个阶段的流转。

如果需求更复杂,数据的旅程会更长:

  • 可以暂存到 LocalStorage 作为草稿(防止意外关闭页面)
  • 可能需要同步到其他打开的标签页(如果用户同时打开了多个页面)
  • 可能在移动端 App 下次启动时被拉取(如果是多端应用)

但这些都是可选的优化方案,而非必经之路。

数据流动的复杂性

当我开始梳理这个问题时,我发现数据流动有几个容易被忽视的特点:

1. 数据不是"一次性"的,它有状态变化

从用户输入到最终保存,数据会经历"草稿"、"待提交"、"已保存"等多个状态。在不同状态下,我们对数据的处理方式是不同的。

2. 数据不是"单一"的,它有多个副本

同一份数据可能同时存在于:

  • 组件的 state 中
  • 服务器的数据库中

如果应用有额外需求,还可能存在于:

  • 浏览器的 LocalStorage 里(用于草稿保存)
  • 服务端的 Redis 缓存里(用于性能优化)

如何保证这些副本之间的一致性?这是一个核心挑战。

3. 数据不是"孤立"的,它有依赖关系

修改用户地址后,可能需要同步更新:

  • 订单列表中的收货地址
  • 个人资料页的显示
  • 地址选择器的默认值

数据之间的依赖关系,决定了我们需要什么样的状态管理方案。

理解生命周期的价值

那么,为什么要花时间思考这些?我觉得有几个原因:

  • 选择合适的技术方案:理解数据的流动路径,才能知道在哪个环节使用什么技术
  • 避免数据不一致问题:当数据存在多个副本时,不一致是最常见的 bug 来源
  • 建立系统性思维:从"点"到"线"到"面",培养更宏观的思考习惯

接下来,我想从"数据生命周期"的角度,尝试梳理这个过程。

核心概念探索:数据的几个关键阶段

在我的理解中,数据在 Web 应用中大致会经历五个阶段:产生、存储、传输、更新、销毁。让我们逐一展开。

阶段一:数据产生

数据从哪里来?这个问题看似简单,但认真想想会发现有多个来源。

来源 1:用户输入

最直接的来源是用户的操作——在表单中输入文字、点击按钮、拖拽元素等。

// Environment: React
// Scenario: State update on user input

function UserForm() {
  const [name, setName] = useState('');
  
  const handleChange = (e) => {
    // The moment data is born
    // Extract from DOM event and store in component state
    setName(e.target.value);
  };
  
  return (
    <input 
      value={name} 
      onChange={handleChange} 
      placeholder="Enter your name"
    />
  );
}

这里有个有趣的细节:从用户按下键盘到 setName 执行,中间其实经历了浏览器事件系统的捕获、冒泡,React 的合成事件处理,以及状态调度机制。数据的"产生"并不是一个瞬间,而是一个过程。

来源 2:服务端获取

另一个常见来源是从服务器拉取数据——通过 API 请求、WebSocket 推送等方式。

// Environment: React + React Query
// Scenario: Fetch user info from server

function UserProfile() {
  const { data, isLoading } = useQuery('user', async () => {
    const response = await fetch('/api/user');
    return response.json();
  });
  
  if (isLoading) return <div>Loading...</div>;
  
  // Data is "born" from client's perspective
  return <div>Hello, {data.name}</div>;
}

这种场景下,数据在服务器端早已存在,但对于客户端来说,它是"新产生"的。

来源 3:本地计算

有些数据是通过计算得到的,比如派生状态(derived state)。

// Environment: React
// Scenario: Calculate derived data

function ShoppingCart({ items }) {
  // totalPrice is derived from items
  const totalPrice = items.reduce((sum, item) => {
    return sum + item.price * item.quantity;
  }, 0);
  
  return <div>Total: {totalPrice}</div>;
}

这让我开始思考:什么样的数据应该被存储?什么样的数据应该被计算?这是一个权衡——存储数据占用空间,计算数据消耗性能。

阶段二:数据存储

数据产生后,需要被存储在某个地方。根据存储位置的不同,数据的特性也不同。

位置 1:内存中的状态

最常见的是存储在组件的状态中,比如 React 的 state、Vue 的 data、或者 Zustand 这样的状态管理库。

// Environment: React
// Scenario: Component state management

function DraftEditor() {
  // Data lives in memory (component state)
  const [draft, setDraft] = useState({
    title: '',
    content: ''
  });
  
  return (
    <textarea 
      value={draft.content}
      onChange={(e) => setDraft({
        ...draft,
        content: e.target.value
      })}
    />
  );
}

特点:

  • 访问速度极快
  • 页面刷新后丢失
  • 只存在于当前设备的当前页面

适用场景:临时的 UI 状态、待提交的表单数据。

位置 2:浏览器存储

如果希望数据在页面刷新后仍然存在,可以使用 LocalStorage、SessionStorage 或 IndexedDB。

// Environment: Browser
// Scenario: Save draft to LocalStorage

function saveDraft(draft) {
  // Persist to browser storage
  localStorage.setItem('draft', JSON.stringify(draft));
}

function loadDraft() {
  const saved = localStorage.getItem('draft');
  return saved ? JSON.parse(saved) : null;
}

特点:

  • 页面刷新后依然存在
  • 只在当前浏览器/设备可访问
  • 容量有限(通常 5-10MB)

适用场景:用户偏好设置、离线数据、表单草稿。

位置 3:服务端存储

如果数据需要在多个设备间共享,或者需要永久保存,就要存储到服务器端。

// Environment: Browser
// Scenario: Submit data to server

async function saveToServer(data) {
  const response = await fetch('/api/save', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
  
  if (!response.ok) {
    throw new Error('Save failed');
  }
  
  return response.json();
}

特点:

  • 多端访问、永久保存
  • 需要网络请求(有延迟)
  • 可以进行复杂的业务逻辑处理

适用场景:用户资料、订单记录、文章内容等核心业务数据。

服务端还可能使用 Redis 等缓存层来优化性能,但这属于服务端架构的范畴,对前端来说通常是透明的。

思考:一份数据的多个副本

在实际开发中,一份数据经常会同时存在于多个位置:

// Environment: React
// Scenario: Data storage across multiple layers

function UserEditor() {
  // Layer 1: In-memory state (temporary)
  const [formData, setFormData] = useState({
    name: '',
    email: ''
  });
  
  // Layer 2: Save draft to browser storage (optional, prevent data loss)
  useEffect(() => {
    localStorage.setItem('userDraft', JSON.stringify(formData));
  }, [formData]);
  
  // Layer 3: Submit to server (required, persistence)
  const handleSubmit = async () => {
    await fetch('/api/user', {
      method: 'POST',
      body: JSON.stringify(formData)
    });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {/* Form content */}
    </form>
  );
}

这里的问题是:如何保证这些副本的一致性?这是我在实际开发中经常遇到的挑战。

阶段三:数据传输

数据不会一直待在同一个地方,它需要在不同位置间流动。

场景 1:组件间传输

在 React 中,最常见的是父子组件间通过 props 传递数据。

// Environment: React
// Scenario: Parent-child data passing

// Parent component
function App() {
  const [user, setUser] = useState({ name: 'Zhang San', age: 18 });
  
  return (
    <div>
      {/* Pass data down via props */}
      <UserCard user={user} />
      <UserEditor user={user} onChange={setUser} />
    </div>
  );
}

// Child component
function UserCard({ user }) {
  // Receive props
  return <div>{user.name}</div>;
}

这是最简单的数据流动方式,但当组件层级变深时,就会遇到"prop drilling"的问题——需要一层层往下传递。

场景 2:跨组件传输

对于跨层级的组件,可以使用 Context、状态管理库或事件总线。

// Environment: React + Context
// Scenario: Cross-level data sharing

const UserContext = createContext();

function App() {
  const [user, setUser] = useState({ name: 'Zhang San' });
  
  return (
    <UserContext.Provider value={{ user, setUser }}>
      {/* Any deeply nested child can access user */}
      <DeepNestedComponent />
    </UserContext.Provider>
  );
}

function DeepNestedComponent() {
  const { user } = useContext(UserContext);
  return <div>{user.name}</div>;
}

场景 3:客户端与服务端传输

这是最常见也最复杂的数据传输场景。

// Environment: Browser
// Scenario: Client-server data exchange

// Client -> Server
async function submitForm(data) {
  const response = await fetch('/api/submit', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data)
  });
  return response.json();
}

// Server -> Client
async function fetchData() {
  const response = await fetch('/api/data');
  return response.json();
}

这里有个微妙的点:数据在网络传输时,必须被序列化(serialize)成字符串。JavaScript 对象 → JSON 字符串 → 服务器接收 → 解析成对象,这个过程中,某些类型(比如 Date、Function)会丢失。

数据流向的可视化

graph TD
    A[用户输入] --> B[组件 State]
    B --> C{需要持久化?}
    C -->|否| D[仅内存存储]
    C -->|是| E[LocalStorage]
    C -->|是| F[服务器]
    F --> G[数据库]
    G --> H[其他设备拉取]
    E --> I[页面刷新后恢复]

阶段四:数据更新

数据很少是一成不变的,它会随着用户操作或服务器推送而更新。

方式 1:不可变更新 vs 直接修改

这是前端状态管理中最核心的概念之一。

// Environment: React
// Scenario: Two ways to update state

function TodoList() {
  const [todos, setTodos] = useState([
    { id: 1, text: 'Learn React' }
  ]);
  
  // ❌ Direct mutation (not recommended in React, won't trigger re-render)
  const badUpdate = () => {
    todos[0].text = 'Learn Vue';
    setTodos(todos); // React thinks todos hasn't changed
  };
  
  // ✅ Immutable update (create new object)
  const goodUpdate = () => {
    setTodos(todos.map(todo => 
      todo.id === 1 
        ? { ...todo, text: 'Learn Vue' }
        : todo
    ));
  };
  
  return (
    <div>
      <button onClick={goodUpdate}>Update</button>
    </div>
  );
}

为什么 React 要求不可变更新?我的理解是:

  1. 便于追踪变化(通过引用比较,而非深度遍历)
  2. 支持时间旅行调试
  3. 避免意外的副作用

方式 2:乐观更新 vs 悲观更新

在客户端-服务端交互中,更新策略也很重要。

// Environment: React + React Query
// Scenario: Two update strategies

// Pessimistic: Wait for server response before updating UI
function pessimisticUpdate() {
  const mutation = useMutation(updateUser, {
    onSuccess: (newData) => {
      // Update local state only after server responds
      queryClient.setQueryData('user', newData);
    }
  });
}

// Optimistic: Update UI immediately, rollback on failure
function optimisticUpdate() {
  const mutation = useMutation(updateUser, {
    onMutate: async (newData) => {
      // Cancel in-flight queries
      await queryClient.cancelQueries('user');
      
      // Save old data for rollback
      const previous = queryClient.getQueryData('user');
      
      // Update UI immediately
      queryClient.setQueryData('user', newData);
      
      return { previous };
    },
    onError: (err, newData, context) => {
      // Rollback on failure
      queryClient.setQueryData('user', context.previous);
    },
    onSuccess: () => {
      // Refetch to ensure data sync
      queryClient.invalidateQueries('user');
    }
  });
}

乐观更新的好处是体验更好(无需等待),但代价是增加了复杂度——需要处理失败回滚、冲突解决等问题。

阶段五:数据销毁

数据不会永远存在,它也有消失的时候。

场景 1:组件卸载

当 React 组件被卸载时,组件内的 state 会被自动清理。

// Environment: React
// Scenario: Cleanup on component unmount

function DataSubscriber() {
  const [data, setData] = useState(null);
  
  useEffect(() => {
    // Subscribe to data source
    const subscription = dataSource.subscribe(setData);
    
    return () => {
      // Cleanup on unmount
      subscription.unsubscribe();
      console.log('Data cleaned up, preventing memory leak');
    };
  }, []);
  
  return <div>{data}</div>;
}

如果忘记清理,就会导致内存泄漏——组件虽然已经销毁,但订阅还在后台运行。

场景 2:缓存失效

浏览器存储的数据通常有生命周期。

// Environment: Browser
// Scenario: Cache with expiration time

function cacheWithExpiry(key, data, ttl) {
  const item = {
    data,
    expiry: Date.now() + ttl
  };
  localStorage.setItem(key, JSON.stringify(item));
}

function getCachedData(key) {
  const cached = localStorage.getItem(key);
  if (!cached) return null;
  
  const item = JSON.parse(cached);
  
  // Check if expired
  if (Date.now() > item.expiry) {
    localStorage.removeItem(key);
    return null; // Data is "destroyed"
  }
  
  return item.data;
}

场景 3:用户登出

出于安全考虑,用户登出时应该清理敏感数据。

// Environment: Browser
// Scenario: Cleanup on logout

function logout() {
  // Clear in-memory state
  clearUserState();
  
  // Clear browser storage
  localStorage.removeItem('token');
  localStorage.removeItem('userInfo');
  
  // Clear Service Worker cache
  if ('serviceWorker' in navigator) {
    caches.delete('user-data');
  }
  
  // Redirect to login page
  window.location.href = '/login';
}

实际场景思考:用一个完整例子串联起来

让我们通过一个具体场景,把上面的概念串联起来。

场景:用户修改个人资料

这是一个典型的 CRUD 操作,但其中的数据流动比想象中复杂。

// Environment: React + React Query + TypeScript
// Scenario: Complete flow of editing user profile

interface User {
  id: string;
  name: string;
  email: string;
}

function ProfileEditor() {
  // 1. Data creation: Fetch current user info from server
  const { data: user, isLoading } = useQuery<User>(
    'user',
    fetchUserProfile
  );
  
  // 2. Data storage: Temporarily store in component state
  const [formData, setFormData] = useState<User | null>(null);
  
  // Initialize form when user data loads
  useEffect(() => {
    if (user) {
      setFormData(user);
      // Optional: Save to LocalStorage as draft
      localStorage.setItem('profileDraft', JSON.stringify(user));
    }
  }, [user]);
  
  // 3. Data update: Handle user input
  const handleChange = (field: keyof User, value: string) => {
    if (!formData) return;
    
    // Immutable update
    setFormData({
      ...formData,
      [field]: value
    });
  };
  
  // 4. Data transmission: Submit to server
  const queryClient = useQueryClient();
  const mutation = useMutation(
    (newData: User) => updateUserProfile(newData),
    {
      // Optimistic update
      onMutate: async (newData) => {
        // Cancel in-flight queries
        await queryClient.cancelQueries('user');
        
        // Save old data for rollback
        const previousUser = queryClient.getQueryData<User>('user');
        
        // Update UI immediately
        queryClient.setQueryData('user', newData);
        
        return { previousUser };
      },
      
      // Rollback on error
      onError: (err, newData, context) => {
        if (context?.previousUser) {
          queryClient.setQueryData('user', context.previousUser);
        }
        alert('Save failed, please retry');
      },
      
      // Refetch on success
      onSuccess: () => {
        queryClient.invalidateQueries('user');
        
        // Clear draft
        localStorage.removeItem('profileDraft');
        
        // Notify other tabs (using BroadcastChannel)
        const channel = new BroadcastChannel('user-updates');
        channel.postMessage({ type: 'profile-updated' });
        channel.close();
        
        alert('Saved successfully!');
      }
    }
  );
  
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (formData) {
      mutation.mutate(formData);
    }
  };
  
  if (isLoading) return <div>Loading...</div>;
  if (!formData) return <div>Load failed</div>;
  
  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={formData.name}
        onChange={(e) => handleChange('name', e.target.value)}
        placeholder="Name"
      />
      <input
        type="email"
        value={formData.email}
        onChange={(e) => handleChange('email', e.target.value)}
        placeholder="Email"
      />
      <button type="submit" disabled={mutation.isLoading}>
        {mutation.isLoading ? 'Saving...' : 'Save'}
      </button>
    </form>
  );
}

// API functions
async function fetchUserProfile(): Promise<User> {
  const response = await fetch('/api/user/profile');
  if (!response.ok) throw new Error('Fetch failed');
  return response.json();
}

async function updateUserProfile(user: User): Promise<User> {
  const response = await fetch('/api/user/profile', {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(user)
  });
  if (!response.ok) throw new Error('Update failed');
  return response.json();
}

这个流程中的数据状态变化

让我们追踪一下数据在这个过程中的状态:

  1. 初始状态:数据存在于服务器数据库中
  2. 加载状态:通过 HTTP GET 请求,数据被传输到客户端
  3. 缓存状态:React Query 将数据缓存在内存中
  4. 编辑状态:用户修改时,数据存在于组件 state 和 LocalStorage
  5. 同步状态:提交时,乐观更新立即修改 UI
  6. 确认状态:服务器响应后,确认或回滚
  7. 广播状态:通过 BroadcastChannel,通知其他标签页

在这个过程中,数据经历了至少 7 次状态变化,存在于 4 个不同的位置(组件 state、LocalStorage、内存缓存、服务器)。

可能出现的问题

这个流程看似完美,但在实际中可能遇到的问题:

问题 1:网络请求失败

  • 乐观更新已经修改了 UI,用户看到了新数据
  • 但服务器请求失败,需要回滚
  • 用户可能已经切换到其他页面,如何处理?

问题 2:多标签页冲突

  • 用户在两个标签页同时修改资料
  • 标签页 A 提交成功,标签页 B 不知道
  • 标签页 B 再次提交,覆盖了 A 的修改

问题 3:数据不一致

  • LocalStorage 中的草稿与服务器数据不一致
  • 用户刷新页面,应该优先使用哪份数据?

这些问题没有标准答案,需要根据具体场景权衡。

延伸与发散

在梳理数据生命周期的过程中,我产生了一些新的思考。

客户端数据 vs 服务端数据

我觉得这是两种本质不同的数据:

客户端数据

  • 临时性:页面刷新即消失(除非持久化)
  • 单一性:只存在于当前设备
  • 示例:表单草稿、折叠面板的展开状态、滚动位置

服务端数据

  • 持久性:需要主动删除才消失
  • 共享性:多端访问同一份数据
  • 示例:用户资料、订单记录、文章内容

React Query 和 SWR 为什么要区分对待服务端状态?我的理解是:服务端数据有其特殊性——它可能在客户端不知情的情况下被修改,所以需要缓存、重新验证、自动刷新等机制。

这让我想到一个问题:在 Next.js App Router 的服务端组件中,数据是在服务端获取的,它算客户端数据还是服务端数据?

数据流的"单向"与"双向"

React 坚持单向数据流,Vue 支持双向绑定,这背后的设计哲学是什么?

单向数据流(React、Redux):

  • 数据变化可预测,容易追踪
  • 适合复杂应用的状态管理
  • 代价是代码量大,需要手动处理双向同步

双向绑定(Vue v-model、Angular):

  • 代码简洁,开发效率高
  • 数据流向难追踪,容易产生意外的副作用
  • 适合表单密集型应用

有趣的是,Vue 3 的 Composition API 似乎在向单向数据流靠近,提供了更细粒度的控制。这是框架设计的趋同吗?

待探索的问题

这篇文章只是一个起点,还有很多问题值得深入:

  1. 缓存失效策略:如何设计一个高效的缓存失效策略?stale-while-revalidate 是最佳方案吗?
  2. 分布式一致性:在分布式系统中,如何保证数据的最终一致性?
  3. 离线优先:Offline-first 应用如何实现数据的冲突解决?
  4. 实时同步:WebSocket 和 Server-Sent Events 在实时数据同步中各有什么优劣?

小结

这篇文章更多是我个人的思考过程,而非标准答案。

回顾一下,我的核心收获是:

  1. 数据有生命周期:产生 → 存储 → 传输 → 更新 → 销毁,每个阶段都有不同的技术选择
  2. 数据有多个副本:同一份数据可能存在于多个位置,保持一致性是核心挑战
  3. 数据有状态变化:理解数据的状态机,有助于设计更健壮的系统

但这只是一个框架性的思考,真正的细节还需要在实际开发中不断体会。

  • 在你的项目中,数据流动的最大痛点是什么?
  • 有没有遇到过数据不一致的 bug?是怎么解决的?
  • 如果让你设计一个状态管理库,你会怎么考虑数据的生命周期?

参考资料

【从零开始学习Vue|第六篇】生命周期

作者 猪头男
2026年2月20日 15:21

1. 前置知识

a. 数据观测(Data Observer)

  • 在 Vue 2 中,这叫 Object.defineProperty;在 Vue 3 中,这叫 Proxy
  • 它的作用是:当你修改数据时,Vue 能立刻知道,并自动更新页面。

比如说没有输据观测时

let data = { count: 0 };
data.count = 1; 
// 👉 Vue 完全不知道 count 变了,页面也不会更新。
// 就像你把东西藏起来了,没人看见。

有数据观测时:Vue 会把你的对象包裹一层,变成这样(简化版):

let data = new Proxy({ count: 0 }, {
  set(target, key, value) {
    console.log(`嘿!有人把 ${key}${target[key]} 改成了 ${value}`);
    target[key] = value;
    triggerUpdate(); // 🔔 触发页面更新!
    return true;
  }
});

data.count = 1; 
// 👉 控制台打印:"嘿!有人把 count 从 0 改成了 1"
// 👉 页面自动刷新显示 1。

b. Event/Watcher 事件配置

通俗理解:注册“监听器”和“回调函数”。

这指的是你在组件里写的:

  1. watch:监听某个数据变化,执行特定逻辑。
  2. 自定义事件:组件间通信的 $emit / $on (Vue 2) 或 defineEmits (Vue 3)。
  3. 方法绑定:把 methods 里的函数绑定到实例上。

这就是setup()的优势了,在Vue2中,beforeCreate是Event/Watcher事件配置之前调用的,会出现调用beforeCreate实例的时候,压根没有watch,methods等方法,出现bug

  • setup() 函数的执行时机,大致相当于 beforeCreate + created 的结合体。
  • 你在 setup 里定义的 refreactive定义即观测,不需要等待后续步骤。
  • 你在 setup 里写的逻辑,天然就能访问到数据。避免了bug的出现

c. 关于vue的模版编译:预编译和即时编译

ⅰ. ****模板编译是什么?

Vue 写的模板(template)浏览器是看不懂的,需要转换成 JavaScript 的 渲染函数(render function) 才能执行。

template (你写的)  →  [编译]  →  render function (浏览器能执行)

这个编译过程可以在两个时间点进行:

ⅱ. 预编译模版(Pre-compiled)

在打包构建阶段(开发时),就把 template 转换成 render function,浏览器拿到的是已经编译好的代码。

这其实就是用了vite/webpack构建工具编译的

开发阶段 (你的电脑)              用户浏览器
     ↓                              ↓
写 template  →  构建工具编译  →  下发 render 函数
              (Vite/Webpack)

ⅲ. 即时编译模版(Reunime Compilation)

在浏览器运行时,Vue 拿到 template 字符串后,现场编译成 render function 再执行。

这其实就是引入CDN的方式

开发阶段 (你的电脑)              用户浏览器
     ↓                              ↓
写 template  →  直接下发 template  →  浏览器现场编译 →  执行
                                    (Vue 编译器)

ⅳ. 区别

特性 预编译 即时编译
编译时间 开发/构建时 浏览器运行时
需要构建工具 ✅ 需要 (Vite/Webpack) ❌ 不需要
运行时性能 🚀 快 🐌 慢
包体积 📦 小 (不含编译器) 📦 大 (含编译器)
动态模板 ❌ 不支持 ✅ 支持
使用场景 大多数项目 CDN/在线编辑器/低代码

2. 基础说明以及如何注册周期钩子

每个 Vue 组件实例在创建时都需要经历一系列的初始化步骤,比如设置好数据侦听,编译模板,挂载实例到 DOM,以及在数据改变时更新 DOM。在此过程中,它也会运行被称为生命周期钩子的函数,让开发者有机会在特定阶段运行自己的代码。

2.1. 注册周期钩子

举例来说,onMounted 钩子可以用来在组件完成初始渲染并创建 DOM 节点后运行代码:

<script setup>
import { onMounted } from 'vue'

onMounted(() => {
  console.log(`the component is now mounted.`)
})
</script>

还有其他一些钩子,会在实例生命周期的不同阶段被调用。

当调用 onMounted 时,Vue 会自动将回调函数注册到当前正被初始化的组件实例上。这意味着这些钩子应当在组件初始化时被同步注册。

onMounted() 不一定要直接写在 <script setup> 的最外层,可以封装到函数里调用,只要这个函数是在 setup同步调用的就行。

2.2. 周期钩子的写法说明

  1. 直接在setup中调用
<script setup>
import { onMounted } from 'vue'

// 直接写在 setup 顶层
onMounted(() => {
  console.log('组件已挂载')
})
</script>

2. 在外部函数中调用

<script setup>
import { onMounted } from 'vue'

// 定义一个外部函数
function registerHooks() {
  onMounted(() => {
    console.log('组件已挂载')
  })
}

// 在 setup 中同步调用这个函数
registerHooks()
</script>

3. 封装成自定义HooK

<!-- hooks/useMountLog.js -->
import { onMounted } from 'vue'

export function useMountLog(message) {
  onMounted(() => {
    console.log(message)
  })
}

<!-- 组件中使用 -->
<script setup>
import { useMountLog } from './hooks/useMountLog'

// 调用自定义 Hook
useMountLog('用户组件已挂载')
</script>

4. 错误写法(异步调用)

<script setup>
import { onMounted } from 'vue'

// 错误:异步调用,调用栈断了
setTimeout(() => {
  onMounted(() => {
    console.log('这不会工作')
  })
}, 1000)

// 错误:在 Promise 回调中调用
someAsyncFunction().then(() => {
  onMounted(() => {
    console.log('这也不会工作')
  })
})
</script>

3. 生命周期图示

3.1. 阶段一:初始化与创建

  • 流程: 渲染器遇到组件setup / beforeCreate初始化选项式 APIcreated

  1. 渲染器遇到组件:Vue 开始在页面上处理这个组件。
  2. Setup / BeforeCreate
  • setup() (组合式 API):这是 Vue 3 的入口点,最早执行。在这里定义响应式数据和方法。
  • beforeCreate (选项式 API):在实例初始化之后,数据观测 (data observer) 和 event/watcher 事件配置之前被调用。
  • 注意:在 Vue 3 中,通常只用 setup ,它涵盖了 beforeCreate created 的功能。
  1. 初始化选项式 API:如果你使用了传统的 data, methods 等写法,这里会进行初始化。
  2. Created
    • created:实例已经创建完成。此时数据观测、属性和方法的运算、watch/event 事件回调都已完成。但是,此时还没有挂载到页面上(DOM 还不存在),所以不能操作 DOM 元素。

3.2. 阶段二:编译与挂载

流程: 判断模板beforeMount初始渲染 (创建 DOM)mounted

  1. 是否存在预编译模板?
    • NO:如果是运行时编译(比如在浏览器里直接写 template),需要先即时编译模板
    • YES:如果是构建工具(如 Vite/Webpack)预编译好的 render 函数,直接使用。
  1. BeforeMount
    • beforeMount:在挂载开始之前被调用。相关的 render 函数首次被调用。此时页面还是旧的(或者空的)。
  1. 初始渲染 (创建和插入 DOM 节点) :Vue 根据模板生成真实的 HTML 元素,并插入到页面中。
  2. Mounted
    • mounted关键节点! 组件已经挂载到页面上,DOM 已经生成
    • 应用场景:如果你需要操作 DOM(比如初始化图表、获取元素宽高、发起网络请求),通常放在这里(或者 onMounted)。

3.3. 阶段三:更新循环

流程: 数据变化beforeUpdate重新渲染并打补丁updated

这是一个循环过程,只要组件里的响应式数据发生变化,就会触发:

  1. 当数据变化时:你修改了 refreactive 中的数据。
  2. BeforeUpdate
    • beforeUpdate:数据更新时调用,发生在虚拟 DOM 打补丁之前。此时你可以访问到更新前的 DOM 状态。
  1. 重新渲染并打补丁:Vue 对比新旧虚拟 DOM,计算出最小的差异(Diff 算法),然后高效地更新真实 DOM。
  2. Updated
    • updated:由于数据更改导致的虚拟 DOM 重新渲染和打补丁之后调用。此时 DOM 已经是更新后的状态了。
    • 注意:尽量避免在 updated 中修改数据,否则可能导致无限循环更新。

3.4. 阶段四:卸载/销毁

流程: 组件被取消挂载beforeUnmount取消挂载unmounted

  1. 当组件被取消挂载时
  2. BeforeUnmount (Vue 3) / beforeDestroy (Vue 2):
    • beforeUnmount:在卸载组件实例之前调用。此时实例还完全可用。
    • 应用场景:清除定时器、解绑全局事件监听器、断开 WebSocket 连接等清理工作。
  1. 取消挂载:Vue 移除组件对应的 DOM 元素。
  2. Unmounted (Vue 3) / destroyed (Vue 2):
    • unmounted:组件已卸载。所有指令都被解绑,所有事件监听器被移除,所有子实例也被销毁。

3.5. 选项式API 组合式API 生命周期区别

选项式 API (图中红色框) 组合式 API (<script setup>) 最佳使用时机
beforeCreate / created setup() 初始化数据、方法
beforeMount onBeforeMount 很少用到
mounted onMounted 操作 DOM、发请求 (最常用)
beforeUpdate onBeforeUpdate 获取更新前的 DOM
updated onUpdated 获取更新后的 DOM
beforeUnmount onBeforeUnmount 清理副作用 (定时器/监听器)
unmounted onUnmounted 彻底清理

春晚机器人炸翻外网!老外逐帧研究真假:直呼 amazing

作者 莫崇宇
2026年2月17日 17:20

以武会春,宇树春晚机器人马年秀出“赛博真功夫” - 产业家

看完昨晚的春晚,我只能说 Papi 酱当初还是保守了——这何止是加了 100 个机器人?

不知道的,还以为是机器人在开年会。

但最让人没想到的是,春晚机器人节目的效果却是十分炸裂。

当身披金甲的 H2 机器人,手持金箍棒,踩着由一群机器狗组成的「筋斗云」丝滑入场、当一堆宇树 G1 机器人大秀武术后,一大堆切片视频正在海外社交媒体疯传。

老外们此时此刻的状态 be like:一边怀疑自己的眼睛,一边拿着放大镜逐帧观赏今年的机器人春晚节目。

毫不夸张地说,不光是我们没见过这个阵仗,老外的 CPU 也是被干烧了。

比如网友 @ForeverStar2045 就对着屏幕陷入了沉思,直接发推 @ Grok(马斯克的 AI 机器人):「@grok 这是真的视频,还是 AI 生成的视频?」

另一位叫 @Tesla_Dawg 的老哥更是斩钉截铁,贴出一张截图说:

「看起来还是像 CGI 生成的。」

好好好,只能说看似质疑,实则表扬,宇树机器人春晚项目员工的超级年终奖怕是稳了。

最扎心的评论来自一位练武术的老哥:「多年苦练武术,结果一个该死的机器人,完全没受过任何训练,居然比我练得还好……一个有趣的、反乌托邦式的未来正等待着我们。」

网友 @ligbill 更是激动到语无伦次:「疯狂升级!这是赛博朋克与古老神话的完美融合。迫不及待想看看 2027 年会带来什么——机器人悟空对战全世界?」

那么问题来了,为什么老外这次反应这么大?

因为对比太惨烈了,回想一下,2021 年那会儿,24 只机器狗「犇犇」只会磕头拜年,到了 2025 年,H1 学会了扭秧歌,但还是颤颤巍巍,堪比 80 岁老大爷,而今年这个《武 BOT》,直接把难度拉到了地狱级,主打一个健步如飞,武功高强。

要知道,前后空翻,还有跑酷上墙、灵巧手舞剑等等,这种大规模的集群控制,最怕的就是延迟。

几十台 G1 机器人同步对线,只要有一台胳膊伸慢了,或者网络稍微波动一下导致倒地,分分钟就是直播事故,估计得在热搜上从大年初一挂到正月十五。

也难怪网友 @Manki_69 会忍不住问:「这是远程协助;他们用的是什么网络才能连接所有这些设备而不出现延迟?」

对此,科技博主 @Learnyst 表示:「人形机器人能够自主完成复杂动作,说明了一件事:运动智能、协同能力和控制技术正进入一个更加成熟的新阶段。」

其实不光是宇树机器人,像众擎机器人、智元机器人等各种视频切片在 X 上也都是满天飞,让老外直呼「amazing」。

停之停之,看到这我知道你开始说这些表演也就是图一乐呵,对此,宇树科技创始人王兴兴表示,这些表演不仅是为了展示,更是为未来机器人在集群作业、单点调度等实际场景中的应用奠定基础。

昨天在接受央视春晚的采访时,王兴兴还独家揭秘了《武 BOT》的「练功秘籍」。报道称,为了这的两项,春晚节目组和宇树科技的团队一同打磨了多个机器人「全球首创」动作:

  • 全球第一次连续花式翻桌跑酷
  • 全球第一次弹射空翻,空翻最大高度大于 3 米
  • 全球第一次单脚连续空翻,两步蹬墙后空翻
  • 全球第一次 Airflare 大回旋七周半
  • 全球第一次集群快速跑位(最快任意跑位速度可达4m/s)
  • 并且搭载全新自研灵巧手,支持武术道具的快速更换与稳定抓持

王兴兴还介绍称,那个让人捏把汗的弹射起飞,其实是让人形机器人跳上定制的弹射器,然后就可以「跳」到 2 至 3 米高,并在空中完成正空翻及侧空翻动作后平稳落地。

并且,要想实现几十台机器人实时协同动作,需要超低同步延迟,其中包括 AI 算法结合 3D 激光雷达,并攻克长序列表演中运动误差累计难题,才有了这次全球首次实现全自主人形机器人集群武术表演(带复杂快速跑位)。

而这种技术上的降维打击也引发了硅谷式的反思。美国老哥 @Anto Patrex 彻底破防了,他发出了直击灵魂的拷问:

「为什么美国那些投入数十亿美元的研究实验室和机器人初创公司花了十多年时间研究这项技术……而 Unitree Robotics 却用远少得多的资金就实现了?」

波士顿动力内心 OS:勿 cue。

当然,想要上春晚除了要有硬实力,还得有钞能力。之前就有报道称为了争夺这次春晚机器人最大赞助商权益,宇树把报价推到了 1 个亿,从目前来看,回报可以说是远远大于一个亿。

而机器人扎堆上春晚的逻辑,其实不难理解。

看看大洋彼岸的「超级碗」,那可是美国广告界的春晚,今年海外的 AI 公司们为了几十秒的广告位,那是真金白银地往里砸,只为了在大众面前混个脸熟。

在中国,春晚就是那个必须要拿下的「超级碗时刻」。而翻看今年春晚的合作伙伴名单,这个趋势尤为明显——「含硅量」正在取代「含酒量」:

  • 机器人军团:除了宇树科技,还有松延动力、魔法原子、银河通用,甚至连天上飞的亿航智能都来了。
  • AI 军团:火山引擎拿下了独家 AI 云合作,豆包负责互动抽奖,蚂蚁阿福亮相。
  • 智能生活:追觅、华为 Mate80、鸿蒙智行、尊界 S800、极氪科技排着队亮相。

清一色 AI、机器人、新能源汽车,今年春晚的风头完全被这帮搞代码、搞机器人的抢光了。所以,也难怪网友会感叹:「春节正迅速成为 AI 研究人员最喜爱的节日」。

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

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


突发!OpenClaw之父宣布加入OpenAI,小扎抢人失败

作者 莫崇宇
2026年2月16日 10:23

就在刚刚,OpenClaw 开发者 Peter Steinberger 在 X 平台官宣加入 OpenAI。

他还发了一篇长文解释自己的选择。用他自己的话说:「我将加入 OpenAI,致力于把智能体带给每一个人。OpenClaw 将转为基金会形式运作,并保持开源和独立。」

这个结局,某种程度上也算是意料之中。

此前就有消息称,OpenAI 不仅想把 Peter 本人挖过来,连带着维护这款开源 Agent(智能体)项目的几位核心成员也要一锅端。谈判的条件相当诱人——让他们在 OpenAI 负责个人智能体相关工作,顺便参与其他产品开发,甚至还在讨论成立一个基金会专门运营 OpenClaw 开源项目。

不过在最终拍板之前,除了 OpenAI,Meta 同样在疯狂发力。毕竟现在 AI 人才争夺战打得火热,而个人智能体又被各家列为重点方向,谁都不想落后。

上周 Peter Steinberger 就在 Lex Fridman 的播客时爆料,现在每个月自掏腰包 1 万到 2 万美元维持 OpenClaw 运营,同时正在跟好几家大型 AI 实验室谈合作,其中最有意思的对话就来自 Meta 和 OpenAI。

这场争夺战到底有多激烈呢?

Peter 当时在播客里还透露了一个有趣的细节。扎克伯格给他打电话前,居然让他等了 10 分钟,理由是小扎正在写代码。接通之后,俩人花了 10 分钟争论 Claude Code 和 Codex 哪个更好用。

更夸张的是,之后的一周里,扎克伯格一直在玩 OpenClaw,不断发消息反馈「这个太棒了」或者「这个很烂,你得改」。这种亲自下场的紧迫感,足以证明 Meta 对 Agent 赛道有多重视。

另一边 OpenAI 也没闲着,直接甩出超级算力作为筹码。

面对如此豪华的待遇,Peter 对此表现得有点凡尔赛。说自己面前有几条路可以选:什么都不做享受生活、再开一家公司,或者加入大实验室。但他有个核心条件不动摇:项目必须保持开源。

用他自己的话说,「我做这个又不是为了钱……当然,这确实算是一种很棒的认可,但我更想玩得开心、做出影响力」。

为什么最终选择了 OpenAI

在官宣的长文里,Peter 详细解释了自己的心路历程。

他说过去一个月像一场旋风,从未想到自己做着玩的项目会掀起如此大的波澜。「互联网又一次变得『奇怪』起来,而看到我的作品激励了世界各地这么多人,真的非常有趣。」

突然之间,无数可能性向他敞开。很多人试图把他推向不同方向,给建议,问能否投资,或者接下来打算做什么。用 Peter 的话说,「应接不暇」都不足以形容那种感觉。

但他很清楚自己想要什么。「当初开始探索 AI 时,我只是想玩得开心,也希望能激励他人。而现在,这只『龙虾』正在席卷世界。我的下一个目标,是打造一个连我妈妈都能轻松使用的智能体。」

要实现这一点,需要更广泛的改变,需要更加深入地思考如何安全地去做,也需要接触最前沿的模型和研究成果。

Peter 坦言,完全能想象 OpenClaw 会发展成一家大型公司。但说实话,这对他来说并没有那么吸引人。「我骨子里是个『建造者』。创办公司的那一套我已经经历过了,13 年的时间投入其中,也学到了很多。现在我想做的是改变世界,而不是再打造一家大公司。」

与 OpenAI 合作,是把这一切带给更多人的最快方式。

上周他在旧金山,与多家顶尖实验室交流,接触到了许多优秀的人,也看到了尚未发布的研究成果。这些经历在各个方面都让他深受启发。「感谢本周与我交流的每一个人,也感谢这些宝贵的机会。」

对 Peter 来说,OpenClaw 保持开源并拥有自由发展的空间一直非常重要。最终,他认为 OpenAI 是最适合继续推进自己愿景、并扩大其影响力的地方。「与他们深入交流后,我越来越清楚地意识到,我们拥有相同的愿景。」

围绕 OpenClaw 形成的社区非常特别,甚至可以说有些「魔力」。OpenAI 已经做出明确承诺,让 Peter 能够投入时间继续支持这个社区,并且已经成为项目的赞助方。为了让它拥有更完善的架构,Peter 正在推动将其转型为基金会。「它将继续成为思想者、黑客,以及希望掌控自己数据的人们的聚集地,目标是支持更多模型和公司。」

在长文的最后,Peter 写道:「对我个人来说,能够加入 OpenAI,站在 AI 研究与开发的最前沿,并与你们一起继续构建未来,我感到无比兴奋。」

然后用一句话收尾:「Claw 即法则。」

OpenClaw 为啥这么香

那么问题来了,OpenClaw 到底凭什么能让巨头们这么上心?

答案很简单,它代表了下一个时代。

实际上,OpenClaw 最近几周突然爆火的核心原因在于它能让用户搭建功能强大的 AI 智能体,这些智能体可以直接控制电脑并完成复杂任务,比如根据商务会议录音生成新的营销材料,或者直接帮你预约牙医。

要知道,虽然「智能体」这个概念已经火了一年多,但目前大多数 Agent 还是专注于某一类特定任务。比如操作 Microsoft 或 Salesforce 的企业软件。

就连目前最受关注的智能体产品——Anthropic 的 Claude Code 和 OpenAI 的 Codex——也都是编程智能体,主要用来写代码和改代码。

OpenClaw 牛就牛在,它允许用户调用不同厂商的多种 AI 模型,而且可以给智能体授予对电脑的完全访问权限。

这种「通吃」的能力,正是各家巨头梦寐以求的。

当然了,部署 OpenClaw 需要一定的技术门槛,尤其是在确保 OpenClaw 智能体不会过度访问敏感信息方面,所以目前主要还是技术背景用户在用。

也正因为如此,对 OpenAI 来说,一个潜在改进方向就是简化安装配置流程,比如直接整合进现有的智能体产品里——这或许也是他们这么急着拉拢 Peter 团队的原因之一。

OpenAI 的「智能体之年」翻车了?

说到 OpenAI 自己的 Agent 产品,就不得不提一件有点尴尬的事了。

一年前,OpenAI CEO Sam Altman(山姆·奥特曼)在博客里预测,2025 年会出现首批能进入职场、「实质性改变企业产出」的 AI 智能体。和当时很多 AI 领域大佬一样,奥特曼显然是过于乐观了。

去年 7 月推出的 ChatGPT Agent,本意是帮订阅用户在电脑上完成任务,比如构建财务模型或者为晚宴采购食材。但它并没有达到公司的一些内部目标——其中就包括实现 ChatGPT 每周活跃用户中 10% 的使用率。

一位知情人士透露,发布初期高峰阶段,ChatGPT Agent 的每周付费活跃用户达到 400 万,相当于当时 3500 万 ChatGPT 每周付费活跃用户的约 11%(当时每周至少用一次 ChatGPT 的总人数为 6.8 亿,大多数是免费用户)。

这个数字看起来还不错。但几个月后,就跌破了 100 万。数据的崩盘直接导致 OpenAI 将 2025 年通过销售智能体产品获得的收入预期下调了一半,降至 14 亿美元。

问题到底出在哪儿呢?知情人士给出的第一个原因是,用户根本不清楚该怎么用这种通用型、可操作浏览器的智能体。这也反映出一个更广泛的问题——很多 ChatGPT 用户压根不了解产品的全部功能,比如它可以分析一张枯萎植物的照片给出养护建议,或者根据电脑报错截图提供修复方案。

但这还不是全部原因。更致命的问题在于,AI 模型在用户电脑上实际执行操作的能力,并没有在信息整合和研究总结方面表现得那么出色。而「能代替用户操作电脑」恰恰是 ChatGPT Agent 的核心卖点。

换句话说,理想很丰满,现实很骨感。

吃一堑长一智,现在 OpenAI 似乎在换策略了。后续,我们也能看到 OpenAI 开始推出更专业化的智能体产品,比如「购物研究智能体」——这是 ChatGPT 的一项功能,可以帮用户选购商品并提供推荐。

这样做有两个好处:一是用户更清楚智能体具体能干啥,二是产品团队需要开发和保障的功能范围更小,更容易做到稳定可靠。

除了调整产品策略,知情人士还表示,OpenAI 或许可以通过 Atlas 浏览器为其智能体产品寻找新出路。

这款浏览器整合了 ChatGPT Agent 的多项能力,不过目前还不清楚有多少人在用。而且自发布以来,外界关于这款产品的消息也不多。

简言之,Agent 确实是未来。但怎么让用户真正用起来,这事显然比想象中难多了。现在 OpenAI 入手 OpenClaw 团队,或许也是想从开源社区这找找灵感。

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

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


港交所主席唐家成:排队等候上市的还有488家

2026年2月20日 16:26
2月20日,港交所主席唐家成在致辞中分享了一组数据:2026年以来,香港已有24只IPO(首次公开募股)上市,集资额突破870亿港元。“整个马年,以2026年来说,我们已经开了一个好头。”唐家成感叹道:“排队等候上市的还有488家。大家从来没有这么忙过,但忙得开心,因为这代表市场活跃、货如轮转。”唐家成强调,在追求“量”的同时,港交所对“质”的把控绝不松懈。审核过程中的严格把关,旨在确保香港持续作为高质素的金融市场 。此外,他预告了今年的重点改革工作,包括公布优化上市制度以及“T+1” 的咨询文件。至于第二阶段收窄买卖价差,也计划在年中落实。 ( 每日经济新闻)

欧洲主要股指开盘集体走高

2026年2月20日 16:21
欧洲主要股指开盘集体走高。欧洲斯托克50指数涨0.22%,英国富时100指数涨0.37%,法国CAC40指数涨0.54%,德国DAX30指数涨0.15%,富时意大利MIB指数涨0.09%。(界面新闻)

本土咖啡机制造商格米莱申请港交所IPO:中国市占率第二,自有品牌收入占比达83.3%

2026年2月20日 16:18
近日,本土咖啡机制造商格米莱控股有限公司(下称“格米莱”)正式向港交所递交主板上市申请,中信证券担任独家保荐人。官网及招股书信息显示,这家成立于广东顺德的咖啡机企业,历史可追溯至2011年,公司历经代工、贸易、品牌三个阶段,现已建立起涵盖产品设计、制造、销售及售后服务等综合业务模式。(澎湃新闻)

智谱 、MINIMAX市值突破3000亿港元,超越快手、携程

2026年2月20日 16:17
2月20日,港股开盘后,大模型公司智谱和MINIMAX股价均大幅上涨,创上市以来新高。截至发稿,智谱股价上涨36%至 691港元,MiniMax股价上涨12%至957港元,两家大模型企业市值均突破3000亿,接连超越携程、快手和京东市值,逼近泡泡玛特(3273亿港元)与百度(3500亿港元)。(第一财经)

恒生科技指数跌近3%,智谱及MiniMax市值双双逆势突破3000亿港元

2026年2月20日 16:14
2月20日,香港恒生指数收跌1.10%,恒生科技指数收跌2.91%。互联网科技股普跌,百度跌超6%,阿里巴巴跌近5%。半导体股走弱,华虹半导体跌近6%,中芯国际跌超3%。国产AI大模型板块逆势收涨,智谱涨近43%,MiniMax涨超14%,两者市值双双冲破3000亿港元。(财联社)

希腊启动“灯塔”项目推动人工智能发展

2026年2月20日 15:42
希腊政府官员19日表示,希腊人工智能平台项目“灯塔”将于2026年第一季度全面投入运行,旨在为初创企业、大学和科研机构等提供算力、数据和技术支持,推动人工智能创新发展。(新华社)
❌
❌