阅读视图

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

别等用户吐槽!开发者该如何证明自己的程序 “好用”?

结合调研数据,核心比例结论先明确:仅 35% 的用户会主动反馈软件 “慢 / 难用”,65% 的用户选择不反馈(含 “默默忍受” 或 “直接卸载”) ,且不同场景下比例会有差异,具体拆解如下:

从上边结果来看,当我们加班加点把软件系统(一个 APP 或者一个 Java 微服务或者一个 Web 商城网站)开发好上线后,大部分用户不会主动反馈问题,系统再卡顿、体验再差,也很少会说,只会默默选择不用、卸载或离开。

那么如何解决这个问题呢?

系统 SLO——将 “系统好不好用、用户体验佳不佳” 的模糊感知,转化为可量化、可监控、可告警的 SLO 指标体系,再将 SLO 拆解为落地可测的 KPI,既解决领导对系统价值的量化判断难题,也摆脱 “靠用户反馈发现问题” 的被动局面。

名词 描述
SLA 即 Service-Level Agreement,服务等级协议,指系统服务提供者(Provider)对客户(Customer)的服务承诺。您可以对服务商的服务质量 SLA 评分,实时监测服务的达标率
SLI 即 Service Level Indicator,测量指标,指选择用于衡量系统稳定性的指标。观测云 SLI 支持基于监控器设定一个或多个测量指标
SLO 即 Service Level Objective,观测云进行 SLA 评分处理的最小单元,是一个时间窗口内 SLI 累积成功数的目标。而我们又经常把 SLO 转化为错误预算,用于计算可容忍的错误数,在每一个检测周期内出现异常事件的时间将在可容错时长中扣除

这里 SLO 全生命周期管理(定义、监控、告警、复盘)能力,能完美承接这套体系的落地,实现从 “被动救火” 到 “主动防控” 的转变,通过几个维度讲透如何基于 SLO 做系统量化评估,适配企业内所有业务系统 / 技术系统。

一、核心逻辑:SLO 是桥梁,连接 “用户体验” 与 “技术 KPI”

很多企业的痛点是技术指标与用户体验脱节:技术团队盯着 CPU、QPS 等纯技术指标,却不知道这些指标背后对应什么样的用户体验;领导判断系统好不好用,只能靠 “用户有没有投诉、业务有没有提需求”,缺乏客观标准。

  • SLO:站在用户 / 业务视角定义的服务等级目标,是 “系统好不好用” 的核心衡量标准(比如 “核心交易接口 99.99% 的请求在 200ms 内响应”“页面 99.9% 的加载请求在 1.5s 内完成”),直接对应用户体验;
  • KPI:站在技术视角拆解的落地指标,是实现 SLO 的具体技术保障(比如 “接口 99 分位响应耗时≤200ms”“服务器 CPU 峰值利用率≤70%”),可通过直接采集监控;

这套体系的核心价值:

  • 给领导量化判断依据:无需靠主观感受,打开 SLO 大盘,就能看到每个系统的 SLO 达成率、核心 KPI 达标情况,直接判断系统是否 “好用”;
  • 变被动为主动:对 SLO/KPI 做实时监控,一旦指标偏离阈值,提前触发告警,技术团队在用户感知到问题前就介入解决,彻底摆脱 “靠用户反馈发现问题” 的被动;
  • 技术工作对齐业务价值:技术团队的工作不再是 “为了调优指标而调优”,而是围绕 “达成 SLO、提升用户体验” 展开,所有技术优化都有明确的业务目标;
  • 问题可归因、优化可验证:的全链路可观测能力(指标、日志、链路、追踪),能在 SLO 未达标时快速定位根因,优化后也能通过 SLO/KPI 的变化,量化验证优化效果。

二、体系搭建核心步骤:从用户视角出发,基于落地

2.1 明确用户视角的核心体验点

先对企业内所有系统做分层分类,明确每个系统的核心用户(C 端用户 / 业务端用户 / 内部研发 / 运营)和用户最关注的体验点—— 这是定义 SLO 的基础,避免 SLO 与用户体验脱节。针对每个系统,梳理用户在使用系统时的核心动作,并提炼对应的体验诉求,这是 SLO 的 “用户侧源头”。

示例

  • 电商交易系统(C 端用户):核心动作是 “下单支付、商品查询、页面浏览”,体验诉求是 “下单不卡顿、支付不掉线、页面加载快”;
  • 业务中台系统(业务研发用户):核心动作是 “调用接口、配置参数、查看返回结果”,体验诉求是 “接口调用成功、响应快、参数配置生效及时”;
  • 运营后台(运营用户):核心动作是 “查询数据、导出报表、操作工单”,体验诉求是 “数据查询不超时、报表导出快、操作不报错”。

2.2 基于 SLO 模型定义各系统的核心

以下三类 SLO 是的核心能力覆盖范围,无需二次开发,可直接在平台内配置监控、告警、复盘,也是最能反映 “系统好不好用” 的核心维度。

SLO 类型 定义 对应用户体验 能力支撑
可用性 SLO 统计周期内,系统 / 功能 / 接口可用时长占比(扣除计划内维护) 系统 “不宕机、能正常访问”,是用户体验的基础 主机监控、服务监控、心跳检测,精准统计可用 / 不可用时长
性能 SLO 统计周期内,符合用户体验的请求响应占比(如 “200ms 内响应的请求占比”) 系统 “不卡顿、加载快”,是用户体验的核心 接口监控、链路追踪、前端性能监控,按分位值 / 固定阈值统计性能达标请求占比
成功率 SLO 统计周期内,系统 / 接口 / 功能成功执行的请求占比(如 “交易成功请求占比”“页面加载成功占比”) 系统 “操作不报错、执行有结果”,是用户体验的关键 日志分析、接口监控、业务埋点,精准统计成功 / 失败请求数

2.3 核心 SLO-KPI 拆解模型(基于采集能力,可直接复用)

结合的全维度可观测指标库,将 3 类核心 SLO 拆解为通用 KPI,不同系统可根据实际情况微调,所有 KPI 均可直接配置监控

核心 SLO 类型 核心拆解 KPI KPI 定义 采集方式 通用目标值(核心系统 / 一般系统)
可用性 SLO 系统服务运行率 统计周期内,系统核心服务正常运行时长 / 统计总时长 服务监控:采集服务启动 / 停止状态、心跳检测结果 核心≥99.99% / 一般≥99.9%
可用性 SLO 主机在线率 统计周期内,系统部署主机正常在线时长 / 统计总时长 主机监控:采集主机 CPU、内存、网络心跳,判定在线状态 核心≥99.99% / 一般≥99.9%
性能 SLO 接口 99 分位响应耗时 系统核心接口请求响应耗时的 99 分位值 接口监控 / 链路追踪:采集接口每次请求的响应耗时,计算分位值 核心≤200ms / 一般≤500ms
性能 SLO 页面首屏加载耗时 前端页面首屏内容渲染完成的平均耗时 前端性能监控:埋点采集页面加载各阶段耗时 核心≤1.5s(移动端)/ 一般≤3s
性能 SLO 数据库 99 分位读写耗时 核心数据库 SELECT/INSERT 操作的 99 分位耗时 数据库监控:采集数据库执行语句的耗时 核心≤50ms(读)/≤100ms(写)
成功率 SLO 核心接口成功率 统计周期内,核心接口成功请求数 / 总请求数 接口监控:按返回码(200 为成功)统计 核心≥99.99% / 一般≥99.9%
成功率 SLO 前端页面加载成功率 统计周期内,页面成功加载次数 / 总请求次数 前端监控 / 日志分析:统计页面加载失败(4xx/5xx)次数 核心≥99.9% / 一般≥99%
成功率 SLO 业务操作成功率 统计周期内,核心业务操作(交易 / 下单 / 导出)成功次数 / 总次数 业务埋点 / 日志分析:按业务日志关键字(“成功 / 失败”)统计 核心≥99.99% / 一般≥99.9%

2.4 配置步骤

2.4.1 基础配置:确保能采集所有 KPI 数据

先完成数据采集接入,确保所有拆解的 KPI 都能被自动采集,无数据盲区 —— 支持多维度采集方式,适配所有技术栈,操作简单:

  • 基础设施采集:通过 Agent 接入主机、容器、云服务器,采集 CPU、内存、磁盘等主机 KPI;
  • 服务 / 接口采集:通过 SDK/APM 接入微服务、HTTP 接口,采集接口响应耗时、成功率等 KPI;
  • 前端采集:通过前端埋点 SDK,接入 H5/APP/小程序,采集页面加载耗时、成功率等 KPI;
  • 中间件 / 数据库采集:通过专属插件,接入 Redis、MQ、MySQL、PostgreSQL,采集缓存命中率、数据库读写耗时等 KPI;
  • 业务采集:通过自定义埋点 / 日志采集,接入业务操作成功率等自定义 KPI(支持日志关键字提取、自定义指标上报)。

2.4.2 核心配置:在定义 SLO,关联 KPI 指标

SLO 模块支持自定义 SLO 规则、关联指标、自动计算 SLO 达成率,直接对接前面定义的 SLO,步骤如下:

  • 登录平台,进入「SLO 管理」→「新建 SLO」;
  • 填写 SLO 基本信息:名称、所属系统、SLO 类型(可用性 / 性能 / 成功率)、目标值、统计周期;
  • 关联 KPI 指标:从指标库中选择已采集的 KPI,设置 SLO 计算规则(如 “成功率 SLO = 接口成功请求数 / 总请求数,排除压测流量标签”);
  • 设置SLO 告警阈值:建议设置 “预警阈值(如 99.9%)+ 告警阈值(如 99.8%)”,提前触发预警,避免 SLO 达标率跌破目标;
  • 保存并启用 SLO:将自动实时计算 SLO 达成率,关联的 KPI 指标发生变化时,SLO 达成率同步更新。

2.4.3 关键配置:设置分级告警,摆脱被动响应

基于的告警模块,为 SLO/KPI 设置分级告警规则,确保异常在用户感知前被发现,技术团队主动介入,核心是 “按 SLO 重要性分级,匹配不同的告警方式和响应时效”:

告警等级 触发条件 告警方式(支持) 响应时效 责任主体
P0(紧急) 核心系统核心 SLO 达成率跌破目标值(如 99.99%→99.5%),或核心 KPI 严重异常(如接口成功率骤降) 电话 + 短信 + 企业微信 / 钉钉 @所有人 + 平台红字告警 5 分钟内响应,30 分钟内解决 技术负责人 + 核心研发 + 运维
P1(重要) 核心系统辅助 SLO/KPI 异常,或重要系统核心 SLO 达成率跌破预警阈值 企业微信 / 钉钉 @项目组 + 平台告警 15 分钟内响应,1 小时内解决 项目研发 + 运维
P2(一般) 重要系统辅助 SLO/KPI 异常,或一般系统 SLO/KPI 异常 企业微信 / 钉钉单聊通知责任人 + 平台告警 30 分钟内响应,2 小时内解决 对应模块研发 / 运维

2.4.4 最终呈现:打造可视化大盘,一键判断系统好坏

基于的可视化模块,打造3 级可视化大盘,满足领导、技术管理、一线研发的不同查看需求,大盘支持实时刷新、钻取分析、多维度筛选,让 “系统好不好用” 一目了然。

三、AI 系统 SLO 落地案例

以下结合 AI 系统的实操案例,详细说明大盘搭建与 SLO 配置的完整流程(该案例已落地验证,可直接复用配置逻辑):

3.1 前置准备:统一规范与标签体系

为确保监控与 SLO 的统一性和可追溯性,首先建立标准化的标签与命名规范:

  • 全局标签:为 AI系统 配置专属全局标签(如df_label=AI系统),关联service(服务名)、http_route(接口路由)、pod_name(容器名)等维度,便于指标筛选与聚合;
  • 命名规范:所有监控器、SLO、看板均以 “项目名开头”,确保辨识度,例如 “智慧供应链服务请求错误率大于 80%”“AI系统 ”。

3.2 步骤 1:创建核心监控器(SLI 数据来源)

监控器是 SLO 的基础数据支撑(即 SLI,服务等级指标),需针对系统核心 KPI 配置监控规则,具体要求如下:

  • 监控器配置维度:覆盖错误率、响应时间、请求量、资源使用率等核心场景,例如:

    • 服务请求错误率监控:AI系统 请求错误率大于 80%(检测频率 1 分钟,检测区间最近 5 分钟);
    • 响应时间监控:AI系统 平均响应时间大于 3 秒、P95 响应时间过长、响应时间突增;
    • 业务异常监控:代理 24 小时未发货、请求数突增、请求失败率突增;
  • 配置注意事项:避免选择高基数字段作为检测维度,防止告警过于宽松引发频繁告警;检测频率与区间可自定义(如 20m、2h、1d),核心指标建议按分钟级检测。

3.3 步骤 2:创建系统专属 SLO

基于已配置的监控器(SLI),创建项目组专属 SLO,实现 “监控指标→SLO 目标” 的关联:

  • SLO 创建规则:每个项目组对应 1 个核心 SLO,直接关联第一步创建的监控告警(如错误率监控、响应时间监控),无需额外重复配置数据来源;
  • SLO 命名格式:统一为 “xxxxSLO”,例如 “AI系统 SLO”,目标值设置为 95%(结合业务实际设定,全年 SLA 目标 99.7427%);
  • 统计配置:采用最近 5 分钟作为检测区间,与监控器检测频率保持一致,确保数据同步性。

3.4 步骤 3:搭建三级可视化大盘

3.4.1 企业级总览大盘:xxx系统健康度大屏

  • 核心功能:展示全公司所有系统的 SLO 达成率总览,包含 AI系统 在内的 17 个系统健康度数据(如 SLO 达成率、告警次数、请求量),核心指标(如 100% 达成率)突出显示,支持领导快速掌握全局状态;
  • 配置要点:将 AI系统 纳入总览大盘,关联 “最近 5 分钟”“全年 SLA” 两个时间维度,直观展示短期表现与长期稳定性(案例中该系统全年 SLA 达 99.7427%)。

3.4.2 系统级详情大盘:AI系统 - SLO 健康度大屏

通过克隆基础看板并自定义修改,打造项目专属详情页:

  • 视图变量修改:将看板的视图变量替换为 AI系统 的专属信息(如app_idproject);
  • 标题与内容规范:标题统一格式为 “大屏详情 - xxxx-SLO”(例:大屏详情 - AI系统 - SLO);
  • 核心展示内容:最近 5 分钟 SLO 达成率、全年 SLA、告警事件列表(关联df_label=AI系统标签)、核心 KPI 趋势图(错误率、响应时间、请求量);
  • 交互配置:支持分页查看告警事件(默认 50 条),显示当前查询的起止时间,便于追溯异常时段。

3.4.3 跳转链路配置

建立 “总览大盘→详情大盘” 的跳转链接:在《xxx系统健康度大屏》中,为 AI系统 的 SLO 指标配置跳转规则,点击后直接进入该系统的 SLO 详情看板,实现 “全局→局部” 的快速钻取。

3.5 步骤 4:告警与 SLI 关联优化

  • 告警 SLI 适配:修改详情看板中告警模块的df_label为系统全局标签(AI系统),确保告警事件仅展示当前系统相关内容,避免跨系统干扰;
  • 静默与抑制配置:结合的静默管理、告警策略管理功能,设置告警抑制规则,避免同一根因引发的告警风暴(如接口超时导致的错误率告警与响应时间告警,仅触发 1 条核心告警)。

3.6 案例落地效果

  • 领导视角:通过《AI 系统健康度大屏》,1 秒查看 AI系统 的 SLO 达成率(如 100%)与全年 SLA,无需关注技术细节即可判断系统是否 “好用”;
  • 技术视角:通过详情看板,实时监控错误率、响应时间等核心指标,结合告警快速定位异常(如请求错误率突增),在用户反馈前介入解决;
  • 管理视角:统一的命名与标签体系,便于跨项目对比与批量管理,17 个系统的健康度数据集中展示,简化运维管理成本。

3.7 大盘层级与核心展示内容

大盘层级 面向人群 核心展示内容(通用模板 +案例适配)
企业级总览大盘 领导 / 技术负责人 所有系统 SLO 达成率、告警总览、Top3 异常系统 案例中展示 17 个系统的健康度数据,核心系统 SLO 达成率突出显示
系统级详情大盘 项目负责人 / 技术管理 单个系统 SLO 达成率、核心 KPI 趋势、告警记录、链路拓扑;案例中包含 AI系统 的错误率、响应时间、业务异常等维度
模块 / 接口级大盘 一线研发 / 运维 具体接口 KPI 实时数据、日志详情、链路追踪;案例中可钻取到单个接口的错误日志、Pod 运行状态

四、总结:基于 SLO,让系统评估有标准、问题响应变主动

基于 SLO 构建的系统量化评估体系,本质是用的技术能力,解决 “系统好不好用无法量化、问题发现靠用户反馈” 的企业痛点,核心价值体现在三个方面:

  • 给领导的量化判断依据:的 SLO 总览大盘,让领导无需靠主观感受,一键掌握所有系统的状态,SLO 达成率高 = 系统好用、用户体验好,决策更有依据;
  • 技术团队的工作方向标:所有技术工作都围绕 “达成 SLO、提升用户体验” 展开,技术优化不再是 “无的放矢”,而是有明确的业务目标和用户价值;
  • 从被动救火到主动防控:的实时监控、分级告警能力,让技术团队在用户感知到问题前就介入解决,彻底摆脱 “靠用户反馈发现问题” 的被动局面,提升用户体验的同时,也降低了业务损失。

后续的核心工作,就是按步骤落地配置,配套保障措施,持续复盘优化,让 SLO-KPI 体系成为企业评估系统、优化系统的 “标准工具”,让每个系统的 “好用与否”,都有明确的量化答案。

性能优化:CDN 缓存加速与调度原理

前言

在前端性能优化中,静态资源加载速度往往是首屏渲染的瓶颈。CDN(Content Delivery Network) 通过将资源分发至全球各地的边缘节点,实现了“物理距离”上的访问加速。本文将带你深入 CDN 的内部,看它是如何通过 DNS 调度实现就近访问的。

一、 核心概念:什么是 CDN?

CDN 是一种分布式网络构建。它通过在全国各地(乃至全球)部署海量边缘节点服务器,缓解因用户地域差异、带宽不同、服务器距离过远导致的访问延迟问题,让用户就近获取所需资源,大幅提升网站响应速度、访问成功率,同时减轻源服务器压力。

1. 解决的痛点

  • 物理距离过远:跨国、跨省访问带来的高延迟。
  • 运营商带宽瓶颈:跨运营商(如电信访问联通)的互联互通问题。
  • 源站压力过大:热点资源引发的服务器并发冲击。

二、 深度拆解:CDN 的通信与调度流程

当用户在浏览器输入一个使用了 CDN 的域名时,背后的解析流程比普通 DNS 复杂得多,CDN具体通信调度流程如下:

  1. 域名解析请求:用户在浏览器输入域名,浏览器向本地DNS服务器请求解析,获取对应IP地址。

  2. CNAME 指向:DNS服务器不会直接返回源站IP,而是返回一个CNAME(别名记录) ,该记录指向CDN专用的全局负载均衡(GSLB)系统。。

  3. 智能调度计算:浏览器重新向CDN全局负载均衡系统发起请求。GSLB 会根据以下维度进行综合计算:

    • 地理位置:用户 IP 距离哪个节点最近?
    • 运营商环境:用户是移动还是电信?选择匹配的线路。
    • 节点健康度:目标服务器当前的负载和带宽是否充足?
    • 资源命中情况:请求的资源在哪个节点有缓存?
  4. 返回边缘节点 IP:GSLB 选择一个最优的区域负载均衡设备(SLB) ,并将这个边缘节点的IP地址返回给用户浏览器。

  5. 资源获取与回源

    • 命中(Hit) :用户向该 IP 请求,边缘节点直接返回资源。
    • 回源(Miss) :如果该节点无缓存,则逐级向上寻找,直至回到源站服务器拉取内容并缓存到本地。

核心逻辑:用户永远不直接访问源站,而是访问CDN边缘节点,源站只负责提供原始资源,极大降低源站压力。


三、 评价指标:如何衡量 CDN 的服务质量?

CDN 的核心价值在于“命中”,我们通常用以下两个指标来评估:

指标 定义 理想状态
命中率 (Hit Rate) 用户访问的资源恰好在CDN节点缓存系统中的比例 越高越好。代表 CDN 拦截了大部分请求,减轻了源站压力。
回源率 (Origin Pull Rate) 用户访问的资源CDN节点无缓存/缓存过期,必须向上级节点或源站请求资源的次数,占总访问次数的比例。 越低越好。高回源率可能导致源站带宽瞬间爆满。

四、 进阶实战:CDN 预热与刷新

在实际项目部署中,我们经常会听到两个核心操作:

1. CDN 预热 (Pre-warming)

  • 场景:大版本上线或活动开启前(如双 11)。
  • 操作:主动将源站资源推送到全国各地的 CDN 节点。
  • 效果:用户在第一波访问时就能直接“命中”,避免瞬间大量请求涌向源站导致崩溃。

2. CDN 刷新 (Refresh)

  • 场景:修复了紧急 Bug,更新了相同文件名的静态资源。
  • 操作:强制清除节点上的缓存。用户下次访问时将触发回源。
  • 优化:推荐在打包时使用 Content Hash(如 main.v123.js),通过文件名变更自然失效,而非手动刷新。

五、 最佳实践:前端如何使用 CDN?

1. 第三方库托管

对于成熟的库(Vue, React, Echarts, Axios),直接使用公共 CDN(如 cdnjs, unpkg, 静态资源库)。

  • 优点:减少自建服务器带宽压力;利用浏览器缓存(如果用户在别的网站也加载过同一个 CDN 链接,则无需下载)。
    <!-- 示例:CDN引入Vue、Axios、ECharts -->
    <script src="https://cdn.jsdelivr.net/npm/vue@3.4.0/dist/vue.global.prod.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/axios@1.6.0/dist/axios.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
    

2. 静态资源部署

将打包生成的 dist 目录(JS、CSS、图片)直接部署至云厂商的 对象存储(如阿里云 OSS, 腾讯云 COS) 并绑定 CDN 加速。

  • 策略:HTML 放在自己的服务器(防止缓存无法更新),而静态资源全走 CDN。

六、核心总结

  • CDN本质:分布式节点+就近访问+缓存加速,解决远程访问延迟、源服务器压力大的问题
  • 调度核心:DNS解析→CNAME指向→负载均衡选最优节点→节点缓存响应
  • 质量关键:命中率越高、回源率越低,CDN加速效果越好
  • 前端用法:第三方库直引、项目dist资源上传部署,是必备性能优化手段

PageAgent-住在网页里的 AI 操控员

一、从一个问题说起:为什么需要"页面内"的 AI Agent?

过去两年,浏览器自动化领域热闹非凡。browser-use、Playwright MCP、各类 Headless 方案层出不穷,但它们都有一个共同特征——需要一个"外部大脑":Python 后端、无头浏览器实例、或浏览器扩展的特殊权限。

阿里巴巴开源的 PageAgent 提出了一个极为简洁的逆向思路:不从外部操控浏览器,让 AI Agent 直接"住在"网页里。 一行 <script> 标签,Agent 就在当前页面的 JavaScript 上下文中运行——不要 Python,不要无头浏览器,不要截图和多模态模型,甚至不要浏览器扩展。

下面这张图能直观地感受到区别:

┌─────────────────────────────────────────────────────────────────┐
│                    传统方案 vs PageAgent                         │
├─────────────────────────────┬───────────────────────────────────┤
│  browser-use / Playwright   │         PageAgent                 │
│                             │                                   │
│  ┌───────────┐              │  ┌─────────────────────────────┐  │
│  │ Python    │──WebSocket──▶│  │         你的网页              │  │
│  │ 后端服务   │  / CDP      │  │  ┌─────────────────────┐    │  │
│  └───────────┘              │  │  │  PageAgent (JS)     │    │  │
│       │                     │  │  │  ┌───────┐ ┌──────┐ │    │  │
│       ▼                     │  │  │  │ Agent │→│ DOM  │ │    │  │
│  ┌───────────┐              │  │  │  │ 循环  │ │ 操控 │ │    │  │
│  │ Headless  │              │  │  │  └───┬───┘ └──────┘ │    │  │
│  │ Browser   │              │  │  │      │  ↕ LLM API   │    │  │
│  └───────────┘              │  │  └──────┼──────────────┘    │  │
│                             │  └─────────┼───────────────────┘  │
│  需要: Python + 无头浏览器    │  只需: 一行 <script> 标签          │
└─────────────────────────────┴───────────────────────────────────┘

这篇文章将从最简单的用法出发,逐层深入到源码架构的核心设计,配有丰富示例和图解,帮你完整理解 PageAgent 的工作原理。


二、实战示例:从入门到高级

🟢 入门级:一行代码,5 秒体验

如果你只想快速感受效果,把下面这行代码贴到任意网页的控制台或 HTML 里:

<script src="https://cdn.jsdelivr.net/npm/page-agent@1.5.9/dist/iife/page-agent.demo.js" crossorigin="true"></script>

页面右下角会出现一个对话面板,输入自然语言指令即可操作页面。这个 Demo CDN 自带免费测试 LLM,开箱即用。

🟡 进阶级:NPM 集成 + 自选模型

实际项目中,你需要接入自己的 LLM:

import { PageAgent } from 'page-agent'

const agent = new PageAgent({
  model: 'qwen3.5-plus',
  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
  apiKey: 'YOUR_API_KEY',
  language: 'zh-CN',
})

// 方式一:程序化执行
const result = await agent.execute('在搜索框输入 "iPhone 16",然后点击搜索按钮')
console.log(result.success)  // true / false
console.log(result.data)     // Agent 的执行总结

// 方式二:弹出对话面板,让用户自行输入
agent.panel.show()

支持的模型非常丰富——OpenAI GPT 系列、Claude、Qwen、DeepSeek、Gemini、Grok、MiniMax、Kimi、GLM,甚至通过 Ollama 本地部署的开源模型都可以。只要兼容 OpenAI 的 /chat/completions 接口即可。

🟡 进阶级:知识注入——让 AI 懂你的业务

裸用 Agent 时,它只知道页面上有什么元素,但不了解你的业务规则。通过 instructions 你可以注入领域知识:

const agent = new PageAgent({
  // ...LLM config
  instructions: {
    // 全局指令:所有页面生效
    system: `
      你是一个专业的电商运营助手。
      规则:
      - 提交订单前必须先确认价格和数量
      - 遇到错误时立即停止,不要盲目重试
      - 优先使用筛选器缩小搜索范围
    `,
    // 页面级指令:根据 URL 动态返回
    getPageInstructions: (url) => {
      if (url.includes('/checkout')) {
        return '这是结算页面。请先核对收货地址,再检查是否有优惠券可用。'
      }
      if (url.includes('/products')) {
        return '这是商品列表页。先使用左侧筛选器缩小范围,再帮用户选择商品。'
      }
      return undefined
    }
  }
})

指令的工作方式如下图所示:

每一步执行前,prompt 的组装结构:

┌────────────────────────────────────────┐
│  <instructions>                        │
│    <system_instructions>               │
│      你是电商运营助手...                  │
│    </system_instructions>              │
│    <page_instructions>                 │  ← 仅当 URL 匹配时才出现
│      这是结算页面...                     │
│    </page_instructions>                │
│  </instructions>                       │
│                                        │
│  <agent_state>                         │
│    用户请求 + 步数信息                    │
│  </agent_state>                        │
│                                        │
│  <agent_history>                       │
│    之前每步的反思 + 动作结果               │
│  </agent_history>                      │
│                                        │
│  <browser_state>                       │
│    当前页面 URL、可交互元素、滚动位置       │
│  </browser_state>                      │
└────────────────────────────────────────┘

🟡 进阶级:数据脱敏——敏感信息不出页面

在把页面内容发送给 LLM 之前,transformPageContent 钩子允许你过滤敏感数据:

const agent = new PageAgent({
  // ...LLM config
  transformPageContent: async (content) => {
    // 手机号脱敏:138****1234
    content = content.replace(/\b(1[3-9]\d)(\d{4})(\d{4})\b/g, '$1****$3')
    // 邮箱脱敏
    content = content.replace(
      /\b([a-zA-Z0-9._%+-])[^@]*(@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})\b/g,
      '$1***$2'
    )
    // 银行卡号脱敏
    content = content.replace(/\b(\d{4})\d{8,11}(\d{4})\b/g, '$1********$2')
    return content
  }
})

LLM 看到的是脱敏后的内容,但页面上的真实数据不受影响,Agent 的操作仍然作用于原始 DOM 元素。

🔴 高级:自定义工具——给 AI 接上后端 API

内置工具只能操作 DOM,但通过 customTools 你可以让 Agent 调用任意业务接口:

import { z } from 'zod/v4'
import { PageAgent, tool } from 'page-agent'

const agent = new PageAgent({
  // ...LLM config
  customTools: {
    // 添加购物车工具:AI 可以直接调 API 而非点按钮
    add_to_cart: tool({
      description: '通过商品 ID 添加到购物车',
      inputSchema: z.object({
        productId: z.string(),
        quantity: z.number().min(1).default(1),
      }),
      execute: async function (input) {
        await fetch('/api/cart', {
          method: 'POST',
          body: JSON.stringify(input),
        })
        return `✅ 已添加 ${input.quantity}${input.productId} 到购物车`
      },
    }),

    // 搜索知识库工具:让 AI 先查资料再操作
    search_kb: tool({
      description: '搜索内部知识库',
      inputSchema: z.object({
        query: z.string(),
        limit: z.number().max(10).default(3),
      }),
      execute: async function (input) {
        const res = await fetch(`/api/kb?q=${encodeURIComponent(input.query)}&limit=${input.limit}`)
        return JSON.stringify(await res.json())
      },
    }),

    // 移除内置工具:比如禁止 AI 向用户提问
    ask_user: null,
  },
})

🔴 高级:完全自定义 UI(React 示例)

不想用内置面板?核心逻辑和 UI 完全解耦,你可以用 React/Vue/任何框架搭建自己的界面:

import { PageAgentCore } from '@page-agent/core'
import { PageController } from '@page-agent/page-controller'
import { useState, useEffect } from 'react'

// 1. 自定义 React Hook 监听 Agent 事件
function useAgent(agent) {
  const [status, setStatus] = useState(agent.status)
  const [history, setHistory] = useState(agent.history)
  const [activity, setActivity] = useState(null)

  useEffect(() => {
    const onStatus = () => setStatus(agent.status)
    const onHistory = () => setHistory([...agent.history])
    const onActivity = (e) => setActivity(e.detail)

    agent.addEventListener('statuschange', onStatus)
    agent.addEventListener('historychange', onHistory)
    agent.addEventListener('activity', onActivity)
    return () => {
      agent.removeEventListener('statuschange', onStatus)
      agent.removeEventListener('historychange', onHistory)
      agent.removeEventListener('activity', onActivity)
    }
  }, [agent])

  return { status, history, activity }
}

