普通视图

发现新文章,点击刷新页面。
昨天 — 2026年3月10日技术

2026低代码选型指南,主流低代码开发平台排名出炉

2026年3月10日 18:36

2026年,数字化转型已迈入“智能组装”时代。低代码平台不再是简单的效率工具,而是企业构建核心业务系统的关键基础设施。面对市场上琳琅满目的产品,企业如何拨开迷雾,找到真正匹配自身战略的技术伙伴?

本文结合Gartner、Forrester及中国信通院的最新报告,梳理2026年低代码行业的核心趋势,为企业提供一份兼具前瞻性与实操性的选型参考。

2026年低代码三大核心趋势

结合多家权威机构报告,当前低代码领域正经历深刻变革:

  1. AI原生重构开发链路:AI已从“辅助插件”进化为平台的“底层引擎”。通过自然语言直接生成领域模型、自动完成智能调试,开发效率实现300%-500%的提升,真正让业务人员与技术团队在同频对话中完成应用构建。

  2. 信创全栈适配成刚需:随着国产化替代的深入,尤其是在金融、政企、军工等领域,平台的信创适配能力已从“部分兼容”升级为必须通过“国产芯片-操作系统-数据库-中间件”的全链路验证。

  3. 高低代码融合成主流:单一的“拖拽式”开发已无法满足复杂业务场景。“可视化配置 + 全量源码生成 + 异构系统集成”的混合模式成为主流,既能保证80%标准化场景的敏捷性,又能通过源码扩展解决20%核心复杂场景的定制化难题。

主流平台分类介绍

基于技术成熟度、信创安全、生态集成等五大维度,当前市场形成了三大核心阵营。其中,国内企业级全栈信创类平台因其在核心业务领域的深耕,备受大型企业关注。JNPF快速开发平台正是该阵营中一个极具代表性的技术派选手。

(一)国内企业级全栈信创类:以JNPF为例看硬核实力

此类平台的核心价值在于能支撑企业核心业务系统(如生产制造、金融风控、复杂供应链)的搭建,对技术深度和信创适配要求极高。

  • JNPF快速开发平台(综合评分参考:98.2分)

  • AI驱动的开发范式:JNPF深度融合AI大模型能力,实现了从“代码生成”到“业务建模”的跃升。开发者可通过自然语言与平台交互,自动生成符合领域驱动设计(DDD)的模型与前后端代码,大幅降低复杂业务的理解与落地门槛。

  • 企业级技术架构:作为一款面向专业开发者的平台,JNPF采用前后端分离架构(SpringBoot/Vue3),支持全量源码生成与二次开发。它并非封闭的“玩具”,而是能无缝融入企业现有技术栈的“开发利器”,完美契合“高低代码融合”的主流趋势。

  • 全栈信创适配:平台已完成与国产主流芯片、数据库(如达梦、人大金仓)、操作系统及中间件的深度适配,满足关键行业对安全可控的严苛要求。其灵活的部署方式(支持私有云、本地化部署)为数据安全提供了坚实保障。

(二)其他代表性平台概览

  • 国内生态集成型:以钉钉·宜搭腾讯云微搭简道云为代表。它们深度绑定特定互联网生态,上手门槛极低,是中小企业快速搭建协同办公、轻量管理应用的“加速器”。

  • 国际主流企业级:以OutSystemsMendixMicrosoft Power Apps为代表。在全球布局、复杂UI构建、特定工业/办公生态集成方面优势显著,是跨国企业的稳健选择。

企业低代码选型指南

面对不同定位的平台,企业需回归自身需求,科学决策:

  1. 看业务场景

  2. 核心业务系统(高并发、复杂逻辑、强数据一致性):优先考察JNPF、OutSystems等具备“高低代码融合”能力、支持全源码输出、信创适配完善的企业级平台。

  3. 轻量级业务/协同应用:可考虑钉钉宜搭、简道云等生态型平台,实现快速部署与业务自助。

  4. 看技术实力与安全合规

  5. 大型企业或技术团队,应选择架构开放、扩展性强、支持源码导出的平台(如JNPF),避免被厂商锁定。对于金融、政务等行业,私有化部署能力信创适配认证是必须跨越的门槛。

  6. 看生态与集成

  7. 平台是否能无缝对接企业现有的ERP、OA及第三方服务?预置连接器是否丰富?API是否开放?这直接关系到应用能否真正融入业务流。

常见问题FAQ(以JNPF为例解答)

Q1:低代码平台真的能开发复杂业务系统吗?会不会性能不够?A1: 可以,关键在于平台的技术架构。以JNPF为代表的企业级平台,采用主流技术栈,支持高并发架构和全量源码生成。开发者既能享受可视化带来的敏捷,也能通过编写源码进行深度优化,确保复杂业务场景下的性能和稳定性。

Q2:用低代码开发,会被平台“锁死”吗?A2: 优质平台会提供“反脆弱”机制。例如JNPF支持全源码导出,企业可获得完整的应用代码,未来即使脱离平台环境,也可独立进行迭代、维护或迁移,从根本上解除了“平台锁定”的忧虑。

Q3:JNPF这类平台适合什么样的团队?A3: JNPF因其强大的扩展性和源码能力,特别适合具备一定开发能力,希望在保证敏捷的同时,又不希望被低代码框架限制的技术团队。它能帮助团队将精力从繁琐的增删改查中解放出来,专注于核心业务的逻辑实现。

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

作者 SmalBox
2026年3月10日 18:28

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

Scene Depth 节点是Unity URP Shader Graph中一个功能强大的工具,它允许着色器访问当前摄像机的深度缓冲区信息。深度缓冲区存储了场景中每个像素到摄像机的距离数据,这些数据在渲染过程中用于确定物体的前后关系。通过Scene Depth节点,开发者可以创建各种基于深度的视觉效果,如水下折射、雾效、边缘检测等高级渲染特性。

在实时渲染中,深度信息是至关重要的。传统的渲染流程使用深度测试来决定哪些片段应该被渲染,哪些应该被丢弃。而Scene Depth节点则让开发者能够在着色器中直接读取这些深度值,从而实现对渲染效果的精细控制。这种能力为创建复杂的视觉特效打开了新的可能性。

深度缓冲区的工作原理是存储每个像素的深度值,通常以非线性的方式分布,以更好地利用精度。在透视投影中,靠近摄像机的物体拥有更高的深度精度,而远处的物体精度较低。理解这种分布特性对于正确使用Scene Depth节点至关重要。

描述

Scene Depth节点的核心功能是允许使用输入UV(标准化的屏幕坐标)访问当前摄像机的深度缓冲区。这意味着开发者可以查询场景中任意屏幕位置对应的深度值,从而了解该位置在3D空间中的深度信息。

深度缓冲区访问要求

要成功使用Scene Depth节点,必须在活动的渲染管线上启用深度缓冲区。这一过程的具体实现方式因渲染管线而异:

  • 在URP(Universal Render Pipeline)中,深度纹理通常默认启用,但可能需要检查渲染管线资产的设置
  • 对于自定义渲染管线,需要显式配置以生成和访问深度缓冲区
  • 在某些平台上,可能需要额外的设置来确保深度缓冲区的可用性

如果深度缓冲区不可用,Scene Depth节点将返回中灰色(RGB 0.5, 0.5, 0.5),这可以作为调试深度缓冲区状态的视觉指示器。

渲染管线兼容性

Scene Depth节点的HLSL代码实现是根据特定的渲染管线定义的,这意味着不同的渲染管线可能会产生不同的结果:

  • URP和HDRP提供了对此节点的原生支持
  • 自定义渲染管线需要显式定义Scene Depth节点的行为
  • 如果未在自定义渲染管线中定义相关功能,节点将返回1(白色)

着色器阶段限制

Scene Depth节点只能在片元着色器阶段使用,这是因为它需要访问已经渲染的深度缓冲区信息。在顶点着色器阶段,深度缓冲区数据尚未完全生成,因此无法使用此节点。

端口

Scene Depth节点的端口配置决定了如何向节点提供输入数据以及如何获取输出结果。正确理解和使用这些端口是实现预期效果的关键。

输入端口

UV输入端口是Scene Depth节点的主要输入接口,它接受Vector 4类型的标准化屏幕坐标:

  • 该端口通常绑定到屏幕位置节点,以获取当前片元的屏幕坐标
  • 可以使用其他方式生成UV坐标,如自定义计算或纹理采样
  • UV坐标的范围应在[0,1]之间,表示从屏幕左下角到右上角的标准化位置
  • 如果提供的UV坐标超出[0,1]范围,结果可能未定义或产生错误值

在实际使用中,最常见的做法是将Screen Position节点的输出连接到Scene Depth节点的UV输入端口。Screen Position节点提供了多种坐标空间选项,其中"Default"模式提供的就是标准化屏幕坐标,非常适合与Scene Depth节点配合使用。

输出端口

Out输出端口提供从深度缓冲区采样得到的深度值:

  • 输出值为Float类型,表示指定屏幕位置的深度信息
  • 具体的数值范围和含义取决于选择的深度采样模式
  • 输出值可以直接用于后续的计算或作为其他节点的输入

理解输出值的含义对于正确使用Scene Depth节点至关重要。不同的深度采样模式会产生不同范围和含义的深度值,开发者需要根据具体需求选择合适的模式。

深度采样模式

Scene Depth节点提供了三种不同的深度采样模式,每种模式都以不同的方式解释和返回深度值。选择合适的采样模式对于实现特定的视觉效果至关重要。

Linear01模式

Linear01模式返回介于0和1之间的线性深度值:

  • 0表示深度值位于摄像机的近裁剪平面
  • 1表示深度值位于摄像机的远裁剪平面
  • 中间值表示在近远裁剪平面之间的线性插值

这种模式特别适合需要基于摄像机距离进行线性计算的效果:

  • 线性雾效的实现,其中雾的密度随距离线性增加
  • 基于距离的渐隐效果
  • 水平面效果,如水下场景的视觉处理

Linear01深度值的计算通常涉及将透视投影的非线性深度缓冲值转换回线性空间。这一转换需要知道摄像机的近远裁剪平面距离,Unity的Scene Depth节点在内部处理了这一复杂计算。

Raw模式

Raw模式返回原始深度缓冲区中的值:

  • 这些值通常是非线性的,在透视投影中更精确地分布靠近摄像机的深度
  • 具体数值范围取决于图形API和深度缓冲区格式
  • 在大多数情况下,Raw深度值不能直接解释为实际距离

Raw模式适用于需要最高性能或特定图形效果的场景:

  • 深度比较操作,如软粒子的实现
  • 自定义的深度解码和处理
  • 需要直接操作原始深度数据的特殊效果

使用Raw模式时,开发者需要了解特定平台和渲染设置下的深度缓冲区编码方式,这可能因图形API(DirectX、OpenGL、Vulkan等)而异。

Eye模式

Eye模式返回转换为眼睛空间单位的深度值:

  • 眼睛空间是以摄像机为原点的坐标系
  • 深度值表示从摄像机到场景点的实际距离
  • 数值范围从近裁剪平面距离到远裁剪平面距离

Eye模式最适合需要实际距离计算的效果:

  • 物理准确的雾效计算
  • 基于真实距离的照明衰减
  • 需要精确3D空间信息的后期处理效果

Eye深度值是通过将原始深度缓冲区值反投影到3D空间计算得到的,这涉及到视图-投影矩阵的逆变换。Scene Depth节点在内部处理了这一复杂数学运算,为开发者提供了方便的接口。

生成的代码示例

理解Scene Depth节点在底层生成的代码有助于更深入地掌握其工作原理,并在需要时进行自定义扩展。

基础实现

以下示例代码表示Scene Depth节点在Raw模式下的一种可能实现:

void Unity_SceneDepth_Raw_float(float4 UV, out float Out)
{
    Out = SHADERGRAPH_SAMPLE_SCENE_DEPTH(UV);
}

这段代码展示了一个简单的深度采样函数:

  • 函数接受标准化屏幕坐标作为输入
  • 使用SHADERGRAPH_SAMPLE_SCENE_DEPTH宏从深度缓冲区采样
  • 返回原始深度值

SHADERGRAPH_SAMPLE_SCENE_DEPTH是一个由Unity定义的宏,它抽象了不同平台和渲染管线之间的差异,确保深度采样的正确性。

不同模式的实现差异

对于不同的深度采样模式,生成的代码会有显著差异:

Linear01模式可能需要额外的计算将原始深度值转换为线性空间:

void Unity_SceneDepth_Linear01_float(float4 UV, out float Out)
{
    float rawDepth = SHADERGRAPH_SAMPLE_SCENE_DEPTH(UV);
    Out = Linear01Depth(rawDepth, _ZBufferParams);
}

Eye模式涉及更复杂的坐标变换:

void Unity_SceneDepth_Eye_float(float4 UV, out float Out)
{
    float rawDepth = SHADERGRAPH_SAMPLE_SCENE_DEPTH(UV);
    Out = LinearEyeDepth(rawDepth, _ZBufferParams);
}

在这些实现中,_ZBufferParams是一个Unity内置变量,包含了解码深度缓冲区所需的参数,如近远裁剪平面信息。

自定义扩展

了解生成的代码结构后,开发者可以创建自定义的深度处理函数:

void CustomDepthProcessing_float(float4 UV, float depthScale, out float Out)
{
    float sceneDepth = SHADERGRAPH_SAMPLE_SCENE_DEPTH(UV);
    // 自定义深度处理逻辑
    Out = sceneDepth * depthScale;
}

这种灵活性允许实现特殊的深度效果,如非标准的深度重映射、基于深度的颜色校正等。

实际应用示例

Scene Depth节点在实战中有广泛的应用,以下是一些常见的用例和实现方法。

水下折射效果

创建逼真的水下效果通常需要结合深度信息来模拟光的折射:

  • 使用Scene Depth节点采样水面下的深度
  • 根据深度差计算折射偏移量
  • 应用偏移到屏幕空间UV坐标
  • 采样场景颜色纹理创建折射效果

实现步骤:

  1. 创建水面着色器并启用透明渲染
  2. 使用Grab Pass或URP的Opaque Texture获取场景颜色
  3. 使用Scene Depth节点计算水面上下点的深度差
  4. 根据深度差计算折射强度
  5. 偏移UV坐标并采样场景颜色

这种技术可以创建从水面看向水下物体时的折射扭曲效果,深度差越大,折射效果越明显。

距离雾效

基于深度的雾效是Scene Depth节点的经典应用:

  • 使用Linear01或Eye模式获取深度值
  • 根据深度计算雾的密度
  • 将雾颜色与场景颜色混合

实现方法:

  1. 在后处理效果或场景着色器中使用Scene Depth节点
  2. 选择合适的深度模式(通常Linear01或Eye)
  3. 定义雾的起始和结束距离
  4. 使用平滑函数(如smoothstep)计算雾的混合因子
  5. 根据混合因子混合场景颜色和雾颜色

通过调整雾参数,可以创建各种大气效果,从薄雾到浓雾,甚至模拟特定的环境条件。

边缘检测

深度信息可以用于检测场景中的几何边缘:

  • 采样当前像素及其周围像素的深度
  • 计算深度差异
  • 根据差异阈值标识边缘

实现步骤:

  1. 定义采样偏移量(通常为1-2像素)
  2. 在多个方向采样深度值
  3. 计算深度梯度或差异
  4. 应用阈值检测显著边缘
  5. 可选:结合法线或颜色信息提高边缘检测质量

这种边缘检测技术可用于卡通渲染、特殊视觉效果或视觉辅助工具。

软粒子

软粒子通过深度比较实现粒子与场景几何体的平滑混合:

  • 采样粒子位置和场景深度
  • 计算深度差异
  • 根据差异调整粒子透明度

实现方法:

  1. 在粒子着色器中访问场景深度
  2. 计算粒子深度与场景深度的差异
  3. 当粒子接近场景几何体时,逐渐淡出粒子
  4. 使用平滑函数控制淡出曲线

软粒子效果消除了粒子与几何体交界的硬边缘,创建更自然的视觉融合。

性能考虑和最佳实践

使用Scene Depth节点时,性能是需要考虑的重要因素,特别是在移动平台或复杂场景中。

性能影响

深度采样可能对性能产生显著影响:

  • 每次深度采样都需要从纹理中读取数据,这可能成为带宽瓶颈
  • 在移动平台上,深度采样可能特别昂贵
  • 复杂的深度处理(如多采样或复杂计算)会增加着色器执行时间

为了优化性能:

  • 尽量减少深度采样的次数
  • 考虑使用深度预计算或缓存
  • 在可能的情况下,使用较低精度的深度格式
  • 避免在每帧每像素上进行复杂的深度处理

平台兼容性

不同平台对深度缓冲区的支持可能有所不同:

  • 某些移动设备可能不支持深度纹理读取
  • 不同的图形API可能有不同的深度缓冲区格式
  • 在多平台项目中,需要测试深度效果在所有目标平台上的表现

应对策略:

  • 提供深度效果的备选方案或简化版本
  • 使用着色器功能关键字根据平台条件编译不同代码路径
  • 在项目设置中确保深度纹理在所有目标平台上可用

调试技巧

调试深度相关效果时,可视化深度值非常有用:

  • 将深度值直接输出为颜色,创建深度可视化
  • 使用不同的颜色映射方案强调特定的深度范围
  • 添加调试控件,实时调整深度参数

常用的调试方法包括:

  • 创建深度可视化着色器,将深度值映射到颜色梯度
  • 使用Unity的Frame Debugger检查深度缓冲区
  • 添加滑块和其他UI控件,方便调整深度参数

高级技巧和创意应用

掌握了Scene Depth节点的基础后,可以探索更高级的应用和创意效果。

深度重建世界位置

通过深度值和屏幕UV,可以重建世界空间位置:

  • 使用深度值和逆视图-投影矩阵
  • 将屏幕UV和深度转换为世界坐标
  • 实现基于世界坐标的复杂效果

这种方法可以用于:

  • 在屏幕空间着色器中访问3D位置信息
  • 实现与世界坐标相关的效果,如全局风场或力场
  • 创建需要精确3D信息的后期处理效果

多层深度效果

结合多个深度采样创建复杂效果:

  • 采样不同偏移位置的深度值
  • 计算深度梯度或曲率
  • 创建基于深度变化的视觉效果

应用场景包括:

  • 模拟视线追踪效果
  • 创建非真实感渲染风格
  • 实现复杂的材质效果,如湿润表面或腐蚀效果

动态深度处理

在运行时修改或响应深度信息:

  • 根据游戏事件动态改变深度处理参数
  • 创建与玩家交互的深度效果
  • 实现时间相关的深度变化

这种动态处理可以用于:

  • 创建魔法效果,如暂时显示隐藏物体
  • 实现环境谜题,基于深度解决
  • 增强游戏叙事,通过深度变化传达信息

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

LogicFlow 小地图性能优化:从「实时克隆」到「占位缩略块」!🚀

作者 橙某人
2026年3月10日 18:27

写在开头

Hi,各位朋友们好呀!😋

今是2026年03月10日,虽迟但到,时间飞快,又过去一个月了。

灵魂一问:你养虾了吗?🦞

最近 OpenClaw 很火呢,但...它真能给你带来实际作用吗?🤔
小编也养了一只,但目前好像除了提供点"情绪价值",其他场景的还没派上用场,生产力也还在观察中。

二月过了个年,这个年小编过得非常开心的,从各个方面。🥳 然后,也给辛苦一年的自己买了个小礼物:换了台电脑——MacBook Air M4 24+512。

猜猜小编花了多少钱拿下的?评论区有答案。

言归正传,今天要分享的内容依旧是关于 LogicFlow 库的,给其小地图插件增加缩略块模式,效果如下,请诸君按需食用哈。

image.png

需求背景 💡

在最近的项目里,小编基于 LogicFlow 做了流程图页面,节点类型不少,而且很多是自定义 HTML 节点,内容里有文本、图片、视频、音频等富媒体。

画布上用了官方推荐的小地图插件,功能没问题,但小地图是实时同步主画布的:主画布渲染一份节点,小地图再渲染一份,内容一样。节点一多,加上拖拽、缩放、批量操作,两边都要更新,有点一个页面干两份活的意思,几十上百个节点时性能压力就很明显。

主画布可以靠局部渲染缓解,小地图那块就没辙了,如果节点再显示图片、视频这类资源,一进页面就要全量加载,非常容易卡顿。

这次目标很明确:给小地图开发一种缩略块模式——用轻量占位块代替真实节点渲染,缓解性能问题。具体来说🤔:

  1. 保留定位导航能力
  2. 不再同步创建真实节点内容
  3. 用轻量占位块表达节点位置和大小
  4. 与现有 miniMap 配置兼容

