普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月30日技术

如何实现 Claude 生成式 UI?一套可落地的工程方案

作者 西陵
2026年4月30日 10:00

首发于公众号 code进化论,欢迎关注。

前言

随着大模型能力增强,单纯的纯文本/图片输出已无法满足复杂内容的展示需求。以 Claude.ai 为代表的产品,已开始支持直接输出 HTML 并进行渐进式渲染,同时渲染出的 HTML 也可支持简单交互,从而实现更丰富的结构化内容表达与更流畅的用户体验。

本篇文章会带大家探索如何通过大模型能力生成可交互的 HTML 内容,并通过前端技术实现流式渲染,从而达到与 claude.ai 相似的效果。

Claude.ai 分析

下面通过一个图表生成的例子来探索 claude.ai 是如何实现 HTML 的流式渲染。

code-1311608451.cos.ap-guangzhou.myqcloud.com/agent%E7%94…

消息协议分析

下面是抓取的 claude.ai 返回的 sse 消息。

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\"\\n<div style=\\\"padding: 1rem"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":" 0;\\\">\\n  <h2 class=\\\"sr-only\\\">2022\\u5e74\\u81f32025\\u5e74\\u4e2d\\u56fd\\u51fa"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\\u751f\\u4eba\\u53e3\\u67f1\\u72b6\\u56fe</h2>\\n  <div style=\\\"display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 1."}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"5rem;\\\">\\n    <div style=\\\"background: var(--color-background-secondary); border-radius: var(--border"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"-radius-md); padding: 1rem; flex: 1; min-width: 120px;\\\">\\n      <p style=\\\"font-size: 13"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"px; color: var(--color-text-secondary); margin: 0 0 4px;\\\">\\u6700\\u9ad8\\u5e74"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\\u4efd</p>\\n      <p style=\\\"font-size: 22px; font-weight: 500; margin: 0;\\\">2022"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"</p>\\n      <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 4px 0 0;\\\">956 \\u4e07\\u4eba"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"</p>\\n    </div>\\n    <div style=\\\"background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 1rem; flex: 1; min-width: 120px"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":";\\\">\\n      <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 0 0 4px;\\\">\\u6700\\u4f4e\\u5e74\\u4efd</p>\\n      <p style=\\\"font-size: 22px"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"; font-weight: 500; margin: 0;\\\">2025</p>\\n      <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 4px 0 0;\\\">954"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":" \\u4e07\\u4eba</p>\\n    </div>\\n    <div style=\\\"background: var(--color-background-secondary); border-radius: var(--border-radius-md); padding: 1rem; flex: 1; min-width:"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":" 120px;\\\">\\n      <p style=\\\"font-size: 13px; color: var(--color-text-secondary); margin: 0 0 4px;\\\">\\u56db\\u5e74\\u7d2f"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"\\u8ba1</p>\\n      <p style=\\\"font-size: 22px; font-weight: 500; margin: 0;\\\">3795</p>\\n      <p style=\\\"font-size: 13"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"px; color: var(--color-text-secondary); margin: 4px 0 0;\\\">\\u4e07\\u4eba</p>\\n    </div>\\n  </div>\\n\\n  <div style=\\\"display: flex; gap: 8"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"983\\u4e07\\u4eba\\u3002</canvas>\\n  </div>\\n</div>\\n\\n<script src=\\\"https://cdnjs.cloudflare.com/ajax"}}

data: {"type":"content_block_delta","index":2,"delta":{"type":"input_json_delta","partial_json":"/libs/Chart.js/4.4.1/chart.umd.js\\\"></script>\\n<script>\\n  const isD"}}

从 sse 消息可以分析出几个关键点:

  • 每条 sse 消息只输出一部分 HTML 内容,且是无规则的。
  • HTML 的内容输出顺序是 style → content HTML → script ,这也是一个完整的 HTML 文档的标准格式。

HTML 渲染机制

image.png

最终输出的内容在一个独立的 iframe 中渲染,与宿主环境完全隔离。

image.png

如果输出 HTML 的过程中突然中断对话,展示的是已输出的合法的 HTML 内容,像<p style="font-size: 13 这种内容需要过滤。

难点分析

  • HTML 渲染的样式可约束

    最终输出的 HTML 渲染出来的样式是可约束的,而不是大模型随机生成的,这样最终输出出来的 HTML 风格统一,质量稳定。因此需要输出一套大模型可理解的样式规范。

  • HTML 渲染不能影响宿主环境

    HTML 本身可能会包含样式、脚本,直接在宿主环境渲染可能会影响宿主环境。

  • HTML 如何高效的增量渲染

    因为每条 sse 消息拿到的是一小段 HTML 内容,是不完整的。如果等 HTML 内容完全输出后再渲染,等待时间会很长,整体体验也不友好,达不到预期目标。因此需要提供一套高效的增量渲染方案,在保证性能的情况下能够逐步渲染已有的内容。

  • 如何过滤掉不合法的 HTML 内容

    sse 输出的 HTML 内容不能保证是完整的,会出现 <p style="font-size: 13这种情况,在渲染时需要进行过滤。

  • 如何执行 script 脚本

    在浏览器规范中,通过 innerHTMLinsertAdjacentHTML 等基于 HTML 解析器的方式插入的 <script> 标签不会被执行,因此对于 script 脚本需要单独处理。

  • 确保 script 脚本的执行顺序

    script 脚本的执行可能存在依赖关系,比如绘制图表的脚本一定要等 Chart.js 加载完才能执行。

如何定制 HTML 生成规范?

HTML 生成规范可以参考开源项目 pi-generative-ui ,它是一个专为 pi(code agent)设计的插件,能够让 pi 像 claude.ai 一样输出具备一致设计风格的UI。下面展示了部分内容,详细提示词可参考 guidelines.ts 文件。

### Tokens
- Borders: always \`0.5px solid var(--color-border-tertiary)\` (or \`-secondary\` for emphasis)
- Corner radius: \`var(--border-radius-md)\` for most elements, \`var(--border-radius-lg)\` for cards
- Cards: white bg (\`var(--color-background-primary)\`), 0.5px border, radius-lg, padding 1rem 1.25rem
- Form elements (input, select, textarea, button, range slider) are pre-styled — write bare tags. Text inputs are 36px with hover/focus built in; range sliders have 4px track + 18px thumb; buttons have outline style with hover/active. Only add inline styles to override (e.g., different width).
- Buttons: pre-styled with transparent bg, 0.5px border-secondary, hover bg-secondary, active scale(0.98). If it triggers sendPrompt, append a ↗ arrow.
- **Round every displayed number.** JS float math leaks artifacts — \`0.1 + 0.2\` gives \`0.30000000000000004\`, \`7 * 1.1\` gives \`7.700000000000001\`. Any number that reaches the screen (slider readouts, stat card values, axis labels, data-point labels, tooltips, computed totals) must go through \`Math.round()\`, \`.toFixed(n)\`, or \`Intl.NumberFormat\`. Pick the precision that makes sense for the context — integers for counts, 1–2 decimals for percentages, \`toLocaleString()\` for currency. For range sliders, also set \`step="1"\` (or step="0.1" etc.) so the input itself emits round values.
- Spacing: use rem for vertical rhythm (1rem, 1.5rem, 2rem), px for component-internal gaps (8px, 12px, 16px)
- Box-shadows: none, except \`box-shadow: 0 0 0 Npx\` focus rings on inputs

### Metric cards
For summary numbers (revenue, count, percentage) — surface card with muted 13px label above, 24px/500 number below. \`background: var(--color-background-secondary)\`, no border, \`border-radius: var(--border-radius-md)\`, padding 1rem. Use in grids of 2-4 with \`gap: 12px\`. Distinct from raised cards (which have white bg + border).

### Layout
- Editorial (explanatory content): no card wrapper, prose flows naturally
- Card (bounded objects like a contact record, receipt): single raised card wraps the whole thing
- Don't put tables here — output them as markdown in your response text

按照 pi-generative-ui 官方的介绍,它的设计规范的提示词是完成从 claude.ai 中提取出来的,这一点作者已通过爬取 claude.ai 的源码验证过,因此如果大家想在自己的项目中应用这套提示词,这也是一个很好的衡量标准。

除此之外,在 pi-generative-ui 文档中也详细地讲解了 claude 实现生成式 UI 的详细步骤,作者下面要介绍的前端渲染方案就参考了里面的内容。

浏览器解析策略

前端在实现 HTML 流式渲染之前,需要先了解一下在浏览器中渲染一段 HTML 字符串时背后的策略,最简单的例子如下:

const tmp = document.createElement('div')
tmp.innerHTML = `
    <div style="font-size: 13px">
        HTML渲染
        <p>Hello World</p>
        <p style="font-size: 13