// 2. 创建无 UI 的 Core Agent
const agent = new PageAgentCore({
  pageController: new PageController({ enableMask: true }),
  baseURL: 'https://api.openai.com/v1',
  apiKey: 'your-key',
  model: 'gpt-5.1',
})

// 3. 你的自定义 UI 组件
function MyAgentPanel() {
  const { status, history, activity } = useAgent(agent)

  return (
    <div className="my-agent-ui">
      <div>状态: {status}</div>
      {activity?.type === 'thinking' && <div>🧠 思考中...</div>}
      {activity?.type === 'executing' && <div>⚡ 执行: {activity.tool}</div>}
      {history.filter(e => e.type === 'step').map((step, i) => (
        <div key={i}>步骤 {i+1}: {step.action.name} → {step.action.output}</div>
      ))}
    </div>
  )
}

🔴 高级:对接外部 Agent 系统

把 PageAgent 作为工具注册到你现有的 AI 客服/助手系统中:

// 你的主 Agent 系统中
const pageAgentTool = {
  name: 'operate_webpage',
  description: '在当前网页上执行操作,如点击、填写表单、查询信息',
  parameters: {
    type: 'object',
    properties: {
      instruction: { type: 'string', description: '操作指令' }
    },
    required: ['instruction']
  },
  execute: async (params) => {
    const result = await pageAgent.execute(params.instruction)
    return { success: result.success, message: result.data }
  }
}

// 注册到你的 Agent 框架...

这样你的客服机器人就不再只会说"请点击左上角的设置按钮",而是直接帮用户操作。


三、Monorepo 架构全景图

PageAgent 采用 monorepo 结构,packages/ 下 7 个子包分层清晰:

┌─────────────────────────────────────────────────────────────┐
│                      用户代码                                │
│              import { PageAgent } from 'page-agent'         │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────┐
│    📦 page-agent (门面层,28行代码)                          │
│    组装 Core + PageController + UI Panel                     │
└───────┬───────────────────┬──────────────────┬──────────────┘
        │                   │                  │
        ▼                   ▼                  ▼
┌──────────────┐  ┌──────────────────┐  ┌────────────┐
│  📦 core     │  │  📦 page-        │  │  📦 ui     │
│  Agent 循环   │  │   controller     │  │  交互面板   │
│  提示词工程   │  │  DOM 提取与简化   │  │            │
│  工具系统     │  │  元素动作模拟     │  │            │
│  AutoFixer   │  │  遮罩层管理       │  │            │
└──────┬───────┘  └──────────────────┘  └────────────┘
       │
       ▼
┌──────────────┐
│  📦 llms     │     📦 extension (可选)
│  OpenAI 协议  │     Chrome 扩展,多标签页
│  模型补丁     │
│  重试机制     │     📦 website
└──────────────┘     官方文档站

核心设计原则:core 不依赖 uipage-controller 不依赖 core,任何一层都可以独立替换。想换 UI?用 PageAgentCore 监听事件自己画。想换 DOM 操作方式?实现 PageController 接口即可。


四、核心引擎:Re-Act Agent 循环

PageAgent 的灵魂在 PageAgentCore 类中。它实现了经典的 Re-Act(Reasoning + Acting)循环

4.1 一次任务的完整生命周期

agent.execute("填写上周五出差的报销单")
         │
         ▼
┌─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
│          while (step < maxSteps)                          │
│                                                           │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐            │
│  │ 1.Observe│───▶│ 2.Think  │───▶│ 3.Act    │──┐         │
│  │ 观察页面  │    │ LLM 推理  │    │ 执行动作  │  │         │
│  └──────────┘    └──────────┘    └──────────┘  │         │
│       ▲                                        │         │
│       │           ┌──────────┐                 │         │
│       └───────────│ 4.Record │◀────────────────┘         │
│                   │ 记录历史  │                            │
│                   └──────────┘                            │
│                        │                                  │
│               action == 'done'?                           │
│                 ├── Yes → 返回结果                          │
│                 └── No  → 继续循环                          │
└─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘

第一阶段 ObservePageController.getBrowserState() 扫描 DOM 树,提取所有可交互元素并编号索引,输出一份 LLM 可读的简化文本。同时进行环境感知——URL 是否变化?累计等待时间是否过长?剩余步数是否告急?这些观察被推入历史流。

第二阶段 Think:系统提示词 + 用户提示词 + 浏览器状态 + 完整历史事件被一起发送给 LLM。这里有一个核心设计——MacroTool(详见下节)。

第三阶段 Act:从 LLM 输出中解析出动作名和参数,通过 PageController 在页面上执行真实的 DOM 操作。

第四阶段 Record:执行结果、LLM 的反思内容、token 用量等打包成 AgentStepEvent 推入历史数组,下一轮循环时回传给 LLM 形成连续记忆。

循环终止条件有三个:LLM 调用 done(任务完成)、步数超过 maxSteps(默认 40)、或不可恢复错误。

4.2 MacroTool:强制"先想后做"

传统方案让 LLM 从多个工具中自由选择。PageAgent 走了一条不同的路——把所有工具合并成一个叫 AgentOutput 的巨型工具:

┌────────────────────────────────────────────────┐
│              MacroTool: AgentOutput             │
│                                                │
│  {                                             │
│    evaluation_previous_goal: "上一步成功了...",   │  ← 反思
│    memory: "已找到搜索框,index=5...",            │  ← 记忆
│    next_goal: "在搜索框输入关键词",               │  ← 规划
│    action: {                                   │
│      input_text: { index: 5, text: "iPhone" }  │  ← 动作
│    }                 ▲                         │
│  }                   │                         │
│                      │                         │
│       action 字段是所有内置工具的联合类型:          │
│       click_element_by_index | input_text |     │
│       scroll | select_dropdown_option |         │
│       wait | done | ask_user | ...              │
└────────────────────────────────────────────────┘

源码中用 Zod 的 z.union 将所有工具的 inputSchema 合并成 action 字段的类型。LLM 每次调用 AgentOutput 时,必须同时输出反思和具体动作。这种设计大幅减少了"冲动行为"——Agent 不会跳过思考直接行动。

4.3 两条事件流:记忆 vs 反馈

┌───────────────────────────────────────────────────────┐
│                  PageAgentCore                        │
│                                                       │
│   Historical Events                Activity Events    │
│   (historychange)                   (activity)        │
│                                                       │
│   ┌─────────────┐                ┌─────────────┐     │
│   │ step        │                │ thinking    │     │
│   │ observation  │                │ executing   │     │
│   │ user_takeover│                │ executed    │     │
│   │ retry       │                │ retrying    │     │
│   │ error       │                │ error       │     │
│   └──────┬──────┘                └──────┬──────┘     │
│          │                              │             │
│    持久化 │ 传给 LLM               瞬态 │ 仅 UI 用     │
│          ▼                              ▼             │
│   agent.history[]               UI 状态动画/loading    │
└───────────────────────────────────────────────────────┘

History Events 构成 Agent 的"记忆",每轮都发送给 LLM。Activity Events 是瞬态 UI 反馈("正在思考"/"正在点击按钮"),不进入 LLM 上下文。这种分离保证了 LLM 的上下文始终干净。


五、DOM 翻译官:不靠截图的页面理解

5.1 纯文本路线 vs 截图路线

截图路线 (Claude Computer Use 等)        文本路线 (PageAgent)
                                        
  页面 → 截图 → 多模态LLM               页面 → DOM树 → 简化文本 → 文本LLM
                                        
  ✓ 能看到图片/Canvas                    ✗ 看不到图片/Canvas
  ✗ 需要多模态模型                       ✓ 普通文本模型即可
  ✗ 截图=更多token≈更贵                   ✓ token 更少更便宜
  ✗ 需要特殊权限                         ✓ 零权限

对于大多数 SaaS 后台、表单填写、数据录入场景,文本路线是极为务实的选择。

5.2 DOM 提取:从真实页面到 LLM 可读文本

PageController.getBrowserState() 是整条链路的入口。它的内部流程:

真实 DOM 树
    │
    ▼  getFlatTree()
遍历 DOM,识别可交互元素,分配数字索引
标记新出现的元素 (WeakMap 缓存)
    │
    ▼  flatTreeToString()
转换为 LLM 友好的文本格式
    │
    ▼  组装 BrowserState
    
  header: "Current Page: [商品列表](https://...)
           Page info: 1920x1080px viewport...
           [Start of page]"

  content: "[0]<a aria-label=首页 />
            [1]<input placeholder=搜索商品... />
            [2]<button>搜索</button>
            今日推荐
            *[3]<div>iPhone 16 Pro ¥7999</div>     ← * 号表示新出现
            *[4]<button>加入购物车</button>
            [5]<select>选择颜色</select>"

  footer: "... 1200 pixels below (2.3 pages) - scroll to see more ..."

flatTreeToString 做了大量优化细节:去除重复属性(aria-label 与文本内容相同时只保留一个)、截断过长属性、标注可滚动容器的滚动距离、缩进表示 DOM 层级关系。

5.3 动作模拟:为什么不用 .click()

简单调用 element.click() 在很多前端框架中不能正确触发事件。PageAgent 的 clickElement 模拟了完整的用户行为链:

clickElement(element) 的执行序列:

  scrollIntoView    ← 确保元素可见
       ↓
  movePointerTo     ← 移动指针到元素中心(触发UI动画)
       ↓
  mouseenter        ← 模拟鼠标进入
  mouseover
       ↓
  mousedown         ← 模拟按下
       ↓
  focus             ← 聚焦(确保 React 等框架的事件能触发)
       ↓
  mouseup           ← 模拟释放
       ↓
  click             ← 最终点击事件

文本输入更复杂——对 contenteditable 富文本编辑器,按顺序派发 beforeinput(清空)→ 修改 innerText → 派发 input(插入),以兼容 React 受控组件和 Quill 等编辑器。对普通 input/textarea,则使用原生 value setter 绕过框架拦截,再手动触发 input 事件。


六、LLM 层:兼容万家,容错为先

6.1 OpenAI 兼容协议统一天下

@page-agent/llms 没引入任何 LLM SDK,直接用 fetch 调 /chat/completions 接口。如今几乎所有主流模型商都支持这套协议,因此 PageAgent 天然兼容数十种模型。

6.2 模型补丁:实战踩坑的结晶

源码中的 modelPatch 函数根据模型名称动态调整请求参数:

模型                    补丁内容
─────────────────────────────────────────────────
Qwen 系列      →  temperature ≥ 1.0,关闭 thinking
Claude 系列    →  tool_choice 格式转换为 Claude 风格
Grok 系列      →  删除 tool_choice,禁用 reasoning
GPT-5 系列     →  reasoning_effort = 'low'
GPT-5-mini     →  reasoning_effort = 'low', temperature = 1
Gemini 系列    →  reasoning_effort = 'minimal'
MiniMax 系列   →  temperature 钳位到 (0, 1],删除 parallel_tool_calls

这些全是真实环境下踩坑后的总结,对多模型兼容开发极有参考价值。

6.3 AutoFixer:当 LLM 不守规矩时

不同 LLM 的输出格式千差万别。normalizeResponse 穷举了各种异常并逐一修复:

LLM 的常见"不规矩"输出              AutoFixer 的修复

把 JSON 放在 content 里              → 提取 JSON,包装成 tool_calls
而不是 tool_calls

返回动作层级而非                     → 包装一层 { action: ... }
AgentOutput 完整结构

双重 JSON 字符串化                    → 递归 JSON.parse
"{ \"action\": \"...\" }"

原始值输入                            → 根据 Zod schema 推断字段名
{ click_element_by_index: 2 }         → { click_element_by_index: { index: 2 } }

content 里还套了一层                   → 解析嵌套的 function 结构
function wrapper

这套容错机制是 PageAgent 能稳定兼容这么多模型的关键原因之一。


七、提示词工程:Agent 的"岗位说明书"

系统提示词(system_prompt.md)详细规定了 Agent 的输入格式、行为准则和能力边界。几个值得注意的设计:

输入格式约定:交互元素的格式是 [index]<type>text</type>,只有带数字索引的元素才可操作。新出现的元素用 *[ 标记。缩进表示 DOM 层级。

行为规则亮点:不要重复同一动作超过 3 次;输入文本后如果被中断,很可能弹出了建议列表(要去选择);遇到验证码告知用户无法解决;区分"精确步骤"和"开放式任务"两种模式。

"示弱"设计——这是最有意思的部分:明确告知 LLM "可以失败"、"用户可能是错的"、"网页可能有 bug"、"过度尝试可能有害"。避免 Agent 在无法完成任务时陷入无意义的死循环。


八、生命周期钩子:完整的可观测性

onBeforeTask ──▶ ┌───────────────────────────────┐
                 │  onBeforeStep ──▶ step ──▶ onAfterStep  │  × N 步
                 └───────────────────────────────┘
onAfterTask  ◀── 返回 ExecutionResult { success, data, history }

onDispose    ◀── agent.dispose()

配合 transformPageContent(数据脱敏)和 customSystemPrompt(完全自定义提示词),开发者拥有对 Agent 行为的完全控制权。


九、使用限制:诚实面对能力边界

PageAgent 选择了"纯文本 DOM"路线,这意味着:

能做的:点击、文本输入、下拉选择、表单提交、页面滚动、焦点切换、执行 JavaScript。

做不到的:悬停(hover)、拖拽、右键菜单、键盘快捷键、坐标定位操作、图片/Canvas/WebGL/SVG 等视觉内容识别、Monaco/CodeMirror 等特殊编辑器。

语义化的 HTML 和良好的可访问性(ARIA 标签等)会显著提升 Agent 效果。反常识的交互逻辑、纯视觉的操作提示则会降低成功率。


十、总结:一个务实的工程决策

通读源码后,PageAgent 的核心设计哲学可以归纳为三个词:

务实——纯文本 DOM 而非截图,牺牲视觉理解换来对普通模型的兼容性和更低的成本。MacroTool 强制"先想后做",在可控性和灵活性之间找到平衡。

容错——从 AutoFixer 对畸形输出的修复,到 modelPatch 对不同模型的适配,到提示词中鼓励"可以失败",整个系统对不确定性有很高包容度。

解耦——Core、PageController、UI、LLMs 四层分明,任何一层可独立替换。你可以只用 Core 做无头自动化,也可以换上自己的 React UI,还可以把它嵌入你现有的 Agent 系统作为"手和眼"。

对于 SaaS 开发者想快速给产品加 AI Copilot、企业想做管理后台的智能化改造、或者无障碍增强场景,PageAgent 提供了目前门槛最低的入口——一行 <script> 标签,你的网页就有了一个 AI 操作员。

A2UI 深度解读:让 AI Agent "说出"用户界面的开放协议

引言:Agent 时代的 UI 困境

想象这样一个场景——你对一个 AI 助手说:"帮我订一张明天晚上 7 点的两人桌。" 如果 Agent 只能回复文本,接下来将是:

用户: "帮我订一张明天晚上7点的两人桌"
Agent: "好的,请问几位用餐?"
用户: "两位"
Agent: "请问哪天?"
用户: "明天"
Agent: "什么时间?"
用户: "晚上7点"
Agent: "有什么忌口吗?"
...(五六个回合后终于订完)

更好的方式是:Agent 直接生成一个表单——日期选择器、时间选择器、人数输入框、提交按钮,一步搞定。但传统方案(Agent 返回 HTML/JS 塞进 iframe)笨重、割裂、不安全。

A2UI(Agent-to-User Interface) 就是为此而生的 Google 开源协议:Agent 发送声明式 JSON 描述界面意图,客户端用自己的原生组件渲染。安全如数据,表达如代码。


一、A2UI 全景架构图

先来一张图看全貌——A2UI 的核心是把 UI 生成和 UI 执行彻底解耦:

┌──────────────────────────────────────────────────────────────┐
│                        用户 (User)                           │
│    输入:"帮我找纽约的中餐馆"    │    看到原生渲染的卡片列表    │
└───────────────┬──────────────────────────────▲───────────────┘
                │ 文字请求                      │ 原生 UI
                ▼                              │
┌───────────────────────────────────────────────────────────────┐
│                   客户端应用 (Client App)                      │
│  ┌─────────────┐   ┌──────────────┐   ┌──────────────────┐   │
│  │  传输层      │   │ A2UI 渲染器   │   │  组件目录         │   │
│  │  (Transport) │──▶│  (Renderer)  │◀──│  (Catalog)       │   │
│  │  A2A/WS/SSE │   │  Lit/Angular │   │  Button, Card... │   │
│  └──────┬──────┘   │  /Flutter    │   └──────────────────┘   │
│         │          └──────────────┘                           │
└─────────┼────────────────────────────────────────────────────┘
          │ JSON 消息流 (JSONL)
          │
┌─────────▼─────────────────────────────────────────────────────┐
│                     AI Agent (后端)                             │
│  ┌───────────────┐    ┌──────────────────┐                     │
│  │  业务逻辑      │───▶│  A2UI 生成器      │                     │
│  │  (Tools/API)  │    │  (LLM 生成 JSON) │                     │
│  └───────────────┘    └──────────────────┘                     │
│                              │                                 │
│                     ┌────────▼────────┐                        │
│                     │   Gemini / GPT  │                        │
│                     │   等 LLM 模型    │                        │
│                     └─────────────────┘                        │
└────────────────────────────────────────────────────────────────┘

关键洞察:Agent 永远不会执行代码或操控 DOM。它只能从客户端预批准的"组件目录"中选取组件来组合界面——就像只能用菜单上的菜来点餐,不能自己跑进厨房。


二、三分钟理解核心概念

2.1 五个关键词

┌─────────────────────────────────────────────────────────────┐
│                    A2UI 五大核心概念                          │
├─────────────┬───────────────────────────────────────────────┤
│  Surface    │ 画布/容器,承载一组组件(如一个表单、一个卡片)  │
│  Component  │ UI 元素(Button, Text, Card, TextField...)    │
│  Data Model │ 应用状态,组件通过路径绑定到它                  │
│  Catalog    │ 组件目录,定义 Agent 能用哪些组件               │
│  Message    │ JSON 消息(创建画布/更新组件/更新数据/删除画布) │
└─────────────┴───────────────────────────────────────────────┘

2.2 邻接表模型:为什么是扁平列表而非嵌套树?

这是 A2UI 最独特的设计。传统 UI 描述用嵌套 JSON 树,但 LLM 生成深层嵌套时极易出错、难以流式传输。A2UI 把组件展平为一个列表,通过 ID 引用建立父子关系:

传统嵌套树(LLM 容易搞乱括号)        A2UI 邻接表(扁平 + ID 引用)
─────────────────────────            ──────────────────────────
{                                    components: [
  "Column": {                          { id: "root",    → Column, children: ["title","btn"] },
    "children": [                      { id: "title",   → Text, text: "Hello" },
      { "Text": { "Hello" } },        { id: "btn",     → Button, child: "btn-text" },
      { "Button": {                    { id: "btn-text",→ Text, text: "OK" }
        "child": {                   ]
          "Text": { "OK" }
        }
      }}
    ]
  }
}

层层嵌套,一个括号没对上就全废了         所有组件平铺,随时增量发送、按 ID 更新

2.3 数据绑定:结构与状态分离

组件定义"长什么样",数据模型定义"展示什么内容"。两者通过 JSON Pointer 路径连接:

         组件结构                              数据模型
    ┌──────────────┐                    ┌──────────────────┐
    │ Text          │                   │ {                │
    │ text: ────────┼───path───────────▶│   "user": {      │
    │   path:       │  "/user/name""name":"Alice"│
    │   "/user/name"│                   │   }              │
    └──────────────┘                    └──────────────────┘
                                              │
    当数据模型更新为 "Bob" 时 ──────────────────┘
    Text 自动显示 "Bob",无需重发组件定义!

三、消息生命周期图解

以一个完整的餐厅预订流程为例,看 A2UI 消息如何流转:

 用户                        客户端                         Agent
  │                            │                              │
  │  "订两人桌"                 │                              │
  │ ──────────────────────────▶│                              │
  │                            │  将用户消息转发给 Agent        │
  │                            │ ─────────────────────────────▶│
  │                            │                              │
  │                            │   ① createSurface            │
  │                            │◀─ (创建画布,指定 Catalog)──── │
  │                            │                              │
  │                            │   ② updateComponents         │
  │                            │◀─ (标题+人数框+日期框+按钮)── │
  │  看到表单渐进式渲染          │                              │
  │◀───────────────────────── │   ③ updateDataModel           │
  │                            │◀─ (日期="明天", 人数="2") ──── │
  │                            │                              │
  │  修改人数为 "3"             │                              │
  │ ──────────────────────────▶│  本地数据模型自动更新           │
  │                            │  /reservation/guests = "3"   │
  │                            │                              │
  │  点击「确认预订」            │                              │
  │ ──────────────────────────▶│                              │
  │                            │   ④ action                   │
  │                            │ ─(name:"confirm",context)───▶│
  │                            │                              │
  │                            │   ⑤ deleteSurface            │
  │  看到"预订成功"确认界面      │◀─ + 新 surface (确认卡片) ── │
  │◀───────────────────────── │                              │

四、实战示例:由浅入深

🟢 入门级:Hello World — 一张静态信息卡

适合人群:想快速了解 A2UI JSON 长什么样的开发者

这是最简单的 A2UI 示例——展示一张带标题和描述的卡片,没有交互,没有数据绑定,纯静态内容。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "hello-card",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义组件
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "hello-card",
    "components": [
      {
        "id": "root",
        "component": "Card",
        "child": "content"
      },
      {
        "id": "content",
        "component": "Column",
        "children": ["title", "desc"]
      },
      {
        "id": "title",
        "component": "Text",
        "text": "👋 欢迎使用 A2UI",
        "variant": "h1"
      },
      {
        "id": "desc",
        "component": "Text",
        "text": "这是一张由 Agent 生成的卡片,渲染为你应用的原生组件。"
      }
    ]
  }
}

解读如下——整个过程只需两条消息。createSurface 告诉客户端"我要创建一个画布,用基础组件目录"。updateComponents 发送四个组件:Card 是容器,Column 纵向排列子组件,两个 Text 分别是标题和正文。所有组件平铺在一个列表里,通过 childchildren 引用彼此的 ID。

渲染效果示意:

┌──────────────────────────┐
│ ┌──────────────────────┐ │
│ │  👋 欢迎使用 A2UI     │ │   ← h1 标题
│ │                      │ │
│ │  这是一张由 Agent     │ │   ← 正文描述
│ │  生成的卡片...        │ │
│ └──────────────────────┘ │
└──────────────────────────┘
         Card 容器

🟡 进阶级:带数据绑定的用户资料卡

适合人群:需要理解数据绑定、响应式更新的前端/全栈开发者

这个示例展示数据绑定的核心能力——组件不写死内容,而是绑定到数据模型的路径。当数据变化时,UI 自动刷新。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "profile",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义组件(结构)
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "profile",
    "components": [
      {
        "id": "root",
        "component": "Card",
        "child": "layout"
      },
      {
        "id": "layout",
        "component": "Column",
        "children": ["avatar", "name", "email", "role"]
      },
      {
        "id": "avatar",
        "component": "Image",
        "url": { "path": "/user/avatar" },
        "fit": "cover"
      },
      {
        "id": "name",
        "component": "Text",
        "text": { "path": "/user/name" },
        "variant": "h2"
      },
      {
        "id": "email",
        "component": "Text",
        "text": { "path": "/user/email" }
      },
      {
        "id": "role",
        "component": "Text",
        "text": { "path": "/user/role" },
        "variant": "caption"
      }
    ]
  }
}

// 消息 3:填充数据
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "profile",
    "path": "/user",
    "value": {
      "name": "Sarah Chen",
      "email": "sarah@techco.com",
      "role": "Product Designer",
      "avatar": "https://example.com/sarah.jpg"
    }
  }
}

关键点在于,组件中的 { "path": "/user/name" } 就是数据绑定语法。渲染器看到它会去数据模型中读取 /user/name 的值来显示。当 Agent 后续发送新的 updateDataModel/user/name 改成 "Bob Lee" 时,名字自动变化,不需要重新发送组件定义。这就是结构与状态分离带来的高效更新。

   组件定义(不变)                  数据模型(可随时更新)
┌──────────────────┐          ┌────────────────────────┐
│ Text              │          │ { "user": {            │
│   text:           │─bindTo──▶│     "name": "Sarah"    │──▶ 显示 "Sarah"path:/user/name│         │   }                    │
└──────────────────┘          └────────────────────────┘
                                       │ Agent 发送数据更新
                              ┌────────▼───────────────┐
                              │ { "user": {            │
                              │     "name": "Bob"      │──▶ 自动显示 "Bob"
                              │   }                    │
                              └────────────────────────┘

🟡 进阶级:带表单交互的餐厅预订

适合人群:需要理解双向绑定和 Action 机制的开发者

这是官方 Demo 的核心场景——Agent 生成一个预订表单,用户填写后提交,Agent 收到数据进行处理。

// 消息 1:创建画布
{
  "version": "v0.9",
  "createSurface": {
    "surfaceId": "booking",
    "catalogId": "https://a2ui.org/specification/v0_9/basic_catalog.json"
  }
}

// 消息 2:定义表单组件
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "booking",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["title", "img", "party-size", "datetime", "dietary", "submit-btn"]
      },
      {
        "id": "title",
        "component": "Text",
        "text": { "path": "/title" },
        "variant": "h2"
      },
      {
        "id": "img",
        "component": "Image",
        "url": { "path": "/imageUrl" }
      },
      {
        "id": "party-size",
        "component": "TextField",
        "label": "用餐人数",
        "value": { "path": "/partySize" },
        "textFieldType": "number"
      },
      {
        "id": "datetime",
        "component": "DateTimeInput",
        "label": "日期和时间",
        "value": { "path": "/reservationTime" },
        "enableDate": true,
        "enableTime": true
      },
      {
        "id": "dietary",
        "component": "TextField",
        "label": "饮食要求",
        "value": { "path": "/dietary" }
      },
      {
        "id": "submit-btn",
        "component": "Button",
        "child": "submit-text",
        "variant": "primary",
        "action": {
          "event": {
            "name": "submit_booking",
            "context": {
              "restaurant": { "path": "/restaurantName" },
              "partySize":  { "path": "/partySize" },
              "time":       { "path": "/reservationTime" },
              "dietary":    { "path": "/dietary" }
            }
          }
        }
      },
      {
        "id": "submit-text",
        "component": "Text",
        "text": "确认预订"
      }
    ]
  }
}

// 消息 3:填充初始数据
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "booking",
    "path": "/",
    "value": {
      "title": "预订 - 西安名吃",
      "restaurantName": "西安名吃",
      "imageUrl": "https://example.com/xian.jpg",
      "partySize": "2",
      "reservationTime": "",
      "dietary": ""
    }
  }
}

这里有三个关键交互机制值得注意。

双向绑定——TextField 的 value 绑定到 /partySize,用户输入 "4" 时,本地数据模型立即更新为 {"partySize": "4"},完全在客户端本地完成,没有网络请求。

Action 的 context——Button 的 action.event.context 定义了提交时要携带哪些数据。每个 key 的 value 用 path 指向数据模型,客户端在点击时解析出当前值。

当用户点击"确认预订",客户端发送的消息如下:

{
  "version": "v0.9",
  "action": {
    "name": "submit_booking",
    "surfaceId": "booking",
    "sourceComponentId": "submit-btn",
    "timestamp": "2026-03-18T19:30:00Z",
    "context": {
      "restaurant": "西安名吃",
      "partySize": "4",
      "time": "2026-03-19T19:00:00Z",
      "dietary": "不吃辣"
    }
  }
}

Agent 端 Python 处理代码类似:

if action_name == "submit_booking":
    restaurant = context.get("restaurant")
    party_size = context.get("partySize")
    time = context.get("time")
    # 让 LLM 处理
    query = f"用户预订了 {restaurant}{party_size} 人,时间 {time}"
    response = await llm.generate(query)

🔴 高级:动态列表 + 模板渲染

适合人群:需要高效渲染大量数据的架构师和高级开发者

当 Agent 返回一组搜索结果时,不需要为每条结果分别定义组件——用一个模板 + 数据数组即可自动渲染:

// 组件定义:一个模板驱动的列表
{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "search-results",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["result-header", "result-list"]
      },
      {
        "id": "result-header",
        "component": "Text",
        "text": "为你找到以下餐厅:",
        "variant": "h2"
      },
      {
        "id": "result-list",
        "component": "List",
        "children": {
          "componentId": "restaurant-card",
          "path": "/restaurants"
        },
        "direction": "vertical"
      },
      {
        "id": "restaurant-card",
        "component": "Card",
        "child": "card-layout"
      },
      {
        "id": "card-layout",
        "component": "Row",
        "children": ["card-img", "card-info"]
      },
      {
        "id": "card-img",
        "component": "Image",
        "url": { "path": "/imageUrl" },
        "fit": "cover"
      },
      {
        "id": "card-info",
        "component": "Column",
        "children": ["card-name", "card-rating", "card-detail"]
      },
      {
        "id": "card-name",
        "component": "Text",
        "text": { "path": "/name" },
        "variant": "h3"
      },
      {
        "id": "card-rating",
        "component": "Text",
        "text": { "path": "/rating" },
        "variant": "caption"
      },
      {
        "id": "card-detail",
        "component": "Text",
        "text": { "path": "/detail" }
      }
    ]
  }
}

// 数据模型:一个数组,有多少项就渲染多少张卡片
{
  "version": "v0.9",
  "updateDataModel": {
    "surfaceId": "search-results",
    "path": "/restaurants",
    "value": [
      {
        "name": "西安名吃",
        "detail": "正宗手拉面,香辣可口",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/xian.jpg"
      },
      {
        "name": "韩朝",
        "detail": "地道四川菜",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/han.jpg"
      },
      {
        "name": "红农场",
        "detail": "现代中餐,农场直供",
        "rating": "★★★★☆",
        "imageUrl": "https://example.com/red.jpg"
      }
    ]
  }
}

核心原理是作用域路径。模板中的 { "path": "/name" } 不是指向全局根路径,而是自动限定到当前数组项。第一张卡片的 /name 解析为 /restaurants/0/name,即 "西安名吃";第二张解析为 /restaurants/1/name,即 "韩朝"。

 数据:/restaurants = [ {name:"西安名吃"}, {name:"韩朝"}, {name:"红农场"} ]
                          │                   │                │
 模板自动实例化            ▼                   ▼                ▼
 ┌─────────────┐  ┌─────────────┐  ┌─────────────┐
 │ 🖼️ 西安名吃  │  │ 🖼️ 韩朝      │  │ 🖼️ 红农场    │
 │ ★★★★☆      │  │ ★★★★☆      │  │ ★★★★☆      │
 │ 正宗手拉面   │  │ 地道四川菜   │  │ 现代中餐     │
 └─────────────┘  └─────────────┘  └─────────────┘

 新增一项到数组 → 自动多渲染一张卡片,无需修改组件定义!