实现过程 ⚡

以下改造思路和实现均基于 LogicFlow 官方 MiniMap 源码,插件整体代码并不算多,可以仔细瞧瞧:传送门

第1️⃣步:明确改造策略——继承官方 MiniMap

小编没有另起炉灶,从零开始,而是直接继承官方 MiniMap,只改关键实现点。

这样做的好处是:

  • 官方行为仍可复用(例如视口更新、定位等)
  • 后续升级 LogicFlow 时,迁移成本更低

🍊 为什么选择「继承 + 局部重写」❓

因为咱们真正的痛点不是功能不够,只是渲染太重。只要把渲染部分调整一下,就能快速拿到收益,不必把整个插件推倒从零开始。

import { MiniMap } from "@logicflow/extension";

/**
 * 自定义小地图:继承官方 MiniMap,通过 placeholderMode 支持「占位块」与「实时克隆」双模式
 */
class CustomMiniMap extends MiniMap {
  constructor({ lf, LogicFlow, options }) {
    const { placeholderMode = true, ...restOptions } = options || {};
    const hasRestOptions = Object.keys(restOptions).length > 0;
    // 将 placeholderMode 以外的配置透传给官方 MiniMap
    super({ lf, LogicFlow, options: hasRestOptions ? restOptions : undefined });
    this.placeholderMode = placeholderMode;  // 默认开启占位块模式
  }
}

第2️⃣步:增加 placeholderMode,支持双模式切换

这一步是整个方案的开关:

  • placeholderMode: true:占位块模式(默认)
  • placeholderMode: false:实时模式(回退到官方行为)

也就是说,咱们不是把官方逻辑「干掉」,而是给它加了个性能开关。

/**
 * 重写 setView:根据 placeholderMode 决定走官方渲染还是轻量占位块渲染
 */
setView(reRender = true) {
  if (!this.placeholderMode) {
    return MiniMap.prototype.setView.call(this, reRender);  // 回退到官方实时克隆
  }
  // placeholderMode === true 时,走轻量占位块渲染逻辑(此处省略具体实现)
}

第3️⃣步:把真实节点数据转换为占位块数据

⏰ 关键点❗❗❗

小地图不再吃原始节点类型,而是统一转换成一个占位节点类型: minimap:placeholder

转换时只保留导航必需信息:

  • id
  • x / y
  • width / height
  • 少量 properties(用于占位模型读取)
const MINIMAP_PLACEHOLDER_TYPE = "minimap:placeholder";

/**
 * 将原始节点数据转换为占位块数据,仅保留定位、尺寸等导航必需信息
 * @param {Object} data - { nodes, edges }
 * @returns {Object} 转换后的 { nodes, edges },节点类型统一为 minimap:placeholder
 */
_resetDataWithPlaceholder(data) {
  const nodes = data.nodes.map((node) => {
    // 优先从 properties 取尺寸,再 fallback 到节点顶层,默认 200
    const width = Number(node.properties?.width) || Number(node.width) || 200;
    const height = Number(node.properties?.height) || Number(node.height) || 200;
    return {
      id: node.id,
      type: MINIMAP_PLACEHOLDER_TYPE,
      x: node.x,
      y: node.y,
      width,
      height,
      properties: { width, height, _originalType: node.type },
    };
  });

  return {
    nodes,
    edges: this.showEdge ? data.edges.map((e) => ({ ...e, text: undefined })) : [],
  };
}

💡 小贴士:这里优先从 properties.width/height 取尺寸,再 fallback 到节点顶层尺寸,这个细节非常重要,能保证小地图占位块尺寸更贴近主画布真实节点。

第4️⃣步:注册轻量占位节点视图与模型

占位节点本身非常轻,只渲染一个 rect,不挂任何复杂内容。

import { h, RectNode, RectNodeModel } from "@logicflow/core";

/**
 * 轻量占位节点视图:只渲染一个圆角矩形,不挂载任何子节点或富媒体内容
 */
class MinimapPlaceholderView extends RectNode {
  getShape() {
    const { x, y, width, height } = this.props.model;
    return h("g", {}, [
      h("rect", {
        x: x - width / 2,   // LogicFlow 节点以中心点为坐标,rect 需偏移
        y: y - height / 2,
        rx: 10,
        ry: 10,
        width,
        height,
      }),
    ]);
  }
}

这样小地图的渲染成本就从创建一堆真实节点内容,降到了画几个轻量矩形块。

第5️⃣步:接入现有使用的地方

在业务页面里,小编是直接替换 MiniMap 的来源,不改既有交互入口:

// 仅改 import 来源,其余用法与官方 MiniMap 一致
import { CustomMiniMap as MiniMap } from "./plugins/CustomMiniMap";

LogicFlow.use(MiniMap);

再加上 CustomMiniMap.pluginName = "miniMap",可以继续复用原有 pluginsOptions.miniMap 配置,不需要大动干戈改业务代码,这点非常香。😁

完整源码

传送门

总结

这次改造的核心就一句话:小地图有时可能并不需要真实还原,只需要正确导航就行。

通过二次改造增加小地图新模式后,咱们拿到了几个关键收益:

  • 小地图渲染负担显著下降
  • 节点规模上来后,交互更稳
  • 依然保留官方 MiniMap 的主要能力




至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

我受够了混乱的 API 代码,所以我写了个框架

作者 Mr_Mao
2026年3月10日 18:00

前阵子帮几个项目接后端 API,代码库里到处都是风格不一的请求封装:有的直接手写 fetch,有的半自动生成,有的还混着好几版接口。弄了一堆没人敢碰的"神圣代码",动一点就怕出生产事故。最后我干脆写了个小框架,就是现在的 genapi

手写 VS 传统代码生成 VS genapi

  • 手动编写 API
    好处是完全可控,想怎么写就怎么写,也最贴合项目风格;问题是接口一多,代码重复、命名不统一、漏改一个字段就要线上排查半天。

  • 传统代码生成器
    好处是类型安全、一次性全生成,很多还支持自定义模板;但经常会顺带送你一整套运行时和抽象层,函数名、目录结构都被"设计好"了,和现有项目不太对味。模板虽然能改,但门槛不低,团队里最后往往只剩一个人敢碰。

  • genapi 的路子
    genapi 只做一件事:从数据源生成"长得像你自己手写"的 API 代码。它不强行塞运行时,不要求你接受一整套工程哲学,而是把生成过程拆成可组合的 pipeline / transform / mapping,让你在"完全手写"和"被框架锁死"之间,找到一个舒服的中间态。


genapi 想做的事,很简单

一句话概括:一个轻量的 API 代码生成器——只生成你真正需要的代码。

它有几个核心设计思路:

  • 尽量贴近你平时手写的代码风格
    生成出来的就是普通的 axios / fetch / ofetch / tanstack-query 调用函数,你看一眼就能改,甚至可以直接当手写模板来抄。

  • 不强推运行时框架
    genapi 更像是一个"代码 printer":给它 OpenAPI/Swagger 文档,它吐出一堆很正常的 TS/JS 文件,没有私货,没有黑盒运行时。

  • 生成过程是可定制的 pipeline
    你可以在 pipeline 里插各种"transform"和"patch":比如统一加上 /api 前缀、重命名某些路径、批量改类型、对个别接口做精确替换。

  • 既能 TS 也能 JS,类型一视同仁
    同时生成 TS 源码和 .d.ts 类型文件,团队里有人用 JS 也能享受完整的类型提示和 IntelliSense。

  • 支持多种 HTTP 客户端和 schema 模式
    除了 axiosfetchkygotofetchtanstack-query 等常规客户端,还提供基于 schema 的类型安全 fetch 预设,适合喜欢自己包一层 HTTP 的同学。


一个最小可用示例:从 OpenAPI 到可用的 axios API

安装依赖(推荐用 pnpm,也支持 npm / yarn):

# 推荐
pnpm dlx @genapi/core genapi init

# 或者手动安装
pnpm add @genapi/core @genapi/presets -D

创建一个最简单的 genapi.config.ts

import { defineConfig } from '@genapi/core'
import { axios } from '@genapi/presets'

export default defineConfig({
  preset: axios.ts,
  input: 'https://petstore3.swagger.io/api/v3/openapi.json',
  output: {
    main: 'src/api/index.ts',
    type: 'src/api/index.type.ts',
  },
})

然后直接跑:

npx genapi

生成出来的代码大概长这样(节选):

import type { AxiosRequestConfig } from 'axios'
import type * as Types from './index.type'
import http from 'axios'

/**
 * @summary Update an existing pet.
 * @description Update an existing pet by Id.
 * @method put
 * @tags pet
 */
export function putPet(data?: Types.Pet, config?: AxiosRequestConfig) {
  const url = '/pet'
  return http.request<Types.Pet>({ method: 'put', url, data, ...config })
}

如果你项目里已经有一套封装好的 request 实例,只需要在 preset 或 pipeline 里替换 http 的引入和调用方式,就能无缝接上现有代码。


Schema 模式:类型驱动的 fetch

除了传统的"按接口生成一堆函数",genapi 还内置了 schema 模式的 preset,更适合那些已经有一层 HTTP 封装、只想让它真正类型安全的项目。 核心思路是:你先声明一份纯类型的 API Schema,然后只实现一次带泛型的 $fetch

// API Schema
interface APISchema {
  '/users': {
    [Endpoint]: {
      GET: {
        response: Types.User[]
      }
    }
  }
}

export async function $fetch<T extends TypedFetchInput<APISchema>>(
  input: T,
  init?: TypedFetchRequestInit<APISchema, T>,
) {
  return fetch(input, init as any) as Promise<
    TypedResponse<TypedFetchResponseBody<APISchema, T>>
  >
}

genapi 会根据 APISchema 生成类型约束和辅助类型,你在业务代码里写 $fetch('/users') 的时候,请求参数和返回值就都自动带上了精确的类型;而当你改了 schema 或 mapping 之后,类型会跟着一起更新。


适合哪些项目用 genapi?

  • 有 OpenAPI/Swagger 文档,但不想被"官方生成器"绑架的团队
    想要类型安全、规范化 API,又希望生成的代码看起来像自己写的。

  • 多端、多 HTTP 客户端的项目
    Web 用 axios,小程序用 uni-network,服务端用 ofetch,都可以通过不同 preset 一次性生成。

  • 需要长期维护的中大型前端仓库
    接口多、改动频繁,用 genapi 把"接口签名"部分自动化,让人力更专注在业务和状态管理上。

如果你只是一个 demo 项目,手写几行 fetch 也没什么问题;但一旦接口数量上来、版本反复变更,自动化生成就会慢慢变成刚需。


不只有 Swagger:genapi 的扩展性

虽然最常见的输入是 OpenAPI 2.0/3.x、Swagger 这类规范文档,但 genapi 的内核其实只关心一件事:给我一份可以描述"有哪些接口、参数和返回值"的结构化数据

这意味着:

  • 你可以自己写一个小转换,把后端现有的接口列表(哪怕是一个 Excel、一个内网 JSON)先转成中间格式,再丢给 genapi;
  • 也可以从其它系统(比如 wpapi、内部网关、BFF 配置)拉元数据,做一层适配,就能享受同一套生成能力;
  • 真正做定制的时候,不需要 fork genapi 本身,而是通过"数据源适配器 + pipeline/transform/mapping"把你们的接口信息结构化输出。

换句话说:只要你能把接口信息结构化输出,genapi 基本都能搞定。


写在最后

我对 genapi 的期待其实很朴素:它不要把你的项目变成"genapi 风格",而是安静地融进你现有的代码库。

如果你也在用 OpenAPI,却一直没找到顺手的代码生成工具,不妨抽个时间试一下:

pnpm dlx @genapi/core genapi init

用完有任何想法或吐槽,欢迎来 GitHub 开个 issue 或 PR。

来自顶级大佬 TypeScript 之父的 7 个启示

作者 冴羽
2026年3月10日 17:46

最近,GitHub 发布了一份与 Anders Hejlsberg 的深度访谈,你可能不知道这个名字,但你肯定听说过 Turbo Pascal、Delphi、C# 和 TypeScript。

对,他就是 Turbo Pascal 和 Delphi 的创建者,C# 的首席架构师,以及 TypeScript 的设计者。

从这次深度访谈中,我总结出了一套构建能够经受规模化考验的系统模式。以下是我学到的 7 个经验:

1. 快速反馈:最为重要

Hejlsberg 说,Turbo Pascal 的成功不是因为 Pascal 语言本身有多好,而是因为它能让开发者能“立刻”看到结果。

同样,TypeScript 的价值不仅在于语言本身,更在于其强大的工具链:增量检查、快速响应,哪怕是在大型代码库。

image.png

所以快速反馈能够改变行为

当错误能够立刻出现,开发者会进行更多的重构和测试,于是问题在出现之初就被解决。

反之,当反馈延迟,团队则会通过通过约定俗成的规则、变通方案以及额外的流程开销来弥补。 

所以无论选择编程语言、框架还是内部工具,响应速度都至关重要。能够缩短编写代码与理解其后果之间距离的工具往往更受信任。

2. 团队协作:放下个人偏好

当 Hejlsberg 从单独工作转向带领团队,最难的调整不是技术层面,而是学会放弃个人偏好。

Anders Hejlsberg 说:“你必须接受事情的进展与你的预期有所不同。即便解决了这个问题,也改变不了事情的本质。”

这种思维方式远不止于适用于语言设计。

任何需要跨团队扩展的系统都需要从个人品味转向共同目标。

目标不再是编写符合你个人风格的代码,而是编写多人都能理解、维护和共同演进的代码。

C# 的诞生并非源于一个全新的理想,而是源于各种相互冲突的需求。Visual Basic 开发者追求易用性,C++ 开发者追求强大功能,而 Windows 则要求务实。

最终的结果并非理论上的纯粹,而是一种足够多的人能够有效使用的语言。

语言的成功并非源于其完美无缺的设计,而是源于其能够适应团队实际的工作方式。

image.png

3. 顺势而为:为什么 TypeScript 选择扩展 JavaScript

TypeScript 为什么能成功?

不是因为它比 JavaScript 更"完美",而是因为它选择了"扩展"而不是"替代"。

当时很多团队为了用上静态类型,直接用其他语言编译成 JavaScript。这种"推倒重来"的做法,要求开发者放弃现有的工具、库、思维模式——成本太高了!

TypeScript 的聪明之处就在于:我不要你放弃任何东西,我只是在你现有的基础上加点内容。

这背后其实也是妥协。

尊重现有工作流程的改进往往得到传播,需要全面替换的改进则很少能实现。

有意义的进展往往来自于让你已经依赖的系统变得更强大,而不是试图重新开始。

image.png

4. 透明化:公开透明建立信任

TypeScript 团队在 2014 年做了一个重要决定:完全开放开发过程,所有讨论都在 GitHub 上进行,让全世界都能看到他们是怎么做决策的。

这样做有什么好处?

开发者不仅能看到最终成果,还能理解为什么这么做。信任就这样建立起来了。

对团队来说,这也改变了工作优先级。他们可以直接查看开发者关心的问题,而不是猜测什么最重要。

所以最有效的开源项目不仅仅是分享代码。它们使决策过程可视化。这样贡献者和用户就能理解如何设定优先级,以及为什么做出权衡。

image.png

5. 必要的突破:什么时候该彻底重做

TypeScript 团队曾经用 JavaScript 来写 TypeScript 编译器,这在小项目时没问题。但随着项目越来越大,JavaScript 的单线程特性成了瓶颈。

这时他们做了一个艰难决定:把编译器用 Go 语言重写

这不是为了炫技,而是因为技术限制已经到了不突破就无法继续发展的地步。

而这次重写的目标是语义保真度。新编译器需要表现得与旧编译器完全一样,包括怪癖和边缘情况。

结果就是带来了显著的性能收益,而社区也不必重新学习编译器。

所以有时最负责任的选择不是雄心勃勃,完全重写,而是保持最小化破坏,并移除无法通过增量优化克服的硬限制。

6. AI 时代:基础比想象力更重要

Hejlsberg 对 AI 时代的编程有个很深刻的观点:

在 AI 能生成代码的时代,工具的价值不在于创造,而在于约束。

想象一下,如果 AI 可以写代码了,那程序员的价值在哪里?就在于他们知道什么时候约束 AI,知道什么是正确的,什么是错误的。

所以AI 辅助工作流程中最有价值的工具不是生成最多代码的工具,而是正确约束它的工具。

强大的类型系统、可靠的重构工具和准确的语义模型成为必不可少的护栏。它们提供了允许 AI 输出被审查、验证和有效纠正而不是盲目信任的结构。

image.png

7. 开放协作:至关重要

尽管面临资金和维护的挑战,Hejlsberg 对开放协作依然保持乐观。一个原因是制度记忆。哪怕是多年前的讨论、决策和权衡,仍然可以搜索和可见。

“我们有 12 年的历史记录在我们的项目中,”他解释道。“如果有人记得发生了讨论,我们通常能找到它。上下文不会消失在电子邮件或私人系统中。”

这种可见性改变了系统如何演变。设计时的辩论、被拒绝的想法和权衡在个人决策做出后很长时间仍然可访问。

对于以后加入项目的开发者来说,这种共享上下文常常与代码本身同样重要。

总结

你会发现,在 Anders Hejlsberg 40 年语言设计的历程中,同样的主题反复出现:

  • 快速反馈比优雅更重要

  • 系统需要容纳许多人编写不完美的代码

  • 行为兼容性往往比架构纯粹性更重要

  • 透明化的权衡取舍能够建立信任

这些并非次要因素,而是决定工具能否随着用户群体增长而不断适应的根本性决策

此外,它们也为创新奠定了基础,确保新想法能够在不破坏现有有效机制的前提下生根发芽。

对于任何致力于打造经久不衰的工具的人来说,这些基本要素与任何突破性功能都同样重要。而这或许才是最重要的一课。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

在 Vue 项目中玩转 FullCalendar:从零搭建可交互的事件日历

作者 leafyyuki
2026年3月10日 17:41

在很多业务场景中,我们都需要把「时间维度上的事件」清晰地呈现在一个可交互的日历里,并和其它数据视图(图表、报表、分析面板等)联动。本文基于一个典型的「事件日历 + 外部报表联动」场景,总结一套在 Vue 项目中落地 FullCalendar 的通用实践。

概览

FullCalendar 是最受欢迎的js calendar组件

官网:fullcalendar.io/

demos: fullcalendar.io/demos

vue demo: github.com/fullcalenda…

安装

使用 NPM 或 Yarn 安装软件包core以及您计划使用的任何插件(按需安装) 插件列表:fullcalendar.io/docs/plugin…

标题 描述 示意
@fullcalendar/core 必须
@fullcalendar/vue vue2项目
@fullcalendar/vue3 vue3项目
@fullcalendar/interaction 支持点击、拖拽等事件
@fullcalendar/daygrid DayGrid 视图 image.png
@fullcalendar/timegrid timegrid视图 image.png
@fullcalendar/resource-timeline 时间轴视图 image.png
npm install \
  @fullcalendar/core \ 
  @fullcalendar/vue  \  
  @fullcalendar/daygrid \   
  @fullcalendar/timegrid \   
  @fullcalendar/interaction \  

如果是vue3项目用@fullcalendar/vue3

使用

引入组件

<template>
    <FullCalendar ref="myCalendar" :options="calendarOptions" />
</template>

<script>
  // 引入已经安装好的,页面所需要的 FullCalendar 插件
  import FullCalendar from '@fullcalendar/vue'
  import dayGridPlugin from '@fullcalendar/daygrid'
  import timeGridPlugin from '@fullcalendar/timegrid'
  import interactionPlugin from '@fullcalendar/interaction'
  // 日历参数配置
  const calendarOptions = {}

  export default {
    name: "my-calendar",
    components: {
      FullCalendar
    },
    data () {
      return {
        calendarOptions
      }
    }
  }
</script>

参数配置

dayGridMonth视图的简单配置参考:

calendarOptions = {
        locale: 'zh-cn',
        plugins: [
          dayGridPlugin,
          // interactionPlugin, // needed for dateClick
        ],
        headerToolbar: {
          left: 'prev,next today',
          center: 'title',
          right: 'dayGridMonth',
        },
        buttonText: { today: '今天', prev: '上个月', next: '下个月', dayGridMonth: '月' }, // 设置按钮文本内容
        initialView: 'dayGridMonth',
        initialEvents: [], // alternatively, use the `events` setting to fetch from a feed

        firstDay: 1,
        aspectRatio: 1.35, // 日历单元格宽高比 默认值:1.35
        eventColor: '#3a79eb', // 日历中事件的默认背景色颜色,优先级低于添加事件时设置的背景色
        dayMaxEvents: true,
        editable: false,
        selectable: false,
        selectMirror: true,
        dayMaxEvents: true,
        weekends: true,
        events: this.fetchEvents, // 获取事件
        eventClick: this.handleEventClick, // 点击事件
        eventsSet: this.handleEvents,
        // you can update a remote database when these fire:
        // eventChange: this.handleEventChange        // eventAdd:
        // eventRemove:

详细的配置可参考文章:blog.csdn.net/FlowGuanEr/…

slot模板

<template>
  <FullCalendar :options="calendarOptions">
    <template v-slot:eventContent='arg'>
      <b>{{ arg.event.title }}</b>
    </template>
  </FullCalendar>
</template>

Calendar API

let calendarApi = this.$refs.myCalendar.getApi() 
calendarApi.next()

事件

1. 事件对象

var calendar = new Calendar(calendarEl, {
  timeZone: 'UTC',
  events: [
    {
      id: 'a',
      title: 'my event',
      start: '2018-09-01',
      end: '2018-09-01'
    }
  ]
})

更多字段详解:fullcalendar.io/docs/event-…

2. 初始化事件

可以在initialEvents配置初始化事件列表

也可以用events中配置方法,调用接口去获取数据,将数据格式化成事件对象规范的格式,显示事件列表。

事件排序:接口返回的顺序可能杂乱,建议在传给 successCallback 前对列表按 start(及可选的 endtitle)排序,这样同一天内多事件在月视图中的展示顺序一致、可预期(FullCalendar 会按你传入的顺序在同一格内排列)。

fetchEvents (info, successCallback, failureCallback) {
  // info.start / info.end 是当前视图的起止时间
  requestAPI(api.fetchCalendarEvents, {
    startTime: info.start.getTime(),
    endTime: info.end.getTime()
  }).then(data => {
    if (data?.length) {
      const list = data.map((item) => ({
        id: item.eventId,
        title: item.eventTitle,
        start: moment(item.startTime).format('YYYY-MM-DD'),
        end: moment(item.endTime + MS_PER_DAY).format('YYYY-MM-DD')
      }))
      // 按开始时间排序,同一天内按结束时间、再按标题排序,保证展示顺序稳定
      list.sort((a, b) => {
        const startDiff = new Date(a.start) - new Date(b.start)
        if (startDiff !== 0) return startDiff
        const endDiff = new Date(a.end) - new Date(b.end)
        if (endDiff !== 0) return endDiff
        return (a.title || '').localeCompare(b.title || '')
      })
      successCallback(list)
    } else {
      failureCallback()
    }
  }).catch(failureCallback)
}

3. 事件回调

实战:事件日历与外部报表的联动方案

下面是一个抽象化的实战场景:在某个运营看板页面,我们用 FullCalendar 搭建了一个「事件日历」,并和右侧的数据分析报表(通过 iframe 嵌入)联动,整体思路可以概括为三步:

  1. Calendar 只负责展示事件排期
    • 通过 events: this.fetchEvents 懒加载当前视图范围内的事件,避免一次性加载整年数据。
    • 接口返回后在前端做一次格式化,转成 FullCalendar 认可的事件对象:
      • id: 使用 eventId 标识事件;
      • title: 使用 eventTitle 作为日历上展示的文案;
      • start / end: 用 moment 格式化为 YYYY-MM-DD,结束时间额外 +1 天,避免跨天活动少算一天。
    • 在调用 successCallback(list) 前对 liststartendtitle 排序,保证同一天内多事件的展示顺序稳定(见上文「事件排序」)。
fetchEvents (info, successCallback, failureCallback) {
  requestAPI(api.fetchCalendarEvents, {
    startTime: info.start.getTime(),
    endTime: info.end.getTime()
  }).then(data => {
    if (data?.length) {
      const list = data.map(item => ({
        id: item.eventId,
        title: item.eventTitle,
        start: moment(item.startTime).format('YYYY-MM-DD'),
        end: moment(item.endTime + MS_PER_DAY).format('YYYY-MM-DD')
      }))
      list.sort((a, b) => {
        const startDiff = new Date(a.start) - new Date(b.start)
        if (startDiff !== 0) return startDiff
        const endDiff = new Date(a.end) - new Date(b.end)
        if (endDiff !== 0) return endDiff
        return (a.title || '').localeCompare(b.title || '')
      })
      successCallback(list)
    } else {
      failureCallback()
    }
  }).catch(failureCallback)
}
  1. 点击日历事件,高亮并联动外部报表
    • eventClick 中拿到被点击的事件,做两件事:
      • 把上一次选中的事件颜色还原为默认蓝色;
      • 把当前事件改成高亮色,并记录 currentEventId
    • 同时,基于事件编号 eventId 拼接外部报表地址,赋值给 pageUrl,iframe 会自动切到该事件对应的数据分析页面:
handleEventClick (clickInfo) {
  if (clickInfo?.event?.id) {
    this.currentEvents.forEach(event => {
      if (event.id === this.currentEventId) {
        event.setProp('color', '#3a79eb')
      }
    })
    clickInfo.event.setProp('color', '#db3491')
    this.currentEventId = clickInfo.event.id
    this.pageUrl = `${REPORT_BASE_URL}?eventId=${clickInfo.event.id}`
  }
}
  1. 通过插槽自定义事件渲染
    • 使用 eventContent 插槽可以灵活控制日历单元里的展示结构,比如在运营看板里我们希望同时显示「活动时间 + 活动名称」:
<FullCalendar class="calendar-app-calendar" :options="calendarOptions">
  <template v-slot:eventContent="arg">
    <b>{{ arg.timeText }}</b>
    <i>{{ arg.event.title }}</i>
  </template>
</FullCalendar>

综合以上三点,一个完整的「事件日历 + 数据分析联动」就搭建好了:
使用者只需要在日历上点选某个事件,对应的外部报表就会自动切换到该事件的分析视图,从「时间排期」自然跳转到「结果分析」,大大提升日常分析效率。

彻底讲透浏览器缓存机制,吊打面试官

2026年3月10日 17:24

第一层:幼儿园阶段 —— 为什么要有缓存?

首先要明白一个铁律:网络请求很慢,内存和硬盘很快

想象一下:你是一位厨师(浏览器),客人(用户)点了一份宫保鸡丁(网页)。

没有缓存

  • 每次客人来,你都要打电话去农场(服务器)问:"有鸡肉吗?有花生吗?"
  • 农场说"有",你再等快递送过来
  • 客人饿晕了,页面还在转圈

有缓存

  • 第一次做完宫保鸡丁,你把菜谱和食材存进冰箱(本地缓存)
  • 下次客人点同样的菜,直接从冰箱拿,5秒上桌
  • 农场偶尔打电话告诉你:"菜谱更新了",你再同步一下

缓存的本质:用空间(本地存储)换时间(网络延迟),同时保证数据新鲜度。


第二层:小学阶段 —— 缓存的"三级冰箱"

浏览器有三层缓存,像俄罗斯套娃,层层查找:

Service Worker(离线缓存)→ Memory Cache(内存缓存)→ Disk Cache(磁盘缓存)→ Push Cache(HTTP/2推送缓存)→ 网络请求

1. Service Worker Cache(私藏小金库)

  • 位置:浏览器主线程之外,独立运行
  • 特点:开发者完全控制,可以离线访问
  • 场景:PWA应用,飞机模式下也能刷知乎

2. Memory Cache(案板上的食材)

  • 位置:内存(RAM)
  • 特点:极快(纳秒级),但容量小,页面关闭就消失
  • 存储内容:Base64图片、小体积JS/CSS、当前页面的资源

3. Disk Cache(冰箱冷冻层)

  • 位置:硬盘(SSD/HDD)
  • 特点:较慢(毫秒级),容量大,持久保存
  • 存储内容:大文件、不常变的资源、跨会话共享

4. Push Cache(服务员提前备菜)

  • 位置:HTTP/2连接内
  • 特点:服务器主动推送,未被使用就丢弃(会话期内)
  • 场景:HTTP/2 Server Push,提前把可能需要的资源塞过来

查找顺序:Service Worker → Memory → Disk → Push → 网络

面试考点:为什么同样的资源,刷新页面后from memory cache变成from disk cache

  • 首次加载:资源进Memory + Disk
  • 刷新页面:HTML重新解析,原Memory缓存被清,从Disk恢复
  • 新开标签:跨标签共享Disk缓存

第三层:中学阶段 —— HTTP缓存协议(协商 vs 强缓存)

这是面试最高频的考点,两种缓存策略像两条不同的保鲜规则:

强缓存(Freshness Strategy)—— 看保质期

浏览器不问服务器,直接拿本地缓存。

判断依据ExpiresCache-Control

┌─────────────────────────────────────────┐
│  浏览器:这包薯片保质期到明天,今天能吃吗?  │
│  自己看标签 → 能吃 → 直接吃(不发请求)      │
└─────────────────────────────────────────┘

HTTP头

Expires: Wed, 21 Oct 2025 07:28:00 GMT  # 绝对时间(HTTP/1.0,已过时)

Cache-Control: max-age=31536000         # 相对时间,秒(HTTP/1.1,推荐)
Cache-Control: no-cache                 # 可以存,但每次要协商
Cache-Control: no-store                 # 完全不存,隐私数据
Cache-Control: private                  # 仅浏览器存,CDN不存
Cache-Control: public                   # 大家都能存

状态码200 (from disk cache)200 (from memory cache)

协商缓存(Validation Strategy)—— 问仓库还有没有

缓存过期了,但不确定服务器有没有新版本,带着"证据"去问

判断依据Last-Modified/If-Modified-SinceETag/If-None-Match

┌─────────────────────────────────────────┐
│  浏览器:这包薯片过期了,但看起来没坏?      │
│  打电话给仓库:"批次号A123,还有货吗?"     │
│  仓库:"还是A123,没换"304 Not Modified  │
│  仓库:"现在批次B456了"200 + 新货       │
└─────────────────────────────────────────┘

HTTP头

# 方案A:时间戳(秒级精度,可能不准)
Last-Modified: Wed, 21 Oct 2025 07:28:00 GMT
If-Modified-Since: Wed, 21 Oct 2025 07:28:00 GMT  # 请求头

# 方案B:内容指纹(优先级更高,精确到字节)
ETag: "33a64df5"  # 服务器生成的唯一标识(文件内容哈希)
If-None-Match: "33a64df5"  # 请求头

状态码304 Not Modified(没改,用缓存)或 200(改了,重新下载)

完整决策流程(必背)

┌─────────────┐
  发起请求    
└──────┬──────┘
       
┌─────────────────┐
 Service Worker? │──Yes──► 查SW缓存 ──► 有?返回 : 走网络
└────────┬────────┘ No
         
┌─────────────────┐
  有Cache-Control?│──No──► 查Expires ──► 过期?走协商 : 走强缓存
└────────┬────────┘ Yes
         
┌─────────────────┐
 max-age过期了? │──No──► 200 from cache(强缓存命中)
└────────┬────────┘ Yes
         
┌─────────────────┐
  有ETag?        │──Yes──► 发If-None-Match ──► 304? 用缓存 : 200更新
└────────┬────────┘ No
         
┌─────────────────┐
  有Last-Modified?│──Yes──► 发If-Modified-Since ──► 304? 用缓存 : 200更新
└────────┬────────┘ No
         
    直接请求新资源

口诀:先强缓存(看时间),再协商(问指纹),最后才下载。


第四层:大学阶段 —— 缓存的"暗坑"与黑魔法

坑点1:Cache-Control 的"障眼法"

Cache-Control: no-cache

误区:以为不能缓存?
真相:可以缓存,但每次用之前必须协商(问服务器能不能用)。

Cache-Control: no-store

真相:这才是真正的禁止缓存,敏感数据用这个。

Cache-Control: max-age=0

效果:等于 no-cache,立即过期,走协商。

坑点2:ETag 的"分布式灾难"

场景:负载均衡,3台服务器轮询

请求1 → 服务器A → ETag: "abc-123"
请求2 → 服务器B → ETag: "abc-456"  # 同样内容,不同ETag!
请求3 → 服务器C → ETag: "abc-789"

后果:明明内容没变,ETag不同导致缓存失效,反复下载。

解决

  • Last-Modified 替代(时间戳一致)
  • 或配置服务器用内容哈希生成ETag(MD5相同则ETag相同)
  • 或加 Cache-Control: public 让CDN统一处理

坑点3:304 的"性能陷阱"

误区:304没下载内容,所以很快?
真相:304仍然要建立TCP连接(HTTPS还要TLS握手),发送HTTP请求,等待服务器响应。

优化:强缓存直接本地读取,零网络开销

数据对比

  • 强缓存:0ms,本地磁盘读取
  • 304协商:50-200ms,取决于RTT
  • 200重新下载:100ms-数秒,取决于资源大小

坑点4:Vary 头的"缓存分裂"

Vary: Accept-Encoding, User-Agent

作用:告诉缓存服务器,哪些请求头不同就要存不同版本

后果

  • Accept-Encoding: gzip → 存压缩版
  • Accept-Encoding: br → 存Brotli版
  • User-Agent: Mobile → 存移动端版

:Vary头太多 → 缓存爆炸,命中率暴跌。


第五层:博士阶段 —— 缓存一致性模型(强一致性 vs 最终一致)

缓存失效的三种策略(计算机科学的终极难题)

策略 描述 适用场景
Cache-Aside(旁路缓存) 应用先查缓存,没命中查DB,再回填缓存 读多写少,最常用
Read-Through(直读) 缓存没命中自动查DB,对应用透明 需要缓存中间件(如Redis)
Write-Through(直写) 写缓存同时写DB,同步完成 强一致性要求
Write-Behind(异步写) 先写缓存,异步批量写DB 高性能,容忍短暂不一致
Refresh-Ahead(预刷新) 缓存即将过期时自动后台更新 热点数据,不允许击穿

浏览器特有的"新鲜度计算"(Heuristic Freshness)

场景:服务器没给 Cache-Control 也没给 Expires,但给了 Last-Modified

浏览器黑魔法

新鲜期 = (当前时间 - Last-Modified时间) × 10%

比如文件一年前修改,浏览器认为能缓存 365天 × 10% = 36.5天

面试杀招:解释为什么"啥也没配"的资源也会被缓存,以及为什么这是不可靠的(各浏览器算法不同)。

缓存污染与中毒(安全视角)

攻击场景

  1. 攻击者请求 script.js?callback=alert(1)
  2. CDN/浏览器缓存了这个带恶意回调的版本
  3. 正常用户请求 script.js(不带参数),但缓存命中了带毒版本

防御

Cache-Control: no-cache  # 有查询字符串就不缓存
# 或
Vary: Query-String        # 不同参数不同缓存

第六层:上帝视角 —— 现代浏览器的缓存架构演进

从单进程到多进程:缓存的"线程安全"

上古时代

  • 所有标签页共享一个缓存目录
  • 标签A缓存的JS,标签B直接读取
  • 问题:崩溃一个标签,全浏览器缓存损坏

现代架构(Chrome Site Isolation)

浏览器进程(Browser Process)
    ↓
网络服务进程(Network Service)← 统一处理HTTP缓存
    ↓
渲染进程A(Renderer)──┐
渲染进程B(Renderer)──┼── 通过Mojo IPC访问缓存,相互隔离
渲染进程C(Renderer)──┘

关键改进:HTTP缓存由独立进程管理,Renderer崩溃不影响缓存完整性。

磁盘缓存的"物理结构"(Chrome的SimpleCache)

磁盘缓存目录
├── index          # 索引文件(快速查找)
├── data_0         # 数据块文件(小块资源)
├── data_1
├── data_2
├── data_3
├── f_000001       # 大文件(独立存储)
├── f_000002
└── ...

存储策略

  • 小文件(<16KB):存 data_* 块文件,减少碎片
  • 大文件:独立 f_xxxxxx 文件,避免阻塞小文件读取
  • 内存映射:热点索引常驻内存,磁盘IO异步化

缓存淘汰算法(LRU+优先级混合)

Chrome使用改进的LRU

优先级 = 访问频率 × 时间衰减 + 资源类型权重

HTML/JS/CSS:高权重(页面核心)
图片:中权重
视频:低权重(体积大,但可能不再看)

淘汰顺序

  1. 先删低优先级 + 最久未访问
  2. 磁盘空间不足时,触发后台清理
  3. 用户可手动"清除浏览数据"

第七层:Service Worker —— 缓存的"终极形态"

从"浏览器控制"到"开发者控制"

// sw.js - 拦截所有请求,完全自定义缓存策略
self.addEventListener('fetch', event => {
    event.respondWith(
        caches.match(event.request).then(response => {
            // 1. 缓存命中?直接返回
            if (response) return response;
            
            // 2. 否则走网络
            return fetch(event.request).then(networkResponse => {
                // 3. 动态更新缓存
                caches.open('v1').then(cache => {
                    cache.put(event.request, networkResponse.clone());
                });
                return networkResponse;
            });
        })
    );
});

缓存策略矩阵(Google推荐)

策略 代码模式 适用场景
Cache First 先查缓存,没命中再网络 静态资源,离线优先
Network First 先网络,失败再缓存 API数据,实时性优先
Stale-While-Revalidate 立即返回缓存,后台更新 新闻列表,快速+新鲜兼顾
Cache Only 只用缓存 纯离线应用
Network Only 只用网络 实时性极强(股票行情)

背景同步(Background Sync)—— 离线提交的救赎

// 用户离线时提交表单
navigator.serviceWorker.ready.then(registration => {
    registration.sync.register('submit-form');
});

// SW中处理
self.addEventListener('sync', event => {
    if (event.tag === 'submit-form') {
        event.waitUntil(
            // 网络恢复后自动重试
            sendFormDataFromIndexedDB()
        );
    }
});

第八层:CDN 与边缘缓存 —— 缓存的"全球化"

多层缓存架构

用户浏览器 ──► CDN边缘节点 ──► 源站服务器
     │              │              │
  Memory/      Memory/Disk/      Disk/DB
  Disk Cache   全局分布式缓存     原始数据

CDN缓存的"回源策略"

指令 含义
Cache-Control: s-maxage=3600 CDN共享缓存1小时(覆盖max-age)
CDN-Cache-Control: max-age=3600 专用CDN头(Cloudflare等支持)
Surrogate-Control: max-age=3600 另一种CDN专用头

缓存穿透、击穿、雪崩(经典面试三连)

问题 现象 解决
穿透 查询不存在的数据,每次都打到DB 布隆过滤器,或缓存空值
击穿 热点key过期,瞬间大量请求打DB 互斥锁,或逻辑过期(永不过期,异步刷新)
雪崩 大量key同时过期,DB崩溃 随机过期时间,多级缓存,熔断降级

浏览器层面的防御

// Stale-While-Revalidate 模式防止击穿
const cache = await caches.open('api-cache');
const cached = await cache.match(request);

// 立即返回缓存(即使过期)
if (cached) {
    // 后台异步更新
    fetch(request).then(response => cache.put(request, response.clone()));
    return cached;
}

第九层:实战优化 —— 从"能用"到"极速"

项目场景 1:高频迭代的 B 端管理系统(如:飞书、钉钉网页版)

  • 业务特点

    1. 代码量巨大(JS 动辄 5MB 以上)。
    2. 版本更新极快(可能每天都要修复 Bug 发版)。
    3. 痛点:发版后,由于浏览器缓存了旧的 JS,用户报错(缓存不一致),或者发版后用户加载太慢。
  • 极致优化方案(Webpack + Nginx)

    • 第一步(基础):Webpack 配置 contenthash。如果只改了“客户模块”的代码,打包出来的 customer.a1b2.js 名字变了,但“合同模块” contract.c3d4.js 名字不变。
    • 第二步(Nginx 调优)
      • index.html 设置 no-cache:每次打开页面,浏览器都得问服务器“菜单换了吗?”。
      • 对 JS/CSS 设置 public, max-age=31536000, immutable
    • 解决的实际问题
      1. 秒开:用户第二次打开飞书,除了 HTML 那个几十字节的请求,所有几 MB 的 JS 全部从本地磁盘 0ms 读取,完全不走网络
      2. 更新不报错:发版后,HTML 里的 JS 路径变成了新哈希,浏览器发现名字变了,自动下载新代码。旧代码在缓存里互不干扰,彻底解决“发版后要清缓存”的低级 Bug

项目场景 2:内容型 App 或 社交平台(如:小红书、今日头条)

  • 业务特点

    1. 首页是长列表(Feed 流)。
    2. 用户对“白屏”极度敏感,多转一秒圈圈就要关掉 App。
    3. 痛点:每次点开 Feed 流,都要等接口返回(数据协商),用户会看到 1-2 秒的 Loading 动画。
  • 极致优化方案(SWR / staleTime 模式)

    • 项目实践:使用 React QuerySWR 库请求首页列表接口。
    • 配置:设置 staleTime: 5分钟
    • 解决的实际问题
      1. 消灭 Loading 圈圈:用户在 5 分钟内反复切换页面,数据直接从内存缓存拿,瞬间呈现,完全没有加载状态
      2. “先看后换”:如果超过 5 分钟,用户点开时,页面先展示上次留下的旧数据(不白屏),同时后台静默发请求,等新笔记刷出来了,再无感替换。这就是用户感觉这些 App 运行飞快的核心秘密。

项目场景 3:在线教育或视频平台(如:B站、慕课网)

  • 业务特点

    1. 视频分片文件(.ts 文件)非常多且大。
    2. 用户喜欢反复看同一个知识点(反复拖动进度条)。
    3. 痛点:浏览器默认的 Disk Cache(磁盘缓存)像个“黑盒”,空间满了会随机删文件。用户回头看一段视频时,发现刚才看过的片段被浏览器偷偷删了,又得重新缓冲,浪费流量且卡顿。
  • 极致优化方案(IndexedDB 手动存储)

    • 项目实践:在网页端写一个 VideoCache 类,利用 Service Worker 拦截视频请求。
    • 逻辑
      1. 视频下载后,不交给浏览器自动管,而是由代码强行存入 IndexedDB(这是浏览器里一个几百 MB 到几 GB 的永久数据库)。
      2. 下次进度条拖回来,代码先去 IndexedDB 查:“这个片段我有吗?”如果有,直接转成 Blob 给播放器。
    • 解决的实际问题
      1. 省钱:公司带宽费大幅下降,因为用户反复看同一个视频,流量消耗为 0。
      2. 极致丝滑:即便用户断网了,只要之前看过的部分,进度条随便拖,完全不缓冲

总结:我该怎么选?

你的项目类型 核心要用的缓存技术 一句话理由
普通的网站 / B端后台 Webpack 哈希 + Nginx Immutable 保证发版不报错,重复访问 0 耗时。
手机端 Feed 流 / 实时看板 SWR (stale-while-revalidate) 消灭 Loading 转圈,让用户感觉“数据瞬间就在那”。
大文件 / 离线优先 / 播放器 Service Worker + IndexedDB 绕过浏览器不可控的清理机制,实现持久化的二进制存储。

面试对话示范:

面试官:你在项目中怎么做缓存优化的? :我会分场景。比如在我们那个 [XX 管理系统] 里,我利用 Webpack 的 contenthash 配合 Nginx 的 immutable 头部,把静态资源加载耗时降到了 0ms;而在 [XX 首页 Feed 流] 中,我为了解决接口返回慢导致的白屏,引入了 SWR 机制,先用旧缓存渲染 UI 提升首屏速度,再后台静默更新。


第十层:未来趋势 —— 缓存的" Web 3.0 时代"

1. 从“存响应”到“存数据”:结构化缓存

  • 现在的痛点(为什么虚): 现在的 Cache API 就像一个死板的仓库。你存了一个 5MB 的 JSON 接口响应,如果你只想查“价格 > 100”的商品,你必须先把整个 JSON 读进内存,用 JS 去遍历。这太费内存和 CPU 了
  • 落地的业务场景大型离线应用(如:Figma、在线文档、移动端商城)
    • 未来进化:浏览器尝试将 IndexedDB(数据库)和 Cache API(网络缓存)融合。
    • 面试谈资:你可以说:“现在的缓存是 URL 维度的,未来的缓存应该是 数据维度的。像 Google 正在推进的存储标准,就是希望让 Service Worker 能直接对缓存的二进制数据流进行搜索和过滤,而不是全量解析,这对低端机极其友好。”

2. 从“手动预取”到“AI 智能猜”: Speculation Rules API

  • 现在的痛点(为什么虚): 现在的 Preload(预加载)是程序员硬编码的。代码写死:用户点“详情页”时加载“评论插件”。 问题是:有的用户根本不看评论,你白白浪费了用户的流量。
  • 落地的业务场景新闻资讯流(如:今日头条、知乎)
    • 现在的技术动作:Google 已经推出了 Speculation Rules API。它不再是简单的标签,而是一套动态规则。
    • AI 的介入:浏览器观察用户的路径。如果 90% 的人在看完文章后会点开“相关推荐”,浏览器会在后台自动、低优先级地缓存下个页面的内容。
    • 面试谈资:你可以聊 “预测性性能优化”。这比单纯的缓存更超前,它是在用户还没动作时,通过浏览器的学习模型实现“零时延切换”。

3. 从“中心化”到“邻居互传”:去中心化缓存(P2P)

  • 现在的痛点(为什么虚): 现在的缓存路径是:你 → CDN → 源站。 问题是:双 11 时,CDN 也会崩;而且公司要付给 CDN 供应商巨额的流量费。
  • 落地的业务场景大型游戏资源下载(如:米哈游网页端、在线高清视频)
    • 核心逻辑:如果我邻居刚才看过了《流浪地球2》,我再看的时候,浏览器能不能直接从邻居的电脑(或路由器)里通过局域网把缓存切片传给我?而不必再去几千公里外的服务器拿?
    • 面试谈资:你可以提到 “内容寻址缓存”。现在的缓存是按“链接”找,未来是按“内容指纹(Hash)”找。即便链接变了,只要文件内容一样,就能从全球网络任何一个节点获取,这能帮公司省下 70% 的 CDN 费用。

终极回答策略:从协议深度到架构广度的四维阐述

1. 核心定性(展现系统思维)

“我认为浏览器缓存不是孤立的几个 HTTP 头,而是一套由多方协同的复杂调度系统。它向下对接底层的浏览器内核存储(Memory/Disk),向上承接前端工程化的构建产物(Webpack/Vite),向外延伸至全球分布的 CDN 节点。它的本质是在数据新鲜度(Freshness)、**加载延迟(Latency)网络成本(Cost)**之间寻找业务最优解。”

2. 决策链路(展现协议精度)

“在实际执行中,我将其总结为**‘两级验证、零 RTT 追求’**。

  • 第一级是本地自校验:优先匹配 Cache-Control。我的准则是‘静态资源全量 immutable,入口文件严格 no-cache’,以此追求绝对的 0 RTT
  • 第二级是云端再确认:当强缓存失效,通过 ETag 进行字节级比对。我会特别关注分布式环境下的 ETag 漂移问题,确保 304 命中率不因多台服务器生成的指纹不一致而崩盘。”

3. 工程落地(展现全栈理解)

“缓存策略必须与 CI/CD 流程深度绑定。

  • 构建层,通过 contenthash 实现‘文件内容即标识’,让长效强缓存成为可能。
  • 应用层,通过 Stale-While-Revalidate(SWR)模式,将网络请求异步化。即‘先用旧数据渲染 UI,后台静默更新缓存’,彻底消除用户感知的 Loading 状态,实现**‘瞬时响应’**的极致 UX。”

4. 架构设计(展现大厂视野)

“针对大型复杂应用,我会设计**‘三层递进式存储架构’**:

  • L1(动态拦截层):利用 Service Worker 自定义缓存策略,处理离线可用和高频接口拦截。
  • L2(标准协议层):严格遵循 HTTP 语义,利用磁盘缓存存储海量静态资源。
  • L3(边缘算力层):在 CDN 边缘节点完成 Vary 头的逻辑判断或 A/B 测试注入,减少回源压力。 这种设计能让首屏时间(FCP)在各种网络环境下保持在 300ms 级别。”

避坑指南:面试中的 3 个“反直觉”细节(必考点)

细节点 你的深度回答(加分项)
no-cache 的字面陷阱 “不要被名字误导,no-cache 并不禁用缓存,它只是强制每次使用前必须通过协商确认。真正禁写磁盘的是 no-store。”
304 的隐藏成本 “304 虽省流量但不省时间。在高延迟环境下(RTT > 100ms),一次 304 协商可能比下载一个 10KB 的文件更慢,所以强缓存才是性能的终点。”
Vary 头的副作用 “慎用 Vary: User-Agent。它会让 CDN 为成千上万个浏览器版本各存一份缓存,导致命中率雪崩,甚至拖垮源站。”

终极速记卡片(临考前 30 秒看这个)

  • 一个中心:以消除 RTT(往返时延)为中心。
  • 两个基本点:强缓存看保质期(过期前不问),协商缓存看指纹(过期了再问)。
  • 三项黑科技immutable(刷新不重验)、SWR(先吃陈粮再换新米)、Service Worker(离线救星)。
  • 四对头Expires/Cache-Control vs Last-Modified/ETag

速记核心关键词

面试前记住这 5 个关键词,串联整个知识网:

关键词 含义
两级验证 强缓存(时间)+ 协商缓存(指纹)
三级存储 Memory → Disk → Service Worker
四对头 Cache-Control/Expires + ETag/Last-Modified
304陷阱 协商仍有开销,强缓存才是极致性能
SW革命 开发者接管缓存,离线优先成为可能

最后一句面试杀招

"优秀的缓存策略不是配置几个HTTP头,而是深入理解浏览器从内存到磁盘、从本地到CDN的完整缓存链路,让数据在最合适的位置以最合适的形态存在,在性能、新鲜度和一致性之间找到业务最优解。"

箭头函数与 this 面试题深度解析:从原理到实战

作者 swipe
2026年3月10日 17:12

为什么箭头函数如此重要

在现代 JavaScript 开发中,你是否遇到过这些场景:

  • 在 React 组件中,事件处理函数的 this 总是 undefined
  • 在定时器或异步回调中,访问不到外层的 this
  • 看到别人代码中的 var _this = this,不理解为什么要这样写
  • 面试官问"箭头函数和普通函数的区别",只能回答"语法更简洁"

箭头函数是 ES6 引入的最重要特性之一,它不仅仅是语法糖,更是解决了 JavaScript 中 this 绑定的历史难题。在 React、Vue 等现代框架中,箭头函数已经成为标配写法。

本文收益

  • 深入理解箭头函数的 this 绑定机制
  • 掌握箭头函数的各种简写技巧和使用场景
  • 学会判断何时使用箭头函数,何时使用普通函数
  • 通过 4 道经典面试题,建立完整的 this 知识体系
  • 了解箭头函数在实际项目中的最佳实践

一、箭头函数的本质:词法作用域的 this

1.1 什么是箭头函数

箭头函数(Arrow Function)是 ES6 引入的新函数语法,因其使用 => 符号而得名,也被称为"胖箭头"函数。

** 图9-1 箭头函数的箭头**

基础语法结构

// 基础模板
(参数) => { 函数体 }

// 实际示例
const add = (a, b) => {
  return a + b;
}

// 简写形式
const add = (a, b) => a + b;

核心特性

  1. 更简洁的语法:相比传统函数表达式,代码量可减少 30%-50%
  2. 不绑定 this:this 由外层作用域决定,不受调用方式影响
  3. 没有 arguments 对象:需要使用剩余参数 ...args 替代
  4. 不能作为构造函数:不能使用 new 关键字调用

1.2 箭头函数的语法解析

语法结构分解

要素 描述 作用
() 参数列表 定义函数输入。单个参数可省略括号,无参数或多参数必须保留
=> 箭头符号 连接参数和函数体,标识这是箭头函数
{} 函数体 包含执行语句。单条返回语句可省略大括号和 return

两种常见写法对比

// 方式1:内联方式(推荐用于简单逻辑)
var nums = [10, 20, 30, 40]
nums.forEach((value, index, array) => {
  console.log(value, index, array)
})

// 方式2:完整方式(适用于复杂逻辑或需要复用)
var foo = (value, index, array) => {
  console.log(value, index, array)
}
nums.forEach(foo)

选择建议

  • 简单的一次性逻辑:使用内联方式,代码更直观
  • 复杂逻辑或需要复用:抽取为独立函数,提高可维护性
  • 团队协作:优先考虑可读性,而非极致简洁

1.3 箭头函数的三种简写技巧

简写1:省略参数括号

条件:只有一个参数时可省略

// 简写前
nums.forEach((item) => {
  console.log(item)
})

// 简写后
nums.forEach(item => {
  console.log(item)
})

简写2:省略函数体大括号

条件:函数体只有一条语句且需要返回值

// 完整写法
var newNums = nums.filter(item => {
  return item % 2 === 0
})

// 简写(隐式返回)
var newNums = nums.filter(item => item % 2 === 0)

隐式返回:省略大括号后,表达式的结果会自动作为返回值,无需 return 关键字。

实战案例

const books = [
  { title: "Book A", rating: 4.5 },
  { title: "Book B", rating: 3.9 },
  { title: "Book C", rating: 4.7 }
];

// 链式调用 + 箭头函数简写
const titles = books
  .filter(book => book.rating > 4)
  .map(book => book.title);

console.log(titles); // ["Book A", "Book C"]

// 对比:传统写法需要 10+ 行代码
var highRatingBooks = [];
for (var i = 0; i < books.length; i++) {
  if (books[i].rating > 4) {
    highRatingBooks.push(books[i]);
  }
}

var titles2 = [];
for (var i = 0; i < highRatingBooks.length; i++) {
  titles2.push(highRatingBooks[i].title);
}

这种链式调用在 React、Vue 等现代框架中随处可见,是必须掌握的技能。

简写3:返回对象字面量

陷阱:直接返回对象会产生语法冲突

// ❌ 错误写法:大括号被解析为函数体
var bar = () => { name: "小吴", age: 18 }
console.log(bar()) // undefined

// ✅ 正确写法:用小括号包裹对象
var bar = () => ({ name: "why", age: 18 })
console.log(bar()) // { name: "why", age: 18 }

// 或者使用完整写法(推荐用于复杂对象)
var bar = () => {
  return { name: "小吴", age: 18 }
}

** 图9-2 简写3-通俗易懂的写法及结果**

原理解析

  • JavaScript 引擎会将 {} 优先解析为函数体,而非对象字面量
  • 小括号 () 强制将内容视为表达式,避免歧义
  • 类似数学表达式中的括号,改变运算优先级

代码规范建议

  • 简单对象:使用小括号包裹
  • 复杂对象:使用完整 return 语句,提高可读性
  • 避免过度简写,团队协作中可读性优先

二、箭头函数的 this:词法绑定的革命

2.1 箭头函数没有自己的 this

核心概念:箭头函数不创建自己的 this 上下文,而是继承外层作用域的 this。

社区中有两种说法:

  1. "箭头函数没有 this"
  2. "箭头函数的 this 由外层作用域决定"

准确理解

  • 箭头函数本身不绑定 this
  • 箭头函数内的 this 是从外层(非箭头函数)作用域继承而来
  • 这种继承是词法的(静态的),在函数定义时就确定了
var name = "小吴"

var foo = () => {
  console.log(this);
}

foo()                    // window
var obj = { foo: foo }
obj.foo()                // window(不受隐式绑定影响)
foo.call("这是call调用")  // window(不受显式绑定影响)

为什么三种调用方式都是 window?

  1. foo 的外层作用域是全局作用域
  2. 全局作用域的 this 指向 window(浏览器环境)
  3. 箭头函数不受调用方式影响,始终使用外层的 this

2.2 箭头函数 vs 普通函数的 this 对比

普通函数(受调用方式影响)

var name = "小吴"

function foo() {
  console.log(this);
}

var obj = {
  name: "你已经被小吴绑定到obj上啦",
  foo: foo
}

obj.foo() // { name: '你已经被小吴绑定到obj上啦', foo: [Function: foo] }

箭头函数(不受调用方式影响)

var name = "小吴"

var foo = () => {
  console.log(this);
}

var obj = {
  name: "你已经被小吴绑定到obj上啦",
  foo: foo
}

obj.foo() // window

关键差异

  • 普通函数:this 由调用方式决定(隐式绑定生效)
  • 箭头函数:this 由定义位置的外层作用域决定(隐式绑定无效)

2.3 箭头函数解决的经典问题

问题场景:异步回调中的 this 丢失

在 ES6 之前,异步回调中访问外层 this 是一个常见痛点:

// ES5 时代的解决方案:保存 this 引用
var obj = {
  data: [],
  getData: function() {
    var _this = this  // 保存外层 this

    setTimeout(function() {
      var result = ["小吴", 'why', 'JS高级']
      _this.data = result  // 通过闭包访问外层 this
      console.log(_this)
    }, 2000)
  }
}

obj.getData()

为什么需要 var _this = this

  1. setTimeout 的回调函数是独立调用,this 指向 window
  2. 无法直接访问 getData 方法的 this(obj 对象)
  3. 通过变量保存 this,利用闭包机制保持引用

** 图9-3 var _this = this操作内存图**

内存机制解析

  • obj 对象存储在堆内存中
  • getData 方法中的 _this 变量保存了 obj 的引用
  • setTimeout 回调形成闭包,持有 _this 的引用
  • 即使回调函数的 this 指向 window,仍可通过 _this 访问 obj

箭头函数的优雅解决方案

// ES6 箭头函数方案
var obj = {
  data: [],
  getData: function() {
    setTimeout(() => {
      var result = ["小吴", 'why', 'JS高级']
      this.data = result  // 直接使用 this,指向 obj
      console.log(this)
    }, 2000)
  }
}

obj.getData()

优势

  • 无需 var _this = this 的样板代码
  • this 自动指向外层作用域(getData 方法的 this)
  • 代码更简洁,意图更清晰

2.4 实战场景:网络请求中的 this

在实际项目中,网络请求是箭头函数最常见的应用场景:

** 图9-4 正式网络请求存储(this指向)**

典型模式

// Vue 组件中的网络请求
export default {
  data() {
    return {
      userList: []
    }
  },
  methods: {
    fetchUsers() {
      // 使用箭头函数,this 自动指向 Vue 实例
      fetch('/api/users')
        .then(res => res.json())
        .then(data => {
          this.userList = data  // this 指向 Vue 实例
        })
    }
  }
}

// React 类组件中的网络请求
class UserList extends React.Component {
  state = { users: [] }

  fetchUsers = () => {
    // 箭头函数属性,this 自动绑定到组件实例
    fetch('/api/users')
      .then(res => res.json())
      .then(data => {
        this.setState({ users: data })
      })
  }
}

小结

  • 箭头函数不绑定 this,继承外层作用域的 this
  • 解决了异步回调中 this 丢失的问题
  • 在现代框架中是处理事件和异步操作的标准方案
  • 理解箭头函数的 this 机制,比死记硬背规则更重要

三、箭头函数的使用场景

3.1 适合使用箭头函数的场景

使用场景 描述 示例
回调函数 事件监听、异步处理中保持外层 this setTimeout(() => console.log(this), 1000)
数组操作 配合 map、filter、reduce 等方法 nums.map(n => n * 2)
简洁表达 一行代码完成函数定义 const square = x => x * x
链式调用 Promise 链和流式 API fetch(url).then(res => res.json())
柯里化 简化函数柯里化实现 const add = x => y => x + y

3.2 不适合使用箭头函数的场景

1. 对象方法

// ❌ 错误:this 不指向对象
const obj = {
  name: "小吴",
  sayName: () => {
    console.log(this.name) // undefined
  }
}

// ✅ 正确:使用普通函数
const obj = {
  name: "小吴",
  sayName: function() {
    console.log(this.name) // 小吴
  }
}

2. 原型方法

// ❌ 错误
Person.prototype.sayName = () => {
  console.log(this.name)
}

// ✅ 正确
Person.prototype.sayName = function() {
  console.log(this.name)
}

3. 需要动态 this 的场景

// ❌ 错误:事件处理中需要访问 DOM 元素
button.addEventListener('click', () => {
  this.classList.toggle('active') // this 不是 button
})

// ✅ 正确
button.addEventListener('click', function() {
  this.classList.toggle('active') // this 是 button
})

四、this 面试题深度解析

在学习了箭头函数后,我们已经掌握了 this 的完整知识体系。接下来通过 4 道经典面试题,验证学习成果。

** 图9-5 基础篇面试题大纲**

4.1 面试题1:绑定规则综合考察

题目:判断以下代码的输出

var name = "window"
var person = {
  name: "person",
  sayName: function() {
    console.log(this.name);
  }
};

function sayName() {
  var sss = person.sayName
  sss();                    // ?
  person.sayName();         // ?
  (person.sayName)();       // ?
  (b = person.sayName)();   // ?
}

sayName()

考点分析

  • 隐式绑定
  • 独立函数调用
  • 间接函数引用

逐行解析

var name = "window"
var person = {
  name: "person",
  sayName: function() {
    console.log(this.name);
  }
};

function sayName() {
  var sss = person.sayName
  sss();                    // "window" - 独立调用,默认绑定
  person.sayName();         // "person" - 隐式绑定
  (person.sayName)();       // "person" - 括号不改变隐式绑定
  (b = person.sayName)();   // "window" - 间接引用,独立调用
}

sayName()

详细解释

  1. sss()

    • sss 保存的是函数引用(内存地址)
    • 调用时没有对象前缀,属于独立调用
    • 应用默认绑定,this 指向 window
  2. person.sayName()

    • 通过对象调用方法
    • 应用隐式绑定,this 指向 person
  3. (person.sayName)()

    • 括号只是将表达式视为整体,不改变调用方式
    • 本质仍是 person.sayName()
    • 应用隐式绑定,this 指向 person
  4. (b = person.sayName)()

    • 赋值表达式返回函数引用
    • 相当于先执行 b = person.sayName,再执行 b()
    • 属于独立调用,应用默认绑定,this 指向 window

关键要点

  • 函数引用赋值后,调用方式决定 this
  • 括号不改变调用方式,除非内部是赋值表达式
  • 间接引用是独立调用的一种形式

4.2 面试题2:箭头函数与显式绑定

题目:判断以下代码的输出

var name = 'window'

var person1 = {
  name: 'person1',
  foo1: function () {
    console.log(this.name)
  },
  foo2: () => console.log(this.name),
  foo3: function () {
    return function () {
      console.log(this.name)
    }
  },
  foo4: function () {
    return () => {
      console.log(this.name)
    }
  }
}

var person2 = { name: 'person2' }

person1.foo1();                  // ?
person1.foo1.call(person2);      // ?

person1.foo2();                  // ?
person1.foo2.call(person2);      // ?

person1.foo3()();                // ?
person1.foo3.call(person2)();    // ?
person1.foo3().call(person2);    // ?

person1.foo4()();                // ?
person1.foo4.call(person2)();    // ?
person1.foo4().call(person2);    // ?

答案与解析

person1.foo1();                  // "person1" - 隐式绑定
person1.foo1.call(person2);      // "person2" - 显式绑定优先级更高

person1.foo2();                  // "window" - 箭头函数,外层是全局
person1.foo2.call(person2);      // "window" - 箭头函数不受 call 影响

person1.foo3()();                // "window" - 返回普通函数,独立调用
person1.foo3.call(person2)();    // "window" - 返回的函数仍是独立调用
person1.foo3().call(person2);    // "person2" - 对返回的函数显式绑定

person1.foo4()();                // "person1" - 箭头函数继承 foo4 的 this
person1.foo4.call(person2)();    // "person2" - foo4 的 this 被改为 person2
person1.foo4().call(person2);    // "person1" - 箭头函数不受 call 影响

核心考点

  1. foo1 系列:普通函数的隐式绑定和显式绑定

    • 显式绑定(call)优先级高于隐式绑定
  2. foo2 系列:箭头函数的特性

    • 箭头函数定义在对象字面量中,外层作用域是全局
    • call/apply/bind 无法改变箭头函数的 this
  3. foo3 系列:返回普通函数

    • foo3() 返回一个新函数,再调用 () 是独立调用
    • foo3().call(person2) 对返回的函数进行显式绑定
  4. foo4 系列:返回箭头函数

    • 箭头函数的 this 取决于 foo4 执行时的 this
    • foo4.call(person2)() 改变了 foo4 的 this,箭头函数继承这个 this
    • foo4().call(person2) 无法改变箭头函数的 this

记忆技巧

  • 箭头函数的 this 在定义时确定(词法绑定)
  • 普通函数的 this 在调用时确定(动态绑定)
  • 连续调用 ()() 时,每个 () 都是一次独立的调用判断

4.3 面试题3:new 绑定与箭头函数

题目:判断以下代码的输出

var name = 'window'

function Person(name) {
  this.name = name
  this.foo1 = function () {
    console.log(this.name)
  },
  this.foo2 = () => console.log(this.name),
  this.foo3 = function () {
    return function () {
      console.log(this.name)
    }
  },
  this.foo4 = function () {
    return () => {
      console.log(this.name)
    }
  }
}

var person1 = new Person('person1')
var person2 = new Person('person2')

person1.foo1()                   // ?
person1.foo1.call(person2)       // ?

person1.foo2()                   // ?
person1.foo2.call(person2)       // ?

person1.foo3()()                 // ?
person1.foo3.call(person2)()     // ?
person1.foo3().call(person2)     // ?

person1.foo4()()                 // ?
person1.foo4.call(person2)()     // ?
person1.foo4().call(person2)     // ?

答案与解析

person1.foo1()                   // "person1" - 隐式绑定
person1.foo1.call(person2)       // "person2" - 显式绑定

person1.foo2()                   // "person1" - 箭头函数继承构造函数的 this
person1.foo2.call(person2)       // "person1" - 箭头函数不受 call 影响

person1.foo3()()                 // "window" - 独立调用
person1.foo3.call(person2)()     // "window" - 独立调用
person1.foo3().call(person2)     // "person2" - 显式绑定

person1.foo4()()                 // "person1" - 箭头函数继承 foo4 的 this
person1.foo4.call(person2)()     // "person2" - foo4 的 this 被改变
person1.foo4().call(person2)     // "person1" - 箭头函数不受 call 影响

关键理解

  1. new 绑定创建新对象

    • new Person('person1') 创建新对象,this 指向该对象
    • 构造函数中的 this.foo2 是箭头函数,继承构造函数的 this
  2. 箭头函数在构造函数中的特殊性

    • foo2 是箭头函数,定义在构造函数中
    • 外层作用域是构造函数,this 指向 new 创建的对象
    • 因此 person1.foo2() 输出 "person1"
  3. 与对象字面量的区别

    • 对象字面量中的箭头函数,外层是全局作用域
    • 构造函数中的箭头函数,外层是构造函数作用域

4.4 面试题4:嵌套对象中的 this

题目:判断以下代码的输出

var name = 'window'

function Person(name) {
  this.name = name
  this.obj = {
    name: 'obj',
    foo1: function () {
      return function () {
        console.log(this.name)
      }
    },
    foo2: function () {
      return () => {
        console.log(this.name)
      }
    }
  }
}

var person1 = new Person('person1')
var person2 = new Person('person2')

person1.obj.foo1()()                 // ?
person1.obj.foo1.call(person2)()     // ?
person1.obj.foo1().call(person2)     // ?

person1.obj.foo2()()                 // ?
person1.obj.foo2.call(person2)()     // ?
person1.obj.foo2().call(person2)     // ?

答案与解析

person1.obj.foo1()()                 // "window" - 独立调用
person1.obj.foo1.call(person2)()     // "window" - 返回的函数独立调用
person1.obj.foo1().call(person2)     // "person2" - 显式绑定

person1.obj.foo2()()                 // "obj" - 箭头函数继承 foo2 的 this
person1.obj.foo2.call(person2)()     // "person2" - foo2 的 this 被改变
person1.obj.foo2().call(person2)     // "obj" - 箭头函数不受 call 影响

难点解析

  1. person1.obj.foo2()()

    • person1.obj.foo2() 通过 obj 调用,this 指向 obj
    • 返回箭头函数,继承 foo2 的 this(obj)
    • 输出 "obj"
  2. person1.obj.foo2.call(person2)()

    • foo2.call(person2) 改变 foo2 的 this 为 person2
    • 返回箭头函数,继承 foo2 的 this(person2)
    • 输出 "person2"
  3. person1.obj.foo2().call(person2)

    • person1.obj.foo2() 返回箭头函数,this 已确定为 obj
    • .call(person2) 无法改变箭头函数的 this
    • 输出 "obj"

判断技巧

  • 看到 ()() 连续调用,先判断第一个 () 返回什么
  • 如果返回箭头函数,this 取决于外层函数执行时的 this
  • 如果返回普通函数,this 取决于第二个 () 的调用方式

五、实战应用与最佳实践

5.1 箭头函数的使用决策树

是否需要动态 this?
├─ 是 → 使用普通函数
│   ├─ 对象方法
│   ├─ 原型方法
│   └─ 事件处理(需要访问 DOM 元素)
│
└─ 否 → 考虑使用箭头函数
    ├─ 回调函数(保持外层 this)
    ├─ 数组方法(map、filter 等)
    ├─ Promise 链
    └─ 简单的工具函数

5.2 常见陷阱与解决方案

陷阱1:对象方法使用箭头函数

// ❌ 错误
const calculator = {
  value: 0,
  add: (num) => {
    this.value += num  // this 不指向 calculator
  }
}

// ✅ 正确
const calculator = {
  value: 0,
  add(num) {
    this.value += num
  }
}

陷阱2:原型方法使用箭头函数

// ❌ 错误
function Person(name) {
  this.name = name
}
Person.prototype.sayName = () => {
  console.log(this.name)  // this 不指向实例
}

// ✅ 正确
Person.prototype.sayName = function() {
  console.log(this.name)
}

陷阱3:需要 arguments 对象

// ❌ 错误:箭头函数没有 arguments
const sum = () => {
  console.log(arguments)  // ReferenceError
}

// ✅ 正确:使用剩余参数
const sum = (...args) => {
  console.log(args)
  return args.reduce((a, b) => a + b, 0)
}

5.3 框架中的最佳实践

React 类组件

class MyComponent extends React.Component {
  // ✅ 推荐:箭头函数属性,自动绑定 this
  handleClick = () => {
    this.setState({ clicked: true })
  }

  // ❌ 不推荐:需要在构造函数中手动绑定
  handleClick() {
    this.setState({ clicked: true })
  }
  constructor() {
    super()
    this.handleClick = this.handleClick.bind(this)
  }
}

Vue 组件

export default {
  data() {
    return { count: 0 }
  },
  methods: {
    // ✅ 推荐:普通方法,this 自动指向组件实例
    increment() {
      this.count++
    },

    // ✅ 推荐:异步操作中使用箭头函数
    async fetchData() {
      const data = await fetch('/api/data')
        .then(res => res.json())  // 箭头函数保持 this
      this.data = data
    }
  }
}

5.4 性能优化建议

避免在渲染中创建箭头函数

// ❌ 不推荐:每次渲染都创建新函数
render() {
  return (
    <button onClick={() => this.handleClick()}>
      点击
    </button>
  )
}

// ✅ 推荐:使用箭头函数属性
handleClick = () => {
  // ...
}
render() {
  return <button onClick={this.handleClick}>点击</button>
}

5.5 团队协作规范

代码审查检查点

  • 对象方法是否误用箭头函数
  • 事件处理是否需要访问 DOM 元素(this)
  • 箭头函数是否在不必要的地方使用
  • 是否有过度简写影响可读性

编码规范建议

  1. 对象方法统一使用简写语法:method() {} 而非 method: function() {}
  2. 回调函数优先使用箭头函数
  3. 需要动态 this 时明确使用普通函数
  4. 复杂逻辑避免过度简写,保持可读性

六、总结与进阶路线

6.1 核心要点回顾

箭头函数的本质

  • 更简洁的函数语法
  • 不绑定 this,继承外层作用域的 this
  • 没有 arguments 对象,使用剩余参数替代
  • 不能作为构造函数使用

this 绑定规则完整体系

  1. 默认绑定:独立调用 → 全局对象或 undefined
  2. 隐式绑定:对象方法调用 → 调用对象
  3. 显式绑定:call/apply/bind → 指定对象
  4. new 绑定:构造函数调用 → 新对象
  5. 箭头函数:不绑定 this → 继承外层作用域

优先级:new > 显式 > 隐式 > 默认 > 箭头函数(不参与优先级)

使用原则

  • 需要动态 this:使用普通函数
  • 需要保持外层 this:使用箭头函数
  • 简单工具函数:优先箭头函数
  • 对象/原型方法:使用普通函数

6.2 团队落地建议

阶段一:知识普及(1 周)

  • 组织箭头函数专题分享
  • 整理常见误用案例库
  • 在代码审查中重点关注 this 相关问题

阶段二:规范制定(1 周)

  • 制定箭头函数使用规范
  • 配置 ESLint 规则自动检测
  • 建立最佳实践文档

阶段三:工具支持(持续)

  • 使用 TypeScript 减少 this 错误
  • 引入现代框架减少 this 依赖
  • 建立单元测试覆盖 this 逻辑

阶段四:持续优化(持续)

  • 定期回顾 this 相关 bug
  • 更新团队知识库
  • 在新人培训中加入专题

6.3 进阶学习路线

下一步学习内容

  1. 手写实现 call/apply/bind

    • 理解显式绑定的内部机制
    • 掌握 arguments 对象的使用
    • 实现函数柯里化
  2. 深入理解作用域

    • 词法作用域 vs 动态作用域
    • 闭包与箭头函数的关系
    • 作用域链的查找机制
  3. ES6+ 新特性

    • 解构赋值与箭头函数
    • 默认参数与剩余参数
    • 模板字符串与标签函数
  4. 框架源码分析

    • React Hooks 如何避免 this
    • Vue 3 Composition API 的设计思想
    • 现代框架的 this 处理策略

推荐资源

  • 《你不知道的 JavaScript(上卷)》- this 和对象原型
  • MDN Web Docs - 箭头函数
  • JavaScript.info - 箭头函数基础

6.4 自测题

基础题

  1. 以下代码输出什么?
const obj = {
  name: "obj",
  getName: () => this.name
}
console.log(obj.getName())
  1. 如何修改使其正确输出 "obj"?

进阶题

  1. 解释为什么以下代码无法正常工作:
function Timer() {
  this.seconds = 0
  setInterval(() => {
    this.seconds++
  }, 1000)
}
const timer = new Timer()
  1. 在 React 中,以下两种写法有什么区别?
// 方式1
<button onClick={() => this.handleClick()}>

// 方式2
<button onClick={this.handleClick}>

答案

  1. undefined(箭头函数的 this 指向全局)
  2. 使用普通函数:getName: function() { return this.name }
  3. 代码可以正常工作,箭头函数继承构造函数的 this
  4. 方式1 每次渲染创建新函数,性能较差;方式2 需要确保 handleClick 已绑定 this

七、写在最后

箭头函数是 ES6 最重要的特性之一,它不仅简化了语法,更从根本上解决了 JavaScript 中 this 绑定的痛点。

关键心态

  • 理解箭头函数的本质:词法作用域的 this
  • 不要盲目使用箭头函数,根据场景选择
  • 在现代开发中,优先考虑函数式编程思想
  • 善用工具和框架,减少对 this 的依赖

实践建议

  • 在真实项目中刻意练习箭头函数的使用
  • 遇到 this 问题时,先判断是否适合用箭头函数
  • 代码审查时,关注箭头函数的使用场景
  • 定期回顾本文,加深理解

掌握箭头函数和 this,是成为高级前端工程师的必经之路。接下来,我们将手写实现 call/apply/bind,深入理解显式绑定的内部机制。

持续学习,保持好奇心,我们下期见!

了解 window.history 和 window.location, 更好地掌握 vue-router、react-router单页面路由

作者 米丘
2026年3月10日 17:10

在 JavaScript 中,History 是浏览器提供的一个内置对象(通过 window.history 访问),用于管理浏览器的会话历史记录(即用户浏览过的页面序列)。它是实现无刷新页面跳转(如单页应用 SPA 的路由)的核心工具,同时也支持传统的前进、后退等操作。

刷新页面 与 不刷新页面?

history 对象的语境中,“刷新页面” 和 “不刷新页面” 指的是修改 URL 或导航时是否触发浏览器重新加载页面

刷新页面(传统导航)

刷新页面,浏览器会重新请求服务器并加载新页面,整个页面会刷新(白屏后重新渲染)。这是传统多页应用的默认行为。

会刷新页面的方法

  • location.assign(url),跳转到指定的 url(与 location.href = url 效果相同),会在历史记录中添加新记录(可后退)。
  • location.reload(forceReload),刷新当前页面,不改变历史记录。
  • location.replace(url),跳转到指定的 url,但替换当前历史记录(不会新增记录,无法后退到当前页)。

不刷新页面(SPA 无刷新导航)

不刷新页面,浏览器不会重新请求服务器,页面内容可通过 JavaScript 动态更新。修改 URL 和历史记录,但不触发页面刷新。这是单页应用(SPA)实现无刷新路由的核心原理。

不刷新页面的 history 方法:

  • history.pushState(state, title, url):添加新的历史记录,更新 URL,但不刷新页面。
  • history.replaceState(state, title, url):替换当前历史记录,更新 URL,同样不刷新页面。

History

在单页应用(SPA)中,history.go(n)history.forward()(以及 history.back())默认不会「重新加载整个页面」 —— 它们只是切换浏览器历史记录栈中的条目,复用已缓存的页面资源,仅在特殊场景下才会触发刷新。

http://localhost:5173/yo/home

image.png

location

http://localhost:5173/yo/home

image.png

popstate 事件(监听历史记录变化)

popstate 仅在「历史记录栈发生变化」且「不是新增记录」时触发。当用户通过 浏览器前进 / 后退按钮,或通过 back()/forward()/go() 方法切换历史记录时触发。

注意 1、调用 pushStatereplaceState不会触发此事件。 2、首次加载页面(无历史记录变更),不触发。

hashchange 事件

hashchange 事件是 JavaScript 中用于监听浏览器地址栏中 Hash 部分变化 的事件。

vue-router v4/v5 、react-routerv6 已经不再监听 hashchange事件了。

hashchange 触发场景:

  1. 手动修改地址栏中的 Hash 并回车(如从 #/home 改为 #/about)。
  2. 点击页面中带有 Hash 的链接(如 <a href="#/contact">联系我们</a>)。
  3. 通过 JavaScript 修改 location.hash(如 location.hash = '#/login')。

hashchange 不会触发的场景:

  • history.pushStatehistory.replaceState 方法执行不会触发。
  • 页面首次记载不会触发。

我们用1万行Vue3代码,做了款开源AI PPT项目

作者 徐小夕
2026年3月10日 16:48

今天和大家聊聊,我们做的开源AI PPT项目。

图片

写这篇文章之前也在掘金提前和大家聊过,我们为什么会开源这个项目。一方面是因为我们团队目前会聚焦于打磨和迭代 JitWord 这款AI办公解决方案;另一方面,我们希望能通过开源,得到更多用户的正式需求反馈,方便我们更好的迭代产品。

图片

这款AI PPT项目,我们应用了目前市面上比较流行的AI技术方案,比如:

  • AI SKills 技能
  • MCP服务
  • 通用LLM模型适配器方案设计
  • PPT可视化编辑解决方案
  • AI语音识别方案
  • 基于Coze工作流设计PPT生成Agent编排
  • Canvas绘制PPT能力

如果大家感兴趣,可以在 github 上研究参考:

github:github.com/jitOffice/a…

演示地址:ppt.jitword.com/jit-slide

接下来我就和大家详细介绍一下我们开发的这款AI PPT项目的功能亮点和核心技术实现。

JitPPT项目介绍

图片

AIPPT 是一款功能丰富的开源 AI 演示文稿编辑器,让我们在数秒内创建精美的幻灯片。它在浏览器中直接集成了主流大语言模型——DeepSeek、GPT、Claude、Gemini、Kimi、通义千问等——并支持零后端模式,可立即在本地使用。

大家在项目的全局环境变量中可以配置自己的AI 模型,即可实现AI生成PPT。

图片

核心亮点我总结如下,供大家参考:

功能 说明
🤖 多模型支持 DeepSeek、OpenAI、Claude、Gemini、Kimi、通义千问、智谱 GLM、豆包、Grok、MiniMax——均使用您自己的 API Key
⚡ AI 幻灯片生成 一句话描述即可生成完整演示文稿,实时流式预览
🎨 可视化幻灯片编辑器 拖拽画布、丰富格式化、ECharts 图表、思维导图、表格
📊 智能图表识别 自动检测数据结构并推荐最佳图表类型
🔊 AI 语音助手 基于讯飞 ASR 的语音转文字编辑功能
🌍 国际化(8 种语言) 简体中文、繁體中文、English、日本語、한국어、Bahasa、ไทย、Tiếng Việt
🔌 自定义 LLM 接口 接入任意兼容 OpenAI 格式的 API 端点
📤 多格式导出 通过 jsPDF 和 PptxGenJS 导出为 PDF、PPTX、PNG/图片
🧩 智能体架构 分层 AI 智能体系统(Core / Memory / Skills),支持扩展 AI 功能
🔒 隐私优先 API Key 仅存储在浏览器 localStorage 中,绝不发送至我们的服务器

我们提供了完整的PPT解决方案,大家可以基于我们的设计进行二次开发,对接自己的后台服务来实现AI PPT产品。

具体模块介绍如下:

  1. 精美的登录注册模块

图片

  1. AI PPT的入口管理模块

图片

  1. AI生成PPT模块

图片图片

  1. PPT可视化编辑模块

图片

我们可以在线编辑PPT,对每张PPT做排版,同时也支持非常丰富的PPT组件和模块:

  1. PPT布局模版

图片

布局模版我们内置了几个基础布局,大家可以扩展来实现快速设计PPT页面的效果。

  1. PPT支持一件嵌入媒体素材

图片

我们可以上传各种平台的视频,音频等,让PPT演示更生动。

  1. 支持嵌入自定义表格/形状素材

图片

  1. 支持嵌入可视化图表

图片

图表是PPT报告的非常重要的一个功能,目前我们内置了8个可视化图表,大家也可以基于我们的方案进行扩展。

如果大家想二次开发,肯定比较关注技术栈,那接下来详细和大家分享一下我们开源的JitPPT的核心技术方案。

核心技术栈清单

前端核心技术栈

  • Vue 3 + Composition API + <script setup>
  • Vite 5 — 极速开发服务器和构建工具
  • TypeScript — 类型安全的 composables 和工具函数
  • Pinia — 轻量级状态管理
  • Vue Router 4 — 带权限守卫的 SPA 路由

UI 与样式

  • Arco Design Vue — 企业级组件库
  • UnoCSS — 原子化 CSS 引擎
  • Konva.js — 幻灯片编辑器的 Canvas 渲染
  • Iconify — 20 万+ 统一图标库

AI 与大模型

  • 流式 SSE — 通过 fetch + ReadableStream 实现实时 Token 流
  • 智能模型路由 — 根据任务上下文自动选择最优模型
  • 多提供商架构 — OpenAI 兼容 API 抽象层
  • 智能体系统 — 核心编排器 + 上下文记忆 + 技能注册表

组件方案

  • ECharts 5.5 — 交互式数据可视化
  • AntV G2 — 声明式图表
  • Mind Elixir — 思维导图编辑器
  • Tiptap — 支持数学/LaTeX 的富文本编辑
  • KaTeX — 快速 LaTeX 数学公式渲染
  • Mermaid — 流程图与图表支持
  • highlight.js + Shiki — 代码语法高亮

导出

  • jsPDF — PDF 导出
  • PptxGenJS — PPTX 导出
  • html2canvas + html-to-image — 幻灯片截图

下面再来和大家分享一下AI PPT的核心功能设计。

技术实现

图片

我们的 AIPPT 项目是一个纯前端应用,核心设计目标是:

  • 零后端依赖可用用户只需填写 LLM API Key 即可完整使用
  • 多 LLM 兼容所有主流 OpenAI-compatible 接口统一抽象
  • 模块化可扩展新增 LLM 提供商或 AI 技能只需增加配置

所有 LLM 提供商通过统一的 ProviderConfig 接口描述,位于 src/utils/ai/providers.ts

支持的提供商及其 baseUrl:

提供商 baseUrl
DeepSeek https://api.deepseek.com/v1
OpenAI https://api.openai.com/v1
Claude https://api.anthropic.com/v1
Gemini https://generativelanguage.googleapis.com/v1beta/openai
Kimi https://api.moonshot.cn/v1
Qwen https://dashscope.aliyuncs.com/compatible-mode/v1
GLM https://open.bigmodel.cn/api/paas/v4
Doubao https://ark.cn-beijing.volces.com/api/v3
Grok https://api.x.ai/v1
MiniMax https://api.minimaxi.com/v1
Custom 用户自定义

新增提供商只需在 PROVIDERS 对象中添加一条配置即可,无需修改任何其他代码。

1. 流式生成核心:streamGenerate

src/utils/ai/index.ts 导出的 streamGenerate 是整个 AI 调用的统一入口;

2. SSE 流式解析

src/utils/ai/openaiStream.ts 实现标准 OpenAI SSE 协议解析:

支持 AbortController 中断,用户可随时停止生成。

3. 智能模型路由

src/utils/ai/modelRouter.ts 根据任务类型自动选择最优模型:

image.png

设计原则:复杂推理任务用 DeepSeek(准确度优先),实时交互任务用 MiniMax Lightning(速度优先)。

4. Canvas 渲染引擎

图片

幻灯片编辑器基于 Konva.js 构建:

  • vue-konva 将 Konva 对象包装为 Vue 组件
  • 每个幻灯片元素(文本框/图片/形状)是一个 Konva Group
  • 拖拽、缩放、旋转通过 Konva Transformer 实现
  • 多选操作通过 Ctrl+Click 更新 selectedIds 数组

大家可以在我们项目里学习如何使用 Canvas 来实现高性能可视化编辑器。5. Agent 的三层设计架构

AgentOrchestrator (core/)
    │  接收用户请求,协调各层
    │
    ├── ContextManager (memory/)
    │      存储对话历史 + 当前幻灯片上下文
    │
    └── SkillRegistry (skills/)
           注册并管理所有 AI 技能
               ├── TextOptimizationSkill
               ├── ImageGenerationSkill
               ├── ChartGenerationSkill
               ├── LayoutOptimizationSkill
               └── IntelligentLayoutSkill

这里我们采用了最近比较流行的Skills方案,可以在 src/agents/skills/implementations/ 创建新文件(skill):

image.png

在 AgentOrchestrator.ts 的 registerSkills() 中注册即可。当然还有很多技术细节,这里就不一一介绍了,大家可以获取github项目源码自行研究体验:

github地址:github.com/jitOffice/a…

演示地址:ppt.jitword.com/jit-slide

当然这个项目还有很多优化的空间,大家可以使用AI Coding的方式自行优化,实现后端服务等,来打造自己的AI PPT项目。

后面我会在掘金中持续分享更多AI技术实践和高价值AI开源项目。

vue3 reactive解析

作者 哇哇哇哇
2026年3月10日 16:44

Vue3 的 reactive 是实现响应式数据的核心 API 之一,它基于 Proxy 代理 实现,相比 Vue2 的 Object.defineProperty 具有更强的能力(支持数组、新增属性、Map/Set 等)。下面我会从核心原理、源码拆解、关键逻辑三个维度,由浅入深解析 reactive 的实现。

一、核心原理概述

reactive 的本质是:通过 Proxy 拦截目标对象的读取、修改、删除等操作,在读取时收集依赖(track),在修改时触发依赖更新(trigger) ,从而实现数据变化驱动视图更新。

二、源码核心拆解(简化版)

Vue3 源码中 reactive 相关逻辑主要在 packages/reactivity/src/reactive.ts 文件中,下面是关键代码的简化版(保留核心逻辑,剔除边界处理),方便你理解:

1. 核心常量与工具函数

// 1. 存储响应式对象的缓存(避免重复代理)
const reactiveMap = new WeakMap();

// 2. 标记响应式对象的唯一标识
export const ReactiveFlags = {
  IS_REACTIVE: '__v_isReactive', // 标记是否为reactive对象
  RAW: '__v_raw' // 指向原始对象
};

// 3. 判断是否为可代理的对象(排除基本类型、null等)
function isObject(value) {
  return typeof value === 'object' && value !== null;
}

// 4. 判断是否为只读/非响应式对象(边界处理)
function canObserve(value) {
  return !value[ReactiveFlags.IS_READONLY] && isObject(value);
}

2. reactive 主函数

export function reactive(target) {
  // 边界1:如果目标是只读对象,直接返回
  if (target && target[ReactiveFlags.IS_READONLY]) {
    return target;
  }
  // 边界2:如果目标已经是响应式对象,直接返回代理对象
  const existingProxy = reactiveMap.get(target);
  if (existingProxy) {
    return existingProxy;
  }
  // 边界3:非对象类型(如字符串/数字),无法代理,直接返回
  if (!isObject(target)) {
    return target;
  }

  // 核心:创建Proxy代理
  const proxy = new Proxy(target, {
    // 拦截读取操作(如 obj.xxx、obj['xxx']、in 操作、for...in 等)
    get(target, key, receiver) {
      // 特殊key:判断是否为reactive对象
      if (key === ReactiveFlags.IS_REACTIVE) {
        return true;
      }
      // 特殊key:获取原始对象
      if (key === ReactiveFlags.RAW) {
        return target;
      }

      // 读取原始值(处理继承/原型链)
      const res = Reflect.get(target, key, receiver);

      // 收集依赖:只有非特殊key才收集
      if (!isSymbol(key) && !isNonTrackableKeys(key)) {
        track(target, 'get', key); // 核心:收集依赖
      }

      // 深度代理:如果返回值是对象,递归转为reactive
      if (isObject(res)) {
        return reactive(res);
      }

      return res;
    },

    // 拦截修改操作(如 obj.xxx = 123)
    set(target, key, value, receiver) {
      // 获取旧值,用于对比
      const oldValue = Reflect.get(target, key, receiver);
      // 如果是新增属性(旧值为undefined且key不在对象中)
      const hadKey = hasOwn(target, key);

      // 执行赋值操作
      const result = Reflect.set(target, key, value, receiver);

      // 避免重复触发:只有值真的变化才触发更新
      if (!hadKey || !Object.is(oldValue, value)) {
        trigger(target, 'set', key, value, oldValue); // 核心:触发更新
      }

      return result;
    },

    // 拦截删除操作(如 delete obj.xxx)
    deleteProperty(target, key) {
      const hadKey = hasOwn(target, key);
      const result = Reflect.deleteProperty(target, key);
      // 只有删除成功才触发更新
      if (hadKey && result) {
        trigger(target, 'delete', key);
      }
      return result;
    },

    // 拦截 in 操作(如 'xxx' in obj)
    has(target, key) {
      const result = Reflect.has(target, key);
      // 收集依赖
      if (!isSymbol(key)) {
        track(target, 'has', key);
      }
      return result;
    },

    // 拦截 Object.keys/for...in 等遍历操作
    ownKeys(target) {
      track(target, 'iterate', Array.isArray(target) ? 'length' : ITERATE_KEY);
      return Reflect.ownKeys(target);
    }
  });

  // 缓存代理对象,避免重复创建
  reactiveMap.set(target, proxy);
  return proxy;
}

3. 依赖收集(track)与触发更新(trigger)核心逻辑

tracktrigger 是响应式的灵魂,简化版逻辑如下:

// 存储依赖的映射表:target -> key -> 副作用函数集合
const targetMap = new WeakMap();
// 当前正在执行的副作用函数(如组件渲染函数、watch回调)
let activeEffect = null;

// 收集依赖
function track(target, type, key) {
  // 无活跃副作用函数时,无需收集
  if (!activeEffect) return;
  
  // 1. 获取target对应的依赖映射
  let depsMap = targetMap.get(target);
  if (!depsMap) {
    targetMap.set(target, (depsMap = new Map()));
  }
  
  // 2. 获取key对应的副作用函数集合
  let dep = depsMap.get(key);
  if (!dep) {
    depsMap.set(key, (dep = new Set()));
  }
  
  // 3. 将当前副作用函数加入集合
  if (!dep.has(activeEffect)) {
    dep.add(activeEffect);
    // 反向收集:让副作用函数记住自己被哪些dep收集,用于清理
    activeEffect.deps.push(dep);
  }
}

// 触发更新
function trigger(target, type, key, newValue?, oldValue?) {
  // 1. 获取target对应的依赖映射
  const depsMap = targetMap.get(target);
  if (!depsMap) return;
  
  // 2. 收集所有需要执行的副作用函数
  const effects = new Set();
  const add = (effectsToAdd) => {
    if (effectsToAdd) {
      effectsToAdd.forEach(effect => effects.add(effect));
    }
  };
  
  // 3. 根据操作类型获取对应的副作用函数
  if (key !== void 0) {
    add(depsMap.get(key)); // 触发key对应的副作用
  }
  
  // 4. 执行所有副作用函数
  effects.forEach(effect => {
    if (effect.scheduler) {
      effect.scheduler(); // 调度执行(如nextTick)
    } else {
      effect(); // 直接执行
    }
  });
}

三、关键细节解析

1. 为什么用 Proxy 而不是 Object.defineProperty?

  • 支持数组:Proxy 能拦截数组的 push/pop/splice 等操作(通过 set 拦截),而 Object.defineProperty 无法监听数组索引变化。
  • 支持新增 / 删除属性:Proxy 能拦截 deleteProperty 和新增属性的 set 操作,而 Object.defineProperty 只能监听已有属性。
  • 支持 Map/Set 等集合类型:Proxy 可以拦截这些内置对象的方法(如 set/delete)。

2. 缓存机制(reactiveMap)

reactiveMap 是一个 WeakMap(弱引用,不影响垃圾回收),用于存储「原始对象 -> 代理对象」的映射,避免对同一个对象重复创建 Proxy

const obj = {};
const r1 = reactive(obj);
const r2 = reactive(obj);
console.log(r1 === r2); // true

3. 深度响应式

get 拦截器中,若读取的属性值是对象,则递归调用 reactive 转为响应式,实现深度代理

4. 避免无限递归

通过 ReactiveFlags.RAW 标记原始对象,当代理对象的 get 操作读取 __v_raw 时,直接返回原始对象,避免代理对象访问自身导致的无限递归。

5. 边界处理

  • 非对象类型(如字符串、数字)直接返回,不代理;
  • 只读对象(readonly 创建的)不重复代理;
  • 只有值真正变化时(!Object.is(oldValue, value))才触发更新,避免无效更新。

四、总结

  1. reactive 核心是通过 Proxy 拦截对象操作,在读取时 track 收集依赖,修改时 trigger 触发更新;
  2. 利用 WeakMap(reactiveMap/targetMap)实现缓存和依赖管理,兼顾性能和垃圾回收;
  3. 相比 Vue2 的响应式,reactive 支持数组、新增属性、复杂集合类型,且实现更简洁。

补充:与 ref 的区别

  • reactive 用于对象 / 数组的响应式,基于 Proxy 实现;
  • ref 用于基本类型(如字符串、数字)的响应式,本质是封装成 { value: xxx } 的对象,再通过 reactive 代理 value 属性。

vue3 ref解析

作者 哇哇哇哇
2026年3月10日 16:42

在 Vue3 中,ref 是响应式系统的核心 API 之一,专门解决原始值(Number、String、Boolean 等) 无法被 Proxy 拦截的问题。我会从「设计初衷」「源码实现」「核心特性」「使用场景」四个维度,彻底解析 ref,帮你理解它的底层逻辑和使用细节。

一、为什么需要 ref?

Vue3 的 reactive 基于 Proxy 实现,但 Proxy 只能拦截对象 / 数组的属性访问,无法直接拦截原始值(因为原始值不是引用类型,没有属性可拦截)。

举个例子:

// 原始值无法被 reactive 响应式化
let num = reactive(1); 
num = 2; // 只是重新赋值变量,不会触发更新

// 而 ref 可以解决这个问题
let num = ref(1);
num.value = 2; // 会触发响应式更新

ref 的核心设计思路:把原始值包裹成一个对象,通过访问器属性(get/set)拦截 .value 的读写,从而实现响应式

二、ref 核心源码解析

ref 的源码主要在 packages/reactivity/src/ref.ts 中,核心逻辑分为「创建 ref 实例」「依赖收集」「触发更新」三部分。

1. 核心入口:ref 函数

// 对外暴露的 ref 函数
export function ref<T>(value: T): Ref<UnwrapRef<T>> {
  return createRef(value, false)
}

// 内部核心创建函数(区分 shallowRef)
function createRef(rawValue: unknown, shallow: boolean) {
  // 避免重复包装:如果已经是 ref,直接返回
  if (isRef(rawValue)) {
    return rawValue
  }
  // 核心:创建 RefImpl 实例(真正实现响应式的类)
  return new RefImpl(rawValue, shallow)
}

2. 核心实现:RefImpl 类

这是 ref 的核心,通过类的访问器属性(get value()/set value())拦截 .value 的读写:

class RefImpl<T> {
  // 私有属性:存储原始值和处理后的响应式值
  private _value: T
  private _rawValue: T

  // 标记:标识这是一个 ref(供 isRef 检测)
  public readonly __v_isRef = true

  constructor(value: T, public readonly _shallow: boolean) {
    // 1. 保存原始值(用于后续对比是否变化)
    this._rawValue = toRaw(value)
    // 2. 处理值:shallow 为 false 时,深层响应式(如 ref({a:1}) 会转 reactive)
    this._value = _shallow ? value : toReactive(value)
  }

  // 读取 .value 时触发(依赖收集)
  get value() {
    // 核心:收集依赖(和 reactive 的 track 逻辑一致)
    trackRefValue(this)
    // 返回处理后的值
    return this._value
  }

  // 赋值 .value 时触发(触发更新)
  set value(newVal) {
    // 转原始值,避免响应式对象对比出错
    newVal = this._shallow ? newVal : toRaw(newVal)
    // 只有值真正变化时,才更新并触发更新
    if (hasChanged(newVal, this._rawValue)) {
      // 更新原始值和响应式值
      this._rawValue = newVal
      this._value = this._shallow ? newVal : toReactive(newVal)
      // 核心:触发依赖更新
      triggerRefValue(this, newVal)
    }
  }
}

3. 关键辅助函数

  • toReactive:如果值是对象,自动转为 reactive(所以 ref({a:1}) 等价于 ref(reactive({a:1}))):

    export const toReactive = <T extends unknown>(value: T): T => {
      return isObject(value) ? reactive(value) : value
    }
    
  • hasChanged:判断值是否真的变化(处理 NaN、引用类型等特殊情况):

    export const hasChanged = (value: any, oldValue: any): boolean => {
      return !Object.is(value, oldValue)
    }
    
  • trackRefValue/triggerRefValue:专门针对 ref 的依赖收集和触发更新,底层复用了 track/trigger 逻辑。

三、ref 的核心特性

1. 自动解包(模板中无需 .value)

在模板中使用 ref 时,Vue 会自动解包,无需写 .value

<template>
  <!-- 直接写 num,等价于 num.value -->
  <div>{{ num }}</div>
</template>

<script setup>
import { ref } from 'vue'
const num = ref(1)
</script>

源码逻辑:在组件渲染时,Vue 会遍历 setup 返回的对象,对 ref 类型的属性做「自动解包」处理(访问属性时自动取 .value)。

2. 嵌套对象的响应式

如果 ref 的值是对象 / 数组,会被 toReactive 转为 reactive,因此嵌套属性也能响应式:

const objRef = ref({ a: 1 })
objRef.value.a = 2 // 会触发更新(因为内部是 reactive)

3. ref 与 reactive 的互操作

  • ref 作为 reactive 对象的属性时,会自动解包:

    const numRef = ref(1)
    const obj = reactive({ num: numRef })
    console.log(obj.num) // 1(自动解包,无需 .value)
    obj.num = 2 // 等价于 numRef.value = 2,会触发更新
    
  • 数组 / Map 中的 ref 不会自动解包:

    const arr = reactive([ref(1)])
    console.log(arr[0].value) // 必须写 .value
    

四、ref 的衍生 API

1. shallowRef

浅响应式 ref,只监听 .value 的赋值,不监听内部对象的变化:

export function shallowRef<T>(value: T): Ref<T> {
  return createRef(value, true) // _shallow 为 true
}

// 使用示例
const objRef = shallowRef({ a: 1 })
objRef.value.a = 2 // 不会触发更新(只监听 .value 赋值)
objRef.value = { a: 2 } // 会触发更新

2. isRef

检测是否为 ref 实例(通过 __v_isRef 标记):

export function isRef<T>(r: Ref<T> | unknown): r is Ref<T> {
  return Boolean(r && (r as Ref).__v_isRef === true)
}

3. unref

语法糖,unref(x) 等价于 isRef(x) ? x.value : x

export function unref<T>(ref: T | Ref<T>): T {
  return isRef(ref) ? ref.value : ref
}

总结

  1. 核心本质:ref 是「原始值的响应式包装器」,通过类的访问器属性拦截 .value 的读写,解决原始值无法被 Proxy 拦截的问题;
  2. 关键逻辑:创建 RefImpl 实例 → get value 时收集依赖 → set value 时触发更新;
  3. 核心特性:模板自动解包、对象值自动转 reactive、与 reactive 互操作时的部分自动解包。

理解 ref 的核心是记住:ref 所有的响应式都围绕 .value 展开,无论是原始值还是对象,只有操作 .value(或模板自动解包)才会触发响应式更新。

《Vue 自定义指令注册技巧:从手动到自动,效率翻倍》

作者 星_离
2026年3月10日 16:35

在 Vue 开发中,自定义指令是个非常实用的功能,比如实现输入框自动聚焦、图片懒加载、长按事件等场景都能用到。但随着项目中自定义指令数量增多,一个个手动注册会变得繁琐且容易遗漏。今天就聊聊 Vue 自定义指令的两种注册方式:手动注册(适合少量指令)和自动扫描注册(适合指令较多的场景),用最通俗的方式讲清楚怎么用、为什么这么用

自定义指令基础

在开始之前,先简单回顾下 Vue 自定义指令的核心:自定义指令本质是一个包含bindinsertedupdate等钩子函数的对象,比如我们写一个focus指令(让输入框自动聚焦):

export default { 
// 指令绑定到元素且元素插入DOM时执行 
    inserted(el) { 
        el.focus(); // 让元素获得焦点 
    } 
};

有了指令文件,接下来就是把它注册成全局指令,让整个项目都能使用。

手动全局统一注册

如果你的项目里自定义指令只有 1-2 个,手动注册是最直接的方式,逻辑简单、一目了然。

/**
 * 全局指令分发
 * 适合数量少的情况
 */
import Vue from "vue";
import focusDirective from "./focus";

//手机全局自定义指令
const OS = {
  focus: focusDirective,
};

Object.keys(OS).forEach((key) => {
  Vue.directive(key, OS[key]);
});

怎么用?

在 Vue 组件里直接用v-指令名即可:

<template> 
<!-- 使用v-focus指令,输入框渲染后自动聚焦 --> 
    <input v-focus type="text" placeholder="自动聚焦的输入框" /> 
</template>

优点&缺点

  • 优点:代码少、逻辑清晰,新手一看就懂,适合指令数量少的小项目。

  • 缺点:每新增一个指令,都要手动导入、手动加到对象里,容易忘写,维护成本随指令数量增加而上升。

自动全局统一注册

当项目里的自定义指令越来越多(比如 5 个以上),手动注册就显得很麻烦。这时可以用 Vue 生态里的require.context(Webpack 提供的 API)实现自动扫描指定目录下的指令文件,自动注册,新增指令时只需要新建文件,无需修改注册代码

import Vue from "vue";
// 【可选】手动指定一些特殊指令(比如不想被自动扫描的)
const manualDirectives = {
  focus: require("./focus").default,
};
// 核心:自动扫描当前目录下的指令文件 
// require.context(目录, 是否递归查找子目录, 匹配文件的正则) 
// 这里规则:扫描./目录、不递归、匹配除了index.js之外的所有.js文件
const autoDirectives = require.context("./", false, /^\.\/(?!index).+\.js$/);
// 合并并注册
// 合并手动指令和自动扫描的指令
const allDirectives = {
  ...manualDirectives,// 展开手动指令
  // 遍历自动扫描的文件,转换成{指令名: 指令对象}的格式
  ...autoDirectives.keys().reduce((obj, fileName) => {
      // 处理文件名:比如./longpress.js → longpress(作为指令名)
    const name = fileName.replace(/^\.\/|\.js$/g, "");
    // 获取文件导出的指令对象(取default导出)
    obj[name] = autoDirectives(fileName).default;
    return obj;
  }, {}),
};
// 统一注册所有指令(加了校验,避免空指令导致报错)
Object.keys(allDirectives).forEach((name) => {
  const directive = allDirectives[name];
  // 校验指令是否存在
  if (directive) Vue.directive(name, directive);
});

核心逻辑拆解

  • require.context:像一个 “文件扫描器”,会返回一个包含指定目录下所有匹配文件的对象,keys()方法能拿到所有文件路径(比如./focus.js./longpress.js)。

  • reduce遍历:把文件路径转换成 “指令名 - 指令对象” 的键值对,比如./focus.js{focus: 指令对象}

  • 合并指令:把手动指定的和自动扫描的指令合并,兼顾灵活性和自动化。

  • 统一注册:遍历合并后的指令对象,用Vue.directive注册全局指令。

怎么用?

新增指令时,只需要在src/directives/目录下新建.js文件即可,比如新建longpress.js

// src/directives/longpress.js 
export default { 
bind(el, binding) { 
    // 长按指令的逻辑(示例) 
    let timer = null; 
    el.addEventListener('touchstart', () => { 
        timer = setTimeout(() => { 
            binding.value(); // 执行指令绑定的方法 
            }, 1000); 
        }); 
        el.addEventListener('touchend', () => {
            clearTimeout(timer); 
        }); 
    } 
};

组件里直接用v-longpress,无需修改注册代码:

<template> 
    <button v-longpress="handleLongPress">长按1秒触发</button> 
</template> 
<script> 
export default { 
    methods: { handleLongPress() { alert('长按触发啦!'); } 
    } 
}; 
</script>

优点 & 缺点

  • 优点:新增指令只需新建文件,无需手动注册,维护成本低,适合中大型项目。
  • 缺点:比手动注册多了一点代码,新手需要理解require.contextreduce的用法,但理解后会非常香。

两种方法怎么选?

场景 推荐方式 核心原因
指令数量≤3 个 手动注册 简单直接,无需额外学习成本
指令数量≥3 个 自动扫描注册 减少重复工作,降低维护成本
新手入门 先手动后自动 循序渐进理解,避免一开始懵

总结

  1. Vue 全局注册自定义指令的核心是Vue.directive(指令名, 指令对象),两种方式最终都是调用这个方法。
  2. 手动注册适合指令少的场景,优点是简单直观;自动扫描注册基于require.context实现,适合指令多的场景,新增指令无需改注册代码。
  3. 实际开发中可以结合两种方式:特殊指令手动指定,常规指令自动扫描,兼顾灵活性和自动化。

Cursor 要被淘汰了?开发者最应该关注的 10 个信号

2026年3月10日 16:24

一位名叫 Jerry Murdoch 的顶级风险投资人(他是 Insight Partners 的联合创始人,这家公司如今管理着超过 900 亿美元资产),这两天在参加一个节目时公开表示,很多公司和团队告诉他:Cursor 已经过时了

如果你想查看全文,请关注 ai超级个人 公众号,输入 curosr 即可获得全部我翻译过的详细访谈内容。

我从访谈内容中总结出 10 个对于我们开发者应该注意的信号!欢迎一起讨论哦!

Cursor 为什么可能被淘汰

1. Cursor 的核心范式是「AI 辅助编程」

Cursor 的模式是:

Human → 写 prompt → AI 生成代码

本质仍然是: Human in the loop 人仍然是开发主体。但新的趋势是:

Human → 给任务 → Agent 自己写代码

开发者从 coder 变成 supervisor差异

模式 主体
AI Copilot
AI Agent AI

一旦进入 Agent 模式,Cursor 的价值会下降。


2. Autonomous Agent 可以「自己写代码」

访谈中提到:很多 AI 原生公司已经在使用 autonomous agents 写代码

Agent 会:

  1. 分解任务
  2. 生成代码
  3. 运行代码
  4. 自动测试
  5. 自动修复

流程变成:

Task
 ↓
Agent
 ↓
Code → Test → Fix → Deploy

开发者只做:目标设定 + 审查而不是写代码。

3. Agent 会自动选择模型

未来不会只用一个 LLM。Agent 会:

Workflow
   ├ Claude(复杂推理)
   ├ GPT(通用任务)
   ├ 开源模型(便宜任务)

即:

LLM Orchestration cursor 这种 单模型 IDE 就不再是中心。未来中心是:

Agent Orchestrator

4. Agent 会自动试错(程序员不会)

人类开发者通常:

猜一个方案 → 写代码

但 Agent 会:

生成10种方案
跑10个 sandbox
自动benchmark
选最优方案

也就是说:开发本身变成实验过程。 Cursor 不具备这种能力。

5. Sandbox 成为关键基础设施

访谈中提到一个细节:普通 sandbox:

400ms 启动

E2B sandbox:

80ms

为什么重要?因为 Agent 可能:

1秒启动 10万 sandbox

未来架构:

Agent
 ↓
Massive Sandbox
 ↓
Experimentation

这和 Cursor IDE 完全不同。

6. 软件将卖给 Agent 而不是人

今天:

Software → 人购买

未来:

Software → Agent 自动采购

流程:

Agent
 ↓
需要能力
 ↓
调用 API / SaaS
 ↓
按使用量付费

所以软件形态会变成:

API / compute service

而不是:

GUI 产品

IDE 的重要性会下降。

7. SaaS 正在变成 Compute Service

传统 SaaS:

软件 + UI

未来:

API + Compute

定价模式:

Consumption based

例如:

Sandbox
Compute
Inference
Storage

Agent 自动调用。

开发者甚至看不到 UI。

8. 开源 Agent 生态正在爆炸

访谈提到:真正推动 AI 的是:

Open source communities

原因:

开源有:

10000+开发者

而公司只有:

1000工程师

未来可能出现类似:

LAMP Stack

Agent Stack

例如:

Model
Orchestrator
Memory
Tools
Sandbox

9. AI Native 公司正在取代 AI Feature 公司

两种公司:

Bolt-on AI

旧产品 + AI功能

例如:

Notion + AI
Figma + AI
IDE + AI

AI Native

从第一天就:

Agent architecture

例如:

Auto Dev
Auto Research
Auto Design

AI Native 的效率会远高于 Bolt-on。

10. Developer Role 正在改变

过去:

Developer = 写代码

现在:

Developer = Prompt engineer

未来:

Developer = Agent architect

开发者工作会变成:

定义任务
设计 agent
管理系统
审核结果

而不是:

写代码

搭建高可用私有 NPM 镜像

作者 醒来明月
2026年3月10日 16:21

前言

在 AI 飞速发展的今天,各种 AI 编程助手极大地提升了代码编写效率,但是不要忽视了代码安全和研发规范的重要性。私有 npm 镜像在开发中就是关键的一环。本文将介绍利用 Verdaccio 搭建一套私有 npm 仓库。

一、 为什么我们需要私有 NPM 镜像

  1. 安全合规: AI 生成的代码需要依赖库,企业内部的私有代码(业务逻辑库、组件库)不能上传到公共 npm 仓库。私有镜像是保护企业数字资产的一道防线。
  2. 依赖稳定性: AI 无法解决公共仓库包删除、网络波动或其他事件导致的构建失败。私有镜像缓存机制是 CI/CD 流水线的基石。
  3. 标准化与提效: AI 可能会推荐各种第三方库,私有镜像可以充当白名单,确保团队使用经过审核的、统一的工具链,避免 AI 引入劣质依赖。

二、 技术选型:为什么选择 Verdaccio?

搭建私有 npm 仓库的方案主要有三种:

  1. Git Submodule / Monorepo: 适合小型团队,但缺乏版本管理,且占用项目体积。
  2. Sonatype Nexus: 功能极其强大,支持 Maven、Docker、Npm 等,但配置复杂,属于“重武器”,适合有专门运维团队的大型公司。
  3. Verdaccio: 轻量级、基于 Node.js、零配置启动、支持插件扩展。

对于 90% 的中小型及中大型前端团队,Verdaccio 是最佳选择。 它开箱即用,支持代理公共仓库,且社区活跃。

三、 从零搭建实战流程

我们将使用 Docker 进行部署,这是最符合企业级运维规范的方式。

步骤 1:服务器准备与安装

假设你有一台 Linux 服务器(推荐 CentOS/Ubuntu),已安装 Docker 和 Node.js 环境。

方式一:快速体验(CLI 安装)

# 全局安装
npm install -g verdaccio

# 后台启动
verdaccio

默认会在 http://localhost:4873 启动服务。

方式二:企业级 Docker 部署(推荐) 创建配置文件目录并启动容器:

# 创建挂载目录
mkdir -p /data/verdaccio/conf
mkdir -p /data/verdaccio/storage
mkdir -p /data/verdaccio/plugins

# 设置权限(Docker 容器内默认使用 uid 10001)
chown -R 10001:10001 /data/verdaccio

# 启动容器
docker run -d -it \
  --name verdaccio \
  -p 4873:4873 \
  -v /data/verdaccio/conf:/verdaccio/conf \
  -v /data/verdaccio/storage:/verdaccio/storage \
  -v /data/verdaccio/plugins:/verdaccio/plugins \
  verdaccio/verdaccio
  
 # docker run -d -it 启动一个新容器,后台运行并保留虚拟终端
 
 # -p 4873:4873 端口映射
 # 冒号左边 `4873` 是服务器的端口(外面),冒号右边 `4873` 是容器内部的端口(里面)
 # 这样你访问 `http://服务器IP:4873` 就能打开界面了

 # -v /data/verdaccio/conf:/verdaccio/conf  文件挂载
 # 冒号左边 `/data/verdaccio/conf` 是你服务器上的真实文件夹(你需要提前创建好的)。
 # 冒号右边 `/verdaccio/conf` 是容器内部的配置文件目录。
 # 这样做的好处是:配置文件保存在服务器上,即使容器被删除了,配置也不会丢。

 # -v ...storage和-v ...plugins同上,分别挂载了存储目录(存放下载的 npm 包)和插件目录。

 # verdaccio/verdaccio 是 Docker 镜像的名字,告诉 Docker 去下载并运行哪个软件。

步骤 2:核心配置

Verdaccio 的核心配置文件位于 /data/verdaccio/conf/config.yaml。如果文件不存在,Docker 启动时会自动生成一份默认配置。我们需要修改它以适应企业需求。

关键配置项解析:

# 存储路径
storage: /verdaccio/storage

# 插件路径
plugins: /verdaccio/plugins

# 认证配置 (默认使用 htpasswd,即文件存储用户)
auth:
  htpasswd:
    file: /verdaccio/conf/htpasswd
    max_users: 100 # 最大用户数

# 包的访问控制 (重点)
packages:
  # '@company/' 开头的包视为公司私有包
  '@company/*':
    access: $authenticated  # 只有登录用户可访问
    publish: $authenticated # 只有登录用户可发布
    unpublish: $authenticated
    proxy: npmjs # 私有包不需要代理

  # 其他所有包 (公共包)
  '**':
    access: $all # 所有人可访问
    publish: $authenticated
    proxy: npmjs # 如果本地没有,去 npmjs 代理下载

# 上游代理配置
uplinks:
  npmjs:
    url: https://registry.npmjs.org/
    # 如果在国内,可以代理淘宝源加速缓存
    # url: https://registry.npmmirror.com/

# 日志配置
logs:
  - { type: stdout, format: pretty, level: warn }

配置解读:

  • 我们区分了 @company/* 作用域的包,确保私有包只有认证用户才能看到和下载。
  • 对于公共包,利用 uplinks 配置了代理。当请求 lodash 时,Verdaccio 会先查本地缓存,没有则去 npmjs 拉取并缓存。

步骤 3:NPM 客户端配置

服务端搭建好后,开发者本地需要切换源。强烈建议不要直接修改 npm 的全局 registry,因为这会导致发布公共包时误发布到私有源。

推荐方案:使用 npm 配置作用域

# 1. 设置私有包指向私有仓库
npm config set @company:registry http://your-server-ip:4873

# 2. 注册用户
npm adduser --registry http://your-server-ip:4873
# 按提示输入用户名、密码、邮箱

这样,当 npm install lodash 时,依然走公共源(或淘宝镜像);当 npm install @company/ui-lib 时,会自动走私有服务器。

步骤 4:发布私有包

在项目根目录初始化 npm 项目,确保 package.json 中的 name 字段带有作用域:

{
  "name": "@company/logger-util",
  "version": "1.0.0",
  "private": false
}

发布流程:

# 登录(如果未登录)
npm login --registry=http://your-server-ip:4873

# 发布
npm publish --registry=http://your-server-ip:4873

发布成功后,访问 http://your-server-ip:4873 即可在网页端看到你的私有包。

四、 进阶:CI/CD 集成与自动化

在企业中,我们通常使用 Jenkins 或 GitHub Actions 自动发布包。为了安全,不应使用明文密码,而是使用 npm token

1. 生成 Token

# 登录后生成 token
npm token create --registry=http://your-server-ip:4873

2. CI 环境变量配置

将生成的 Token 存入 CI 系统的 Secrets(如 NPM_TOKEN)。

在项目根目录创建 .npmrc 文件(或者在 CI 脚本中动态写入):

//your-server-ip:4873/:_authToken=${NPM_TOKEN}
@company:registry=http://your-server-ip:4873

CI 流水线脚本示例:

echo "//your-server-ip:4873/:_authToken=${NPM_TOKEN}" >> ~/.npmrc
npm publish

五、 总结

在 AI 时代,代码生成的门槛降低了,但工程化治理的门槛变高了。搭建私有 npm 镜像,不仅是为了解决下载速度问题,更是构建企业级前端研发体系的第一步。本文完成了 Verdaccio 的基础搭建、权限配置、作用域管理以及 CI/CD 集成。这套架构成本极低(一台低配服务器即可),却能带来代码安全、团队协作效率的提升,是必须掌握的基础设施建设能力。

Vue的响应式原理?Vue2和Vue3有什么区别?

作者 光影少年
2026年3月10日 16:19

一、什么是 Vue 的响应式

Vue 的核心能力就是 数据变化 → 自动更新视图

例如:

data() {
  return {
    count: 1
  }
}
<div>{{ count }}</div>

当执行:

this.count = 2

页面会自动更新。

这个过程就是 响应式系统

核心流程:

数据变化
   ↓
监听数据变化
   ↓
通知依赖更新
   ↓
重新渲染视图

Vue 内部有三个关键角色:

角色 作用
Observer 监听数据
Dep 依赖收集
Watcher 触发更新

二、Vue2 响应式原理(Object.defineProperty)

Vue2 使用:

Object.defineProperty

劫持对象属性。

示例

let obj = {}

Object.defineProperty(obj, "name", {
  get() {
    console.log("读取")
    return value
  },
  set(newVal) {
    console.log("修改")
    value = newVal
  }
})

当访问:

obj.name

会触发

get()

当修改:

obj.name = "Vue"

会触发

set()

Vue 就利用这个机制实现响应式。


Vue2 内部流程

1 数据劫持

Vue 在初始化 data 时:

遍历所有属性

给每个属性添加 getter / setter。

伪代码:

function defineReactive(obj, key, val) {
  Object.defineProperty(obj, key, {
    get() {
      // 收集依赖
      dep.depend()
      return val
    },
    set(newVal) {
      val = newVal
      // 通知更新
      dep.notify()
    }
  })
}

2 依赖收集

当模板渲染:

{{ count }}

会生成一个 Watcher

Watcher 会读取数据:

count

触发

getter

然后:

Dep 收集 Watcher

结构:

count
  ↓
Dep
  ↓
Watcher

3 数据变化

当执行:

this.count++

触发:

setter

然后:

Dep.notify()

通知所有 Watcher 更新。

Watcher → 重新渲染

三、Vue2 的缺点

Vue2 的响应式有几个问题:

1 不能监听对象新增属性

this.obj.age = 18

不会更新。

必须:

Vue.set(this.obj, "age", 18)

2 不能监听数组下标

this.arr[1] = 10

不会更新。

必须:

splice
push
pop
shift

Vue 重写了数组方法。


3 初始化性能差

Vue2 会:

递归遍历整个 data

如果数据非常大:

初始化慢

四、Vue3 响应式原理(Proxy)

Vue3 使用:

Proxy

代替

Object.defineProperty

示例:

let obj = { name: "vue" }

let proxy = new Proxy(obj, {
  get(target, key) {
    console.log("读取")
    return target[key]
  },
  set(target, key, value) {
    console.log("修改")
    target[key] = value
    return true
  }
})

Proxy 优势

Proxy 可以拦截:

13 种操作

比如:

get
set
deleteProperty
has
ownKeys

所以:

新增属性
删除属性
数组下标

都能监听。


Vue3 响应式核心

Vue3 内部有两个核心方法:

track(收集依赖)

track(target, key)

当读取数据:

get

收集依赖。


trigger(触发更新)

trigger(target, key)

当数据变化:

set

通知更新。


核心结构:

targetMap
  ↓
WeakMapMapSet

结构图:

WeakMap
  target -> Map
              key -> Set(effect)

意思是:

对象
  ↓
属性
  ↓
依赖函数

五、Vue2 vs Vue3 区别

对比 Vue2 Vue3
响应式实现 Object.defineProperty Proxy
监听新增属性 不支持 支持
数组下标 不支持 支持
初始化性能 需要递归遍历 按需代理
API Options API Composition API
代码体积 较大 更小
TS支持 一般 非常好

六、Vue3 Composition API(核心变化)

Vue3 新增:

setup()

例如:

import { ref } from "vue"

export default {
  setup() {
    const count = ref(0)

    const add = () => {
      count.value++
    }

    return { count, add }
  }
}

优势:

逻辑复用更好
代码组织更清晰
TS友好

七、面试最佳回答(推荐说法)

面试时可以这样回答:

Vue 的响应式原理是通过数据劫持和依赖收集实现的。

在 Vue2 中,主要通过 Object.defineProperty 对 data 的属性进行 getter 和 setter 劫持,当数据被读取时进行依赖收集,当数据被修改时通知依赖更新,从而触发视图重新渲染。

Vue2 的缺点是无法监听对象新增属性和数组下标变化,因此需要使用 Vue.set 或重写数组方法。

在 Vue3 中,响应式系统改为使用 Proxy 实现。Proxy 可以拦截更多操作,例如属性新增、删除、数组索引等,因此解决了 Vue2 的很多限制。同时 Vue3 使用 tracktrigger 来进行依赖收集和触发更新,并且性能更好。

此外 Vue3 还引入了 Composition API,使得逻辑复用更加灵活,对 TypeScript 支持更好。

Sentry browserTracingIntegration 实现原理深度解析

作者 charmson
2026年3月10日 16:12

基于 @sentry/browser 源码(packages/browser)及官方开发者文档整理


一、整体架构

browserTracingIntegration 本质上是一个插件容器,它在 afterAllSetup 钩子被调用时(即 Sentry Client 完全初始化后),批量注册多个底层监听器和 Monkey Patch,从而实现零侵入的自动追踪。

Sentry.init()
    └── afterAllSetup(client) 被调用
            ├── 1. 启动 Pageload Span(回溯时间戳到浏览器请求开始)
            ├── 2. 监听 History API → Navigation Span
            ├── 3. Monkey Patch fetch / XHR → HTTP Spans
            ├── 4. PerformanceObserver → Web Vitals Spans
            └── 5. PerformanceObserver → 资源加载 Spans

二、Pageload Span:页面加载追踪

实现方式

// packages/browser/src/tracing/browserTracingIntegration.ts(简化)

function afterAllSetup(client) {
  if (options.instrumentPageLoad) {
    // 1. 立即创建 pageload idle span
    const pageloadSpan = startIdleSpan({
      name: window.location.pathname,
      op: 'pageload',
      // 2. 关键:将开始时间回溯到浏览器真实发起请求的时刻
      startTime: getDocumentStartTime(), 
    });
  }
}

function getDocumentStartTime(): number {
  // 使用 Navigation Timing API 获取浏览器真实请求时间
  // performance.timing.navigationStart 或
  // performance.getEntriesByType('navigation')[0].startTime
  const navEntry = performance.getEntriesByType('navigation')[0];
  return navEntry ? navEntry.startTime / 1000 : Date.now() / 1000;
}

关键设计:Idle Span(空闲 Span)

Pageload Span 不会被显式结束,而是通过空闲机制自动关闭:

Span 创建
  │
  ├── 子 Span 活跃期间:保持开启
  │
  ├── 无新子 Span 超过 idleTimeout(默认 1s)→ 自动结束
  │
  └── 超过 finalTimeout(默认 30s)→ 强制结束

最早结束时机:document.readyState === 'interactive' | 'complete'

为什么需要 Idle Span?因为浏览器没有一个统一的"页面加载完毕"事件,不同应用(SSR、SPA、懒加载)的结束时机各不相同。


三、Navigation Span:路由变化追踪

实现方式:Monkey Patch History API

SPA 路由跳转不会触发页面刷新,Sentry 通过劫持 History API 来感知路由变化:

// packages/utils/src/instrument/history.ts(简化)

const originalPushState = window.history.pushState.bind(window.history);
const originalReplaceState = window.history.replaceState.bind(window.history);

window.history.pushState = function (...args) {
  // 先执行原始方法
  const result = originalPushState.apply(this, args);
  // 触发 Sentry 内部事件
  triggerHandlers('history', { from: oldHref, to: window.location.href });
  return result;
};

window.history.replaceState = function (...args) {
  const result = originalReplaceState.apply(this, args);
  triggerHandlers('history', { from: oldHref, to: window.location.href });
  return result;
};

// 同时监听浏览器前进/后退(popstate 事件)
window.addEventListener('popstate', () => {
  triggerHandlers('history', { from: oldHref, to: window.location.href });
});

自动重定向检测(v9.37.0+)

Sentry 能区分用户主动导航程序自动重定向(如未登录跳转到 /login):

导航发生
  │
  ├── 在 pageload 进行中 + 极短时间内触发 → 视为"重定向",不新建 Span
  │
  └── 用户交互后触发(有 click/keydown 事件) → 视为"导航",新建 Navigation Span

框架集成:参数化路由名

通用方案只能拿到原始 URL(如 /users/12345),框架 SDK 通过直接挂钩路由器获得参数化名称:

// @sentry/react 中的路由感知(简化)
// 可将 /users/12345 → /users/:id,便于在 Sentry UI 中聚合分析
browserTracingIntegration({
  beforeStartSpan: (context) => ({
    ...context,
    name: location.pathname.replace(//\d+/g, '/:id'),
  }),
});

四、HTTP 请求 Spans:fetch / XHR 追踪

fetch Monkey Patch

// packages/utils/src/instrument/fetch.ts(简化)

const originalFetch = window.fetch.bind(window);

window.fetch = function (input, init) {
  const url = getUrlFromInput(input);
  
  // 创建子 Span
  const span = startInactiveSpan({
    name: `${method} ${url}`,
    op: 'http.client',
    attributes: {
      'http.method': method,
      'http.url': url,
    },
  });

  // 注入分布式追踪 Header(传播 traceId 到后端)
  const headers = {
    ...init?.headers,
    'sentry-trace': spanToSentryTrace(span),
    'baggage': spanToBaggage(span),
  };

  return originalFetch(input, { ...init, headers })
    .then((response) => {
      // 请求完成,结束 Span
      span.setAttribute('http.status_code', response.status);
      span.end();
      return response;
    })
    .catch((error) => {
      // 请求失败
      span.setStatus({ code: SpanStatusCode.ERROR });
      span.end();
      throw error;
    });
};

XMLHttpRequest Monkey Patch

// packages/utils/src/instrument/xhr.ts(简化)

const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;

XMLHttpRequest.prototype.open = function (method, url) {
  // 记录请求信息到实例上,send 时使用
  this._sentryData = { method, url };
  return originalOpen.apply(this, arguments);
};

XMLHttpRequest.prototype.send = function (body) {
  const span = startInactiveSpan({
    name: `${this._sentryData.method} ${this._sentryData.url}`,
    op: 'http.client',
  });

  this.addEventListener('readystatechange', () => {
    if (this.readyState === XMLHttpRequest.DONE) {
      span.setAttribute('http.status_code', this.status);
      span.end();
    }
  });

  // 注入追踪 Header
  this.setRequestHeader('sentry-trace', spanToSentryTrace(span));
  this.setRequestHeader('baggage', spanToBaggage(span));

  return originalSend.apply(this, arguments);
};

shouldCreateSpanForRequest 过滤器

可以精细控制哪些请求需要创建 Span:

browserTracingIntegration({
  shouldCreateSpanForRequest: (url) => {
    // 不追踪健康检查和埋点请求
    return !url.match(//(health|analytics)/?$/);
  },
});

五、Web Vitals 追踪

实现方式:PerformanceObserver

// packages/browser/src/metrics/index.ts(简化)

// LCP(最大内容绘制)
const lcpObserver = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  const lastEntry = entries[entries.length - 1] as LargestContentfulPaint;
  
  // 将 LCP 值记录为当前 pageload span 的属性
  setMeasurement('lcp', lastEntry.startTime, 'millisecond');
});
lcpObserver.observe({ type: 'largest-contentful-paint', buffered: true });

// CLS(累积布局偏移)
const clsObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!(entry as LayoutShift).hadRecentInput) {
      clsValue += (entry as LayoutShift).value;
    }
  }
});
clsObserver.observe({ type: 'layout-shift', buffered: true });

// FCP(首次内容绘制)
const fcpObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (entry.name === 'first-contentful-paint') {
      setMeasurement('fcp', entry.startTime, 'millisecond');
    }
  }
});
fcpObserver.observe({ type: 'paint', buffered: true });

// TTFB(首字节时间)—— 从 Navigation Timing API 直接读取
const navEntry = performance.getEntriesByType('navigation')[0];
if (navEntry) {
  setMeasurement('ttfb', navEntry.responseStart, 'millisecond');
}

INP(交互到下一帧,v8.x+ 默认启用)

// INP 需要持续监听所有用户交互
const inpObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    // 每次用户交互(click/keydown/pointerdown)都创建一个独立 Span
    startSpan({
      name: `${entry.name} ${entry.target}`,
      op: 'ui.interaction',
      attributes: {
        'inp.value': entry.duration,
      },
    });
  }
});
inpObserver.observe({ type: 'event', durationThreshold: 40, buffered: true });

LCP/CLS 独立 Span(v10 实验性特性)

旧版本在 pageload span 结束时才上报 LCP/CLS,可能错过最终值。新版将其解耦为独立 Span:

// 实验性配置
browserTracingIntegration({
  _experiments: {
    enableStandaloneLcpSpans: true,
    enableStandaloneClsSpans: true,
  },
});

六、资源加载 Spans

通过 PerformanceObserver 监听资源加载(JS、CSS、图片、字体等):

const resourceObserver = new PerformanceObserver((list) => {
  for (const entry of list.getEntries() as PerformanceResourceTiming[]) {
    startInactiveSpan({
      name: entry.name,           // 资源 URL
      op: `resource.${getResourceType(entry)}`, // resource.script / resource.css / resource.img
      startTime: entry.startTime / 1000,
      attributes: {
        'http.response_content_length': entry.transferSize,
        'resource.render_blocking_status': entry.renderBlockingStatus,
      },
    }).end(entry.responseEnd / 1000); // 直接用真实结束时间
  }
});
resourceObserver.observe({ type: 'resource', buffered: true });

七、分布式追踪:Header 传播

每个 HTTP Span 创建时,会在请求头中注入追踪上下文,实现前后端链路打通:

浏览器发起请求
  
  ├── 自动注入 Header:
       sentry-trace: {traceId}-{spanId}-{sampled}
       baggage: sentry-trace_id=xxx,sentry-environment=prod,...
  
后端 Sentry SDK 读取 Header
  
  └── 将后端 Span 挂载到同一 traceId   完整链路视图

配置允许传播的域名:

browserTracingIntegration({
  // 只对这些域名注入追踪 Header,防止泄露到第三方
  tracePropagationTargets: ['localhost', /^https://api.myapp.com/],
});

八、总结:各功能的底层 API 对应关系

功能 底层 Browser API 核心技术
Pageload Span performance.getEntriesByType('navigation') Navigation Timing API + Idle Span
Navigation Span history.pushState / popstate Monkey Patch
fetch 追踪 window.fetch Monkey Patch
XHR 追踪 XMLHttpRequest.prototype.open/send Monkey Patch
LCP / FCP / CLS new PerformanceObserver(...) PerformanceObserver API
INP PerformanceObserver({ type: 'event' }) PerformanceObserver API
TTFB performance.getEntriesByType('navigation')[0].responseStart Navigation Timing API
资源加载 PerformanceObserver({ type: 'resource' }) PerformanceObserver API
分布式追踪 HTTP Header 注入 sentry-trace / baggage Header

How to Create a systemd Service File in Linux

systemd is the init system used by most modern Linux distributions, including Ubuntu, Debian, Fedora, and RHEL. It manages system processes, handles service dependencies, and starts services automatically at boot.

A systemd service file — also called a unit file — tells systemd how to start, stop, and manage a process. This guide explains how to create a systemd service file, install it on the system, and manage it with systemctl.

Quick Reference

Task Command
Create a service file sudo nano /etc/systemd/system/myservice.service
Reload systemd after changes sudo systemctl daemon-reload
Enable service at boot sudo systemctl enable myservice
Start the service sudo systemctl start myservice
Enable and start at once sudo systemctl enable --now myservice
Check service status sudo systemctl status myservice
Stop the service sudo systemctl stop myservice
Disable at boot sudo systemctl disable myservice
View service logs sudo journalctl -u myservice
View live logs sudo journalctl -fu myservice

Understanding systemd Unit Files

Service files are plain text files with an .service extension. They are stored in one of two locations:

  • /etc/systemd/system/ — system-wide services, managed by root. Files here take priority over package-installed units.
  • /usr/lib/systemd/system/ or /lib/systemd/system/ — units installed by packages, depending on the distribution. Do not edit these directly; copy them to /etc/systemd/system/ to override.

A service file is divided into three sections:

  • [Unit] — describes the service and declares dependencies.
  • [Service] — defines how to start, stop, and run the process.
  • [Install] — controls how and when the service is enabled.

Creating a Simple Service File

In this example, we will create a service that runs a custom Bash script. First, create the script you want to run:

Terminal
sudo nano /usr/local/bin/myapp.sh

Add the following content to the script:

/usr/local/bin/myapp.shsh
#!/bin/bash
echo "myapp started" >> /var/log/myapp.log
# your application logic here
exec /usr/local/bin/myapp

Make the script executable:

Terminal
sudo chmod +x /usr/local/bin/myapp.sh

Now create the service file:

Terminal
sudo nano /etc/systemd/system/myapp.service

Add the following content:

/etc/systemd/system/myapp.serviceini
[Unit]
Description=My Custom Application
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/myapp.sh
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

After saving the file, reload systemd to pick up the new unit:

Terminal
sudo systemctl daemon-reload

Enable and start the service:

Terminal
sudo systemctl enable --now myapp

Check that it is running:

Terminal
sudo systemctl status myapp
output
● myapp.service - My Custom Application
Loaded: loaded (/etc/systemd/system/myapp.service; enabled; vendor preset: enabled)
Active: active (running) since Mon 2026-03-10 09:00:00 CET; 2s ago
Main PID: 1234 (myapp.sh)
Tasks: 1 (limit: 4915)
CGroup: /system.slice/myapp.service
└─1234 /bin/bash /usr/local/bin/myapp.sh

Unit File Sections Explained

[Unit] Section

The [Unit] section contains metadata and dependency declarations.

Common directives:

  • Description= — a short human-readable name shown in systemctl status output.
  • After= — start this service only after the listed units are active. Does not create a dependency; use Requires= for hard dependencies.
  • Requires= — this service requires the listed units. If they fail, this service fails too.
  • Wants= — like Requires=, but the service still starts even if the listed units fail.
  • Documentation= — a URL or man page reference for the service.

[Service] Section

The [Service] section defines the process and how systemd manages it.

Common directives:

  • Type= — the startup type. See Service Types below.
  • ExecStart= — the command to run when the service starts. Must be an absolute path.
  • ExecStop= — the command to run when the service stops. If omitted, systemd sends SIGTERM.
  • ExecReload= — the command to reload the service configuration without restarting.
  • Restart= — when to restart automatically. See Restart Policies below.
  • RestartSec= — how many seconds to wait before restarting. Default is 100ms.
  • User= — run the process as this user instead of root.
  • Group= — run the process as this group.
  • WorkingDirectory= — set the working directory for the process.
  • Environment= — set environment variables: Environment="KEY=value".
  • EnvironmentFile= — read environment variables from a file.

[Install] Section

The [Install] section controls how the service is enabled.

  • WantedBy=multi-user.target — the most common value. Enables the service for normal multi-user (non-graphical) boot.
  • WantedBy=graphical.target — use this if the service requires a graphical environment.

Service Types

The Type= directive tells systemd how the process behaves at startup.

Type Description
simple Default. The process started by ExecStart is the main process.
forking The process forks and the parent exits. systemd tracks the child. Use with traditional daemons.
oneshot The process runs once and exits. systemd waits for it to finish before marking the service as active.
notify Like simple, but the process notifies systemd when it is ready using sd_notify().
idle Like simple, but the process is delayed until all active jobs finish.

For most custom scripts and applications, Type=simple is the right choice.

Restart Policies

The Restart= directive controls when systemd automatically restarts the service.

Value Restarts when
no Never (default).
always The process exits for any reason.
on-failure The process exits with a non-zero code, is killed by a signal, or times out.
on-abnormal Killed by a signal or times out, but not clean exit.
on-success Only if the process exits cleanly (exit code 0).

For long-running services, Restart=on-failure is the most common choice. Use Restart=always for services that must stay running under all circumstances.

Running a Service as a Non-Root User

Running services as root is a security risk. Use the User= and Group= directives to run the service as a dedicated user:

/etc/systemd/system/myapp.serviceini
[Unit]
Description=My Custom Application
After=network.target

[Service]
Type=simple
User=myappuser
Group=myappuser
ExecStart=/usr/local/bin/myapp
Restart=on-failure
WorkingDirectory=/opt/myapp
Environment="NODE_ENV=production"

[Install]
WantedBy=multi-user.target

Create the system user before enabling the service:

Terminal
sudo useradd -r -s /usr/sbin/nologin myappuser

Troubleshooting

Service fails to start — “Failed to start myapp.service”
Check the logs with journalctl : sudo journalctl -u myapp --since "5 minutes ago". The output shows the exact error message from the process.

Changes to the service file have no effect
You must run sudo systemctl daemon-reload after every edit to the unit file. systemd reads the file at reload time, not at start time.

“ExecStart= must be an absolute path”
The ExecStart= value must start with /. Use the full path to the binary — for example /usr/bin/python3 not python3. Use which python3 to find the absolute path.

Service starts but immediately exits
The process may be crashing on startup. Check sudo journalctl -u myapp -n 50 for error output. If Type=simple, make sure ExecStart= runs the process in the foreground — not a script that launches a background process and exits.

Service does not start at boot despite being enabled
Confirm the [Install] section has a valid WantedBy= target and that systemctl enable was run after daemon-reload. Check with systemctl is-enabled myapp.

FAQ

Where should I put my service file?
Put custom service files in /etc/systemd/system/. Files there take priority over package-installed units in /usr/lib/systemd/system/ and persist across package upgrades.

What is the difference between enable and start?
systemctl start starts the service immediately for the current session only. systemctl enable creates a symlink so the service starts automatically at boot. To do both at once, use systemctl enable --now myservice.

How do I view logs for my service?
Use sudo journalctl -u myservice to see all logs. Add -f to follow live output, or --since "1 hour ago" to limit the time range. See the journalctl guide for full usage.

Can I run a service as a regular user without root?
Yes, using user-level systemd units stored in ~/.config/systemd/user/. Manage them with systemctl --user enable myservice. They run when the user logs in and stop when they log out (unless lingering is enabled with loginctl enable-linger username).

How do I reload a service after changing its configuration?
If the service supports it, use sudo systemctl reload myservice — this sends SIGHUP or runs ExecReload= without restarting the process. Otherwise, use sudo systemctl restart myservice.

Conclusion

Creating a systemd service file gives you full control over how and when your process runs — including automatic restarts, boot integration, and user isolation. Once the file is in place, use systemctl enable --now to activate it and journalctl -u to monitor it. For scheduling tasks that run once at a set time rather than continuously, consider cron jobs as an alternative.

electron、edge.js调用C#动态链接库的一些问题

作者 忘ci
2026年3月10日 15:57

Edge.js 是一个开源的互操作桥梁,它允许开发者在同一个进程中让 Node.js 和 .NET 代码一起运行。electron-edge-js在这个基础上对electron进行了适配,使得可以在electron中调用C#动态链路库。

1.安装electron-edge-js

npm install electron-edge-js

2.调用示例

需要注意的是dll在打包后文件路径会发生变化需要进行

/**
 * 调用 .NET DLL 中的方法
 * @param methodName - 方法名
 * @param params - 传递给 .NET 方法的参数(须可 JSON 序列化)
 * @param options - 可选配置:typeName,customSearchPaths(自定义 DLL 搜索路径)
 */
export async function callDotNetMethod(
  methodName: string,
  params?: any,
  options?: {
    typeName?: string;
    customSearchPaths?: string[];
  }
): Promise<any> {
  // 1. 定位 DLL
  const { dll, root } = locateDll(options?.customSearchPaths);
  process.env.DOTNET_ROOT = root;
  ensureEnvironment();
  // 2. 获取或创建方法代理
  const cacheKey = `${dll}|${options?.typeName || ''}|${methodName}`;
  let func = methodCache.get(cacheKey);
  if (!func) {
    func = edge.func({
      assemblyFile: dll,
      typeName: options?.typeName || '',
      methodName: methodName.trim(),
    });
    methodCache.set(cacheKey, func);
  }

  // 3. 执行调用
  return new Promise((resolve, reject) => {
    func(params, (error: any, result: any) => {
      if (error) {
        reject(error);
      } else {
        resolve(result);
      }
    });
  });
}

3.dll定位

  • 在打包后(例如使用 electron-builder 或 electron-forge),DLL 会被复制到 resources 目录下,因此需要根据 process.resourcesPath 动态构造路径。
  • 根据运行环境查找对应的路径
❌
❌