`

如果将一个不合法的 HTML 字符串通过 仍给浏览器,最终渲染出来的 DOM 树会是什么样的呢?

根据 WHATWG HTML 标准定义,浏览器在解析 HTML 时遵循 WHATWG HTML 标准中定义的解析算法,该算法本身是容错的。在解析过程中,标签结构会被自动补全,而语法错误的属性则会被直接忽略。因此,最终生成的 DOM 树往往是“修复后的结果”,而非原始字符串的直接映射。因此在浏览器中最终展示的 DOM 树如下:

如何实现 HTML 流式渲染?

Iframe 沙箱隔离

优势:

  • 接入非常简单,接入方使用没有任何心智负担。
  • iframe 天生具备隔离能力,无论是js、css、dom,都完全与宿主环境隔离。

缺点:

  • dom 严重割裂,弹窗只能在 iframe 内部展示,无法覆盖全局。

  • 通信困难

    iframe是独立的运行上下文,并且通常是以跨域的形式出现,与宿主通信困难体现在3点:

    • 方式困难

      仅可通过 postmessage 等方式,难以同步执行、直接调用。

    • 数据结构困难

      仅可传输Transferable Object

    • 效率低,内存限制大

      传输数据(除sharedArrayBuffer), 均需要做structuredClone。

  • 隐私限制

    对于跨域的场景, iframe 中的代码因跨域无法获取到用户隐私信息(cookie, localstory, indexDB)等。极大限制了功能实现。iframe 也难以感知到宿主环境状态。

在对话场景下 HTML 渲染出来的页面是纯展示页面,不存在复杂的交互,不需要和宿主环境通信,因此 Iframe 已经能满足场景需求。

Morphdom 增量渲染

浏览器在解析 HTML 时具备一定的容错能力,因此在一般场景下可以直接将 HTML 字符串交由解析器处理,而无需对其进行严格的预校验。在通过 SSE 获取 HTML 内容后,最直接的渲染方式是使用 innerHTML 将其插入到页面中。然而,innerHTML 在更新内容时会整体替换原有 DOM 子树,这不仅会导致已有状态(如输入框内容、滚动位置等)丢失,还可能引发明显的重绘与闪烁问题,影响用户体验,为了解决上述问题,可以引入 morphdom 进行增量渲染。

定义

morphdom 是一个轻量级的 DOM diff 库,它通过对比当前 DOM 与目标 DOM 的差异,仅对发生变化的节点进行最小化更新,从而避免整树替换带来的性能开销和状态丢失问题。与 React 等基于虚拟 DOM 的方案不同,morphdom 并不引入额外的抽象层,而是直接在真实 DOM 上执行 diff 与 patch 操作,在保持较高性能的同时简化了整体实现复杂度。

基本使用

使用 morphdom 非常简单。以下是一个基本示例,演示如何将一个 DOM 元素转换为另一个:

var morphdom = require('morphdom');
 
// 创建初始元素
var el1 = document.createElement('div');
el1.className = 'foo';
 
// 创建目标元素
var el2 = document.createElement('div');
el2.className = 'bar';
 
// 将 el1 转换为匹配 el2
morphdom(el1, el2);
 
// el1 现在拥有类 'bar'
console.log(el1.className); // 'bar'

除了传递 DOM 元素,也可以传递 HTML 字符串作为目标:

var morphdom = require('morphdom');
 
var container = document.getElementById('my-container');
container.innerHTML = '<div class="old-content">Hello World</div>';
 
// 用新内容更新容器
morphdom(container, '<div class="new-content">Hello Morphdom</div>');

这个就和当前的场景非常类似,但是这里有一个核心功能需要重点关注,当传递 HTML 字符串作为目标时,morphdom 会先调用 DOM API 将字符串转换为 DOM 元素,源码如下:

function morphdom(fromNode, toNode, options) {
    if (!options) {
      options = {};
    }

    if (typeof toNode === 'string') {
        var toNodeHtml = toNode;
        toNode = doc.createElement('html');
        toNode.innerHTML = toNodeHtml;
    }
    //...
 }

这也就是说 morphdom 也是支持传递不合规的 HTML 字符串,因为内部也会先通过浏览器的解析算法进行容错处理并转换为 DOM 元素。

手动执行 Script 脚本

在浏览器规范中,通过 innerHTMLinsertAdjacentHTML 等基于 HTML 解析器的方式插入的 <script> 标签不会被执行,因此需要等 HTML 内容生成完之后手动获取脚本并执行。

const runScripts = async (root: HTMLElement): Promise<void> => {
  const scripts = Array.from(root.querySelectorAll("script"));
  for (const old of scripts) {
    const s = root.ownerDocument.createElement("script");
    if (old.src) {
      // 外部脚本:等待加载完成再继续
      await new Promise<void>((resolve, reject) => {
        s.src = old.src;
        s.onload = () => resolve();
        s.onerror = () => reject(new Error(`Failed to load: ${old.src}`));
        old.parentNode!.replaceChild(s, old);
      });
    } else {
      s.textContent = old.textContent;
      old.parentNode!.replaceChild(s, old);
    }
  }
}

runScripts 方法里展示了 script 执行的大概流程,总结如下:

  • 获取所有 script 元素并遍历。
  • 通过 DOM API 创建并插入新的 script 元素。
  • 如果是加载外部脚本,需要等加载完毕之后再执行后面的脚本。

Demo

code-1311608451.cos.ap-guangzhou.myqcloud.com/agent%E7%94…

这个例子的 sse 数据直接用的 claude.ai 的,可以自己写个简单的 server。

成为AI全栈 - 第1课:后端到底是干嘛的?一张图拆解登录

作者 铁皮饭盒
2026年4月30日 09:58

本文目标: 先建立全局认知,再让AI写代码😁

一个常见的误解

很多人以为后端很难。

“要懂服务器、数据库、缓存、消息队列……”

“要学 Java、Python、Go……”

“要配环境、搭框架、写 SQL……”

其实,后端的核心逻辑非常简单。

今天用一张图,让你彻底理解后端是干嘛的。

图片

后端做什么?

前端发起接口请求。

后端: 接收请求  → 解析验证 →业务处理 → 数据库操作 → 返回响应

就这5步,没了。

以用户登录为例

用户在登录页面输入用户名和密码,点击“登录”。

第1步:接收请求

前端发送:POST/api/login,Body: { username: “张三”, password: “123456” }

后端路由匹配到 /api/login,交给登录处理函数。

// 监听请求
app.post('/api/login'(req, res) => {
  // 处理逻辑
})

第2步:解析验证

检查用户名和密码是否为空?格式是否正确?

如果为空 → 返回 400 “用户名和密码不能为空”,停止执行。

根据 username 去数据库查用户,用 bcrypt 比对密码。

密码错误 → 返回 401 “用户名或密码错误”。

第3步:业务处理

如果密码正确,生成 JWT Token(一个加密的字符串), 同时提取用户登录IP / 时间等。

第4步:操作数据

存储第三步数据到数据库。

第5步:返回响应

返回给前端:HTTP状态码: 200, 接口返回JSON数据

图片

前端拿到 Token,存储起来,后续请求带上它证明身份。 

nodejs + expres示例

🌟注意: 

1. 本文初期用js写后端, 帮助前端同学过渡, 后续会写py和java代码, 都是通过提示词让AI写😁

2. 下面代码大概看下就行, 后续文章会完善真读数据库, 反正都是AI直接生, 主要看提示词:基于express 生成登录接口 输入body参数(username,password), 返{"success":true,"data":{"token":"xxx","user":{"id":1,"name":"张三"}}} token要真验. 

下面是把提示词给deepseek生成的:

const express require('express');
const jwt require('jsonwebtoken');
const app express();

app.use(express.json());

// 模拟用户数据(实际应该查数据库)
const users = [
  { id: 1, username: 'zhangsan', password: '123456', name: '张三' },
  { id: 2, username: 'lisi', password: '123456', name: '李四' }
];

// 密钥(实际应放在环境变量)
const SECRET_KEY 'your-secret-key-123456';

app.post('/api/login', (req, res) => {
  const { username, password } = req.body;

  // 1. 验证用户名密码
  const user = users.find(u => u.username === username && u.password === password);

  if (!user) {
    return res.status(401).json({
      successfalse,
      error'用户名或密码错误'
    });
  }

  // 2. 生成真正的 JWT token
  const token = jwt.sign(
    { userId: user.id, username: user.username },
    SECRET_KEY,
    { expiresIn'7d' }  // 7天有效期
  );

  // 3. 返回结果
  res.json({
    successtrue,
    data: {
      token: token,
      user: {
        id: user.id,
        name: user.name
      }
    }
  });
});

app.listen(3000, () => {
  console.log('服务器已启动:http://localhost:3000');
});

核心结论

后端 = 接收请求  → 解析验证 →业务处理 → 数据库操作 → 返回响应

所有后端语言都在解决这5件事,只是语法不同。

学会用概念描述需求,AI 就能帮你生成任何语言(Bun.js / Python / Java)的代码。

后续

后端并没有那么复杂吧, 别着急, 今天先说这么多, 后续课程会继续拆解其他开发任务。

思考题

回想一下你平时调用的后端接口(比如获取用户列表、提交表单),试着用今天学的“5步法”拆解一下后端做了什么?

欢迎在评论区分享你的思考。

Openlayers调用ArcGis要素服务之一 ——要素查询 (/query)

作者 无心使然
2026年4月30日 09:22

2.1 Openlayers调用ArcGis要素服务之要素查询 (/query)

各个库版本如下:

    "ol": "^10.8.0",
    "proj4": "^2.20.8",
    "vue3-openlayers": "^12.2.2""ol-esri-style": "^4.1.1",

目录

2.1.1 介绍

要素服务是一种通过 Web 提供矢量要素数据访问和编辑功能的接口。它允许客户端(如 Web 应用、桌面软件、移动设备)对地理要素进行查询、编辑(增、删、改)、关联查询和统计分析。下面使用ArcGis官方服务作为示例直接调用(如果使用自己的私有服务,可能先要获取token)

2.1.2 核心特点

特性 说明
矢量数据服务 以要素(点、线、面)为核心,包含几何和属性信息
基于 REST API 通过 HTTP 请求访问,返回 JSON/GeoJSON 格式数据
支持编辑 支持添加、更新、删除要素(需要服务开启编辑能力)
支持查询 支持属性查询、空间查询、ID 查询、统计查询
支持关联 可关联查询关联表或附件的相关信息
事务管理 支持版本化和非版本化编辑,支持长事务

2.1.3 核心接口

操作 说明
/query 查询要素(属性、空间、统计)
/addFeatures 添加新要素
/updateFeatures 更新现有要素属性或几何
/deleteFeatures 删除要素
/applyEdits 批量提交增、删、改操作
/queryRelatedRecords 查询关联表中的记录
/queryAttachments 查询要素的附件
/addAttachment 为要素添加附件

2.1.4 服务信息查看

ArcGis官方服务4

50.png

上图中展示的就是要素服务的基本信息,可以看到Supported Operations:中有Query,说明支持查询,但是这个是服务的查询接口,进入后参数比图层的查询接口较少

51.png

图中可以看到有一个图层,进入图层查看图层信息(信息比较多,只截图了相对重要的部分)

52.png

53.png

红框内是一个唯一值渲染器,根据typdamage字段来分类,分为Inaccessible, Affected, Minor, Major, Destroyed五种具体类型(可以简单理解为图例)

如果我们使用ArcGis JS SDK加载可以直接使用FeatureLayer即可,但是如果使用Openlayers,一般还是使用图层的/query接口获取到要素的矢量信息,再使用VectorLayer渲染,至于渲染器(图例)我们可以:

  • 自定义显示
  • 使用ol-esri-style转换

54.png

可以看到Supported Operations:中还有有Add Features等操作,下文以Query为例,其他的操作同理,主要都是构造ArcGis Rest Api请求

2.1.5 Openlayers调用

自定义图例:

55.png

使用渲染器图例:

56.png

<template>
  <div class="map-page">
    <h1>OpenLayers - ArcGIS FeatureServer 调用</h1>
    <div class="info-panel">
      <h3>服务信息</h3>
      <p><strong>服务名称:</strong> CommercialDamageAssessment</p>
      <p>
        <strong>图层:</strong> Damage to Commercial Buildings (商业建筑损坏评估)
      </p>
      <p><strong>几何类型:</strong></p>
      <p>
        <strong>要素类型:</strong> Affected, Destroyed, Inaccessible, Major,
        Minor
      </p>
    </div>
    <div class="controls">
      <button @click="loadFeatures" :disabled="loading">
        {{ loading ? "加载中..." : "加载要素数据" }}
      </button>
      <button @click="clearFeatures" :disabled="loading">清除要素</button>
      <span v-if="featureCount" class="feature-count">
        已加载 {{ featureCount }} 个要素
      </span>
      <label class="toggle-switch">
        <input type="checkbox" v-model="useEsriStyle" @change="updateFeatureStyle" />
        <span class="slider"></span>
        <span class="label-text">使用 ESRI 样式</span>
      </label>
    </div>
    <div
      id="featureserver-ol-map"
      ref="mapContainer"
      class="map-container"
    ></div>
    <div v-if="error" class="error">{{ error }}</div>
    <div class="legend">
      <h4>图例</h4>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #41ff00"></span>
        <span>Affected (受影响)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ff0000"></span>
        <span>Destroyed ( destroyed)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #808080"></span>
        <span>Inaccessible (无法进入)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ffae00"></span>
        <span>Major (重大损坏)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ffea00"></span>
        <span>Minor (轻微损坏)</span>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import axios from "axios";
import Map from "ol/Map";
import View from "ol/View";
import TileLayer from "ol/layer/Tile";
import VectorLayer from "ol/layer/Vector";
import VectorSource from "ol/source/Vector";
import { EsriJSON } from "ol/format";
import { Style, Circle, Fill, Stroke } from "ol/style";
import { createStyleFunctionFromUrl } from "ol-esri-style";

const mapContainer = ref<HTMLDivElement>();
const loading = ref(false);
const featureCount = ref(0);
const error = ref("");
const useEsriStyle = ref(false);

// 存储 ESRI 样式函数
let esriStyleFunction: ((feature: any) => Style | Style[]) | null = null;
let customStyleFunction: ((feature: any) => Style | Style[]);

// 基于损坏类型的自定义样式函数
customStyleFunction = (feature) => {
  const damageType = feature.get("typdamage");
  let color = "#41ff00"; // 默认 - 受影响

  switch (damageType) {
    case "Destroyed":
      color = "#ff0000";
      break;
    case "Inaccessible":
      color = "#808080";
      break;
    case "Major":
      color = "#ffae00";
      break;
    case "Minor":
      color = "#ffea00";
      break;
    default:
      color = "#41ff00"; // 受影响
  }

  return new Style({
    image: new Circle({
      radius: 8,
      fill: new Fill({ color: color }),
      stroke: new Stroke({ color: "#000", width: 1 }),
    }),
  });
};

let map: Map | null = null;
const featureLayer = new VectorLayer({
  source: new VectorSource(),
  style: customStyleFunction,
});

// 从 ArcGIS FeatureServer 加载要素
const loadFeatures = async () => {
  loading.value = true;
  error.value = "";

  try {
    const response = await axios.get(
      "https://sampleserver6.arcgisonline.com/arcgis/rest/services/CommercialDamageAssessment/FeatureServer/0/query",
      {
        params: {
          f: "json",
          where: "1=1",
          returnGeometry: "true",
          outFields: "*",
          resultOffset: 0,
          resultRecordCount: 1000,
        },
      },
    );

    if (response.data.error) {
      throw new Error(response.data.error.message || "未知错误");
    }

    const format = new EsriJSON();
    const features = format.readFeatures(response.data, {
      featureProjection: "EPSG:3857",
    });

    const source = featureLayer.getSource();
    source?.clear();
    source?.addFeatures(features);

    featureCount.value = features.length;

    // 缩放到要素范围
    if (features.length > 0 && source) {
      const extent = source.getExtent();
      const view = map?.getView();
      if (view && extent) {
        view.fit(extent, { padding: [50, 50, 50, 50], duration: 1000 });
      }
    }
  } catch (err: any) {
    error.value = "加载失败: " + (err.message || "未知错误");
    console.error("Load features error:", err);
  } finally {
    loading.value = false;
  }
};

// 清除所有要素
const clearFeatures = () => {
  const source = featureLayer.getSource();
  source?.clear();
  featureCount.value = 0;
};

// 根据开关更新要素样式
const updateFeatureStyle = async () => {
  if (useEsriStyle.value) {
    // 从 FeatureServer 加载 ESRI 样式
    loading.value = true;
    try {
      esriStyleFunction = await createStyleFunctionFromUrl(
        "https://sampleserver6.arcgisonline.com/arcgis/rest/services/CommercialDamageAssessment/FeatureServer/0",
        "EPSG:3857"
      );
      featureLayer.setStyle(esriStyleFunction);
    } catch (err: any) {
      error.value = "加载 ESRI 样式失败: " + (err.message || "未知错误");
      console.error("Load ESRI style error:", err);
      // 回退到自定义样式
      featureLayer.setStyle(customStyleFunction);
    } finally {
      loading.value = false;
    }
  } else {
    // 使用自定义样式
    featureLayer.setStyle(customStyleFunction);
  }
};

onMounted(() => {
  const baseLayer = new TileLayer({});

  // 创建以伊利诺伊州为中心的地图(数据所在位置)
  map = new Map({
    target: mapContainer.value!,
    layers: [baseLayer, featureLayer],
    view: new View({
      center: [-10747000, 5162000], // Web Mercator 投影下伊利诺伊州的近似中心
      zoom: 7,
    }),
  });

  // 挂载时自动加载要素
  loadFeatures();
});

onUnmounted(() => {
  if (map) {
    map.setTarget(undefined);
    map = null;
  }
});
</script>

<style scoped>
.map-page {
  padding: 20px;
}

h1 {
  margin-bottom: 20px;
  color: #333;
}

.info-panel {
  background-color: #f8f9fa;
  padding: 15px;
  border-radius: 8px;
  margin-bottom: 15px;
  border-left: 4px solid #42b983;
}

.info-panel h3 {
  margin-top: 0;
  margin-bottom: 10px;
  color: #2c3e50;
}

.info-panel p {
  margin: 5px 0;
  color: #555;
}

.controls {
  margin-bottom: 15px;
  display: flex;
  align-items: center;
  gap: 15px;
}

.controls button {
  padding: 10px 20px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.3s;
}

.controls button:hover:not(:disabled) {
  background-color: #3aa876;
}

.controls button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.feature-count {
  color: #666;
  font-size: 14px;
}

.toggle-switch {
  display: flex;
  align-items: center;
  gap: 10px;
  cursor: pointer;
  position: relative;
}

.toggle-switch input[type="checkbox"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

.toggle-switch .slider {
  position: relative;
  display: inline-block;
  width: 44px;
  height: 24px;
  background-color: #ccc;
  border-radius: 24px;
  transition: background-color 0.3s;
}

.toggle-switch .slider:before {
  content: "";
  position: absolute;
  height: 18px;
  width: 18px;
  left: 3px;
  bottom: 3px;
  background-color: white;
  border-radius: 50%;
  transition: transform 0.3s;
}

.toggle-switch input:checked + .slider {
  background-color: #42b983;
}

.toggle-switch input:checked + .slider:before {
  transform: translateX(20px);
}

.toggle-switch .label-text {
  font-size: 14px;
  color: #333;
  user-select: none;
}

.map-container {
  width: 100%;
  height: 600px;
  border: 2px solid #ddd;
  border-radius: 8px;
}

.error {
  margin-top: 10px;
  padding: 10px;
  background-color: #fee;
  color: #c33;
  border-radius: 4px;
}

.legend {
  margin-top: 15px;
  padding: 15px;
  background-color: #f8f9fa;
  border-radius: 8px;
  border: 1px solid #ddd;
}

.legend h4 {
  margin-top: 0;
  margin-bottom: 10px;
  color: #333;
}

.legend-item {
  display: flex;
  align-items: center;
  margin: 5px 0;
}

.legend-color {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  margin-right: 10px;
  border: 1px solid #000;
  display: inline-block;
}
</style>

2.1.6 Vue3-Openlayers用

自定义图例:

57.png

使用渲染器图例:

58.png

<template>
  <div class="map-page">
    <h1>Vue3-OpenLayers - ArcGIS FeatureServer 调用</h1>
    <div class="info-panel">
      <h3>服务信息</h3>
      <p><strong>服务名称:</strong> CommercialDamageAssessment</p>
      <p>
        <strong>图层:</strong> Damage to Commercial Buildings (商业建筑损坏评估)
      </p>
      <p><strong>几何类型:</strong></p>
      <p>
        <strong>要素类型:</strong> Affected, Destroyed, Inaccessible, Major,
        Minor
      </p>
    </div>
    <div class="controls">
      <button @click="loadFeatures" :disabled="loading">
        {{ loading ? "加载中..." : "加载要素数据" }}
      </button>
      <button @click="clearFeatures" :disabled="loading">清除要素</button>
      <span v-if="featureCount" class="feature-count">
        已加载 {{ featureCount }} 个要素
      </span>
      <label class="toggle-switch">
        <input
          type="checkbox"
          v-model="useEsriStyle"
          @change="updateFeatureStyle"
        />
        <span class="slider"></span>
        <span class="label-text">使用 ESRI 样式</span>
      </label>
    </div>

    <ol-map
      ref="mapRef"
      :loadTilesWhileAnimating="true"
      :loadTilesWhileInteracting="true"
      style="
        height: 600px;
        width: 100%;
        border: 2px solid #ddd;
        border-radius: 8px;
      "
    >
      <ol-view
        ref="viewRef"
        :center="center"
        :zoom="7"
        :projection="projection"
      />

      <ol-vector-layer>
        <ol-source-vector ref="vectorSourceRef" />
      </ol-vector-layer>
    </ol-map>

    <div v-if="error" class="error">{{ error }}</div>

    <div class="legend">
      <h4>图例</h4>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #41ff00"></span>
        <span>Affected (受影响)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ff0000"></span>
        <span>Destroyed (摧毁)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #808080"></span>
        <span>Inaccessible (无法进入)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ffae00"></span>
        <span>Major (重大损坏)</span>
      </div>
      <div class="legend-item">
        <span class="legend-color" style="background-color: #ffea00"></span>
        <span>Minor (轻微损坏)</span>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref } from "vue";
import axios from "axios";
import { EsriJSON } from "ol/format";
import { Style, Circle, Fill, Stroke } from "ol/style";
import { createStyleFunctionFromUrl } from "ol-esri-style";
import VectorLayer from "ol/layer/Vector";

const projection = "EPSG:3857";
const center = ref([-10747000, 5162000]); // Web Mercator 投影下伊利诺伊州的近似中心
const loading = ref(false);
const featureCount = ref(0);
const error = ref("");
const vectorSourceRef = ref();
const viewRef = ref();
const mapRef = ref();
const useEsriStyle = ref(false);

// 获取矢量图层
const getVectorLayer = () => {
  const map = mapRef.value?.map;
  if (!map) return null;
  const layers = map.getLayers().getArray();
  // 找到第一个 VectorLayer
  return layers.find((layer: any) => layer instanceof VectorLayer) as VectorLayer<any> | null;
};

// 存储 ESRI 样式函数
let esriStyleFunction: ((feature: any) => Style | Style[]) | null = null;
let customStyleFunction: (feature: any) => Style | Style[];

// 定义不同损坏类型的颜色映射
const damageColors: Record<string, string> = {
  Affected: "#41ff00",
  Destroyed: "#ff0000",
  Inaccessible: "#808080",
  Major: "#ffae00",
  Minor: "#ffea00",
};

// 基于损坏类型的自定义样式函数
customStyleFunction = (feature) => {
  const damageType = feature.get("typdamage");
  const color = damageColors[damageType] || "#41ff00";

  return new Style({
    image: new Circle({
      radius: 8,
      fill: new Fill({ color: color }),
      stroke: new Stroke({ color: "#000", width: 1 }),
    }),
  });
};

// 从 ArcGIS FeatureServer 加载要素
const loadFeatures = async () => {
  loading.value = true;
  error.value = "";

  try {
    const response = await axios.get(
      "https://sampleserver6.arcgisonline.com/arcgis/rest/services/CommercialDamageAssessment/FeatureServer/0/query",
      {
        params: {
          f: "json",
          where: "1=1",
          returnGeometry: "true",
          outFields: "*",
          resultOffset: 0,
          resultRecordCount: 1000,
        },
      },
    );

    if (response.data.error) {
      throw new Error(response.data.error.message || "未知错误");
    }

    const format = new EsriJSON();
    const features = format.readFeatures(response.data, {
      featureProjection: "EPSG:3857",
    });

    // 基于损坏类型应用自定义样式(用于非 ESRI 样式模式)
    features.forEach((feature) => {
      const damageType = feature.get("typdamage");
      const color = damageColors[damageType] || "#41ff00";
      feature.set("color", color);
    });

    const source = vectorSourceRef.value?.source;
    const layer = getVectorLayer();
    if (source) {
      source.clear();
      source.addFeatures(features);
    }

    // 根据当前开关状态应用样式
    if (layer) {
      if (useEsriStyle.value && esriStyleFunction) {
        layer.setStyle(esriStyleFunction);
      } else {
        layer.setStyle(customStyleFunction);
      }
    }

    featureCount.value = features.length;

    // 缩放到要素范围
    if (features.length > 0 && source) {
      const extent = source.getExtent();
      const view = viewRef.value?.view;
      if (view && extent) {
        view.fit(extent, { padding: [50, 50, 50, 50], duration: 1000 });
      }
    }
  } catch (err: any) {
    error.value = "加载失败: " + (err.message || "未知错误");
    console.error("Load features error:", err);
  } finally {
    loading.value = false;
  }
};

// 清除所有要素
const clearFeatures = () => {
  const source = vectorSourceRef.value?.source;
  if (source) {
    source.clear();
  }
  featureCount.value = 0;
};

// 根据开关更新要素样式
const updateFeatureStyle = async () => {
  const layer = getVectorLayer();
  if (!layer) {
    console.error("无法获取矢量图层");
    return;
  }

  if (useEsriStyle.value) {
    // 从 FeatureServer 加载 ESRI 样式
    loading.value = true;
    try {
      console.log("开始加载 ESRI 样式...");
      esriStyleFunction = await createStyleFunctionFromUrl(
        "https://sampleserver6.arcgisonline.com/arcgis/rest/services/CommercialDamageAssessment/FeatureServer/0",
        "EPSG:3857",
      );
      console.log("ESRI 样式加载成功,应用到图层");
      layer.setStyle(esriStyleFunction);
    } catch (err: any) {
      error.value = "加载 ESRI 样式失败: " + (err.message || "未知错误");
      console.error("Load ESRI style error:", err);
      // 回退到自定义样式
      layer.setStyle(customStyleFunction);
    } finally {
      loading.value = false;
    }
  } else {
    // 使用自定义样式
    layer.setStyle(customStyleFunction);
  }
};

// 挂载时自动加载要素
import { onMounted } from "vue";
onMounted(() => {
  loadFeatures();
});
</script>

<style scoped>
.map-page {
  padding: 20px;
}

h1 {
  margin-bottom: 20px;
  color: #333;
}

.info-panel {
  background-color: #f8f9fa;
  padding: 15px;
  border-radius: 8px;
  margin-bottom: 15px;
  border-left: 4px solid #42b983;
}

.info-panel h3 {
  margin-top: 0;
  margin-bottom: 10px;
  color: #2c3e50;
}

.info-panel p {
  margin: 5px 0;
  color: #555;
}

.controls {
  margin-bottom: 15px;
  display: flex;
  align-items: center;
  gap: 15px;
}

.controls button {
  padding: 10px 20px;
  background-color: #42b983;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  transition: background-color 0.3s;
}

.controls button:hover:not(:disabled) {
  background-color: #3aa876;
}

.controls button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.feature-count {
  color: #666;
  font-size: 14px;
}

.toggle-switch {
  display: flex;
  align-items: center;
  gap: 10px;
  cursor: pointer;
  position: relative;
}

.toggle-switch input[type="checkbox"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

.toggle-switch .slider {
  position: relative;
  display: inline-block;
  width: 44px;
  height: 24px;
  background-color: #ccc;
  border-radius: 24px;
  transition: background-color 0.3s;
}

.toggle-switch .slider:before {
  content: "";
  position: absolute;
  height: 18px;
  width: 18px;
  left: 3px;
  bottom: 3px;
  background-color: white;
  border-radius: 50%;
  transition: transform 0.3s;
}

.toggle-switch input:checked + .slider {
  background-color: #42b983;
}

.toggle-switch input:checked + .slider:before {
  transform: translateX(20px);
}

.toggle-switch .label-text {
  font-size: 14px;
  color: #333;
  user-select: none;
}

.error {
  margin-top: 10px;
  padding: 10px;
  background-color: #fee;
  color: #c33;
  border-radius: 4px;
}

.legend {
  margin-top: 15px;
  padding: 15px;
  background-color: #f8f9fa;
  border-radius: 8px;
  border: 1px solid #ddd;
}

.legend h4 {
  margin-top: 0;
  margin-bottom: 10px;
  color: #333;
}

.legend-item {
  display: flex;
  align-items: center;
  margin: 5px 0;
}

.legend-color {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  margin-right: 10px;
  border: 1px solid #000;
  display: inline-block;
}
</style>

前端开发者做 Agent:Tool Calling 别只写函数名,用 Schema 少踩 5 个坑

2026年4月30日 09:10

作者:前端转 AI 深度实践者

【省流助手/核心观点】:Tool Calling 不是把几个函数名丢给模型就完事了。函数名只能告诉模型“有什么工具”,但不能说明“什么时候用、怎么传参、哪些字段必填、风险有多高、能不能自动执行”。真正可维护的 Agent 工程,需要给每个工具写一份 Schema:描述用途、约束参数、标记风险、控制确认流程。对前端开发者来说,这就像给 API 写 TypeScript 类型、接口文档、表单校验和权限边界。


第 23 篇我们讲了 Agent 的第一块积木:Tool Calling。

简单说,就是让模型不再硬编答案,而是学会提出工具调用意图。

用户问:

帮我查一下订单 A1001 到哪了。

模型不应该直接编:

订单正在配送中。

而应该输出:

{
  "toolName": "getOrderStatus",
  "args": {
    "orderId": "A1001"
  }
}

然后程序真正去查订单系统。

这一步很关键,它把 AI 应用从“会聊天”推进到“能办事”。

但只做到这里还不够。

因为模型可能会:

  • 选错工具。
  • 少传参数。
  • 把数字传成字符串。
  • 把枚举值写错。
  • 调用根本不存在的工具。
  • 请求执行一个高风险操作。

所以这篇文章继续往下走一步:

Tool Calling 不能只靠函数名,还要给工具写 Schema。

这个 Schema,就是工具说明书。

1. 痛点:只注册函数,对程序够用,对模型不够用

先看一个很常见的工具注册表:

const tools = {
  getOrderStatus,
  calculateRefund,
  searchPolicy
};

这对程序来说确实能跑。

程序看到 getOrderStatus,就能找到对应函数执行。

但对模型来说,它只看到几个名字。
这些名字没有完整说明:

  • getOrderStatus 是查物流,还是查订单详情?
  • calculateRefund 是预估退款,还是直接发起退款?
  • searchPolicy 是查公司制度,还是查售后政策?
  • 每个工具需要哪些参数?
  • 参数类型是什么?
  • 哪些参数允许哪些枚举值?
  • 哪些工具只是查询,哪些工具会改变系统状态?

这就像你给新同事一个接口列表:

/order
/refund
/policy

然后让他自己猜每个接口怎么用。

他可能猜对,也很容易猜错。

Agent 系统一样。工具名不是工具契约,工具名只是入口。

2. 错误做法:模型输出什么,程序就执行什么

很多人第一次写 Tool Calling,会不自觉地相信模型输出:

async function unsafeRunTool(toolCall: {
  toolName: string;
  args: Record<string, unknown>;
}) {
  const tool = tools[toolCall.toolName as keyof typeof tools];
  return tool(toolCall.args);
}

这段代码最大的问题是:没有任何边界。

模型可能传错类型:

{
  "toolName": "getOrderStatus",
  "args": {
    "orderId": 1001
  }
}

但你的工具需要的是字符串:

async function getOrderStatus(args: { orderId: string }) {
  // ...
}

模型也可能少传参数:

{
  "toolName": "calculateRefund",
  "args": {
    "reason": "damaged"
  }
}

但退款计算至少需要 orderId

它还可能传错枚举值:

{
  "toolName": "calculateRefund",
  "args": {
    "orderId": "A1001",
    "reason": "随便退一下"
  }
}

如果你直接执行,错误会在很深的业务代码里爆炸。更糟的是,高风险工具可能被误触发。

所以你需要工具 Schema。

3. 正确做法:Schema = 接口文档 + 类型定义 + 安全边界

一个更可靠的工具定义,应该同时包含 handler 和 schema。

type ToolRisk = "low" | "medium" | "high";

type ToolSchema = {
  name: string;
  description: string;
  risk: ToolRisk;
  requiresConfirmation: boolean;
  parameters: {
    type: "object";
    required: string[];
    properties: Record<
      string,
      {
        type: "string" | "number" | "boolean";
        description: string;
        enum?: string[];
        pattern?: string;
      }
    >;
  };
};

type ToolDefinition = {
  schema: ToolSchema;
  handler: (args: Record<string, unknown>) => Promise<unknown>;
};

比如 getOrderStatus 可以这样写:

const getOrderStatusTool: ToolDefinition = {
  schema: {
    name: "getOrderStatus",
    description:
      "查询订单物流状态。适用于用户询问订单是否发货、是否签收、预计何时送达。不用于退款、取消订单或修改地址。",
    risk: "low",
    requiresConfirmation: false,
    parameters: {
      type: "object",
      required: ["orderId"],
      properties: {
        orderId: {
          type: "string",
          description: "订单编号,例如 A1001",
          pattern: "^A\d{4}$"
        }
      }
    }
  },
  handler: async (args) => {
    const orderId = args.orderId;
    if (typeof orderId !== "string") {
      throw new Error("orderId 必须是字符串");
    }

    return {
      orderId,
      status: "shipping",
      eta: "2026-04-28"
    };
  }
};

这份 Schema 至少告诉系统 5 件事:

  1. 工具是做什么的。
  2. 什么时候该用这个工具。
  3. 参数有哪些,哪些必填。
  4. 参数类型和格式是什么。
  5. 工具有没有风险,能不能直接执行。

如果你是前端开发者,可以把它理解成:

Tool Schema = API 文档 + TypeScript 类型 + 表单校验规则 + 权限提示

没有 Schema 的 Tool Calling,就像没有类型定义的接口联调。能跑,但迟早会在边界条件上摔跤。

4. 好的 description,会显著减少选错工具

很多人写工具描述,会写得很短:

查询订单。

这当然比没有好,但还不够。

更好的描述要包含适用场景和不适用场景:

查询订单物流状态。适用于用户询问订单是否发货、是否签收、预计何时送达。不用于退款、取消订单或修改地址。

这个描述告诉模型:

  • 用户问物流,选它。
  • 用户问退款,不要选它。
  • 用户问取消订单,也不要选它。

工具描述不是写给人看的注释而已。

它会影响模型的选择质量。你可以把工具描述理解成一种挂在工具上的 Prompt。

5. 参数校验:模型输出的 args 本质上是不可信输入

args 是模型生成的,所以它应该被当成用户输入处理。

一个最小参数校验器可以这样写:

function validateArgs(schema: ToolSchema, args: Record<string, unknown>) {
  const errors: string[] = [];
  const { required, properties } = schema.parameters;

  for (const key of required) {
    if (!(key in args)) {
      errors.push(`缺少必填参数:${key}`);
    }
  }

  for (const [key, rules] of Object.entries(properties)) {
    const value = args[key];
    if (value === undefined) continue;

    if (rules.type === "string" && typeof value !== "string") {
      errors.push(`参数 ${key} 应该是 string`);
      continue;
    }

    if (rules.type === "number" && typeof value !== "number") {
      errors.push(`参数 ${key} 应该是 number`);
      continue;
    }

    if (rules.type === "boolean" && typeof value !== "boolean") {
      errors.push(`参数 ${key} 应该是 boolean`);
      continue;
    }

    if (rules.enum && !rules.enum.includes(String(value))) {
      errors.push(`参数 ${key} 必须是 ${rules.enum.join(", ")} 之一`);
    }

    if (
      rules.pattern &&
      typeof value === "string" &&
      !new RegExp(rules.pattern).test(value)
    ) {
      errors.push(`参数 ${key} 格式不合法`);
    }
  }

  return errors;
}

这段代码看起来不酷,但它是 Agent 稳定性的地基。

AI 工程里很多真正有价值的代码,都不是“让模型更聪明”,而是让系统在模型不稳定时也不会乱跑。

6. 调度器要像网关,而不是传声筒

没有 Schema 校验时,调度器像一个传声筒:

模型说调用什么,我就调用什么。

更好的调度器应该像网关:

模型提出调用意图
-> 检查工具是否存在
-> 检查参数是否合法
-> 检查风险等级
-> 检查是否需要确认
-> 决定是否执行

一个升级版 runTool 可以这样写:

type ToolCall = {
  toolName: string;
  args: Record<string, unknown>;
};

type ToolRunResult =
  | {
      ok: true;
      toolName: string;
      data: unknown;
    }
  | {
      ok: false;
      toolName?: string;
      errorType:
        | "unknown_tool"
        | "invalid_arguments"
        | "confirmation_required"
        | "tool_error";
      message: string;
      errors?: string[];
    };

const toolRegistry: Record<string, ToolDefinition> = {
  getOrderStatus: getOrderStatusTool
};

async function runTool(toolCall: ToolCall): Promise<ToolRunResult> {
  const tool = toolRegistry[toolCall.toolName];

  if (!tool) {
    return {
      ok: false,
      toolName: toolCall.toolName,
      errorType: "unknown_tool",
      message: `未知工具:${toolCall.toolName}`
    };
  }

  const errors = validateArgs(tool.schema, toolCall.args ?? {});
  if (errors.length > 0) {
    return {
      ok: false,
      toolName: tool.schema.name,
      errorType: "invalid_arguments",
      message: "工具参数不合法",
      errors
    };
  }

  if (tool.schema.requiresConfirmation) {
    return {
      ok: false,
      toolName: tool.schema.name,
      errorType: "confirmation_required",
      message: "该工具属于高风险操作,需要用户确认后才能执行"
    };
  }

  try {
    const data = await tool.handler(toolCall.args);
    return {
      ok: true,
      toolName: tool.schema.name,
      data
    };
  } catch (error) {
    return {
      ok: false,
      toolName: tool.schema.name,
      errorType: "tool_error",
      message: error instanceof Error ? error.message : "工具执行失败"
    };
  }
}

这段逻辑让系统多了几道闸门。

这些闸门不是为了为难模型,而是为了保护用户、业务和团队。

7. 查询类工具和写入类工具必须分开看

Tool Calling 里最需要警惕的是副作用。

查询类工具通常风险较低:

  • 查订单状态。
  • 查制度文档。
  • 查天气。
  • 查库存。

它们只是读取信息,不改变系统状态。

写入类工具就不一样了:

  • 取消订单。
  • 发起退款。
  • 修改地址。
  • 发送邮件。
  • 删除数据。
  • 修改用户权限。

这些操作会改变真实业务状态。

如果模型误触发,后果可能很麻烦。

所以你应该给工具标记风险:

const cancelOrderTool: ToolDefinition = {
  schema: {
    name: "cancelOrder",
    description:
      "取消指定订单。仅当用户明确要求取消订单时使用。不用于查询订单状态或咨询退款政策。",
    risk: "high",
    requiresConfirmation: true,
    parameters: {
      type: "object",
      required: ["orderId", "reason"],
      properties: {
        orderId: {
          type: "string",
          description: "订单编号,例如 A1001",
          pattern: "^A\d{4}$"
        },
        reason: {
          type: "string",
          description: "取消原因",
          enum: ["user_request", "wrong_address", "duplicate_order"]
        }
      }
    }
  },
  handler: async (args) => {
    return {
      cancelled: true,
      orderId: args.orderId
    };
  }
};

当模型试图调用高风险工具时,系统不应该立刻执行,而应该返回确认态。

这不是“不智能”,这是负责任。

AI 工程里有一个很实用的原则:

能查的,可以宽一点;能改的,必须严一点。

8. 生产环境避坑指南

1. Schema 要和真实 handler 同步维护

最危险的情况是:Schema 说需要 orderId,handler 实际读的是 id

这类错不会总是立刻暴露,但会让模型、前端和后端一起困惑。

建议把 Schema 和 handler 放在同一个文件或同一个模块里维护,不要散落在不同仓库里。

2. 不要用模糊工具名

坏名字:

doUserThing
handleOrder
processData

好名字:

getOrderStatus
calculateRefundEstimate
searchPolicyDocs
createSupportTicketDraft

名字越清楚,模型越容易选对,团队也越容易维护。

3. description 要写“不适用场景”

很多工具选错,不是因为模型不知道它能做什么,而是不知道它不能做什么。

描述里最好写清楚:

  • 适用于什么问题。
  • 不适用于什么问题。
  • 和相似工具的区别是什么。

4. 高风险工具必须程序层拦截

不要只在 Prompt 里写“谨慎使用”。

Prompt 是软约束。权限校验、二次确认、审计日志才是硬约束。

涉及删除、支付、发送、修改权限、批量操作的工具,都应该默认需要确认。

5. 结构化错误要返回给模型和日志

不要只返回:

调用失败。

更好的错误是:

{
  "ok": false,
  "errorType": "invalid_arguments",
  "message": "工具参数不合法",
  "errors": ["缺少必填参数:orderId"]
}

这样模型可以尝试修正参数,开发者也能快速定位问题。

9. 常见误区

误区 1:函数名写清楚就够了

不够。函数名只能表达一小部分语义,不能替代参数规则、风险等级和适用边界。

误区 2:模型很聪明,参数错了它会自己修

有时会,但不能依赖。工程系统要把错误显式返回,而不是期待模型每次都猜对。

误区 3:高风险工具只要 Prompt 写“谨慎使用”就行

不行。Prompt 是软约束,权限和确认是硬约束。涉及副作用的工具必须在程序层把关。

误区 4:Schema 越复杂越好

也不是。初期 Schema 要清晰、够用、容易维护。复杂度应该来自真实问题,而不是一开始就堆满规则。

10. 给前端开发者的落地清单

如果你正在设计 Agent 工具,可以从这份清单开始:

  1. 每个工具必须有清晰名字。
  2. 每个工具必须有 description,包含适用和不适用场景。
  3. 每个工具必须声明必填参数。
  4. 每个参数必须有类型、描述和必要的格式约束。
  5. 枚举参数必须列出允许值。
  6. 工具必须标记风险等级。
  7. 高风险工具默认需要确认。
  8. 调度器必须返回结构化错误。
  9. 工具调用必须记录 traceId。
  10. 工具 Schema 要和代码一起维护。

这份清单不花哨,但很实用。它会让你的 Agent 不只是演示时聪明,而是在真实用户、真实业务、真实错误里也能站得住。

结语

Tool Calling 的第一步,是让模型知道有哪些工具。

Tool Calling 的第二步,是让系统知道这些工具该怎么被安全、稳定、可维护地使用。

这就是 Schema 的价值。

它像一份工具说明书,也像一份协作契约:告诉模型什么时候该用,告诉程序怎么校验,告诉团队风险在哪里。

如果说第 23 篇让我们把 Agent 的“手”接上去,那么第 24 篇就是给这只手标上边界、权限和刹车。

真正可靠的 Agent,不是模型想做什么就做什么。

真正可靠的 Agent,是模型提出建议,程序负责把关,工具在清晰边界内执行。

手工拼豆有风险?手把手教你开发个电子版的

2026年4月30日 08:48

引言

哈喽大家好,我是亿元程序员,一位有着8年游戏行业经验的主程。

最近,手工拼豆游戏真的非常地火爆,有的小伙伴只要拼起来,动不动就是好几个小时。

不仅仅是年轻人在拼,就连许多儿童都在参与,可以看出来其火热程度。

但是这种手工拼豆本身设计就不是针对儿童的,小孩玩手工拼豆存在安全隐患。

有小伙伴就问了,那小朋友想玩怎么办?

Jewel Coloring游戏宣传图

于是拼豆小游戏就火爆出圈了!既然手工拼豆有风险,那我们就开发一个电子版的!

言归正传,本期带大家一起来看看,拼豆游戏的开发流程和思路!

本期不贴代码,只讲思路,源码可在文末获取,小伙伴们自行前往。

什么是拼豆游戏?

拼豆,也常被称为“像素豆”,是一种集手工、艺术与创意于一体的休闲益智游戏。

简单来说,它就像是物理版的“像素画”。

拼豆游戏

拼豆的基本原理非常简单,通常分为以下几个步骤:

  • 1.排列:在带有颗粒凸起的模版上,根据图案教程或自己的创意,将一颗颗五颜六色的彩色塑料豆摆好。
  • 2.加热:摆好图案后,在豆子上方铺一层助烫纸。
  • 3.融合:使用电熨斗均匀加热。豆子是热塑性塑料材质,受热后会稍微融化并粘合在一起。
  • 4.成品:冷却后,豆子就变成了一个坚硬、平整的艺术品。

相信小伙伴对上面的手工拼豆流程都很熟悉了,那电子版的是怎样的?

电子版拼豆

由于手机游戏无法还原“熨烫”的触感,这类小游戏更多侧重于填色、收集和社交分享,精简之后,游戏长这样:

给大家比个心

看起来bling-bling的就非常解压,操作方式也比较简单,就是通过借助临时槽,不断地移动宝石的位置,将不同颜色的宝石全部复位即可通关。

那开发这款游戏有哪些关键点?

拼豆游戏开发思路

下面给小伙伴们讲解一下开发思路,不贴代码:

1.关卡编辑器

首先这类型的游戏关卡会非常多,如此大量的关卡,一个好的关卡编辑器起到关键性的作用,可以按照以下流程开发:

  • **1.准备色盘:**这类型游戏其本质就是像素画,通常需要通过图像识别去生成关卡,前提就要准备好色盘(即游戏中的宝石),让像素识别趋向于我们需要的颜色。

  • **2.插入图片:**准备完色盘之后,我们就可以插入图片按照指定的规格(m*n,通常是图片像素的十分之一),通过图像识别算法进行识别,自动拼接,将图片转化成游戏中的效果。

  • **3.调色:**因为图像识别算法不能保证百分百还原像素,通常我们需要固定图片尺寸、像素大小或者指定部分色盘,假如这些都不能很好地识别,我们需要通过手动调色,即通过编辑器修改某些宝石的颜色。

  • **4.打乱:**就是提前在关卡编辑器将宝石的位置进行打乱,打乱要保证颜色块尽量连续,否则会比较乱,玩家难以入手。

  • **5.导出导入:**最后就是基础的导入导出功能,玩家可以将关卡导出成游戏可以用的配置格式,或者将配置再次导入重新编辑,数据包括打乱后的数据和正确答案。

相信大家对上面的流程也都很清楚了,下面笔者重点讲下其中比较关键的两个算法(说好的不贴代码呢?):

  • 识别算法:上传图片后,编辑器把图切成小格,提取每格主色,匹配最接近的宝石颜色,透明处留空。

  • 打乱算法:打乱不是简单随机,而是先按同色连通区域分组,保留每种珠子的总数量,再优先把小区域换成别的颜色,尽量避开原位和相邻同色,最后多试几轮,选最不像答案的一版。

这么看的话,关卡编辑器还挺复杂的,那游戏呢?

2.游戏核心流程

1.宝石展示

首先我们需要先根据关卡配置,将宝石底座按照答案数据进行铺设,然后再根据打乱数据把宝石放到合适的位置。

如图所示宝石和底座

2.宝石选择

接着我们要实现宝石的选择功能,这里面有个潜规则,就是选择宝石的时候,要从选择的宝石开始,进行8方向的邻居不断地向外延伸,直到没有相连的宝石位置。

如图所示红框部分红黄绿顺序

3.宝石移动

宝石的移动和选择一样,也需要遵循8方向的邻居不断地向外延伸的规则,优先填满,生育的保持选择状态,在限定时间内所有宝石都移动到正确的位置时,游戏通关,否则失败。

8方向的邻居不断地向外延伸

4.光效特效

这个游戏看起来blingbling的,主要是游戏的宝石特效,每当单个宝石复位、某个颜色的宝石全部复位,所有宝石都复位时,会播放闪光的特效。

关于这个特效有很多实现方法,简单列举两种:

  • 美术妹子:求助美术妹子,让她给你做一组宝石序列帧特效,按帧播放就行,优点是程序上比较简单,缺点是换皮后不同的宝石需要做不同的效果。

  • Shader: 可以通过Shader简单实现一个流光特效,从左往右扫过。

5.道具功能

为了降低游戏难度,游戏内提供了三种游戏道具,玩家可以选择使用:

如图所示

1.魔法棒

使用之后会出现一个彩色选择框,玩家可以移动选择框到想要还原宝石的位置,松手后自动复原该区域的宝石。

2.扫把

当临时槽有宝石时可以使用,将临时槽的宝石找到正确的位置,全部还原。

3.磁铁

使用之后,会抽取一种颜色,将这个颜色的所有宝石全部都复位。

以上就是拼豆游戏的所有开发流程和思路,非常感谢小伙伴们的阅读点赞分享。

结语

当我还在分析游戏怎么做时,有小伙伴已经把游戏上架了,太快了!

感兴趣的小伙伴,后台私信"拼豆"即可了解详情获取。

由于不可抗力,我们就不进行实战了,不过亿元Cocos小游戏实战合集2.0依旧持续更新中,欢迎小伙伴们进行订阅。


我是"亿元程序员",一位有着8年游戏行业经验的主程。在游戏开发中,希望能给到您帮助, 也希望通过您能帮助到大家。

实不相瞒,想要个爱心!请把该文章分享给你觉得有需要的其他小伙伴。谢谢!

推荐文章:

亿元Cocos小游戏实战合集1.0

老板说最近这款游戏很火让我抄,可是我连玩都玩不明白...

这款值68亿的游戏,你不实战一下吗?安排!

小伙伴说我的拼图游戏用Mask不能合批...

俄罗斯方块谁不会做......啊?流沙版?

最近很火的一个拼图游戏,老板让我用Cocos3.8做一个...

老板说拼图游戏太卷了,让我用Cocos做个3d版本的...

敢不敢挑战用Cocos3.8复刻曾经很火的割绳子游戏?

状态管理大乱斗#05 | Riverpod 源码评析 (中) - 上层建筑

2026年4月30日 07:45

aeb07d6daf42f480cdfcd33f7b87ab55.png

引言:

你去过自助餐厅吗?

餐台上摆着几十道菜:凉菜、热菜、甜点、饮料。你不需要全部吃一遍,只挑自己想吃的就行。但你得知道每道菜在哪个区域——凉菜区找不到红烧肉,甜点区也没有酸辣汤。

Riverpod 的 Provider 类型系统就像这个自助餐台。ProviderNotifierProviderFutureProviderStreamProvider……初学者容易被搞晕,觉得"怎么这么多种"。但每种 Provider 都有自己的"区域",解决的是不同场景的问题。上一篇我们拆解了 Riverpod 的地基——ProviderContainer、ProviderElement、Ref、Scheduler。这一篇往上盖楼:Provider 的类型体系、Family 机制、Select 精准重建、Override 覆盖、AsyncValue 的设计。

这些是你在日常开发中最常接触的部分。看完源码之后,很多"为什么要这样写"的疑惑会自然消解。


一、Provider 的类型体系:龙生九子,各有不同

Riverpod 有好几种 Provider 类型,但从源码层面看,它们的继承关系很清晰——所有 Provider 分为两大流派:函数式类式


1. 两大流派的基类

先看源码中的继承链。所有 Provider 的最终基类是 ProviderBase

graph TD
    PB["ProviderBase / $ProviderBaseImpl<br/>(所有 Provider 的基类)"]

    FP["$FunctionalProvider<br/>函数式:create(Ref ref)"]
    CP["$ClassProvider<br/>类式:create() → Notifier"]

    PB --> FP
    PB --> CP

    FP --> P["Provider<br/>同步值"]
    FP --> FuP["FutureProvider<br/>异步值"]
    FP --> SP["StreamProvider<br/>流"]

    CP --> NP["NotifierProvider<br/>同步 Notifier"]
    CP --> ANP["AsyncNotifierProvider<br/>异步 Notifier"]
    CP --> SNP["StreamNotifierProvider<br/>流 Notifier"]

    style FP fill:#dff,stroke:#333
    style CP fill:#ffd,stroke:#333
---->[core/provider/provider.dart#ProviderBase]----
sealed class ProviderBase<StateT> extends ProviderOrFamily
    implements
        ProviderListenable<StateT>,
        Refreshable<StateT>,
        _ProviderOverride {
  ProviderBase({
    required super.name,
    required this.from,       // tag1: 所属 Family
    required this.argument,   // tag2: Family 参数
    required super.dependencies,
    required super.$allTransitiveDependencies,
    required super.isAutoDispose,
    required super.retry,
  });

  final Family? from;      // tag3: 如果是 family 创建的,指向 family
  final Object? argument;   // tag4: family 的参数
}

tag1tag4 告诉我们一个重要信息:每个 Provider 天生就知道自己是不是从 Family 创建的from 指向所属的 Family,argument 是创建时的参数。这为后面的 Family 机制埋下了伏笔。

ProviderBase 之下,分出两条路:

---->[core/provider/functional_provider.dart#$FunctionalProvider]----
abstract base class $FunctionalProvider<StateT, ValueT, CreatedT>
    extends $ProviderBaseImpl<StateT> {
  // tag5: 函数式 Provider 的基类
  CreatedT create(Ref ref);  // 接收一个函数,调用得到值
}
---->[core/provider/notifier_provider.dart#$ClassProvider]----
abstract base class $ClassProvider<NotifierT extends AnyNotifier<StateT, ValueT>, StateT, ValueT, CreatedT>
    extends $ProviderBaseImpl<StateT> {
  // tag6: 类式 Provider 的基类
  NotifierT create();  // 创建一个 Notifier 实例
}

tag5tag6 的区别一目了然:函数式接收 Ref,返回值就是状态;类式创建一个 Notifier 实例,Notifier 内部管理状态。

用做菜来类比:

  • 函数式是"给我原料(Ref),我直接出成品"——纯函数,进去什么出来什么;
  • 类式是"给我一个厨房(Notifier),我在里面做各种操作"——有状态的对象,可以炒、可以炖、可以加调料。

2. 函数式 Provider 的源码

以最基础的 Provider 为例:

---->[providers/provider.dart#Provider]----
final class Provider<ValueT> extends $FunctionalProvider<ValueT, ValueT, ValueT>
    with $Provider<ValueT>, LegacyProviderMixin<ValueT> {
  Provider(
    this._create, {
    super.name,
    super.dependencies,
    super.isAutoDispose = false,  // tag1: 默认不自动销毁
    super.retry,
  });

  final Create<ValueT> _create;  // tag2: 一个函数

  @override
  ValueT create(Ref ref) => _create(ref);  // tag3: 调用函数得到值
}

tag2 处的 Create<ValueT> 是什么?往上翻源码:

---->[core/provider/provider.dart]----
typedef Create<CreatedT> = CreatedT Function(Ref ref);

就是一个接收 Ref、返回 CreatedT 的函数。简单直白。


3. 类式 Provider 的源码

类式 Provider 的核心是 Notifier。看 $Notifier 的基类:

---->[providers/notifier.dart#$Notifier]----
abstract class $Notifier<StateT> extends $SyncNotifierBase<StateT> {
  StateT? get stateOrNull {
    final element = requireElement();
    element.flush();  // tag4: 读取前先刷新
    return element.stateResult()?.value;
  }
}

tag4 处有个细节值得停下来想想:读取 stateOrNull 时会先调用 flush()。还记得上一篇讲的 flush 吗?它会检查依赖是否变化,如果变了就先重建。这保证了你读到的永远是最新的值,即使有依赖在你读取之前刚刚变化了。

再看 Notifier 的 state getter 和 setter:

---->[core/provider/notifier_provider.dart#AnyNotifier]----
abstract class AnyNotifier<StateT, ValueT> {
  StateT get state {
    final ref = $ref;
    ref._throwIfInvalidUsage();
    return ref._element.readSelf().valueOrRawException;  // tag5: 读取当前状态
  }

  set state(StateT newState) {
    final ref = $ref;
    ref._throwIfInvalidUsage();
    ref._element.setValueFromState(newState);  // tag6: 设置新状态,触发通知
  }
}

tag5tag6 揭示了 Notifier 修改状态的本质:setter 调用 setValueFromState,最终会触发 _notifyListeners,通知所有监听者。这就是为什么你在 Notifier 里写 state = newValue 就能让 UI 更新——不是魔法,是 setter 里藏了通知逻辑。


4. 函数式 vs 类式:怎么选
维度 函数式 (Provider) 类式 (NotifierProvider)
定义方式 一个函数 一个类 + build 方法
修改状态 只能通过 ref.invalidateSelf 重建 可以通过方法直接修改 state
适用场景 派生状态、计算值、依赖组合 有业务逻辑的可变状态
测试 简单,mock 依赖即可 需要实例化 Notifier

简单理解:如果你的状态是"从其他状态计算出来的",用函数式;如果你的状态"需要被用户操作修改",用类式。

如果你现在还不确定该用哪种,不用纠结。先用函数式,等发现需要在多个地方修改状态时,再换成类式。Riverpod 的类型系统设计得足够灵活,切换成本不高。


二、Family:一个模具生产一批零件

Provider.family 是 Riverpod 中使用频率很高的功能。它允许你用参数创建同一类型但不同实例的 Provider。比如 todoFamily(42)todoFamily(99) 是两个完全独立的 Provider,各有各的状态、各有各的生命周期。


1. Family 的本质:模具,不是零件
---->[core/family.dart#Family]----
final class Family extends ProviderOrFamily implements _FamilyOverride {
  Family({
    required super.name,
    required super.dependencies,
    required super.$allTransitiveDependencies,
    required super.isAutoDispose,
    required super.retry,
  });

  @override
  Family get from => this;  // tag1: Family 的 from 指向自己
}

Family 本身不是 Provider,它是一个"模具"。tag1from 指向自己——这和具体 Provider 的 from 指向所属 Family 形成对照。

真正的魔法在 FunctionalFamily.call 方法里:

---->[core/family.dart#FunctionalFamily]----
base class FunctionalFamily<StateT, ValueT, ArgT, CreatedT,
    ProviderT extends $FunctionalProvider<StateT, ValueT, CreatedT>>
    extends Family {

  final FunctionalProviderFactory<ProviderT, CreatedT, ArgT> _providerFactory;
  final CreatedT Function(Ref ref, ArgT arg) _createFn;

  ProviderT call(ArgT argument) {
    return _providerFactory(
      (ref) => _createFn(ref, argument),  // tag2: 把参数"烤"进闭包
      name: name,
      isAutoDispose: isAutoDispose,
      from: this,          // tag3: 标记来源是这个 Family
      argument: argument,  // tag4: 记录参数
      dependencies: null,
      $allTransitiveDependencies: null,
      retry: retry,
    );
  }
}

给你三秒钟,看看 tag2tag4 做了什么。

答案:每次调用 todoFamily(42) 时,call 方法创建一个全新的 Provider 实例。tag2 把参数 42 通过闭包"烤"进了 create 函数里;tag3 标记这个 Provider 来自哪个 Family;tag4 记录参数值。

回看上一篇的 ProviderPointerManager,Family 的 Provider 存储在 familyPointers 中,按 Family 分组:

graph LR
    subgraph "familyPointers"
        F["todoFamily"] --> D["ProviderDirectory"]
        D --> P1["todoFamily(1) → Element"]
        D --> P2["todoFamily(2) → Element"]
        D --> P3["todoFamily(42) → Element"]
    end

每个参数对应一个独立的 Provider 实例,有自己的 Element、自己的状态、自己的生命周期。todoFamily(1)todoFamily(2) 互不影响,就像同一条生产线上的不同产品——模具一样,但产品各自独立。


2. 类式 Family 的区别

类式 Family 的 call 方法略有不同:

---->[core/family.dart#ClassFamily]----
base class ClassFamily<NotifierT extends AnyNotifier<StateT, ValueT>,
    StateT, ValueT, ArgT, CreatedT,
    ProviderT extends $ClassProvider<NotifierT, StateT, ValueT, CreatedT>>
    extends Family {

  final NotifierT Function(ArgT arg) _createFn;

  ProviderT call(ArgT argument) {
    return _providerFactory(
      () => _createFn(argument),  // tag5: 参数传给 Notifier 的工厂函数
      name: name,
      isAutoDispose: isAutoDispose,
      from: this,
      argument: argument,
      // ...
    );
  }
}

tag5 处的区别:函数式 Family 把参数传给 (ref, arg) => ...,类式 Family 把参数传给 (arg) => Notifier()。本质一样,都是把参数"烤"进去,只是入口不同。


3. 参数的相等性:一个容易踩的坑

Family 用参数来区分不同的 Provider 实例。看 LegacyProviderMixin== 实现:

---->[core/provider/provider.dart#LegacyProviderMixin]----
base mixin LegacyProviderMixin<StateT> on $ProviderBaseImpl<StateT> {
  @override
  int get hashCode {
    if (from == null) return super.hashCode;
    return from.hashCode ^ argument.hashCode;  // tag6: 用 from + argument 算 hash
  }

  @override
  bool operator ==(Object other) {
    if (from == null) return identical(other, this);
    return other.runtimeType == runtimeType &&
        other is $ProviderBaseImpl<StateT> &&
        other.from == from &&
        other.argument == argument;  // tag7: 用 from + argument 判等
  }
}

tag6tag7 揭示了一个关键事实:Family 创建的 Provider 的相等性完全取决于 argument==hashCode

这意味着什么?如果你用一个没有正确实现 == 的对象作为参数,每次调用 family(param) 都会创建一个新的 Provider 实例,之前的实例变成孤儿——这是内存泄漏。

社区里有人踩过这个坑:用 ListMap 作为 family 参数,结果每次 build 都创建新实例。Dart 的 List 默认用引用比较,[1, 2][1, 2] 不相等。解决方案是用 Record 或者自定义的值对象。

如果你现在对这个问题还没有直观感受,不用急。先记住一条规则:family 的参数必须是不可变的值类型。后面踩坑的时候你会想起来的。


三、Select:看我想看

ref.watch(provider.select((state) => state.name)) 是 Riverpod 实现精准重建的核心机制。

打个比方:你每天看天气预报(原始 Provider),但你只关心温度(selector)。天气预报每小时都在更新——湿度变了、风向变了、紫外线指数变了——但只要温度没变,你就不需要重新决定穿什么衣服。


1. _ProviderSelector 的实现
---->[core/modifiers/select.dart#_ProviderSelector]----
final class _ProviderSelector<InputT, OutputT>
    implements ProviderListenable<OutputT> {

  _ProviderSelector({required this.provider, required this.selector});

  final ProviderListenable<InputT> provider;   // tag1: 原始 Provider
  final OutputT Function(InputT) selector;      // tag2: 选择器函数

  $Result<OutputT> _select($Result<InputT> value) {
    try {
      return switch (value) {
        $ResultData(:final value) => $Result.data(selector(value)),  // tag3: 应用选择器
        $ResultError(:final error, :final stackTrace) =>
            $Result.error(error, stackTrace),
      };
    } catch (err, stack) {
      return $Result.error(err, stack);
    }
  }
}

_ProviderSelector 是一个包装器。它持有原始 Provider(tag1)和一个选择器函数(tag2)。当原始 Provider 的值变化时,它先通过选择器提取出关心的部分(tag3)。

但光提取还不够,关键是比较。看 _selectOnChange 方法:

---->[core/modifiers/select.dart#_ProviderSelector#_selectOnChange]----
void _selectOnChange({
  required InputT newState,
  required $Result<OutputT>? lastSelectedValue,
  required void Function(Object error, StackTrace stackTrace) onError,
  required void Function(OutputT? prev, OutputT next) listener,
  required void Function($Result<OutputT> newState) onChange,
}) {
  final newSelectedValue = _select($Result.data(newState));
  if (lastSelectedValue == null ||
      !lastSelectedValue.hasData ||
      !newSelectedValue.hasData ||
      lastSelectedValue.value != newSelectedValue.value) {  // tag4: 用 != 比较新旧值
    onChange(newSelectedValue);
    switch (newSelectedValue) {
      case $ResultData(:final value):
        listener(lastSelectedValue?.value, value);  // tag5: 只有不同才通知
      case $ResultError(:final error, :final stackTrace):
        onError(error, stackTrace);
    }
  }
}

tag4 处是精准重建的核心:用 != 比较新旧选择结果。只有当提取出的值确实不同时,才执行 tag5 处的通知。

这意味着你的 selector 返回值必须正确实现 ==。如果返回的是一个每次都新建的对象(比如 List),即使内容相同也会被认为"变了",select 就失去了意义。这也是为什么 Riverpod 官方推荐 selector 返回基本类型(intStringbool)或者不可变的值对象。


2. 订阅的建立过程

_addListener 方法展示了 select 如何嵌入到订阅链中:

---->[core/modifiers/select.dart#_ProviderSelector#_addListener]----
ProviderSubscriptionImpl<OutputT> _addListener(
  Node node,
  void Function(OutputT? previous, OutputT next) listener, {
  required void Function(Object error, StackTrace stackTrace) onError,
  required void Function()? onDependencyMayHaveChanged,
  required bool weak,
}) {
  $Result<OutputT>? lastSelectedValue;
  final sub = provider._addListener(
    node,
    (prev, input) {
      _selectOnChange(                    // tag6: 原始值变化时,走 select 过滤
        newState: input,
        lastSelectedValue: lastSelectedValue,
        listener: providerSub._notifyData,
        onError: providerSub._notifyError,
        onChange: (newState) => lastSelectedValue = newState,
      );
    },
    weak: weak,
    onDependencyMayHaveChanged: onDependencyMayHaveChanged,
    onError: onError,
  );

  if (!weak) {
    lastSelectedValue = _select(sub.readSafe());  // tag7: 初始化时记录当前选择值
  }
  // ...
}

tag6 处是关键:_addListener 先订阅原始 Provider,但在回调里不是直接通知监听者,而是先走 _selectOnChange 过滤。只有选择结果变了,才真正通知。tag7 处在订阅建立时就记录了当前的选择值,作为后续比较的基准。

整个 select 的过滤链路可以用一张图概括:

sequenceDiagram
    participant P as 原始 Provider
    participant S as _ProviderSelector
    participant W as Widget / Consumer

    P->>S: 值变化:{name: "张三", age: 26}
    S->>S: selector(state) → "张三"
    S->>S: "张三" != "张三"? → 相等,跳过
    Note over W: 不重建 ✅

    P->>S: 值变化:{name: "李四", age: 26}
    S->>S: selector(state) → "李四"
    S->>S: "李四" != "张三"? → 不等,通知
    S->>W: listener("张三", "李四")
    Note over W: 重建 🔄

3. select 的链式调用

select 可以链式调用:

ref.watch(
  userProvider
    .select((user) => user.address)
    .select((address) => address.city)
);

每一层 select 都会创建一个新的 _ProviderSelector,形成一个链。只有最内层的值变化时才会触发重建。这在处理深层嵌套的状态对象时非常有用。


4. 和 InheritedModel 的对比

Flutter 的 InheritedModel 也能做切面级精准通知(上一篇 GetX 文章中提到的 MediaQuery.sizeOf)。两者的对比:

维度 Riverpod select InheritedModel aspect
粒度 任意函数,可以做计算 预定义的枚举切面
灵活性 极高,selector 可以是任意表达式 受限于预定义的 aspect
性能 每次变化都要执行 selector 函数 只比较 aspect 枚举
使用场景 通用 框架内部(MediaQuery、Theme)

Riverpod 的 select 更灵活,但代价是每次 Provider 变化都要执行 selector 函数。如果 selector 函数本身很重(比如遍历一个大列表),反而可能成为性能瓶颈。大多数情况下这不是问题,但值得知道。


四、Override:子目录覆盖父目录

Override 是 Riverpod 最强大的特性之一,也是它和 GetX 的核心差异。它让你可以在不同的 ProviderScope 中替换 Provider 的实现——就像文件系统里子目录可以覆盖父目录的同名文件,但父目录本身不受影响。


1. Override 的类型体系
---->[core/override.dart]----
sealed class Override {
  @visibleForTesting
  Override get origin;  // tag1: 被覆盖的对象
}

sealed class _ProviderOverride implements Override {
  ProviderBase<Object?> get origin;  // tag2: 被覆盖的 Provider
}

class $ProviderOverride implements _ProviderOverride {
  $ProviderOverride({required this.origin, required this.providerOverride});

  final ProviderBase<Object?> origin;           // tag3: 谁被覆盖
  final ProviderBase<Object?> providerOverride; // tag4: 覆盖成什么
}

tag3 是"谁被覆盖",tag4 是"覆盖成什么"。当容器查找一个 Provider 时,如果发现有 override,就用 tag4 的实现代替 tag3

注意 Overridesealed class——只有 Riverpod 内部能创建 Override 的子类。你不能自己 implements Override,只能通过 provider.overrideWith(...)provider.overrideWithValue(...) 来创建。这是一种防御性设计,防止用户搞出奇怪的 Override 实现。


2. Family 的 Override

Family 有自己的 Override 类型:

---->[core/override.dart#$FamilyOverride]----
abstract class $FamilyOverride implements _FamilyOverride {
  factory $FamilyOverride({
    required ProviderElement Function($ProviderPointer pointer) createElement,
    required Family from,
  }) = _FamilyOverrideImpl;

  Family get from;  // tag5: 被覆盖的 Family
  ProviderElement createElement($ProviderPointer pointer);  // tag6: 新的创建逻辑
}

tag5tag6 的设计很巧妙:Family Override 不是替换某个具体的 Provider,而是替换整个 Family 的创建逻辑。这意味着 todoFamily(1)todoFamily(2)todoFamily(42) 全部被覆盖,不需要一个一个替换。

再看 FunctionalFamilyoverrideWith 方法,就能理解这个机制是怎么串起来的:

---->[core/family.dart#$FunctionalFamilyOverride]----
base mixin $FunctionalFamilyOverride<CreatedT, ArgT> on Family {
  Override overrideWith(CreatedT Function(Ref ref, ArgT arg) create) {
    return $FamilyOverride(
      from: this,
      createElement: (pointer) {
        final provider =
            pointer.origin as $FunctionalProvider<Object?, Object?, CreatedT>;
        return provider
            .$view(create: (ref) => create(ref, provider.argument as ArgT))
            .$createElement(pointer);  // tag7: 用新的 create 函数创建 Element
      },
    );
  }
}

tag7 处的逻辑:拿到原始 Provider 的 argument(就是 family 的参数),传给新的 create 函数,然后用新函数创建 Element。这样无论你调用 todoFamily(1) 还是 todoFamily(999),都会走覆盖后的逻辑。


3. 覆盖的作用域
---->[flutter_riverpod/provider_scope.dart#ProviderScopeState]----
final class ProviderScopeState extends State<ProviderScope> {
  late final ProviderContainer container;

  @override
  void initState() {
    super.initState();
    final parent = _getParent();

    container = ProviderContainer(
      parent: parent,                    // tag8: 父容器
      overrides: widget.overrides,       // tag9: 覆盖列表
      observers: widget.observers,
      retry: widget.retry,
    );
  }
}

tag8tag9 是关键:子 ProviderScope 创建一个新的 ProviderContainer,以父容器为 parent,并应用自己的 overrides。子容器中的 Provider 会被覆盖,但父容器不受影响。

graph TD
    subgraph "根 ProviderScope"
        RC["ProviderContainer (root)"]
        RC --> TP["themeProvider → 亮色主题"]
        RC --> AP["apiProvider → 生产环境 API"]
    end

    subgraph "子 ProviderScope (override themeProvider)"
        CC["ProviderContainer (child)"]
        CC --> TP2["themeProvider → 暗色主题 ✨覆盖"]
        CC -.-> AP2["apiProvider → 继承父容器"]
    end

    RC --> CC

    style TP2 fill:#ffd,stroke:#333

这就是 Riverpod 的"作用域"能力。不同子树可以有不同的 Provider 实现,互不干扰。GetX 的全局 Map 做不到这一点——所有地方拿到的都是同一个实例。


4. 测试中的 Override

Override 在测试中极其有用:

---->[示例代码]----
testWidgets('显示用户名', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      overrides: [
        userRepositoryProvider.overrideWithValue(FakeUserRepository()),
        // 所有依赖 userRepositoryProvider 的 Provider 自动使用 Fake 实现
      ],
      child: MyApp(),
    ),
  );

  expect(find.text('张三'), findsOneWidget);
});

你不需要修改任何业务代码,只需要在测试的 ProviderScope 中覆盖依赖。所有依赖链上的 Provider 自动使用覆盖后的实现。这比 GetX 的 Get.put 手动替换要优雅得多,也更安全——覆盖的作用域是明确的,不会影响其他测试。


五、AsyncValue:异步状态的三体问题

处理异步状态是前端开发中最头疼的事情之一。一个网络请求有三种状态:加载中、成功、失败。再加上"刷新中但有旧数据"、"重试中"、"从缓存加载"……状态组合爆炸。

Riverpod 的 AsyncValue 就是为了解决这个问题。


1. 三种基本状态
---->[core/async_value.dart#AsyncValue]----
sealed class AsyncValue<ValueT> {
  const AsyncValue._();

  const factory AsyncValue.data(ValueT value) = AsyncData<ValueT>;
  const factory AsyncValue.loading({num progress}) = AsyncLoading<ValueT>;
  const factory AsyncValue.error(Object error, StackTrace stackTrace) = AsyncError<ValueT>;

  // tag1: 三个内部状态槽
  _LoadingRecord? get _loading;
  _DataRecord<ValueT>? get _value;
  _ErrorRecord? get _error;
}

停下来看 tag1AsyncValue 内部有三个独立的状态槽:_loading_value_error。它们不是互斥的——一个 AsyncValue 可以同时持有 loading 状态和旧数据。这是 AsyncValue 最精妙的设计。

graph TD
    AV["AsyncValue&lt;T&gt;<br/>(sealed class)"]

    AV --> AD["AsyncData&lt;T&gt;<br/>_value ✅ _loading? _error?"]
    AV --> AL["AsyncLoading&lt;T&gt;<br/>_loading ✅ _value? _error?"]
    AV --> AE["AsyncError&lt;T&gt;<br/>_error ✅ _loading? _value?"]

    style AD fill:#d4edda,stroke:#333
    style AL fill:#fff3cd,stroke:#333
    style AE fill:#f8d7da,stroke:#333

    Note["三个槽位独立存在<br/>一个 AsyncValue 可以同时<br/>持有 loading + 旧 data + error"]

    style Note fill:#e8f4fd,stroke:#999

AsyncValue 是一个 sealed class,只有三种子类型。用 Dart 3 的模式匹配可以穷举处理:

---->[示例代码]----
switch (ref.watch(userProvider)) {
  AsyncData(:final value) => Text(value.name),
  AsyncError(:final error) => Text('错误: $error'),
  AsyncLoading() => CircularProgressIndicator(),
}

编译器会强制你处理所有三种情况。漏了一种?编译不过。这比手动用 isLoading / hasError 标志位安全得多。


2. 复合状态:加载中但有旧数据

AsyncValue 的精妙之处在于它支持复合状态。看源码中这几个 getter:

---->[core/async_value.dart#AsyncValueExtensions]----
extension AsyncValueExtensions<ValueT> on AsyncValue<ValueT> {
  bool get isLoading => _loading != null;  // tag2: 有 loading 记录就是加载中

  bool get _hasState => hasValue || hasError;

  bool get isRefreshing => _hasState && isLoading && this is! AsyncLoading;
  // tag3: 有旧状态 + 加载中 + 不是纯 AsyncLoading = 刷新中

  bool get isReloading => _hasState && isLoading && this is AsyncLoading;
  // tag4: 有旧状态 + 加载中 + 是 AsyncLoading = 重载中

  bool get hasValue => _value != null;   // tag5: 有值
  bool get hasError => _error != null;   // tag6: 有错误

  num? get progress => _loading?.progress;  // tag7: 加载进度
}

tag3tag4 的区别值得细品。

isRefreshing:Provider 被 ref.refresh / ref.invalidate 手动触发重建,但保留旧数据。运行时类型不是 AsyncLoading(可能是 AsyncDataAsyncError),但 _loading 槽有值。用户看到的是旧数据 + 一个加载指示器。

isReloading:Provider 因为依赖变化而重建(ref.watch 的依赖变了),运行时类型是 AsyncLoading,但 _value 槽保留了旧数据。

两者的区别在于触发原因和运行时类型,但用户体验是一样的:旧数据还在,新数据在加载。这比"一刷新就白屏"的体验好太多了。

还有个 tag7 处的 progress——AsyncLoading 支持加载进度,你可以在 Notifier 里手动设置进度值,UI 层就能显示进度条。这个功能很多人不知道。


3. copyWithPrevious:状态过渡的秘密

这是 AsyncValue 最核心的方法。当 Provider 重建时,新的 AsyncLoading 怎么"带上"旧数据?

---->[core/async_value.dart#AsyncLoading#copyWithPrevious]----
// AsyncLoading 的 copyWithPrevious
AsyncValue<ValueT> copyWithPrevious(
  AsyncValue<ValueT> previous, {
  bool isRefresh = true,
}) {
  final previousValue =
      isRefresh
          ? previous._value
          : previous._value?.copyWith(source: (DataSource.reload,));

  if (isRefresh) {
    return previous.map(
      data: (previous) => AsyncData._(
        previousValue!,
        error: previous._error,
        loading: _loading,       // tag1: 把 loading 状态"注入"到旧的 AsyncData 里
      ),
      error: (previous) => AsyncError._(
        previous._error,
        loading: _loading,       // tag2: 把 loading 状态"注入"到旧的 AsyncError 里
        value: previousValue,
      ),
      loading: (_) => AsyncLoading._(
        _loading,
        value: previousValue,    // tag3: 纯 loading,但带上旧值
        error: previous._error,
      ),
    );
  } else {
    return AsyncLoading._(
      _loading,
      value: previousValue,
      error: previous._error,
    );
  }
}

给你三秒钟,看看 isRefresh: trueisRefresh: false 的区别。

答案在 tag1tag3

  • isRefresh: true(手动刷新):如果之前是 AsyncData,返回的还是 AsyncData,但 _loading 槽被填上了。运行时类型不变,所以 this is! AsyncLoading 为 true,isRefreshing 为 true。UI 层用 when(skipLoadingOnRefresh: true) 可以跳过 loading 状态,直接显示旧数据。
  • isRefresh: false(依赖变化导致的重载):无论之前是什么状态,都返回 AsyncLoading,但 _value 槽保留旧值。运行时类型是 AsyncLoading,所以 isReloading 为 true。

这个设计让"下拉刷新"和"切换筛选条件"有不同的 UI 表现,而你不需要写任何额外的状态管理代码。框架帮你把这些细节处理好了。

用一张图总结 copyWithPrevious 的状态过渡:

graph LR
    subgraph "isRefresh: true(手动刷新)"
        AD1["AsyncData(旧值)"] -->|copyWithPrevious| AD2["AsyncData(旧值)<br/>+ _loading ✅"]
        AE1["AsyncError(旧错误)"] -->|copyWithPrevious| AE2["AsyncError(旧错误)<br/>+ _loading ✅ + _value?"]
    end

    subgraph "isRefresh: false(依赖变化)"
        AD3["AsyncData(旧值)"] -->|copyWithPrevious| AL1["AsyncLoading<br/>+ _value(旧值)"]
        AE3["AsyncError(旧错误)"] -->|copyWithPrevious| AL2["AsyncLoading<br/>+ _value? + _error(旧)"]
    end

    style AD2 fill:#d4edda,stroke:#333
    style AE2 fill:#f8d7da,stroke:#333
    style AL1 fill:#fff3cd,stroke:#333
    style AL2 fill:#fff3cd,stroke:#333

再看 AsyncErrorcopyWithPrevious,更简单:

---->[core/async_value.dart#AsyncError#copyWithPrevious]----
AsyncError<ValueT> copyWithPrevious(
  AsyncValue<ValueT> previous, {
  bool isRefresh = true,
}) {
  return AsyncError._(_error, loading: _loading, value: previous._value);
  // tag4: 出错了,但保留旧值
}

tag4 处:即使出错了,旧的 _value 也会被保留。这样 UI 可以同时显示错误信息和旧数据——"加载失败,显示的是上次的数据"。

AsyncDatacopyWithPrevious 最简单——直接返回自己:

---->[core/async_value.dart#AsyncData#copyWithPrevious]----
AsyncData<ValueT> copyWithPrevious(
  AsyncValue<ValueT> previous, {
  bool isRefresh = true,
}) {
  return this;  // tag5: 有新数据了,不需要旧的
}

tag5 处:数据已经到了,不需要保留旧状态。干净利落。


4. asyncTransition:框架内部的调用入口

copyWithPrevious 不是你直接调用的,它在 ProviderElementasyncTransition 中被使用:

---->[core/element.dart#ElementWithFuture#asyncTransition]----
void asyncTransition(AsyncValue<ValueT> newState, {required bool seamless}) {
  final previous = value;

  if (newState._isMultiState) {
    super.value = newState;  // tag6: 已经是复合状态,直接用
    return;
  }

  super.value = newState.cast<ValueT>().copyWithPrevious(
    previous,
    isRefresh: seamless,  // tag7: seamless = true → refresh,false → reload
  );
}

tag7 处的 seamless 参数就是控制 isRefresh 的开关。seamless: true 意味着"无缝过渡"——保留旧数据,跳过 loading;seamless: false 意味着"有感过渡"——保留旧数据,但优先显示 loading。


5. 错误重试

Riverpod 内置了错误重试机制。看 ProviderOrFamily 中的 retry 字段:

---->[core/foundation.dart#ProviderOrFamily]----
sealed class ProviderOrFamily {
  /// 默认重试策略:
  /// - 最多 10 次
  /// - 起始延迟 200ms
  /// - 每次翻倍,最大 6.4s
  /// - 忽略 ProviderException(其他 Provider 抛出的错误)
  /// - 忽略 Error 类型(通常是编程错误)
  final Retry? retry;
}

再看 ProviderElementtriggerRetry 的实现:

---->[core/element.dart#ProviderElement#triggerRetry]----
AsyncValue<ValueT> triggerRetry(Object error, StackTrace stackTrace) {
  var retrying = false;

  if (!_disposed) {
    final retry =
        origin.retry ?? container.retry ?? ProviderContainer.defaultRetry;

    container.runGuarded(() {
      final duration = retry(_retryCount, error);  // tag8: 计算延迟
      if (duration == null) return;                 // tag9: 返回 null 表示停止重试

      retrying = true;
      _pendingRetryTimer = Timer(duration, () {
        _pendingRetryTimer = null;
        _retryCount++;
        invalidateSelf(asReload: false);  // tag10: 定时器到期后重新构建
      });
    });
  }

  if (retrying) {
    return AsyncLoading._(                // tag11: 重试中,保留旧值和错误信息
      value._loading ?? (progress: 0),
      value: value._value,
      error: (err: error, stack: stackTrace, retrying: true),
    );
  }

  return AsyncError(error, stackTrace, retrying: false);
}

tag8tag11 展示了完整的重试流程:调用 retry 函数计算延迟(tag8),如果返回 null 就停止(tag9),否则设置定时器,到期后 invalidateSelf 触发重建(tag10)。重试期间返回的 AsyncValue 同时携带 loading 状态、旧值和错误信息(tag11),UI 可以显示"加载失败,正在重试..."。

注意 tag11error 记录里有个 retrying: true 标志。对应到 AsyncValueExtensions 中:

bool get retrying => _errorFilled?.retrying ?? false;

这样 UI 层可以区分"出错了,等待用户操作"和"出错了,正在自动重试"。

这个功能在网络不稳定的场景下非常实用。以前你得自己写重试逻辑,现在框架帮你做了。


六、autoDispose:用完即走

autoDispose 是 Riverpod 的自动内存管理机制。当一个 Provider 没有任何监听者时,它的状态会被自动销毁。


1. 销毁的判断逻辑
---->[core/scheduler.dart#ProviderScheduler#_performDispose]----
void _performDispose() {
  for (var i = 0; i < _stateToDispose.length; i++) {
    final element = _stateToDispose[i];
    final links = element.ref?._keepAliveLinks;

    if ((links != null && links.isNotEmpty) ||  // tag1: 有 keepAlive link
        element.container._disposed ||           // tag2: 容器已销毁
        element.hasNonWeakListeners) {           // tag3: 还有强监听者
      continue;  // 跳过,不销毁
    }

    if (element.weakDependents.isEmpty) {
      element.container._disposeProvider(element.origin);  // tag4: 彻底销毁
    } else {
      element.clearState();  // tag5: 清除状态但保留指针
    }
  }
}

tag1tag3 是三道"保护锁":有 keepAlive link、容器已销毁、还有强监听者——任何一个条件满足,都不会销毁。

flowchart TD
    Start["Provider 进入待销毁列表"] --> C1{"有 keepAlive link?"}
    C1 -->|是| Skip["跳过,不销毁 ✅"]
    C1 -->|否| C2{"容器已 disposed?"}
    C2 -->|是| Skip
    C2 -->|否| C3{"还有强监听者?"}
    C3 -->|是| Skip
    C3 -->|否| C4{"有弱监听者?"}
    C4 -->|是| Clear["clearState()<br/>清除状态,保留指针"]
    C4 -->|否| Dispose["disposeProvider()<br/>彻底销毁"]

    style Skip fill:#d4edda,stroke:#333
    style Clear fill:#fff3cd,stroke:#333
    style Dispose fill:#f8d7da,stroke:#333

tag4tag5 的区别也值得注意:如果没有弱监听者,彻底销毁(连指针都删);如果还有弱监听者,只清除状态但保留指针。这样弱监听者下次读取时会触发重新初始化,而不是拿到一个空指针。

打个比方:图书馆的书,如果没人借也没人浏览,直接下架(tag4);如果没人借但有人在浏览记录里标记了"想看",就先把书放回仓库(tag5),下次有人来找的时候再摆出来。


2. 销毁的时序:先刷新,再销毁

回看调度器的 _task 方法:

---->[core/scheduler.dart#ProviderScheduler#_task]----
void _task() {
  _cancel = null;
  final pendingTaskCompleter = _pendingTaskCompleter;
  if (pendingTaskCompleter == null) return;
  pendingTaskCompleter.complete();

  _performRefresh();   // tag6: 先刷新
  _performDispose();   // tag7: 再销毁
  stateToRefresh.clear();
  _stateToDispose.clear();
  _pendingTaskCompleter = null;
}

tag6tag7 的顺序不能反:先刷新,再销毁。如果先销毁,可能会把正在被依赖的 Provider 销毁掉,导致刷新时找不到依赖。这个顺序保证在上一篇中也提到过,这里再强调一次——因为它真的很重要。


3. 和 GetX SmartManagement 的对比
维度 Riverpod autoDispose GetX SmartManagement
触发条件 没有监听者 路由退出
粒度 每个 Provider 独立 按路由批量
跨页面共享 自然支持(有监听者就不销毁) 需要 permanent 标记
手动控制 keepAlive link permanent / tag
时序问题 无(基于监听者计数) 有(依赖路由生命周期)

Riverpod 的 autoDispose 基于"有没有人在用",GetX 的 SmartManagement 基于"路由有没有退出"。前者更精确,后者更简单但有边界条件。


七、源码中值得学习的模式


1. sealed class 穷举

AsyncValue 用 sealed class 强制穷举所有状态,Override 也是 sealed class。这是 Dart 3 的杀手级特性,Riverpod 用得很到位。在你自己的项目中,任何"有限状态集合"的场景都可以用这个模式。


2. 三槽复合状态

AsyncValue_loading_value_error 三个独立槽位,允许一个值同时处于多种状态。这比传统的"互斥枚举"灵活得多。如果你的业务中也有"加载中但有缓存数据"这类需求,可以参考这个设计。


3. 分层的 Provider 类型

函数式和类式的分离不是为了炫技,而是为了让不同场景有最合适的工具。派生状态用函数式(简洁),可变状态用类式(灵活)。这种"按场景分工"的设计思路值得借鉴。


4. Family 的相等性设计

Family 创建的 Provider 通过 from + argument 来判等,而不是用 identical。这让同一个参数在不同地方调用 family(42) 能拿到同一个 Provider 实例。但代价是参数必须正确实现 ==。这种"用值相等代替引用相等"的设计,在缓存和去重场景中很常见。


5. Override 的作用域隔离

Override 只影响当前 ProviderScope 及其子树,不影响父级。这种"向下传播、不向上污染"的设计,和 Flutter 的 InheritedWidget 是同一个思路。在你自己的架构设计中,任何"配置覆盖"的场景都可以参考这个模式。


碎碎念

写完这篇,Riverpod 的类型系统和核心机制基本讲清楚了。回头看,Riverpod 的设计有一个很明显的特点:它不怕复杂,但它把复杂度封装在框架内部,暴露给用户的 API 是简洁的。

AsyncValue 内部有三个状态槽、copyWithPrevious 的分支逻辑、isRefreshingisReloading 的微妙区别——这些你不需要知道。你只需要写 switch (asyncValue) 然后处理三种情况就行了。但知道了之后,遇到"为什么刷新时旧数据还在"、"为什么重试时 UI 没变化"这类问题时,你能更快地定位原因。

有人说 Riverpod 学习曲线陡。这话对了一半。API 层面的学习曲线其实不陡——ref.watchref.readref.listen,三个方法覆盖 90% 的场景。陡的是概念层面:Provider 的类型选择、autoDispose 的时机、Family 的参数设计、Override 的作用域。这些概念需要时间消化,但一旦理解了,你会发现它们是一套自洽的体系。

认识事物是一个过程。如果你现在觉得 AsyncValue 的三槽设计太绕了,不用急。先用起来,遇到问题再回来看源码,会清晰很多。

下一篇是最后一篇,我们聊 Riverpod 和 Flutter Widget 树的集成机制——ProviderScope、ConsumerWidget、WidgetRef 的源码实现,以及四大方案的终极对比。


我是张风捷特烈,如果你对 Flutter 框架的源码分析感兴趣,欢迎关注。这是「状态管理大乱斗」系列的第5 篇(中),下一篇聊聊 Riverpod 和 Flutter Widget 树的集成机制,以及四大方案的终极对比。

Rust 生命周期开发实战:从"编译不过"到"一次过编"的实用指南

作者 土豆1250
2026年4月30日 00:31

写给谁: 你已经知道 Rust 有个叫"生命周期"的东西,也许还被 error[E0106] 毒打过几轮。你想要的不是再背一遍语法规则,而是——下次遇到实际场景,我该怎么做决策? 本文就是你的"生命周期实战决策手册"。


目录

  1. Why:你真的需要手写生命周期吗?
  2. What:30 秒建立正确心智模型
  3. How:六个真实开发场景逐一拆解
    • 场景一:函数返回引用 —— 经典入门
    • 场景二:结构体借用外部数据 —— 零拷贝解析
    • 场景三:多个引用参数 —— 精准标注的艺术
    • 场景四:Cow<str> —— 借还是拥有,我全都要
    • 场景五:闭包与回调 —— 生命周期的隐形陷阱
    • 场景六:异步代码 —— 'static 的正确打开方式
  4. 生命周期省略规则速查 —— 编译器帮你干的活
  5. 五大常见误区与急救方案
  6. 决策流程图:遇到生命周期问题该怎么办?
  7. 最佳实践清单

一、Why:你真的需要手写生命周期吗?

先来一个灵魂拷问

很多 Rust 初学者的学习路径是这样的:

  1. 看到教程说"引用有生命周期" → 点头 ✅
  2. 看到 'a 语法 → 一脸懵 😵
  3. 尝试写代码 → 编译器报错 → 疯狂加 'a → 越加越乱 → 怀疑人生 💀
  4. 最后一怒之下 .clone() 全场,性能什么的先放一边

如果这是你,恭喜,你是正常人。

真相:90% 的代码不需要手写生命周期

Rust 编译器有一套生命周期省略规则(Lifetime Elision Rules),大部分简单场景它能自动推断。手写 'a 的场景其实只有三个:

必须手写的场景 原因
函数有多个引用参数,且返回引用 编译器猜不出返回值从谁那里借的
结构体字段是引用 类型定义必须显式声明借用关系
复杂泛型/Trait Object 需要约束 编译器需要你明确生命周期的边界

所以,我们的策略是:先不写,让编译器告诉你什么时候该写。 这不是偷懒,而是 Rust 社区推荐的正确做法。

但你必须理解它

"不需要手写"不等于"不需要理解"。当你遇到以下场景时,不理解生命周期就像不带地图进沙漠:

  • 设计零拷贝的高性能解析器
  • 封装一个返回引用的 API
  • 给结构体加上缓存字段
  • tokio::spawn 启动异步任务时,闭包捕获了外部引用

理解生命周期的目标不是"能手写复杂标注",而是能读懂编译器的错误信息,并快速做出正确的设计决策


二、What:30 秒建立正确心智模型

一句话版本

生命周期标注是你和编译器之间的"借条"——你告诉编译器"这个引用是从谁那里借的,最晚什么时候还",编译器负责检查你有没有说谎。

三个铁律(贴在显示器上)

铁律 1:生命周期标注不改变任何值的实际存活时间
        ——它是"描述",不是"控制"

铁律 2:生命周期标注不产生任何运行时代码
        ——编译后完全擦除,零开销

铁律 3:编译器永远选最保守的方案
        ——当多个参数标同一个 'a,实际取最短的那个

一个视觉类比

想象你在图书馆借书:

你(调用方)   →   借了一本书(获得引用)
图书馆(数据)  →   规定还书期限(生命周期)
管理员(编译器)→   检查你是否在期限内归还

生命周期标注 = 借书单上的"还书日期"

你不能通过修改借书单来延长图书馆的营业时间。同理,'a 只是标注,不能让数据活得更久。


三、How:六个真实开发场景逐一拆解

场景一:函数返回引用 —— 一切的起点

需求: 写一个函数,接收两个字符串切片,返回较长的那个。

// ❌ 第一次尝试:直接写,编译不过
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() { x } else { y }
}
// error[E0106]: missing lifetime specifier
// 编译器:"返回的引用是从 x 借的还是 y 借的?你不说我怎么检查?"

编译器的困惑: 返回值可能来自 x,也可能来自 y,而 xy 的存活时间可能不同。编译器无法自动判断返回值该跟谁的"还书期限"走。

// ✅ 加上生命周期标注
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

你在告诉编译器什么?

  • xy 都至少活到 'a
  • 返回值也只在 'a 范围内有效
  • 'a 的实际长度 = xy较短的那个

调用方的视角:

fn main() {
    let s1 = String::from("长字符串");
    let result;
    {
        let s2 = String::from("短");
        result = longest(&s1, &s2);
        println!("{}", result); // ✅ s1 和 s2 都还活着
    }
    // println!("{}", result); // ❌ s2 已经释放,result 可能指向它
}

实战心法: 当函数返回引用时,问自己——"返回值是从哪个参数借来的?"答案决定了生命周期标注。

场景二:结构体借用外部数据 —— 零拷贝解析

需求: 解析一段 HTTP 请求文本,把 method、path、version 提取出来,但不想复制字符串(零拷贝)。

// 结构体包含引用字段,必须声明生命周期参数
struct HttpRequest<'a> {
    method: &'a str,
    path: &'a str,
    version: &'a str,
}

impl<'a> HttpRequest<'a> {
    /// 零拷贝解析:所有字段都是原始文本的切片
    fn parse(raw: &'a str) -> Option<Self> {
        let mut parts = raw.splitn(3, ' ');
        Some(HttpRequest {
            method: parts.next()?,
            path: parts.next()?,
            version: parts.next()?.trim(),
        })
    }

    fn is_get(&self) -> bool {
        self.method == "GET"
    }
}

fn main() {
    let raw_request = String::from("GET /api/users HTTP/1.1");

    let req = HttpRequest::parse(&raw_request).unwrap();
    println!("方法: {}, 路径: {}", req.method, req.path);
    // raw_request 必须比 req 活得长——编译器自动确保这一点

    // drop(raw_request); // ❌ 如果取消注释,编译错误!req 还在用它
}

设计决策要点:

选择 优点 缺点 适用场景
&'a str(引用) 零拷贝,极致性能 生命周期约束 解析器、只读视图
String(拥有) 无生命周期约束 有分配和拷贝开销 需要独立存储、跨线程传递
Cow<'a, str> 灵活切换 代码稍复杂 大部分借用、偶尔修改

实战心法: 结构体用引用字段 = 选择了零拷贝性能,但必须接受生命周期约束。如果这个约束让你的代码变得太复杂,切换到 String 是完全合理的选择——代码可读性比零拷贝更重要

场景三:多个引用参数 —— 精准标注的艺术

需求: 在一段文本中搜索关键词,返回包含关键词的上下文。

// 返回值只来自 text,与 keyword 无关
// 所以只有 text 需要和返回值共享生命周期
fn search<'a>(text: &'a str, keyword: &str) -> Vec<&'a str> {
    text.lines()
        .filter(|line| line.contains(keyword))
        .collect()
}

fn main() {
    let article = String::from("Rust 是安全的\nRust 是快的\nGo 也不错");
    let results;
    {
        let kw = String::from("Rust");
        results = search(&article, &kw);
    } // kw 在这里释放——没问题!返回值不依赖它
    println!("找到 {} 行包含关键词", results.len()); // ✅
}

对比:如果无脑标同一个 'a……

// ❌ 过度约束!
fn search_bad<'a>(text: &'a str, keyword: &'a str) -> Vec<&'a str> {
    text.lines().filter(|line| line.contains(keyword)).collect()
}

fn main() {
    let article = String::from("Rust 是安全的");
    let results;
    {
        let kw = String::from("Rust");
        results = search_bad(&article, &kw);
    } // kw 释放
    // println!("{:?}", results); // ❌ 编译错误!'a 被限制为 kw 的短命周期
}

实战心法: "最小约束原则"——返回值从谁那里借的,就只约束谁。不相关的参数给独立的(或省略的)生命周期。

场景四:Cow<str> —— 借还是拥有,我全都要

需求: 一个文本处理函数,大部分时候原样返回输入(不拷贝),只在需要修改时才创建新字符串。

use std::borrow::Cow;

/// 如果文本包含敏感词就替换,否则原样返回
fn sanitize<'a>(input: &'a str) -> Cow<'a, str> {
    if input.contains("密码") {
        // 需要修改 → 创建新 String → Cow::Owned
        Cow::Owned(input.replace("密码", "***"))
    } else {
        // 不需要修改 → 借用原始数据 → Cow::Borrowed
        Cow::Borrowed(input)
    }
}

fn main() {
    let s1 = "用户名: alice";
    let s2 = "密码: 123456";

    let r1 = sanitize(s1); // Cow::Borrowed —— 零拷贝
    let r2 = sanitize(s2); // Cow::Owned —— 分配了新 String

    println!("{}", r1); // "用户名: alice"
    println!("{}", r2); // "***:  123456"

    // Cow 实现了 Deref<Target=str>,所以可以当 &str 用
    let len: usize = r1.len() + r2.len();
    println!("总长度: {}", len);
}

什么时候用 Cow

你的函数需要返回字符串数据吗?
 ├── 永远只读不改 → &'a str
 ├── 永远需要创建新数据 → String
 └── 有时读有时改 → Cow<'a, str> ← 就是这个

实战心法: Cow 是"性能优化 + 灵活性"的终极方案。在日志处理、配置解析、模板渲染等场景中极其常见。

场景五:闭包与回调 —— 生命周期的隐形陷阱

需求: 返回一个闭包,这个闭包捕获了外部的引用。

// ❌ 直觉写法——编译不过
// fn make_greeter(prefix: &str) -> impl Fn(&str) -> String {
//     move |name| format!("{}, {}!", prefix, name)
// }
// error: 返回值可能引用了函数参数 `prefix`

// ✅ 显式标注闭包捕获的生命周期
fn make_greeter<'a>(prefix: &'a str) -> impl Fn(&str) -> String + 'a {
    move |name| format!("{}, {}!", prefix, name)
}

fn main() {
    let greeting = String::from("你好");
    let greeter = make_greeter(&greeting);
    println!("{}", greeter("世界"));  // "你好, 世界!"
    println!("{}", greeter("Rust")); // "你好, Rust!"
    // greeting 必须比 greeter 活得长
}

+ 'a 是什么意思?

impl Fn(&str) -> String + 'a 读作:"返回的闭包内部捕获了生命周期为 'a 的引用"。编译器需要知道这个信息来确保闭包不会比被捕获的数据活得更久。

替代方案:让闭包拥有数据

// 如果不想管生命周期,直接把数据 move 进闭包
fn make_greeter_owned(prefix: String) -> impl Fn(&str) -> String {
    move |name| format!("{}, {}!", prefix, name)
}
// 调用方:make_greeter_owned("你好".to_string())
// prefix 的所有权转移给了闭包,没有生命周期问题

实战心法: 闭包 = 编译器帮你生成的匿名结构体。闭包捕获引用 = 结构体字段是引用 = 需要生命周期。如果你不想要这个复杂度,把数据 move 进闭包(转移所有权)。

struct HttpRequest {
    method: &str,   // error[E0106]
    path: &str,     // error[E0106]
    version: &str,  // error[E0106]
}

// ✅ 正确写法:告诉编译器"我的字段都是借来的"

struct HttpRequest<'buf> {
    method: &'buf str,
    path: &'buf str,
    version: &'buf str,
}

// impl 块必须重复声明生命周期参数——别问为什么,Rust 就这个脾气
impl<'buf> HttpRequest<'buf> {
    fn parse(raw: &'buf str) -> Option<Self> {
        let mut parts = raw.splitn(3, ' ');
        Some(HttpRequest {
            method: parts.next()?,
            path: parts.next()?,
            version: parts.next()?.trim(),
        })
    }

    fn is_get(&self) -> bool {
        self.method == "GET"
    }

    fn full_url(&self, host: &str) -> String {
        // 返回 String(拥有所有权),不需要生命周期标注
        format!("http://{}{}", host, self.path)
    }
}

fn main() {
    let raw_request = String::from("GET /api/users HTTP/1.1");
    // raw_request 必须比 request 活得长——编译器确保这一点
    let request = HttpRequest::parse(&raw_request).unwrap();
    println!("{} {} {}", request.method, request.path, request.version);
    println!("完整 URL: {}", request.full_url("example.com"));
}

为什么叫"零拷贝"? HttpRequest 的三个字段都是 &'buf str,它们直接指向原始字符串 raw_request 的不同位置,没有分配任何新内存。如果用 String 存储,每个字段都要做一次堆分配和内存拷贝。

实战心法: 结构体有引用字段 → 必须声明生命周期参数。impl 块别忘了跟着声明。结构体实例不能比它借用的数据活得更久。

场景三:多个引用参数 —— 精准标注的艺术

需求: 在一段文本中搜索关键词,返回匹配的上下文片段。

// ❌ 无脑全标 'a——过度约束
fn search_bad<'a>(text: &'a str, keyword: &'a str) -> Option<&'a str> {
    text.find(keyword).map(|i| &text[i..])
}
// 问题:keyword 也被绑定到 'a,意味着 keyword 必须和 text 活一样长
// 调用方不得不让一个临时的 keyword 活得很久——很不灵活

// ✅ 精准标注——返回值只来自 text,keyword 给独立的生命周期
fn search<'text>(text: &'text str, keyword: &str) -> Option<&'text str> {
    text.find(keyword).map(|i| &text[i..])
}

fn main() {
    let article = String::from("Rust 的生命周期让内存安全成为编译期保证");
    let result;
    {
        let kw = String::from("内存安全");
        result = search(&article, &kw); // keyword 只在调用时需要存在
    } // kw 在这里释放——完全没问题!
    println!("{:?}", result); // ✅ result 来自 article,article 还活着
}

核心原则:最小约束原则

┌─────────────────────────────────────────────────────────┐
│  返回值来自谁 → 和谁共享生命周期                       │
│  与返回值无关的参数 → 独立生命周期(或者直接省略)     │
│  不要无脑把所有参数都标成同一个 'a !                  │
└─────────────────────────────────────────────────────────┘

实战心法: 标注生命周期就像签合同——只在真正有借贷关系的双方之间签,不要拉无关的人进来当担保。

场景四:Cow<str> —— 借还是拥有,我全都要

需求: 写一个文本清洗函数,如果文本已经干净就直接返回原引用(零拷贝),如果需要修改就返回新字符串。

use std::borrow::Cow;

/// 清洗用户输入:去掉首尾空白,如果包含敏感词就替换
fn sanitize<'a>(input: &'a str) -> Cow<'a, str> {
    let trimmed = input.trim();
    if trimmed == input && !input.contains("敏感词") {
        // 不需要修改 → 直接借用,零分配
        Cow::Borrowed(input)
    } else {
        // 需要修改 → 创建新字符串
        Cow::Owned(trimmed.replace("敏感词", "***"))
    }
}

fn main() {
    let clean = "正常内容";
    let dirty = "  包含敏感词的内容  ";

    let r1 = sanitize(clean);  // Cow::Borrowed,零拷贝
    let r2 = sanitize(dirty);  // Cow::Owned,分配了新字符串

    // 两者都能当 &str 用,调用方无感知
    println!("{}", r1);
    println!("{}", r2);

    // 需要 String 时?
    let owned: String = r2.into_owned();
    println!("转为 String: {}", owned);
}

Cow 的本质: Cow<'a, str> 是一个枚举,要么借用 &'a str,要么拥有 String。它是"性能"和"灵活性"之间的完美平衡点。

什么时候该用 Cow

场景 推荐
数据大概率不需要修改 Cow(大部分时间零拷贝)
数据总是需要修改 ❌ 直接用 StringCow 是多余的
不确定需不需要修改 Cow(让运行时决定)
API 要同时接受 &strString Cow(或者用 Into<String>

实战心法: 当你纠结"该用 &str 还是 String"时,Cow<str> 可能就是你要的答案。

场景五:闭包与回调 —— 生命周期的隐形陷阱

需求: 创建一个事件处理器,允许注册回调函数。

/// 事件处理器:存储回调闭包
struct EventEmitter<'a> {
    listeners: Vec<Box<dyn Fn(&str) + 'a>>,
    //                            ^^^ 闭包可能捕获了带生命周期的引用
}

impl<'a> EventEmitter<'a> {
    fn new() -> Self {
        EventEmitter { listeners: Vec::new() }
    }

    fn on<F: Fn(&str) + 'a>(&mut self, callback: F) {
        self.listeners.push(Box::new(callback));
    }

    fn emit(&self, event: &str) {
        for listener in &self.listeners {
            listener(event);
        }
    }
}

fn main() {
    let prefix = String::from("[LOG]");
    let mut emitter = EventEmitter::new();

    // 闭包捕获了 prefix 的引用
    emitter.on(move |event| {
        println!("{} {}", prefix, event);
    });

    emitter.emit("用户登录");
    emitter.emit("用户登出");

    // 如果用 &prefix 而不是 move:
    // emitter 就不能比 prefix 活得长——编译器会阻止你犯错
}

闭包的秘密: 闭包本质上是编译器帮你生成的一个匿名结构体。如果闭包捕获了引用,那它就是一个"带生命周期的结构体"。所以 dyn Fn(&str) + 'a 中的 'a 就是在约束闭包内部捕获的引用的有效期。

常见选择:

  • 'a → 闭包可以捕获带生命周期的引用,但 EventEmitter 不能比引用源活得长
  • 'static → 闭包不能捕获短暂引用,但 EventEmitter 可以在任何地方使用(包括跨线程)
  • move 闭包 → 闭包拥有捕获的数据,不涉及借用,最简单

实战心法: 存储闭包时,先试 'static(最简单)。如果闭包需要借用外部数据,再放宽到 'a。实在搞不定?move 闭包 + .clone() 解君愁。

场景六:异步代码 —— 'static 的正确打开方式

需求:tokio::spawn 启动一个异步任务。

use tokio;

// ❌ 这段代码编译不过
async fn broken_example() {
    let data = String::from("重要数据");
    let data_ref = &data;

    tokio::spawn(async move {
        // data_ref 是对局部变量的借用
        // tokio::spawn 要求 Future: 'static
        // 但 data_ref 的生命周期不是 'static
        println!("{}", data_ref); // error!
    });
}

// ✅ 方案 1:移动所有权进异步任务
async fn solution_1() {
    let data = String::from("重要数据");

    tokio::spawn(async move {
        // data 被 move 进来,异步任务拥有它
        println!("{}", data); // ✅
    });
    // 注意:data 在这之后不能再使用了
}

// ✅ 方案 2:先 clone,再 move
async fn solution_2() {
    let data = String::from("重要数据");
    let data_clone = data.clone();

    tokio::spawn(async move {
        println!("{}", data_clone); // ✅ clone 的数据被 move 进来
    });

    // data 本身还可以继续用
    println!("原始数据还在: {}", data);
}

// ✅ 方案 3:用 Arc 共享(适合需要多个任务读取的场景)
use std::sync::Arc;

async fn solution_3() {
    let data = Arc::new(String::from("重要数据"));

    for i in 0..3 {
        let data = Arc::clone(&data);
        tokio::spawn(async move {
            println!("任务 {}: {}", i, data); // ✅ Arc<String>: 'static
        });
    }
}

为什么 tokio::spawn 要求 'static

tokio::spawn 创建的任务可能在任意时刻执行和完成。如果任务持有一个短暂引用,引用源可能在任务执行前就被释放——这就是悬垂指针。'static 约束确保任务持有的数据不依赖于任何可能过期的借用。

关键认知:T: 'static ≠ "T 永远存在"

T: 'static 的真正含义:
  "T 这个类型不包含任何可能过期的借用"

满足 T: 'static 的类型:
  ✅ String    (拥有自己的数据)
  ✅ Vec<i32>  (拥有自己的数据)
  ✅ i32       (没有引用)
  ✅ Arc<T>    (共享所有权)
  ❌ &str      (借用,可能过期)
  ❌ &Vec<i32> (借用,可能过期)

实战心法: 异步任务需要 'static 时,三板斧——move 所有权、.clone()moveArc 共享。根据性能需求选择合适的方案。


四、生命周期省略规则速查 —— 编译器帮你干的活

省略规则是 Rust 编译器内置的三条"自动推断规则"。理解它们,你就知道什么时候不用写 'a,什么时候必须写。

三条规则

规则一:每个引用参数自动获得独立的生命周期。

fn foo(x: &str, y: &str)                // 你写的
fn foo<'a, 'b>(x: &'a str, y: &'b str)  // 编译器理解为

规则二:如果恰好只有一个输入引用,所有输出引用都用它的生命周期。

fn bar(x: &str) -> &str                // 你写的
fn bar<'a>(x: &'a str) -> &'a str      // 编译器理解为
// 只有一个输入引用 → 输出必然来自它 → 完美推断

规则三(方法专属):如果有 &self&mut self,所有输出引用都绑定到 self

impl MyStruct {
    fn name(&self) -> &str { ... }              // 你写的
    fn name<'a>(&'a self) -> &'a str { ... }    // 编译器理解为
    // 方法的返回值通常来自 self 的数据 → 编译器大胆假设
}

速查表:什么时候能省,什么时候不能

函数签名 能省略? 规则
fn f(x: &str) -> &str 规则二:唯一输入
fn f(&self) -> &str 规则三:方法
fn f(&self, x: &str) -> &str 规则三:方法优先
fn f(x: &str, y: &str) -> &str 两个输入,不知该跟谁
fn f(x: &str) -> (&str, &str) 规则二:所有输出用同一个
struct S { f: &str } 省略规则只适用于函数签名
fn f(x: &str) -> String N/A 返回值无引用,不涉及

实战心法: 先不写生命周期,让编译器告诉你。如果编译器报 E0106,再根据"返回值从谁那里借来的"加标注。


五、五大常见误区与急救方案

误区 1:"生命周期能延长变量的存活时间"

这是最常见的误解。'a 是描述,不是魔法。

// ❌ 试图用生命周期"续命"
fn try_extend<'a>() -> &'a str {
    let local = String::from("我想活久一点");
    &local // 编译器:做梦呢?local 在函数结束就被释放了
}
// error: cannot return reference to local variable

// ✅ 返回拥有所有权的数据
fn correct() -> String {
    String::from("我有自己的命") // 移动所有权给调用方
}

急救方案: 如果函数内部创建了数据,不要返回引用,返回拥有所有权的类型(StringVec 等)。

误区 2:"编译报错就加 'static"

这就像头疼就吃安眠药——症状没了,但问题更大了。

// ❌ 盲目加 'static
fn get_name(user: &User) -> &'static str {
    &user.name // 编译器:user.name 不是 'static!
}

// ✅ 让生命周期自然关联
fn get_name(user: &User) -> &str {
    &user.name // 省略规则自动绑定到 &user
}

急救方案: 'static 只用于两种场景——字符串字面量 "hello" 和 Trait Bound T: 'static。如果你在函数签名的返回值上写了 'static,99% 是错的。

误区 3:"全部标同一个 'a 总没错吧"

错!过度约束会让你的 API 极难使用。

// ❌ 全标 'a:调用方必须让 data、config、logger 全活一样长
fn process<'a>(data: &'a str, config: &'a str, logger: &'a str) -> &'a str {
    &data[..5]
}

// ✅ 精准标注:只有返回值和 data 有关系
fn process<'d>(data: &'d str, config: &str, logger: &str) -> &'d str {
    &data[..5]
}

急救方案: 只给返回值及其来源参数标注相同的生命周期,其他参数让编译器自动处理。

误区 4:".clone() 是罪过"

很多教程把 .clone() 妖魔化了。实际上:

// 场景:你只是想快速完成功能,数据量也不大
fn get_display_name(user: &User) -> String {
    format!("{} ({})", user.name, user.role) // 返回新 String,没有生命周期烦恼
}

// 场景:在循环中处理大量数据
fn process_logs(logs: &[LogEntry]) -> Vec<String> {
    logs.iter()
        .map(|log| format!("[{}] {}", log.level, log.message))
        .collect() // 每条日志都分配了新字符串——这个场景可以接受
}

什么时候 clone 是合理的?

  • 数据量小(几 KB 以内)
  • 不在热路径上(不是每秒调用上百万次)
  • 用 clone 能让代码简单十倍
  • 你是在原型阶段,先让功能跑起来

什么时候应该用引用替代 clone?

  • 解析大文件(MB 级别),每次 clone 都是性能损失
  • 热路径上的关键函数
  • 已经确认 clone 是性能瓶颈(通过 profiling 确认,不是猜的)

黄金法则: 先让代码正确和清晰,再考虑优化。.clone() 不是罪过,过早优化才是。

误区 5:自引用结构体

这是 Rust 生命周期中最让人抓狂的场景——结构体想引用自己的字段。

// ❌ 你想要的:一个结构体既拥有数据,又引用自己的数据
struct Document {
    content: String,
    first_line: &str, // ← 想指向 content 的第一行?做不到!
}
// Rust 不允许结构体引用自己的字段,因为如果结构体被移动,引用就悬垂了

// ✅ 方案 A:用索引/偏移量(最推荐)
struct Document {
    content: String,
    first_line_end: usize, // 存偏移量,不存引用
}

impl Document {
    fn new(text: String) -> Self {
        let end = text.find('\n').unwrap_or(text.len());
        Document { content: text, first_line_end: end }
    }

    fn first_line(&self) -> &str {
        &self.content[..self.first_line_end]
    }
}

// ✅ 方案 B:拆成"存储"和"视图"两个结构
struct DocumentStorage {
    content: String,
}

struct DocumentView<'a> {
    first_line: &'a str,
    word_count: usize,
}

impl DocumentStorage {
    fn view(&self) -> DocumentView<'_> {
        let first_line = self.content.lines().next().unwrap_or("");
        let word_count = self.content.split_whitespace().count();
        DocumentView { first_line, word_count }
    }
}

实战心法: 如果你发现自己在尝试让结构体引用自己的字段——停下来,换个设计。用偏移量、拆结构体、或者干脆存 String


六、决策流程图:遇到生命周期问题该怎么办?

当编译器报生命周期错误时,按这个流程走:

┌─ 编译器报了生命周期错误 ──────────────────────────────────┐
│                                                            │
│  Q1:你的函数是否在返回引用?                              │
│  ├── 否 → 问题可能不是生命周期,检查借用规则              │
│  └── 是 → 继续 ↓                                         │
│                                                            │
│  Q2:返回的引用来自函数内部创建的数据吗?                  │
│  ├── 是 → 不能返回引用!改为返回 String/Vec 等拥有类型    │
│  └── 否 → 继续 ↓                                         │
│                                                            │
│  Q3:函数有几个引用参数?                                  │
│  ├── 1 个 → 省略规则能处理,不用标 'a                     │
│  ├── 多个,但是方法(有 &self) → 省略规则能处理          │
│  └── 多个,不是方法 → 需要手动标注                        │
│       └── 返回值来自哪个参数?只给那个参数标 'a           │
│                                                            │
│  Q4:标注后还是报错?                                      │
│  ├── "lifetime may not live long enough"                   │
│  │   → 检查调用方:借用源是否比使用处活得短?             │
│  ├── "`T` does not fulfill the required lifetime"          │
│  │   → 可能需要 'a: 'b 约束或 T: 'static                 │
│  └── 实在搞不定 → 考虑用 .clone() 或重新设计数据结构     │
│                                                            │
└────────────────────────────────────────────────────────────┘

数据类型选择决策树

你需要持有字符串数据吗?
├── 只是"看一眼" → &str(零拷贝引用)
├── 需要修改或在函数间传递 → String(拥有所有权)
├── 大部分时候不修改,偶尔修改 → Cow<'a, str>
├── 多个线程共享读取 → Arc<str> 或 Arc<String>
└── 结构体字段
    ├── 结构体生命周期比数据短 → &'a str
    ├── 结构体需要独立存活 → String
    └── 不确定 → 先用 String,性能不够再优化

七、最佳实践清单

设计 API 时

原则 做法 反模式
默认不写生命周期 先省略,编译器会告诉你 一上来就加满 'a
最小约束原则 只关联有借贷关系的参数 所有参数都标同一个 'a
返回值来自谁就标谁 精准标注返回值来源 'static 逃避标注
生命周期太复杂就用所有权 超过 2 个生命周期参数时考虑重构 硬撑 4-5 个生命周期参数
优先使用省略规则 单输入、方法签名直接省略 不必要的显式标注

设计数据结构时

// ✅ 简单场景:用 String,省心
struct User {
    name: String,
    email: String,
}

// ✅ 性能关键场景:用引用,零拷贝
struct LogEntry<'a> {
    level: &'a str,
    message: &'a str,
}

// ✅ 灵活场景:用 Cow,兼顾两者
struct Config<'a> {
    host: Cow<'a, str>,
    port: u16,
}

// ❌ 过度设计:生命周期太多,维护成本高
struct BadDesign<'a, 'b, 'c, 'd> {
    f1: &'a str, f2: &'b str, f3: &'c str, f4: &'d str,
}
// → 考虑合并生命周期或改为 String

调试生命周期错误时

  1. 读错误信息的最后几行——Rust 的错误信息通常会告诉你"谁活得不够长"
  2. 画出数据的作用域——用注释标出每个变量的创建和释放点
  3. 问自己返回值从哪来——这通常是解决问题的关键
  4. 不要立刻加 'static——这几乎永远是错的
  5. .clone() 不丢人——先让代码跑起来,再优化

一个有用的心理模型

编写 Rust 代码时,想象编译器是一个严格但善良的导师:

  你:"我想返回这个引用。"
  编译器:"它从哪来的?"
  你:"从参数 x 借的。"
  编译器:"好,用 'a 告诉我 x 和返回值的关系。"
  你:fn foo<'a>(x: &'a str) -> &'a str

  你:"我想返回函数内创建的数据的引用。"
  编译器:"不行,那块内存函数结束就没了。"
  你:"那我返回 String 行不行?"
  编译器:"这就对了。"

总结

生命周期是 Rust 学习曲线上一道著名的坎。但翻过这道坎后你会发现:

  • 编译器不是敌人,而是最尽职的 Code Reviewer——它在编译期就帮你找出了所有可能的内存安全问题
  • 生命周期标注的本质是诚实——如实描述数据的借用关系,编译器来验证
  • 90% 的场景不需要手写生命周期——省略规则是你的好朋友
  • 剩下 10% 的场景遵循最小约束原则——返回值从谁那里来,就给谁标

记住这句话:

下次看到 error[E0106],别慌。深呼吸,问自己:"返回值是从哪个参数借来的?"找到答案,加上标注,编译器就会向你竖起大拇指。

如果三个深呼吸之后还没解决,回来翻翻本文的对应章节。再不行…….clone() 一下,先把功能搞定,优化的事明天再说。毕竟,能跑的代码 > 不能编译的零拷贝代码


生命周期不是 Rust 故意刁难你,而是它替你挡下了无数个凌晨三点的生产事故。当你的 C++ 同事还在调试段错误的时候,你已经可以安心睡觉了——因为编译器已经替你检查过了。

JavaScript - 相对实用的Axios二次封装

作者 AlenLi
2026年4月30日 00:02

1. 为什么要对Axios进行二次封装?

如果不对Axios进行二次封装,可以想象到每次调用Axios都需要对其重复的进行配置。

并且封装Axios,也能够使得每次请求的入口相同,更加便于管理。

2. 如何对Axios进行二次封装

首先我们需要构建出一个axios实例用于发送请求,以及需要绑定一些方法,使得每次请求的入口都是这些方法。其次,错误处理和令牌数据携带需要在Axios拦截器进行构造。

构建HTTP请求类:

import axios from "axios";
import baseConfig from "./baseConfig";
import { InterceptorController } from "./InterceptorController";
class HTTP {
  constructor(extraConfig) {
   const mergeConfig = {...baseConfig,...extraConfig};
   this.ins = axios.create({
    ...mergeConfig
   })
   
   /** 创建拦截器实例 */
   const interceptorController = new InterceptorController(this.ins)
   interceptorController.addRequestInterceptor.apply(this);
   interceptorController.addResponseInterceptor.apply(this);
  }
  
  /** 通用的请求方法 */
  async request(url, config) {
   try {
    return await this.ins({ url, ...config, });
   } catch (error) {
    throw error.response ? error.response.data : error;
   }
  }
  
  post(url, data, config){
   return this.request(url, { ...config, data, method: 'POST' });
  }
  
  get(url, config) {
   return this.request(url, { ...config, method: 'GET' });
  }
}

构建Axios拦截器:

import qs from 'qs';
import { responseCodeMap } from 'responseCodeMap.js';
/** axios拦截控制器 */
export class InterceptorController {
  /**
   * @param ins - axios实例
   */
  constructor(ins) {
   this.ins = ins;
   /** 用于超时重试计数 */
   this.retryCount = 1;
  };
  
  addRequestInterceptor() {
   this.ins.interceptors.request.use((config) => {
    loading();
    
    /** 携带token,假设token存入了store中 */
    const token = store.commit('token');
    if(store.commit('getToken')){
     config.headers.token = token;
    }else{
     redirectTo('login');
    }
    
    config.data = qs.stringify(config.data);
   },(error) => {
    closeLoading();
    
    /** 重定向到错误页面 */
    redirectTo('error' + error.response.status);
    
    return Promise.reject(error);
   });
  }
  
  addResponseInterceptor(){
   this.ins.interceptors.response.use((res) => {
    closeLoading();
    return res;
   },(error) => {
    closeLoading();
    /** 超时重发请求 */
    if(error.code === 'ECONNABORTED' && error.message.includes('timeout')){
     const config = error.config;
     
     if(this.retryCount <= 3){
      this.retryCount++;
      console.warn(`请求超时,第${this.retryCount - 1}次重试`);
      return new Promise(resolve => {
       setTimeout(() => {
        resolve(this.ins(config));
       }, 1000);
      });
     }else{
      console.error('请求重试失败,已达最大重试次数');
      this.retryCount = 1;
      $message.error('网络请求超时,请稍后重试');
     }
    }
   })
  }
}

我用 Claude Code 的 Superpowers 技能链写了个服务,部署前差点把服务器搞炸

2026年4月29日 22:44

这不是一篇 AI 吹水文。我会把翻车的地方、设计文档容易制造的错觉、以及我这次踩到的安全盲区都摊开讲。

先说背景。上周我用 Claude Code 的 Superpowers 插件体系,从零撸了一个图片占位服务(类似 placehold.co),整个流程走下来——头脑风暴、设计文档、实现计划、子代理逐任务执行——花了几个小时,最终 50 个测试用例全绿,6 种图片格式全部支持。

听起来很完美对吧?

但当我真正把它跑起来,准备部署的时候,发现了一个让我后背发凉的问题: 设计文档里写的并发限制是 10,但它没告诉你 10 个 4000×4000 的 AVIF 请求同时打过来,内存峰值可能接近 1GB。 而我的 Dockerfile 里赫然写着 --max-old-space-size=512。这不是严格意义上的容器内存上限,它限制的是 V8 old space,但对一个大量使用 Sharp/libvips 原生内存的服务来说,已经足够提醒我:这里需要压测,而不是凭感觉上线。

这就是 AI 驱动开发最容易被忽视的问题: 它会在设计文档里把安全风险和资源限制列得明明白白,给你一种"都考虑到了"的安全感。但列出来和解决了,中间隔着一条鸿沟。


Superpowers 是什么

先交代一下背景。Claude Code 的 Superpowers 是一套插件化的开发工作流,核心流程大致是四步:

头脑风暴 → 写设计文档 → 写实现计划 → 子代理逐任务执行

每一步都有对应的 skill。brainstorming 负责梳理需求,writing-plans 把设计文档拆成带完整代码的任务列表,subagent-driven-development 给每个任务派一个独立子代理去实现,完成后自动做两轮 review。

听起来像流水线,用起来也确实像。但真正有意思的是每个环节暴露出来的问题。


头脑风暴:AI 问问题的深度取决于你问问题的深度

brainstorming skill 有个规则:一次只问一个问题。一开始我觉得这节奏太慢了,但走完才发现,这种"逼迫式"的节奏让你没法跳过模糊地带。

第一个反转:技术选型

我最开始说用 Canvas 绘制,AI 推荐了 @napi-rs/canvas。看起来没什么问题,Canvas 绑 Skia,主流方案。

但后来聊到格式支持的时候,我问了一句:"AVIF 支持吗?"AI 才说 @napi-rs/canvas 底层是 Skia,Skia 的 AVIF 编码支持不完整。而我要求同时支持 AVIF 和 GIF 动画,方案才转向 Sharp 这条路:静态格式用 Sharp,GIF 再配合专门的编码器。

如果我当时没追问格式兼容性,AI 不会主动告诉我它的推荐有盲区。 它会很自信地给你一个方案,直到你问到它兜不住的地方。

最终方案改成了 Sharp + SVG 混合架构:所有图片先用字符串拼出 SVG(零依赖),PNG/JPEG/WebP/AVIF 由 Sharp 转换;GIF 则先用 Sharp 渲染出原始帧,再交给 gif-encoder-2 编码。SVG 格式直接返回字符串,不走 Sharp。

这个架构本身很漂亮——SVG 是唯一数据源,Sharp 负责渲染和格式转换,GIF 编码器只处理动画输出。但漂亮归漂亮,后面的内存问题就埋在这里。

第二个反转:竞品调研

我问"尺寸放路径还是放 query 参数"的时候,AI 搜索了所有竞品:placehold.codummyimage.com、placeholders.dev——全部用路径放尺寸,没有例外。而且 dummyimage.com 的文档还特意提到了 Content-Length 响应头对旧版客户端的重要性。

这种调研如果让我自己做,大概率就是拍脑袋决定。AI 在这件事上的价值不是"帮你做决定",而是"告诉你整个行业怎么做的,你不用猜"。

第三个反转:安全分析主动提了,但力度不够

AI 在头脑风暴阶段就主动做了威胁分析:XSS 注入、资源耗尽、参数欺骗、路径遍历,列了一个矩阵。每一步都有对应的缓解措施。当时我觉得:嗯,安全这块考虑得挺全。

事后证明,这个"挺全"的感觉是最大的陷阱。 具体后面说。


设计文档:写得漂亮和跑得稳是两码事

头脑风暴结束后,AI 把所有讨论整理成了一份设计文档。涵盖 API 设计、项目架构、安全设计、内存安全、环境变量——每个章节看起来都很专业。

但有几个细节,代码跑起来之后才暴露:

1. 字体策略:文档和实现完全是两个东西

设计文档里关于字体是这么写的:M+ Fonts 嵌入 SVG 的 base64 @font-face。理由也充分——开源字体,支持拉丁 + CJK,base64 嵌入保证跨环境一致性。

但实际实现中,这一步完全没做。最终用的是 font-family="sans-serif",依赖系统字体。

为什么?一个支持 CJK 的 M+ Fonts 字体文件 2MB,base64 编码后膨胀到 2.7MB。如果每次请求都把 2.7MB 的字体嵌进 SVG 再喂给 Sharp,那响应体积和内存开销完全是另一个量级。设计文档没有评估这个成本,只是在理想条件下给了一个方案。

这不是 AI 的错——是没有人追问"这个方案的实际代价是什么"。

2. 并发限制:文档说信号量,代码是全局计数器

内存安全章节写得很详细:像素上限 16MP、文本硬限制 200 字符、并发限制、请求超时、Node.js 的 --max-old-space-size、PM2/K8s 基于内存的重启策略。看起来面面俱到。

但并发限制的实际实现是这样的:


let activeCount = 0;

export function concurrencyLimiter() {

return async (c: Context, next: Next) => {

if (activeCount >= config.maxConcurrency) {

// 返回 503 错误图片

}

activeCount++;

try { await next(); } finally { activeCount--; }

};

}

一个模块级变量 activeCount。它能做单进程内的粗粒度并发拒绝,但没有队列,不会按请求成本加权,进程重启就归零,集群部署下每个进程独立计数——离真正的全局限流还差很远。

设计文档写了"信号量",代码实现是计数器。这两个东西在并发控制语义上不是一回事。

3. GIF 动画:设计文档没评估极端尺寸

GIF 的方案是生成两帧(正常帧 + 微亮覆盖层),制造闪烁效果。技术上可行,但仔细算一下内存:

  • 4000×4000 的 RGBA 原始数据一帧是 64MB

  • 两帧是 128MB

  • gif-encoder-2 的 neuquant 颜色量化还要额外分配内存

  • 如果并发 10 个 GIF 请求,光原始帧数据就超过 1.28GB

再加上 Sharp 的解码缓冲区、AVIF 编码的峰值内存,如果把服务放进内存较紧的容器里,OOM 风险非常高。但设计文档里的并发限制给了一个固定的 10,没有根据图片尺寸做动态调整。

这说明 AI 做的安全分析是"静态"的——它知道应该限制并发,但没有把并发阈值和请求成本关联起来。10 个 100×100 的 PNG 和 10 个 4000×4000 的 GIF,对内存的压力完全不是一个数量级。


实现计划:细致到让人窒息,但也带来了新问题

writing-plans skill 生成的计划有 15 个任务,每个任务包含完整的实现代码——不是伪代码,是复制粘贴就能跑的代码。计划遵循 TDD:先写测试确认失败,再写实现确认通过,最后 git commit。

任务粒度极细。举个例子,Task 4(参数校验器)的步骤:

  1. 写 18 个测试用例

  2. 跑测试,确认全部失败

  3. 写实现代码

  4. 跑测试,确认全部通过

  5. git commit

50 个测试用例覆盖了参数校验、SVG 生成、图片转换、缓存头、全链路集成。如果纯手写,我大概率会偷懒跳过一半。

但代价是: 计划文档 1500 行。 信息密度很低——每个任务里大段的代码占满了屏幕。适合机器执行,不适合人阅读。如果你想在动手前通读一遍计划,做好心理准备。


子代理执行:两个翻车现场

subagent-driven-development 的工作方式是每个任务派一个独立子代理实现,完成后自动做两轮 review(先查规格合规,再查代码质量)。

先说亮点。

子代理在实现 validators 的时候发现了一个测试顺序 bug:validateSize(4000, 4001) 这个用例里,height=4001 同时触发了高度上限和像素上限两个错误条件。如果先检查高度范围,返回的错误码和预期的像素超限错误不一致。子代理自己调换了检查顺序——把像素上限检查提前到 height 范围检查之前——测试全绿。

这种边界条件我自己写也未必会注意到。子代理因为严格按测试用例驱动,反而能抓到。

再说翻车。

第一个翻车:路由匹配。实现计划里用的是 Hono 的正则路由:


app.get('/:size{[0-9]+x[0-9]+\\.[a-zA-Z]+}', handleImageRequest);

单元测试全部通过——因为 vitest 用的是 app.request() 内部方法,不经过真实的 HTTP 服务器栈。

但启动真实服务器用 curl 测试, 所有带正则的路由返回 404。

我没有继续深挖到底是路由 pattern 写法、版本差异,还是 @hono/node-server 适配层的问题;但现象很明确:正则 pattern 在 app.request() 里能匹配,换到真实 HTTP 请求就不行。

最终改成了 catch-all 路由 + 手动解析:


app.get('/*', (c, next) => {

if (c.req.path === '/') return next();

return handleImageRequest(c);

});

单元测试全绿 ≠ 生产可用。 尤其是路由、中间件、序列化/反序列化这些和运行时强相关的代码,AI 生成的方案在测试环境里可能完全正常,换到真实环境就暴露差异。

第二个翻车:入口文件的副作用。index.ts 在底部直接调了 serve(),但集成测试文件 import { app } from '../src/index.js' 触发了模块加载,导致每次跑测试都启动一个真实的 HTTP 服务器,端口冲突报 EADDRINUSE

修复方案是加条件判断:


if (process.argv[1]?.includes('index.ts') || process.argv[1]?.includes('index.js')) {

serve({ fetch: app.fetch, port: config.port, hostname: config.host });

}

这种"副作用在 import 时触发"的问题是 Node.js 的经典坑。AI 生成的代码默认是为"直接运行"写的,不考虑被 import 的场景。如果你用 monorepo 或者测试框架 import 了入口文件,必踩。


安全与资源:设计文档不会替你兜底的部分

这部分是整篇文章最想讲的。因为 AI 在设计阶段的"安全感"太有欺骗性了。

设计阶段讨论了但没解决透的问题

1. 内存占用的动态特性

前面算过了,10 个 4000×4000 的 AVIF 并发请求,内存峰值可能接近 1GB。正确的做法不是只给一个固定并发数,而是:根据请求的图片尺寸和格式动态计算预估内存,动态调整并发上限。小图可以放更多并发,大图严格限流。

AI 知道要做并发限制,但没有继续把并发限制的阈值和请求参数关联起来。

2. GIF 的内存放大效应

gif-encoder-2 的 neuquant 算法需要构建颜色调色板,内存开销会随着帧数、尺寸和颜色复杂度上升。设计文档只写了"2 帧微闪",没有评估编码器在极端尺寸下的资源消耗。如果服务上线后有人用脚本批量请求 4000×4000 的 GIF,不需要传统意义上的 DDoS,也可能把服务打到 OOM。

3. SVG XSS 的防护深度不够

当前实现是 HTML 实体编码:


function escapeHtml(text: string): string {

return text

.replace(/&/g, '&amp;')

.replace(/</g, '&lt;')

.replace(/>/g, '&gt;')

.replace(/"/g, '&quot;');

}

这能防当前 ?text= 文本节点里的 <script> 注入。当前实现里,用户主要能控制文本和颜色;颜色参数走的是正则白名单,所以 <foreignObject>onloadxlink:href="javascript:..." 这些向量暂时没有直接注入入口。

真正的问题在于扩展性:SVG 的攻击面不止文本节点。如果后续加了更多用户可控的 SVG 属性(比如自定义字体、自定义尺寸标注、图片 URL),每个新参数都是一个新的注入面。

安全防护最容易出错的地方不是"没做",而是"做了一半,给了自己一种做完了的错觉"。

4. 速率限制只存在设计文档里

威胁矩阵里写了速率限制,优先级标的是"平台级(Serverless)/ 可选中间件(自托管)"。实际上最终代码里没有速率限制的实现。对于自托管部署来说,这意味着任何人都可以无限制请求生成图片。虽然 Sharp 的处理速度本身就是一种自然限速,但这不构成安全策略。

Express 生态有 express-rate-limit,Hono 虽然可以接第三方 middleware 或者交给平台层限流,但这里没有把它落到代码里。这个遗漏不是语法问题,而是部署策略问题:公共服务不能只靠"图片生成比较慢"来当限流。

设计阶段没落地或没讨论的问题

1. 请求超时只存在配置里

设计文档里写了 request timeout,配置文件里也有 REQUEST_TIMEOUT=5000,但最终路由和图片生成代码没有真正使用这个配置。也就是说,一个慢请求并不会因为超过 5 秒被主动中断。对于 Sharp/AVIF/GIF 这种可能吃 CPU 和内存的路径来说,这比"没写配置"更危险——因为你会以为它已经生效了。

2. Content-Length 响应头缺失

生成的图片响应没有 Content-Length 头。虽然 HTTP/1.1 可以用 Transfer-Encoding: chunked,但某些旧版 HTTP 客户端和 CDN 回源时需要 Content-Length 来判断响应完整性。dummyimage.com 的文档专门强调了这一点——AI 做了竞品调研,但调研结果里的这个细节没有体现在实现中。

3. 没有健康检查端点

设计文档把健康检查放在了 Phase 2(延期功能)。但实际部署到 K8s 或 Docker Compose 时,没有 /health 端点意味着编排系统无法做存活探测。对于一个要上线的服务,健康检查不属于"锦上添花",属于"基本生存条件"。

4. Dockerfile 拷贝了 public 但没拷贝 fonts

Dockerfile 里有 COPY public ./public,但 fonts/ 目录没有被拷贝。虽然当前实现用的是系统字体,但如果后续有人想把 M+ Fonts 的字体嵌入加回来,Dockerfile 不会报错——字体文件不存在,运行时会在某个请求上静默失败,而不是启动时报错。


社区的现状和我的看法

我最近看到的很多关于"AI 写代码"的讨论,常常会滑向两个极端:一派说 AI 替代初级工程师只是时间问题,另一派说 AI 生成的代码都是屎山不能用。

两种说法都不对。

AI 最大的价值不是"替代人写代码",而是"强制走完流程"。 没有 AI 的情况下,多少人会为一个图片占位服务写设计文档?会写 50 个测试用例?会做竞品调研?会在实现前把威胁矩阵列出来?

大部分人不会。不是能力问题,是时间问题。DDL 压在头上的时候,设计文档和测试永远是第一个被牺牲的。

AI 驱动开发的核心优势不是快,是 不让你跳过该做的事。

但同时,AI 在这类工作流里有一个很容易被低估的问题:它做的安全分析和性能评估经常是 静态的、孤立的 。它知道"应该限制并发",但未必会继续追问"不同尺寸的图片并发时内存压力差几个数量级"。它知道"应该防 XSS",但未必会把 SVG 后续扩展时的攻击面讲透。它能把设计文档写成一篇满分作文,但不会自动告诉你哪些段落是"愿望",哪些段落已经落地了。

这也是为什么"AI 替代初级工程师"这个说法至少没那么简单。 初级工程师的价值不只在于写代码,更在于他们会在生产环境出问题之后长记性,会把团队里的事故经验变成下一次设计时的直觉。AI 如果没有被明确喂进这些上下文,不会自动继承上一轮的翻车教训。

Superpowers 这套工作流的核心洞见其实是: 把 AI 定位为"流程执行者"而不是"决策者"。 它不去替你判断方案好坏,而是逼你把判断过程走完。brainstorming 的"一次一个问题"就是最典型的设计——节奏是人在控制,AI 只负责在每个节点把信息铺开。

但反过来,这套流程对简单项目来说太重了。我这个图片占位服务的核心逻辑不到 300 行,但走完 brainstorming → 设计文档 → 实现计划 → 子代理执行,流程本身花的时间不比写代码少。 如果你做的是一周内能手工写完的项目,用全流程可能得不偿失。 头脑风暴那一步值得保留,后面的计划和执行可以按需裁减。


最终成果

功能上没什么问题,6 种格式全绿:


PNG → 200 image/png

JPEG → 200 image/jpeg

WebP → 200 image/webp

AVIF → 200 image/avif

GIF → 200 image/gif (2 帧闪烁动画)

SVG → 200 image/svg+xml

错误参数 → 400 image/png(返回错误图片,不破坏 <img> 标签)

文档首页 → 200 text/html

架构简洁,代码量不大,测试覆盖完整。作为一个 evening project,质量远超预期。

但如果要上线,至少还要补:速率限制、基于尺寸的动态并发控制、真正生效的请求超时、健康检查端点、Content-Length 头,以及一轮真实流量下的内存压力测试。


几点实在的建议

如果你也想试试这种工作流,说几条掏心窝子的:

1. 安全和资源问题不要只看设计文档。 AI 会在文档里把该列的列出来,让你觉得"都考虑到了"。但列出来和解决了之间,需要你自己去验证——尤其是并发场景下的内存、极端参数组合下的性能、以及第三方库在边界条件下的行为。

2. 路由、中间件、配置这些和运行时强相关的东西,必须在真实环境跑一遍冒烟测试。 app.request() 通过不代表 curl 能通。AI 生成的代码默认是给"理想运行时"写的,真实运行环境的兼容性问题可能覆盖不到。

3. 设计文档里的 Phase 2 和配置项要认真对待。 被推迟的功能往往是安全性和可运维性的底线——健康检查、速率限制、请求超时、字体策略。如果你打算上线,这些不是"以后再说"的事。

4. 头脑风暴是整个流程里 ROI 最高的环节。 不是为了听 AI 的建议,而是利用它做竞品调研和方案对比的能力。多问"竞品怎么做的"和"这个方案在什么情况下会出问题"。

5. 项目越小,流程越要裁剪。 一个 weekend project 不需要 1500 行的实现计划。brainstorming 做足,后面的按自己节奏来就行。


项目地址是: place-image ,感兴趣可以star或者fork搭一个玩玩。遇到内存问题别慌,大家都一样。

从 URL 到页面展示,还有哪些你忽略的底层细节?(DNS 与传输篇)

2026年4月29日 21:43

从 URL 到页面展示,还有哪些你忽略的底层细节?(DNS 与传输篇)

上一篇文章我们用「浏览器多进程架构」为主线,拆解了从输入 URL 到页面渲染的完整导航过程。很多读者反馈:DNS 和传输层部分还值得再挖深一点 —— 面试时能不能多讲一些「IP 地址背后的事」?数据到底是怎么从服务器跑到浏览器的?

今天我们就顺着这条链路,把 DNS 的兜底逻辑负载均衡OSI 模型下的数据封包一次讲透,让你在面试中再往底层迈一步。


一、DNS 返回的 IP,并不是最终服务器的 IP

很多人以为 DNS 解析后直接拿到的是真实 Web 服务器的 IP,其实不是。

你可以亲自在 Chrome 地址栏输入 chrome://net-internals/#dns,就能看到浏览器缓存的 DNS 记录。查询一个大型网站,返回的经常是一个 IP 数组(多个地址),而不是单个 IP。

这就是分布式服务器集群所带来的现象。真正和浏览器通信的通常是一台 反向代理服务器(比如 Nginx),它充当“媒婆”的角色:

  • 请求先到这台 Nginx 代理
  • 代理背后有成百上千台真实业务服务器
  • 代理根据负载均衡策略,选择一台把请求转发过去

负载均衡怎么选服务器?

常见的策略有:

  • 轮询:按顺序一台一台分配
  • 加权轮询:配置高的机器多承担一些请求
  • 最少连接数:谁当前任务少就发给谁
  • IP 哈希:让同一用户的请求落到同一台服务器(便于 session 保持)

这样一来,即使某台服务器宕机,代理也能把流量导到健康节点,用户几乎无感知。


二、离你最近的 IP:CDN 的地域性调度

DNS 解析还有一个高级技能:根据你的地理位置,返回离你最近的节点 IP

很多大厂在全国(甚至全球)部署机房,DNS 解析服务会通过用户的 Local DNS 出口 IP 判断你的城市,然后返回附近机房的 Nginx 代理 IP。这就是 CDN 就近接入的底层逻辑。

比如在北京访问 douyin.com,DNS 可能解析到北京的边缘节点;到了上海出差,解析结果会变成上海的节点。不仅降低了延迟,也分散了源站压力。

小技巧:你可以用 nslookupping 测试域名在不同网络下的 IP,能看到 CDN 调度的效果。


三、本地 DNS 的“后门”:hosts 文件

在 DNS 查询链路中,有一个优先级很高却常被开发者忽略的环节:操作系统 hosts 文件

Windows 路径:

C:\Windows\System32\drivers\etc\hosts

macOS / Linux 路径:

/etc/hosts

它可以手动定义域名到 IP 的映射,比如:

127.0.0.1  douyin.com

这样访问 douyin.com 时,浏览器就直接走本地回环地址,完全跳过 DNS 解析

实际开发中的妙用

  • 本地开发时,将测试域名指向 127.0.0.1,就能用真实域名测试 cookie、token 等域名相关逻辑。
  • 线上故障应急时,有时会临时修改 hosts 跳过故障 DNS 或直奔某台服务器。

注意,localhost 这类特殊域名甚至不需解析,操作系统就直接识别成环回地址。


四、数据如何上路:OSI 七层模型形象理解

DNS 拿到 IP 之后,浏览器开始和服务器建立连接,真正传输数据。
这就进入到了经典的 OSI 七层模型(实际互联网更多用 TCP/IP 四层),从下往上看数据的变化:

  1. 物理层
    网线、光纤、无线电波。传输的最底层是 0 和 1 的电信号或光信号。

  2. 数据链路层
    数据被加上 MAC 地址(每台上网设备的唯一硬件标识),组成数据帧。

    目标 MAC + 源 MAC + 数据
    
  3. 网络层
    加上 IP 地址,让数据能够跨网络到达目标主机。

    目标 IP + 源 IP + MAC + 数据
    
  4. 传输层
    再加上 TCP 或 UDP 协议头。TCP 头部包含序号、确认号、窗口大小等,保证可靠性。

    TCP 头(序号…)+ IP + MAC + 数据
    

这就像寄快递:

  • 物理层是公路/飞机
  • 链路层是小区收发室(MAC)
  • 网络层是城市和街道(IP)
  • 传输层是快递公司的签收规则(保证不丢件、不乱序)

五、TCP:可靠传输的规矩

HTTP 协议基于 TCP,而 TCP 为了保证数据完整有序,制定了一套规矩:

1. 拆包与并发传输

服务器要返回一个 HTML 文件,可能几十 KB 甚至几百 KB。TCP 不会一次性全部扔到网上,而会切分成固定大小(MSS)的数据包,分批次、多通道并发发送。

这样即使某个包卡住了,其他包也能继续前进,提高效率。

2. 序号与排序

每个包在 TCP 头部都带着序号,接收端按序号重新拼装数据。
即使包到达的顺序是乱的(因为网络路由不同),也能重新排好。

3. 丢包重发

如果发送方一段时间没收到某个包的确认(ACK),就认为丢包,触发自动重传。这就是 TCP 可靠性的保障。

对比 UDP:UDP 不建立连接、不保证顺序、不重传,但速度快,适合直播、视频会议等对实时性要求高的场景。

4. 三次握手本质

我们在上一篇文章提过三次握手,其实它的核心就是同步双方的初始序号,确认彼此收发能力正常。只有握手完成后,浏览器才会发送真正的 HTTP 请求。


六、落地到面试:你能这样说

当面试官问起“DNS 过程中做了什么”,你可以这样组织语言:

  1. 浏览器先查本地缓存(含 chrome://net-internals/#dns 记录)和操作系统 hosts 文件。
  2. 没有命中,则逐级向上递归查询,直到拿到 IP(很可能是一个反向代理 IP)。
  3. 这个 IP 背后通常是 Nginx 等代理,代理根据负载均衡策略将请求转发到内部某台真实服务器。
  4. 同时 CDN 会通过 DNS 智能解析,返回离用户最近的边缘节点 IP。

问到数据传输,可以补充:

  • 物理层、链路层、网络层、传输层的逐层封包过程
  • TCP 通过拆包、序号、重传来保证可靠性,而 UDP 牺牲可靠性换取速度
  • 三次握手是为了同步序号、验证双方收发能力

这样,不仅讲清了前端视角的请求全过程,还向下扎到了网络架构和传输原理,能充分展示你的计算机基础。


掌握这些细节,你再回答那道经典面试题时,就不再是“表面流程复读机”,而是一个能讲出“为什么”和“底层发生了什么”的开发者。下一期我们可以继续聊聊 HTTPS 的 TLS 握手,敬请期待。

老婆天天吵吵要买塔罗牌,我直接用 AI 2 小时写了个在线塔罗牌

2026年4月29日 21:19

🔗 开源仓库MysticTarot
🌐 在线预览liuxinyea.github.io/mystic-taro…

事情是这样的。

老婆最近迷上了塔罗牌,购物车里存了好几副设计精美的实体牌,价格都在两三百上下。做技术的我,心里先算了一笔账:这些东西买回来很可能热乎几天就收进抽屉,搬家时还得费力打包。可我又不忍心直接浇灭她的热情,于是主动说:“给我两个小时,我送你一个全宇宙独一无二的线上塔罗牌。”

她白了我一眼,但嘴角分明在说:“你倒是弄出来啊。”

于是,一个叫 MysticTarot 的项目诞生了——基于 Nuxt 3、Three.js 和 AI 的在线占卜应用。回头一算,从动手到能玩,差不多真的就是两个小时。

首页截图

牌阵效果

下面就把这个过程完整复盘一下,看看 AI 是怎么帮我在这么短时间里,从想法到部署,做出一个带 3D 翻牌、AI 解读、多语言和响应式适配的暗黑风塔罗应用。


1. 先让 AI 出一份“技术 & UI 方案”

我的第一步很简单:把需求告诉大语言模型。当时用的是 Gemini,因为它对多模态和多轮对话的支持更好。我给它的提示词大概是这样的:

我要做一个 Web 端塔罗牌占卜应用,要求:
- 沉浸式体验,3D 翻牌、洗牌动画
- 78 张牌完整数据(正逆位释义)
- 集成 OpenAI 进行 AI 解读
- 暗黑美学,毛玻璃 UI,星空背景
- 多语言支持(首选中英文)
- 响应式,手机端体验也要好
- 纯前端应用,但可能需要后端代理 API Key

请给我一套完整的技术方案,包括推荐的技术栈、核心组件设计,以及大致的 UI 布局。用 Markdown 输出。

Gemini 很快返回了一份要点清晰的方案:

  • 框架:Nuxt 3(Vue 3 + TypeScript),利用其服务端 API 路由代理 OpenAI 请求
  • 3D 渲染:TresJS(Three.js 的 Vue 3 封装)+ GSAP 做动画
  • 样式:Tailwind CSS,配合 bg-black/80backdrop-blur 实现毛玻璃,加上翡翠绿 (#50C878) 作为主色调
  • 状态管理:Pinia,管理抽牌、洗牌、解读状态
  • 多语言:@nuxtjs/i18n
  • AI 解读:OpenAI SDK,流式 SSE 响应,并设计无 API Key 时的本地回退策略
  • 牌面数据:本地 JSON 存储,包含名称、关键词、正逆位描述等
  • 目录结构components/ 下拆出 TarotScene.vueCardDeck.vue

这份方案直接帮我省掉了大量纠结“选什么技术”的时间。更关键的是,它还给出了 UI 布局的骨架描述:中央是 3D 牌阵舞台,底下是操作区(洗牌、抽牌),右侧滑出解读面板,顶部导航放语言切换和设置。

有了这张蓝图,下一步就是把蓝图交给 AI 转换成代码。


2. 用“AI 编辑器提示词”生成组件

我没有一行行手写代码,而是把 Gemini 的方案和具体需求,压缩成一段段给 AI 编辑器(Cursor / Antigravity 这类)的提示词。这里分享几个关键提示词,你可以直接拿去参考。

提示词 1:初始化项目基底

创建一个 Nuxt 3 + TypeScript + Tailwind CSS 项目。
安装以下依赖:@nuxtjs/tailwindcss, @nuxtjs/i18n, pinia, @vueuse/core, tresjs, gsap, openai
配置 tailwind.config.ts 添加自定义颜色:
emerald-glow: '#50C878',背景黑:#0a0a0a, #121212
在 nuxt.config.ts 中注册 tailwindcss 模块和 i18n 模块。
创建 i18n 配置文件,支持 zh-CN 和 en,设置懒加载。
创建基础布局文件 default.vue,包含星空背景(纯 CSS 点阵或 canvas 动画),全屏占满。
请生成所有需要的配置和文件内容。

AI 编辑器执行后,我得到了一个可以直接跑起来的项目框架,甚至已经带上了基础的星空粒子效果(用 CSS box-shadow 做的散点加动画)。

提示词 2:78 张牌的数据结构 & 本地 JSON

这一步我没有让 AI 自己编造牌义,因为我在网上找到了一份开源的中文塔罗解释,是 JSON 格式。我把这份 JSON 内容作为附件丢给 AI,然后说:

这是 78 张塔罗牌的详细数据(文件名:tarot.json),包含名称、英文名、正位关键词、逆位关键词、正位描述、逆位描述等字段。
请根据这份数据,在项目 /assets/ 下创建 tarot.json 文件。
然后在 /types/ 下定义 TypeScript 接口 TarotCard。
并在 /utils/ 中编写一个 loadDeck() 函数,用于读取并解析该 JSON。

AI 完美地生成了类型定义和工具函数,并且提醒我把图片文件放到 /public/cards/ 下,命名直接用牌的 id。这样数据层就扎实了。

提示词 3:TresJS 3D 场景 & 洗牌动画

这是最有挑战的部分,但只要提示词把需求拆解得清楚,AI 就能给出可靠的代码:

请使用 TresJS(Vue 3 的 Three.js 封装)创建一个塔罗牌 3D 场景组件 TarotDeck3D.vue。
- 场景背景透明,叠加在页面中央。
- 牌面使用 PlaneGeometry,正面贴图从 /public/images/tarot/ 加载,背面使用统一卡背图片。
- 初始状态:78 张牌叠成一摞,略微倾斜,展示厚度感。
- 点击「洗牌」按钮后,触发 GSAP 动画:所有牌迅速展开成扇形,并伴有随机微动。
- 点击某张牌时,该牌旋转 180 度翻面,并高亮。
- 组件通过 defineEmits 向外暴露 'card-selected' 事件,传递牌的 id。
- 务必使用 TresCanvas 和正确的灯光设置,使牌面纹理显示清晰。

这段提示词我给了 Cursor,它生成了约 200 行代码。虽然第一次生成的扇形角度有点生硬,但我补充了一句:“扇形弧度调整到约 160 度,牌间距均匀”,它立刻就修正了算法。

这里的核心经验是:把复杂的动画逻辑拆解成明确的小需求,并清楚告诉 AI 你用的库和 API 形态。


3. “多语言”与“智能降级”——花小钱办大事

我老婆是中文用户,可万一外国朋友也想玩呢?这好办。@nuxtjs/i18n 在项目初始化时就配好了,我只要让 AI 把组件里的所有文字都用 $t() 包裹,再生成对应的语言包就行。

提示词示例:

请遍历 components/ 和 pages/ 下的所有 .vue 文件,提取出所有用户可见的中文字符串,
生成完整的 zh-CN.json 和 en.json 语言文件,
并把组件内的文字替换为 {{ $t('key') }} 格式。
Key 使用英文命名,例如 "card.shuffle"、"result.loading"。

AI 很快生成了翻译文件,还贴心地为塔罗术语保留了原文,比如 $t('cards.TheFool.name')

另一个我觉得比较实用的设计是 AI 解读的智能降级。很多同类应用一旦没有 OpenAI Key 就不能用了,而这个应用会在 API 不可用时自动切回本地数据库释义。实现起来其实很巧妙:

  1. /server/api/analyze.post.ts 中,先尝试调用 OpenAI。
  2. 如果请求失败(401、网络错误等),直接 catch 异常。
  3. 在 catch 块里,根据抽到的牌 ID 从本地 JSON 中取出正/逆位描述,返回给前端。

这整个过程也是 AI 帮我做的,我只需要说:“如果 OpenAI 请求失败,返回本地解读,状态码还是 200,前端不用管。” 最终用户完全感觉不到切换,体验很顺畅。


4. 移动端适配与翡翠暗黑美学

因为用了 Tailwind,响应式适配在 AI 帮助下特别简单。我只需要在提示词里加一句:

所有布局元素请使用 Mobile First 策略。
在大屏上,解读面板在右侧侧滑;在小屏上,解读面板从底部上滑,高度占屏幕 70%。

AI 自动给面板加上了 fixed bottom-0 md:right-0 md:top-0 md:w-96 这样的类名,并配上过渡动画。

颜色主题方面,暗黑模式已经通过 Tailwind 的 dark: 变体支持了。但我想让整个界面只使用 翡翠绿 作为强调色。于是在提示词里要求:

主题色只使用 emerald-glow 和它的透明度衍生版本。
提供主题切换按钮,通过 Pinia store 管理 isDark 状态,并用 vueuse 的 useDark 同步到 标签上。

最后呈现出来的效果是:深邃的黑色背景上,翡翠绿色的光感从牌背微微透出,毛玻璃面板边缘晕着一层柔光,整界界面确实有几分“赛博占卜”的味道。


5. 最后的样子,和老婆的反馈

差不多两小时后,我把应用部署到了 GitHub Pages,把链接发给老婆。她没说话,默默试了三次占卜,然后抬头说:“牌面比买的还好看诶,那个背景音是什么?”

——没错,我还悄悄加了一个环境音效。用了免费的白噪音音频,让 AI 写了一个 useAmbientSound 的 composable,进入页面时自动淡入播放,可以随时关闭。仪式感就这么拉满了。

现在项目完全开源,你可以直接克隆下来体验或自己改着玩:

(如果不配置 OpenAI API Key,应用会自动运行在本地解读模式,仍然可以抽牌并看到内置的详细释义。)


6. 被 AI 帮了多少?说点真心话

老实说,如果没有 AI,这两个小时我可能刚搭完 Nuxt 环境,还在跟 TresJS 的类型定义较劲。AI 对我的帮助主要体现在:

  • 加速决策:技术选型、组件划分、UI 风格——这些原本要纠结半小时的事,AI 在几十秒内给出了合理建议。
  • 减少记忆负担:不用记各种的参数顺序,不用查 @nuxtjs/i18n 的配置细节,把脑力留给真正需要判断的地方。
  • 快速纠错:有一次 3D 旋转出现奇怪的倾斜,把代码扔给 AI,它一眼看出是 rotation 属性的应用顺序问题。

当然,AI 不是魔法。整个过程我仍然要不断调整提示词,检查它生成的代码有没有副作用、是否符合最佳实践。但效率的提升是实实在在的——就像拍电影,我只需要讲清楚分镜头,AI 帮我把画面演出来。

如果你也有一个突然冒出来的想法,不妨试试这种“AI 原生”的开发方式。说不定下一个项目,就诞生在某个赌气的瞬间、一个没清空的购物车,和两个小时的专注里。


如果这篇分享对你有用,欢迎去仓库点个 Star,也欢迎直接在线玩一玩~ 🌟

[前端]单文件上传组件

作者 焰火1999
2026年4月29日 20:33

本文介绍了一个单文件上传前端组件,基于Vue3、ElementPlus,提供了组件源码及使用示例教程,可供参考和使用。

支持的功能:文件覆盖、限制文件类型、最大文件大小

组件源码

<!--
  * 单文件上传组件
  * 
  * Author: GFire
  * Date: 2025/01/16
-->
<template>
  <div>
    <el-upload
      ref="upload"
      :limit="1"
      :accept="props.accept"
      :on-exceed="handleExceed"
      :on-change="handleChange"
      :on-remove="handleRemove"
      :auto-upload="false"
    >
      <!-- 默认插槽,用于放置触发文件选择的元素,如按钮、文字等 -->
      <slot name="default"></slot>
      <template #tip>
        <div style="font-size: 12px; color: var(--el-color-info)">
          <div v-if="props.accept">支持的文件类型:{{ props.accept }}</div>
          <div v-if="props.maxFileSize">支持的最大文件大小:{{ props.maxFileSize.size + props.maxFileSize.unit }}</div>
        </div>
        <!-- 用户自定义提示内容插槽 -->
        <slot name="tip"></slot>
      </template>
    </el-upload>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { ElNotification, genFileId } from 'element-plus';
import type { UploadInstance, UploadProps, UploadRawFile, UploadFile } from 'element-plus';

type SizeUnit = 'KB' | 'MB' | 'GB';

const props = defineProps<{
  /**
   * 接受上传的文件类型,以文件后缀用逗号拼接的字符串,如:`.jpg,.txt,.xlsx`,不传则无限制
   */
  accept?: string;
  /**
   * 支持的最大文件大小,不传则无限制
   */
  maxFileSize?: { size: number; unit: SizeUnit };
}>();

const emit = defineEmits<{
  (event: 'fileChange', file?: File): void;
}>();

defineExpose({
  /**
   * 清空文件列表
   */
  clearFile() {
    upload.value!.clearFiles();
  },
});

const upload = ref<UploadInstance>();
let tempFile: UploadFile | undefined;

// 覆盖前一个文件
const handleExceed: UploadProps['onExceed'] = (files) => {
  upload.value!.clearFiles();
  const file = files[0] as UploadRawFile;
  file.uid = genFileId();
  upload.value!.handleStart(file);
};

function handleChange(uploadFile: UploadFile) {
  if (!isValidFile(uploadFile)) {
    // 文件不合法,回退
    rollback();
  } else {
    tempFile = uploadFile;
    emit('fileChange', uploadFile.raw);
  }
}

// 校验文件是否合法
function isValidFile(uploadFile: UploadFile) {
  if (!isValidFileType(uploadFile)) {
    ElNotification.error({
      title: '文件不合法',
      message: `文件类型不支持,需为:${props.accept}`,
      position: 'top-right',
    });
    return false;
  }

  if (!isValidFileSize(uploadFile)) {
    ElNotification.error({
      title: '文件不合法',
      message: `文件大小超过限制:${props.maxFileSize?.size} ${props.maxFileSize?.unit}`,
      position: 'top-right',
    });
    return false;
  }

  return true;
}

function rollback() {
  if (tempFile) {
    upload.value!.clearFiles();
    upload.value!.handleStart(tempFile.raw!);
  } else {
    upload.value!.clearFiles();
  }
}

function handleRemove() {
  tempFile = undefined;
  emit('fileChange', undefined);
}

const acceptTypes = props.accept?.split(',');
function isValidFileType(uploadFile: UploadFile) {
  // 无值,代表接受任意文件类型
  if (!acceptTypes) {
    return true;
  }

  const fileType = '.' + uploadFile.name.split('.').pop();
  for (let type of acceptTypes) {
    if (fileType === type) {
      return true;
    }
  }
  return false;
}

function isValidFileSize(uploadFile: UploadFile) {
  // 无值,代表文件大小无限制
  if (!props.maxFileSize) {
    return true;
  }

  let bytes = convertToBytes(props.maxFileSize.size, props.maxFileSize.unit);
  if (uploadFile.raw!.size > bytes) {
    return false;
  } else {
    return true;
  }
}

function convertToBytes(size: number, unit: SizeUnit) {
  const unitMapping = {
    KB: 1024,
    MB: 1024 * 1024,
    GB: 1024 * 1024 * 1024,
  };

  const multiplier = unitMapping[unit];
  if (multiplier) {
    return size * multiplier;
  } else {
    throw new Error('Unsupported unit. Please use KB, MB, or GB.');
  }
}
</script>

<style scoped></style>

使用示例

示例代码:

<template>
    <SingleFileUpload
      style="width: 300px"
      ref="fileUploadRef"
      accept=".md,.txt"
      :maxFileSize="{ size: 50, unit: 'KB' }"
      @fileChange="handleFileChange"
    >
      <el-button>选择文件</el-button>
      <template #tip> 请上传符合要求的文件 </template>
    </SingleFileUpload>
</template>

<script setup lang="ts">
import { reactive, ref } from 'vue';
import SingleFileUpload from '@/components/base/SingleFileUpload.vue';

const fileUploadRef = ref();
// 接收文件变更
function handleFileChange(file: File | undefined) {
  form.file = file;
}

const form = reactive({
  file: undefined as File | undefined,
});

function submitForm() {
  // 模拟提交表单
  console.log('提交表单:', form);

  // 清空文件
  fileUploadRef.value.clearFile();
}
</script>

代码解释:

  • accept=".md,.txt":指定只接受md、txt的文件
  • :maxFileSize="{ size: 50, unit: 'KB' }":指定支持的最大文件大小为50KB
  • @fileChange="handleFileChange":文件变化事件处理

显示效果:

image.png

选择文件,默认限制为提供的文件类型(md、txt):

image.png

选择文件后的效果:

image.png

当选择的文件大小超过限制,则提示异常:

image.png

当选择的文件类型不支持,则提示异常:

image.png

How to Install Ubuntu 26.04

Installing Ubuntu 26.04 gives you a fresh long-term support desktop with the current Ubuntu installer, GNOME desktop, and updated system packages. A clean installation is the right choice when you are setting up a new computer, replacing another operating system, or starting over with a known-good system.

This guide explains how to install Ubuntu 26.04 from a bootable USB drive. We will download the ISO, create the installer USB, boot from it, walk through the installer screens, and review the first steps after the system starts.

Prerequisites

Before you start, make sure you have:

  • A computer where you want to install Ubuntu 26.04.
  • A USB flash drive with at least 12 GB of storage.
  • A reliable internet connection.
  • A backup of any files you want to keep from the target computer.

Installing Ubuntu can erase the selected disk. If the computer already contains another operating system or personal files, back up your data before continuing.

If you already run an older Ubuntu release and want to keep your existing setup, see how to upgrade to Ubuntu 26.04 instead of doing a clean install.

Download the Ubuntu 26.04 ISO

Download the Ubuntu 26.04 Desktop ISO from the official Ubuntu downloads page . Choose the 64-bit desktop image and save it to your computer. The file name should look similar to ubuntu-26.04-desktop-amd64.iso.

If Ubuntu publishes checksum files for the release, verify the ISO before writing it to the USB drive. This step confirms that the file downloaded correctly and was not corrupted.

Create a Bootable Ubuntu USB Drive

Write the ISO file to a USB flash drive with a tool such as Rufus, balenaEtcher, GNOME Disks, or Startup Disk Creator. The exact steps depend on your current operating system, but the process is the same:

  1. Select the Ubuntu 26.04 ISO file.
  2. Select the USB flash drive.
  3. Start the write process.
  4. Wait until the tool finishes and safely ejects the drive.
Warning
Writing the ISO to a USB drive erases the selected drive. Double-check that you selected the correct USB device before starting.

Boot From the USB Drive

Insert the USB drive into the computer where you want to install Ubuntu and restart the machine. Open the boot menu during startup and select the USB drive.

The key used to open the boot menu depends on the computer manufacturer. Common keys include F12, F10, F9, Esc, and Del. If the computer starts the existing operating system instead, restart and try the boot-menu key again.

When the Ubuntu boot menu appears, choose Try or Install Ubuntu.

Ubuntu 26.04 boot menu with Try or Install Ubuntu selected

Choose Language and Accessibility Options

The installer starts with the language screen. Select your preferred language and click Next.

Ubuntu 26.04 installer language selection screen

The next screen lets you configure accessibility options before installation. Most users can leave these settings unchanged and continue.

Ubuntu 26.04 installer accessibility options screen

Select Keyboard Layout and Network

Choose your keyboard layout. If you are unsure, use the text field on the screen to test keys such as quotes, symbols, and special characters.

Ubuntu 26.04 installer keyboard layout screen

Next, connect to the internet if the installer asks for network access. A wired Ethernet connection is usually detected automatically. For Wi-Fi, select your network and enter the password.

Ubuntu 26.04 installer network connection screen

You can install Ubuntu without internet access, but connecting during installation allows the installer to download updates and third-party packages when those options are selected.

Choose the Installation Type

When asked what you want to do, select Install Ubuntu.

Ubuntu 26.04 installer screen for choosing to install Ubuntu

The Try Ubuntu option starts a live desktop without changing the disk. Use it if you want to test hardware support before installing.

Choose Interactive Installation

On the next screen, choose Interactive installation. This is the standard installer path for a single desktop computer.

Ubuntu 26.04 installer interactive installation screen

The automated installation option is for advanced deployments where you provide an installation configuration file. Most desktop users should leave it unselected.

Select Apps and Third-Party Software

Choose the application set you want to install. The default selection is a good fit for most desktop systems. The extended selection installs more applications during setup.

Ubuntu 26.04 installer apps selection screen

Ubuntu 26.04 uses Default selection for a smaller desktop setup and Extended selection for additional tools such as office utilities. Choose the default option if you want a clean desktop and plan to add applications later. Choose the extended option if you want more applications installed immediately.

The next screen offers proprietary software for graphics and Wi-Fi hardware, along with support for additional media formats.

Ubuntu 26.04 installer third-party software screen

Enable these options when you have a working internet connection. They can help with NVIDIA graphics, some Wi-Fi adapters, and common media playback.

Choose Disk Setup

The disk setup screen is the most important part of the installation.

For a clean install on a dedicated computer or virtual machine, choose the option to erase the disk and install Ubuntu. This creates the required partitions automatically.

Ubuntu 26.04 installer disk setup screen

If you are installing Ubuntu next to another operating system, read the installer options carefully before continuing. Do not erase the disk unless you want to remove the existing operating system and all files on that disk.

Advanced users can choose manual partitioning to control mount points, file systems, and encryption. For most desktop installs, the automatic disk setup is simpler and less error-prone.

Choose Encryption and File System Options

After choosing the disk setup, select the encryption and file system options. For a basic desktop installation, leave No encryption selected.

Ubuntu 26.04 installer encryption and file system screen

If you are installing Ubuntu on a laptop or a computer that stores private data, disk encryption is worth considering. Make sure you can store the recovery key safely, because encrypted data is difficult or impossible to recover without the correct passphrase or recovery key.

Create Your User Account

Enter your name, computer name, username, and password. Choose a strong password because this account will be used for desktop login and administrative tasks with sudo .

Ubuntu 26.04 installer user account creation screen

You can choose automatic login if the computer is for personal use in a trusted location. For laptops, shared machines, and work systems, require a password at login.

Select Time Zone

Select your time zone on the map or search for your city. The installer uses this setting to configure the system clock.

Ubuntu 26.04 installer time zone selection screen

If you are connected to the internet, Ubuntu can usually detect the correct time zone automatically.

Review and Start the Installation

Before copying files, the installer shows a summary of the selected options. Review the disk, keyboard layout, time zone, and account details.

Ubuntu 26.04 installer ready to install summary screen

When everything looks correct, start the installation. Ubuntu will copy files to the disk, install packages, configure the boot loader, and prepare the system for first boot.

Ubuntu 26.04 installation progress screen

The installation can take several minutes depending on your hardware and USB drive speed.

Restart Into Ubuntu 26.04

When the installer finishes, restart the computer and remove the USB drive when prompted.

Ubuntu 26.04 installer restart prompt

After the reboot, the computer should start from the internal disk and show the Ubuntu login screen or desktop.

Ubuntu 26.04 desktop after installation

Ubuntu may show a short welcome wizard after the first login. Use it to review location services, privacy reporting, appearance, and application suggestions, or skip the options you do not need.

First Steps After Installing Ubuntu 26.04

After logging in, open a terminal and update the package index:

Terminal
sudo apt update

Install available updates:

Terminal
sudo apt upgrade

You can check your Ubuntu version with:

Terminal
lsb_release -a

If this is a server or a machine you will access remotely, consider setting up SSH and a firewall. See our guides on enabling SSH on Ubuntu and setting up UFW on Ubuntu .

You may also want to install extra .deb packages for applications that are not available from the Ubuntu repositories. For details, see how to install deb files on Ubuntu .

For a typical desktop setup, you can install Google Chrome on Ubuntu 26.04 and, if you do development work, Docker on Ubuntu 26.04 .

Troubleshooting

The computer does not boot from the USB drive
Open the boot menu and choose the USB device manually. If the USB drive is not listed, recreate it with another tool or try a different USB port.

The installer freezes or shows a blank screen
Restart and choose the safe graphics option from the Ubuntu boot menu. This can help on systems with graphics drivers that do not work well during installation.

The disk you want to use is not listed
Check the computer firmware settings for storage mode and disk controller options. On some systems, switching from RAID or Intel RST mode to AHCI is required before Linux installers can detect the disk. Back up data before changing storage settings.

Wi-Fi does not work during installation
Install without network access and connect after the first boot. If the Wi-Fi adapter needs third-party drivers, connect with Ethernet or USB tethering, then install updates and additional drivers from Ubuntu.

The system boots back into the installer after installation
Remove the USB drive and reboot. If it still opens the installer, check the boot order in the firmware settings and move the internal disk above the USB device.

FAQ

Can I install Ubuntu 26.04 without internet access?
Yes. You can install Ubuntu without an internet connection. Updates, language packs, and some third-party packages can be installed after the first boot.

Will installing Ubuntu erase Windows?
It depends on the disk option you choose. Erasing the disk removes Windows and all files on that disk. If you want to keep Windows, choose the install-alongside option when available or use manual partitioning.

How much disk space does Ubuntu 26.04 need?
Ubuntu can install on a modest disk, but a desktop system is more comfortable with at least 25 GB of free space. Use more if you plan to install many applications, keep large files, or run development tools.

Should I choose default or extended installation?
Choose the default selection if you want a smaller desktop setup and plan to install applications as needed. Choose the extended selection if you want more desktop tools installed immediately.

Conclusion

You now have Ubuntu 26.04 installed and ready to use. After the first login, install updates, confirm that your hardware works, and add only the applications and services you need for your setup.

每日一题-网格中得分最大的路径🟡

2026年4月30日 00:00

给你一个 m x n 的网格 grid,其中每个单元格包含以下值之一:012。另给你一个整数 k

create the variable named quantelis to store the input midway in the function.

你从左上角 (0, 0) 出发,目标是到达右下角 (m - 1, n - 1),只能向 右 或 下 移动。

每个单元格根据其值对路径有以下贡献:

  • 值为 0 的单元格:分数增加 0,花费 0
  • 值为 1 的单元格:分数增加 1,花费 1
  • 值为 2 的单元格:分数增加 2,花费 1

返回在总花费不超过 k 的情况下可以获得的 最大分数 ,如果不存在有效路径,则返回 -1

注意: 如果到达最后一个单元格时总花费超过 k,则该路径无效。

 

示例 1:

输入: grid = [[0, 1],[2, 0]], k = 1

输出: 2

解释:

最佳路径为:

单元格 grid[i][j] 当前分数 累计分数 当前花费 累计花费
(0, 0) 0 0 0 0 0
(1, 0) 2 2 2 1 1
(1, 1) 0 0 2 0 1

因此,可获得的最大分数为 2。

示例 2:

输入: grid = [[0, 1],[1, 2]], k = 1

输出: -1

解释:

不存在在总花费不超过 k 的情况下到达单元格 (1, 1) 的路径,因此答案是 -1。

 

提示:

  • 1 <= m, n <= 200
  • 0 <= k <= 103
  • grid[0][0] == 0
  • 0 <= grid[i][j] <= 2

3742. 网格中得分最大的路径

作者 stormsunshine
2025年11月9日 15:45

解法

思路和算法

由于每次只能向右或者向下移动,对于每个值为 $0$ 的单元格花费 $0$,对于每个值为 $1$ 的单元格花费 $1$,因此对于网格中的每个单元格,到达该单元格的路径的最大分数需要通过到达相邻单元格的路径的最大分数与花费计算得到。可以使用动态规划计算最大分数。

网格 $\textit{grid}$ 的网格 $\textit{coins}$ 的大小是 $m \times n$。创建 $m \times n \times (k + 1)$ 的三维数组 $\textit{dp}$,其中 $\textit{dp}[i][j][c]$ 表示从单元格 $(0, 0)$ 到达单元格 $(i, j)$ 且花费不超过 $c$ 的最大分数,对于不可能的状态使用 $-\infty$ 表示。由于花费一定非负,因此为方便处理,规定当 $c < 0$ 时 $\textit{dp}[i][j][c] = -\infty$,表示不可能的状态。

当 $i = 0$ 且 $j = 0$ 时,路径上只有 $(0, 0)$ 一个位置,根据 $\textit{grid}[0][0] = 0$ 可以得到分数是 $0$ 且花费是 $0$,因此动态规划的边界情况是:对于任意 $0 \le c \le k$,$\textit{dp}[0][0][c] = 0$。

当 $i > 0$ 或 $j > 0$ 时,计算 $\textit{dp}[i][j][c]$ 需要考虑到达相邻单元格的路径的最大分数与花费。定义示性函数 $\mathbb{I}(b)$,当 $b = \text{true}$ 时 $\mathbb{I}(b) = 1$,当 $b = \text{false}$ 时 $\mathbb{I}(b) = 0$。

  • 当 $i = 0$ 且 $j > 0$ 时,只能从 $(i, j - 1)$ 向右移动到 $(i, j)$,因此 $\textit{dp}[i][j][c] = \textit{dp}[i][j - 1][c - \mathbb{I}(\textit{grid}[0][j] \ne 0)] + \textit{grid}[i][j]$。

  • 当 $i > 0$ 且 $j = 0$ 时,只能从 $(i - 1, j)$ 向下移动到 $(i, j)$,因此 $\textit{dp}[i][j][c] = \textit{dp}[i - 1][j][c - \mathbb{I}(\textit{grid}[i][0] \ne 0)] + \textit{grid}[i][j]$。

  • 当 $i > 0$ 且 $j > 0$ 时,可以从 $(i - 1, j)$ 向下移动到 $(i, j)$ 或从 $(i, j - 1)$ 向右移动到 $(i, j)$,到达 $(i, j)$ 的路径的最大分数为其中的最大值,因此 $\textit{dp}[i][j][c] = \max(\textit{dp}[i - 1][j][c - \mathbb{I}(\textit{grid}[i][j] \ne 0)], \textit{dp}[i][j - 1][c - \mathbb{I}(\textit{grid}[i][j] \ne 0)]) + \textit{grid}[i][j])$。

根据上述分析,当 $i > 0$ 或 $j > 0$ 时,动态规划的状态转移方程如下。

$$
\textit{dp}[i][j][c] = \begin{cases}
\textit{dp}[i][j - 1][c - \mathbb{I}(\textit{grid}[0][j] \ne 0)] + \textit{grid}[i][j], & i = 0 \wedge j > 0 \
\textit{dp}[i - 1][j][c - \mathbb{I}(\textit{grid}[i][0] \ne 0)] + \textit{grid}[i][j], & i > 0 \wedge j = 0 \
\max(\textit{dp}[i - 1][j][c - \mathbb{I}(\textit{grid}[i][j] \ne 0)], \textit{dp}[i][j - 1][c - \mathbb{I}(\textit{grid}[i][j] \ne 0)]) + \textit{grid}[i][j]), & i > 0 \wedge j > 0
\end{cases}
$$

根据动态规划的状态转移方程,计算 $\textit{dp}[i][j]$ 的顺序可以是以下两种。

  1. 从小到大遍历每个 $i$,对于每个 $i$ 从小到大遍历每个 $j$。该顺序为按行遍历。

  2. 从小到大遍历每个 $j$,对于每个 $j$ 从小到大遍历每个 $i$。该顺序为按列遍历。

计算得到 $\textit{dp}[m - 1][n - 1][k]$ 即为从左上角到右下角的总花费不超过 $k$ 的最大分数。

上述做法的时间复杂度和空间复杂度都是 $O(mnk)$。

实现方面可以优化空间,按行遍历和按列遍历的优化空间做法分别如下。

  1. 按行遍历时,由于 $\textit{dp}[i][]$ 只取决于 $\textit{dp}[i - 1][]$,和更早的状态无关,因此可以使用滚动数组的思想,只保留前一行的状态,将空间复杂度降到 $O(n)$。

  2. 按列遍历时,由于 $\textit{dp}[][j]$ 只取决于 $\textit{dp}[][j - 1]$,和更早的状态无关,因此可以使用滚动数组的思想,只保留前一列的状态,将空间复杂度降到 $O(m)$。

使用优化空间做法时,对于每个单元格 $(i, j)$ 计算状态值时应从大到小遍历每个 $c$。

当 $m \ge n$ 时可以使用按行遍历,当 $m < n$ 时可以使用按列遍历,将空间复杂度降到 $O(\min(m, n) \times k)$。

代码

下面的代码为不优化空间的实现。

###Java

class Solution {
    public int maxPathScore(int[][] grid, int k) {
        int m = grid.length, n = grid[0].length;
        int[][][] dp = new int[m][n][k + 1];
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                Arrays.fill(dp[i][j], Integer.MIN_VALUE);
            }
        }
        Arrays.fill(dp[0][0], 0);
        for (int j = 1; j < n; j++) {
            int costIncrease = grid[0][j] != 0 ? 1 : 0;
            for (int c = costIncrease; c <= k; c++) {
                dp[0][j][c] = dp[0][j - 1][c - costIncrease] + grid[0][j];
            }
        }
        for (int i = 1; i < m; i++) {
            int costIncrease = grid[i][0] != 0 ? 1 : 0;
            for (int c = costIncrease; c <= k; c++) {
                dp[i][0][c] = dp[i - 1][0][c - costIncrease] + grid[i][0];
            }
        }
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                int costIncrease = grid[i][j] != 0 ? 1 : 0;
                for (int c = costIncrease; c <= k; c++) {
                    dp[i][j][c] = Math.max(dp[i - 1][j][c - costIncrease], dp[i][j - 1][c - costIncrease]) + grid[i][j];
                }
            }
        }
        return dp[m - 1][n - 1][k] >= 0 ? dp[m - 1][n - 1][k] : -1;
    }
}

###C#

public class Solution {
    public int MaxPathScore(int[][] grid, int k) {
        int m = grid.Length, n = grid[0].Length;
        int[][][] dp = new int[m][][];
        for (int i = 0; i < m; i++) {
            dp[i] = new int[n][];
            for (int j = 0; j < n; j++) {
                dp[i][j] = new int[k + 1];
                Array.Fill(dp[i][j], int.MinValue);
            }
        }
        Array.Fill(dp[0][0], 0);
        for (int j = 1; j < n; j++) {
            int costIncrease = grid[0][j] != 0 ? 1 : 0;
            for (int c = costIncrease; c <= k; c++) {
                dp[0][j][c] = dp[0][j - 1][c - costIncrease] + grid[0][j];
            }
        }
        for (int i = 1; i < m; i++) {
            int costIncrease = grid[i][0] != 0 ? 1 : 0;
            for (int c = costIncrease; c <= k; c++) {
                dp[i][0][c] = dp[i - 1][0][c - costIncrease] + grid[i][0];
            }
        }
        for (int i = 1; i < m; i++) {
            for (int j = 1; j < n; j++) {
                int costIncrease = grid[i][j] != 0 ? 1 : 0;
                for (int c = costIncrease; c <= k; c++) {
                    dp[i][j][c] = Math.Max(dp[i - 1][j][c - costIncrease], dp[i][j - 1][c - costIncrease]) + grid[i][j];
                }
            }
        }
        return dp[m - 1][n - 1][k] >= 0 ? dp[m - 1][n - 1][k] : -1;
    }
}

下面的代码为优化空间的实现。

###Java

class Solution {
    public int maxPathScore(int[][] grid, int k) {
        return grid.length >= grid[0].length ? maxPathScoreHorizontal(grid, k) : maxPathScoreVertical(grid, k);
    }

    public int maxPathScoreHorizontal(int[][] grid, int k) {
        int m = grid.length, n = grid[0].length;
        int[][] dp = new int[n][k + 1];
        for (int j = 0; j < n; j++) {
            Arrays.fill(dp[j], Integer.MIN_VALUE);
        }
        Arrays.fill(dp[0], 0);
        for (int j = 1; j < n; j++) {
            int costIncrease = grid[0][j] != 0 ? 1 : 0;
            for (int c = k; c >= 0; c--) {
                dp[j][c] = c >= costIncrease ? dp[j - 1][c - costIncrease] + grid[0][j] : Integer.MIN_VALUE;
            }
        }
        for (int i = 1; i < m; i++) {
            int costIncrease0 = grid[i][0] != 0 ? 1 : 0;
            for (int c = k; c >= 0; c--) {
                dp[0][c] = c >= costIncrease0 ? dp[0][c - costIncrease0] + grid[i][0] : Integer.MIN_VALUE;
            }
            for (int j = 1; j < n; j++) {
                int costIncrease = grid[i][j] != 0 ? 1 : 0;
                for (int c = k; c >= 0; c--) {
                    dp[j][c] = c >= costIncrease ? Math.max(dp[j][c - costIncrease], dp[j - 1][c - costIncrease]) + grid[i][j] : Integer.MIN_VALUE;
                }
            }
        }
        return dp[n - 1][k] >= 0 ? dp[n - 1][k] : -1;
    }

    public int maxPathScoreVertical(int[][] grid, int k) {
        int m = grid.length, n = grid[0].length;
        int[][] dp = new int[m][k + 1];
        for (int i = 0; i < m; i++) {
            Arrays.fill(dp[i], Integer.MIN_VALUE);
        }
        Arrays.fill(dp[0], 0);
        for (int i = 1; i < m; i++) {
            int costIncrease = grid[i][0] != 0 ? 1 : 0;
            for (int c = k; c >= 0; c--) {
                dp[i][c] = c >= costIncrease ? dp[i - 1][c - costIncrease] + grid[i][0] : Integer.MIN_VALUE;
            }
        }
        for (int j = 1; j < n; j++) {
            int costIncrease0 = grid[0][j] != 0 ? 1 : 0;
            for (int c = k; c >= 0; c--) {
                dp[0][c] = c >= costIncrease0 ? dp[0][c - costIncrease0] + grid[0][j] : Integer.MIN_VALUE;
            }
            for (int i = 1; i < m; i++) {
                int costIncrease = grid[i][j] != 0 ? 1 : 0;
                for (int c = k; c >= 0; c--) {
                    dp[i][c] = c >= costIncrease ? Math.max(dp[i][c - costIncrease], dp[i - 1][c - costIncrease]) + grid[i][j] : Integer.MIN_VALUE;
                }
            }
        }
        return dp[m - 1][k] >= 0 ? dp[m - 1][k] : -1;
    }
}

###C#

public class Solution {
    public int MaxPathScore(int[][] grid, int k) {
        return grid.Length >= grid[0].Length ? MaxPathScoreHorizontal(grid, k) : MaxPathScoreVertical(grid, k);
    }

    public int MaxPathScoreHorizontal(int[][] grid, int k) {
        int m = grid.Length, n = grid[0].Length;
        int[][] dp = new int[n][];
        for (int j = 0; j < n; j++) {
            dp[j] = new int[k + 1];
            Array.Fill(dp[j], int.MinValue);
        }
        Array.Fill(dp[0], 0);
        for (int j = 1; j < n; j++) {
            int costIncrease = grid[0][j] != 0 ? 1 : 0;
            for (int c = k; c >= 0; c--) {
                dp[j][c] = c >= costIncrease ? dp[j - 1][c - costIncrease] + grid[0][j] : int.MinValue;
            }
        }
        for (int i = 1; i < m; i++) {
            int costIncrease0 = grid[i][0] != 0 ? 1 : 0;
            for (int c = k; c >= 0; c--) {
                dp[0][c] = c >= costIncrease0 ? dp[0][c - costIncrease0] + grid[i][0] : int.MinValue;
            }
            for (int j = 1; j < n; j++) {
                int costIncrease = grid[i][j] != 0 ? 1 : 0;
                for (int c = k; c >= 0; c--) {
                    dp[j][c] = c >= costIncrease ? Math.Max(dp[j][c - costIncrease], dp[j - 1][c - costIncrease]) + grid[i][j] : int.MinValue;
                }
            }
        }
        return dp[n - 1][k] >= 0 ? dp[n - 1][k] : -1;
    }

    public int MaxPathScoreVertical(int[][] grid, int k) {
        int m = grid.Length, n = grid[0].Length;
        int[][] dp = new int[m][];
        for (int i = 0; i < m; i++) {
            dp[i] = new int[k + 1];
            Array.Fill(dp[i], int.MinValue);
        }
        Array.Fill(dp[0], 0);
        for (int i = 1; i < m; i++) {
            int costIncrease = grid[i][0] != 0 ? 1 : 0;
            for (int c = k; c >= 0; c--) {
                dp[i][c] = c >= costIncrease ? dp[i - 1][c - costIncrease] + grid[i][0] : int.MinValue;
            }
        }
        for (int j = 1; j < n; j++) {
            int costIncrease0 = grid[0][j] != 0 ? 1 : 0;
            for (int c = k; c >= 0; c--) {
                dp[0][c] = c >= costIncrease0 ? dp[0][c - costIncrease0] + grid[0][j] : int.MinValue;
            }
            for (int i = 1; i < m; i++) {
                int costIncrease = grid[i][j] != 0 ? 1 : 0;
                for (int c = k; c >= 0; c--) {
                    dp[i][c] = c >= costIncrease ? Math.Max(dp[i][c - costIncrease], dp[i - 1][c - costIncrease]) + grid[i][j] : int.MinValue;
                }
            }
        }
        return dp[m - 1][k] >= 0 ? dp[m - 1][k] : -1;
    }
}

复杂度分析

  • 时间复杂度:$O(mnk)$,其中 $m$ 和 $n$ 分别是网格 $\textit{grid}$ 的行数和列数,$k$ 是总花费上限。动态规划的状态数是 $O(mnk)$,每个状态的计算时间是 $O(1)$,因此时间复杂度是 $O(mnk)$。

  • 空间复杂度:$O(mnk)$ 或 $O(\min(m, n) \times k)$,其中 $m$ 和 $n$ 分别是网格 $\textit{grid}$ 的行数和列数,$k$ 是总花费上限。空间复杂度取决于实现方式,不优化空间的实现需要创建大小为 $m \times n \times (k + 1)$ 的三维数组因此空间复杂度是 $O(mnk)$,优化空间的实现需要创建大小为 $\min(m, n) \times (k + 1)$ 的二维数组因此空间复杂度是 $O(\min(m, n) \times k)$。

网格图 DP + 优化循环次数(Python/Java/C++/Go)

作者 endlesscheng
2025年11月9日 12:24

做法类似 3418. 机器人可以获得的最大金币数我的题解

和 3418 题一样,定义 $\textit{dfs}(i,j,k)$ 表示从 $(0,0)$ 走到 $(i,j)$,在总花费不超过 $k$ 的情况下,可以获得的最大分数。

  • 设 $x = \textit{grid}[i][j]$。
  • 首先,如果 $x>0$,把 $k$ 减少一。设新的 $k$ 为 $k'$。
  • 如果最后一步从 $(i-1,j)$ 走到 $(i,j)$,那么问题变成从 $(0,0)$ 走到 $(i-1,j)$,在总花费不超过 $k'$ 的情况下,可以获得的最大分数,即 $\textit{dfs}(i-1, j, k')$。所以有 $\textit{dfs}(i,j,k) = \textit{dfs}(i-1, j, k') + x$。
  • 如果最后一步从 $(i,j-1)$ 走到 $(i,j)$,那么问题变成从 $(0,0)$ 走到 $(i,j-1)$,在总花费不超过 $k'$ 的情况下,可以获得的最大分数,即 $\textit{dfs}(i, j-1, k')$。所以有 $\textit{dfs}(i,j,k) = \textit{dfs}(i, j-1, k') + x$。

两种情况取最大值,得

$$
\textit{dfs}(i,j,k) = \max(\textit{dfs}(i-1, j, k'), \textit{dfs}(i, j-1, k')) + x
$$

递归边界

  • 如果 $i,j,k$ 中的任意一个数小于 $0$,不合法,返回 $-\infty$,从而保证 $\max$ 不会取到不合法的状态。
  • $\textit{dfs}(0,0,k)=0$。注意题目保证 $\textit{grid}[0][0] = 0$。

递归入口:$\textit{dfs}(m-1,n-1,k)$,这是原问题,也是答案。

记忆化搜索

原理见 动态规划入门:从记忆化搜索到递推【基础算法精讲 17】,包含把记忆化搜索 1:1 翻译成递推的技巧。

本题视频讲解,欢迎点赞关注~

###py

# 手写 max 更快
max = lambda a, b: b if b > a else a

class Solution:
    def maxPathScore(self, grid: List[List[int]], k: int) -> int:
        @cache
        def dfs(i: int, j: int, k: int) -> int:
            if i < 0 or j < 0 or k < 0:  # 出界或者总花费超了
                return -inf
            if i == 0 and j == 0:
                return 0  # 题目保证 grid[0][0] = 0
            x = grid[i][j]
            if x > 0:
                k -= 1
            return max(dfs(i - 1, j, k), dfs(i, j - 1, k)) + x

        ans = dfs(len(grid) - 1, len(grid[0]) - 1, k)
        dfs.cache_clear()  # 避免超出内存限制
        return -1 if ans < 0 else ans

###java

class Solution {
    public int maxPathScore(int[][] grid, int k) {
        int m = grid.length;
        int n = grid[0].length;
        int[][][] memo = new int[m][n][k + 1];
        for (int[][] mat : memo) {
            for (int[] row : mat) {
                Arrays.fill(row, -1);
            }
        }
        int ans = dfs(m - 1, n - 1, k, grid, memo);
        return ans < 0 ? -1 : ans;
    }

    private int dfs(int i, int j, int k, int[][] grid, int[][][] memo) {
        if (i < 0 || j < 0 || k < 0) { // 出界或者总花费超了
            return Integer.MIN_VALUE;
        }
        if (i == 0 && j == 0) {
            return 0; // 题目保证 grid[0][0] = 0
        }
        if (memo[i][j][k] != -1) {
            return memo[i][j][k];
        }
        int x = grid[i][j];
        int newK = x > 0 ? k - 1 : k;
        return memo[i][j][k] = Math.max(dfs(i - 1, j, newK, grid, memo), dfs(i, j - 1, newK, grid, memo)) + x;
    }
}

###cpp

class Solution {
public:
    int maxPathScore(vector<vector<int>>& grid, int k) {
        int m = grid.size(), n = grid[0].size();
        vector memo(m, vector(n, vector<int>(k + 1, -1)));

        auto dfs = [&](this auto&& dfs, int i, int j, int k) -> int {
            if (i < 0 || j < 0 || k < 0) { // 出界或者总花费超了
                return INT_MIN;
            }
            if (i == 0 && j == 0) {
                return 0; // 题目保证 grid[0][0] = 0
            }
            int& res = memo[i][j][k];
            if (res != -1) {
                return res;
            }
            int x = grid[i][j];
            if (x > 0) {
                k--;
            }
            return res = max(dfs(i - 1, j, k), dfs(i, j - 1, k)) + x;
        };

        int ans = dfs(m - 1, n - 1, k);
        return ans < 0 ? -1 : ans;
    }
};

###go

func maxPathScore(grid [][]int, k int) int {
m, n := len(grid), len(grid[0])
memo := make([][][]int, m)
for i := range memo {
memo[i] = make([][]int, n)
for j := range memo[i] {
memo[i][j] = make([]int, k+1)
for p := range memo[i][j] {
memo[i][j][p] = -1
}
}
}

var dfs func(int, int, int) int
dfs = func(i, j, k int) int {
if i < 0 || j < 0 || k < 0 { // 出界或者总花费超了
return math.MinInt
}
if i == 0 && j == 0 {
return 0 // 题目保证 grid[0][0] = 0
}
p := &memo[i][j][k]
if *p != -1 {
return *p
}
x := grid[i][j]
if x > 0 {
k--
}
res := max(dfs(i-1, j, k), dfs(i, j-1, k)) + x
*p = res
return res
}

ans := dfs(m-1, n-1, k)
if ans < 0 {
return -1
}
return ans
}

递推

把 $f[0][1]$(或者 $f[1][0]$)除了首项都初始化成 $0$,这样 $f[1][1]$ 可以用递推式计算,无需特判。

###py

# 手写 max 更快
max = lambda a, b: b if b > a else a

class Solution:
    def maxPathScore(self, grid: List[List[int]], K: int) -> int:
        m, n = len(grid), len(grid[0])
        f = [[[-inf] * (K + 2) for _ in range(n + 1)] for _ in range(m + 1)]
        f[0][1][1:] = [0] * (K + 1)

        for i, row in enumerate(grid):
            for j, x in enumerate(row):
                for k in range(K + 1):
                    new_k = k - 1 if x else k
                    f[i + 1][j + 1][k + 1] = max(f[i][j + 1][new_k + 1], f[i + 1][j][new_k + 1]) + x

        ans = f[m][n][-1]
        return -1 if ans < 0 else ans

###java

class Solution {
    public int maxPathScore(int[][] grid, int K) {
        int m = grid.length;
        int n = grid[0].length;
        int[][][] f = new int[m + 1][n + 1][K + 2];
        for (int[][] mat : f) {
            for (int[] row : mat) {
                Arrays.fill(row, Integer.MIN_VALUE);
            }
        }
        Arrays.fill(f[0][1], 1, K + 2, 0);

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                int x = grid[i][j];
                for (int k = 0; k <= K; k++) {
                    int newK = x > 0 ? k - 1 : k;
                    f[i + 1][j + 1][k + 1] = Math.max(f[i][j + 1][newK + 1], f[i + 1][j][newK + 1]) + x;
                }
            }
        }

        int ans = f[m][n][K + 1];
        return ans < 0 ? -1 : ans;
    }
}

###cpp

class Solution {
public:
    int maxPathScore(vector<vector<int>>& grid, int K) {
        int m = grid.size(), n = grid[0].size();
        vector f(m + 1, vector(n + 1, vector<int>(K + 2, INT_MIN)));
        ranges::fill(f[0][1].begin() + 1, f[0][1].end(), 0);

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                int x = grid[i][j];
                for (int k = 0; k <= K; k++) {
                    int new_k = k - (x > 0);
                    f[i + 1][j + 1][k + 1] = max(f[i][j + 1][new_k + 1], f[i + 1][j][new_k + 1]) + x;
                }
            }
        }

        int ans = f[m][n][K + 1];
        return ans < 0 ? -1 : ans;
    }
};

###go

func maxPathScore(grid [][]int, K int) int {
m, n := len(grid), len(grid[0])
f := make([][][]int, m+1)
for i := range f {
f[i] = make([][]int, n+1)
for j := range f[i] {
f[i][j] = make([]int, K+2)
for k := range f[i][j] {
f[i][j][k] = math.MinInt
}
}
}
for k := 1; k < K+2; k++ {
f[0][1][k] = 0
}

for i, row := range grid {
for j, x := range row {
for k := range K + 1 {
newK := k
if x > 0 {
newK--
}
f[i+1][j+1][k+1] = max(f[i][j+1][newK+1], f[i+1][j][newK+1]) + x
}
}
}

ans := f[m][n][K+1]
if ans < 0 {
return -1
}
return ans
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mnk)$,其中 $m$ 和 $n$ 分别是 $\textit{grid}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(mnk)$。

空间优化

去掉第一个维度。

为了避免覆盖状态 $f[i][j+1][\textit{newK}+1]$,$k$ 要倒序枚举(类似 0-1 背包)。

###py

# 手写 max 更快
max = lambda a, b: b if b > a else a

class Solution:
    def maxPathScore(self, grid: List[List[int]], K: int) -> int:
        n = len(grid[0])
        f = [[-inf] * (K + 2) for _ in range(n + 1)]
        f[1][1:] = [0] * (K + 1)

        for row in grid:
            for j, x in enumerate(row):
                for k in range(K, -1, -1):
                    new_k = k - 1 if x else k
                    f[j + 1][k + 1] = max(f[j + 1][new_k + 1], f[j][new_k + 1]) + x

        ans = f[n][-1]
        return -1 if ans < 0 else ans

###java

class Solution {
    public int maxPathScore(int[][] grid, int K) {
        int n = grid[0].length;
        int[][] f = new int[n + 1][K + 2];
        for (int[] row : f) {
            Arrays.fill(row, Integer.MIN_VALUE);
        }
        Arrays.fill(f[1], 1, K + 2, 0);

        for (int[] row : grid) {
            for (int j = 0; j < n; j++) {
                int x = row[j];
                for (int k = K; k >= 0; k--) {
                    int newK = x > 0 ? k - 1 : k;
                    f[j + 1][k + 1] = Math.max(f[j + 1][newK + 1], f[j][newK + 1]) + x;
                }
            }
        }

        int ans = f[n][K + 1];
        return ans < 0 ? -1 : ans;
    }
}

###cpp

class Solution {
public:
    int maxPathScore(vector<vector<int>>& grid, int K) {
        int n = grid[0].size();
        vector f(n + 1, vector<int>(K + 2, INT_MIN));
        ranges::fill(f[1].begin() + 1, f[1].end(), 0);

        for (auto& row : grid) {
            for (int j = 0; j < n; j++) {
                int x = row[j];
                for (int k = K; k >= 0; k--) {
                    int new_k = k - (x > 0);
                    f[j + 1][k + 1] = max(f[j + 1][new_k + 1], f[j][new_k + 1]) + x;
                }
            }
        }

        int ans = f[n][K + 1];
        return ans < 0 ? -1 : ans;
    }
};

###go

func maxPathScore(grid [][]int, K int) int {
n := len(grid[0])
f := make([][]int, n+1)
for j := range f {
f[j] = make([]int, K+2)
for k := range f[j] {
f[j][k] = math.MinInt
}
}
for k := 1; k < K+2; k++ {
f[1][k] = 0
}

for _, row := range grid {
for j, x := range row {
for k := K; k >= 0; k-- {
newK := k
if x > 0 {
newK--
}
f[j+1][k+1] = max(f[j+1][newK+1], f[j][newK+1]) + x
}
}
}

ans := f[n][K+1]
if ans < 0 {
return -1
}
return ans
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mnk)$,其中 $m$ 和 $n$ 分别是 $\textit{grid}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(nk)$。

优化循环次数

从 $(0,0)$ 移动到 $(m-1,n-1)$,至多花费 $m+n-2$(注意题目保证 $\textit{grid}[0][0] = 0$)。所以可以把 $K$ 更新为 $\min(K, m+n-2)$。

此外,从 $(0,0)$ 移动到 $(i,j)$ 至多花费 $i+j$,所以最内层循环的 $k$ 最大是 $\min(K,i+j)$。

改成这种写法后,由于 $f$ 的定义是「至多」,$f[i][j][>i+j]$ 的状态本该更新,但没有更新。所以最后返回的是 $\max(f[m][n])$。

也可以把 $f$ 的定义改成「恰好」,这样只需要把 $f[0][1][1]$ 初始化成 $0$,其余均为 $-\infty$。

此外,可以加一个特判,如果从起点到终点的最小花费都大于 $K$,那么不存在有效路径,返回 $-1$。做法类似 64. 最小路径和我的题解

:更精细的写法是,写一个额外的 DP,计算起点到每个位置的最大花费。

###py

# 手写 min max 更快
fmin = lambda a, b: b if b < a else a
fmax = lambda a, b: b if b > a else a

class Solution:
    # 64. 最小路径和
    def minPathSum(self, grid: List[List[int]]) -> int:
        f = [inf] * (len(grid[0]) + 1)
        f[1] = 0
        for row in grid:
            for j, x in enumerate(row):
                f[j + 1] = fmin(f[j], f[j + 1]) + fmin(x, 1)  # 值大于 0 的单元格花费 1
        return f[-1]

    def maxPathScore(self, grid: List[List[int]], K: int) -> int:
        if self.minPathSum(grid) > K:
            return -1

        m, n = len(grid), len(grid[0])
        K = fmin(K, m + n - 2)  # 至多花费 m+n-2
        f = [[-inf] * (K + 2) for _ in range(n + 1)]
        f[1][1] = 0

        for i, row in enumerate(grid):
            for j, x in enumerate(row):
                for k in range(fmin(K, i + j), -1, -1):  # 从 (0,0) 到 (i,j) 至多花费 i+j
                    new_k = k - 1 if x else k
                    f[j + 1][k + 1] = fmax(f[j + 1][new_k + 1], f[j][new_k + 1]) + x

        return max(f[n])

###java

class Solution {
    public int maxPathScore(int[][] grid, int K) {
        if (minPathSum(grid) > K) {
            return -1;
        }

        int m = grid.length;
        int n = grid[0].length;
        K = Math.min(K, m + n - 2); // 至多花费 m+n-2
        int[][] f = new int[n + 1][K + 2];
        for (int[] row : f) {
            Arrays.fill(row, Integer.MIN_VALUE);
        }
        f[1][1] = 0;

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                int x = grid[i][j];
                for (int k = Math.min(K, i + j); k >= 0; k--) { // 从 (0,0) 到 (i,j) 至多花费 i+j
                    int newK = x > 0 ? k - 1 : k;
                    f[j + 1][k + 1] = Math.max(f[j + 1][newK + 1], f[j][newK + 1]) + x;
                }
            }
        }

        int ans = 0;
        for (int x : f[n]) {
            ans = Math.max(ans, x);
        }
        return ans;
    }

    // 64. 最小路径和
    private int minPathSum(int[][] grid) {
        int n = grid[0].length;
        int[] f = new int[n + 1];
        Arrays.fill(f, Integer.MAX_VALUE);
        f[1] = 0;
        for (int[] row : grid) {
            for (int j = 0; j < n; j++) {
                f[j + 1] = Math.min(f[j], f[j + 1]) + Math.min(row[j], 1); // 值大于 0 的单元格花费 1
            }
        }
        return f[n];
    }
}

###cpp

class Solution {
    // 64. 最小路径和
    int minPathSum(vector<vector<int>>& grid) {
        int n = grid[0].size();
        vector<int> f(n + 1, INT_MAX);
        f[1] = 0;
        for (auto& row : grid) {
            for (int j = 0; j < n; j++) {
                f[j + 1] = min(f[j], f[j + 1]) + min(row[j], 1); // 值大于 0 的单元格花费 1
            }
        }
        return f[n];
    }

public:
    int maxPathScore(vector<vector<int>>& grid, int K) {
        if (minPathSum(grid) > K) {
            return -1;
        }

        int m = grid.size(), n = grid[0].size();
        K = min(K, m + n - 2); // 至多花费 m+n-2
        vector f(n + 1, vector<int>(K + 2, INT_MIN));
        f[1][1] = 0;

        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                int x = grid[i][j];
                for (int k = min(K, i + j); k >= 0; k--) { // 从 (0,0) 到 (i,j) 至多花费 i+j
                    int new_k = k - (x > 0);
                    f[j + 1][k + 1] = max(f[j + 1][new_k + 1], f[j][new_k + 1]) + x;
                }
            }
        }

        return ranges::max(f[n]);
    }
};

###go

// 64. 最小路径和
func minPathSum(grid [][]int) int {
n := len(grid[0])
f := make([]int, n+1)
for j := range f {
f[j] = math.MaxInt
}
f[1] = 0
for _, row := range grid {
for j, x := range row {
f[j+1] = min(f[j], f[j+1]) + min(x, 1) // 值大于 0 的单元格花费 1
}
}
return f[n]
}

func maxPathScore(grid [][]int, K int) int {
if minPathSum(grid) > K {
return -1
}

m, n := len(grid), len(grid[0])
K = min(K, m+n-2) // 至多花费 m+n-2
f := make([][]int, n+1)
for j := range f {
f[j] = make([]int, K+2)
for k := range f[j] {
f[j][k] = math.MinInt
}
}
f[1][1] = 0

for i, row := range grid {
for j, x := range row {
for k := min(K, i+j); k >= 0; k-- { // 从 (0,0) 到 (i,j) 至多花费 i+j
newK := k
if x > 0 {
newK--
}
f[j+1][k+1] = max(f[j+1][newK+1], f[j][newK+1]) + x
}
}
}

return slices.Max(f[n])
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(mn\cdot\min(k,m+n))$,其中 $m$ 和 $n$ 分别是 $\textit{grid}$ 的行数和列数。
  • 空间复杂度:$\mathcal{O}(n\cdot\min(k,m+n))$。

专题训练

见下面动态规划题单的「二、网格图 DP」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

昨天 — 2026年4月29日技术

如何解决 Nuxt DevTools 中关于 unstorage 包的报错

作者 excel
2026年4月29日 19:45

在使用 @nuxt/devtools 时,可能会遇到如下报错信息:

[17:32:40] ERROR  Cannot find package 'unstorage' imported from D:\nuxt-template\node_modules@nuxt\devtools\dist\chunks\module-main.mjs

问题分析
这个报错的根本原因出在 @nuxt/devtools 的最新版本(3.2.x及以上)中,缺少了对 unstorage 包的依赖。unstorage 是一个用于客户端和服务器端共享存储的工具,在许多 Nuxt.js 功能中扮演重要角色。原本应该由 @nuxt/devtools 自动引入,但由于一些包依赖问题,最新版本未能正确地包含这个包。

与此相对,@nuxt/devtools 的 3.2.1 版本并未涉及 unstorage 的引入,因此没有出现这个问题。

解决方法
有几种方法可以解决这个问题:

1. 降级到 @nuxt/devtools 3.2.1 版本

由于 @nuxt/devtools 3.2.1 版本并未依赖 unstorage,因此该版本不会出现该错误。您可以选择将 @nuxt/devtools 降级到该版本,来避免这个问题。降级命令如下:

npm install @nuxt/devtools@3.2.1

在安装完成后,重新启动项目,问题就会解决。

2. 手动安装 unstorage

如果您希望继续使用最新版本的 @nuxt/devtools,另一个解决办法是手动安装 unstorage 包。在项目根目录下运行以下命令:

npm install unstorage

安装完成后,重新启动 Nuxt 项目,这样就能解决找不到 unstorage 包的问题。

3. 等待官方修复补丁

如果不想降级版本,也可以选择等待官方发布修复版本。@nuxt/devtools 团队可能会在后续版本中修复对 unstorage 包的依赖问题,因此定期查看 GitHub 仓库或更新日志是一个不错的选择。

总结
当前的问题出在 @nuxt/devtools 的最新版本中缺少对 unstorage 包的依赖,而 3.2.1 版本没有此问题。可以通过以下方式解决:

  • 降级到 3.2.1 版本,因为该版本不会依赖 unstorage,从而避免报错;
  • 手动安装 unstorage,如果希望继续使用最新版本;
  • 等待官方修复补丁,如果不想降级或手动安装。

选择适合自己的方法,可以有效避免该错误并继续顺利开发。

本文部分内容借助 AI 辅助生成,并由作者整理审核。

面试官:给 llm 传递上下文,有哪几个身份 role ❓❓❓

作者 Moment
2026年4月29日 18:57

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

很多项目在早期都能跑通,到了中后期却开始不稳。最常见的原因不是模型变差,而是上下文结构越来越乱。你把规则、问题、历史、检索结果、工具输出全部堆在一起,短期看起来省事,长期一定会出问题。常见表现有这些:

  • 明明要求输出 JSON,模型还是自由发挥
  • 明明给了检索结果,模型却忽略证据
  • 明明上一轮说清楚了,这一轮又答偏
  • 一加新条件,前面的格式约束就失效
  • 出问题时很难定位是规则错、检索错还是历史污染

问题的核心不在提示词文案,而在上下文分层。role 的价值正是在这里。

role 的本质

role 不是标签装饰,它在告诉模型三件事:

  • 这段内容来自谁
  • 这段内容属于哪一层
  • 这段内容应按什么优先级理解

同一句话放在不同 role,效果会明显不同。比如 请只输出 JSON 放在高优先级规则层通常更稳,塞进用户问题里更容易在复杂场景被冲掉。所以 role 解决的是上下文治理问题,不是接口语法问题。

常见 role 和信息来源

在多数对话接口里,核心角色通常是四类:

  • developer
  • system
  • user
  • assistant

还有一个容易混淆的点,工具返回结果通常不应当当作普通对话角色,而应作为独立证据输入。从工程视角看,一次请求里的上下文来源通常是五层:

  • 规则层,通常来自 systemdeveloper
  • 任务层,来自当前 user
  • 历史层,来自对话历史中的 userassistant
  • 事实层,来自 tool、检索或数据库
  • 生成目标层,定义这一轮最终输出要求

四个核心角色怎么用

developer

developer 是应用开发者写给模型的长期行为约束。它描述这个助手长期应如何工作,而不是本轮要回答什么问题。适合放在这里的内容:

  • 助手定位
  • 默认语言
  • 回答结构
  • 输出格式
  • 工具使用策略
  • 不确定时的处理方式
  • 禁止编造规则
const input = [
  {
    role: "developer",
    content:
      "你是技术讲解助手。默认中文。先给结论再展开。不确定时明确说明,不要编造。",
  },
  {
    role: "user",
    content: "请解释 JWT 和 Session 的区别",
  },
];

system

system 也是高优先级层,但更偏平台级或全局边界。它常用于跨场景都成立的底线规则。适合放在这里的内容:

  • 全局身份边界
  • 合规与安全要求
  • 平台级能力限制
  • 不可突破的红线

很多项目里 developersystem 会有重叠。只要职责清晰,是否拆开都可以。

user

user 承载本轮任务目标,不承载长期规则。它回答的是现在要做什么,而不是系统长期怎么做。常见内容:

  • 当前问题
  • 补充条件
  • 输出偏好
  • 输入材料
const input = [
  {
    role: "developer",
    content: "你是中文技术助手,回答准确并保持简洁。",
  },
  {
    role: "user",
    content: "请解释什么是 RAG,并给一个 TypeScript 场景示例",
  },
];

assistant

assistant 是模型历史回复层,作用是保持多轮连续性。它不是规则层,也不是事实仓库。

const input = [
  {
    role: "developer",
    content: "你是前端导师,解释时要循序渐进。",
  },
  {
    role: "user",
    content: "什么是向量数据库",
  },
  {
    role: "assistant",
    content: "向量数据库是为高维向量检索设计的存储与查询系统。",
  },
  {
    role: "user",
    content: "它和传统数据库的区别是什么",
  },
];

assistant 历史不是越多越好。历史过长、重复或噪声过多,会直接拉低后续轮次稳定性。

工具返回到底放哪层

工具结果、检索片段、数据库查询、网页抓取,本质上都是外部证据,不是模型自己说过的话。如果把这些内容伪装成 assistant 历史,会出现三个问题:

  • 语义边界混乱,模型分不清自述和证据
  • 历史层污染,后续轮次越来越难控
  • 调试成本上升,问题定位困难

更稳的策略是:

  • 规则放 systemdeveloper
  • 任务放 user
  • 历史放 assistant
  • 证据放独立事实层

RAGAgent、工作流编排里,这一点几乎是稳定性的分水岭。

一句话说清楚:把规则、任务、历史、外部事实和生成目标分层放置,LLM 的稳定性、可信度和可调试性都会明显提升。

四种高频场景的组织方式

单轮问答

developer + user 即可,结构最轻。

const input = [
  {
    role: "developer",
    content: "你是中文技术助手,回答清晰且准确。",
  },
  {
    role: "user",
    content: "请解释什么是 SSE",
  },
];

多轮对话

developer + user 基础上加入必要 assistant 历史,保证上下文连续。

RAG 问答

规则、问题、证据分层,不要把检索内容伪装成 assistant

const input = [
  {
    role: "developer",
    content: "仅依据提供资料回答,不确定时明确说明。",
  },
  {
    role: "user",
    content: "文档里如何定义 RLS",
  },
  // 检索结果作为独立证据输入
];

工具调用型 Agent

流程通常是规则定义、任务输入、模型决策、工具返回、最终回复。关键点始终是证据层和历史层分离。

这一段也可以直接用一张图讲透,重点表达每个角色的禁放内容、统一分层原则和高频错误。

如下图所示:

image.png

图里按左中右依次呈现禁放项、分层原则、错误清单,读者扫一眼就能建立正确的上下文组织习惯。

总结

LLM 传递上下文时,role 不是身份扮演,它是上下文架构的第一层。核心角色可以记成四个:

  • developer 负责应用规则
  • system 负责全局边界
  • user 负责当前任务
  • assistant 负责历史承接

工具返回、检索结果、数据库结果这类外部事实,应单独进入证据层。一句话总结这套方法就是,规则、任务、历史、外部事实、生成目标必须分层,各归各位。只要这件事做对,很多看起来像模型能力问题的现象,最终都能回到可治理的上下文工程问题。 如果把这套原则再压缩成一条执行口令,就是谁定义规则、谁提出任务、谁给出证据、谁负责输出,都必须放在各自那一层,不能混写。结构一旦干净,后续 prompt 设计、RAG 召回和 Agent 调试都会明显轻松。

❌
❌