🔴 高级:多 Agent 编排(Orchestrator)

适合人群:构建企业级多 Agent 系统的架构师

在真实的企业场景中,一个主协调器(Orchestrator)管理多个专业子 Agent,每个子 Agent 负责自己领域的 UI。这是仓库里 samples/agent/adk/orchestrator 示例所展示的架构:

                           ┌───────────────────┐
              用户问题       │   Orchestrator     │
         ───────────────── ▶│   (主协调 Agent)    │
                            │                   │
                            │  ① 意图识别        │
                            │  "找中餐" → 路由到  │
                            │   餐厅 Agent       │
                            └──┬──────┬─────┬──┘
                               │      │     │
               ┌───────────────┘      │     └───────────────┐
               ▼                      ▼                     ▼
   ┌───────────────────┐  ┌──────────────────┐  ┌──────────────────┐
   │  餐厅查找 Agent     │  │  联系人查找 Agent  │  │  数据图表 Agent   │
   │  (port 10003)      │  │  (port 10004)     │  │  (port 10005)    │
   │                    │  │                   │  │                  │
   │  返回:餐厅列表 UI  │  │  返回:联系人卡片  │  │  返回:图表 UI    │
   │  (A2UI JSON)       │  │  (A2UI JSON)      │  │  (A2UI JSON)     │
   └────────────────────┘  └───────────────────┘  └──────────────────┘

Orchestrator 需要处理两个关键安全问题:

Surface 所有权映射——当子 Agent 创建 Surface 时,Orchestrator 记录"这个 surfaceId 属于哪个子 Agent"。当用户在 UI 上操作触发 Action 时,Orchestrator 根据 surfaceId 把请求路由回正确的子 Agent。

数据模型隔离——当 sendDataModel: true 启用时,客户端会在每条消息元数据中附带所有 Surface 的数据模型。Orchestrator 必须在转发给子 Agent 前剥离其他 Agent 的数据,否则会导致跨 Agent 的数据泄露。

 客户端发来的元数据(包含所有 Surface 的数据):
 ┌──────────────────────────────────────┐
 │ a2uiClientDataModel: {              │
 │   surfaces: {                       │
 │     "restaurant-list": {...},  ◀─── 属于餐厅 Agent
 │     "contact-card":   {...},  ◀─── 属于联系人 Agent
 │     "sales-chart":    {...}   ◀─── 属于图表 Agent
 │   }                                 │
 │ }                                   │
 └──────────────────────────────────────┘
            │
    Orchestrator 必须 strip
            │
            ▼  转发给餐厅 Agent 时只保留:
 ┌──────────────────────────────────────┐
 │ a2uiClientDataModel: {              │
 │   surfaces: {                       │
 │     "restaurant-list": {...}        │  ✅ 只有自己的数据
 │   }                                 │
 │ }                                   │
 └──────────────────────────────────────┘

🔴 高级:自定义组件 Catalog

适合人群:需要扩展 A2UI 到特定业务领域的团队

标准 Catalog 只有通用组件。如果你需要地图、图表、股票行情等,就需要自定义 Catalog:

{
  "$id": "https://mycompany.com/catalogs/dashboard/v1/catalog.json",
  "components": {
    "allOf": [
      { "$ref": "basic_catalog.json#/components" },
      {
        "SalesChart": {
          "type": "object",
          "description": "交互式销售数据图表",
          "properties": {
            "chartType": {
              "type": "string",
              "enum": ["bar", "line", "pie"],
              "description": "图表类型"
            },
            "data": {
              "description": "绑定到数据模型的图表数据路径"
            },
            "title": {
              "type": "string",
              "description": "图表标题"
            }
          },
          "required": ["chartType", "data"]
        },
        "GoogleMap": {
          "type": "object",
          "description": "显示指定位置的 Google 地图",
          "properties": {
            "latitude":  { "type": "number" },
            "longitude": { "type": "number" },
            "zoom":      { "type": "integer", "default": 14 }
          },
          "required": ["latitude", "longitude"]
        }
      }
    ]
  }
}

然后 Agent 就可以这样使用自定义组件:

{
  "version": "v0.9",
  "updateComponents": {
    "surfaceId": "dashboard",
    "components": [
      {
        "id": "root",
        "component": "Column",
        "children": ["chart", "map"]
      },
      {
        "id": "chart",
        "component": "SalesChart",
        "chartType": "bar",
        "data": { "path": "/sales/quarterly" },
        "title": "Q4 销售数据"
      },
      {
        "id": "map",
        "component": "GoogleMap",
        "latitude": 31.2304,
        "longitude": 121.4737,
        "zoom": 12
      }
    ]
  }
}

整个协商流程如下:

 客户端                                     Agent
   │                                          │
   │  "我支持这些 Catalog":                     │
   │  [basic_catalog, dashboard/v1]           │
   │ ────────────────────────────────────────▶ │
   │                                          │
   │                      Agent 选择最佳匹配    │
   │                      dashboard/v1 ✅      │
   │                                          │
   │  createSurface:                          │
   │    catalogId: "dashboard/v1"             │
   │ ◀──────────────────────────────────────── │
   │                                          │
   │  此后该 Surface 只能用                     │
   │  dashboard/v1 中定义的组件                 │

五、v0.8 vs v0.9 差异速查表

两个版本的核心差异一图了然。如果你是新项目,建议直接用 v0.9;如果要维护旧代码,参考此表迁移。

       v0.8 (稳定版)                          v0.9 (草案版)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
组件格式:                                组件格式:
"component": {                          "component": "Text",
  "Text": {                             "text": "Hello"
    "text": {"literalString":"Hello"}
  }                                     ← 更扁平、更少 token
}

子组件:                                  子组件:
"children": {                           "children": ["a", "b"]
  "explicitList": ["a", "b"]
}                                        ← 标准数组

数据更新:                                数据更新:
[{"key":"name","valueString":"Alice"}]  {"name": "Alice"}
                                         ← 标准 JSON 对象

画布创建:                                画布创建:
beginRendering + surfaceUpdate          createSurface (含 catalogId)
                                         ← 显式目录协商

按钮样式:                                按钮样式:
"primary": true                         "variant": "primary"
                                         ← 更灵活的枚举

Action 格式:                             Action 格式:
{"name": "submit"}                      {"event": {"name": "submit"}}
                                         ← 支持 event/functionCall 区分

版本标识:                                版本标识:
无                                      每条消息含 "version": "v0.9"

六、安全模型图解

A2UI 的安全是多层防御体系,这是它区别于传统 iframe 方案的核心优势:

┌────────────────────────────────────────────────────────────┐
│                      安全防御层级                            │
├────────────────────────────────────────────────────────────┤
│                                                            │
│  第 1 层:声明式格式 ─ 不是代码,是数据                       │
│  ────────────────────────────────────────                  │
│  Agent 发送的是 JSON 描述,不是 HTML/JS                      │
│  客户端永远不会 eval() 任何 Agent 内容                       │
│                                                            │
│  第 2 层:组件目录白名单 ─ 只能用"菜单上的菜"                 │
│  ────────────────────────────────────────                  │
│  Agent 只能请求 Catalog 中预定义的组件                       │
│  未知组件类型直接被忽略或降级为占位符                          │
│                                                            │
│  第 3 层:双端 Schema 验证 ─ Agent 端 + 客户端都检查          │
│  ────────────────────────────────────────                  │
│  Agent 端:发送前验证 JSON 是否合法                           │
│  客户端:接收后再验证一次,不合法就报错给 Agent               │
│                                                            │
│  第 4 层:VALIDATION_FAILED 反馈 ─ LLM 自我纠正              │
│  ────────────────────────────────────────                  │
│  客户端告诉 Agent "你的 JSON 第X处不对"                       │
│  Agent 据此修正并重新生成                                    │
│                                                            │
│  第 5 层:Orchestrator 数据隔离 ─ 多 Agent 不互相窥探         │
│  ────────────────────────────────────────                  │
│  必须剥离其他 Agent 的数据模型后再转发                        │
│                                                            │
└────────────────────────────────────────────────────────────┘

七、与同类方案的对比一览

                 A2UI              MCP Apps           AG UI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
本质          UI 描述格式        预构建 HTML          传输协议
                                (iframe)
                                
渲染方式

nestjs学习 - 守卫

NestJS 守卫是一个实现了 CanActivate 接口的类。

一、它是什么?

在 NestJS 里,「守卫(Guard)」是一种用来控制请求是否能进入路由处理器(Controller 方法) 的机制。

通俗点说:

守卫就是“门卫”——每次请求进来之前,它会先检查一下你有没有资格进去。

  • 通过进入下一步
  • 未通过❌,请求拒绝(比如返回 403 Forbidden)

核心职责:主要关注 授权(Authorization) 。虽然也可以做认证(Authentication),但通常认证由中间件或 Passport 策略处理,而守卫用于更细粒度的权限控制(如:只有管理员才能删除文章)。

在框架生命周期中,守卫的执行时机是:

请求进入 → 中间件 → 守卫 → 拦截器 → 管道 → 控制器 → 服务

二、怎么用?

创建守卫

import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';

@Injectable()
export class AuthGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const request = context.switchToHttp().getRequest();

    // 简单示例:如果请求头中有 token,就放行
    const token = request.headers['authorization'];
    if (token) {
      return true; // 放行
    }

    // 否则拒绝
    return false;
  }
}

canActivate() 方法返回:

  • true → 允许进入控制器;
  • false → 阻止访问(会返回 403 Forbidden);
  • 也可以返回一个 Promise<boolean>Observable<boolean>(支持异步)。

应用守卫

你可以在三个层级使用守卫:

1. 方法级

import { UseGuards, Controller, Get } from '@nestjs/common';
import { AuthGuard } from './auth.guard';

@Controller('user')
export class UserController {
  @Get('profile')
  @UseGuards(AuthGuard)
  getProfile() {
    return { msg: '用户资料' };
  }
}

2. 控制器级

@UseGuards(AuthGuard)
@Controller('admin')
export class AdminController {
  @Get()
  getAdminData() {
    return '后台数据';
  }
}

3. 全局守卫

// main.ts
import { AppModule } from './app.module';
import { AuthGuard } from './auth.guard';
import { NestFactory } from '@nestjs/core';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalGuards(new AuthGuard());
  await app.listen(3000);
}
bootstrap();

三、使用场景

守卫最常见的用途是:权限控制 / 身份验证

  1. 身份验证(Authentication) :检查用户是否已登录(例如验证 JWT Token 是否存在且有效)。
  2. 角色授权(Role-based Authorization) :检查当前用户是否拥有特定角色(如 admin, editor)。
  3. 权限控制(Permission-based Authorization) :检查用户是否有执行特定操作的权限(如:user:delete)。
  4. IP 地址过滤:只允许特定 IP 段的请求访问。
  5. 功能开关:根据配置动态开启或关闭某些接口。
  6. 请求时间限制(比如只允许工作时间访问)

你可以把它理解为:

在“进入接口之前”的最后一道防线。

四、中间件 vs 守卫

1. 中间件

中间件 是通用的“流水线工人”,负责处理请求的通用逻辑(如日志、解析数据),它不知道具体的业务逻辑是什么。

中间件的盲区: 当中间件运行时,NestJS 还没有确定最终由哪个 Controller 的哪个方法来处理请求。因此,中间件无法知道当前请求是否需要“管理员权限”。你无法在中间件里写:“如果这个路由用了 @Roles('admin') 装饰器,则检查角色”。

2. 守卫:

守卫 是专业的“安检员”,专门负责授权决策(能不能进),它完全知道即将执行哪个具体的控制器方法,并能根据元数据做判断。

守卫的全知视角

守卫接收 ExecutionContext 对象。通过这个对象,你可以拿到:

  • context.getClass(): 当前的 Controller 类。
  • context.getHandler(): 当前正在执行的方法。
  • 结合 Reflector,你可以读取该方法上所有的装饰器元数据(例如 @Roles('admin'))。
  • 结论:凡是需要根据路由元数据(装饰器)来做判断的逻辑,必须用守卫。

3. 核心区别对比表

特性 中间件 (Middleware) 守卫 (Guard)
主要职责 通用逻辑:日志、压缩、Cookie 解析、原始请求预处理。 授权 (Authorization) :决定请求是否允许执行特定的 Handler。
执行时机 最早。在守卫、拦截器、管道之前执行。 中间。在中间件之后,拦截器和管道之前执行。
上下文感知 。只知道 reqres不知道具体要调用哪个 Controller 或哪个方法。 。拥有 ExecutionContext,知道具体的 Class、Handler 方法、参数类型等。
访问元数据 无法直接访问路由装饰器(如 @Roles, @Get)定义的元数据。 可以访问。配合 Reflector 可以轻松读取路由上的自定义元数据。
返回值/控制流 必须调用 next() 才能继续,或者直接 res.end() 结束响应。 返回 boolean (或 Promise/Observable)。true 放行,false 拒绝(抛出异常)。
依赖注入 支持,但配置稍显繁琐(通常通过 forRoot 或模块配置)。 完美支持,像普通 Service 一样注入依赖。
适用场景 记录所有请求日志、解析 JSON/Cookie、设置 CORS、Gzip 压缩。 检查 JWT、验证用户角色、IP 白名单、基于权限的访问控制。

为什么资深前端都在悄悄学 WebAssembly?

2026 年,WebAssembly(WASM)早已不是“前端黑科技”。
它正以静默的方式,重构前端工程师的能力边界

你可能以为 WASM 只是用来加速图像处理或跑个游戏引擎。
但真相是:顶尖团队用 WASM 解决的,从来不是“快一点”的问题,而是“能不能做”的问题

以下是资深前端不敢公开说、却在疯狂投入的 4 个真实原因。


原因1:JavaScript 的“能力天花板”,正在被 WASM 击穿

JavaScript 无法做这些事:

  • 直接操作二进制数据流(如解析 .zip、.pdf、.dwg)
  • 实现确定性浮点运算(金融/科学计算要求 IEEE 754 严格一致)
  • 运行成熟的 C/C++/Rust 生态库(如 OpenCV、FFmpeg、TensorFlow Lite)

而 WASM 可以。

案例:某在线 CAD 平台,将 Autodesk 的 C++ 渲染引擎编译为 WASM,直接在浏览器中打开 500MB 的工程图纸——过去这只能靠桌面软件。

这意味着什么?
前端不再只是“调 API + 写 UI”,而是能构建真正的生产力工具:视频剪辑器、3D 建模器、代码编译器、甚至操作系统模拟器。


原因2:WASM 是对抗“框架内卷”的终极武器

React、Vue、Svelte……框架月月新,API 天天变。
但 WASM 模块一旦编译,十年后仍可运行

更关键的是:WASM 与框架无关
你用 Rust 写的核心算法模块,今天嵌入 React,明天迁到 Svelte,后天跑在 Deno Edge Runtime——零改造成本

资深前端的焦虑,不是学不动新框架,而是怕自己变成“API 搬运工”。
而 WASM 让你沉淀可复用、跨平台、高壁垒的核心逻辑


原因3:隐私合规时代,WASM 是“数据不出浏览器”的唯一解

GDPR、CCPA、中国《个人信息保护法》……全球监管趋严。
用户数据一旦传到服务器,就是法律风险。

而 WASM 允许你在浏览器沙箱内完成敏感计算

  • 人脸模糊(不上传原始照片)
  • 医疗影像分析(DICOM 文件本地处理)
  • 财务报表加密(密钥永不离开设备)

某欧洲银行用 WASM 实现本地 KYC 验证,用户上传身份证 → 浏览器内 OCR + 活体检测 → 仅上传验证结果
合规成本下降 70%,用户信任度飙升。


原因4:WASM 正在成为“全栈统一语言”的桥梁

过去:前端写 JS,后端写 Go/Rust,算法写 Python——三套代码,三套部署,三套调试。

现在:用 Rust 写一次核心逻辑,编译成 WASM(前端用) + Native(后端用) + CLI(运维用)

案例:一个加密货币钱包项目

  • 浏览器端:WASM 运行签名算法
  • 移动端:Rust Native 库
  • 后台服务:同一份 Rust 代码编译为 gRPC 服务
    三端逻辑 100% 一致,漏洞率下降 90%

这不仅是效率提升,更是工程可靠性的质变


但别被 hype 蒙蔽:WASM 不是万能药

资深前端之所以“悄悄学”,是因为他们清楚 WASM 的边界:

  • 不能操作 DOM(必须通过 JS 调用)
  • 启动有冷启动开销(不适合高频小函数)
  • 调试体验仍弱于 JS(但 DevTools 已支持 WASM Source Map)

所以,WASM 的正确姿势是:JS 负责交互,WASM 负责计算——两者协同,而非替代。


如何开始?三条务实路径(2026 年最新)

  1. 从“痛点场景”切入

    • 图片/视频处理 → 试试 ffmpeg.wasm
    • 加密/哈希 → 用 wasm-crypto
    • 数学计算 → 编译 Eigen(C++ 线性代数库)
  2. 选择友好语言

    • Rust + wasm-pack(生态最成熟)
    • AssemblyScript(TypeScript 子集,学习曲线平缓)
    • C/C++ + Emscripten(适合移植现有库)
  3. 集成现代工具链

    // Vite / Webpack 5 原生支持 .wasm
    import init, { run_algorithm } from './pkg/my_wasm.js';
    await init();
    const result = run_algorithm(input);
    

结语:WASM 不是前端的终点,而是“能力主权”的起点

当别人还在争论“React vs Vue”,
聪明人已经用 WASM 把浏览器变成了通用计算终端

2026 年,前端工程师的价值,不再由“会几个框架”定义,
而由“能否用 WASM 解决别人解决不了的问题”决定。

未来的全栈开发者,左手 JS,右手 WASM。

学 WASM,不是为了取代 JavaScript,
而是为了让 JavaScript,只做它该做的事。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

Redux 技术栈使用总结

Redux 技术栈使用总结

1. Redux 基本概念

Redux 是一个用于管理 JavaScript 应用状态的容器,遵循单向数据流原则。

核心概念

  • Store: 存储应用状态的单一数据源
  • Action: 描述状态变化的对象(必须包含 type 属性)
  • Reducer: 纯函数,根据当前状态和 action 返回新状态
  • Dispatch: 触发 action 的方法
  • Subscribe: 监听状态变化的方法

基本工作流程

View -> Dispatch Action -> Reducer -> Store -> View

简单示例

// Action Types
const INCREMENT = 'INCREMENT'
const DECREMENT = 'DECREMENT'

// Action Creators
function increment() {
  return { type: INCREMENT }
}

function decrement() {
  return { type: DECREMENT }
}

// Reducer
function counterReducer(state = { count: 0 }, action) {
  switch (action.type) {
    case INCREMENT:
      return { count: state.count + 1 }
    case DECREMENT:
      return { count: state.count - 1 }
    default:
      return state
  }
}

// Store
import { createStore } from 'redux'
const store = createStore(counterReducer)

// 使用
store.dispatch(increment())
console.log(store.getState()) // { count: 1 }

2. React-Redux 在 React 组件中的使用

React-Redux 是 Redux 的官方 React 绑定库,提供了将 Redux 状态连接到 React 组件的方法。

核心 API

  • Provider: 包裹应用,提供 Redux store
  • connect: 高阶组件,将组件连接到 Redux store

使用示例

import React from 'react'
import { Provider, connect } from 'react-redux'
import { createStore } from 'redux'
import counterReducer from './reducers'

// 创建 store
const store = createStore(counterReducer)

// 展示组件
class CounterComponent extends React.Component {
  render() {
    const { count, increment, decrement } = this.props
    return (
      <div>
        <p>Count: {count}</p>
        <button onClick={increment}>+</button>
        <button onClick={decrement}>-</button>
      </div>
    )
  }
}

// 映射 state 到 props
const mapStateToProps = (state) => {
  return { count: state.count }
}

// 映射 dispatch 到 props
const mapDispatchToProps = (dispatch) => {
  return {
    increment: () => dispatch({ type: 'INCREMENT' }),
    decrement: () => dispatch({ type: 'DECREMENT' })
  }
}

// 连接组件
const Counter = connect(mapStateToProps, mapDispatchToProps)(CounterComponent)

// 应用入口
const App = () => (
  <Provider store={store}>
    <Counter />
  </Provider>
)

3. React-Redux 在 React Hooks 中的使用

React-Redux 提供了 Hooks API,使函数组件可以更方便地使用 Redux。

核心 Hooks

  • useSelector: 从 Redux store 中获取状态
  • useDispatch: 获取 dispatch 函数

使用示例

import React from 'react'
import { Provider, useSelector, useDispatch } from 'react-redux'
import { createStore } from 'redux'
import counterReducer from './reducers'

// 创建 store
const store = createStore(counterReducer)

// 函数组件
const Counter = () => {
  // 获取状态
  const count = useSelector(state => state.count)
  // 获取 dispatch
  const dispatch = useDispatch()

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
      <button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
    </div>
  )
}

// 应用入口
const App = () => (
  <Provider store={store}>
    <Counter />
  </Provider>
)

4. Redux Thunk 的使用

Redux Thunk 是 Redux 的中间件,用于处理异步操作。

核心概念

  • 允许 action creator 返回函数而不是普通对象
  • 这个函数可以接收 dispatchgetState 作为参数
  • 可以在函数内部执行异步操作,完成后分发普通 action

使用示例

import { createStore, applyMiddleware } from 'redux'
import thunk from 'redux-thunk'
import counterReducer from './reducers'

// 创建带有 thunk 中间件的 store
const store = createStore(counterReducer, applyMiddleware(thunk))

// 异步 Action Creator
const fetchUser = (userId) => {
  return async (dispatch, getState) => {
    // 分发请求开始的 action
    dispatch({ type: 'FETCH_USER_REQUEST' })
    
    try {
      // 执行异步操作
      const response = await fetch(`https://api.example.com/users/${userId}`)
      const user = await response.json()
      
      // 分发请求成功的 action
      dispatch({ type: 'FETCH_USER_SUCCESS', payload: user })
    } catch (error) {
      // 分发请求失败的 action
      dispatch({ type: 'FETCH_USER_FAILURE', payload: error.message })
    }
  }
}

// 使用
dispatch(fetchUser(123))

5. Redux Toolkit 的概念和使用

Redux Toolkit 是官方推荐的 Redux 开发工具集,旨在简化 Redux 开发。

核心概念和 API

  • configureStore: 简化 store 创建,自动配置中间件和 devtools
  • createSlice: 自动生成 reducer 和 action creators
  • createAsyncThunk: 创建异步 thunk,自动处理 pending/fulfilled/rejected 状态
  • Immer 集成: 允许直接修改状态对象,自动转换为不可变更新

使用示例

import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit'

// 异步 thunk
const fetchUser = createAsyncThunk(
  'user/fetchUser',
  async (userId) => {
    const response = await fetch(`https://api.example.com/users/${userId}`)
    return response.json()
  }
)

// 创建 slice
const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    value: 0,
    user: null,
    loading: false,
    error: null
  },
  // 同步 reducers
  reducers: {
    increment: (state) => {
      state.value += 1 // 直接修改状态
    },
    decrement: (state) => {
      state.value -= 1
    }
  },
  // 异步状态处理
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.loading = true
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.loading = false
        state.user = action.payload
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.loading = false
        state.error = action.payload
      })
  }
})

// 自动生成的 action creators
export const { increment, decrement } = counterSlice.actions

// 创建 store
const store = configureStore({
  reducer: {
    counter: counterSlice.reducer
  }
})

// 在组件中使用
import { useSelector, useDispatch } from 'react-redux'
import { increment, decrement, fetchUser } from './counterSlice'

const Component = () => {
  const { value, user, loading } = useSelector(state => state.counter)
  const dispatch = useDispatch()

  return (
    <div>
      <p>Count: {value}</p>
      <button onClick={() => dispatch(increment())}>+</button>
      <button onClick={() => dispatch(decrement())}>-</button>
      <button onClick={() => dispatch(fetchUser(123))} disabled={loading}>
        {loading ? 'Loading...' : 'Fetch User'}
      </button>
      {user && <p>User: {user.name}</p>}
    </div>
  )
}

总结

  • Redux: 管理应用状态的容器,遵循单向数据流
  • React-Redux: 将 Redux 与 React 组件连接,提供 Providerconnect 等 API
  • React-Redux Hooks: 提供 useSelectoruseDispatch,简化函数组件使用 Redux
  • Redux Thunk: 处理异步操作的中间件,允许 action creator 返回函数
  • Redux Toolkit: 官方推荐的开发工具集,简化 Redux 开发,减少样板代码

Redux Toolkit 是当前 Redux 开发的最佳实践,它整合了多种常用的 Redux 工具和最佳实践,大大减少了样板代码,提高了开发效率。

【JavaScript面试题-this 绑定】请说明 `this` 在不同场景下的指向(默认、隐式、显式、new、箭头函数)。

今天我们来聊一聊 JavaScript 中一个既基础又让人头疼的概念——this

一、this 是什么?

简单来说,this 是函数执行时内部自动生成的一个对象,它指向调用该函数的上下文。你可以把它理解为函数内部的“环境变量”,代表了当前函数运行时所处的对象。

一个形象的比喻

想象一下,你有一个“自我介绍”的功能,不同的人调用它时,“我”这个字指向不同的人:

  • 小明说“叫小明”,这里的“我”就是小明。
  • 小红说“叫小红”,这里的“我”就是小红。

在 JavaScript 中,this 就像这句话里的“我”,而那个自我介绍的函数就像一句模板:“我叫 xxx”。这个模板里的 this.name 会根据是谁在调用而自动替换成对应的人名。

用代码表示:

javascript

function introduce() {
  console.log(`我叫 ${this.name}`);
}

const ming = { name: '小明', introduce };
const hong = { name: '小红', introduce };

ming.introduce(); // 我叫 小明(this 指向 ming)
hong.introduce(); // 我叫 小红(this 指向 hong)

这里的 introduce 函数内部的 this 就像“我”一样,随着调用者(ming 或 hong)不同,指向也不同。这就是 this 的动态性——它是在函数执行时,根据调用它的对象确定的。

二、this 能做什么?

理解了 this 是动态上下文,那么它能为我们做什么呢?

  • 让同一个函数服务于不同的对象,实现代码复用;
  • 在构造函数中初始化实例属性
  • 在事件处理中方便地访问触发元素
  • 显式地指定上下文,借用其他对象的方法
  • 在回调函数中优雅地保留外层 this

下面我们就通过一个个实战场景,来体会 this 的妙用。


三、实战场景一网打尽

场景1:对象方法中的 this —— 隐式绑定

假设我们有一个用户对象,需要输出用户的名称:

javascript

const user1 = {
  name: '小明',
  greet() {
    console.log(`大家好,我是 ${this.name}`);
  }
};

user1.greet(); // 大家好,我是 小明

当 greet 作为 user1 的方法被调用时,this 指向 user1,所以能正确访问 name

能做什么:我们可以定义多个类似的对象,使用同一个方法结构,轻松访问各自的数据。

陷阱:如果把方法赋值给一个变量再调用,this 就会丢失:

javascript

const fn = user1.greet;
fn(); // 大家好,我是 undefined (非严格模式下 this 指向 window,没有 name 属性)

解决方法:使用 bind 强制绑定 this,或者用箭头函数(后面会讲)。


场景2:构造函数中的 this —— new 绑定

在面向对象编程中,我们经常用构造函数来创建对象:

javascript

function Person(name, age) {
  this.name = name;
  this.age = age;
  this.intro = function() {
    console.log(`我叫 ${this.name},今年 ${this.age} 岁。`);
  };
}

const p1 = new Person('小红', 20);
p1.intro(); // 我叫 小红,今年 20 岁。

当使用 new 调用 Person 时,this 指向新创建的空对象,然后我们往这个对象上添加属性,最后返回这个对象。

能做什么:轻松批量创建结构相似的对象,并且每个对象的方法都能正确访问自己的属性。

注意:如果忘记写 newthis 会指向全局对象,导致全局变量污染。所以构造函数通常首字母大写,提醒自己用 new 调用。


场景3:DOM 事件处理中的 this

在浏览器中处理事件时,this 通常指向触发事件的 DOM 元素:

html

<button id="myBtn">点我</button>
<script>
  const btn = document.getElementById('myBtn');
  btn.addEventListener('click', function() {
    console.log(this); // <button id="myBtn">点我</button>
    this.textContent = '已点击';
  });
</script>

能做什么:在事件回调中直接通过 this 操作当前元素,非常方便。

注意:如果回调使用箭头函数,this 就会指向外层作用域(比如 window),无法直接操作元素。所以事件回调一般用普通函数。


场景4:显式指定 this —— call / apply / bind

有时候我们需要手动指定函数的 this,比如“借用”其他对象的方法。

javascript

const user2 = { name: '小刚' };
const user3 = { name: '小丽' };

function introduce(hobby) {
  console.log(`我是 ${this.name},喜欢 ${hobby}`);
}

introduce.call(user2, '篮球'); // 我是 小刚,喜欢 篮球
introduce.apply(user3, ['跳舞']); // 我是 小丽,喜欢 跳舞

const introduceXiaoGang = introduce.bind(user2, '足球');
introduceXiaoGang(); // 我是 小刚,喜欢 足球
  • call 和 apply 立即调用函数,区别是传参方式不同。
  • bind 返回一个新函数,永久绑定 this,可用于后续调用。

能做什么:实现函数复用,动态改变上下文;也可以用于“函数借用”,比如数组方法借用给类数组对象。


场景5:回调函数中保持 this —— 箭头函数的妙用

在异步回调或定时器中,我们经常需要访问外层的 this,但普通函数的 this 会指向全局(或 undefined 严格模式),导致无法访问期望的对象。

传统解决方式是用 var self = this 缓存,或者用 bind

javascript

function Counter() {
  this.count = 0;
  setInterval(function() {
    this.count++; // 这里的 this 指向 window,无法更新 count
    console.log(this.count);
  }, 1000);
}
new Counter(); // 输出 NaN 或 undefined

用 bind 修正:

javascript

function Counter() {
  this.count = 0;
  setInterval(function() {
    this.count++;
    console.log(this.count);
  }.bind(this), 1000);
}
new Counter(); // 1 2 3 ...

而箭头函数让这一切变得简单:箭头函数没有自己的 this,它会捕获定义时外层作用域的 this

javascript

function Counter() {
  this.count = 0;
  setInterval(() => {
    this.count++; // 这里的 this 继承自 Counter 实例
    console.log(this.count);
  }, 1000);
}
new Counter(); // 1 2 3 ...

能做什么:在回调、事件监听、Promise 等场景中,优雅地保留外层 this,避免繁琐的 self = this 或 bind

注意:箭头函数的 this 一旦确定,就无法通过 call/apply/bind 改变,所以不能用于动态上下文。


场景6:嵌套函数中的 this 问题

在对象方法内部定义普通函数,这个普通函数的 this 会指向全局(或 undefined),这常常让人困惑:

javascript

const obj = {
  name: 'obj',
  foo() {
    function bar() {
      console.log(this.name);
    }
    bar(); // 非严格模式输出 undefined 或 window.name
  }
};
obj.foo();

如何让 bar 也能访问 obj 的 name?有几种方法:

  • 用箭头函数(推荐):

    javascript

    foo() {
      const bar = () => {
        console.log(this.name);
      };
      bar(); // obj
    }
    
  • 在外层保存 this

    javascript

    foo() {
      const self = this;
      function bar() {
        console.log(self.name);
      }
      bar();
    }
    
  • 用 bind

    javascript

    foo() {
      function bar() {
        console.log(this.name);
      }
      bar.bind(this)();
    }
    

能做什么:保证嵌套函数也能访问外层对象的属性,避免作用域丢失。


四、this 绑定规则优先级(一句话总结)

当多种规则同时适用时,this 的绑定优先级是:

new 绑定 > 显式绑定(call/apply/bind) > 隐式绑定(对象方法) > 默认绑定(独立调用)

箭头函数不参与这个优先级,它完全由外层作用域决定。


五、总结与思考

回到最初的问题:this 能做什么?

  • 它让函数灵活地适应不同的调用对象,实现代码复用;
  • 它在构造函数中帮助我们初始化实例;
  • 它在事件处理中方便操作当前元素;
  • 它通过显式绑定让我们能动态指定上下文;
  • 它配合箭头函数,优雅地解决了回调中的 this 保持问题。

掌握 this 的关键,不是死记硬背规则,而是在写代码时问自己:这个函数是怎么被调用的?  调用方式决定了 this 的指向。

希望这篇文章能帮你从“this 是什么”的困惑,走向“this 能做什么”的熟练应用。如果你有更多关于 this 的实战经验或疑惑,欢迎在评论区留言讨论!


最后留个思考题:下面代码的输出是什么?为什么?

javascript

const length = 10;
function fn() {
  console.log(this.length);
}
const obj = {
  length: 5,
  method(fn) {
    fn();
    arguments[0]();
  }
};
obj.method(fn, 1);

(答案:先输出 10(或 undefined),然后输出 2。因为第一次调用 fn() 是默认绑定,第二次 arguments[0]() 是隐式绑定,this 指向 arguments 对象,其 length 是传入的参数个数,即 2。)

欢迎留言你的答案和理解!我们下期再见。

#前端、#前端面试、#干货

如果这篇这篇文章对您有帮助?关注、点赞、收藏,三连支持一下。
有疑问或想法?评论区见

小程序-下拉刷新不走回调函数

下拉刷新

配置与回调

  • .json 文件中添加配置开启下拉刷新
{
  "enablePullDownRefresh": true,//开启下拉刷新
  "backgroundTextStyle": "dark" //配置颜色
}
  • onPullDownRefresh 是下拉刷新的回调函数
/**
* 页面相关事件处理函数--监听用户下拉动作
*/
onPullDownRefresh() {
    wx.showNavigationBarLoading();
},
  • stopPullDownRefresh 是自己写的停止下拉动效函数
stopPullDownRefresh() {
    wx.stopPullDownRefresh();
    wx.hideNavigationBarLoading();
},

Question

Q1: 下拉动效出来了,但是没有触发回调函数
原因: 页面问题:页面高度 = 屏幕高度,没有任何可滚动空间
  • 代码里的布局逻辑(必然是有这种结构):
page { height: 100%; }
.container { height: 100vh; }
.full_screen_container { height: 100%; }
  • 这种写法会导致:

  • 页面高度 = 手机屏幕高度 → 页面无法滚动 → 系统认为 “没有下拉动作” → 不触发 onPullDownRefresh 回调

  • 但!系统依然会播放下拉动画(因为配置开着)。

  • 下拉刷新动画是 【系统全局自动触发】 的,只要配置了 enablePullDownRefresh:true,不管页面能不能滚动、不管回调写没写,动画都会出现!

  • onPullDownRefresh ()回调函数是业务逻辑触发,必须满足页面存在可滚动区域 + 页面真的发生了下拉滚动行为才会执行!

解决方案
方案一:
  • 把根容器改成这样
/* 必须去掉固定 100% 高度!!! */
page {
  height: auto; /* 关键 */
  min-height: 100%;
}

.container {
  min-height: 100vh; /* 不能写死 height */
  overflow: visible;
}
方案二:
/* 给页面加一个看不见的高度,强制让页面可滚动 */
page::after {
  content: '';
  display: block;
  height: 1rpx;
}

Python 短信接口高效集成指南:Django/Flask 框架最佳实践

在 Django/Flask 后端开发中,python 短信接口的集成是用户验证码发送、订单状态通知、风控提醒等核心业务的必备环节,但多数开发者常因框架适配逻辑不当、异步处理缺失、参数配置不规范,导致 python 短信接口响应超时、高并发下服务阻塞、错误码排查效率低等问题。本文聚焦 python 短信接口在 Django/Flask 框架的高效集成,拆解不同框架的适配原理,提供同步 / 异步两种实现方案,结合生产级优化技巧,帮助开发者快速落地高可用的短信发送功能。

b-1.jpg

一、Python 短信接口开发核心基础

1.1 短信接口通信原理

python 短信接口本质是基于 HTTP/HTTPS 协议的 RESTful 接口调用,核心逻辑可拆解为三步:1)组装认证参数(account、password)和业务参数(mobile、content);2)向短信服务商 API 地址发送请求;3)解析 JSON/XML 响应并处理业务逻辑。主流 python 短信接口均支持 POST/GET 请求,字符编码为 UTF-8,如互亿无线提供的标准化 python 短信接口文档,明确了框架适配时的参数传递规则,是行业内典型的参考范式。

1.2 Django/Flask 适配核心差异

Django 和 Flask 作为主流 Python Web 框架,集成 python 短信接口的核心差异体现在异步处理和任务调度上,具体对比如下:

表格

框架 异步实现方式 任务调度推荐 适用场景
Flask 基于 aiohttp + 协程 Celery/APScheduler 轻量应用、高并发接口
Django 基于视图异步 / Celery Celery/Django Q 中大型项目、复杂业务

二、Flask 框架集成 Python 短信接口实战

2.1 同步实现(基础版)

同步方案适合低并发的轻量场景,开发成本低、易上手,核心依赖requests库实现 HTTP 请求:

python

运行

from flask import Flask, request, jsonify
import requests

app = Flask(__name__)

@app.route('/send-sms', methods=['POST'])
def send_sms():
    """Flask同步实现Python短信接口调用"""
    # 获取请求参数
    data = request.get_json()
    mobile = data.get('mobile')
    content = data.get('content')
    
    # 手机号脱敏与格式校验(避免完整手机号泄露)
    if not mobile or len(mobile) != 11:
        return jsonify({"code": 406, "msg": "手机号格式不正确"})
    safe_mobile = mobile[:3] + "****" + mobile[7:]
    
    # 短信接口配置(注:需通过注册链接获取APIID/APIKEY:http://user.ihuyi.com/?udcpF6)
    config = {
        "api_url": "https://api.ihuyi.com/sms/Submit.json",
        "account": "你的APIID",  # 替换为实际APIID
        "password": "你的APIKEY"  # 替换为实际APIKEY
    }
    
    # 组装请求参数(符合接口UTF-8编码规范)
    params = {
        "account": config["account"],
        "password": config["password"],
        "mobile": mobile,
        "content": content
    }
    
    try:
        # 发送同步POST请求,设置10秒超时避免阻塞
        response = requests.post(
            config["api_url"],
            data=params,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            timeout=10
        )
        result = response.json()
        app.logger.info(f"手机号[{safe_mobile}]短信发送结果:{result}")
        return jsonify(result)
    except requests.exceptions.Timeout:
        return jsonify({"code": 0, "msg": "请求超时"})
    except Exception as e:
        return jsonify({"code": 0, "msg": f"发送失败:{str(e)}"})

if __name__ == "__main__":
    app.run(debug=True)

2.2 异步优化(高并发版)

同步方案会阻塞 Flask 主线程,高并发场景下需基于aiohttp协程实现异步调用,核心代码如下:

python

运行

from flask import Flask, request, jsonify
import aiohttp
import asyncio

app = Flask(__name__)

async def async_send_sms(mobile, content):
    """异步调用Python短信接口(非阻塞)"""
    config = {
        "api_url": "https://api.ihuyi.com/sms/Submit.json",
        "account": "你的APIID",
        "password": "你的APIKEY"
    }
    params = {
        "account": config["account"],
        "password": config["password"],
        "mobile": mobile,
        "content": content
    }
    
    async with aiohttp.ClientSession() as session:
        async with session.post(
            config["api_url"],
            data=params,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            timeout=aiohttp.ClientTimeout(total=10)
        ) as resp:
            return await resp.json()

@app.route('/async-send-sms', methods=['POST'])
def async_send_sms_view():
    """Flask异步短信发送接口"""
    data = request.get_json()
    mobile = data.get('mobile')
    content = data.get('content')
    
    if not mobile or len(mobile) != 11:
        return jsonify({"code": 406, "msg": "手机号格式不正确"})
    
    # 启动协程任务,避免阻塞主线程
    loop = asyncio.new_event_loop()
    asyncio.set_event_loop(loop)
    result = loop.run_until_complete(async_send_sms(mobile, content))
    loop.close()
    
    return jsonify(result)

if __name__ == "__main__":
    app.run(debug=True)

三、Django 框架集成 Python 短信接口最佳实践

3.1 基于视图函数的同步实现

Django 基础版集成方案适配快速开发场景,通过视图函数直接调用 python 短信接口:

python

运行

# sms/views.py
from django.http import JsonResponse
from django.views.decorators.http import require_POST
import requests

@require_POST
def send_sms(request):
    """Django同步实现Python短信接口调用"""
    mobile = request.POST.get('mobile')
    content = request.POST.get('content')
    
    # 手机号校验与脱敏
    if not mobile or len(mobile) != 11:
        return JsonResponse({"code": 406, "msg": "手机号格式不正确"})
    safe_mobile = mobile[:3] + "****" + mobile[7:]
    
    # 接口基础配置
    config = {
        "api_url": "https://api.ihuyi.com/sms/Submit.json",
        "account": "你的APIID",
        "password": "你的APIKEY"
    }
    
    params = {
        "account": config["account"],
        "password": config["password"],
        "mobile": mobile,
        "content": content
    }
    
    try:
        response = requests.post(
            config["api_url"],
            data=params,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            timeout=10
        )
        result = response.json()
        return JsonResponse(result)
    except Exception as e:
        return JsonResponse({"code": 0, "msg": f"发送失败:{str(e)}"})

# urls.py 配置路由
from django.urls import path
from . import views

urlpatterns = [
    path('send-sms/', views.send_sms, name='send-sms'),
]

demo-python.png

3.2 结合 Celery 的异步发送(生产级)

Django 生产环境中,推荐用 Celery 处理异步任务,避免阻塞 WSGI 进程,核心实现如下:

python

运行

# sms/tasks.py(Celery异步任务)
from celery import shared_task
import requests

@shared_task
def send_sms_task(mobile, content):
    """Celery异步任务:发送短信"""
    config = {
        "api_url": "https://api.ihuyi.com/sms/Submit.json",
        "account": "你的APIID",
        "password": "你的APIKEY"
    }
    
    params = {
        "account": config["account"],
        "password": config["password"],
        "mobile": mobile,
        "content": content
    }
    
    try:
        response = requests.post(
            config["api_url"],
            data=params,
            headers={"Content-Type": "application/x-www-form-urlencoded"},
            timeout=10
        )
        return response.json()
    except Exception as e:
        return {"code": 0, "msg": str(e)}

# sms/views.py(视图调用异步任务)
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .tasks import send_sms_task

@require_POST
def async_send_sms(request):
    mobile = request.POST.get('mobile')
    content = request.POST.get('content')
    
    if not mobile or len(mobile) != 11:
        return JsonResponse({"code": 406, "msg": "手机号格式不正确"})
    
    # 提交Celery任务(非阻塞,立即返回)
    task = send_sms_task.delay(mobile, content)
    return JsonResponse({"code": 2, "msg": "任务已提交", "task_id": task.id})

四、Python 短信接口常见问题排查与优化

4.1 高频错误码解析

集成 python 短信接口时,以下错误码占比超 80%,对应解决方案如下:

  1. 错误码 405:API ID/KEY 错误 → 核对注册的认证信息,确保无拼写 / 空格错误;
  2. 错误码 4052:访问 IP 与备案 IP 不符 → 在短信服务商后台配置服务器 IP 白名单;
  3. 错误码 4072:内容与模板不匹配 → 严格按审核通过的模板拼接变量,禁止篡改固定内容;
  4. 错误码 4085:单手机号单日验证码超限 → 前端增加 60 秒发送间隔,后端记录发送次数并限制。

4.2 跨框架通用优化技巧

  1. 参数前置校验:对手机号格式(11 位数字)、短信内容长度(≤500 字)做前置校验,减少无效接口调用;
  2. 手机号脱敏:日志 / 返回结果中仅保留脱敏手机号(如 138****1234),避免数据泄露;
  3. 超时控制:所有 python 短信接口调用设置 10 秒超时,防止长时间阻塞框架进程;
  4. 重试机制:对网络波动导致的 4086 错误,设置 1-2 次重试(间隔 1 秒),降低失败率;
  5. 日志埋点:记录请求参数(脱敏)、响应结果、耗时,便于线上问题快速定位。

五、Django vs Flask 集成方案对比

表格

维度 Django 集成方案 Flask 集成方案
异步实现 Celery(成熟稳定) aiohttp 协程(轻量高效)
开发成本 中(需配置 Celery) 低(协程直接调用)
并发能力 高(Celery 分布式) 高(协程非阻塞)
生产适配 适合中大型项目 适合轻量微服务
学习曲线 较陡(Celery 配置) 平缓(协程易理解)

总结

  1. python 短信接口在 Django/Flask 中的集成核心是适配框架的异步特性,Flask 优先用 aiohttp 协程实现轻量异步,Django 生产环境推荐结合 Celery 实现分布式异步发送;
  2. 开发时需重点关注参数校验、手机号脱敏、超时控制,针对 405/4052/4072 等高频错误码做好针对性处理;
  3. 不同框架的集成方案各有优劣,需根据项目规模(轻量 / 中大型)选择适配方式,确保 python 短信接口的高可用与高性能。

TS 入门:给 React 穿上“防弹衣”

前言
JavaScript 像是一位随性的艺术家,自由但易错;TypeScript 则是一位严谨的工程师,用类型系统为我们筑起防线。

很多新手觉得 TS 繁琐,那是还没掌握“正确姿势”。今天,我不讲枯燥理论,直接通过实战场景,带你把 TS 融入 React 的血脉。

一、组件的“身份证”:Props 精准定义

在 JS 中,Props 靠“口头约定”;在 TS 中,Props 必须有“身份证”。

传错参数?漏传必填项?运行时才报错?NO!

使用 interface 定义契约,利用 React.ReactNode 兼容所有内容。

// 第一步:定义契约
interface AaaProps {
  name: string;        // 必填:必须是字符串
  age?: number;        // 可选:注意那个问号 '?'
  content: React.ReactNode; // 万能容器:字符串/JSX/Fragment 都能装
}

// 第二步:应用契约 (推荐写法)
function Aaa({ name, content }: AaaProps) {
  return <div> Hi, {name} | {content}</div>;
}

// 第三步:安全使用
export default function App() {
  // TS 会立刻拦截:如果忘记传 name,或者 content 传了数字,直接标红!
  return <Aaa name="玉米🌽" content={<span>我是内容</span>} />;
}
  • ? 的作用:明确区分“可有可无”和“必须拥有”。
  • React.ReactNode:比 any 安全,比 string 灵活,它是 React 内容的“最大公约数”。
  • 解构赋值:直接在函数参数中解构 { name },代码更清爽,TS 依然能自动推断类型。

二、Hooks 的“导航仪”:泛型让状态不再模糊

useStateuseRef 是 React 的左右手,但在 TS 中,如果不加泛型 <T>,它们就像失去了导航的船。

场景 A:状态初始化

// 没给初始值时,TS 默认它是 undefined
const [num, setNum] = useState<number>(); 
// 类型推断:number | undefined

// 给了初始值,TS 就知道它永远是 number
const [count, setCount] = useState<number>(0); 
// 类型推断:number

场景 B:Ref 的双重身份

useRef 既能抓 DOM,也能存数据。怎么区分?看泛型!

// 身份 1:DOM 捕手
const inputRef = useRef<HTMLInputElement>(null);
// current 可能是 HTMLInputElement 或 null

// 身份 2:数据储物柜 (不触发重渲染)
const storeRef = useRef<{ num: number }>(null);

// 安全赋值
if (storeRef.current) {
  storeRef.current.num = 2; // TS 知道这里有 num 属性
}

泛型就像一个**“模具”**。

  • 倒入 HTMLInputElement,它就是抓 Input 的夹子。
  • 倒入 { num: number },它就变成了存数据的盒子。
  • 如果不指定模具,TS 就只能给你一团模糊的橡皮泥(any 或推断错误)。

三、打通任督二脉:ForwardRef 的类型接力

父组件想操作子组件的 DOM?forwardRef 是桥梁,但 TS 需要知道这座桥通向哪里。

核心三步走

// 定义子组件:明确 Ref 的目标是 input
const Child = forwardRef<HTMLInputElement>((props, ref) => {
  return <input ref={ref} placeholder="请聚焦我" />;
});

// 父组件声明:Ref 类型必须与子组件一致
const parentRef = useRef<HTMLInputElement>(null);

// 安全调用:使用可选链 '?.' 防止 null 报错
useEffect(() => {
  parentRef.current?.focus(); 
}, []);

如果子组件说“我要 Input”,父组件却传了个 div 的 ref,TS 编译器会直接亮红灯。这种端到端的类型检查,彻底杜绝了 Cannot read property 'focus' of null 的低级错误。

四、性能优化的“类型护航”

当项目变大,useReducermemo 登场。TS 能让你的优化逻辑无懈可击。

状态建模:Action 联合类型

这是 TS 最强大的特性之一:判别联合类型

// 定义状态
interface State { result: number; }

// 定义动作 (关键!限制 type 的取值)
type Action = 
  | { type: 'add'; num: number } 
  | { type: 'minus'; num: number };

// Reducer:TS 会自动根据 type 推断 action 的结构
function reducer(state: State, action: Action): State {
  switch (action.type) {
    case 'add': 
      // 这里 TS 知道 action 一定有 num
      return { result: state.result + action.num };
    case 'minus':
      return { result: state.result - action.num };
    default:
      return state;
  }
}

配合 Memo 优化

// 缓存计算结果
const count = useMemo(() => res.result * 10, [res.result]);

// 缓存函数引用 (防止子组件无效渲染)
const cb = useCallback(() => 666, []);

// 子组件:只有 props 变了才渲染
const Child = memo(({ count }: { count: number }) => (
  <h2>计算结果:{count}</h2>
));

如果你手误写了 dispatch({ type: 'delete' ... }),TS 会立刻告诉你:“没有 delete 这个动作!”。这在 JS 中可能要等到用户点击按钮报错了才能发现。

结语

TypeScript 初看是束缚,实则是赋予你重构底气的“防弹衣”。它让你从“运行后报错”的被动救火,转向“编写时即知”的主动掌控,将潜在的隐患扼杀在编译阶段。

不必追求一步到位,渐进式地收窄每一个 any,都是在为代码大厦加固地基。当你习惯了类型系统带来的智能提示与安全边界,便真正完成了从“码农”到“工程师”的思维跃迁。

js并发请求,且限制并发请求数量实现方案

说明

  • 前端会遇到这种需求,希望多个请求并发加快前端反应速度,但又要限制同时请求的数量,防止服务器压力过大
  • 以下代码由Trae里的千问模型生成,思路真牛

方案

async function batchQuset(){
      this.$store.commit('openLoading')
      const questNum = this.paramsList.length
      // 每批最多 3 个并发请求
      const BATCH_SIZE = 3
      // 保持顺序的结果数组
      const numResList = new Array(questNum).fill(null) 
      // 并发控制函数
      const runWithConcurrency = async (tasks, concurrency) => {
        const results = []
        // 维护一个正在执行的 Promise 列表(控制并发就靠它)
        const executing = []
        for (const task of tasks) {
          // p 是 then() 方法返回的新 Promise,不是 task() 的原始 Promise
          const p = task().then((result) => {
            // 当task执行完成后,从executing数组中移除
            executing.splice(executing.indexOf(p), 1)
            return result
          })
          results.push(p)
          executing.push(p)
          // 当正在执行的任务数量超过并发限制时,等待任意一个完成
          if (executing.length >= concurrency) {
            // 等有完成的Promise时,外面的for循环才能继续走
            await Promise.race(executing)
          }
        }
        // 走到这里时,仅剩下小于并发数量的Promise还没彻底完成
        return Promise.all(results)
      }
      // 创建所有校验任务[Promise,Promise]
      const tasks = this.paramsList.map(
        (params, index) => async () => {
          const { pass, res } = await questApi(params)
          this.$store.commit(
            'openLoading',
            `进度: ${index + 1}/${questNum}`
          )
          return { index, pass, res }
        }
      )
      // 执行并发校验
      const results = await runWithConcurrency(tasks, BATCH_SIZE)
      // 按原始顺序填充结果
      results.forEach(({ index, pass, res }) => {
        if (!pass) {
          numResList[index] = res
        }
      })
      // 过滤掉 null 值,得到最终的列表
      const resList = numResList.filter((item) => item !== null)
      this.$store.commit('closeLoading')
      // *****请求结果resList进行展示/处理******
}

说明

  • this.$store.commit('openLoading')是自己把element的loading二次封装,方便唤起和改文字
import Vuex from "vuex";
import { Loading } from 'element-ui';
export default new Vuex.Store({
    state: {
    // 全屏loading实例
    loading: null,
    },
    mutations:{
     openLoading(state, str) {
      if (state.loading) {
        state.loading.setText(str)
      } else {
        state.loading = Loading.service({
          lock: true,
          text: str,
          spinner: 'el-icon-loading',
          background: 'rgba(0, 0, 0, 0.7)',
          customClass: 'phone-loading'
        })
      }
    },
    closeLoading(state) {
      state.loading?.close()
      state.loading = null
    },
    }
})
  • then 中 return result 的作用 :
    • 当 then 的回调函数执行完毕后, p 这个 Promise 会被 resolve
    • p 被 resolve 的值就是 return 后面的值
    • 如果 return result ,则 p 解析为 result
    • 如果不 return 或 return undefined ,则 p 解析为 undefined
// 示例 1:return result
const p1 = task().then((result) => {
  console.log('收到 result:', result)  // 假设 result = '任务完成'
  return result  // return '任务完成'
})

p1.then((value) => {
  console.log('p1 的值:', value)  // 输出:'任务完成'
})

// 示例 2:不 return
const p2 = task().then((result) => {
  console.log('收到 result:', result)  // 假设 result = '任务完成'
  // 没有 return 语句
})

p2.then((value) => {
  console.log('p2 的值:', value)  // 输出:undefined
})

// 示例 3:return 其他值
const p3 = task().then((result) => {
  console.log('收到 result:', result)  // 假设 result = '任务完成'
  return '修改后的值'
})

p3.then((value) => {
  console.log('p3 的值:', value)  // 输出:'修改后的值'
})

给 JS穿上铠甲:TypeScript 基础核心概念详解(类型/接口/泛型)

前言

曾经,我沉醉于 JavaScript 的灵活与自由。变量可以随意赋值,函数参数无需声明,一切看起来都那么随心所欲。直到有一天,一个看似简单的 undefined is not a function 错误在生产环境爆发,我才惊觉:这种“自由”有时更像是在没有护栏的悬崖边跳舞。

在深入探索 TypeScript 的过程中,我深刻体会到了它带来的秩序之美。今天,我想结合实战代码,和大家聊聊 TypeScript 的基础,希望能帮同样在转型的你,穿上铠甲,从容前行。

一、混沌与秩序:为什么我们需要类型?

在学习 TypeScript 之前,我们先回看一下 JavaScript 的世界。

在 JavaScript 中,变量就像是一个个没有标签的盒子。你可以把数字放进去,下一秒又可以把它拿出来,换成一个字符串。这种“弱类型”特性虽然开发速度快,但也埋下了隐患。编译器直到代码运行的最后一刻,才知道盒子里装的是什么。如果此时盒子里的东西不是我们预期的,程序就会崩溃。

// JavaScript 的动态类型陷阱
function add(a, b) {
  // 运行时才能发现类型问题
  if (typeof a === 'number' && typeof b === 'number') {
    return a + b
  }
  return undefined; 
}

// 调用时传入了字符串,编译器不会报错,但逻辑可能非预期
const result = add(1, '2'); 
console.log(result); // 输出 undefined,而非报错

相比之下,C 语言等“强类型”语言则要求我们在定义变量时就必须声明类型,一旦类型不匹配,编译直接失败。

TypeScript 正是为了解决 JavaScript 的痛点而生。它给 JavaScript 加上了静态类型的“护栏”。它不改变 JS 的运行机制,而是在代码运行前(编译阶段)就帮我们检查类型是否正确。

看,这是迈向 TypeScript 的第一步:

// TypeScript 的类型注解
let a: number = 1;
// a = '2';  // ❌ 报错!TypeScript 会大声告诉你:'2' 不能赋值给 number 类型的变量
console.log(a);

这就好比给变量贴上了标签。一旦贴上 number 的标签,这个盒子就只能装数字。如果你试图塞进字符串,TS 编译器会立即拦截,将错误扼杀在摇篮里。

二、基础数据类型:构建类型的基石

有了类型注解的概念,我们就可以开始构建更复杂的数据结构了。TS 提供的一系列基础类型,是我们搭建程序的砖瓦。

1. 布尔值与数字

最基础的类型,对应 JS 中的 booleannumber

let isDone: boolean = false;
let count: number = 123;

2. 字符串与字面量类型

除了普通的字符串,TS 还允许我们定义“字面量类型”,即变量只能是某个特定的字符串值。这在做状态管理时非常有用,就像给变量限定了唯一的“身份证号”。

const hello = 'hello';
const a: 'hello' = 'hello'; // ✅ 正确
// const b: 'hello' = 'world'; // ❌ 错误,只能是 'hello'

3. 数组与元组

数组用来存储相同类型的列表,而元组(Tuple)则像是固定长度的“混合容器”,可以存储不同类型的值,但顺序和类型必须严格对应。

// 普通数组:只能装数字
let list: number[] = [1, 2, 3];

// 元组:第一个必须是 number,第二个必须是 string
let tuple: [number, string] = [1, 'hello']; 
// let errorTuple: [number, string] = ['hello', 1]; // ❌ 类型错位,编译器直接红牌罚下

4. 枚举(Enum)

枚举让我们可以定义一组命名的常量,让代码可读性更强。就像给方向定义了名字,而不是使用晦涩的数字。

enum Direction {
  NORTH,
  SOUTH,
  EAST,
  WEST
}
let dir: Direction = Direction.NORTH; // 比直接写 0 更易读,代码自文档化

5. Any 与 Unknown:双刃剑与保险丝

在迁移旧代码时,我们难免会遇到类型不确定的情况。JS 开发者习惯用 any,它意味着“关闭类型检查”。

let notSure: any = 100;
notSure = '123'; // ✅ 随便改,TS 不管了,这里失去了保护

any 用多了,TS 就退化成 JS 了,失去了保护意义。

TS 提供了更安全的 unknown。它和 any 一样可以接收任何类型,但在你使用它之前,必须进行类型判断或断言。这就像是一个带保险丝的电路,虽然通电,但必须先确认安全才能使用。

let value: unknown = 123;
value = '123';

// let abc: string = value; // ❌ 报错!不能直接把 unknown 赋给 string
// 必须先收窄类型,确认安全
if (typeof value === 'string') {
  let abc: string = value; // ✅ 安全了,TS 知道此时 value 一定是 string
}

6. Void, Null, Undefined 与 Symbol

这些类型分别对应无返回值、空值、未定义以及唯一的标识符。特别是 void,常用于没有返回值的函数,明确告诉调用者“别指望我有返回值”。

function warnUser(): void {
  console.log("This is my warning message");
  // 这里不需要 return 任何值,甚至 return undefined 也是允许的
}

三、对象与接口:描绘数据的形状

在实际开发中,我们处理最多的往往是对象。如何描述一个对象的“形状”?TS 提供了 接口类型别名

接口就像是建筑的蓝图,规定了对象必须拥有哪些属性,哪些是可选的。

interface Person {
  name: string;
  age: number;
  sex?: string; // ? 表示可选属性,就像装修时的“预留接口”
}

const p: Person = {
  name: '探长',
  age: 20
  // sex 属性可选,不写也不会报错,系统依然认为它是合法的 Person
};

除了接口,TS 还提供了强大的类型运算。我们可以像搭积木一样组合类型。 使用了交叉类型(&)来合并两个类型,创造出新的形态:

type PartialX = { x: number };

// Point 类型既要有 x,也要有 y,通过 & 将两个类型“焊接”在一起
type Point = PartialX & { y: number };

const p: Point = {
  x: 1,
  y: 2
};

这就像是将两块拼图完美地拼在一起,形成了一个新的、更完整的形状。这种组合能力让 TS 在处理复杂数据结构时游刃有余,避免了重复定义。

四、泛型:类型的“模具”

如果说接口是描述具体对象的蓝图,那么泛型(Generics)就是制造蓝图的模具

想象一下,你要写一个函数,它的功能是“原样返回传入的参数”。

  • 如果传入数字,返回数字;
  • 如果传入字符串,返回字符串。

在没有泛型之前,我们可能要用 any,但这会丢失类型信息,导致调用者不知道返回的是什么。泛型允许我们将类型作为一个参数传递进去,让函数具有“多态”的能力,且保持类型安全。

// T 是一个类型占位符,调用时确定具体是什么类型
// 就像是一个通用的容器,里面装什么,倒出来就是什么
function identity<T>(value: T): T {
  return value;
}

// 调用时指定 T 为 number
const num = identity<number>(100); 
// num 的类型被推断为 number

// 调用时指定 T 为 string
const str = identity<string>('hello');
// str 的类型被推断为 string

泛型还可以同时接受多个类型参数,甚至用于约束数组等复杂结构,极大地提高了代码的复用性:

// 定义一个既可以存 number 也可以存 string 的数组
let arr: Array<number | string> = [1, 2, 3, '1'];

泛型让代码变得更加灵活且安全,它是 TS 进阶的必经之路,也是区分新手与老手的关键标志。

五、类型断言与守卫:掌控不确定性

有时候,我们比编译器更清楚某个变量的类型。比如在处理 DOM 元素或者第三方库返回的数据时。这时,我们可以使用类型断言,告诉编译器:“相信我,我知道我在做什么。”

TS 提供了两种断言方式,推荐使用的是 as 语法:

let someValue: any = 'this is a apple';

// 方式一:as 语法(推荐,兼容性好)
let strLength = (someValue as string).length;

// 方式二:尖括号语法(不能在 JSX/TSX 中使用,容易与 HTML 标签混淆)
// let strLength = (<string>someValue).length;

但断言并非万能,盲目断言可能导致运行时错误。更优雅的方式是使用类型守卫。通过 typeofinstanceof 或自定义判断函数,在代码块内部收窄类型范围。这就像是在迷雾中点亮一盏灯,只有走进灯光范围(if 语句块内),变量的真实面目才会被看清,TS 也会随之放宽限制,允许你访问特定类型的方法。

function printId(id: number | string) {
  if (typeof id === "string") {
    // 在这里,id 的类型被收窄为 string
    console.log(id.toUpperCase());
  } else {
    // 在这里,id 的类型被收窄为 number
    console.log(id);
  }
}

结语:从束缚到自由

回顾这段旅程,我们经历了从“随意赋值”的混乱,到“严格定义”的束缚,最后达到了“类型安全下的自由”。TypeScript 并不是要给 JavaScript 戴上沉重的枷锁,而是为我们提供了一套精密的导航系统。

学习之路漫长,这些基础只是探索 TS 世界的起点。希望这篇文章能帮你理清 TS 的脉络,让你在写代码时多一份底气,少一份 undefined 的惊吓。

WebMCP 时代已至 - Chrome WebMCP 使用指南

文章同步发布于我的 个人博客

在不久前发布的 Chrome 146 版本中增加了实验性的 WebMCP API, 标志着 WebMCP 时代的到来, WebMCPGoogleMicrosoft 提出的一个用于让 AI Agent 直接操作 Web 页面的 MCP API, 我们来尝试通过编写一个简单的支持 WebMCP 的页面来学习一下 WebMCP API

[!WARNING] 截至文章发布时(2026-03-18), Chrome 146 已经发布, 可以在 Chrome 的设置页面 chrome://settings/help 查看当前浏览器版本: chrome-settings-page.pngChrome 146 虽然已经发布, 但依然属于实验性的 API, 需要访问 chrome://flags/#enable-webmcp-testing 手动开启: chrome-flags-enable-webmcp-testing.png 开启后需要重启 Chrome 才能生效, 验证一下是否生效: chrome-console-model-context.png 在浏览器控制台中执行 navigator.modelContext 如果输出了 ModelContext 对象则说明当前已经支持 WebMCP

[!TIP] 由于 WebMCP API 比较新, LLM 还没有相关的知识, 再加上现在的互联网是绝大部分文章都是 AI 生成的, 互联网上现有的文章和教程全部对 AI Agent 如何调用 WebMCP 避而不谈, 没有任何参考价值, 本文我来尝试一下让 Claude Code 调用 WebMCP, 并提供相关的 Skills

介绍

什么是 WebMCP

WebMCP 是浏览器提供的 MCP API, 它实现了 Web 页面上 声明 MCP ToolsAI Agent 进行调用

我们来详细介绍一下 WebMCP工作原理:

  1. Web 开发者将页面上的功能以 tools 的形式进行公开
  2. AI Agent 调用浏览器打开 Web 页面, 通过 WebMCP API 读取所有的 tools
  3. AI Agent 调用这些 tools 来操作 Web 页面, 然后将信息返回给 AI Agent
  4. AI Agent 根据返回的信息, 继续进行对话或操作网页

webmcp-shopping-example.png

举个简单的例子, 以购物网站为例, 假设我想购买一部手机:

  1. AI Agent 进行对话, 描述我的需求和预算, 例如 我想在某购物网站购买一部手机, 预算在 3000 元左右, 要有高刷, 电池容量要大, ...
  2. AI Agent 调用浏览器相关的 MCP(例如 chrome-devtools-mcp, 使用方式可参考我的 另一篇文章), 访问某购物网站
  3. 从此网站读取所有的 webmcp tools, 调用商品搜索相关的 tool, 并增加筛选条件(例如屏幕刷新率 / 电池容量 / 价格 等条件)和排序
  4. 在页面中已经展示所有符合条件的商品, 并且 AI Agent 也获取到了商品的 JSON 数据
  5. 继续进行对话或浏览网页 ...

WebMCP Tool

本质上 WebMCP API 的实现非常简单, 它只是在当前网页上定义的一系列 Function:

  • navigator.modelContext.registerTool(tool): 注册 tool
  • navigator.modelContext.unregisterTool(name): 删除 tool
  • navigator.modelContext.provideContext(): 注册顶级/应用级别的 tool
  • navigator.modelContext.clearContext(): 删除所有 tool

WebMCPtoolMCP 中的 tool 数据结构一致, 下面是一个简单的 demo:

navigator.modelContext.registerTool({
  name: 'get-page-title',
  description: 'Get the current page title',
  inputSchema: { type: 'object', properties: {} },
  async execute() {
    return {
      content: [{ type: 'text', text: document.title }],
    };
  },
});

其中 inputSchema 使用 JSON Schema 格式描述接受的参数, execute 是调用 tool 时执行的函数

除此之外, 还有另外一个用于调试的 API navigator.modelContextTesting:

  • navigator.modelContextTesting.listTools(): 获取所有注册的 tool
  • navigator.modelContextTesting.executeTool(name, argsJson, options?): 执行一个 tool 并提供参数
  • navigator.modelContextTesting.executeTool(name, source, options?): 执行一个 tool 并提供参数(流式请求)
  • navigator.modelContextTesting.registerToolsChangedCallback(callback): 监听 tool 注册/注销事件
  • navigator.modelContextTesting.getCrossDocumentScriptToolResult(): 以序列化字符串的形式返回跨文档声明式工具的结果

具体使用方式可以参考 Demo

API 参考

你可以在 mcp-b 的文档 中查看 WebMCP 的详细 API 参考, 这也是现有的唯一可以参考的文档, 注意, 这仍然是一个实验性的 API, 未来可能会有变化

为什么要使用 WebMCP

现阶段 AI Agent 调用浏览器操控 Web 页面有以下三种方式

这三种方式存在以下问题:

  • 需要 消耗大量的 token, 因为本质上是完全读取整个页面进行分析
  • 操作的 步骤越多, 耗时会越长
  • 操作不一定准确, 对于复杂的页面无法进行准确的操作, 例如需要滚动才能看到的元素

WebMCP 可以让 Web 开发者直接提供 官方 的操作方式:

  • 无需解析页面, 只需要调用已经注册的 tool
  • 本质上在调用 tool 时执行的是 js 代码, 不会有任何的耗时操作
  • toolsWeb 开发者进行维护, 可以确保操作的准确性和安全性

为什么不直接调用后端 API

WebMCP 的局限性在于, 它必须打开浏览器访问当前网页, 才能调用 tool, 为什么不直接调用 API 接口或者后端提供的 MCP 服务呢?

对于简单的操作当然可以直接调用 API, 网页的优势在于它可以提供给用户更丰富的可视化和更好的交互体验, 这是通过 AI Agent 的聊天窗口无法实现的, 这也是 Web 页面的魅力所在

WebMCP 的缺陷

在以下场景不应该使用 WebMCP

  • 依赖于开发者提供的 tool, 如果当前的任务没有对应的 tool 则无法使用
  • 必须打开浏览器访问当前网页, 才能获取或者调用 tool, 在草案中也提及了这一点, 其实可以通过 声明式方案 来直接获取所有 tools
  • 如果没有健全的权限管理, 很可能被滥用, 例如注册了当前用户没有权限的 tool, 或者本应该移除的 tool 没有被移除
  • 兼容性问题, 这是一个非常新的 API, 目前 WebMCP 只在 Chrome 146 及以上版本中支持, 在 caniuseMDN 中甚至都找不到任何信息; 不过因为其 API 的简单性, 可以使用 @mcp-b/webmcp-polyfill 来在旧版本的 Chrome 中使用 WebMCP

WebMCP 的应用场景与优势

对于用户来说:

  • 填写复杂的表单 的场景, 例如需要填写几十上百个字段的表单
  • 需要经过用户确认的场景, 例如生成数据填充到表单中, 让用户确认

对于 AI Agent:

  • 节省 token, 直接调用 WebMCP tools 完成任务
  • 可以在 WebMCP 的帮助下 100% 正确 的完成复杂的任务

对于软件测试:

  • 基于确定性的 WebMCP tools 调用, 降低测试用例的维护成本
  • 基于封装的 WebMCP 减少测试用例的不稳定性

使用 WebMCP

选型

WebMCP 还处在草案阶段, 应该只有基于 Chromium 的浏览器才会原生支持, 所以就目前来看, polyfill 是必须的, 除此之外我们还可以选择直接使用 mcp-b, 以下是这三者直接的关系

  • 浏览器原生 API: 兼容性极差, 目前只有 这些 API, 但 API 相对稳定可靠
  • @mcp-b/webmcp-polyfill: 仅仅提供原生 APIpolyfill, 不包含 mcp-bAPI
  • mcp-b: 完全兼容原生 WebMCP API, 并在此基础上提供扩展, 提供了额外的功能, 扩展的部分 API 属于非官方 API

[!TIP] 现阶段最佳选择就是 @mcp-b/webmcp-polyfill 方案, 我们也会使用此方案, 关于这三者的对比可参考 原生 API vs Polyfill vs mcp-b 全局运行时

WebMCP 调试工具

webmcp-chrome-extension.png

官方提供了一个浏览器插件 Model Context Tool Inspector 来帮助开发者调试 WebMCP, 它可以查看当前注册的 tools, 以及调用 tools 时的参数和返回值, 推荐安装, 但是感觉以后这个插件可能会集成到 Chrome devtools

我们来扒一下这个 extension 的源码:

  1. 进入扩展的详情页, 复制 ID webmcp-chrome-extension-details.png
  2. 进入 Google Chrome 的插件目录
open ~/Library/Application\ Support/Google/Chrome/Default/Extensions/gbpdfapgefenggkahomfgkhfehlcenpd

文件比较少, 我们先查看 content.js:

webmcp-chrome-extension-flag-enable-error.png

首先监测是否开启了 WebMCP Testing 标志

webmcp-chrome-extension-source.png

这里也是调用了 navigator.modelContextTesting.listTools() 来获取当前注册的 tools

创建一个新项目

接下来我们开始创建一个新项目来演示 WebMCP 的使用:

pnpm create vite@latest my-webmcp-react

.../19cf5f0e3d5-d06b                     |   +1 +
.../19cf5f0e3d5-d06b                     | Progress: resolved 1, reused 0, downloaded 1, added 1, done
│
◇  Select a framework:
│  React
│
◇  Select a variant:
│  TypeScript
│
◇  Install with pnpm and start now?
│  Yes
│
◇  Scaffolding project in /Users/kuidi/projects/my-webmcp-react...
│
◇  Installing dependencies with pnpm...

这里选择 react + typescript, 然后安装 polyfillusewebmcp:

npm install @mcp-b/webmcp-polyfill usewebmcp

然后修改 src/main.tsx, 引入 polyfill:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { initializeWebMCPPolyfill } from '@mcp-b/webmcp-polyfill';
import './index.css'
import App from './App.tsx'

initializeWebMCPPolyfill()

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

改一下 src/App.tsx:

import { useWebMCP } from 'usewebmcp';
import './App.css'

const INPUT_SCHEMA = {
  type: 'object',
  properties: {
    name: { type: 'string' },
  },
} as const;

function App() {
  const helloTool = useWebMCP({
    name: 'say_hello',
    description: 'Returns a hello message',
    inputSchema: INPUT_SCHEMA,
    execute: async (args) => ({
      content: [{ type: 'text', text: `Hello ${args?.name ?? 'world'}!` }],
    }),
  });

  return (
    <div>
      <h1>My First React WebMCP Tool</h1>
      <p>Tool "say_hello" registered.</p>
      <p>Executions: {helloTool.state.executionCount}</p>
      <p>Last result: {helloTool.state.lastResult
        ? JSON.stringify(helloTool.state.lastResult)
        : 'none'}</p>
      {helloTool.state.error && (
        <p style={{ color: 'red' }}>Error: {helloTool.state.error.message}</p>
      )}
      <button onClick={() => helloTool.execute({ name: 'React' })}>
        Run Tool Locally
      </button>
    </div>
  );
}

export default App

然后我们点击 Run Tool Locally, 可以看到 WebMCP 成功调用了 say_hello 工具, 并返回了 Hello React!

react-webmcp-demo-page.png

AI Agent 调用 WebMCP

至此我们完成了 WebMCP 的声明, 可笑的是网络上大部分教程也都止步于此, 明明 WebMCP 是让 AI Agent 进行调用的啊, 前端自己执行算是怎么回事?

接下来我们来尝试一下在 Claude Code 中调用 WebMCP, 由于 LLM 并不知道 WebMCP API 的存在, 所以我根据 mcp-b 的文档, 编写一个简单的 skills, 我已经写好了, 可以参考 SublimeCT/webmcp-agent, 我们来直接安装它

npx skills add SublimeCT/webmcp-agent

███████╗██╗  ██╗██╗██╗     ██╗     ███████╗
██╔════╝██║ ██╔╝██║██║     ██║     ██╔════╝
███████╗█████╔╝ ██║██║     ██║     ███████╗
╚════██║██╔═██╗ ██║██║     ██║     ╚════██║
███████║██║  ██╗██║███████╗███████╗███████║
╚══════╝╚═╝  ╚═╝╚═╝╚══════╝╚══════╝╚══════╝

┌   skills
│
◇  Source: https://github.com/SublimeCT/webmcp-agent.git
│
◇  Repository cloned
│
◇  Found 1 skill
│
●  Skill: webmcp-agent
│
│  A skill for guiding AI Agents to interact with WebMCP web pages
│
◇  41 agents
◇  Which agents do you want to install to?
│  Amp, Cline, Codex, Cursor, Gemini CLI, GitHub Copilot, Kimi Code CLI, OpenCode, Claude Code, Trae
│
◇  Installation scope
│  Global
│
◇  Installation method
│  Symlink (Recommended)

│
◇  Installation Summary ───────────────────────────────────────╮
│                                                              │
│  ~/.agents/skills/webmcp-agent                               │
│    universal: Amp, Cline, Codex, Cursor, Gemini CLI +3 more  │
│    symlink → Claude Code, Trae                               │
│                                                              │
├──────────────────────────────────────────────────────────────╯
│
◇  Proceed with installation?
│  Yes
│
◇  Installation complete

│
◇  Installed 1 skill ──────────────────────────────────────────╮
│                                                              │
│  ✓ ~/.agents/skills/webmcp-agent                             │
│    universal: Amp, Cline, Codex, Cursor, Gemini CLI +3 more  │
│    symlinked: Claude Code, Trae                              │
│                                                              │
├──────────────────────────────────────────────────────────────╯

│
└  Done!  Review skills before use; they run with full agent permissions.

这里我只安装到 Claude Code 中了, 我们来通过 Claude Code 进入 mcp-b 的测试页面 webmcp.sh 来测试一下:

claude

╭─── Claude Code v2.1.62 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│                                  │ Tips for getting started                                                                         │
│           Welcome back!          │ Run /init to create a CLAUDE.md file with instructions for Claude                                │
│                                  │ ─────────────────────────────────────────────────────────────────                                │
│                                  │ Recent activity                                                                                  │
│              ▐▛███▜▌             │ No recent activity                                                                               │
│             ▝▜█████▛▘            │                                                                                                  │
│               ▘▘ ▝▝              │                                                                                                  │
│   Sonnet 4 · API Usage Billing   │                                                                                                  │
│        ~/projects/isms-ui        │                                                                                                  │
╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯

  /model to try Opus 4.6

❯ 调用 chrome-devtools mcp 进入 https://webmcp.sh/dashboard, 通过 webmcp-agent 获取所有的路由

⏺ 我来帮你使用 chrome-devtools 进入 WebMCP 页面并获取路由信息。

  首先,让我打开浏览器并导航到该页面:

⏺ chrome-devtools - new_page (MCP)(url: "https://webmcp.sh/dashboard")
  ⎿  ## Pages
     1: about:blank
     2: https://webmcp.sh/dashboard [selected]

⏺ 页面已成功打开。现在让我使用 webmcp-agent skill 来获取所有的路由信息:

⏺ Skill(webmcp-agent)
  ⎿  Successfully loaded skill

⏺ 现在让我执行 WebMCP 的 listTools 脚本来获取所有可用的工具/路由:

⏺ chrome-devtools - evaluate_script (MCP)(function: "() => {\n  // 检查 WebMCP 是否可用\n  if (!navigator.modelContextTesting || typeof
                                          navigator.modelContextTesting.listTools !== 'function') {\n    return {\n      error: 'WebMCP
                                          API not available on this page',\n      available: false\n    };\n  }\n  \n  // 调用
                                         listTools 获取所有工具\n  const tools = navigator.modelContextTesting.listTools();\n  return
                                         {\n    available: true,\n    count: tools.length,\n    tools: tools\n  };\n}")
  ⎿  Running…

chrome-devtools-mcp-open-page.png

此时 Claude Code 调用 chrome-devtools MCP 打开了浏览器, 进入了 webmcp.sh/dashboard 页面, 然后顺利的触发了 webmcp-agent skill 来获取所有的路由信息

[!TIP] 经过测试, 在使用 chrome-devtools mcp 时如果不说明使用 webmcp-agent 就不会调用 webmcp-agent skills, 所以我这里说明要调用 webmcp-agent 来获取路由信息

⏺ chrome-devtools - evaluate_script (MCP)(function: "() => {\n  const result =
                                         navigator.modelContextTesting.executeTool('list_all_routes', '{}');\n  return result;\n}")
  ⎿  Script ran on page and returned:
     ```json
     "{\"content\":[{\"type\":\"text\",\"text\":\"=== WEBMCP APPLICATION NAVIGATION ===\\n\\nABOUT THIS APP\\n──────────────\\nWeb
     … +29 lines (ctrl+o to expand)

⏺ 📋 WebMCP 所有路由列表

  WebMCP 是一个内存和知识管理系统,具有以下路由:

  🏠 主要路由

  ┌─────────────────────┬──────────┬──────────────────────────────┐
  │        路由         │   描述   │             功能             │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /                   │ 首页     │ WebMCP 演示概览和文档        │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /dashboard          │ 仪表盘   │ 内存概览、统计图表和快速访问 │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /entities           │ 实体页面 │ 浏览和管理所有内存实体       │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /entities/$entityId │ 实体详情 │ 查看和编辑特定实体           │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /graph              │ 知识图谱 │ 可视化实体关系               │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /memory-blocks      │ 内存块   │ 管理始终在上下文的核心内存   │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /sql-repl           │ SQL REPL │ 执行直接数据库查询           │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /sql-execution-log  │ SQL 日志 │ 查看查询执行历史             │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /about              │ 关于页面 │ 技术栈和架构信息             │
  ├─────────────────────┼──────────┼──────────────────────────────┤
  │ /showcase           │ 展示页面 │ UI 组件演示                  │
  └─────────────────────┴──────────┴──────────────────────────────┘

  🔧 详细功能

  📊 Dashboard (/dashboard)
  - 查看内存块和实体计数
  - 按类别和层级查看令牌使用图表
  - 浏览数据库更改审计日志
  - 快速创建内存块和实体

  📝 Entities (/entities)
  - 在可搜索表格中查看所有实体
  - 按类别筛选(事实、偏好、技能、人员、项目、目标)
  - 创建、编辑和删除实体
  - 按名称或描述搜索实体

  🕸️  Knowledge Graph (/graph)
  - 以交互式 2D 或 3D 图形查看实体
  - 探索实体之间的连接
  - 运行视觉效果(相机漫游、粒子爆发)
  - 直接执行 SQL 查询

  💾 Memory Blocks (/memory-blocks)
  - 在表格中查看所有内存块
  - 创建新内存块
  - 编辑块内容、标签和优先级
  - 按块类型筛选(user_profile、agent_persona、current_goals、context)

  🗄️  SQL REPL (/sql-repl)
  - 对浏览器内数据库运行 SQL 查询
  - 探索数据库架构和表
  - 查看格式化的查询结果

  🚀 导航示例

  // 导航到实体页面
  { "to": "/entities" }

  // 查看特定实体
  { "to": "/entities/$entityId", "params": { "entityId": "abc-123" } }

  // 打开知识图谱
  { "to": "/graph" }

  // 管理内存块
  { "to": "/memory-blocks" }

  这个应用使用了 TanStack Router 进行路由管理,当你导航到不同路由时,会自动激活相应的上下文相关工具。

可以看到 Claude Code 在触发 webmcp-agent 之后正确的调用了名为 list_all_routestool, 然后获取到了所有的路由信息

WebMCP 的未来

human-ai-agent-web.png

在过去和现在, 网页是被设计为让人类进行操作的, 在未来, 网页会更多的被设计为让 AI Agent 进行操作, 例如:

  • 自动填写表单
  • 自动进行购物
  • 自动打网约车

也就是说, 我们可以不必面对复杂的表单, 不必受困于繁琐的操作和一些反人类的交互设计, 直接让 AI Agent 替代我们与网页或程序进行交互, 实际上腾讯已经开始着手这样做了, 腾讯计划在微信中集成一个 AI Agent, 通过 Agent 与小程序进行交互, 腾讯控制着微信小程序这样体量巨大的应用, 包括 打车 / 外卖 / 购物 在内的基本上所有的操作都可以在小程序上完成

2025腾讯Q3财报会上,刘炽平就曾表示,微信的理想蓝图是最终会推出一个AI智能体:“微信的生态系统拥有通信和社交生态系统,使智能体能够理解用户的需求、意图和兴趣;拥有内容生态系统,包括公众号和视频号;拥有小程序生态系统,基本上涵盖了互联网上的大部分用例;拥有商业生态系统,允许人们购买商品,以及支付生态系统,允许人们几乎立即完成支付。所以,这几乎是用户的理想助手,理解用户的需求,并且能够在该生态系统内执行所有任务。

来源: 秘密开发Agent,微信告别AI克制

参考

vue-router v5.x createRouter 是创建路由实例?

vue-router 初始化方法 createRouter。

image.png

createRouter

1、做了什么?

  • createRouterMatcher 初始化路由匹配系统。
  • 初始化 URL 处理,设置 URL 查询参数的解析和序列化函数。
  • 初始化历史管理器,初始化路由历史管理(Hash/History 模式)。
  • 初始化导航守卫系统,创建全局导航守卫的回调队列,beforeGuards,beforeResolveGuardsafterGuards
  • 初始化路由状态,创建响应式的当前路由状态currentRoute和待处理路由pendingLocation
  • 浏览器寒假配置滚动行为。
  • 定义核心的路由管理、路由导航方法。

2、函数返回?

返回 Router 实例。

/**
 * Creates a Router instance that can be used by a Vue app.
 * 负责组装路由的所有核心能力(路由匹配、导航守卫、历史记录管理、滚动行为、URL 解析 / 生成等),
 * 最终返回一个可安装到 Vue 应用的 Router 实例
 * @param options - {@link RouterOptions}
 */
export function createRouter(options: RouterOptions): Router {
  // 创建路由匹配器:解析 routes 配置,生成匹配规则(核心)
  const matcher = createRouterMatcher(options.routes, options)

  // 初始化 URL 查询参数解析/序列化函数(默认/自定义)
  const parseQuery = options.parseQuery || originalParseQuery
  const stringifyQuery = options.stringifyQuery || originalStringifyQuery

  // 初始化历史管理器(Hash/History 模式),开发环境校验必传
  const routerHistory = options.history

  if (__DEV__ && !routerHistory)
    throw new Error(
      'Provide the "history" option when calling "createRouter()":' +
        ' https://router.vuejs.org/api/interfaces/RouterOptions.html#history'
    )

  // 初始化导航守卫队列(全局前置/解析后/后置守卫)
  const beforeGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
  const beforeResolveGuards = useCallbacks<NavigationGuardWithThis<undefined>>()
  const afterGuards = useCallbacks<NavigationHookAfter>()

  // 初始化当前路由(响应式)和待处理路由
  const currentRoute = shallowRef<RouteLocationNormalizedLoaded>(
    START_LOCATION_NORMALIZED
  )
  // 待处理路由(当前导航目标),初始值为起始路由
  let pendingLocation: RouteLocation = START_LOCATION_NORMALIZED

  // 滚动行为初始化:有自定义 scrollBehavior 时,禁用浏览器默认滚动恢复
  // leave the scrollRestoration if no scrollBehavior is provided
  if (isBrowser && options.scrollBehavior && 'scrollRestoration' in history) {
    history.scrollRestoration = 'manual'
  }

  const normalizeParams = applyToParams.bind(
    null,
    paramValue => '' + paramValue
  )
  // 遍历路由参数对象的所有值,对每个值应用指定的处理函数,并返回新的参数对象
  const encodeParams = applyToParams.bind(null, encodeParam)
  const decodeParams: (params: RouteParams | undefined) => RouteParams =
    // @ts-expect-error: intentionally avoid the type check
    applyToParams.bind(null, decode)

  let removeHistoryListener: undefined | null | (() => void)
  
  let readyHandlers = useCallbacks<_OnReadyCallback>()
  let errorListeners = useCallbacks<_ErrorListener>()
  let ready: boolean
  
  const go = (delta: number) => routerHistory.go(delta)

  let started: boolean | undefined
  const installedApps = new Set<App>()
  
    // NOTE: we need to cast router as Router because the experimental
  // data-loaders add many properties that aren't available here. We might want
  // to add them later on instead of having declare module in experimental
  const router = {
    currentRoute,
    listening: true, // 监听路由

    addRoute,
    removeRoute,
    clearRoutes: matcher.clearRoutes,
    hasRoute,
    getRoutes,
    resolve,
    options,

    push,
    replace,
    go,
    back: () => go(-1),
    forward: () => go(1),

    beforeEach: beforeGuards.add,
    beforeResolve: beforeResolveGuards.add,
    afterEach: afterGuards.add,

    onError: errorListeners.add,
    isReady,

    /**
     * Vue 应用集成(install 方法)
     * @param app
     */
    install(app: App) {
      // 注册全局组件 RouterLink 和 RouterView
      app.component('RouterLink', RouterLink)
      app.component('RouterView', RouterView)

      // 暴露 $router/$route 到全局
      app.config.globalProperties.$router = router as Router
      Object.defineProperty(app.config.globalProperties, '$route', {
        enumerable: true,
        get: () => unref(currentRoute),
      })

      // this initial navigation is only necessary on client, on server it doesn't
      // make sense because it will create an extra unnecessary navigation and could
      // lead to problems
      // 初始化首次导航(客户端)
      if (
        isBrowser &&
        // used for the initial navigation client side to avoid pushing
        // multiple times when the router is used in multiple apps
        !started &&
        currentRoute.value === START_LOCATION_NORMALIZED
      ) {
        // see above
        started = true
        push(routerHistory.location).catch(err => {
          if (__DEV__) warn('Unexpected error when starting the router:', err)
        })
      }

      const reactiveRoute = {} as RouteLocationNormalizedLoaded
      for (const key in START_LOCATION_NORMALIZED) {
        Object.defineProperty(reactiveRoute, key, {
          get: () => currentRoute.value[key as keyof RouteLocationNormalized],
          enumerable: true,
        })
      }

      // 提供路由注入(useRouter/useRoute)
      app.provide(routerKey, router as Router)
      app.provide(routeLocationKey, shallowReactive(reactiveRoute))
      app.provide(routerViewLocationKey, currentRoute)

      const unmountApp = app.unmount
      installedApps.add(app)

      // 应用卸载时清理
      app.unmount = function () {
        installedApps.delete(app)
        // the router is not attached to an app anymore
        if (installedApps.size < 1) {
          // invalidate the current navigation
          pendingLocation = START_LOCATION_NORMALIZED
          removeHistoryListener && removeHistoryListener()
          removeHistoryListener = null
          currentRoute.value = START_LOCATION_NORMALIZED
          started = false
          ready = false
        }
        unmountApp()
      }

      // TODO: this probably needs to be updated so it can be used by vue-termui
      if (
        (__DEV__ || __FEATURE_PROD_DEVTOOLS__) &&
        isBrowser &&
        !__STRIP_DEVTOOLS__
      ) {
        addDevtools(app, router as Router, matcher)
      }
    },
  } satisfies Pick<Router, Extract<keyof Router, string>>
  
  return router as Router
}

参数 options 有哪些属性?

/**
 * Options to initialize a {@link Router} instance.
 */
export interface RouterOptions extends EXPERIMENTAL_RouterOptions_Base {
  /**
   * Initial list of routes that should be added to the router.
   */
  routes: Readonly<RouteRecordRaw[]>
}
/**
 * Options to initialize a {@link Router} instance.
 */
export interface EXPERIMENTAL_RouterOptions_Base extends PathParserOptions {
  /**
   * History implementation used by the router. Most web applications should use
   * `createWebHistory` but it requires the server to be properly configured.
   * You can also use a _hash_ based history with `createWebHashHistory` that
   * does not require any configuration on the server but isn't handled at all
   * by search engines and does poorly on SEO.
   *
   * @example
   * ```js
   * createRouter({
   *   history: createWebHistory(),
   *   // other options...
   * })
   * ```
   */
  history: RouterHistory // 指定路由使用的「历史记录管理器」,决定路由模式(Hash/History)

  /**
   * Function to control scrolling when navigating between pages. Can return a
   * Promise to delay scrolling.
   *
   * @see {@link RouterScrollBehavior}.
   *
   * @example
   * ```js
   * function scrollBehavior(to, from, savedPosition) {
   *   // `to` and `from` are both route locations
   *   // `savedPosition` can be null if there isn't one
   * }
   * ```
   */
  scrollBehavior?: RouterScrollBehavior // 自定义路由切换时的页面滚动行为(如返回顶部、恢复滚动位置)

  /**
   * Custom implementation to parse a query. See its counterpart,
   * {@link EXPERIMENTAL_RouterOptions_Base.stringifyQuery}.
   *
   * @example
   * Let's say you want to use the [qs package](https://github.com/ljharb/qs)
   * to parse queries, you can provide both `parseQuery` and `stringifyQuery`:
   * ```js
   * import qs from 'qs'
   *
   * createRouter({
   *   // other options...
   *   parseQuery: qs.parse,
   *   stringifyQuery: qs.stringify,
   * })
   * ```
   */
  parseQuery?: typeof originalParseQuery // 将 URL 中的查询参数字符串(如 a=1&b=2)解析为对象({ a: '1', b: '2' })

  /**
   * Custom implementation to stringify a query object. Should not prepend a leading `?`.
   * {@link parseQuery} counterpart to handle query parsing.
   * 将查询参数对象({ a: '1', b: '2' })序列化为字符串(a=1&b=2),无需手动加 ?
   */

  stringifyQuery?: typeof originalStringifyQuery

  /**
   * Default class applied to active {@link RouterLink}. If none is provided,
   * `router-link-active` will be applied.
   * 设置 <RouterLink> 「部分匹配激活」时的默认类名(如 /home 匹配 /home/child)
   */
  linkActiveClass?: string

  /**
   * Default class applied to exact active {@link RouterLink}. If none is provided,
   * `router-link-exact-active` will be applied.
   * 设置 <RouterLink> 「精确匹配激活」时的默认类名(仅 /home 匹配 /home)
   */
  linkExactActiveClass?: string

  /**
   * Default class applied to non-active {@link RouterLink}. If none is provided,
   * `router-link-inactive` will be applied.
   * 预留配置,用于设置 <RouterLink> 「非激活状态」的默认类名,当前版本未启用
   */
  // linkInactiveClass?: string
}
/**
 * @internal
 */
export interface _PathParserOptions {
  /**
   * Makes the RegExp case-sensitive.
   * 控制路由路径匹配时是否区分大小写(影响生成的正则表达式是否添加 i 标志)
   * @defaultValue `false` false(不区分大小写,如 /Home 和 /home 视为同一路由)
   */
  sensitive?: boolean

  /**
   * Whether to disallow a trailing slash or not.
   * 控制是否严格匹配路径末尾的斜杠(/)
   * @defaultValue `false` false(允许末尾斜杠,如 /home 和 /home/ 视为同一路由)
   */
  strict?: boolean

  /**
   * Should the RegExp match from the beginning by prepending a `^` to it.
   * @internal
   * 控制生成的路径匹配正则是否添加 ^ 前缀(即是否从字符串开头开始匹配)
   * @defaultValue `true` true(必须从路径开头匹配,符合路由匹配的基本逻辑)
   */
  start?: boolean

  /**
   * Should the RegExp match until the end by appending a `$` to it.
   * 控制生成的路径匹配正则是否添加 $ 后缀(即是否完整匹配路径末尾)
   * @deprecated this option will alsways be `true` in the future. Open a discussion in vuejs/router if you need this to be `false`
   * 已废弃
   * @defaultValue `true`
   */
  end?: boolean
}
export type PathParserOptions = Pick<
  _PathParserOptions,
  'end' | 'sensitive' | 'strict'
>

routes 配置

export type RouteRecordRaw =
  | RouteRecordSingleView // 最基础的路由配置,对应「一个路径匹配一个组件」的场景,无嵌套子路由
  // 基础单视图路由 + 嵌套子路由(对应 <RouterView> 嵌套渲染)
  | RouteRecordSingleViewWithChildren
  // 一个路径匹配多个组件,对应 <RouterView name="xxx"> 命名视图
  | RouteRecordMultipleViews
  // 多视图路由 + 嵌套子路由,是 RouteRecordMultipleViews 的扩展
  | RouteRecordMultipleViewsWithChildren
  // 仅用于路由重定向,无组件 / 视图配置,匹配路径后跳转到目标路由
  | RouteRecordRedirect

1、RouteRecordSingleView

基础单视图,一个路径匹配一个组件。禁止components、children、redirect。

/**
 * Route Record defining one single component with the `component` option.
 */
export interface RouteRecordSingleView extends _RouteRecordBase {
  /**
   * Component to display when the URL matches this route.
   * 指定路由匹配时要渲染的单个组件,是单视图路由的核心标识
   */
  component: RawRouteComponent
  // 明确禁止在单视图路由中使用 components 字段(多视图路由的核心字段)
  components?: never
  // 明确禁止在单视图路由中使用 children 字段(嵌套路由的核心字段)
  children?: never
  // 明确禁止在单视图路由中使用 redirect 字段(重定向路由的核心字段)
  redirect?: never

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   * 控制是否将路由参数(params/query)作为 props 传递给路由组件,避免组件直接依赖 $route
   */
  props?: _RouteRecordProps
}
// TODO: could this be moved to matcher? YES, it's on the way
/**
 * Internal type for common properties among all kind of {@link RouteRecordRaw}.
 */
export interface _RouteRecordBase extends PathParserOptions {
  /**
   * Path of the record. Should start with `/` unless the record is the child of
   * another record.
   * 路由路径
   * @example `/users/:id` matches `/users/1` as well as `/users/posva`.
   */
  path: string

  /**
   * Where to redirect if the route is directly matched. The redirection happens
   * before any navigation guard and triggers a new navigation with the new
   * target location.
   * 路由重定向选项,用于定义路由跳转目标
   */
  redirect?: RouteRecordRedirectOption

  /**
   * Aliases for the record. Allows defining extra paths that will behave like a
   * copy of the record. Allows having paths shorthands like `/users/:id` and
   * `/u/:id`. All `alias` and `path` values must share the same params.
   * 路由别名数组,用于定义额外的路径
   */
  alias?: string | string[]

  /**
   * Name for the route record. Must be unique.
   * 路由名称,必须唯一
   */
  name?: RouteRecordNameGeneric

  /**
   * Before Enter guard specific to this record. Note `beforeEnter` has no
   * effect if the record has a `redirect` property.
   */
  beforeEnter?:
    | NavigationGuardWithThis<undefined>
    | NavigationGuardWithThis<undefined>[]

  /**
   * Arbitrary data attached to the record.
   * 路由元数据,用于存储自定义信息,如权限、标题等
   */
  meta?: RouteMeta

  /**
   * Array of nested routes.
   * 子路由数组,用于定义嵌套路由结构
   */
  children?: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   */
  props?: _RouteRecordProps | Record<string, _RouteRecordProps>
}

2、RouteRecordSingleViewWithChildren

单视图嵌套子路由。禁止配置components。

/**
 * Route Record defining one single component with a nested view. Differently
 * from {@link RouteRecordSingleView}, this record has children and allows a
 * `redirect` option.
 */
export interface RouteRecordSingleViewWithChildren extends _RouteRecordBase {
  /**
   * Component to display when the URL matches this route.
   * 指定父路由匹配时渲染的布局组件(需包含 <RouterView> 用于渲染子路由)
   */
  component?: RawRouteComponent | null | undefined
  // 与 RouteRecordSingleView 一致,禁止使用 components(多视图字段),保证父路由为「单视图布局」
  components?: never

  // 定义父路由下的嵌套子路由,是该接口的核心标识(区别于 RouteRecordSingleView)
  children: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by `router-view`.
   * 控制是否将父路由的参数传递给父布局组件(而非子路由组件)
   */
  props?: _RouteRecordProps
}

3、RouteRecordMultipleViews

多视图。禁止配置component、children、redirect。

/**
 * Route Record defining multiple named components with the `components` option.
 */
export interface RouteRecordMultipleViews extends _RouteRecordBase {
  /**
   * Components to display when the URL matches this route. Allow using named views.
   * 指定路由匹配时要渲染的多个命名组件,键为「视图名称」,值为「组件」,是多视图路由的核心标识
   * 示例  components: {
            default: () => import('./DashboardMain.vue'), // 对应 <RouterView>(默认视图)
            header: () => import('./DashboardHeader.vue'), // 对应 <RouterView name="header">
            sidebar: () => import('./DashboardSidebar.vue'), // 对应 <RouterView name="sidebar">
          },
   */
  components: Record<string, RawRouteComponent>
  component?: never // 明确禁止使用 component 字段(单视图路由的核心字段)
  // 禁止使用 children 字段,多视图 + 嵌套子路由需使用 RouteRecordMultipleViewsWithChildren 类型
  children?: never
  // 禁止使用 redirect 字段,重定向路由需使用 RouteRecordRedirect 类型
  redirect?: never

  /**
   * Allow passing down params as props to the component rendered by
   * `router-view`. Should be an object with the same keys as `components` or a
   * boolean to be applied to every component.
   * 控制是否将路由参数传递给每个命名视图组件,是单视图 props 字段的多视图扩展
   */
  props?: Record<string, _RouteRecordProps> | boolean
}

4、RouteRecordMultipleViewsWithChildren

多视图嵌套子路由。禁止配置component。

/**
 * Route Record defining multiple named components with the `components` option and children.
 */
export interface RouteRecordMultipleViewsWithChildren extends _RouteRecordBase {
  /**
   * Components to display when the URL matches this route. Allow using named views.
   * 指定父路由匹配时渲染的多命名视图布局组件(需包含多个 <RouterView name="xxx"> 用于渲染子路由);
   * 1、有布局组件:父路由渲染多视图布局(如 header + sidebar + main),子路由可覆盖 / 扩展父视图;
   * 2、无布局组件:父路由仅用于路径分组(如 /admin/* 下的多视图子路由,无可视化布局);
   */
  components?: Record<string, RawRouteComponent> | null | undefined
  // 与 RouteRecordMultipleViews 一致,禁止使用 component 字段(单视图路由的核心字段)
  component?: never

  // 定义父多视图路由下的嵌套子路由,是该接口的核心标识(区别于 RouteRecordMultipleViews)
  children: RouteRecordRaw[]

  /**
   * Allow passing down params as props to the component rendered by
   * `router-view`. Should be an object with the same keys as `components` or a
   * boolean to be applied to every component.
   * 控制是否将父路由的参数传递给父多视图组件(而非子路由组件)
   */
  props?: Record<string, _RouteRecordProps> | boolean
}

路由独享守卫 beforeEnter

beforeEnter 守卫 只在进入路由时触发,不会在 paramsquery 或 hash 改变时触发。

image.png

{
  path: '/dashboard',
  name: 'dashboard',
  component: () => import('@/views/dashboard/DashBoard.vue'),
  meta: {
    title: '看板',
    icon: 'dashboard',
    roles: ['admin', 'user']
  },
  // beforeEnter: (to, from) => {
  //   console.log('beforeEnter-to', to)
  //   console.log('beforeEnter-from', from)
  //   return true
  // },
  beforeEnter: [(to, from) => {
    console.log('beforeEnter-111to', to)
    console.log('beforeEnter-f111rom', from)
    return true
  }, (to, from) => {
    console.log('beforeEnter-222to', to)
    console.log('beforeEnter-222from', from)
    return true
  }]

},

Router 实例有哪些属性?

/**
 * Router instance.
 * 路由实例
 */
export interface Router extends EXPERIMENTAL_Router_Base<RouteRecordNormalized> {
  /**
   * Original options object passed to create the Router
   * 存储创建路由实例时传入的原始配置项
   */
  readonly options: RouterOptions

  /**
   * Add a new {@link RouteRecordRaw | route record} as the child of an existing route.
   * 动态路由方法
   * 重载 1:添加嵌套路由
   * 返回值:一个「移除该动态路由的函数」,调用后可删除本次添加的路由
   * @param parentName - Parent Route Record where `route` should be appended at
   * @param route - Route Record to add
   */
  addRoute(
    // NOTE: it could be `keyof RouteMap` but the point of dynamic routes is not knowing the routes at build
    parentName: NonNullable<RouteRecordNameGeneric>,
    route: RouteRecordRaw
  ): () => void
  /**
   * Add a new {@link RouteRecordRaw | route record} to the router.
   *
   * @param route - Route Record to add
   * 重载 2:添加顶级路由
   * 返回值:一个「移除该动态路由的函数」,调用后可删除本次添加的路由
   */
  addRoute(route: RouteRecordRaw): () => void

  /**
   * Remove an existing route by its name.
   *
   * @param name - Name of the route to remove 路由名称(非空),注意只能通过名称删除,不能通过路径
   * 根据路由名称删除已存在的路由(包括静态路由和动态添加的路由)
   * 
   */
  removeRoute(name: NonNullable<RouteRecordNameGeneric>): void

  /**
   * Delete all routes from the router.
   * 清空路由表中所有路由(包括静态路由和动态添加的路由)
   * 注意:清空后路由表为空,需重新调用 addRoute 添加路由,否则导航会失效
   */
  clearRoutes(): void
}
/**
 * Router base instance.
 *
 * @experimental This version is not stable, it's meant to replace {@link Router} in the future.
 */
export interface EXPERIMENTAL_Router_Base<TRecord> {
  // NOTE: for dynamic routing we need this
  // <TRouteRecordRaw, TRouteRecord>
  /**
   * Current {@link RouteLocationNormalized}
   * 存储当前激活的标准化路由信息(响应式)
   */
  readonly currentRoute: ShallowRef<RouteLocationNormalizedLoaded>

  /**
   * Allows turning off the listening of history events. This is a low level api for micro-frontend.
   * 控制是否监听浏览器历史事件,专为「微前端」场景设计
   */
  listening: boolean

  // TODO: deprecate in favor of getRoute(name) and add it
  /**
   * Checks if a route with a given name exists
   * 根据路由名称判断路由是否存在(静态 / 动态添加的路由均可检测)
   * @param name - Name of the route to check
   */
  hasRoute(name: NonNullable<RouteRecordNameGeneric>): boolean

  /**
   * Get a full list of all the {@link RouteRecord | route records}.
   * 返回路由表中所有标准化路由记录
   */
  getRoutes(): TRecord[]

  /**
   * Returns the {@link RouteLocation | normalized version} of a
   * {@link RouteLocationRaw | route location}. Also includes an `href` property
   * that includes any existing `base`. By default, the `currentLocation` used is
   * `router.currentRoute` and should only be overridden in advanced use cases.
   * 将原始路由地址(如字符串、对象)解析为标准化的路由对象(包含 href、fullPath 等)
   * @param to - Raw route location to resolve
   * @param currentLocation - Optional current location to resolve against
   */
  resolve<Name extends keyof RouteMap = keyof RouteMap>(
    to: RouteLocationAsRelativeTyped<RouteMap, Name>,
    // NOTE: This version doesn't work probably because it infers the type too early
    // | RouteLocationAsRelative<Name>
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved<Name>
  resolve(
    // not having the overload produces errors in RouterLink calls to router.resolve()
    to: RouteLocationAsString | RouteLocationAsRelative | RouteLocationAsPath,
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved

  /**
   * Programmatically navigate to a new URL by pushing an entry in the history
   * stack.
   * 通过「新增历史记录」实现无刷新导
   *
   * @param to - Route location to navigate to
   */
  push(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>

  /**
   * Programmatically navigate to a new URL by replacing the current entry in
   * the history stack.
   * 通过「替换当前历史记录」实现导航(对应 history.replaceState),无历史记录回溯
   *
   * @param to - Route location to navigate to
   */
  replace(to: RouteLocationRaw): Promise<NavigationFailure | void | undefined>

  /**
   * Go back in history if possible by calling `history.back()`. Equivalent to
   * `router.go(-1)`.
   * 历史记录回溯
   */
  back(): void

  /**
   * Go forward in history if possible by calling `history.forward()`.
   * Equivalent to `router.go(1)`.
   * 历史记录回溯
   */
  forward(): void

  /**
   * Allows you to move forward or backward through the history. Calls
   * `history.go()`.
   *
   * @param delta - The position in the history to which you want to move,
   * relative to the current page
   * 历史记录回溯
   */
  go(delta: number): void

  /**
   * Add a navigation guard that executes before any navigation. Returns a
   * function that removes the registered guard.
   *
   * 注册全局前置守卫,导航触发时最先执行(可拦截、重定向导航)
   * @param guard - navigation guard to add
   */
  beforeEach(guard: NavigationGuardWithThis<undefined>): () => void

  /**
   * Add a navigation guard that executes before navigation is about to be
   * resolved. At this state all component have been fetched and other
   * navigation guards have been successful. Returns a function that removes the
   * registered guard.
   *
   * @param guard - navigation guard to add
   * @returns a function that removes the registered guard
   * 在所有组件内守卫、异步路由组件解析完成后,导航确认前执行
   * @example
   * ```js
   * router.beforeResolve(to => {
   *   if (to.meta.requiresAuth && !isAuthenticated) return false
   * })
   * ```
   *
   */
  beforeResolve(guard: _NavigationGuardResolved): () => void

  /**
   * Add a navigation hook that is executed after every navigation. Returns a
   * function that removes the registered hook.
   *
   * 导航完成后(成功 / 失败均执行),无法拦截导航
   *
   * @param guard - navigation hook to add
   * @returns a function that removes the registered hook
   *
   * @example
   * ```js
   * router.afterEach((to, from, failure) => {
   *   if (isNavigationFailure(failure)) {
   *     console.log('failed navigation', failure)
   *   }
   * })
   * ```
   */
  afterEach(guard: NavigationHookAfter): () => void

  /**
   * Adds an error handler that is called every time a non caught error happens
   * during navigation. This includes errors thrown synchronously and
   * asynchronously, errors returned or passed to `next` in any navigation
   * guard, and errors occurred when trying to resolve an async component that
   * is required to render a route.
   * 注册导航错误监听器,捕获导航过程中的所有未处理错误
   *
   * @param handler - error handler to register
   */
  onError(handler: _ErrorListener): () => void

  /**
   * Returns a Promise that resolves when the router has completed the initial
   * navigation, which means it has resolved all async enter hooks and async
   * components that are associated with the initial route. If the initial
   * navigation already happened, the promise resolves immediately.
   *
   * This is useful in server-side rendering to ensure consistent output on both
   * the server and the client. Note that on server side, you need to manually
   * push the initial location while on client side, the router automatically
   * picks it up from the URL.
   */
  isReady(): Promise<void> // 等待初始导航完成

  /**
   * Called automatically by `app.use(router)`. Should not be called manually by
   * the user. This will trigger the initial navigation when on client side.
   * 安装路由到 Vue 应用
   * 由 app.use(router) 自动调用,完成路由的初始化(注册全局组件、注入路由实例、触发初始导航)
   * @internal
   * @param app - Application that uses the router
   */
  install(app: App): void
}

实例方法 router.replace

  function replace(to: RouteLocationRaw) {
    return push(assign(locationAsObject(to), { replace: true }))
  }

实例方法 router.push

  function push(to: RouteLocationRaw) {
    return pushWithRedirect(to)
  }

pushWithRedirect

  /**
   * 负责处理「路由跳转 + 重定向 + 守卫执行 + 历史记录更新 + 错误处理」的全流程
   * @param to 目标路由位置(可以是字符串路径、命名路由对象或路径对象)
   * @param redirectedFrom 重定向来源路由位置(可选)
   * @returns 导航失败原因、成功时无返回值或 undefined
   */
  function pushWithRedirect(
    to: RouteLocationRaw | RouteLocation,
    redirectedFrom?: RouteLocation
  ): Promise<NavigationFailure | void | undefined> {

    // 解析目标路由为标准化 RouteLocation 对象
    const targetLocation: RouteLocation = (pendingLocation = resolve(to))
    const from = currentRoute.value // 获取当前路由(响应式的 currentRoute)

    // 获取历史记录状态(state)
    const data: HistoryState | undefined = (to as RouteLocationOptions).state
    // 获取强制跳转标志(force)
    const force: boolean | undefined = (to as RouteLocationOptions).force
    // to could be a string where `replace` is a function
    // 获取替换标志(replace)
    const replace = (to as RouteLocationOptions).replace === true

    // 检查目标路由是否配置了 redirect,返回重定向后的路由
    const shouldRedirect = handleRedirectRecord(targetLocation, from)

    // 若存在重定向,递归调用 pushWithRedirect 处理重定向后的路由
    if (shouldRedirect)
      return pushWithRedirect(
        // 合并重定向路由与原配置
        assign(locationAsObject(shouldRedirect), {
          state:
            typeof shouldRedirect === 'object'
              ? assign({}, data, shouldRedirect.state)
              : data,
          force,
          replace,
        }),
        // keep original redirectedFrom if it exists
        redirectedFrom || targetLocation
      )

    // if it was a redirect we already called `pushWithRedirect` above
    const toLocation = targetLocation as RouteLocationNormalized // 标准化目标路由

    toLocation.redirectedFrom = redirectedFrom // 标记重定向来源

    let failure: NavigationFailure | void | undefined // 声明导航失败变量

    // 非强制跳转 + 路由完全相同 → 生成重复跳转错误
    if (!force && isSameRouteLocation(stringifyQuery, from, targetLocation)) {
      failure = createRouterError<NavigationFailure>(
        ErrorTypes.NAVIGATION_DUPLICATED,
        {
          to: toLocation,
          from,
        }
      )
      // trigger scroll to allow scrolling to the same anchor
      // 即使重复跳转,仍处理滚动(如锚点 #top)
      handleScroll(
        from,
        from,
        // this is a push, the only way for it to be triggered from a
        // history.listen is with a redirect, which makes it become a push
        true, // push导航 
        // This cannot be the first navigation because the initial location
        // cannot be manually navigated to
        false // 非首次导航,初始路由不能手动跳转
      )
    }

    // 有失败则返回 resolved 的 failure,否则调用 navigate 执行真正的导航
    return (failure ? Promise.resolve(failure) : navigate(toLocation, from))
      .catch((error: NavigationFailure | NavigationRedirectError) =>
        isNavigationFailure(error)
          ? // navigation redirects still mark the router as ready
          // 导航守卫重定向 → 仅返回错误,不标记 ready
            isNavigationFailure(error, ErrorTypes.NAVIGATION_GUARD_REDIRECT)
            ? error
            // 其他导航失败 → 标记 router 为 ready 并返回错误
            : markAsReady(error) // also returns the error
          : // reject any unknown error
          // 未知错误 → 触发全局错误并抛出
            triggerError(error, toLocation, from)
      )
      .then((failure: NavigationFailure | NavigationRedirectError | void) => {
        if (failure) {
          if (
            isNavigationFailure(failure, ErrorTypes.NAVIGATION_GUARD_REDIRECT)
          ) {
            if (
              __DEV__ &&
              // we are redirecting to the same location we were already at
              // 开发环境:检测无限重定向(超过30次)并报警
              isSameRouteLocation(
                stringifyQuery,
                resolve(failure.to),
                toLocation
              ) &&
              // and we have done it a couple of times
              redirectedFrom &&
              // @ts-expect-error: added only in dev
              (redirectedFrom._count = redirectedFrom._count
                ? // @ts-expect-error
                  redirectedFrom._count + 1
                : 1) > 30
            ) {
              warn(
                `Detected a possibly infinite redirection in a navigation guard when going from "${from.fullPath}" to "${toLocation.fullPath}". Aborting to avoid a Stack Overflow.\n Are you always returning a new location within a navigation guard? That would lead to this error. Only return when redirecting or aborting, that should fix this. This might break in production if not fixed.`
              )
              return Promise.reject(
                new Error('Infinite redirect in navigation guard')
              )
            }

            return pushWithRedirect(
              // keep options
              assign(
                {
                  // preserve an existing replacement but allow the redirect to override it
                  replace,
                },
                locationAsObject(failure.to),
                {
                  state:
                    typeof failure.to === 'object'
                      ? assign({}, data, failure.to.state)
                      : data,
                  force,
                }
              ),
              // preserve the original redirectedFrom if any
              redirectedFrom || toLocation
            )
          }
        } else {
          // if we fail we don't finalize the navigation
          // 导航成功 → 最终化导航(更新历史记录/滚动/路由状态)
          failure = finalizeNavigation(
            toLocation as RouteLocationNormalizedLoaded,
            from,
            true,
            replace,
            data
          )
        }
        // 触发 afterEach 后置钩子
        triggerAfterEach(
          toLocation as RouteLocationNormalizedLoaded,
          from,
          failure
        )
        return failure
      })
  }

当待处理路由 与 当前路由完全一致,会出现以下问题

image.png

image.png

image.png

handleRedirectRecord

  /**
   * 「解析目标路由匹配记录中最后一条的 redirect 配置
   *  →标准化重定向目标格式→校验重定向合法性→合并原路由的 query/hash 等参数→返回最终的重定向目标」
   * @param to 目标路由对象
   * @param from 来源路由对象
   * @returns 
   */
  function handleRedirectRecord(
    to: RouteLocation,
    from: RouteLocationNormalizedLoaded
  ): RouteLocationRaw | void {

    const lastMatched = to.matched[to.matched.length - 1] // 获取最后一条匹配记录
    
    if (lastMatched && lastMatched.redirect) {
      const { redirect } = lastMatched // 获取 redirect 配置

      // 解析 redirect,目标重定向位置
      let newTargetLocation =
        typeof redirect === 'function' ? redirect(to, from) : redirect

      // 标准化字符串格式的 redirect → 对象格式
      if (typeof newTargetLocation === 'string') {
        newTargetLocation =
          // 字符串含 ?/# → 解析为完整对象(包含 query/hash)
          newTargetLocation.includes('?') || newTargetLocation.includes('#')
            ? (newTargetLocation = locationAsObject(newTargetLocation))
            : // force empty params
              { path: newTargetLocation }

        // @ts-expect-error: force empty params when a string is passed to let
        // the router parse them again
        // 强制清空 params,避免原路由 params 污染重定向目标
        newTargetLocation.params = {}
      }

      if (
        __DEV__ &&
        newTargetLocation.path == null &&
        !('name' in newTargetLocation)
      ) {
        warn(
          `Invalid redirect found:\n${JSON.stringify(
            newTargetLocation,
            null,
            2
          )}\n when navigating to "${
            to.fullPath
          }". A redirect must contain a name or path. This will break in production.`
        )
        throw new Error('Invalid redirect')
      }

      return assign(
        {
          query: to.query, // 继承原路由的 query 参数
          hash: to.hash, // 继承原路由的 hash 锚点
          // avoid transferring params if the redirect has a path
          // 重定向目标有 path → 清空 params;无 path(用 name 跳转)→ 继承原 params
          params: newTargetLocation.path != null ? {} : to.params,
        },
        newTargetLocation
      )
    }
  }

image.png

handleScroll

  // Scroll behavior
  function handleScroll(
    to: RouteLocationNormalizedLoaded, // 目标路由
    from: RouteLocationNormalizedLoaded, // 来源路由
    isPush: boolean, // 是否为 push 导航
    isFirstNavigation: boolean // 是否是应用首次导航(如页面初始化时的路由)
  ): // the return is not meant to be used
  Promise<unknown> {
  
    const { scrollBehavior } = options
    // 非浏览器环境(如SSR) 或 未配置 scrollBehavior → 直接返回成功 Promise
    if (!isBrowser || !scrollBehavior) return Promise.resolve()

    // 计算初始滚动位置(scrollPosition)
    const scrollPosition: _ScrollPositionNormalized | null =
      // 非 push 跳转(replace/后退)→ 读取保存的滚动位置
      (!isPush && getSavedScrollPosition(getScrollKey(to.fullPath, 0))) ||
      // 首次导航 或 非 push 跳转 → 读取 history.state 中的滚动位置
      ((isFirstNavigation || !isPush) &&
        (history.state as HistoryState) &&
        history.state.scroll) ||
      null // 其他情况 → 无滚动位置

    // 等待 DOM 更新完成后再执行滚动(路由跳转后组件渲染需要时间,避免滚动到未渲染的元素)
    return nextTick()
      // 调用用户配置的 scrollBehavior,获取目标滚动位置
      .then(() => scrollBehavior(to, from, scrollPosition))
      // 若返回了滚动位置,执行实际的滚动操作
      .then(position => position && scrollToPosition(position))
      // 捕获滚动过程中的错误,触发全局错误处理
      .catch(err => triggerError(err, to, from))
  }

scrollToPosition

最终调用原生 API window.scrollTo 实现。

export function scrollToPosition(position: ScrollPosition): void {
  let scrollToOptions: ScrollPositionCoordinates

  // 元素锚点型(包含 el 字段)
  if ('el' in position) {
    const positionEl = position.el
    const isIdSelector =
      typeof positionEl === 'string' && positionEl.startsWith('#')
    /**
     * `id`s can accept pretty much any characters, including CSS combinators
     * like `>` or `~`. It's still possible to retrieve elements using
     * `document.getElementById('~')` but it needs to be escaped when using
     * `document.querySelector('#\\~')` for it to be valid. The only
     * requirements for `id`s are them to be unique on the page and to not be
     * empty (`id=""`). Because of that, when passing an id selector, it should
     * be properly escaped for it to work with `querySelector`. We could check
     * for the id selector to be simple (no CSS combinators `+ >~`) but that
     * would make things inconsistent since they are valid characters for an
     * `id` but would need to be escaped when using `querySelector`, breaking
     * their usage and ending up in no selector returned. Selectors need to be
     * escaped:
     *
     * - `#1-thing` becomes `#\31 -thing`
     * - `#with~symbols` becomes `#with\\~symbols`
     *
     * - More information about  the topic can be found at
     *   https://mathiasbynens.be/notes/html5-id-class.
     * - Practical example: https://mathiasbynens.be/demo/html5-id
     */
    if (__DEV__ && typeof position.el === 'string') {
      // 场景1:是 ID 选择器但对应元素不存在,或不是 ID 选择器
      if (!isIdSelector || !document.getElementById(position.el.slice(1))) {
        try {
          const foundEl = document.querySelector(position.el)
          // 场景1.1:是 ID 选择器但通过 querySelector 找到了元素 → 警告(建议用 getElementById)
          if (isIdSelector && foundEl) {
            warn(
              `The selector "${position.el}" should be passed as "el: document.querySelector('${position.el}')" because it starts with "#".`
            )
            // return to avoid other warnings
            return
          }
        } catch (err) {
           // 场景1.2:选择器语法错误 → 警告(提示转义字符)
          warn(
            `The selector "${position.el}" is invalid. If you are using an id selector, make sure to escape it. You can find more information about escaping characters in selectors at https://mathiasbynens.be/notes/css-escapes or use CSS.escape (https://developer.mozilla.org/en-US/docs/Web/API/CSS/escape).`
          )
          // return to avoid other warnings
          return
        }
      }
    }

    // 查找目标 DOM 元素
    const el =
      typeof positionEl === 'string'
        ? isIdSelector
          ? document.getElementById(positionEl.slice(1)) // ID 选择器:直接用 getElementById
          : document.querySelector(positionEl)  // 其他选择器:用 querySelector
        : positionEl // 非字符串:直接使用传入的 HTMLElement

    // 元素不存在 → 开发环境警告并返回
    if (!el) {
      __DEV__ &&
        warn(
          `Couldn't find element using selector "${position.el}" returned by scrollBehavior.`
        )
      return
    }
    // 计算元素的滚动坐标
    scrollToOptions = getElementPosition(el, position)

    // 坐标型(直接使用)
  } else {
    scrollToOptions = position
  }

  // 浏览器支持平滑滚动(scrollBehavior API)
  // 判断浏览器是否支持 window.scrollTo 的配置项(如 { behavior: 'smooth' })
  if ('scrollBehavior' in document.documentElement.style)
    window.scrollTo(scrollToOptions)

  // 不支持平滑滚动 → 降级使用基础 scrollTo
  else {
    window.scrollTo(
      scrollToOptions.left != null ? scrollToOptions.left : window.scrollX,
      scrollToOptions.top != null ? scrollToOptions.top : window.scrollY
    )
  }
}

finalizeNavigation

  /**
   * - Cleans up any navigation guards
   * - Changes the url if necessary
   * - Calls the scrollBehavior
   */
  /**
   * 导航最终化
   * @param toLocation 目标路由
   * @param from 当前路由
   * @param isPush 是否为 push 导航
   * @param replace 是否为 replace 导航
   * @param data 导航状态数据
   * @returns
   */
  function finalizeNavigation(
    toLocation: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    isPush: boolean,
    replace?: boolean,
    data?: HistoryState
  ): NavigationFailure | void {
    // a more recent navigation took place
    // 校验导航是否被取消(并发导航冲突)
    const error = checkCanceledNavigation(toLocation, from)
    if (error) return error

    // only consider as push if it's not the first navigation
    // 判断是否为首次导航
    const isFirstNavigation = from === START_LOCATION_NORMALIZED
    const state: Partial<HistoryState> | null = !isBrowser ? {} : history.state

    // change URL only if the user did a push/replace and if it's not the initial navigation because
    // it's just reflecting the url
    // 仅在「主动 push 跳转」时更新 URL
    if (isPush) {
      // on the initial navigation, we want to reuse the scroll position from
      // history state if it exists
      // replace 模式 或 首次导航 → 使用 replaceState 更新 URL
      if (replace || isFirstNavigation)
        routerHistory.replace(
          toLocation.fullPath,
          assign(
            {
              scroll: isFirstNavigation && state && state.scroll,
            },
            data
          )
        )
        // 普通 push 跳转 → 使用 pushState 新增历史记录
      else routerHistory.push(toLocation.fullPath, data)
    }

    // accept current navigation
    // 更新响应式的当前路由 → 触发组件重新渲染
    currentRoute.value = toLocation 
    handleScroll(toLocation, from, isPush, isFirstNavigation) // 触发滚动

    markAsReady() // 标记就绪
  }

实例方法 router.resolve

router.resolve 是 Vue Router 提供的路由地址解析 API,用于将任意格式的路由地址(字符串 / 对象 / 命名路由)解析为标准化的 RouteLocationResolved 对象。

  /**
   * 路由地址解析器
   * @param rawLocation 原始路由地址(字符串或对象)
   * @param currentLocation 当前路由状态(可选)
   * @returns 解析后的路由地址对象
   */
  function resolve(
    rawLocation: RouteLocationRaw,
    currentLocation?: RouteLocationNormalizedLoaded
  ): RouteLocationResolved {
    // const resolve: Router['resolve'] = (rawLocation: RouteLocationRaw, currentLocation) => {
    // const objectLocation = routerLocationAsObject(rawLocation)
    // we create a copy to modify it later
    currentLocation = assign({}, currentLocation || currentRoute.value)

    // 解析字符串路由地址(包含 query/hash)
    if (typeof rawLocation === 'string') {
      const locationNormalized = parseURL(
        parseQuery,
        rawLocation,
        currentLocation.path
      )
      const matchedRoute = matcher.resolve(
        { path: locationNormalized.path },
        currentLocation
      )

      const href = routerHistory.createHref(locationNormalized.fullPath)
      if (__DEV__) {
        if (href.startsWith('//'))
          warn(
            `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.`
          )
        else if (!matchedRoute.matched.length) {
          warn(`No match found for location with path "${rawLocation}"`)
        }
      }

      // locationNormalized is always a new object
      return assign(locationNormalized, matchedRoute, {
        params: decodeParams(matchedRoute.params),
        hash: decode(locationNormalized.hash),
        redirectedFrom: undefined,
        href,
      })
    }

    // 校验 rawLocation 是否为合法的路由对象(包含 path/name 至少其一)
    if (__DEV__ && !isRouteLocation(rawLocation)) {
      warn(
        `router.resolve() was passed an invalid location. This will fail in production.\n- Location:`,
        rawLocation
      )
      return resolve({})
    }

    let matcherLocation: MatcherLocationRaw

    // path could be relative in object as well
    // 解析对象路由地址(包含 path/params/query/hash)
    // 含 path 的对象路由
    if (rawLocation.path != null) {
      // 开发环境警告:path 与 params 混用(params 会被忽略)
      // path 与 params 不兼容:通过 path 跳转时,params 会被忽略(因 path 已包含参数,如 /user/1)
      if (
        __DEV__ &&
        'params' in rawLocation &&
        !('name' in rawLocation) &&
        // @ts-expect-error: the type is never
        Object.keys(rawLocation.params).length
      ) {
        warn(
          `Path "${rawLocation.path}" was passed with params but they will be ignored. Use a named route alongside params instead.`
        )
      }
      matcherLocation = assign({}, rawLocation, {
        path: parseURL(parseQuery, rawLocation.path, currentLocation.path).path,
      })

      // 解析命名路由地址(包含 name/params)
    } else {
      // remove any nullish param
      const targetParams = assign({}, rawLocation.params)
      for (const key in targetParams) {
         // 移除 null/undefined 的 params(避免匹配错误)
        if (targetParams[key] == null) {
          delete targetParams[key]
        }
      }
      // pass encoded values to the matcher, so it can produce encoded path and fullPath
      matcherLocation = assign({}, rawLocation, {
        params: encodeParams(targetParams),
      })
      // current location params are decoded, we need to encode them in case the
      // matcher merges the params
      currentLocation.params = encodeParams(currentLocation.params)
    }

    const matchedRoute = matcher.resolve(matcherLocation, currentLocation)
    const hash = rawLocation.hash || ''

    // 开发环境警告:hash 未以 # 开头
    if (__DEV__ && hash && !hash.startsWith('#')) {
      warn(
        `A \`hash\` should always start with the character "#". Replace "${hash}" with "#${hash}".`
      )
    }

    // the matcher might have merged current location params, so
    // we need to run the decoding again
    matchedRoute.params = normalizeParams(decodeParams(matchedRoute.params))

    // 生成 fullPath(合并 path/query/hash)
    const fullPath = stringifyURL(
      stringifyQuery,
      assign({}, rawLocation, {
        hash: encodeHash(hash),
        path: matchedRoute.path,
      })
    )

    const href = routerHistory.createHref(fullPath)
    if (__DEV__) {
      if (href.startsWith('//')) {
        warn(
          `Location "${rawLocation}" resolved to "${href}". A resolved location cannot start with multiple slashes.`
        )
      } else if (!matchedRoute.matched.length) {
        warn(
          `No match found for location with path "${
            rawLocation.path != null ? rawLocation.path : rawLocation
          }"`
        )
      }
    }

    return assign(
      {
        fullPath,
        // keep the hash encoded so fullPath is effectively path + encodedQuery +
        // hash
        hash,
        query:
          // if the user is using a custom query lib like qs, we might have
          // nested objects, so we keep the query as is, meaning it can contain
          // numbers at `$route.query`, but at the point, the user will have to
          // use their own type anyway.
          // https://github.com/vuejs/router/issues/328#issuecomment-649481567
          stringifyQuery === originalStringifyQuery
            ? normalizeQuery(rawLocation.query)
            : ((rawLocation.query || {}) as LocationQuery),
      },
      matchedRoute,
      {
        redirectedFrom: undefined,
        href,
      }
    )
  }
{
  path: '/dashboard',
  name: 'dashboard',
  component: () => import('@/views/dashboard/DashBoard.vue'),
  meta: {
    title: '看板',
    icon: 'dashboard',
    roles: ['admin', 'user']
  }
}

router.resolve 支持哪些输入格式?

  • 字符串格式(含绝对 / 相对路径、query/hash)。
  • 对象格式(path 模式),path 模式下传入 params 会被忽略(开发环境会警告)。
  • 对象格式(命名路由模式)。
// 解析 path 模式
console.log('router.resolve', router.resolve({
  path: '/dashboard'
}))

// 解析命名路由
console.log('router.resolve', router.resolve({
  name: 'dashboard'
}))

// 解析路径
console.log('router.resolve', router.resolve('/dashboard'))

image.png

实例方法 addRoute

  /**
   * 新增路由(支持嵌套)
   * 格式 1:addRoute(父路由名称, 子路由配置)
   * 格式 2:addRoute(路由配置)
   * @param parentOrRoute 父路由记录名或路由记录对象
   * @param route 子路由记录(可选)
   * @returns 移除路由的函数
   */
  function addRoute(
    parentOrRoute: NonNullable<RouteRecordNameGeneric> | RouteRecordRaw,
    route?: RouteRecordRaw
  ) {
    let parent: Parameters<(typeof matcher)['addRoute']>[1] | undefined
    let record: RouteRecordRaw

    // 判断第一个参数是否为「路由名称」(而非路由配置对象)
    if (isRouteName(parentOrRoute)) {
      // 根据路由名称从底层匹配器中获取对应的「路由记录匹配器」
      parent = matcher.getRecordMatcher(parentOrRoute)
      if (__DEV__ && !parent) {
        warn(
          `Parent route "${String(parentOrRoute)}" not found when adding child route`,
          route
        )
      }
      record = route!
    } else {
      record = parentOrRoute
    }

    return matcher.addRoute(record, parent)
  }

实例方法 removeRoute

  /**
   * 删除路由(根据路由记录名)
   * @param name 路由记录名称
   */
  function removeRoute(name: NonNullable<RouteRecordNameGeneric>) {
    const recordMatcher = matcher.getRecordMatcher(name)
    if (recordMatcher) {
      matcher.removeRoute(recordMatcher)
    } else if (__DEV__) {
      warn(`Cannot remove non-existent route "${String(name)}"`)
    }
  }

实例方法 getRoutes

  /**
   * 获取所有路由记录
   * @returns
   */
  function getRoutes() {
    return matcher.getRoutes().map(routeMatcher => routeMatcher.record)
  }

实例方法 hasRoute

  /**
   * 判断路由是否存在
   * @param name
   * @returns
   */
  function hasRoute(name: NonNullable<RouteRecordNameGeneric>): boolean {
    return !!matcher.getRecordMatcher(name)
  }

vue-router 是如何安装的?

router 实例的 install 是一个函数,vue 利用 vue 实例 app app.use(router) 引入 vue-router 。

image.png

vue-router 全局路由守卫有哪些?

image.png

beforeEach(guard: NavigationGuardWithThis<undefined>): () => void
beforeResolve(guard: _NavigationGuardResolved): () => void
afterEach(guard: NavigationHookAfter): () => void

v5 版本,已废弃 next() 写法,建议使用 return 返回替代。

// 已废弃写法
// 全局前置守卫
router.beforeEach((to, from, next) => {
  console.log('router.beforeEach-to', to)
  console.log('router.beforeEach-from', from)
  next()
})

// 全局解析守卫
router.beforeResolve((to, from, next) => {
  console.log('router.beforeResolve-to', to)
  console.log('router.beforeResolve-from', from)
  next()
})

image.png

// 建议写法
// 全局前置守卫
router.beforeEach((to, from) => {
  console.log('router.beforeEach-to', to)
  console.log('router.beforeEach-from', from)
  return true
})

// 全局解析守卫
router.beforeResolve((to, from) => {
  console.log('router.beforeResolve-to', to)
  console.log('router.beforeResolve-from', from)
  return true
})
export interface NavigationGuardWithThis<T> {
  (
    this: T,
    to: RouteLocationNormalized, // 目标路由对象
    from: RouteLocationNormalizedLoaded, // 来源路由对象
    /**
     * @deprecated Return a value from the guard instead of calling `next(value)`.
     * The callback will be removed in a future version of Vue Router.
     * 未来版本将移除对 `next(value)` 的调用,建议直接返回值。
     */
    next: NavigationGuardNext // 导航守卫回调函数
  ): _Awaitable<NavigationGuardReturn>
}

export interface _NavigationGuardResolved {
  (
    this: undefined,
    to: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    /**
     * @deprecated Return a value from the guard instead of calling `next(value)`.
     * The callback will be removed in a future version of Vue Router.
     */
    next: NavigationGuardNext
  ): _Awaitable<NavigationGuardReturn>
}

export interface NavigationHookAfter {
  (
    to: RouteLocationNormalizedLoaded,
    from: RouteLocationNormalizedLoaded,
    failure?: NavigationFailure | void
  ): unknown
}

router.push 接收参数的 3 种方式

/**
 * Route location that can be passed to `router.push()` and other user-facing APIs.
 */
export type RouteLocationRaw<Name extends keyof RouteMap = keyof RouteMap> =
  RouteMapGeneric extends RouteMap
    ?
        | RouteLocationAsString // 字符串路径(如 "/home")
        | RouteLocationAsRelativeGeneric // 命名路由泛型对象(如 { name: 'Home' })
        | RouteLocationAsPathGeneric  // 路径对象泛型(如 { path: '/home' })
    : // 强类型约束(开启 TS 强校验)
        | _LiteralUnion<RouteLocationAsStringTypedList<RouteMap>[Name], string>
        | RouteLocationAsRelativeTypedList<RouteMap>[Name]
        | RouteLocationAsPathTypedList<RouteMap>[Name]
const handleClick = () => {
  // 命名路由
  router.push({
    name: "user-list",
  });
};

const handleClick2 = () => {
  // 对象路由(path模式)
  router.push({
    path: "/user/123",
  });
};

const handleClick3 = () => {
  // 字符路由
  router.push("/data-view");
};

最后

  1. 源码阅读:github.com/hannah-lin-…

《Vue3 watch详情:deep/immediate/flush/once 全用法 + 踩坑总结》

本文全面解析 Vue3 watch 所有用法,包含监听基础类型、引用类型、多个数据源、停止监听、深度监听、新旧值获取、与 watchEffect 区别,适合前端开发日常使用与面试准备。

《Vue3 watch详情:deep/immediate/flush/once 全用法 + 踩坑总结》

1. API介绍

watch(WatcherSource, Callback, [WatchOptions])

type WatcherSource<T> = Ref<T> | (() => T) 

interface WatchOptions extends WatchEffectOptions {
    deep?: boolean // 默认:false 
    immediate?: boolean // 默认:false 
    flush?: string // 默认:'pre'
}

参数说明:

WatcherSource: 用于指定要侦听的响应式数据源。侦听器数据源可以是返回值的 getter 函数,可以直接 是 ref reactive

callback : 执行的回调函数,可依次接受 newValue , oldValue 作为参数。

watchOptions: deep immediate flush once(3.4新增) 可选

  • 当需要对响应式对象进行深度监听时,设置 deep: true

  • 默认情况下watch是惰性的,当我们设置 immediate: true 时,watch会在初始化时立即执行回调函数

  • flush 选项可以更好地控制回调的时间。它可设置为 pre、post 或 sync

    • 默认值是 pre,指定的回调应该在DOM渲染前被调用。
    • post 值是可以用来将回调推迟到DOM渲染之后的。如果回调需要通过 $refs 访问更新的 DOM 或子组件,那么则使用该值。
    • 如果 flush 被设置为 sync,一旦值发生了变化,回调将被同步调用(少用,影响性能)。
  • once: true : 一次性侦听器;只生效一次(3.4新增参数)

WatchSource必须是引用对象;因此它的写法有两种;

  • 如果是响应式的引用对象,如ref,reactive; 直接写变量名即可;
  • 如果是基础数据,需要使用getter函数;

getter函数的使用除了上面的情况还有一个就是获取引用对象新旧值的时候会用到;

2. 侦听单个数据源及停止侦听

<script setup>
  import { watch, ref, reactive } from 'vue'
  // 侦听一个 getter
  const person = reactive({name: '小松菜奈'})
  watch(
    () => person.name,
    (value, oldValue) => {
      console.log(value, oldValue)
    }, {immediate:true}
  )
  person.name = '有村架纯'

  // 直接侦听ref  停止侦听
  const ageRef = ref(16)
  const stopAgeWatcher = watch(ageRef, (value, oldValue) => {
    console.log(value, oldValue)
    if (value > 18) {
      stopAgeWatcher() // 当ageRef大于18,停止侦听
    }
  })

  const changeAge = () => {
    ageRef.value += 1
  }
</script>

现象

配置了immediate:truewatch,在初始化时触发了一次watch的回调。我们连续点击增加年龄,当年龄 的当前值大于18时,watch停止了侦听。

结论

侦听器数据源可以是返回值的 getter 函数,也可以直接是 refwatch函数是有返回值的,返回值是停止器,然后通 过执行停止器() 函数来停止侦听。

3. 监听多个数据源

<script setup>
  import {ref, watch, nextTick} from 'vue'

  const name = ref('小松菜奈')
  const age = ref(25)

  watch([name, age], ([name, age], [prevName, prevAge]) => {
    console.log('newName', name, 'oldName', prevName)
    console.log('newAge', age, 'oldAge', prevAge)
  })

  // 如果你在同一个函数里同时改变这些被侦听的来源,侦听器只会执行一次
  const change1 = () => {
    name.value = '有村架纯'
    age.value += 2
  }

  // 用 nextTick 等待侦听器在下一步改变之前运行,侦听器执行了两次
  const change2 = async () => {
    name.value = '新垣结衣'
    await nextTick()
    age.value += 2
  }
</script>

现象

以上,当我们在同一个函数里同时改变nameage两个侦听源,watch的回调函数只触发了一次;当我们 在nameage的改变之间增加了一个nextTickwatch回调函数触发了两次。

结论

我们可以通过watch侦听多个数据源的变化。如果在同一个函数里同时改变这些被侦听的来源,侦听器只会 执行一次。若要使侦听器执行多次,我们可以利用 nextTick ,等待侦听器在下一步改变之前运行。

4. 侦听引用对象

<template>
  <div>
    <div>ref定义数组:{{arrayRef}}</div>
    <div>reactive定义数组:{{arrayReactive}}</div>
  </div>
  <div>
    <button @click="changeArrayRef">改变ref定义数组第一项</button>
    <button @click="changeArrayReactive">改变reactive定义数组第一项</button>
  </div>
</template>

<script setup>
  import {ref, reactive, watch} from 'vue'

  const arrayRef = ref([1, 2, 3, 4])
  const arrayReactive = reactive([1, 2, 3, 4])

  // ref not deep, 不能深度侦听
  const arrayRefWatch = watch(arrayRef, (newValue, oldValue) => {
    console.log('newArrayRefWatch', newValue, 'oldArrayRefWatch', oldValue)
  })

  // ref deep, 深度侦听,新旧值一样
  const arrayRefDeepWatch = watch(arrayRef, (newValue, oldValue) => {
    console.log('newArrayRefDeepWatch', newValue, 'oldArrayRefDeepWatch', oldValue)
  }, {deep: true})

  // ref deep, getter形式 , 新旧值不一样
  const arrayRefDeepGetterWatch = watch(() => [...arrayRef.value], (newValue, oldValue) => {
    console.log('newArrayRefDeepGetterWatch', newValue, 'oldArrayRefDeepGetterWatch', oldValue)
  })

  // reactive,默认深度监听,可以不设置deep:true, 新旧值一样
  const arrayReactiveWatch = watch(arrayReactive, (newValue, oldValue) => {
    console.log('newArrayReactiveWatch', newValue, 'oldArrayReactiveWatch', oldValue)
  })

  // reactive,getter形式 , 新旧值不一样
  const arrayReactiveGetterWatch = watch(() => [...arrayReactive], (newValue, oldValue) => {
    console.log('newArrayReactiveFuncWatch', newValue, 'oldArrayReactiveFuncWatch', oldValue)
  })

  const changeArrayRef = () => {
    arrayRef.value[0] = 3
  }
  const changeArrayReactive = () => {
    arrayReactive[0] = 6
  }
</script>

现象

  • 当将引用对象采用ref形式定义时,如果不加上deep:true watch侦听不到值的变化的;而加 deep:truewatch可以侦听到数据的变化,但是当前值和先前值一样,即不能获取旧值。
  • 当将引用对象采用 reactive形式定义时,不作任何处理,watch可以侦听到数据的变化,但是当前值和旧值一样。
  • 两种定义下,把watch的数据源写成getter函数的形式并进行深拷贝返回,可以在watch回调中同时获得当前值和旧值。
    const objReactive = reactive({user: {name: 'aa', age: '18'}, brand: 'Channel'});
    
    /** 对象深度监听的最佳实践- reactive且源采用函数式返回,返回深拷贝后的数据 */
    watch(() => _.cloneDeep(objReactive), (newVal, oldVal) => {
      console.log('newVal', newVal);
      console.log('oldVal', oldVal);
    })

结论: 当我们使用watch侦听引用对象时

  • 若使用ref定义的引用对象:
    • 只要获取当前值,watch第一个参数直接写成数据源,另外需要加上deep:true选项
    • 若要获取当前值和旧值,需要把数据源写成getter函数的形式,并且需对数据源进行深拷贝
  • 若使用 reactive定义的引用对象:
    • 只要获取当前值,watch第一个参数直接写成数据源,可以不加deep:true选项
    • 若要获取当前值和旧值,需要把数据源写成getter函数的形式,并且需对数据源进行深拷贝

5. watchEffect

watchEffect(callback, options): 只有两个参数,第一个是回调函数,第二个是配置项,配置项参数与watch一样;

watchEffect会立即执行,不像watch是惰性的;当然也可以通过watch配置项加{immediate: true}实现;

const number = reactive({ count: 0 });
const countAdd = () => {
  number.count++;
};
watchEffect(()=>{
  console.log("新的值:", number.count);
})

TIP

watchEffect 仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

以便dom更新之后运行watchEffect, 有个简单写法

watchPostEffect(() => {
  /* 在 Vue 更新后执行 */
})

6. watch, watchEffect的区别

  1. watchwatchEffect 都能监听响应式数据的变化,不同的是它们监听数据变化的方式不同。
  2. watch 会明确监听某一个响应数据,而 watchEffect则是隐式的监听回调函数中响应数据。
  3. watch 在响应数据初始化时是不会执行回调函数的,watchEffect 在响应数据初始化时就会立即执行回调函数。

7. FAQ

通常来说,我们的一个组件被销毁或者卸载后,监听器也会跟着被停止,并不需要我们手动去关闭监听器。但是总是有一些特殊情况,即使组件卸载了,但是监听器依然存在,这个时候其实式需要我们手动关闭它的,否则容易造成内存泄漏。

比如下面这中写法,我们就需要手动停止监听器:

<script setup>
import { watchEffect } from 'vue'
// 它会自动停止
watchEffect(() => {})
// ...这个则不会!
setTimeout(() => {
  watchEffect(() => {})
}, 100)
</script>

上段代码中我们采用异步的方式创建了一个监听器,这个时候监听器没有与当前组件绑定,所以即使组件销毁了,监听器依然存在。

关闭方法很简单,代码如下:

const unwatch = watchEffect(() => {})
// ...当该侦听器不再需要时
unwatch()

感谢您抽出宝贵的时间观看本文;本文是 Vue3 核心 API 系列的第 1 篇,后续会持续更新 computed、ref/reactive、生命周期等实战内容,同时正在整理「Vue3 完整项目实战小册」(包含从 0 到 1 开发小程序 / 管理系统的全流程),欢迎关注~

Flutter InkWell与GestureDetector

Flutter 中点击与手势:从 InkWell、GestureDetector 到事件机制

前言

在 Flutter 里做「可点区域」「手势识别」时,最先接触的往往是 GestureDetectorMaterial 体系下的 InkWell。二者都能响应点击,但语义、视觉效果和命中区域并不相同;再往下还有 ListenerMouseRegionRawGestureDetector 等。理清「用什么组件」和「事件怎么从屏幕传到回调」,能少踩很多坑(例如:点了没反应、水波纹不出现、和滚动冲突、透明区域也能点等)。


一、InkWell 与 GestureDetector:该用谁?

1. GestureDetector:纯手势识别,不负责 Material 反馈

GestureDetector 本质是**手势竞技场(Gesture Arena)**的封装:把指针序列识别成 onTaponLongPressonPanUpdate 等,然后调你的回调。

特点简要归纳:

维度 说明
视觉反馈 没有 Material 水波纹 / 高亮,除非你自己DecoratedBox 或在外层再套 Material
子组件 通常包在非空 child 上;child 没有尺寸时可能无法命中(见后文「命中测试」)
手势种类 onTaponDoubleTaponLongPressonVerticalDrag*onHorizontalDrag*onScale* 等,较全
与滚动 列表里横向滑动手势容易和 Vertical scroll 抢手势,需 behavior 或改用手势组合策略

典型写法:


GestureDetector(

onTap: () {},

child: Text('点我'),

)

2. InkWell:在 Material 上提供「水波纹」式点击反馈

InkWell 必须放在 Material(或带 Material 祖先,如 CardMaterial)里,否则水波纹不显示或行为异常。

特点简要归纳:

维度 说明
视觉反馈 点击有 splash(水波纹)、可配 highlightColor
形状 常用 borderRadius + InkWellborderRadius子组件圆角一致,否则波纹会「方角溢出」
子组件 同样依赖子树的布局尺寸;无 child 时需配合 SizedBox.expand
手势 以点击类为主(onTaponLongPress 等),不如 GestureDetector 的拖动手势全

典型写法:


Material(

color: Colors.white,

borderRadius: BorderRadius.circular(8),

child: InkWell(

borderRadius: BorderRadius.circular(8),

onTap: () {},

child: Padding(

padding: const EdgeInsets.all(16),

child: Text('带波纹的按钮'),

),

),

)

选型小结:

  • Material 点击反馈InkWell(或 InkResponseIconButton 等)。

  • 只要 回调、不要波纹,或要 复杂拖动手势GestureDetector(或 Listener + 自处理)。


二、容易混淆的「兄弟组件」

1. InkResponse

与 InkWell 类似,但可更细调水波纹形状(如圆形),适合图标按钮外层。

2. Listener

底层指针事件onPointerDown / onPointerMove / onPointerUp不参与手势竞技场语义封装,适合:

  • 只要原始指针、不要「点一下」语义;

  • 和手势系统解耦(例如自定义绘制、调试命中区域)。

注意:Listenerbehavior 同样影响命中;要独占事件需配合 HitTestBehavior

3. MouseRegion / Hover

桌面 / Web 上悬停、光标样式(cursor),和移动端「点击」互补。

4. AbsorbPointer / IgnorePointer

  • AbsorbPointer:子树吸收事件,下层兄弟收不到。

  • IgnorePointer:子树不参与命中,事件穿透到下层(只读蒙层误用会导致「点透」)。

5. MergeSemantics / ExcludeSemantics

无障碍与语义树相关,影响读屏,与「可点区域」产品语义常一起考虑。

6. RawGestureDetector

需要自定义 GestureRecognizer、多 recognizer 精细组合时使用,一般业务少用。


三、细节:为什么「点了没反应」?

1. HitTestBehavior(GestureDetector / Listener)

子组件没有尺寸(如空的 Container 无宽高)时,命中区域可能为 0。可设:


GestureDetector(

behavior: HitTestBehavior.opaque, // 或 translucent

onTap: () {},

child: Container(color: Colors.red, width: 16, height: 16),

)

取值 含义
deferToChild 默认;只在 child 报告命中的区域响应
opaque 整块区域参与命中,挡住下面,适合蒙层拦截
translucent 参与命中,可与下层同时参与部分命中测试(具体仍受竞技场影响)

2. 子组件超出父布局的命中区

若子控件用负 margin / 负 Positioned 画出父布局外,父级未扩大时,触点可能落在父级 HitTest 范围外,表现为「点了没反应」。修复:扩大父级可点区域(如更大的 SizedBox)或把按钮移回命中区内。

3. 与 ScrollView 的手势冲突

竖向 ListView 里若 onTap 不触发,常见原因是拖动手势在竞技场中胜出。可尝试:缩小识别区域、用 Listener、或调整 ScrollPhysics / ScrollBehavior

4. InkWell 与 Clip

水波纹画在 Material 的 ink layer 上,若子组件裁切不当,会出现波纹被裁掉;Material 与 InkWell 的 borderRadius 建议一致


四、事件响应机制原理(简述)

1. 命中测试(Hit Test)

手指按下后,从根节点向下做 Hit Test

  • 每个 RenderObject 根据几何判断触点是否落在自己范围内;

  • 得到一条从根到最内层可命中节点的路径。

未参与命中的节点不会收到后续指针事件。

2. 指针事件(PointerEvent)

命中路径上的节点会收到:

PointerDownEventPointerMoveEventPointerUpEvent / PointerCancelEvent

Listener 监听的就是这一层。

3. 手势识别与竞技场(Gesture Arena)

GestureDetector 内部注册 GestureRecognizer(如 TapGestureRecognizer)。多个 recognizer 可能同时收到同一串指针,但最终通常只有一个手势胜出

  • Gesture Arena:从 PointerDown 开始角逐;例如「轻点」和「拖动」竞争,赢的回调触发,输的取消。

因此:onTap 不触发 ≠ 没点到,也可能是被别手势抢走

4. InkWell 的路径

InkWell 同样建立在手势识别之上,额外把点击反馈交给 MaterialInkController 画水波纹;因此需要 Material 祖先

5. 与经典 DOM 冒泡的差异

Flutter 不是经典 DOM 的冒泡模型,而是:

命中路径分发指针 → 手势层竞技场决出胜者。

理解这一点可解释:透明上层挡住下层、IgnorePointer 点透、以及「扩大 SizedBox 修复关闭钮不响应」等实际问题。


五、实践注意点

注意点 建议
可点区域过小 至少保证约 40×40 命中区(可用 SizedBox + Center 包小图标)
只要拦截点击 HitTestBehavior.opaque 的 GestureDetector 盖一层
列表 + 点击 优先 InkWell / ListTile,注意与滚动手势竞争
调试 debugPaintPointersEnabled、或临时加半透明背景看命中范围

六、小结

组件 典型用途 反馈 / 行为
GestureDetector 通用点击、拖动、缩放 无内置 Material 波纹;手势类型全
InkWell / InkResponse 列表项、卡片点击 水波纹;需 Material 祖先
Listener 原始指针 不经「点击」语义封装
IgnorePointer / AbsorbPointer 只读蒙层、穿透/拦截 改变是否参与命中
事件机制 命中测试 → Pointer → Recognizer → Arena 解释「点了没反应」与手势冲突

选型时先想:要不要 Material 反馈要不要复杂手势命中区域是否足够;遇到异常再从 Hit Test + Gesture Arena 两条线排查即可。

QT中自定义标题栏

QT中自定义标题栏

在 Qt 桌面开发里,自定义标题栏是一个很常见的需求。比如你想统一品牌风格、做更轻的窗口外观,或者需要让标题栏和业务界面融为一体,这时候系统默认标题栏往往就不够用了。

但真正动手之后会发现,这件事远不止“把标题栏画出来”这么简单。因为一旦把系统标题栏去掉,随之消失的还有一整套窗口能力:

  • 窗口拖动
  • 双击最大化/还原
  • 四边八角缩放
  • 最大化后的拖动恢复
  • Windows 下的系统阴影、圆角、细边框
  • 最大化时窗口位置偏移等细节问题

这篇文章结合一个实际 Qt 6 项目的实现,完整讲清楚如何在 QML + Qt + Win32/DWM 的组合下,做出一套可用、接近原生体验的自定义标题栏方案。

一、先说结论:自定义标题栏要分三层做

如果想把这件事做好,建议拆成三层:

  1. QML 负责界面
    • 标题栏布局
    • 最小化、最大化、关闭按钮
    • 鼠标 hover、图标切换
  2. Qt 负责窗口交互
    • startSystemMove()
    • startSystemResize()
    • 窗口状态切换
    • 普通状态与最大化状态之间的恢复逻辑
  3. Windows 原生层负责非客户区细节
    • 系统阴影
    • 圆角
    • 细边框
    • 非客户区裁剪
    • 最大化偏移修正

这样分层后,代码结构会清晰很多,而且不会把所有逻辑都塞进一个 QML 文件里。

二、去掉系统标题栏

第一步是把系统标题栏去掉,让窗口变成无边框窗口。

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window

Window {
    id: window

    flags: Qt.Window | Qt.FramelessWindowHint

    width: 1200
    height: 800
    minimumWidth: 800
    minimumHeight: 600
    visible: true
    title: ""

    color: "transparent"

    Rectangle {
        anchors.fill: parent
        color: "#f5f7fb"

        ColumnLayout {
            anchors.fill: parent
            spacing: 0

            WindowTitleBar {
                id: titleBar
                Layout.fillWidth: true
                title: "我是标题"
                logoSource: "qrc:/icons/logo.png"
                windowStateController: windowStateController
            }

            Item {
                Layout.fillWidth: true
                Layout.fillHeight: true
            }
        }
    }

    WindowStateController {
        id: windowStateController
        window: window
    }

    WindowResizeHandles {
        anchors.fill: parent
        windowStateController: windowStateController
    }
}

这里最关键的是:

flags: Qt.Window | Qt.FramelessWindowHint

只要加上 Qt.FramelessWindowHint,系统标题栏就没了。接下来窗口顶部就可以完全由我们自己接管。

三、实现一个自定义标题栏组件

接下来开始做标题栏 UI。这个标题栏需要具备几个最基本的能力:

  • 展示标题、Logo
  • 最小化按钮
  • 最大化 / 还原按钮
  • 关闭按钮
  • 鼠标按下时开始拖动窗口
  • 双击时最大化 / 还原

下面是一份比较完整的 WindowTitleBar.qml

import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick.Window

Item {
    id: root

    property var windowStateController
    property string title: "我是标题"
    property color backgroundColor: "#f7f8fa"
    property color borderColor: "#d9dde4"
    property color textColor: "#111827"
    property color buttonHoverColor: "#e9edf3"
    property color closeHoverColor: "#e81123"
    property alias logoSource: logo.source

    height: 40

    function toggleMaximize() {
        if (!windowStateController) {
            return;
        }
        windowStateController.toggleMaximize();
    }

    Rectangle {
        anchors.fill: parent
        color: root.backgroundColor
    }

    Rectangle {
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        height: 1
        color: root.borderColor
    }

    MouseArea {
        anchors.fill: parent
        acceptedButtons: Qt.LeftButton
        propagateComposedEvents: true

        onPressed: function(mouse) {
            if (mouse.button === Qt.LeftButton && windowStateController) {
                windowStateController.startMove(root, mouse.x, mouse.y);
            }
        }

        onDoubleClicked: function(mouse) {
            if (mouse.button === Qt.LeftButton) {
                root.toggleMaximize();
            }
        }
    }

    RowLayout {
        anchors.fill: parent
        anchors.leftMargin: 12
        spacing: 8

        Image {
            id: logo
            Layout.alignment: Qt.AlignVCenter
            Layout.preferredWidth: 18
            Layout.preferredHeight: 18
            sourceSize.width: 18
            sourceSize.height: 18
            fillMode: Image.PreserveAspectFit
            smooth: true
        }

        Text {
            Layout.alignment: Qt.AlignVCenter
            text: root.title
            color: root.textColor
            font.family: "Microsoft YaHei"
            font.pixelSize: 13
            elide: Text.ElideRight
        }

        Item {
            Layout.fillWidth: true
        }

        Rectangle {
            Layout.preferredWidth: 46
            Layout.preferredHeight: parent.height
            color: minimizeArea.containsMouse ? root.buttonHoverColor : "transparent"

            Image {
                anchors.centerIn: parent
                width: 18
                height: 18
                source: "qrc:/icons/minimize.png"
                fillMode: Image.PreserveAspectFit
            }

            MouseArea {
                id: minimizeArea
                anchors.fill: parent
                hoverEnabled: true
                onClicked: {
                    if (windowStateController) {
                        windowStateController.minimize();
                    }
                }
            }
        }

        Rectangle {
            Layout.preferredWidth: 46
            Layout.preferredHeight: parent.height
            color: maximizeArea.containsMouse ? root.buttonHoverColor : "transparent"

            Image {
                anchors.centerIn: parent
                width: 18
                height: 18
                source: windowStateController && windowStateController.maximized
                        ? "qrc:/icons/exitFullScreen.png"
                        : "qrc:/icons/fullScreen.png"
                fillMode: Image.PreserveAspectFit
            }

            MouseArea {
                id: maximizeArea
                anchors.fill: parent
                hoverEnabled: true
                onClicked: {
                    root.toggleMaximize();
                }
            }
        }

        Rectangle {
            Layout.preferredWidth: 46
            Layout.preferredHeight: parent.height
            color: closeArea.containsMouse ? root.closeHoverColor : "transparent"

            Image {
                anchors.centerIn: parent
                width: 18
                height: 18
                source: "qrc:/icons/close.png"
                fillMode: Image.PreserveAspectFit
            }

            MouseArea {
                id: closeArea
                anchors.fill: parent
                hoverEnabled: true
                onClicked: {
                    if (Window.window) {
                        Window.window.close();
                    }
                }
            }
        }
    }
}

四、拖动窗口时,尽量别自己算位移

很多人第一次做自定义标题栏,会在 MouseArea 里记录鼠标按下位置,然后自己计算 window.xwindow.y。这能跑,但体验一般,而且容易在多屏、贴边、最大化恢复时出 bug。

更稳妥的方式是直接使用 Qt 提供的系统能力:

window.startSystemMove()

这会把拖动行为交还给系统窗口管理器。吸附、贴边、拖动体验都会更接近原生。

所以在标题栏里,不应该自己移动窗口,而是交给一个统一的状态控制器处理。

五、抽一个 WindowStateController,统一管理窗口状态

无边框窗口最容易出问题的地方,就是最大化与还原之间的状态切换。

建议把这些逻辑统一放进一个 WindowStateController.qml。下面是完整实现:

import QtQuick
import QtQuick.Window

Item {
    id: root

    required property Window window
    visible: false
    width: 0
    height: 0

    property bool maximized: false
    property rect normalGeometry: Qt.rect(0, 0, 0, 0)

    function availableGeometry() {
        if (window && window.screen) {
            return window.screen.availableGeometry;
        }
        return Qt.rect(0, 0, Screen.width, Screen.height);
    }

    function rememberNormalGeometry() {
        if (!window || maximized || window.visibility === Window.Minimized || window.visibility === Window.FullScreen) {
            return;
        }

        normalGeometry = Qt.rect(window.x, window.y, window.width, window.height);
    }

    function applyGeometry(geometry) {
        if (!window) {
            return;
        }

        window.x = Math.round(geometry.x);
        window.y = Math.round(geometry.y);
        window.width = Math.round(geometry.width);
        window.height = Math.round(geometry.height);
    }

    function maximize() {
        if (!window) {
            return;
        }

        rememberNormalGeometry();
        maximized = true;
        window.showMaximized();
        applyGeometry(availableGeometry());
    }

    function restore() {
        if (!window) {
            return;
        }

        maximized = false;
        window.showNormal();
        if (normalGeometry.width > 0 && normalGeometry.height > 0) {
            applyGeometry(normalGeometry);
        }
    }

    function toggleMaximize() {
        if (maximized) {
            restore();
        } else {
            maximize();
        }
    }

    function minimize() {
        if (!window) {
            return;
        }

        window.showMinimized();
    }

    function startMove(item, mouseX, mouseY) {
        if (!window || !item) {
            return;
        }

        if (!maximized) {
            window.startSystemMove();
            return;
        }

        const globalPoint = item.mapToGlobal(mouseX, mouseY);
        const preservedWidth = window.width;
        const preservedHeight = window.height;

        maximized = false;
        window.showNormal();

        Qt.callLater(function() {
            window.width = preservedWidth;
            window.height = preservedHeight;
            window.x = Math.round(globalPoint.x - mouseX);
            window.y = Math.round(globalPoint.y - mouseY);
            window.startSystemMove();
        });
    }

    function startResize(item, mouseX, mouseY, edges) {
        if (!window || !item) {
            return;
        }

        if (!maximized) {
            window.startSystemResize(edges);
            return;
        }

        const globalPoint = item.mapToGlobal(mouseX, mouseY);
        const preservedWidth = window.width;
        const preservedHeight = window.height;

        maximized = false;
        window.showNormal();

        Qt.callLater(function() {
            window.width = preservedWidth;
            window.height = preservedHeight;

            if (edges & Qt.LeftEdge) {
                window.x = Math.round(globalPoint.x);
            } else if (edges & Qt.RightEdge) {
                window.x = Math.round(globalPoint.x - preservedWidth);
            }

            if (edges & Qt.TopEdge) {
                window.y = Math.round(globalPoint.y);
            } else if (edges & Qt.BottomEdge) {
                window.y = Math.round(globalPoint.y - preservedHeight);
            }

            window.startSystemResize(edges);
        });
    }

    Component.onCompleted: {
        rememberNormalGeometry();
    }

    Connections {
        target: window

        function onXChanged() {
            root.rememberNormalGeometry();
        }

        function onYChanged() {
            root.rememberNormalGeometry();
        }

        function onWidthChanged() {
            root.rememberNormalGeometry();
        }

        function onHeightChanged() {
            root.rememberNormalGeometry();
        }
    }
}

这段代码里有几个关键点。

1. 记录普通状态下的窗口尺寸

property rect normalGeometry: Qt.rect(0, 0, 0, 0)

最大化之前,先把正常窗口的位置和大小保存下来,还原时才能回到原来的状态。

2. 最大化后拖动,要先恢复再开始拖

这是最容易漏掉的细节。

如果窗口已经最大化,此时用户在标题栏按住往下拖,正确体验应该是:

  • 先从最大化状态恢复
  • 再让窗口跟着鼠标继续拖动

这就是 startMove() 里这段逻辑的意义:

if (!maximized) {
    window.startSystemMove();
    return;
}

而不是在最大化状态下直接调用 startSystemMove()

3. 最大化后从边缘缩放,也要先恢复

同理,最大化窗口通常不能直接以当前状态进入边缘缩放,所以这里先恢复,再启动系统缩放:

window.showNormal();
Qt.callLater(function() {
    ...
    window.startSystemResize(edges);
});

Qt.callLater() 在这里很有用,它能让窗口先完成一次状态切换,再去执行后续几何调整。

六、四边八角缩放:透明热区比手写缩放逻辑更稳

标题栏能拖了,接下来还要把缩放能力补回来。

最简单可控的做法,是在窗口四周加一层透明热区,把边和角都覆盖到。下面是一份完整的 WindowResizeHandles.qml

import QtQuick
import QtQuick.Window

Item {
    id: root

    property var windowStateController
    property int handleSize: 6
    property bool active: Window.window
                          && Window.window.visibility !== Window.FullScreen

    function startResize(edges, mouseArea, mouse) {
        if (root.active && windowStateController) {
            windowStateController.startResize(mouseArea, mouse.x, mouse.y, edges);
        }
    }

    MouseArea {
        id: topHandle
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.top: parent.top
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeVerCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.TopEdge, topHandle, mouse); }
    }

    MouseArea {
        id: bottomHandle
        anchors.left: parent.left
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeVerCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.BottomEdge, bottomHandle, mouse); }
    }

    MouseArea {
        id: leftHandle
        anchors.left: parent.left
        anchors.top: parent.top
        anchors.bottom: parent.bottom
        width: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeHorCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.LeftEdge, leftHandle, mouse); }
    }

    MouseArea {
        id: rightHandle
        anchors.right: parent.right
        anchors.top: parent.top
        anchors.bottom: parent.bottom
        width: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeHorCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.RightEdge, rightHandle, mouse); }
    }

    MouseArea {
        id: topLeftHandle
        anchors.left: parent.left
        anchors.top: parent.top
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeFDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.LeftEdge | Qt.TopEdge, topLeftHandle, mouse); }
    }

    MouseArea {
        id: topRightHandle
        anchors.right: parent.right
        anchors.top: parent.top
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeBDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.RightEdge | Qt.TopEdge, topRightHandle, mouse); }
    }

    MouseArea {
        id: bottomLeftHandle
        anchors.left: parent.left
        anchors.bottom: parent.bottom
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeBDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.LeftEdge | Qt.BottomEdge, bottomLeftHandle, mouse); }
    }

    MouseArea {
        id: bottomRightHandle
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        width: root.handleSize
        height: root.handleSize
        enabled: root.active
        cursorShape: Qt.SizeFDiagCursor
        acceptedButtons: Qt.LeftButton
        onPressed: function(mouse) { root.startResize(Qt.RightEdge | Qt.BottomEdge, bottomRightHandle, mouse); }
    }
}

这里的核心思路是:

  • 边缘和角分别放 MouseArea
  • 根据不同区域传不同的 Qt.Edge
  • 缩放时最终仍然走 window.startSystemResize(edges)

这比自己去改窗口宽高稳定得多。

七、只做到这里还不够:Windows 下往往会丢系统阴影

做到前面这些,窗口已经“能用了”,但视觉上通常还是不够像原生应用。最常见的问题是:

  • 没有系统阴影
  • 圆角丢失
  • 无边框后显得很薄、很飘
  • 最大化后可能有位置偏移

这些问题仅靠 QML 很难优雅解决,Windows 下最好下沉到原生层处理。

八、在 Windows 层接管非客户区

下面这部分是整个方案的关键。思路是:

  • 拿到 Qt 主窗口对应的 HWND
  • 自定义窗口过程
  • 拦截 WM_NCCALCSIZE
  • 让整个窗口区域作为客户区
  • 同时继续借助 DWM 提供阴影、圆角、边框

1. 自定义窗口过程

#ifdef Q_OS_WIN
#include <dwmapi.h>
#include <windows.h>

#ifndef DWMWA_BORDER_COLOR
#define DWMWA_BORDER_COLOR 34
#endif

namespace
{
    constexpr DWORD kDwmWindowCornerPreferenceAttribute = 33;
    constexpr DWORD kDwmWindowCornerPreferenceRound = 2;
    constexpr wchar_t kMainWindowOriginalWndProcProperty[] = L"WujieAgentMainWindowOriginalWndProc";
    constexpr COLORREF kThinBorderColor = RGB(0xD9, 0xDD, 0xE4);

    LRESULT CALLBACK mainWindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
    {
        const auto originalProc = reinterpret_cast<WNDPROC>(GetPropW(hwnd, kMainWindowOriginalWndProcProperty));

        switch (message)
        {
        case WM_NCCALCSIZE:
            return 0;

        case WM_NCDESTROY:
            if (originalProc)
            {
                SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(originalProc));
                RemovePropW(hwnd, kMainWindowOriginalWndProcProperty);
            }
            break;

        default:
            break;
        }

        if (originalProc)
        {
            return CallWindowProcW(originalProc, hwnd, message, wParam, lParam);
        }
        return DefWindowProcW(hwnd, message, wParam, lParam);
    }
}
#endif

WM_NCCALCSIZE 返回 0 的作用,是告诉系统:不要再给我单独计算传统标题栏和边框的客户区了,我要把整个窗口都当作内容区来用。

这样 QML 标题栏才能真正贴到窗口最上方。

2. 安装新的窗口过程

#ifdef Q_OS_WIN
void installMainWindowProc(QWindow *window)
{
    if (!window)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        return;
    }

    if (GetPropW(hwnd, kMainWindowOriginalWndProcProperty))
    {
        return;
    }

    const auto previousProc =
        reinterpret_cast<WNDPROC>(SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&mainWindowProc)));
    if (previousProc)
    {
        SetPropW(hwnd, kMainWindowOriginalWndProcProperty, reinterpret_cast<HANDLE>(previousProc));
    }
}
#endif

这里把原始 WndProc 存在窗口属性里,窗口销毁时再恢复,避免资源泄漏和消息链断裂。

九、补回系统阴影、圆角和细边框

仅仅接管 WM_NCCALCSIZE 还不够,还要主动调用 DWM 接口恢复视觉效果。

#ifdef Q_OS_WIN
void enableSystemShadow(QWindow *window)
{
    if (!window)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        return;
    }

    BOOL compositionEnabled = FALSE;
    if (FAILED(DwmIsCompositionEnabled(&compositionEnabled)) || !compositionEnabled)
    {
        return;
    }

    const DWMNCRENDERINGPOLICY policy = DWMNCRP_ENABLED;
    DwmSetWindowAttribute(hwnd, DWMWA_NCRENDERING_POLICY, &policy, sizeof(policy));

    const COLORREF borderColor = kThinBorderColor;
    DwmSetWindowAttribute(hwnd, DWMWA_BORDER_COLOR, &borderColor, sizeof(borderColor));

    const DWORD cornerPreference = kDwmWindowCornerPreferenceRound;
    DwmSetWindowAttribute(hwnd,
                          kDwmWindowCornerPreferenceAttribute,
                          &cornerPreference,
                          sizeof(cornerPreference));

    const MARGINS margins{1, 1, 1, 1};
    DwmExtendFrameIntoClientArea(hwnd, &margins);

    SetWindowPos(hwnd,
                 nullptr,
                 0,
                 0,
                 0,
                 0,
                 SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
#endif

这段代码分别做了几件事:

  • 启用非客户区渲染
  • 设置一圈很薄的边框颜色
  • 指定圆角风格
  • 把 DWM frame 延伸进客户区
  • 通知系统刷新窗口 frame

其中这一句非常关键:

const MARGINS margins{1, 1, 1, 1};
DwmExtendFrameIntoClientArea(hwnd, &margins);

它能帮助系统继续在窗口边缘绘制阴影和相关效果。没有它时,无边框窗口经常显得“糊在屏幕上”。

十、隐藏系统标题和图标,避免残留

有时候即便你已经用了无边框窗口,Windows 某些状态下仍可能残留标题、图标或系统边框表现。可以额外做一层清理:

#ifdef Q_OS_WIN
void hideWindowsCaptionIconAndTitle(QWindow *window)
{
    if (!window)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        return;
    }

    const LONG exStyle = GetWindowLongW(hwnd, GWL_EXSTYLE);
    SetWindowLongW(hwnd, GWL_EXSTYLE, exStyle | WS_EX_DLGMODALFRAME);

    SendMessageW(hwnd, WM_SETICON, ICON_SMALL, 0);
    SendMessageW(hwnd, WM_SETICON, ICON_BIG, 0);
    SetWindowTextW(hwnd, L"");

    SetWindowPos(hwnd,
                 nullptr,
                 0,
                 0,
                 0,
                 0,
                 SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED);
}
#endif

这一步的目标不是“美化”,而是避免系统残留元素干扰我们自己的标题栏。

十一、修正最大化后的窗口偏移

无边框窗口在 Windows 上有一个很常见的问题:最大化之后,窗口左上角可能和工作区不完全对齐,出现 1~几像素的偏移。

可以在窗口可见性变化后做一次修正:

#ifdef Q_OS_WIN
void correctMaximizedWindowOffset(QWindow *window)
{
    if (!window || window->visibility() != QWindow::Maximized)
    {
        return;
    }

    QScreen *screen = window->screen();
    if (!screen)
    {
        return;
    }

    const QRect availableGeometry = screen->availableGeometry();
    const QPoint expectedTopLeft = availableGeometry.topLeft();

    if (window->position() == expectedTopLeft)
    {
        return;
    }

    const HWND hwnd = reinterpret_cast<HWND>(window->winId());
    if (!hwnd)
    {
        window->setPosition(expectedTopLeft);
        return;
    }

    SetWindowPos(hwnd,
                 nullptr,
                 expectedTopLeft.x(),
                 expectedTopLeft.y(),
                 0,
                 0,
                 SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
}
#endif

这段代码的思路很直接:

  • 读取当前屏幕的 availableGeometry
  • 取它的左上角作为最大化后应有的位置
  • 如果窗口没有对齐,就用 SetWindowPos 修正

十二、在应用启动时统一应用这些 tweak

最后,把这些原生能力统一挂到主窗口上。比如在 QQmlApplicationEngine 加载完成后执行:

void applyMainWindowTweaks(QQmlApplicationEngine &engine)
{
#ifdef Q_OS_WIN
    if (engine.rootObjects().isEmpty())
    {
        return;
    }

    auto *mainWindow = qobject_cast<QWindow *>(engine.rootObjects().constFirst());
    if (!mainWindow)
    {
        return;
    }

    mainWindow->setTitle(QString());
    installMainWindowProc(mainWindow);
    hideWindowsCaptionIconAndTitle(mainWindow);
    enableSystemShadow(mainWindow);

    QObject::connect(mainWindow, &QWindow::visibilityChanged, mainWindow, [mainWindow]() {
        if (mainWindow->isVisible())
        {
            hideWindowsCaptionIconAndTitle(mainWindow);

            if (mainWindow->visibility() == QWindow::Windowed)
            {
                enableSystemShadow(mainWindow);
            }

            QTimer::singleShot(0, mainWindow, [mainWindow]() {
                correctMaximizedWindowOffset(mainWindow);
            });
        }
    });

    QObject::connect(mainWindow, &QWindow::windowTitleChanged, mainWindow, [mainWindow](const QString &title) {
        if (!title.isEmpty())
        {
            mainWindow->setTitle(QString());
            hideWindowsCaptionIconAndTitle(mainWindow);

            if (mainWindow->visibility() == QWindow::Windowed)
            {
                enableSystemShadow(mainWindow);
            }
        }
    });
#else
    Q_UNUSED(engine);
#endif
}

这里有两个处理很有必要:

  • visibilityChanged 后重新应用阴影和标题栏隐藏逻辑
  • windowTitleChanged 后强制把系统标题清掉

因为某些状态切换后,Windows 可能会重新套回部分非客户区表现,不补这一层很容易出现“偶发性异常”。

十三、这套方案为什么比“纯 QML 手搓窗口管理”更稳

很多实现方案都会走到一个误区:既然标题栏自己画了,那拖动、缩放、最大化逻辑也都自己算。

理论上可行,实际上问题很多:

  • 鼠标位移要自己维护
  • 多显示器切换容易错
  • 最大化恢复点不好算
  • 贴边、吸附、系统手势体验差
  • Windows 阴影和边框很难补完整

而这套方案的核心原则是:

外观自己画,窗口行为尽量交给系统。

也就是:

  • UI 用 QML 自定义
  • 窗口拖拽用 startSystemMove()
  • 窗口缩放用 startSystemResize()
  • 阴影圆角边框交给 DWM
  • 自己只补系统默认标题栏拿走后丢失的那一层胶水逻辑

这样代码量不一定最少,但稳定性和体验会明显更好。

十四、落地时最容易踩的几个坑

1. 只去掉标题栏,不补缩放热区

结果就是窗口看起来没问题,但用户拉不动边缘。

2. 最大化后直接 startSystemMove()

这样拖动标题栏时会出现跳动,或者不能正确恢复到普通窗口状态。

3. 自己算拖拽位移,不用系统拖动

短期看能用,长期看问题最多,尤其是多屏和贴边。

4. 只写 QML,不处理 Win32/DWM

这会导致窗口没有阴影、圆角异常、边框层次不足。

5. 最大化后不修正 offset

某些机器上会出现窗口边缘错位,视觉上很明显。

十五、总结

Qt 自定义标题栏真正难的地方,从来不是“把按钮画出来”,而是把系统标题栏拿掉以后,如何把窗口该有的行为和质感补回来。

比较稳的一条路线是:

  • Qt.FramelessWindowHint 去掉系统标题栏
  • 用 QML 重建顶部标题栏 UI
  • startSystemMove()startSystemResize() 保留原生交互
  • 用独立控制器管理最大化、还原和普通窗口几何
  • 在 Windows 层通过 WM_NCCALCSIZE + DWM 补回阴影、圆角、细边框和位置修正

如果只是做一个“看起来像标题栏”的 UI,这件事并不难;但如果目标是做出一个真正可用、体验接近原生的桌面窗口,这些细节基本都绕不过去。

用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

用 TypeScript 的 infer 搓一个类型安全的深层路径访问工具

你写过 get(obj, 'a.b.c') 吗?

Lodash 的 _.get 应该是前端用得最多的工具函数之一了。好用是好用,但它返回的类型是 any。你传个 'a.b.c',TypeScript 完全不知道这条路径是不是真的存在,更不知道取出来的值是什么类型(这个说法其实不太严谨)。

import _ from 'lodash'

const config = {
  db: {
    host: 'localhost',
    port: 5432,
    pool: { max: 10, min: 2 }
  }
}

const host = _.get(config, 'db.host')
// host 的类型:any
// 你拼错成 'db.hoost' 也不会报错,运行时才炸

const max = _.get(config, 'db.pool.max')
// max 的类型:还是 any
// 你把它当 string 用,TypeScript 不拦你

这事困扰了我挺久。后来 TypeScript 4.1 加了模板字面量类型,再配合 infer 和递归条件类型,终于可以让路径访问变得类型安全了。

这篇就聊聊怎么一步步把这个工具类型搓出来。

约束路径:只允许合法路径

这一步是整个方案里最有意思的部分。要生成一个对象所有合法路径的联合类型。

type AllPaths<T, Prefix extends string = ''> =
  T extends object
    ? {
        [K in keyof T & string]:
          | `${Prefix}${K}`                              // 当前层的 key
          | AllPaths<T[K], `${Prefix}${K}.`>             // 递归下一层
      }[keyof T & string]
    : never

type ConfigPaths = AllPaths<Config>
// 'db' | 'db.host' | 'db.port' | 'db.pool' | 'db.pool.max' | 'db.pool.min'
// | 'redis' | 'redis.host' | 'redis.ttl'

这个类型做的事情:遍历对象的每一层,把所有可能的路径拼成字符串字面量的联合类型。

现在改造一下 deepGet

function deepGet<T, P extends AllPaths<T> & string>(
  obj: T,
  path: P
): DeepGet<T, P> {
  return path.split('.').reduce((acc: any, key) => acc?.[key], obj) as any
}

deepGet(config, 'db.host')       //  正常
deepGet(config, 'db.pool.max')   //  正常
deepGet(config, 'db.hoost')      //  编译报错!'db.hoost' 不在合法路径里

写错路径直接标红。编辑器自动补全也能用了——输入 'db.' 会提示 hostportpool

这体验比 Lodash 的 _.get 好太多了。

处理数组和可选属性

上面的版本遇到数组就歇菜了。真实业务里对象嵌数组太常见了,得处理。

type Config2 = {
  servers: Array<{
    host: string
    port: number
    tags: string[]
  }>
  metadata?: {
    version: string
  }
}

数组怎么办?一般有两种思路:

思路一:用 [number] 语法表示数组索引

路径写成 'servers.[number].host',类型层面识别 [number] 并取数组元素类型。

type DeepGetV2<T, P extends string> =
  P extends `${infer Key}.${infer Rest}`
    ? Key extends keyof T
      ? DeepGetV2<T[Key], Rest>
      : Key extends `[number]`                    // 命中 [number]
        ? T extends Array<infer Item>             // T 是数组吗?
          ? DeepGetV2<Item, Rest>                 // 是 → 取元素类型继续递归
          : never
        : never
    : P extends keyof T
      ? T[P]
      : P extends `[number]`
        ? T extends Array<infer Item> ? Item : never
        : never

思路二:自动穿透数组

遇到数组类型自动取元素,路径里不用写 [number]。路径写 'servers.host' 就能拿到 string

我个人更倾向思路一。虽然写起来啰嗦点,但语义更明确——你一眼就知道这里穿过了一个数组。思路二在类型层面倒是简洁,但读代码的人可能会困惑:servers 明明是个数组,怎么直接 .host 了?

可选属性的处理相对简单,DeepGet 递归下去自然会带上 undefined

type Config3 = {
  metadata?: { version: string }
}

type V = DeepGet<Config3, 'metadata.version'>
// string | undefined (因为 metadata 可能不存在)

这里 TypeScript 的行为其实符合直觉,不用额外处理。

AllPaths 的性能问题

AllPaths 有个坑:对象属性越多、嵌套越深,生成的联合类型就越庞大。

假设一个对象每层 10 个属性,嵌套 4 层。AllPaths 生成的路径数量大概是 10 + 10×10 + 10×10×10 + 10×10×10×10 ≈ 11110 个字符串字面量。TypeScript 编译器处理这么大的联合类型,编辑器会明显卡顿。

之前在一个项目里给一个比较大的配置对象加了 AllPaths 约束——先别急着反驳,VSCode 的 TS Server 直接转圈了好几秒。后来只好妥协,只对核心配置做路径约束,其他的还是用 string

几个缓解思路:

// 1. 限制递归深度,只生成前 N 层的路径
type ShallowPaths<T, Depth extends any[] = []> =
  Depth['length'] extends 3 ? never :  // 只展开 3 层
  T extends object
    ? { [K in keyof T & string]:
        | K
        | `${K}.${ShallowPaths<T[K], [...Depth, any]>}`
      }[keyof T & string]
    : never

// 2. 拆分类型,对子树单独约束
// 不要 AllPaths<WholeConfig>,而是 AllPaths<Config['db']>
function getDbConfig<P extends AllPaths<Config['db']>>(path: P) {
  return deepGet(config.db, path)
}

说实话这块没有完美方案。类型安全和编译性能之间得做取舍。

聊到这

infer + 模板字面量类型 + 递归条件类型,这三个东西组合起来能做的事情比想象中多很多。路径访问只是其中一个典型应用。

不过也别上头。也行。类型体操写得越复杂,维护成本越高。一个新人看到五六层嵌套的条件类型,大概率直接懵。我的经验是:工具类型可以复杂,但暴露给使用者的 API 要简单。把复杂度藏在工具类型内部,让调用方只需要写 deepGet(obj, 'a.b.c') 就够了。

好吧这个问题比我想的复杂。

还有一点,TypeScript 的类型系统本身是图灵完备的,理论上啥都能算。但"能做"和"该做"是两回事。等等,其实"和"该做"是两回事。如果一个类型写了超过 20 行,先想想是不是设计上能简化。

❌