<?xml version="1.0" encoding="UTF-8"?><rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title>掘金 JavaScript</title><link>https://juejin.cn/tag/JavaScript</link><atom:link href="http://rss.144-124-237-35.sslip.io/juejin/tag/JavaScript" rel="self" type="application/rss+xml"></atom:link><description>掘金 JavaScript - Powered by AtomRSS</description><generator>AtomRSS</generator><webMaster>contact@atomgroup.dev (AtomRSS)</webMaster><language>en</language><image><url>https://p1-jj.byteimg.com/tos-cn-i-t2oaga2asx/leancloud-assets/5d70fd6af940df373834.png~tplv-t2oaga2asx-image.image</url><title>掘金 JavaScript</title><link>https://juejin.cn/tag/JavaScript</link></image><lastBuildDate>Mon, 19 Oct 2026 04:08:56 GMT</lastBuildDate><ttl>5</ttl><item><title>AI 帮你画模板、点一下就云打印：sv-print 这三个月都干了啥</title><description>&lt;h2 data-id=&quot;heading-0&quot;&gt;AI 帮你画模板、点一下就云打印：sv-print 这三个月都干了啥&lt;/h2&gt;
&lt;p&gt;哈喽哇！我是&lt;em&gt;小不&lt;/em&gt;，&lt;em&gt;不简说&lt;/em&gt;的不～&lt;/p&gt;
&lt;p&gt;上一篇关于 sv-print 的文章已经是是七月初了，现在都十月中了。中间 sv-print 更了 &lt;strong&gt;6 次&lt;/strong&gt;，版本号从 &lt;code&gt;0.2.22&lt;/code&gt; 一路滚到 &lt;code&gt;0.2.26&lt;/code&gt;，插件也越来越多啦~&lt;/p&gt;
&lt;p&gt;我原本想数一下这三个月新增了多少东西，数到一半放弃了，太多了 😂&lt;/p&gt;
&lt;p&gt;所以这期不按时间线写了，&lt;strong&gt;按模块写&lt;/strong&gt;。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;云服务、&lt;/li&gt;
&lt;li&gt;AI 设计、&lt;/li&gt;
&lt;li&gt;数据源、&lt;/li&gt;
&lt;li&gt;骑缝章、&lt;/li&gt;
&lt;li&gt;矢量 PDF、&lt;/li&gt;
&lt;li&gt;Word 导出、&lt;/li&gt;
&lt;li&gt;静默打印客户端纸张管理&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;……你用哪个就看哪个，不用从头翻。&lt;/p&gt;
&lt;p&gt;一句话概括这三个月：&lt;/p&gt;
&lt;p&gt;&lt;em&gt;sv-print 从&quot;一个打印设计器&quot;变成了&quot;一个能连云、能被 AI 帮我干活、能对接 SaaS 的打印设计器&quot;。&lt;/em&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h3 data-id=&quot;heading-1&quot;&gt;一、新插件篇：这一波最大的变化&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-2&quot;&gt;1. &lt;code&gt;@sv-print/plugin-cloud&lt;/code&gt; 云服务插件 🌩️&lt;/h4&gt;
&lt;p&gt;这个是这三个月里分量最重的一个。它把 sv-print 从&quot;孤岛&quot;拉进了云端生态。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;能干啥：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;可插拔登录&lt;/strong&gt;：oauth / token / host / local 四种模式，面板直接切换，&lt;code&gt;autoLogin&lt;/code&gt; 启动没登录就自动登录一次&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;服务端渲染&lt;/strong&gt;：不用在浏览器里渲染，直接甩给服务端出 PDF&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;云打印 / 中转打印&lt;/strong&gt;：打印任务下发到云端设备，本地机器只负责发指令&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;模板中心&lt;/strong&gt;：模板存云端，多人协作、跨设备同步&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/456f1b359e8b462ca92d4144e2bbce80~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=mwdYRW0WTuQHCpdYPAJaI3VSInU%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/f9c3f884d7c44c44b5f4d28c461de404~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=FHboiaxDWSb9M7wYnHJPz30v%2Brw%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-cloud/dist/style.css&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginCloud &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-cloud&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [
  &lt;span class=&quot;hljs-title function_&quot;&gt;pluginCloud&lt;/span&gt;({
    &lt;span class=&quot;hljs-comment&quot;&gt;// host 缺省时走当前站点 /api&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;autoLogin&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 启动未登录时自动发起一次登录&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;auth&lt;/span&gt;: {
      &lt;span class=&quot;hljs-attr&quot;&gt;type&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;oauth&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;authorizeOrigin&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;https://www.example.com&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;clientId&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;sv-print-demo&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-comment&quot;&gt;// 生产推荐: client_secret 由宿主服务端持有&lt;/span&gt;
      &lt;span class=&quot;hljs-attr&quot;&gt;exchangeToken&lt;/span&gt;: &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; (code, state) =&amp;gt; {
        &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; res = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;fetch&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;/my-backend/svprint/token&quot;&lt;/span&gt;, {
          &lt;span class=&quot;hljs-attr&quot;&gt;method&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;POST&quot;&lt;/span&gt;,
          &lt;span class=&quot;hljs-attr&quot;&gt;headers&lt;/span&gt;: { &lt;span class=&quot;hljs-string&quot;&gt;&quot;Content-Type&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;application/json&quot;&lt;/span&gt; },
          &lt;span class=&quot;hljs-attr&quot;&gt;body&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;stringify&lt;/span&gt;({ code, state }),
        });
        &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; res.&lt;span class=&quot;hljs-title function_&quot;&gt;json&lt;/span&gt;()).&lt;span class=&quot;hljs-property&quot;&gt;data&lt;/span&gt;; &lt;span class=&quot;hljs-comment&quot;&gt;// { accessToken, ... }&lt;/span&gt;
      },
    },
  }),
];

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; cloud = designerUtils.&lt;span class=&quot;hljs-property&quot;&gt;cloud&lt;/span&gt;;
&lt;span class=&quot;hljs-comment&quot;&gt;// 模板中心&lt;/span&gt;
cloud.&lt;span class=&quot;hljs-property&quot;&gt;templateCenter&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;show&lt;/span&gt;();
&lt;span class=&quot;hljs-comment&quot;&gt;// 服务端渲染(同步) / 云打印 / 中转打印&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; designerUtils.&lt;span class=&quot;hljs-property&quot;&gt;printTemplate&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;serverRender&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;sync&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;type&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;pdf&quot;&lt;/span&gt; });
&lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; designerUtils.&lt;span class=&quot;hljs-property&quot;&gt;printTemplate&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;cloudPrint&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;device_id&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;dev-xxx&quot;&lt;/span&gt; });
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;✨ 10-18 这一版又补了几块：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;云打印现在是三步走&lt;/strong&gt;——选设备 → 选打印机 → 下发任务。以前是拿 device_id 直接打，现在有了 &lt;code&gt;getDevicePrinters&lt;/code&gt;，能先看看这台设备上到底有几台打印机、哪台是默认：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; cloud = designerUtils.&lt;span class=&quot;hljs-property&quot;&gt;cloud&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; devices = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; cloud.&lt;span class=&quot;hljs-title function_&quot;&gt;getDevices&lt;/span&gt;();
&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!devices.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;暂无云打印设备&quot;&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; device = devices[&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;];
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; printers = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; cloud.&lt;span class=&quot;hljs-title function_&quot;&gt;getDevicePrinters&lt;/span&gt;(device.&lt;span class=&quot;hljs-property&quot;&gt;device_id&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; printer =
  printers.&lt;span class=&quot;hljs-title function_&quot;&gt;find&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;p&lt;/span&gt;) =&amp;gt;&lt;/span&gt; p.&lt;span class=&quot;hljs-property&quot;&gt;isDefault&lt;/span&gt; &amp;amp;&amp;amp; !p.&lt;span class=&quot;hljs-property&quot;&gt;disabled&lt;/span&gt;) ||
  printers.&lt;span class=&quot;hljs-title function_&quot;&gt;find&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;p&lt;/span&gt;) =&amp;gt;&lt;/span&gt; !p.&lt;span class=&quot;hljs-property&quot;&gt;disabled&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!printer) &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;设备暂无可用打印机&quot;&lt;/span&gt;);

&lt;span class=&quot;hljs-comment&quot;&gt;// 用当前设计器的模板 json 和打印数据&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; cloud.&lt;span class=&quot;hljs-title function_&quot;&gt;cloudPrint&lt;/span&gt;({
  &lt;span class=&quot;hljs-attr&quot;&gt;device_id&lt;/span&gt;: device.&lt;span class=&quot;hljs-property&quot;&gt;device_id&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;print_type&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;template&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-comment&quot;&gt;// template_data: templateJson, // 缺省取当前设计模板数据&lt;/span&gt;
  &lt;span class=&quot;hljs-comment&quot;&gt;// preview_data: { name: &quot;张三&quot; }, // 缺省取当前设计打印数据&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;print_options&lt;/span&gt;: { &lt;span class=&quot;hljs-attr&quot;&gt;printer&lt;/span&gt;: printer.&lt;span class=&quot;hljs-property&quot;&gt;name&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;copies&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;duplexMode&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;simplex&quot;&lt;/span&gt; },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ 这版还有个细节：&lt;strong&gt;非设计模式下也能用模板的云端方法&lt;/strong&gt;了，还有新建模板时会自动把云服务初始化好。你要是只在预览页塞了个 &lt;code&gt;PrintTemplate&lt;/code&gt;，以前得自己手动 connect，现在省了。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-3&quot;&gt;2. &lt;code&gt;@sv-print/plugin-view-ai&lt;/code&gt; AI 辅助设计 🤖&lt;/h4&gt;
&lt;p&gt;用自然语言生成模板 JSON 和打印数据。听着很玄学，实际用起来还行。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;三种连接模式&lt;/strong&gt;，可以同时配好、运行时切：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;provider&lt;/strong&gt;：SDK 里塞 API Key，最省事，但 Key 会进前端包&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;proxy&lt;/strong&gt;：你后端代理一层，Key 留在服务端 —— 生产环境推荐&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;customService&lt;/strong&gt;：完全自定义，你自己接什么模型都行&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;还有&lt;strong&gt;流式响应&lt;/strong&gt;、&lt;strong&gt;图片输入&lt;/strong&gt;（&lt;code&gt;Ctrl+V&lt;/code&gt; 直接粘贴截图进去让它认）、&lt;strong&gt;AI 工具调用&lt;/strong&gt;、提示管理器。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/8528b87b46a24c8680e1b8220ec53fb8~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=zb30UKHX97mzmUIFJPt31PMOmkM%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-view-ai/dist/style.css&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginViewAi &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-view-ai&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [
  &lt;span class=&quot;hljs-title function_&quot;&gt;pluginViewAi&lt;/span&gt;({
    &lt;span class=&quot;hljs-comment&quot;&gt;// proxy 轻量代理模式: 服务端注入 API Key, 生产环境防泄露&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;proxy&lt;/span&gt;: {
      &lt;span class=&quot;hljs-attr&quot;&gt;proxyUrl&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;https://my-server.com/api/ai/proxy&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;targetBaseUrl&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;https://api.openai.com/v1&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;model&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;gpt-4o&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-comment&quot;&gt;// 从接口动态拉取模型列表 (优先级高于 models, 失败时回退)&lt;/span&gt;
      &lt;span class=&quot;hljs-attr&quot;&gt;modelsUrl&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;https://my-server.com/api/ai/models&quot;&lt;/span&gt;,
    },
    &lt;span class=&quot;hljs-attr&quot;&gt;showPromptManager&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 显示提示管理器&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;enableImageInput&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 支持 Ctrl+V 粘贴/上传/拖拽图片&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;defaultShow&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 默认不显示&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;icon&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;sv-ai&quot;&lt;/span&gt;,
  }),
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;🎯 实际业务场景：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;新产品要给客户演示打印效果，以前得设计师出一个模板。现在让业务对着 AI 说&quot;做一张带骑缝章的多页合同，页眉有公司名和 logo&quot;，出来的 JSON 直接进设计器微调一下就能用。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;✨ 10-18 新增了 &lt;code&gt;onApplied&lt;/code&gt;：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这个回调是&quot;应用&lt;strong&gt;成功之后&lt;/strong&gt;&quot;才触发，不是点一下确认就给你。以前你想在 AI 生成完之后同步点什么，只能在 &lt;code&gt;onApply&lt;/code&gt; 里赌一把；现在可以确定模板和打印数据都落地了再干活：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginViewAi &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-view-ai&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [
  &lt;span class=&quot;hljs-title function_&quot;&gt;pluginViewAi&lt;/span&gt;({
    &lt;span class=&quot;hljs-comment&quot;&gt;// onApply 用于应用前拦截; onApplied 在用户确认且应用成功后触发&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;onApplied&lt;/span&gt;: &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; ({ templateJson, printData }) =&amp;gt; {
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (printData != &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;) {
        designerUtils.&lt;span class=&quot;hljs-property&quot;&gt;dataSource&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;setData&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;default&quot;&lt;/span&gt;, printData);
      }
    },
  }),
];
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;说白了就是：&lt;strong&gt;AI 给你生成了打印数据，那字段树得跟着长出来对吧&lt;/strong&gt;。这一行就把它串上了。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-4&quot;&gt;3. &lt;code&gt;@sv-print/plugin-view-data-source&lt;/code&gt; 数据源插件 🗂️&lt;/h4&gt;
&lt;p&gt;多数据源字段树，拖拽或者点选就能把字段绑到元素上，打印的时候自动从数据源取数。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/b72e5f43f8e5438390940ccab9889201~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=eD%2FnRwBXjmZ4xPogtl5OtCdEiCo%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;数据源支持三种取数方式&lt;/strong&gt;，优先级从低到高：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;data&lt;/code&gt;：直接给原始打印数据，用 &lt;code&gt;parseData&lt;/code&gt; 解析成字段树&lt;/li&gt;
&lt;li&gt;&lt;code&gt;request&lt;/code&gt;：配个 url + &lt;code&gt;dataPath&lt;/code&gt;，点开懒加载&lt;/li&gt;
&lt;li&gt;&lt;code&gt;fetcher&lt;/code&gt;：自定义函数，想怎么取怎么取&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-view-data-source/dist/style.css&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; dataSourcePlugin, { parseData } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-view-data-source&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [
  &lt;span class=&quot;hljs-title function_&quot;&gt;dataSourcePlugin&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;sources&lt;/span&gt;: [
      {
        &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;default&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;title&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;默认数据源&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;data&lt;/span&gt;: &lt;span class=&quot;hljs-title function_&quot;&gt;parseData&lt;/span&gt;(printData), &lt;span class=&quot;hljs-comment&quot;&gt;// 解析打印数据为字段树&lt;/span&gt;
        &lt;span class=&quot;hljs-attr&quot;&gt;rawData&lt;/span&gt;: printData, &lt;span class=&quot;hljs-comment&quot;&gt;// 原始打印数据&lt;/span&gt;
      },
      {
        &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;user&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;title&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;用户数据&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;defaultExpand&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 默认展开&lt;/span&gt;
        &lt;span class=&quot;hljs-comment&quot;&gt;// 接口方式获取字段&lt;/span&gt;
        &lt;span class=&quot;hljs-attr&quot;&gt;request&lt;/span&gt;: { &lt;span class=&quot;hljs-attr&quot;&gt;url&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;/api/user/fields&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;dataPath&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;data.list&quot;&lt;/span&gt; },
      },
      {
        &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;order&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;title&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;订单数据&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;defaultExpand&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 默认折叠, 点击后懒加载&lt;/span&gt;
        &lt;span class=&quot;hljs-comment&quot;&gt;// 自定义函数获取字段 (优先级最高)&lt;/span&gt;
        &lt;span class=&quot;hljs-attr&quot;&gt;fetcher&lt;/span&gt;: &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; () =&amp;gt; {
          &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; res = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;fetch&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;/api/order/schema&quot;&lt;/span&gt;);
          &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; res.&lt;span class=&quot;hljs-title function_&quot;&gt;json&lt;/span&gt;()).&lt;span class=&quot;hljs-property&quot;&gt;fields&lt;/span&gt;;
        },
      },
    ],
    &lt;span class=&quot;hljs-attr&quot;&gt;pickerMultiple&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 多选模式&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;pickerCloseOnSelect&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 多选模式下选后不关闭弹窗&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;onFieldSet&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;field, element, optionName&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-comment&quot;&gt;// 返回 false 可以拦截这次绑定&lt;/span&gt;
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (field.&lt;span class=&quot;hljs-property&quot;&gt;type&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&quot;object&quot;&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
      &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; { type } = element.&lt;span class=&quot;hljs-property&quot;&gt;printElementType&lt;/span&gt;;
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (optionName === &lt;span class=&quot;hljs-string&quot;&gt;&quot;field&quot;&lt;/span&gt;) {
        &lt;span class=&quot;hljs-comment&quot;&gt;// 表格/子模板要数组, 列字段名不能是数组&lt;/span&gt;
        &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (
          [&lt;span class=&quot;hljs-string&quot;&gt;&quot;table&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;box&quot;&lt;/span&gt;].&lt;span class=&quot;hljs-title function_&quot;&gt;some&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;i&lt;/span&gt;) =&amp;gt;&lt;/span&gt; type.&lt;span class=&quot;hljs-title function_&quot;&gt;includes&lt;/span&gt;(i)) &amp;amp;&amp;amp;
          field.&lt;span class=&quot;hljs-property&quot;&gt;type&lt;/span&gt; !== &lt;span class=&quot;hljs-string&quot;&gt;&quot;array&quot;&lt;/span&gt;
        ) {
          &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
        }
      }
      &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;;
    },
  }),
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;✨ 10-18 新增：字段树右键能改能删了。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;之前字段树只能看，现在右键可以直接改字段名、显示名称、类型。改的时候嵌套字段的路径会&lt;strong&gt;同步更新&lt;/strong&gt;（不会出现改了父级子级路径还挂着老名字的情况），删的时候子字段一起带走。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;更关键的是运行时 API 终于来了：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; dataSource = designerUtils.&lt;span class=&quot;hljs-property&quot;&gt;dataSource&lt;/span&gt;;

&lt;span class=&quot;hljs-comment&quot;&gt;// 更新已有数据源配置, 保留未传配置, 清除缓存及手动字段覆盖&lt;/span&gt;
dataSource.&lt;span class=&quot;hljs-title function_&quot;&gt;updateSource&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;product&quot;&lt;/span&gt;, { &lt;span class=&quot;hljs-attr&quot;&gt;title&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;商品资料&quot;&lt;/span&gt; });
&lt;span class=&quot;hljs-comment&quot;&gt;// 替换原始数据并立即重建字段树, 清除旧取数配置及手动字段覆盖&lt;/span&gt;
dataSource.&lt;span class=&quot;hljs-title function_&quot;&gt;setData&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;product&quot;&lt;/span&gt;, { &lt;span class=&quot;hljs-attr&quot;&gt;sku&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;A&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;qty&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt; });
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; productData = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; dataSource.&lt;span class=&quot;hljs-title function_&quot;&gt;getRawData&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;product&quot;&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; allData = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; dataSource.&lt;span class=&quot;hljs-title function_&quot;&gt;getRawData&lt;/span&gt;(); &lt;span class=&quot;hljs-comment&quot;&gt;// 按打印规则合并所有数据源&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;这套 API 一出来，数据源就从&quot;设计时配一次&quot;变成&quot;运行时能改&quot;了。上面的 &lt;code&gt;onApplied&lt;/code&gt; 里的 &lt;code&gt;setData&lt;/code&gt; 就是这么用的。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-5&quot;&gt;4. 元素插件三连：骑缝章、PDF 元素、元素工具集 🖋️&lt;/h4&gt;
&lt;h5 data-id=&quot;heading-6&quot;&gt;骑缝章 &lt;code&gt;@sv-print/plugin-ele-seal&lt;/code&gt;&lt;/h5&gt;
&lt;p&gt;按实际渲染页数切分章图，&lt;strong&gt;逐页在纸边显示，拼合起来正好还原完整章图&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这个功能在纸质合同、凭证类打印里是真刚需&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginEleSeal &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-ele-seal&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [&lt;span class=&quot;hljs-title function_&quot;&gt;pluginEleSeal&lt;/span&gt;()];
&lt;span class=&quot;hljs-comment&quot;&gt;// 注册后左侧&quot;插件&quot;分组出现&quot;骑缝章&quot;元素, 拖入模板即可&lt;/span&gt;
&lt;span class=&quot;hljs-comment&quot;&gt;// 章图来源: field 打印数据字段 &amp;gt; src 直接配置的图片地址&lt;/span&gt;
&lt;span class=&quot;hljs-comment&quot;&gt;// pageRange 盖章页范围: &quot;all&quot; | &quot;first:3&quot; | &quot;1-3,5&quot; | &quot;odd&quot; | &quot;even&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/11ee6be0df7e4f8cba2bec229860bc55~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=R0GL1tYKO3b8fvjfnrzn1nn34Ac%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h5 data-id=&quot;heading-7&quot;&gt;PDF 元素 &lt;code&gt;@sv-print/plugin-ele-pdf&lt;/code&gt;&lt;/h5&gt;
&lt;p&gt;把 PDF 文件当元素塞进模板里渲染，支持 url / base64 / dataURL 三种来源，&lt;strong&gt;流式分页&lt;/strong&gt;、文字叠加层、页码范围。&lt;/p&gt;
&lt;p&gt;叠加层支持 &lt;code&gt;#{field}&lt;/code&gt; 和 &lt;code&gt;#{pdfMeta.xxx}&lt;/code&gt; 占位符 —— 意思是 PDF 页面上还能盖动态内容，比如在合同 PDF 上盖一个&quot;已审核&quot;水印章。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginElePdf &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-ele-pdf&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [&lt;span class=&quot;hljs-title function_&quot;&gt;pluginElePdf&lt;/span&gt;()];
&lt;span class=&quot;hljs-comment&quot;&gt;// 元素参数: pdfUrl 数据源 | pdfPageRange 页码范围如 &quot;1,3,5-8&quot;&lt;/span&gt;
&lt;span class=&quot;hljs-comment&quot;&gt;// pdfOverlays 叠加层支持 #{field} 与 #{pdfMeta.xxx} 占位符&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/2602891f2e2441feb38e7021db9c65dc~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=FHHSqeXqOViiCqPEXM%2BPJ50Y3Ak%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h5 data-id=&quot;heading-8&quot;&gt;元素工具集 &lt;code&gt;@sv-print/plugin-view-ele-tools&lt;/code&gt;&lt;/h5&gt;
&lt;p&gt;选中元素时元素上方浮一条工具栏，还能显示尺寸标签和微调按钮。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginViewEleTools, {
  getDefaultTools,
} &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-view-ele-tools&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [
  &lt;span class=&quot;hljs-title function_&quot;&gt;pluginViewEleTools&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;showSizeBox&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 元素底部显示尺寸标签 + 微调按钮&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;sizeBoxUnit&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;mm&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;sizeBoxStep&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;,
    &lt;span class=&quot;hljs-comment&quot;&gt;// 按元素类型自定义工具&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;resolveTools&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;ctx&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (ctx.&lt;span class=&quot;hljs-property&quot;&gt;elementType&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&quot;text&quot;&lt;/span&gt;) {
        &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; [
          ...&lt;span class=&quot;hljs-title function_&quot;&gt;getDefaultTools&lt;/span&gt;(),
          {
            &lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;my-extra&quot;&lt;/span&gt;,
            &lt;span class=&quot;hljs-attr&quot;&gt;title&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;自定义&quot;&lt;/span&gt;,
            &lt;span class=&quot;hljs-attr&quot;&gt;icon&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;svicon sv-font-big&quot;&lt;/span&gt;,
            &lt;span class=&quot;hljs-attr&quot;&gt;click&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;ctx&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
              ctx.&lt;span class=&quot;hljs-property&quot;&gt;selectEls&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;forEach&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;el&lt;/span&gt;) =&amp;gt;&lt;/span&gt;
                el.&lt;span class=&quot;hljs-title function_&quot;&gt;updateOption&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;fontSize&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;20&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;)
              );
              ctx.&lt;span class=&quot;hljs-title function_&quot;&gt;refreshPageStructure&lt;/span&gt;();
            },
          },
        ];
      }
      &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;; &lt;span class=&quot;hljs-comment&quot;&gt;// 使用内置默认&lt;/span&gt;
    },
  }),
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/9161bc6ab43442b49574cbe590ea91a6~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=sN4khmBKZJK9CRuYK5o6lM%2BYjHs%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;讲真，&lt;strong&gt;尺寸标签&lt;/strong&gt;这个我太喜欢了。手动调宽高全靠眼睛，标签一挂上毫米数，改起来心里有底。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-9&quot;&gt;5. 导出三连：矢量 PDF、Word、Excel 增强 📦&lt;/h4&gt;
&lt;h5 data-id=&quot;heading-10&quot;&gt;矢量 PDF &lt;code&gt;@sv-print/plugin-api-pdf-vector&lt;/code&gt;&lt;/h5&gt;
&lt;p&gt;之前导出的 PDF 是&quot;图片 PDF&quot;，放大就糊、文字选不中。这个插件导的是&lt;strong&gt;矢量&lt;/strong&gt;的：&lt;strong&gt;文字可选中、可搜索、可复制&lt;/strong&gt;，矢量图形，字体子集嵌入。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginApiPdfVector &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-api-pdf-vector&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-comment&quot;&gt;// 需一并安装 peerDependency: @imggion/html2realpdf&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [&lt;span class=&quot;hljs-title function_&quot;&gt;pluginApiPdfVector&lt;/span&gt;({})];

&lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; printData = { &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;张三&quot;&lt;/span&gt; };
&lt;span class=&quot;hljs-comment&quot;&gt;// 1. 直接下载&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; hiprintTemplate.&lt;span class=&quot;hljs-title function_&quot;&gt;toVectorPdf&lt;/span&gt;(printData, { &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;pdf名称&quot;&lt;/span&gt; });
&lt;span class=&quot;hljs-comment&quot;&gt;// 2. 获取 blob 自行处理&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; res = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; hiprintTemplate.&lt;span class=&quot;hljs-title function_&quot;&gt;toVectorPdf&lt;/span&gt;(printData, {
  &lt;span class=&quot;hljs-attr&quot;&gt;isDownload&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;type&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;blob&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// blob、arraybuffer、bloburl、datauristring&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;onProgress&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;cur, total, phase&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
    &lt;span class=&quot;hljs-comment&quot;&gt;// phase: &quot;snapshot&quot; | &quot;wasm&quot; | &quot;complete&quot;&lt;/span&gt;
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;进度&quot;&lt;/span&gt;, phase, cur, total);
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/72dac28082a84f9fbc431e7aa567db67~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=SwVglgCcgNCBXl%2B%2Bvv1sTxyIQts%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ 注意 peerDependency 那个 &lt;code&gt;@imggion/html2realpdf&lt;/code&gt;，漏装直接跑不起来。别问我怎么知道的 🙃&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h5 data-id=&quot;heading-11&quot;&gt;Word 导出 &lt;code&gt;@sv-print/plugin-api-word&lt;/code&gt;&lt;/h5&gt;
&lt;p&gt;导出&lt;strong&gt;可编辑的 docx&lt;/strong&gt;。两种模式：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;editable&lt;/code&gt;（默认）：网格表格还原，文本/表格/图片在 Word 里&lt;strong&gt;原生可编辑&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;positioned&lt;/code&gt;：浮动定位，所见即所得还原设计位置&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginApiWord &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-api-word&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [&lt;span class=&quot;hljs-title function_&quot;&gt;pluginApiWord&lt;/span&gt;()];

&lt;span class=&quot;hljs-comment&quot;&gt;// editable(默认): 网格表格还原, 文本/表格/图片在 Word 中原生可编辑&lt;/span&gt;
template.&lt;span class=&quot;hljs-title function_&quot;&gt;toWord&lt;/span&gt;(printData, { &lt;span class=&quot;hljs-attr&quot;&gt;mode&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;editable&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;出库单&quot;&lt;/span&gt; });
&lt;span class=&quot;hljs-comment&quot;&gt;// positioned: 浮动定位, 所见即所得还原设计位置&lt;/span&gt;
template.&lt;span class=&quot;hljs-title function_&quot;&gt;toWord&lt;/span&gt;(printData, { &lt;span class=&quot;hljs-attr&quot;&gt;mode&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;positioned&quot;&lt;/span&gt; });
&lt;span class=&quot;hljs-comment&quot;&gt;// 不自动下载, 拿到 Blob 自行上传&lt;/span&gt;
template.&lt;span class=&quot;hljs-title function_&quot;&gt;toWord&lt;/span&gt;(printData, { &lt;span class=&quot;hljs-attr&quot;&gt;isDownload&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt; }).&lt;span class=&quot;hljs-title function_&quot;&gt;then&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;blob&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; form = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;FormData&lt;/span&gt;();
  form.&lt;span class=&quot;hljs-title function_&quot;&gt;append&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;file&quot;&lt;/span&gt;, blob, &lt;span class=&quot;hljs-string&quot;&gt;&quot;report.docx&quot;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;fetch&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;/api/upload&quot;&lt;/span&gt;, { &lt;span class=&quot;hljs-attr&quot;&gt;method&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;POST&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;body&lt;/span&gt;: form });
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/01922b86ac6a4b1c85d23a121301486b~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=tdUfoJ6NP9eAQVpO%2F0WouhprMEM%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h5 data-id=&quot;heading-12&quot;&gt;Excel 导出增强 &lt;code&gt;@sv-print/plugin-api-excel&lt;/code&gt;&lt;/h5&gt;
&lt;p&gt;新增 &lt;code&gt;customTypeResolver&lt;/code&gt; / &lt;code&gt;onBeforeWrite&lt;/code&gt; / &lt;code&gt;multiSheet&lt;/code&gt; / &lt;code&gt;pageGap&lt;/code&gt;，文本重叠和换行效果也改了，还原度更高。&lt;/p&gt;
&lt;p&gt;多 sheet 这个参数比较顶 —— 之前一份报表导出一个 sheet，现在可以按业务拆多个。&lt;/p&gt;
&lt;h5 data-id=&quot;heading-13&quot;&gt;骑缝章、PDF、Word 这几个渲染器资源，客户端里也能在线下载更新&lt;/h5&gt;
&lt;p&gt;客户端 2.0.9 把 &lt;code&gt;pdfjs&lt;/code&gt;、&lt;code&gt;elePdf&lt;/code&gt;、&lt;code&gt;eleSeal&lt;/code&gt;、&lt;code&gt;textAuto&lt;/code&gt;、&lt;code&gt;formatter&lt;/code&gt;、&lt;code&gt;eleW2html&lt;/code&gt;、&lt;code&gt;eleBox&lt;/code&gt;、&lt;code&gt;eleStable&lt;/code&gt; 都做成在线渲染器资源了，升级不用等客户端打包。&lt;/p&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-14&quot;&gt;6. 新手引导 &lt;code&gt;@sv-print/plugin-driver&lt;/code&gt; 🧭&lt;/h4&gt;
&lt;p&gt;新用户进来一脸懵，不知道元素怎么拖、面板是什么、预览在哪。这个插件把整套引导步骤配好了。&lt;/p&gt;
&lt;p&gt;支持 &lt;code&gt;cacheKey&lt;/code&gt; 做版本缓存（引导文案改了才重新走一遍）、&lt;code&gt;onHighlightStarted&lt;/code&gt; / &lt;code&gt;onDeselected&lt;/code&gt; 自定义高亮前后的动作、元素可以传函数动态找、还能 &lt;code&gt;disableActiveInteraction&lt;/code&gt; 屏蔽高亮期间的交互。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-driver/dist/style.css&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginDriver &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-driver&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-comment&quot;&gt;// 重置滚动条位置：driver.js 执行时会居中显示。执行完后重置滚动条，避免样式错乱。&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;restScroll&lt;/span&gt; = (&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) =&amp;gt; {
  globalThis.$(&lt;span class=&quot;hljs-string&quot;&gt;`#paperBox`&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;scrollTop&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;);
  globalThis.$(&lt;span class=&quot;hljs-string&quot;&gt;`.svp-designer`&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;scrollTop&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;);
  globalThis.$(&lt;span class=&quot;hljs-string&quot;&gt;`#SVPrint`&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;scrollTop&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;);
};

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [
  &lt;span class=&quot;hljs-title function_&quot;&gt;pluginDriver&lt;/span&gt;({
    &lt;span class=&quot;hljs-comment&quot;&gt;// 缓存key，判断某个版本的 steps 是否需要执行&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;cacheKey&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;driver:DesignGuidance-v1&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;steps&lt;/span&gt;: [
      {
        &lt;span class=&quot;hljs-attr&quot;&gt;element&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;.svp-header&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;popover&lt;/span&gt;: {
          &lt;span class=&quot;hljs-attr&quot;&gt;title&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;这是菜单栏&quot;&lt;/span&gt;,
          &lt;span class=&quot;hljs-attr&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;支持插槽，也可以完全自定义哟！&quot;&lt;/span&gt;,
        },
      },
      {
        &lt;span class=&quot;hljs-attr&quot;&gt;element&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
          &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; previewBox = &lt;span class=&quot;hljs-variable language_&quot;&gt;document&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;querySelector&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;.previewBox&quot;&lt;/span&gt;);
          &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; previewBox.&lt;span class=&quot;hljs-title function_&quot;&gt;closest&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;.svp-modal-box&quot;&lt;/span&gt;);
        },
        &lt;span class=&quot;hljs-attr&quot;&gt;onHighlightStarted&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; designerUtils.&lt;span class=&quot;hljs-property&quot;&gt;preview&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;show&lt;/span&gt;(),
        &lt;span class=&quot;hljs-attr&quot;&gt;onDeselected&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; designerUtils.&lt;span class=&quot;hljs-property&quot;&gt;preview&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;hide&lt;/span&gt;(),
        &lt;span class=&quot;hljs-attr&quot;&gt;popover&lt;/span&gt;: {
          &lt;span class=&quot;hljs-attr&quot;&gt;title&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;这是预览弹窗&quot;&lt;/span&gt;,
          &lt;span class=&quot;hljs-attr&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;支持浏览器打印、静默打印、导出图片、导出pdf。&quot;&lt;/span&gt;,
        },
      },
      {
        &lt;span class=&quot;hljs-attr&quot;&gt;element&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;#dragBox-options&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;side&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;left&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;popover&lt;/span&gt;: {
          &lt;span class=&quot;hljs-attr&quot;&gt;title&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;属性区域&quot;&lt;/span&gt;,
          &lt;span class=&quot;hljs-attr&quot;&gt;description&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;点面板看面板属性，点元素看元素属性。&quot;&lt;/span&gt;,
        },
      },
    ],
    &lt;span class=&quot;hljs-attr&quot;&gt;autoStart&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 是否自动开启引导, 默认 true&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;driverOptions&lt;/span&gt;: {
      &lt;span class=&quot;hljs-attr&quot;&gt;showProgress&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;nextBtnText&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;下一步&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;prevBtnText&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;上一步&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;doneBtnText&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;我知道了&quot;&lt;/span&gt;,
    },
    &lt;span class=&quot;hljs-attr&quot;&gt;onComplete&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-title function_&quot;&gt;restScroll&lt;/span&gt;();
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;onboarded&quot;&lt;/span&gt;);
    },
    &lt;span class=&quot;hljs-attr&quot;&gt;onSkip&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-title function_&quot;&gt;restScroll&lt;/span&gt;();
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;onSkip&quot;&lt;/span&gt;);
    },
  }),
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/afb9066da41e432abb99ed7c60d85470~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=mX1JMoFERImFJ%2FkDTHuK%2FMiTn%2FU%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;上面那三条 steps 是我精简过的，实际内置了一整套（菜单栏、元素列表、画布、属性区、页面结构、历史记录、概览图、页眉页脚线……大概十七八步）。直接用现成的也行。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-15&quot;&gt;7. 参数修改 &lt;code&gt;@sv-print/plugin-options-modify&lt;/code&gt; 弹窗式下拉&lt;/h4&gt;
&lt;p&gt;之前这个插件是把下拉框换成别的控件，这版&lt;strong&gt;内置了弹窗式单选/多选下拉&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;选项特别多的时候，下拉框拉到天荒地老也找不完，现在直接弹窗，还能搜索、还能自定义添加、还能加校验。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginOptionsModify, {
  getSelectToMultiselectModifyHandler,
  &lt;span class=&quot;hljs-title class_&quot;&gt;PopupSelectValueGetHandler&lt;/span&gt;,
  &lt;span class=&quot;hljs-title class_&quot;&gt;PopupSelectValueSetHandler&lt;/span&gt;,
  &lt;span class=&quot;hljs-title class_&quot;&gt;PopupSelectDestroyHandler&lt;/span&gt;,
} &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-options-modify&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [
  &lt;span class=&quot;hljs-title function_&quot;&gt;pluginOptionsModify&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;list&lt;/span&gt;: [
      {
        &lt;span class=&quot;hljs-attr&quot;&gt;filter&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;key, target&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
          &lt;span class=&quot;hljs-comment&quot;&gt;// 忽略一些特殊参数&lt;/span&gt;
          &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; (
            ![&lt;span class=&quot;hljs-string&quot;&gt;&quot;watermarkOptions&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;panelLayoutOptions&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;dataType&quot;&lt;/span&gt;].&lt;span class=&quot;hljs-title function_&quot;&gt;includes&lt;/span&gt;(
              key
            ) &amp;amp;&amp;amp;
            target.&lt;span class=&quot;hljs-title function_&quot;&gt;hasClass&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;hiprint-option-item&quot;&lt;/span&gt;) &amp;amp;&amp;amp;
            target.&lt;span class=&quot;hljs-title function_&quot;&gt;find&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;.hiprint-option-item-field select&quot;&lt;/span&gt;).&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; &amp;gt; &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;
          );
        },
        &lt;span class=&quot;hljs-attr&quot;&gt;modifyHandler&lt;/span&gt;: &lt;span class=&quot;hljs-title function_&quot;&gt;getSelectToMultiselectModifyHandler&lt;/span&gt;({
          &lt;span class=&quot;hljs-attr&quot;&gt;mode&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;single&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// &quot;single&quot; | &quot;multiple&quot;&lt;/span&gt;
          &lt;span class=&quot;hljs-attr&quot;&gt;showSearch&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 是否显示搜索框&lt;/span&gt;
          &lt;span class=&quot;hljs-attr&quot;&gt;allowCustom&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 是否允许自定义添加&lt;/span&gt;
          &lt;span class=&quot;hljs-comment&quot;&gt;// 显示顶部栏：选项数量超过5个时显示&lt;/span&gt;
          &lt;span class=&quot;hljs-attr&quot;&gt;shouldShowTopbar&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;count, key&lt;/span&gt;) =&amp;gt;&lt;/span&gt; count &amp;gt; &lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;,
          &lt;span class=&quot;hljs-comment&quot;&gt;// 自定义添加前的校验：只接受数字&lt;/span&gt;
          &lt;span class=&quot;hljs-attr&quot;&gt;beforeAddCustom&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;v, key&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
            &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; ([&lt;span class=&quot;hljs-string&quot;&gt;&quot;fontSize&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;lineHeight&quot;&lt;/span&gt;].&lt;span class=&quot;hljs-title function_&quot;&gt;includes&lt;/span&gt;(key)) {
              &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; !&lt;span class=&quot;hljs-built_in&quot;&gt;isNaN&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;Number&lt;/span&gt;(v)) || (&lt;span class=&quot;hljs-title function_&quot;&gt;alert&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;请输入数字&quot;&lt;/span&gt;), &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;);
            }
            &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;;
          },
        }),
        &lt;span class=&quot;hljs-attr&quot;&gt;setValueHandler&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;PopupSelectValueSetHandler&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;getValueHandler&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;PopupSelectValueGetHandler&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;destroyHandler&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;PopupSelectDestroyHandler&lt;/span&gt;,
      },
    ],
  }),
];
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-16&quot;&gt;8. 国际化 &lt;code&gt;@sv-print/plugin-i18n&lt;/code&gt; 继续补 🔤&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;支持自定义参数选择器列表（&lt;code&gt;optionsSelectorList&lt;/code&gt;），你的自定义参数也能翻译&lt;/li&gt;
&lt;li&gt;元素 / 面板 / 设计器 三个层级都有回调：&lt;code&gt;onPanelSelected&lt;/code&gt; / &lt;code&gt;onElementSelected&lt;/code&gt; / &lt;code&gt;onDesigned&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;新增&lt;strong&gt;回调延迟处理&lt;/strong&gt;：&lt;code&gt;onDesignedDelay&lt;/code&gt; / &lt;code&gt;onSettingsDelay&lt;/code&gt; / &lt;code&gt;onPreviewDelay&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;设置弹窗、预览弹窗都接上了 hook（&lt;code&gt;onSettings&lt;/code&gt; / &lt;code&gt;onPreview&lt;/code&gt;）&lt;/li&gt;
&lt;li&gt;处理了特殊 DOM（带 &lt;code&gt;i&lt;/code&gt;/&lt;code&gt;span&lt;/code&gt; 子标签的 button 这种）&lt;/li&gt;
&lt;li&gt;&lt;code&gt;missingKeyHandler&lt;/code&gt; 能把缺的 key 收集起来，方便批量补翻译&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;延迟处理这个挺实用 —— 有些 UI 组件是异步渲染的，翻译跑早了等于没翻，&lt;code&gt;onDesignedDelay: 600&lt;/code&gt; 就能等它。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginI18n &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-i18n&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [
  &lt;span class=&quot;hljs-title function_&quot;&gt;pluginI18n&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;resources&lt;/span&gt;: {
      &lt;span class=&quot;hljs-comment&quot;&gt;/* ... */&lt;/span&gt;
    },
    &lt;span class=&quot;hljs-attr&quot;&gt;onDesignedDelay&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;600&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 设计回调延迟(部分 UI 调整后再翻译)&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;onSettingsDelay&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 设置回调延迟&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;onPreviewDelay&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 预览回调延迟&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;onSettings&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;opts&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-comment&quot;&gt;/* opts.type: &quot;show&quot; 等 */&lt;/span&gt;
    },
    &lt;span class=&quot;hljs-attr&quot;&gt;onPreview&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;element&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-comment&quot;&gt;/* ... */&lt;/span&gt;
    },
  }),
];
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-17&quot;&gt;9. 编辑器 &lt;code&gt;@sv-print/plugin-view-code-edit&lt;/code&gt; 📝&lt;/h4&gt;
&lt;p&gt;模板数据、打印数据、元素 json 都能用 CodeMirror 编辑了。缩进、换行、自定义补全、搜索面板全都能配，&lt;code&gt;onReady&lt;/code&gt; 里还能 &lt;code&gt;appendExtension&lt;/code&gt; 追加自己的扩展（监听文档变化、加快捷键），不覆盖原有配置。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-view-code-edit/dist/style.css&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginViewCodeEdit &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-view-code-edit&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [
  &lt;span class=&quot;hljs-title function_&quot;&gt;pluginViewCodeEdit&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;editorConfig&lt;/span&gt;: {
      &lt;span class=&quot;hljs-attr&quot;&gt;lineMaxLength&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;120&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;searchConfig&lt;/span&gt;: { &lt;span class=&quot;hljs-attr&quot;&gt;caseSensitive&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;regexp&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt; },
      &lt;span class=&quot;hljs-attr&quot;&gt;tabSize&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;lineWrapping&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;,
      &lt;span class=&quot;hljs-comment&quot;&gt;// 自定义补全项（会与内置 hinnn API 补全合并）&lt;/span&gt;
      &lt;span class=&quot;hljs-attr&quot;&gt;autocompletion&lt;/span&gt;: [
        { &lt;span class=&quot;hljs-attr&quot;&gt;label&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;myFunc&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;type&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;function&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;info&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;自定义函数&quot;&lt;/span&gt; },
      ],
      &lt;span class=&quot;hljs-attr&quot;&gt;onReady&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;{ view, state, appendExtension, EditorView }&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
        &lt;span class=&quot;hljs-comment&quot;&gt;// 1. 监听文档变化&lt;/span&gt;
        &lt;span class=&quot;hljs-title function_&quot;&gt;appendExtension&lt;/span&gt;(
          &lt;span class=&quot;hljs-title class_&quot;&gt;EditorView&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;updateListener&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;of&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;update&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
            &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (update.&lt;span class=&quot;hljs-property&quot;&gt;docChanged&lt;/span&gt;) {
              &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;内容变化:&quot;&lt;/span&gt;, update.&lt;span class=&quot;hljs-property&quot;&gt;state&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;doc&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;toString&lt;/span&gt;());
            }
          })
        );
        &lt;span class=&quot;hljs-comment&quot;&gt;// 2. 追加快捷键&lt;/span&gt;
        &lt;span class=&quot;hljs-title function_&quot;&gt;appendExtension&lt;/span&gt;(
          keymap.&lt;span class=&quot;hljs-title function_&quot;&gt;of&lt;/span&gt;([
            {
              &lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;Ctrl-s&quot;&lt;/span&gt;,
              &lt;span class=&quot;hljs-attr&quot;&gt;run&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
                &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;保存:&quot;&lt;/span&gt;, view.&lt;span class=&quot;hljs-property&quot;&gt;state&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;doc&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;toString&lt;/span&gt;());
                &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;;
              },
            },
          ])
        );
      },
    },
  }),
];
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/49816837d37648719bfbf895fd7b4a44~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=AfoR32rpowQuL8kWCL%2FfKw8CdcU%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-18&quot;&gt;10. 格式化器 &lt;code&gt;@sv-print/plugin-formatter&lt;/code&gt; 占位符匹配&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;${xxx}&lt;/code&gt; 现在可以直接执行对应 key 的格式化器了，而且能按&lt;strong&gt;正则匹配字段名&lt;/strong&gt;自动套格式化。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; pluginFormatter &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@sv-print/plugin-formatter&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; plugins = [
  &lt;span class=&quot;hljs-title function_&quot;&gt;pluginFormatter&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;格式化器&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;funList&lt;/span&gt;: [
      { &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;金额&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;money&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;fun&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;v&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;`¥ &lt;span class=&quot;hljs-subst&quot;&gt;${&lt;span class=&quot;hljs-built_in&quot;&gt;Number&lt;/span&gt;(v).toFixed(&lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;)}&lt;/span&gt;`&lt;/span&gt; },
      { &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;布尔&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;bool&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;fun&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;v&lt;/span&gt;) =&amp;gt;&lt;/span&gt; (v ? &lt;span class=&quot;hljs-string&quot;&gt;&quot;是&quot;&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&quot;否&quot;&lt;/span&gt;) },
      {
        &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;打印时间&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;datetime&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;fun&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;v&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Date&lt;/span&gt;().&lt;span class=&quot;hljs-title function_&quot;&gt;toLocaleString&lt;/span&gt;(),
      },
    ],
    &lt;span class=&quot;hljs-attr&quot;&gt;regexList&lt;/span&gt;: [
      &lt;span class=&quot;hljs-comment&quot;&gt;// 1. RegExp：对 key / fullKey 依次 test()，命中即匹配&lt;/span&gt;
      { &lt;span class=&quot;hljs-attr&quot;&gt;regex&lt;/span&gt;: &lt;span class=&quot;hljs-regexp&quot;&gt;/(^|\.)(amount|price|total)$/i&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;money&quot;&lt;/span&gt; },
      &lt;span class=&quot;hljs-comment&quot;&gt;// 2. 字符串：与 key 或 fullKey 严格相等&lt;/span&gt;
      { &lt;span class=&quot;hljs-attr&quot;&gt;regex&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;isActive&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;bool&quot;&lt;/span&gt; },
      &lt;span class=&quot;hljs-comment&quot;&gt;// 3. 函数：回调 (key, value, fullKey) =&amp;gt; boolean&lt;/span&gt;
      { &lt;span class=&quot;hljs-attr&quot;&gt;regex&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;key, value, fullKey&lt;/span&gt;) =&amp;gt;&lt;/span&gt; key === &lt;span class=&quot;hljs-string&quot;&gt;&quot;datetime&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;datetime&quot;&lt;/span&gt; },
    ],
  }),
];
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;这条最妙的是第一条正则：&lt;code&gt;amount&lt;/code&gt; / &lt;code&gt;price&lt;/code&gt; / &lt;code&gt;total&lt;/code&gt; 结尾的字段&lt;strong&gt;自动变金额格式&lt;/strong&gt;。后端给什么字段名都不用管了。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;h3 data-id=&quot;heading-19&quot;&gt;二、设计器本体：不太起眼但很顶的那些&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-20&quot;&gt;1. 设置弹窗 + &lt;code&gt;onSettings&lt;/code&gt; hook + header 外部更新&lt;/h4&gt;
&lt;p&gt;7-30 加了设置弹窗（主题、自定义主题、语言、帮助、使用引导、快捷键），10-10 加了 &lt;code&gt;onSettings&lt;/code&gt; hook 让插件能注入自己的 tab。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// 打开设置 // &quot;basic&quot; | &quot;language&quot; | &quot;help&quot; | &quot;shortcut&quot; | &quot;about&quot;&lt;/span&gt;
designerUtils.&lt;span class=&quot;hljs-title function_&quot;&gt;openSettings&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;basic&quot;&lt;/span&gt;);
designerUtils.&lt;span class=&quot;hljs-title function_&quot;&gt;closeSettings&lt;/span&gt;();
designerUtils.&lt;span class=&quot;hljs-title function_&quot;&gt;changeTheme&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;winter&quot;&lt;/span&gt;);
designerUtils.&lt;span class=&quot;hljs-title function_&quot;&gt;changeLanguage&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;繁体中文&quot;&lt;/span&gt;);

&lt;span class=&quot;hljs-comment&quot;&gt;// 插件: 在设置弹窗注入自定义 tab&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; myPlugin = {
  &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;my-plugin&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;hooks&lt;/span&gt;: [
    {
      &lt;span class=&quot;hljs-attr&quot;&gt;hook&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;onSettings&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;mySettingsTab&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;run&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;opts&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
        &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (opts.&lt;span class=&quot;hljs-property&quot;&gt;type&lt;/span&gt; !== &lt;span class=&quot;hljs-string&quot;&gt;&quot;tabs&quot;&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;; &lt;span class=&quot;hljs-comment&quot;&gt;// type: &quot;tabs&quot; | &quot;show&quot;&lt;/span&gt;
        &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; {
          &lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;my-tab&quot;&lt;/span&gt;,
          &lt;span class=&quot;hljs-attr&quot;&gt;label&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;我的设置&quot;&lt;/span&gt;,
          &lt;span class=&quot;hljs-attr&quot;&gt;icon&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;sv-tool&quot;&lt;/span&gt;,
          &lt;span class=&quot;hljs-attr&quot;&gt;sort&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 排序, 内置 tab 为 0~5&lt;/span&gt;
          &lt;span class=&quot;hljs-attr&quot;&gt;render&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;el&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
            el.&lt;span class=&quot;hljs-property&quot;&gt;innerHTML&lt;/span&gt; = &lt;span class=&quot;hljs-string&quot;&gt;&quot;&amp;lt;div&amp;gt;自定义设置内容&amp;lt;/div&amp;gt;&quot;&lt;/span&gt;;
          },
          &lt;span class=&quot;hljs-attr&quot;&gt;destroy&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;el&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {},
        };
      },
    },
  ],
  &lt;span class=&quot;hljs-attr&quot;&gt;leastHiprintVersion&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;0.3.42&quot;&lt;/span&gt;,
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;header 也能从外部改了&lt;/strong&gt; —— 元素列表和菜单列表都能动态增删，可以原地改也可以返回新数组自己排序：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;designerUtils.&lt;span class=&quot;hljs-property&quot;&gt;header&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;updateMenuList&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;list&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
  list.&lt;span class=&quot;hljs-title function_&quot;&gt;splice&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, {
    &lt;span class=&quot;hljs-attr&quot;&gt;icon&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;sv-templates&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;title&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;模板中心&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;click&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;util&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {},
  });
});
designerUtils.&lt;span class=&quot;hljs-property&quot;&gt;header&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;updateEleList&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;list&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
  &lt;span class=&quot;hljs-comment&quot;&gt;/* ... */&lt;/span&gt;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;设计器底部还多了&lt;strong&gt;客户端连接状态、云服务登录态、版本号（点击切换引擎版本）&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/8183f89cdc8b4eb0ba5f746a25365c91~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=RRfmnTxNIMkdxDGlZRX%2Fqdz4ETM%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-21&quot;&gt;2. &lt;code&gt;beforeGetHtml&lt;/code&gt; 这条 hook 线&lt;/h4&gt;
&lt;p&gt;7-30 加的，&lt;code&gt;getHtml&lt;/code&gt; / &lt;code&gt;print&lt;/code&gt; / &lt;code&gt;print2&lt;/code&gt; 都能用，9-28 补了 &lt;code&gt;data&lt;/code&gt; 参数和异步支持。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; printData = {};
&lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; hiprintTemplate.&lt;span class=&quot;hljs-title function_&quot;&gt;getHtml&lt;/span&gt;(printData, {
  &lt;span class=&quot;hljs-attr&quot;&gt;beforeGetHtml&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;{ template, html }&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
    &lt;span class=&quot;hljs-comment&quot;&gt;// 做一些处理, 支持 promise&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; html;
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同批还有 &lt;code&gt;beforePrint&lt;/code&gt; / &lt;code&gt;beforePrint2&lt;/code&gt; 也是 &lt;code&gt;data&lt;/code&gt; + 异步。&lt;strong&gt;能返回 Promise 这一点很重要&lt;/strong&gt; —— 我以前想在打印前拉一次接口（拿最新的价格、库存），只能自己绕，现在直接 await。&lt;/p&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-22&quot;&gt;3. 表格能力&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;表体行处理模式&lt;/strong&gt;（新）：缩小字体 / 不换行+省略 / 不换行，三选一。表格塞不下的时候终于不是&quot;硬撑到溢出&quot;了&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;右键菜单编辑表头&lt;/strong&gt;（新）：不用再改 columns 排序那一套&lt;/li&gt;
&lt;li&gt;表格分页逻辑优化：首页空行、表格头处理、合并删除行、标题双击下拉选源&lt;/li&gt;
&lt;li&gt;多选元素复制粘贴时按原有布局粘贴；旋转 ±90° 时客户端打印纸张的适配&lt;/li&gt;
&lt;li&gt;显示规则 / 隐藏规则支持多选了&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-23&quot;&gt;4. 交互细节&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;Alt + Wheel&lt;/code&gt; / &lt;code&gt;Alt + +/-&lt;/code&gt; 缩放画布&lt;/strong&gt;，快捷键说明同步更新了&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dragbox 支持 &lt;code&gt;cache&lt;/code&gt;&lt;/strong&gt; 缓存拖拽后的位置，面板尺寸变化标尺跟着更新&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;showModal&lt;/code&gt; 的 content 支持 html 字符串&lt;/strong&gt;了（以前传 html 会被当纯文本显示）&lt;/li&gt;
&lt;li&gt;预览组件支持缩略图和样式配置&lt;/li&gt;
&lt;li&gt;拖动方向支持键盘移动、框选移动，指定拖动方向时不处理吸附&lt;/li&gt;
&lt;li&gt;dragbox 调整宽高后会自动吸附贴边&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/ff34bb5a64614c649c482d87631e05a9~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=OP%2BMLTaizNNkDcy6NnazAc2QskI%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;hr&gt;
&lt;h4 data-id=&quot;heading-24&quot;&gt;5. CLI 支持 dev 环境&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;create-sv-print-plugin&lt;/code&gt; 这个 CLI 现在能创建 dev 环境了，写插件可以热调试，不用每次打包再装。&lt;/p&gt;
&lt;hr&gt;
&lt;h3 data-id=&quot;heading-25&quot;&gt;三、静默打印客户端 2.0.9&lt;/h3&gt;
&lt;p&gt;安装包：&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fpan.quark.cn%2Fs%2F428f36751e92&quot; target=&quot;_blank&quot; title=&quot;https://pan.quark.cn/s/428f36751e92&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;pan.quark.cn/s/428f36751…&lt;/a&gt;&lt;/p&gt;
&lt;h4 data-id=&quot;heading-26&quot;&gt;1. 纸张管理（这个我觉得该单独吹一下）📄&lt;/h4&gt;
&lt;p&gt;打印机详情里加了纸张管理面板：纸张列表、搜索、刷新、&lt;strong&gt;自定义纸张尺寸&lt;/strong&gt;。新增三个接口 —— &lt;code&gt;paperSizes&lt;/code&gt;、&lt;code&gt;validatePaper&lt;/code&gt;、&lt;code&gt;customPaper&lt;/code&gt;。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;⚠️ &lt;strong&gt;单位先说清楚&lt;/strong&gt;：三个接口的宽高参数传的都是&lt;strong&gt;微米&lt;/strong&gt;（μm），&lt;code&gt;1mm = 1000μm&lt;/code&gt;。所以 A4 是 &lt;code&gt;210000 × 297000&lt;/code&gt;，不是 &lt;code&gt;210 × 297&lt;/code&gt;。返回结果里两个都给：&lt;code&gt;widthMicrons&lt;/code&gt; / &lt;code&gt;heightMicrons&lt;/code&gt; 是微米，&lt;code&gt;widthMm&lt;/code&gt; / &lt;code&gt;heightMm&lt;/code&gt; 是毫米，前端直接拿后者显示就完事。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// http 服务方式&lt;/span&gt;
&lt;span class=&quot;hljs-comment&quot;&gt;// 纸张列表(单位: 微米, 同时返回 mm)&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; list = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;fetch&lt;/span&gt;(
  &lt;span class=&quot;hljs-string&quot;&gt;`http://127.0.0.1:7071/printer/getPaperSizes?printerName=&lt;span class=&quot;hljs-subst&quot;&gt;${&lt;span class=&quot;hljs-built_in&quot;&gt;encodeURIComponent&lt;/span&gt;(
    &lt;span class=&quot;hljs-string&quot;&gt;&quot;printerName&quot;&lt;/span&gt;
  )}&lt;/span&gt;`&lt;/span&gt;
).&lt;span class=&quot;hljs-title function_&quot;&gt;then&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;r&lt;/span&gt;) =&amp;gt;&lt;/span&gt; r.&lt;span class=&quot;hljs-title function_&quot;&gt;json&lt;/span&gt;());
&lt;span class=&quot;hljs-comment&quot;&gt;// =&amp;gt; { ok: true, printerId, papers: [{ name, widthMm, heightMm, widthMicrons, heightMicrons, paperCode? }] }&lt;/span&gt;

&lt;span class=&quot;hljs-comment&quot;&gt;// 校验自定义纸张尺寸是否受驱动支持&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; validate = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;fetch&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;http://127.0.0.1:7071/printer/validatePaper&quot;&lt;/span&gt;, {
  &lt;span class=&quot;hljs-attr&quot;&gt;method&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;POST&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;headers&lt;/span&gt;: { &lt;span class=&quot;hljs-string&quot;&gt;&quot;Content-Type&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;application/json&quot;&lt;/span&gt; },
  &lt;span class=&quot;hljs-attr&quot;&gt;body&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;stringify&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;printerName&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;printerName&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;widthMicrons&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;210000&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;heightMicrons&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;297000&lt;/span&gt;,
  }),
}).&lt;span class=&quot;hljs-title function_&quot;&gt;then&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;r&lt;/span&gt;) =&amp;gt;&lt;/span&gt; r.&lt;span class=&quot;hljs-title function_&quot;&gt;json&lt;/span&gt;());
&lt;span class=&quot;hljs-comment&quot;&gt;// =&amp;gt; { ok: true, supported: true, orientation, reason?, validatedWidthMicrons?, validatedHeightMicrons? }&lt;/span&gt;

&lt;span class=&quot;hljs-comment&quot;&gt;// 构造一个打印任务可用的自定义纸张描述并校验&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; custom = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;fetch&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;http://127.0.0.1:7071/printer/customPaper&quot;&lt;/span&gt;, {
  &lt;span class=&quot;hljs-attr&quot;&gt;method&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;POST&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;headers&lt;/span&gt;: { &lt;span class=&quot;hljs-string&quot;&gt;&quot;Content-Type&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;application/json&quot;&lt;/span&gt; },
  &lt;span class=&quot;hljs-attr&quot;&gt;body&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;stringify&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;printerName&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;printerName&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;widthMicrons&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;80000&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;heightMicrons&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;60000&lt;/span&gt;,
  }),
}).&lt;span class=&quot;hljs-title function_&quot;&gt;then&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;r&lt;/span&gt;) =&amp;gt;&lt;/span&gt; r.&lt;span class=&quot;hljs-title function_&quot;&gt;json&lt;/span&gt;());
&lt;span class=&quot;hljs-comment&quot;&gt;// =&amp;gt; { ok: true, supported, custom: true, driverWidthMicrons?, driverHeightMicrons?, reason? }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;💡 为什么这个值得吹？&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;自定义纸张这事儿的真相是：&lt;strong&gt;光告诉打印机&quot;我要用 80×60mm&quot;是没用的，得看驱动认不认&lt;/strong&gt;。所以 &lt;code&gt;validatePaper&lt;/code&gt; 会回你 &lt;code&gt;supported: true/false&lt;/code&gt; 和一个 &lt;code&gt;reason&lt;/code&gt;，&lt;code&gt;customPaper&lt;/code&gt; 还能返回驱动实际认下来的尺寸（&lt;code&gt;driverWidthMicrons&lt;/code&gt;）。这才是真正能落地的信息，比&quot;设置成功&quot;四个字有用一百倍。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/0d6297cb7ea74ff1bfb164a083be8b47~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=7auT0b34yEKsBGhrMOgRYV0ElwE%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// socket / mqtt 方式 (监听发送共用同一个事件名)&lt;/span&gt;
socket.&lt;span class=&quot;hljs-title function_&quot;&gt;emit&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;paperSizes&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;printerName&quot;&lt;/span&gt;);
socket.&lt;span class=&quot;hljs-title function_&quot;&gt;on&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;paperSizes&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;res&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(res.&lt;span class=&quot;hljs-property&quot;&gt;papers&lt;/span&gt;);
});

socket.&lt;span class=&quot;hljs-title function_&quot;&gt;emit&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;validatePaper&quot;&lt;/span&gt;, {
  &lt;span class=&quot;hljs-attr&quot;&gt;printerId&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;printerName&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;widthMicrons&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;210000&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;heightMicrons&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;297000&lt;/span&gt;,
});
socket.&lt;span class=&quot;hljs-title function_&quot;&gt;on&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;validatePaper&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;res&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(res.&lt;span class=&quot;hljs-property&quot;&gt;supported&lt;/span&gt;);
});

socket.&lt;span class=&quot;hljs-title function_&quot;&gt;emit&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;customPaper&quot;&lt;/span&gt;, {
  &lt;span class=&quot;hljs-attr&quot;&gt;printerId&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;printerName&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;widthMicrons&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;80000&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;heightMicrons&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;60000&lt;/span&gt;,
});
socket.&lt;span class=&quot;hljs-title function_&quot;&gt;on&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;customPaper&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;res&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(res);
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-27&quot;&gt;2. 调试能力&lt;/h4&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;openDevTools&lt;/code&gt;&lt;/strong&gt; 可以打开指定打印窗口 / 模板渲染窗口的开发者工具，预览窗口也加了&lt;strong&gt;调试&lt;/strong&gt;按钮。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// 打开打印窗口 / 模板渲染窗口 的开发者工具 (已打开则关闭)&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;fetch&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;http://127.0.0.1:7071/printer/openDevTools&quot;&lt;/span&gt;, {
  &lt;span class=&quot;hljs-attr&quot;&gt;method&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;POST&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;headers&lt;/span&gt;: { &lt;span class=&quot;hljs-string&quot;&gt;&quot;Content-Type&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;application/json&quot;&lt;/span&gt; },
  &lt;span class=&quot;hljs-attr&quot;&gt;body&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;stringify&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;printerName&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;printerName&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;isRenderer&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt; }),
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/8f8412c41fb74bb2ba71d53fbf35b1d7~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5LiN566A6K-0:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983710&amp;amp;x-signature=piIKIv21uKWDNDlYLwKpe34Q89k%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;排查渲染问题终于不用猜了，直接开 DevTools 看 DOM。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-28&quot;&gt;3. 打印日志能改能重打&lt;/h4&gt;
&lt;p&gt;打印日志加了&lt;strong&gt;刷新&lt;/strong&gt;按钮，数据用 &lt;strong&gt;CodeMirror 编辑器&lt;/strong&gt;查看，&lt;strong&gt;支持编辑数据后重打&lt;/strong&gt;；打印机详情的数据也改成编辑器查看。&lt;/p&gt;
&lt;p&gt;这个我给满分。以前打印效果不对，只能去日志里扒那一坨 JSON，改完还得手动重新发请求，现在改完直接重打。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-29&quot;&gt;4. &lt;code&gt;print&lt;/code&gt; 接口现在会等任务跑完&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// http 调用 print, 现在会等待队列任务完成并返回执行结果&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; result = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;fetch&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;http://127.0.0.1:7071/printer/print&quot;&lt;/span&gt;, {
  &lt;span class=&quot;hljs-attr&quot;&gt;method&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;POST&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;headers&lt;/span&gt;: { &lt;span class=&quot;hljs-string&quot;&gt;&quot;Content-Type&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;application/json&quot;&lt;/span&gt; },
  &lt;span class=&quot;hljs-attr&quot;&gt;body&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;stringify&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;printer&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;printerName&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;printType&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;html&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;html&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;&amp;lt;h1&amp;gt;sv-print&amp;lt;/h1&amp;gt;&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;copies&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;landscape&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;duplexMode&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;longEdge&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-comment&quot;&gt;// capture 打印可透传 snapdom 参数&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;captureType&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;jpeg&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;captureOptions&lt;/span&gt;: { &lt;span class=&quot;hljs-attr&quot;&gt;dpr&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;backgroundColor&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;#fff&quot;&lt;/span&gt; },
  }),
}).&lt;span class=&quot;hljs-title function_&quot;&gt;then&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;r&lt;/span&gt;) =&amp;gt;&lt;/span&gt; r.&lt;span class=&quot;hljs-title function_&quot;&gt;json&lt;/span&gt;());
&lt;span class=&quot;hljs-comment&quot;&gt;// =&amp;gt; { success: true, msg: &#39;&#39;, data: ... }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;以前是发出去就返回，我这边只能自己轮询日志。现在直接给执行结果，省事。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-30&quot;&gt;5. 其他&lt;/h4&gt;













































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;新增/优化&lt;/th&gt;&lt;th&gt;说明&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;云打印 SaaS&lt;/td&gt;&lt;td&gt;客户端侧适配&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;http 端口可配置&lt;/td&gt;&lt;td&gt;1024 ~ 65535，默认 7071&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;任务栏显示开关&lt;/td&gt;&lt;td&gt;配合下面这条一起用&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;关闭不退出应用&lt;/td&gt;&lt;td&gt;按 &lt;code&gt;showInTaskbar&lt;/code&gt; 决定最小化到任务栏还是藏托盘，协议唤醒时自动弹窗&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;渲染窗口开关&lt;/td&gt;&lt;td&gt;关掉模板渲染窗口能省内存 ⚠️ 但关了之后&lt;strong&gt;传模板 json 打印就不可用了&lt;/strong&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;capture 打印&lt;/td&gt;&lt;td&gt;支持透传 &lt;code&gt;captureOptions&lt;/code&gt;（snapdom 参数，默认 &lt;code&gt;dpr: 2&lt;/code&gt;）&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;插件资源升级&lt;/td&gt;&lt;td&gt;骑缝章、word、excel、组合框、表格、pdf、textAuto、formatter&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;渲染器资源在线更新&lt;/td&gt;&lt;td&gt;pdfjs、elePdf、eleSeal、textAuto、formatter、eleW2html、eleBox、eleStable&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;windows pdf 打印&lt;/td&gt;&lt;td&gt;pdf-to-printer 升到 5.8.1，支持 &lt;code&gt;paperKind&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h3 data-id=&quot;heading-31&quot;&gt;⚠️ 升级提醒&lt;/h3&gt;
&lt;p&gt;::: tip 温馨提示
本次更新请记得更新 print-lock.css 打印样式
:::&lt;/p&gt;
&lt;p&gt;还有一个要留神：&lt;strong&gt;如果为了省内存把&quot;开启模板渲染窗口&quot;关了，那&quot;传模板 json 打印&quot;就不可用了&lt;/strong&gt;。两个开关是有依赖关系的，别为了省那点内存把功能关了。&lt;/p&gt;
&lt;hr&gt;
&lt;h3 data-id=&quot;heading-32&quot;&gt;写到最后&lt;/h3&gt;
&lt;p&gt;三个月，6 次更新，20 多个包。写完这篇我脑子是木的 😂&lt;/p&gt;
&lt;p&gt;但有一说一，这波更新的方向我挺认的。以前 sv-print 是&quot;设计器 + 渲染器&quot;，这三个月补的是&lt;strong&gt;它外面的那一圈&lt;/strong&gt;：连云、连 AI、连数据源、连你后端的口子。这些都不是设计器本身该干的事，但恰恰是让它真能在生产落地的部分。&lt;/p&gt;
&lt;p&gt;我自己的心法就一条：&lt;strong&gt;新东西先看它有没有把&quot;我以前要自己造轮子的那个轮子&quot;给造了&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这三个月里对我最有用的三个：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;自定义纸张的 &lt;code&gt;supported&lt;/code&gt; + &lt;code&gt;reason&lt;/code&gt;&lt;/strong&gt; —— 不用再猜驱动认不认了&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;打印日志编辑后重打&lt;/strong&gt; —— 调试时间直接砍半&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;beforeGetHtml&lt;/code&gt; 支持 async&lt;/strong&gt; —— 打印前拉接口这件事终于不用绕路&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;至于 AI 设计那个，坦白说我现在还在观望阶段。让它生成&quot;标准快递面单&quot;挺好用，让它生成&quot;我们公司那个很特殊的、带骑缝章、四页、页眉页脚都要动态的合同&quot;——它给的 JSON 还是得我改。所以我的用法是：&lt;strong&gt;让它把 70% 的体力活干掉，剩下 30% 我自己收拾&lt;/strong&gt;。这个比例我觉得挺健康。&lt;/p&gt;
&lt;p&gt;AI 这东西吧，讲真，好用是好用，别当它是你同事就行 😂&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;整理时间：2026-10-19
覆盖版本：sv-print@0.2.23 ~ 0.2.26，@sv-print/hiprint@0.3.37 ~ 0.3.42，静默打印客户端@2.0.9
原始更新日志：&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fsv-print.ibujian.cn%2Fchangelog.html&quot; target=&quot;_blank&quot; title=&quot;https://sv-print.ibujian.cn/changelog.html&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;sv-print 更新日志&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;</description><link>https://juejin.cn/post/7697883708062547994</link><guid isPermaLink="false">https://juejin.cn/post/7697883708062547994</guid><pubDate>Mon, 19 Oct 2026 03:01:50 GMT</pubDate><author>不简说</author><category>前端</category><category>JavaScript</category><category>人工智能</category></item><item><title>AI 流式输出都用 SSE，什么时候必须换 WebSocket？</title><description>&lt;p&gt;&lt;img src=&quot;https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/ca1280dedfdf4382be2f57b8f738fd03~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgRXJwYW5PbWVy:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983330&amp;amp;x-signature=gPLGmHX4vExXV63CE2d5O84N83k%3D&quot; alt=&quot;AI 流式输出都用 SSE，什么时候必须换 WebSocket？&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;上个月，我们的 &lt;code&gt;AI&lt;/code&gt; 客服助手全量上线，第三天运维就找上门：问答接口的 &lt;code&gt;QPS&lt;/code&gt; 莫名翻了一倍，客服还收到一堆投诉，说回答&lt;strong&gt;半天不吐字&lt;/strong&gt;，用户以为卡死，点一次发一次，重试越点越多。&lt;/p&gt;
&lt;p&gt;查了一圈，根因不在大模型，而在传输层。&lt;code&gt;Nginx&lt;/code&gt; 默认把流式响应缓冲起来攒一波再吐，首字硬生生拖到 8 秒；前端用的还是最省事的裸 &lt;code&gt;EventSource&lt;/code&gt;，断了不报错、慢了不提示，用户只能靠狂点续命。重试流量反过来又压垮网关，恶性循环。&lt;/p&gt;
&lt;p&gt;这就是流式场景最真实的场景：模型没问题，管子先爆了。今天就把这根管子讲透，什么时候 &lt;code&gt;SSE&lt;/code&gt; 够用，什么时候必须换 &lt;code&gt;WebSocket&lt;/code&gt;，以及各自的坑怎么填。&lt;/p&gt;
&lt;hr&gt;
&lt;h3 data-id=&quot;heading-0&quot;&gt;裸 &lt;code&gt;EventSource&lt;/code&gt; 和 &lt;code&gt;WebSocket&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;出问题后，组里出现两种声音。一种说 &lt;code&gt;EventSource&lt;/code&gt; 一行代码就能跑，别折腾；另一种说干脆全量切 &lt;code&gt;WebSocket&lt;/code&gt;，一劳永逸。这两条路我都踩过，都有坑，只是结果不同。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-1&quot;&gt;裸 &lt;code&gt;EventSource&lt;/code&gt; 的坑在哪？&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/113d527185fc4bb6a0ddde2ecb720837~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgRXJwYW5PbWVy:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983330&amp;amp;x-signature=HiJ1tRnzALOj9kzor3Xlg6SH05g%3D&quot; alt=&quot;原生 EventSource 三大陷阱.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;EventSource&lt;/code&gt; 的 &lt;code&gt;API&lt;/code&gt; 是只读的：只支持 &lt;code&gt;GET&lt;/code&gt;，不支持自定义请求头。&lt;code&gt;token&lt;/code&gt; 只能拼在 &lt;code&gt;URL&lt;/code&gt; 上，网关日志、&lt;code&gt;CDN&lt;/code&gt; 日志、浏览器历史全是明文，安全评审直接打回。断线重连看似自动，实则裸奔：没有服务端配合回放 &lt;code&gt;Last-Event-ID&lt;/code&gt;，重连后要么丢消息，要么从头发完整流，用户会看到回答闪一下重打一遍。更隐蔽的是超时，&lt;code&gt;Nginx&lt;/code&gt; 默认 &lt;code&gt;proxy_buffering on&lt;/code&gt;，流式响应会被攒批，首字时间完全不可控。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-2&quot;&gt;&lt;code&gt;WebSocket&lt;/code&gt; 的坑呢？&lt;/h4&gt;
&lt;p&gt;&lt;img src=&quot;https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/4801aae633cf4e2d99a03ffe9e4afee0~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgRXJwYW5PbWVy:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983330&amp;amp;x-signature=1QWwEjmSJ8enD%2BffGB6jZuPLAvA%3D&quot; alt=&quot;WebSocket长连接与重连成本.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;WebSocket&lt;/code&gt; 是长连接，网关要为每个连接维持状态，连接数直接变成成本。我们的客服页是低频使用的页面，用户问完就关，为这种&lt;strong&gt;低频、单向、下行主导&lt;/strong&gt;的流量维持全双工连接，实例数要翻几倍。移动端更惨，切后台、锁屏、进电梯，连接一断就得整套重连、重鉴权、重订阅，心跳和退避策略全要自己写。&lt;/p&gt;
&lt;p&gt;结论很清晰了👉：&lt;strong&gt;下行主导、低频、低延迟要求不极致，用 &lt;code&gt;SSE&lt;/code&gt;；双向实时、高频上行、需要服务端主动推且客户端也要频繁说话，才上 &lt;code&gt;WebSocket&lt;/code&gt;&lt;/strong&gt;。比如多轮问答、摘要生成是 &lt;code&gt;SSE&lt;/code&gt; 的主场；协作编辑、语音对讲、实时游戏状态同步，才是 &lt;code&gt;WebSocket&lt;/code&gt; 的主场。&lt;/p&gt;
&lt;hr&gt;
&lt;h3 data-id=&quot;heading-3&quot;&gt;生产级的 &lt;code&gt;SSE&lt;/code&gt; 客户端长什么样&lt;/h3&gt;
&lt;p&gt;先看大多数人写的版本，十五分钟能跑，三个月后必返工：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-typescript&quot; lang=&quot;typescript&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// 这个能跑，但迟早返工&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;streamAnswer&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;url: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;, onToken: (t: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;) =&amp;gt; &lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt;&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; es = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;EventSource&lt;/span&gt;(url); &lt;span class=&quot;hljs-comment&quot;&gt;// token 只能拼 URL，GET 裸奔&lt;/span&gt;
  es.&lt;span class=&quot;hljs-property&quot;&gt;onmessage&lt;/span&gt; = &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;e&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;onToken&lt;/span&gt;(e.&lt;span class=&quot;hljs-property&quot;&gt;data&lt;/span&gt;);
  es.&lt;span class=&quot;hljs-property&quot;&gt;onerror&lt;/span&gt; = &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
    es.&lt;span class=&quot;hljs-title function_&quot;&gt;close&lt;/span&gt;(); &lt;span class=&quot;hljs-comment&quot;&gt;// 断了就关，没有重连，没有续接&lt;/span&gt;
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;生产级版本必须同时解决四件事：&lt;code&gt;POST + token&lt;/code&gt; 请求头鉴权、可取消、断线按 &lt;code&gt;Last-Event-ID&lt;/code&gt; 续接、退避重试。直接上代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-typescript&quot; lang=&quot;typescript&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// 真正的 fetch + ReadableStream 的生产级 SSE 客户端&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;SSEMessage&lt;/span&gt; {
  &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt; | &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;;
  &lt;span class=&quot;hljs-attr&quot;&gt;event&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;;
  &lt;span class=&quot;hljs-attr&quot;&gt;data&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;;
}

&lt;span class=&quot;hljs-keyword&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;StreamOptions&lt;/span&gt; {
  headers?: &lt;span class=&quot;hljs-title class_&quot;&gt;Record&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;, &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;&amp;gt;;
  lastEventId?: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt; | &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;;
  maxRetries?: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;;
  baseDelayMs?: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;;
  signal?: &lt;span class=&quot;hljs-title class_&quot;&gt;AbortSignal&lt;/span&gt;;
  onMessage?: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;msg: SSEMessage&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt;;
  onDone?: &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt;;
}

&lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;parseSSEBlock&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;block: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;&lt;/span&gt;): &lt;span class=&quot;hljs-title class_&quot;&gt;SSEMessage&lt;/span&gt; | &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt; {
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt; | &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt; = &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; event = &lt;span class=&quot;hljs-string&quot;&gt;&#39;message&#39;&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;dataLines&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;[] = [];
  &lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; line &lt;span class=&quot;hljs-keyword&quot;&gt;of&lt;/span&gt; block.&lt;span class=&quot;hljs-title function_&quot;&gt;split&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;\n&#39;&lt;/span&gt;)) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (line.&lt;span class=&quot;hljs-title function_&quot;&gt;startsWith&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;id:&#39;&lt;/span&gt;)) id = line.&lt;span class=&quot;hljs-title function_&quot;&gt;slice&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;trim&lt;/span&gt;();
    &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (line.&lt;span class=&quot;hljs-title function_&quot;&gt;startsWith&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;event:&#39;&lt;/span&gt;)) event = line.&lt;span class=&quot;hljs-title function_&quot;&gt;slice&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;6&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;trim&lt;/span&gt;();
    &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (line.&lt;span class=&quot;hljs-title function_&quot;&gt;startsWith&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;data:&#39;&lt;/span&gt;)) dataLines.&lt;span class=&quot;hljs-title function_&quot;&gt;push&lt;/span&gt;(line.&lt;span class=&quot;hljs-title function_&quot;&gt;slice&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;trimStart&lt;/span&gt;());
    &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (line.&lt;span class=&quot;hljs-title function_&quot;&gt;startsWith&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;:&#39;&lt;/span&gt;)) &lt;span class=&quot;hljs-keyword&quot;&gt;continue&lt;/span&gt;; &lt;span class=&quot;hljs-comment&quot;&gt;// 服务端心跳注释行&lt;/span&gt;
  }
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (dataLines.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; === &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt; &amp;amp;&amp;amp; id === &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { id, event, &lt;span class=&quot;hljs-attr&quot;&gt;data&lt;/span&gt;: dataLines.&lt;span class=&quot;hljs-title function_&quot;&gt;join&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;\n&#39;&lt;/span&gt;) };
}

&lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;sleep&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;ms: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;, signal?: AbortSignal&lt;/span&gt;): &lt;span class=&quot;hljs-title class_&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt;&amp;gt; {
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Promise&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;resolve, reject&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; timer = &lt;span class=&quot;hljs-built_in&quot;&gt;setTimeout&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
      signal?.&lt;span class=&quot;hljs-title function_&quot;&gt;removeEventListener&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;abort&#39;&lt;/span&gt;, onAbort);
      &lt;span class=&quot;hljs-title function_&quot;&gt;resolve&lt;/span&gt;();
    }, ms);
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;onAbort&lt;/span&gt; = (&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) =&amp;gt; {
      &lt;span class=&quot;hljs-built_in&quot;&gt;clearTimeout&lt;/span&gt;(timer);
      &lt;span class=&quot;hljs-title function_&quot;&gt;reject&lt;/span&gt;(&lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;DOMException&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;aborted&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;AbortError&#39;&lt;/span&gt;));
    };
    signal?.&lt;span class=&quot;hljs-title function_&quot;&gt;addEventListener&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;abort&#39;&lt;/span&gt;, onAbort, { &lt;span class=&quot;hljs-attr&quot;&gt;once&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt; });
  });
}

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;streamSSE&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;url: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;, body: &lt;span class=&quot;hljs-built_in&quot;&gt;unknown&lt;/span&gt;, options: StreamOptions = {}&lt;/span&gt;): &lt;span class=&quot;hljs-title class_&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt;&amp;gt; {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; { headers = {}, maxRetries = &lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;, baseDelayMs = &lt;span class=&quot;hljs-number&quot;&gt;1000&lt;/span&gt;, signal, onMessage, onDone } = options;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; lastEventId = options.&lt;span class=&quot;hljs-property&quot;&gt;lastEventId&lt;/span&gt; ?? &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; attempt = &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;;

  &lt;span class=&quot;hljs-keyword&quot;&gt;while&lt;/span&gt; (&lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;) {
    signal?.&lt;span class=&quot;hljs-title function_&quot;&gt;throwIfAborted&lt;/span&gt;();
    &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;res&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;Response&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
      res = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;fetch&lt;/span&gt;(url, {
        &lt;span class=&quot;hljs-attr&quot;&gt;method&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;POST&#39;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;headers&lt;/span&gt;: { &lt;span class=&quot;hljs-string&quot;&gt;&#39;Content-Type&#39;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;application/json&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-title class_&quot;&gt;Accept&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;text/event-stream&#39;&lt;/span&gt;, ...headers },
        &lt;span class=&quot;hljs-attr&quot;&gt;body&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;stringify&lt;/span&gt;({ ...(body &lt;span class=&quot;hljs-keyword&quot;&gt;as&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Record&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;, &lt;span class=&quot;hljs-built_in&quot;&gt;unknown&lt;/span&gt;&amp;gt;), ...(lastEventId ? { &lt;span class=&quot;hljs-attr&quot;&gt;resume_from&lt;/span&gt;: lastEventId } : {}) }),
        signal,
      });
    } &lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt; (err) {
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (err &lt;span class=&quot;hljs-keyword&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;DOMException&lt;/span&gt; &amp;amp;&amp;amp; err.&lt;span class=&quot;hljs-property&quot;&gt;name&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;AbortError&#39;&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;; &lt;span class=&quot;hljs-comment&quot;&gt;// 用户主动取消，不算失败&lt;/span&gt;
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (++attempt &amp;gt; maxRetries) &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; err;
      &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;sleep&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;Math&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;min&lt;/span&gt;(baseDelayMs * &lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt; ** attempt + &lt;span class=&quot;hljs-title class_&quot;&gt;Math&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;random&lt;/span&gt;() * &lt;span class=&quot;hljs-number&quot;&gt;500&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;30000&lt;/span&gt;), signal);
      &lt;span class=&quot;hljs-keyword&quot;&gt;continue&lt;/span&gt;;
    }

    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!res.&lt;span class=&quot;hljs-property&quot;&gt;ok&lt;/span&gt; || !res.&lt;span class=&quot;hljs-property&quot;&gt;body&lt;/span&gt;) {
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (++attempt &amp;gt; maxRetries) &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`stream failed: HTTP &lt;span class=&quot;hljs-subst&quot;&gt;${res.status}&lt;/span&gt;`&lt;/span&gt;);
      &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;sleep&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;Math&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;min&lt;/span&gt;(baseDelayMs * &lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt; ** attempt + &lt;span class=&quot;hljs-title class_&quot;&gt;Math&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;random&lt;/span&gt;() * &lt;span class=&quot;hljs-number&quot;&gt;500&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;30000&lt;/span&gt;), signal);
      &lt;span class=&quot;hljs-keyword&quot;&gt;continue&lt;/span&gt;;
    }

    attempt = &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;; &lt;span class=&quot;hljs-comment&quot;&gt;// 连上一次，清零计数&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; reader = res.&lt;span class=&quot;hljs-property&quot;&gt;body&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;getReader&lt;/span&gt;();
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; decoder = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;TextDecoder&lt;/span&gt;();
    &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; buffer = &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt;;

    &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
      &lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt; (;;) {
        &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; { done, value } = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; reader.&lt;span class=&quot;hljs-title function_&quot;&gt;read&lt;/span&gt;();
        &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (done) &lt;span class=&quot;hljs-keyword&quot;&gt;break&lt;/span&gt;;
        buffer += decoder.&lt;span class=&quot;hljs-title function_&quot;&gt;decode&lt;/span&gt;(value, { &lt;span class=&quot;hljs-attr&quot;&gt;stream&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt; });
        &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; blocks = buffer.&lt;span class=&quot;hljs-title function_&quot;&gt;split&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;\n\n&#39;&lt;/span&gt;);
        buffer = blocks.&lt;span class=&quot;hljs-title function_&quot;&gt;pop&lt;/span&gt;() ?? &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt;;
        &lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; block &lt;span class=&quot;hljs-keyword&quot;&gt;of&lt;/span&gt; blocks) {
          &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; msg = &lt;span class=&quot;hljs-title function_&quot;&gt;parseSSEBlock&lt;/span&gt;(block);
          &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!msg) &lt;span class=&quot;hljs-keyword&quot;&gt;continue&lt;/span&gt;;
          &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (msg.&lt;span class=&quot;hljs-property&quot;&gt;id&lt;/span&gt;) lastEventId = msg.&lt;span class=&quot;hljs-property&quot;&gt;id&lt;/span&gt;; &lt;span class=&quot;hljs-comment&quot;&gt;// 记住位点，断线从这里续&lt;/span&gt;
          &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (msg.&lt;span class=&quot;hljs-property&quot;&gt;event&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;done&#39;&lt;/span&gt;) {
            onDone?.();
            &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; reader.&lt;span class=&quot;hljs-title function_&quot;&gt;cancel&lt;/span&gt;().&lt;span class=&quot;hljs-title function_&quot;&gt;catch&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;undefined&lt;/span&gt;);
            &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
          }
          onMessage?.(msg);
        }
      }
      onDone?.();
      &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;; &lt;span class=&quot;hljs-comment&quot;&gt;// 服务端正常关流，结束&lt;/span&gt;
    } &lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt; (err) {
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (err &lt;span class=&quot;hljs-keyword&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;DOMException&lt;/span&gt; &amp;amp;&amp;amp; err.&lt;span class=&quot;hljs-property&quot;&gt;name&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;AbortError&#39;&lt;/span&gt;) {
        &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; reader.&lt;span class=&quot;hljs-title function_&quot;&gt;cancel&lt;/span&gt;().&lt;span class=&quot;hljs-title function_&quot;&gt;catch&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;undefined&lt;/span&gt;);
        &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
      }
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (++attempt &amp;gt; maxRetries) &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; err; &lt;span class=&quot;hljs-comment&quot;&gt;// 读一半断了，带位点重试&lt;/span&gt;
      &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;sleep&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;Math&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;min&lt;/span&gt;(baseDelayMs * &lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt; ** attempt + &lt;span class=&quot;hljs-title class_&quot;&gt;Math&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;random&lt;/span&gt;() * &lt;span class=&quot;hljs-number&quot;&gt;500&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;30000&lt;/span&gt;), signal);
    } &lt;span class=&quot;hljs-keyword&quot;&gt;finally&lt;/span&gt; {
      reader.&lt;span class=&quot;hljs-title function_&quot;&gt;releaseLock&lt;/span&gt;();
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;用法上配合 &lt;code&gt;AbortController&lt;/code&gt; 做取消，组件卸载或用户点停止时直接 &lt;code&gt;abort&lt;/code&gt;，不再有幽灵请求：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-typescript&quot; lang=&quot;typescript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; controller = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;AbortController&lt;/span&gt;();

&lt;span class=&quot;hljs-title function_&quot;&gt;streamSSE&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;/api/chat/stream&#39;&lt;/span&gt;, { messages }, {
  &lt;span class=&quot;hljs-attr&quot;&gt;headers&lt;/span&gt;: { &lt;span class=&quot;hljs-title class_&quot;&gt;Authorization&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;`Bearer &lt;span class=&quot;hljs-subst&quot;&gt;${token}&lt;/span&gt;`&lt;/span&gt; }, &lt;span class=&quot;hljs-comment&quot;&gt;// 头部鉴权，不拼 URL&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;signal&lt;/span&gt;: controller.&lt;span class=&quot;hljs-property&quot;&gt;signal&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;onMessage&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;msg&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;appendToken&lt;/span&gt;(msg.&lt;span class=&quot;hljs-property&quot;&gt;data&lt;/span&gt;),
  &lt;span class=&quot;hljs-attr&quot;&gt;onDone&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;setLoading&lt;/span&gt;(&lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;),
}).&lt;span class=&quot;hljs-title function_&quot;&gt;catch&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;err&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (err &lt;span class=&quot;hljs-keyword&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;DOMException&lt;/span&gt; &amp;amp;&amp;amp; err.&lt;span class=&quot;hljs-property&quot;&gt;name&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;AbortError&#39;&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
  &lt;span class=&quot;hljs-title function_&quot;&gt;toast&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;网络抖动，已自动重试仍失败，请再试一次&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-title function_&quot;&gt;report&lt;/span&gt;(err); &lt;span class=&quot;hljs-comment&quot;&gt;// 上报监控，带 lastEventId 便于复盘&lt;/span&gt;
});

&lt;span class=&quot;hljs-comment&quot;&gt;// 用户点停止 / 组件卸载&lt;/span&gt;
controller.&lt;span class=&quot;hljs-title function_&quot;&gt;abort&lt;/span&gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h3 data-id=&quot;heading-4&quot;&gt;讲一讲遇到的一些坑&lt;/h3&gt;
&lt;p&gt;客户端写结实了，线上照样可能翻车。下面 👇 四个全是生产里真金白银换来的，每个都给兜底。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-5&quot;&gt;&lt;code&gt;Nginx&lt;/code&gt; 吞流，首字永远慢&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;Nginx&lt;/code&gt; 默认 &lt;code&gt;proxy_buffering on&lt;/code&gt;，会把上游的流攒成块再吐，流式直接退化成轮询。兜底要在 &lt;code&gt;location&lt;/code&gt; 里显式关掉，并让上游声明不缓冲：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-nginx&quot; lang=&quot;nginx&quot;&gt;location /api/chat/stream {
  proxy_pass http://upstream;
  proxy_buffering off;          # 关键：禁止攒批
  proxy_cache off;
  proxy_read_timeout 300s;      # 流式需要长超时
  proxy_http_version 1.1;
  chunked_transfer_encoding on;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;服务端再补一个响应头 &lt;code&gt;X-Accel-Buffering: no&lt;/code&gt;，双保险。验收标准只有一个：&lt;strong&gt;首字时间&lt;/strong&gt;进监控，超过 3 秒直接告警，别等用户投诉🤷‍♂️。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/e8a1f47536f7424e8073fb67de7b7f02~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgRXJwYW5PbWVy:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983330&amp;amp;x-signature=x0zKR1B1dJ37o0pSGjIE21qNJCI%3D&quot; alt=&quot;连接、去重与保活图解.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h4 data-id=&quot;heading-6&quot;&gt;浏览器同域连接上限，&lt;code&gt;SSE&lt;/code&gt; 会断开其他请求&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;HTTP/1.1&lt;/code&gt; 下浏览器对同一域名只有 6 个并发连接，&lt;code&gt;SSE&lt;/code&gt; 一旦连上就长期占一个。用户开三个问答标签页，再加载页面资源就开始排队，页面转圈的锅最后全算在前端头上。兜底策略：全局限制同页 &lt;code&gt;SSE&lt;/code&gt; 并发数为 1，后开的流先 &lt;code&gt;abort&lt;/code&gt; 前一个；切 &lt;code&gt;HTTP/2&lt;/code&gt; 则多路复用天然缓解，但网关的 &lt;code&gt;stream&lt;/code&gt; 并发上限同样要核对，别从浏览器瓶颈换成网关瓶颈👋。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-7&quot;&gt;断线重连导致消息重复&lt;/h4&gt;
&lt;p&gt;网络抖动是常态，重连后服务端从 &lt;code&gt;Last-Event-ID&lt;/code&gt; 重放，客户端如果直接追加，用户会看到半截回答突然重来一遍。正确做法是&lt;strong&gt;按 &lt;code&gt;ID&lt;/code&gt; 去重 + 按位点替换&lt;/strong&gt;：维护已收 &lt;code&gt;ID&lt;/code&gt; 集合，重放的消息只用于补位不重复渲染；服务端必须保证 &lt;code&gt;ID&lt;/code&gt; 单调递增，这是前后端要对齐的契约，不是前端单方面能解决的🫡。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-8&quot;&gt;网关空闲超时切断 和 移动端切后台直接失联&lt;/h4&gt;
&lt;p&gt;多数网关默认 60 秒无数据就掐连接，大模型思考超过 60 秒不吐字，连接就被静默掐掉。兜底是服务端每 15 秒发一条注释心跳（&lt;code&gt;:\n\n&lt;/code&gt;），既保活又不污染业务流；客户端加一层兜底计时器，30 秒无任何字节就主动重连。同时统计要洗干净：重连次数、首字时间、完整率单独上报，别把重连算进接口成功率里，否则监控一片祥和，线上全是断流。&lt;/p&gt;
&lt;hr&gt;
&lt;h3 data-id=&quot;heading-9&quot;&gt;什么时候必须换 &lt;code&gt;WebSocket&lt;/code&gt;&lt;/h3&gt;
&lt;p&gt;最后给一张决策表，贴在组里能少吵很多架。出现下面任意一条，&lt;code&gt;SSE&lt;/code&gt; 不要硬撑，直接上 &lt;code&gt;WebSocket&lt;/code&gt;！&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/bc5fb593b7a4430cbc64d060daf570a3~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgRXJwYW5PbWVy:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983330&amp;amp;x-signature=hlIXfc1Ugu7yZYskfBb%2B1E1hQ6Q%3D&quot; alt=&quot;WebSocket 与 SSE + fetch 选型决策表.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;code&gt;WebSocket&lt;/code&gt; 也不是换过去就完事，心跳、重连退避、&lt;code&gt;token&lt;/code&gt; 过期重鉴权，一行都不能少：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-typescript&quot; lang=&quot;typescript&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// WebSocket 保活与重连骨架：心跳 + 指数退避，token 过期单独处理&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;interface&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;WSOptions&lt;/span&gt; {
  heartbeatMs?: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;;
  maxRetries?: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;;
  &lt;span class=&quot;hljs-attr&quot;&gt;getToken&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;&amp;gt;;
  &lt;span class=&quot;hljs-attr&quot;&gt;onMessage&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;data: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt;;
}

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;connectWS&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;url: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;, options: WSOptions&lt;/span&gt;): &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt; {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; { heartbeatMs = &lt;span class=&quot;hljs-number&quot;&gt;25000&lt;/span&gt;, maxRetries = &lt;span class=&quot;hljs-number&quot;&gt;8&lt;/span&gt;, getToken, onMessage } = options;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;ws&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;WebSocket&lt;/span&gt; | &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt; = &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; retries = &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;timer&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;ReturnType&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;setInterval&lt;/span&gt;&amp;gt; | &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt; = &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; closed = &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;open&lt;/span&gt; = &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; (&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) =&amp;gt; {
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; token = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;getToken&lt;/span&gt;();
    ws = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;WebSocket&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${url}&lt;/span&gt;?token=&lt;span class=&quot;hljs-subst&quot;&gt;${&lt;span class=&quot;hljs-built_in&quot;&gt;encodeURIComponent&lt;/span&gt;(token)}&lt;/span&gt;`&lt;/span&gt;);
    ws.&lt;span class=&quot;hljs-property&quot;&gt;onopen&lt;/span&gt; = &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; { retries = &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;; };
    ws.&lt;span class=&quot;hljs-property&quot;&gt;onmessage&lt;/span&gt; = &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;e&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; e.&lt;span class=&quot;hljs-property&quot;&gt;data&lt;/span&gt; !== &lt;span class=&quot;hljs-string&quot;&gt;&#39;string&#39;&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (e.&lt;span class=&quot;hljs-property&quot;&gt;data&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;pong&#39;&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;; &lt;span class=&quot;hljs-comment&quot;&gt;// 心跳回包，直接丢弃&lt;/span&gt;
      &lt;span class=&quot;hljs-title function_&quot;&gt;onMessage&lt;/span&gt;(e.&lt;span class=&quot;hljs-property&quot;&gt;data&lt;/span&gt;);
    };
    ws.&lt;span class=&quot;hljs-property&quot;&gt;onclose&lt;/span&gt; = &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (timer) &lt;span class=&quot;hljs-built_in&quot;&gt;clearInterval&lt;/span&gt;(timer);
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (closed || ++retries &amp;gt; maxRetries) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
      &lt;span class=&quot;hljs-built_in&quot;&gt;setTimeout&lt;/span&gt;(open, &lt;span class=&quot;hljs-title class_&quot;&gt;Math&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;min&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;1000&lt;/span&gt; * &lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt; ** retries, &lt;span class=&quot;hljs-number&quot;&gt;30000&lt;/span&gt;)); &lt;span class=&quot;hljs-comment&quot;&gt;// 退避重连&lt;/span&gt;
    };
  };

  timer = &lt;span class=&quot;hljs-built_in&quot;&gt;setInterval&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (ws?.&lt;span class=&quot;hljs-property&quot;&gt;readyState&lt;/span&gt; === &lt;span class=&quot;hljs-title class_&quot;&gt;WebSocket&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;OPEN&lt;/span&gt;) ws.&lt;span class=&quot;hljs-title function_&quot;&gt;send&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;ping&#39;&lt;/span&gt;);
  }, heartbeatMs);
  &lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;open&lt;/span&gt;();

  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; { closed = &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (timer) &lt;span class=&quot;hljs-built_in&quot;&gt;clearInterval&lt;/span&gt;(timer); ws?.&lt;span class=&quot;hljs-title function_&quot;&gt;close&lt;/span&gt;(); };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意 &lt;code&gt;token&lt;/code&gt; 过期时不要无脑重连，先刷新 &lt;code&gt;token&lt;/code&gt; 再建连，否则重连风暴里全是 &lt;code&gt;401&lt;/code&gt;，网关日志会被打爆。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;流式传输的技术选型，从来都是成本多少的问题。&lt;code&gt;SSE&lt;/code&gt; 便宜、简单、和 &lt;code&gt;HTTP&lt;/code&gt; 基础设施处得来，配上可取消、可续接的客户端，能覆盖九成 &lt;code&gt;AI&lt;/code&gt; 问答场景；&lt;code&gt;WebSocket&lt;/code&gt; 留给真正双向的场景。但是别让低频页面替长连接买单！&lt;/p&gt;
&lt;p&gt;好了，今天就分享到这里吧🙌&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;喜欢我的文章，也欢迎关注我的微信公众号：【前端技术官】。&lt;/p&gt;
&lt;p&gt;主要分享：&lt;strong&gt;前端架构 · AI 编程 · 职场认知 · 开发者成长&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/882fa1aaea724331aac4defa8c3c1ae7~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgRXJwYW5PbWVy:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792983330&amp;amp;x-signature=vwdWwr%2BpMURNqFRKZDso9arDRbE%3D&quot; alt=&quot;前端技术官-ErpanOmer&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;微信扫码关注&lt;/strong&gt; 👆&lt;/p&gt;
&lt;p&gt;不定期更新，不刷屏，聊点真正有用的干货。&lt;/p&gt;</description><link>https://juejin.cn/post/7697591781001691163</link><guid isPermaLink="false">https://juejin.cn/post/7697591781001691163</guid><pubDate>Mon, 19 Oct 2026 02:55:31 GMT</pubDate><author>ErpanOmer</author><category>前端</category><category>JavaScript</category><category>程序员</category></item><item><title>CPU就行, 老照片变清晰, 本地部署582mb腾讯的模型, 本地部署, 过节了给长辈们变个小魔术</title><description>&lt;p&gt;老照片糊成一团，一键变清晰。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/2a82f033e00d4ee890bb5629961d0f3c~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Y2K5Yi757u05bqm:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792980123&amp;amp;x-signature=AOykfOIRomd%2FgKcI0xaIFSRjRJ8%3D&quot; alt=&quot;图片&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;这次项目用 Python 写。&lt;/p&gt;
&lt;p&gt;但机器没要求。&lt;/p&gt;
&lt;p&gt;随便一台电脑就行，集成显卡都够用。&lt;/p&gt;
&lt;p&gt;不用买显卡，不用配 CUDA。&lt;/p&gt;
&lt;p&gt;照片也不出电脑，全本地推理。&lt;/p&gt;
&lt;p&gt;5 行核心代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;from&amp;nbsp;gfpgan&amp;nbsp;import&amp;nbsp;GFPGANer&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 导入修脸器&lt;/span&gt;
import&amp;nbsp;cv2&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 读图、写图&lt;/span&gt;

restorer = GFPGANer(
&amp;nbsp; &amp;nbsp; model_path=&lt;span class=&quot;hljs-string&quot;&gt;&quot;weights/GFPGANv1.4.pth&quot;&lt;/span&gt;,&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 主模型权重&lt;/span&gt;
&amp;nbsp; &amp;nbsp; upscale=2,&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 最终放大 2 倍&lt;/span&gt;
&amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-built_in&quot;&gt;arch&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;clean&quot;&lt;/span&gt;,&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# clean 版结构&lt;/span&gt;
&amp;nbsp; &amp;nbsp; channel_multiplier=2,&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 通道倍数，跟权重对应&lt;/span&gt;
)
_, _, out = restorer.enhance(
&amp;nbsp; &amp;nbsp; cv2.imread(&lt;span class=&quot;hljs-string&quot;&gt;&quot;old.jpg&quot;&lt;/span&gt;),&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 读入老照片&lt;/span&gt;
&amp;nbsp; &amp;nbsp; paste_back=True,&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 修完的脸贴回原图&lt;/span&gt;
&amp;nbsp; &amp;nbsp; weight=0.5,&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 保真度，越小修得越狠&lt;/span&gt;
)
cv2.imwrite(&lt;span class=&quot;hljs-string&quot;&gt;&quot;new.jpg&quot;&lt;/span&gt;, out)&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 结果存盘&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;就这些。&lt;/p&gt;
&lt;p&gt;剩下都是工程活。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/24ec32a684e84fa6b7b8ec0380bde1cd~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Y2K5Yi757u05bqm:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792980123&amp;amp;x-signature=Oz5IfwG7Ti13PZsUCBbb4Y68XDk%3D&quot; alt=&quot;图片&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h2 data-id=&quot;heading-0&quot;&gt;流程图&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;&amp;nbsp; &amp;nbsp; 上传老照片
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; │
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; ▼
&amp;nbsp; &amp;nbsp; Real-ESRGAN 4x 超分（背景）
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; │
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; ▼
&amp;nbsp; &amp;nbsp; facexlib 人脸检测对齐
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; │
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; ▼
&amp;nbsp; &amp;nbsp; GFPGAN v1.4 修脸
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; │
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; ▼
&amp;nbsp; &amp;nbsp; 按遮罩贴回原图
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; │
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; ▼
&amp;nbsp; &amp;nbsp; CPU 单张 8 秒，有显卡更快
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-id=&quot;heading-1&quot;&gt;1 痛点&lt;/h2&gt;
&lt;p&gt;爷爷奶奶那辈的照片，扫描出来脸糊成一团。&lt;/p&gt;
&lt;p&gt;调云端 API，一张图几分钱。&lt;/p&gt;
&lt;p&gt;全家几十张，账单吓人。&lt;/p&gt;
&lt;p&gt;更麻烦的是，图得传外网。&lt;/p&gt;
&lt;p&gt;家庭照片、客户资料，能往外发吗？&lt;/p&gt;
&lt;p&gt;这套组合，全本地推理。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-2&quot;&gt;2 两个模型怎么分工&lt;/h2&gt;
&lt;p&gt;|
模型
|
星数
|
干啥
|
| :-- | :-- | :-- |
|
GFPGAN
|
37.6k
|
修脸，专治糊脸
|
|
Real-ESRGAN
|
36.8k
|
超分，整图放大补像素
|&lt;/p&gt;
&lt;p&gt;都是腾讯 ARC 实验室出品，同一套生态。&lt;/p&gt;
&lt;p&gt;GFPGAN 靠 GAN + 人脸先验。&lt;/p&gt;
&lt;p&gt;它见过几百万张脸，知道正常人脸该长啥样。&lt;/p&gt;
&lt;p&gt;糊掉的五官，它敢按先验补回来。&lt;/p&gt;
&lt;p&gt;Real-ESRGAN 管背景，整图放大 4 倍，像素一点点补。&lt;/p&gt;
&lt;p&gt;两个一起用，脸和背景都清楚。&lt;/p&gt;
&lt;p&gt;demo 就是个网页：拖图进去，滑条对比，参数可调，日志实时。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-3&quot;&gt;3 有显卡自动加速&lt;/h2&gt;
&lt;p&gt;设备选择就几行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;def&amp;nbsp;pick_device():&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 挑一个运行设备&lt;/span&gt;
&amp;nbsp; &amp;nbsp; forced = os.getenv(&lt;span class=&quot;hljs-string&quot;&gt;&quot;DEVICE&quot;&lt;/span&gt;,&amp;nbsp;&lt;span class=&quot;hljs-string&quot;&gt;&quot;&quot;&lt;/span&gt;).strip().lower()&amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 读 DEVICE 环境变量&lt;/span&gt;
&amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt;&amp;nbsp;forced&amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;in&lt;/span&gt;&amp;nbsp;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;cuda&quot;&lt;/span&gt;,&amp;nbsp;&lt;span class=&quot;hljs-string&quot;&gt;&quot;cpu&quot;&lt;/span&gt;):&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 手动指定过就听它的&lt;/span&gt;
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-built_in&quot;&gt;return&lt;/span&gt;&amp;nbsp;forced
&amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-built_in&quot;&gt;return&lt;/span&gt;&amp;nbsp;&lt;span class=&quot;hljs-string&quot;&gt;&quot;cuda&quot;&lt;/span&gt;&amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt;&amp;nbsp;torch.cuda.is_available()&amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt;&amp;nbsp;&lt;span class=&quot;hljs-string&quot;&gt;&quot;cpu&quot;&lt;/span&gt;&amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 有显卡上显卡&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;装 CPU 版 torch 它就走 CPU。&lt;/p&gt;
&lt;p&gt;换 CUDA 版，代码一行不改。&lt;/p&gt;
&lt;p&gt;显存小也不怕，超分器按分块跑：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;tile =&amp;nbsp;512&amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt;&amp;nbsp;device ==&amp;nbsp;&lt;span class=&quot;hljs-string&quot;&gt;&quot;cuda&quot;&lt;/span&gt;&amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt;&amp;nbsp;192&amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 显卡切大块，CPU 切小块省内存&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;实测（CPU，413×617 含人脸, CPU: AMD5500）：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/27d6c11e70bf4a5a9715523373563ee7~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Y2K5Yi757u05bqm:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792980123&amp;amp;x-signature=Cowq4eqwPCKHuI2dEGMgDuCj1wU%3D&quot; alt=&quot;图片&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;所以 CPU 4秒出图。&lt;/p&gt;
&lt;p&gt;因为我还有个老古董1660S显卡, 开了CUDA试了下, 确实快了很多, 但是就1张照片的话无所谓了, 就快2s。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/24a73e9f29764f548141619026675d88~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Y2K5Yi757u05bqm:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792980123&amp;amp;x-signature=l%2FVp7HF9cizHYJKA%2FA9DCD0JWvg%3D&quot; alt=&quot;图片&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h2 data-id=&quot;heading-4&quot;&gt;4 装环境不用踩坑&lt;/h2&gt;
&lt;p&gt;三个坑，脚本全填平了。&lt;/p&gt;
&lt;p&gt;一是 basicsr 源码安装时要 import torch。&lt;/p&gt;
&lt;p&gt;先装 torch，再装模型库，顺序对了就能过。&lt;/p&gt;
&lt;p&gt;二是 gfpgan 硬依赖 tb-nightly，国内源没有，用 --no-deps 绕过。&lt;/p&gt;
&lt;p&gt;那包只有训练才用，推理一行不碰。&lt;/p&gt;
&lt;p&gt;三是新版 torchvision 删了 functional_tensor，造个替身塞进去：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;def&amp;nbsp;patch_torchvision():
&amp;nbsp; &amp;nbsp; import&amp;nbsp;sys, types&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 改模块表 + 造新模块&lt;/span&gt;
&amp;nbsp; &amp;nbsp; import&amp;nbsp;torchvision.transforms.functional&amp;nbsp;as&amp;nbsp;F&amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 新版功能都在这里&lt;/span&gt;
&amp;nbsp; &amp;nbsp; shim = types.ModuleType(&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 造一个空壳当替身&lt;/span&gt;
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-string&quot;&gt;&quot;torchvision.transforms.functional_tensor&quot;&lt;/span&gt;)
&amp;nbsp; &amp;nbsp; shim.__dict__.update(&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 把函数全搬进替身&lt;/span&gt;
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; {k: v&amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt;&amp;nbsp;k, v&amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;in&lt;/span&gt;&amp;nbsp;F.__dict__.items()&amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt;&amp;nbsp;not&amp;nbsp;k.startswith(&lt;span class=&quot;hljs-string&quot;&gt;&quot;__&quot;&lt;/span&gt;)})
&amp;nbsp; &amp;nbsp; sys.modules[&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 注册进模块表&lt;/span&gt;
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-string&quot;&gt;&quot;torchvision.transforms.functional_tensor&quot;&lt;/span&gt;] = shim
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;import gfpgan 之前跑一次就行。&lt;/p&gt;
&lt;p&gt;依赖全用 uv，比 pip 快一个数量级，源走清华：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;uv venv&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 建虚拟环境&lt;/span&gt;
uv pip install&amp;nbsp;-r&amp;nbsp;requirements.txt&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 先装 torch 等基础依赖&lt;/span&gt;
uv pip install&amp;nbsp;--no-deps&amp;nbsp;basicsr facexlib realesrgan gfpgan&amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 后装模型库，绕开 tb-nightly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-id=&quot;heading-5&quot;&gt;5 权重下载不断线&lt;/h2&gt;
&lt;p&gt;四个文件，约 580MB。&lt;/p&gt;
&lt;p&gt;GitHub 直连常被限速到几十 KB/s。&lt;/p&gt;
&lt;p&gt;大文件走多线程分块下载：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;headers = {**UA,&amp;nbsp;&lt;span class=&quot;hljs-string&quot;&gt;&quot;Range&quot;&lt;/span&gt;:&amp;nbsp;f&lt;span class=&quot;hljs-string&quot;&gt;&quot;bytes={start}-{end}&quot;&lt;/span&gt;}&amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 每个线程只下自己那段&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;镜像挂了自动回直连，重试 20 次。&lt;/p&gt;
&lt;p&gt;还有个暗坑：官方 parsing_parsenet 的 v0.1.0 链接是 404，真地址在 v0.2.2。&lt;/p&gt;
&lt;p&gt;脚本还会把检测、解析这两个权重复制进 facexlib 包目录。&lt;/p&gt;
&lt;p&gt;不复制的话，运行时它自己去 GitHub 下，基本必卡。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-6&quot;&gt;6 接口简单，模型带缓存&lt;/h2&gt;
&lt;p&gt;FastAPI 接收图片、调修脸器、返回 base64：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;@app.post(&lt;span class=&quot;hljs-string&quot;&gt;&quot;/api/restore&quot;&lt;/span&gt;) &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 前端把图传上来&lt;/span&gt;
async&amp;nbsp;def&amp;nbsp;api_restore(file: UploadFile = File(...),
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; upscale:&amp;nbsp;int&amp;nbsp;= Form(2), weight:&amp;nbsp;&lt;span class=&quot;hljs-built_in&quot;&gt;float&lt;/span&gt;&amp;nbsp;= Form(0.5)):
&amp;nbsp; &amp;nbsp; img = cv2.imdecode(&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 字节流转图片矩阵&lt;/span&gt;
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; np.frombuffer(await&amp;nbsp;file.read(), np.uint8), cv2.IMREAD_COLOR)
&amp;nbsp; &amp;nbsp; _, _, out = get_restorer(upscale, &lt;span class=&quot;hljs-built_in&quot;&gt;bg&lt;/span&gt;).enhance(&amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 取缓存的修脸器&lt;/span&gt;
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; img, paste_back=True, weight=weight)
&amp;nbsp; &amp;nbsp; ok, buf = cv2.imencode(&lt;span class=&quot;hljs-string&quot;&gt;&quot;.png&quot;&lt;/span&gt;, out)&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 编成 PNG&lt;/span&gt;
&amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-built_in&quot;&gt;return&lt;/span&gt;&amp;nbsp;{&lt;span class=&quot;hljs-string&quot;&gt;&quot;image&quot;&lt;/span&gt;:&amp;nbsp;&lt;span class=&quot;hljs-string&quot;&gt;&quot;data:image/png;base64=&quot;&lt;/span&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 拼 data URI 给前端&lt;/span&gt;
&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; + base64.b64encode(buf.tobytes()).decode()}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;get_restorer 按 (device, upscale, bg) 缓存。&lt;/p&gt;
&lt;p&gt;参数不变就复用，不重复加载模型。&lt;/p&gt;
&lt;p&gt;没检测到人脸也不返回空，自动退化成整图超分。&lt;/p&gt;
&lt;p&gt;前端日志直接打：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;完成 · 8123ms · 检测到 1 张脸 · cpu
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;端口被占了自动找下一个空位。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/938832ed6f384e3492095d60266d0d89~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5Y2K5Yi757u05bqm:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792980123&amp;amp;x-signature=Zh5%2FHEQ0MGi%2Bw7AdMqCHGQbfDoU%3D&quot; alt=&quot;图片&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;分辨率高的执行时间也长, 但是效果真的好&lt;/p&gt;
&lt;h2 data-id=&quot;heading-7&quot;&gt;7 跑起来&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;&lt;span class=&quot;hljs-built_in&quot;&gt;cd&lt;/span&gt;&amp;nbsp;demo/24
powershell&amp;nbsp;-ExecutionPolicy&amp;nbsp;Bypass&amp;nbsp;-File&amp;nbsp;setup.ps1&amp;nbsp;-Run&amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 装完直接起服务&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;老照片修复服务 · http://localhost:3024
&amp;nbsp; 设备: cpu · 权重: D:\www\ai-coder\demo\24\weights
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;强制设备也简单：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;&lt;span class=&quot;hljs-variable&quot;&gt;$env&lt;/span&gt;:DEVICE=&lt;span class=&quot;hljs-string&quot;&gt;&quot;cpu&quot;&lt;/span&gt;; &amp;nbsp;.\.venv\Scripts\python.exe server.py&amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 锁死 CPU&lt;/span&gt;
&lt;span class=&quot;hljs-variable&quot;&gt;$env&lt;/span&gt;:DEVICE=&lt;span class=&quot;hljs-string&quot;&gt;&quot;cuda&quot;&lt;/span&gt;; .\.venv\Scripts\python.exe server.py&amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-comment&quot;&gt;# 有显卡走显卡&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-id=&quot;heading-8&quot;&gt;总结&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;•&amp;nbsp;&lt;strong&gt;GFPGAN&lt;/strong&gt;：37.6k 星，GAN + 人脸先验，专治糊脸&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;•&amp;nbsp;&lt;strong&gt;Real-ESRGAN&lt;/strong&gt;：36.8k 星，整图超分，管背景&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;•&amp;nbsp;&lt;strong&gt;本地推理&lt;/strong&gt;：照片不离开电脑，CPU 只修脸 4 秒&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;•&amp;nbsp;&lt;strong&gt;一键脚本&lt;/strong&gt;：三个装环境坑全填平，权重断了接着下&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;一张老照片，八秒钟的事。&lt;/p&gt;
&lt;p&gt;不用买显卡，不用配 CUDA。&lt;/p&gt;
&lt;p&gt;老照片发朋友圈，家人都会说，&lt;strong&gt;你小子有本事。&lt;/strong&gt;&lt;/p&gt;</description><link>https://juejin.cn/post/7697853177081020466</link><guid isPermaLink="false">https://juejin.cn/post/7697853177081020466</guid><pubDate>Mon, 19 Oct 2026 02:02:04 GMT</pubDate><author>半刻维度</author><category>后端</category><category>前端</category><category>JavaScript</category></item><item><title>栗子前端技术周刊第 150 期 - Stack Overflow 开发者调查结果、TanStack Start 1.0...</title><description>&lt;p&gt;🌰&lt;strong&gt;栗子前端技术周刊第 150 期 (2026.09.11 - 2026.10.18)&lt;/strong&gt;：浏览前端一周最新消息，学习国内外优秀文章，让我们保持对前端的好奇心。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-0&quot;&gt;📰 技术资讯&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fsurvey.stackoverflow.co%2F2026&quot; target=&quot;_blank&quot; title=&quot;https://survey.stackoverflow.co/2026&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;Stack Overflow 开发者调查结果&lt;/a&gt;&lt;/strong&gt;：2026 年 Stack Overflow 开发者调查结果已公布（共有 30903 人参与）：JavaScript 连续第 12 年位居编程语言榜首，TypeScript 排在第 6 位。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Ftanstack.com%2Fblog%2Ftanstack-start-1-0&quot; target=&quot;_blank&quot; title=&quot;https://tanstack.com/blog/tanstack-start-1-0&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;TanStack Start 1.0&lt;/a&gt;&lt;/strong&gt;：这款日渐流行、同时支持 React 和 Solid 的全栈框架迎来稳定版，为部署在 Node、Bun、Cloudflare Workers 等多平台的应用提供类型安全的服务端函数与流式 SSR。文中对比了它和 Next.js 的差异。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fblog.getbootstrap.com%2F2026%2F10%2F08%2Fbootstrap-6-alpha%2F&quot; target=&quot;_blank&quot; title=&quot;https://blog.getbootstrap.com/2026/10/08/bootstrap-6-alpha/&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;Bootstrap 6 Alpha&lt;/a&gt;&lt;/strong&gt;：一睹这款热门框架自 2021 年以来的首个大版本。本次更新主打现代化改造：大部分 Sass 变量已转为 CSS 变量，JS 插件仅支持 ESM 模块，并且更多依托浏览器原生能力实现。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fbun.com%2Fblog%2Fbun-v1.4.3&quot; target=&quot;_blank&quot; title=&quot;https://bun.com/blog/bun-v1.4.3&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;Bun 1.4.3&lt;/a&gt;&lt;/strong&gt;：Bun 1.4.3 新增内置 TypeScript 类型检查器 &lt;code&gt;bun check&lt;/code&gt;：&lt;code&gt;bun check&lt;/code&gt; 移植自 TypeScript 7 的类型检查器，会读取你的 &lt;code&gt;tsconfig.json&lt;/code&gt;，输出和 &lt;code&gt;tsc&lt;/code&gt; 一致的报错信息。Jarred 称它的运行速度快 3～6 倍，同时还能集成到 &lt;code&gt;bun run&lt;/code&gt;/&lt;code&gt;bun test&lt;/code&gt;/&lt;code&gt;bun build&lt;/code&gt; 命令中。空闲服务器进程现在 CPU 占用大幅降低，并且本次升级了 JavaScriptCore，带来多项性能提升。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-id=&quot;heading-1&quot;&gt;📒 技术文章&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fcalibreapp.com%2Fblog%2Fnative-lazy-loading&quot; target=&quot;_blank&quot; title=&quot;https://calibreapp.com/blog/native-lazy-loading&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;Lazy Loading Images and Media: How Far Ahead Browsers Fetch&lt;/a&gt;&lt;/strong&gt;：图片与媒体懒加载：浏览器会提前多久预加载资源 - Ben 测试了 Chrome、Firefox、Safari 对懒加载媒体资源的预加载超前距离。他还介绍了哪些资源可以懒加载（包括 Chromium 系浏览器中的视频与音频），并讲解了懒加载、立即加载以及 &lt;code&gt;fetchpriority&lt;/code&gt; 该如何搭配使用。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://juejin.cn/post/7697087660390547466&quot; target=&quot;_blank&quot; title=&quot;https://juejin.cn/post/7697087660390547466&quot;&gt;浏览器为什么加载不了超大图片&lt;/a&gt;&lt;/strong&gt;：一张 87 MB 的 JPEG 在 Network 面板中返回 200 OK，但页面上却只显示“图片加载失败”。问题究竟出在网络、组件，还是浏览器？&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;&lt;a href=&quot;https://juejin.cn/post/7695079160284201003&quot; target=&quot;_blank&quot; title=&quot;https://juejin.cn/post/7695079160284201003&quot;&gt;把 649 种文件格式塞进一个网页里：一个「文件不出浏览器」的预览器&lt;/a&gt;&lt;/strong&gt;：一个跑在浏览器里的文件预览器：649 个扩展名——Office、PDF、OFD、CAD、三维、科研体数据、医学影像、 压缩包、邮件、电子书、字体、流媒体、旧式音视频……全部在本机解析。 没有上传接口，没有服务端转码，产物是纯静态文件，丢到任何静态托管就能用。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-id=&quot;heading-2&quot;&gt;🔧 开发工具&lt;/h2&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fikrishg.github.io%2Freseter.css%2F&quot; target=&quot;_blank&quot; title=&quot;https://ikrishg.github.io/reseter.css/&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;reseter.css 3.0&lt;/a&gt;&lt;/strong&gt;：这款热门 CSS 重置库迎来 v3 重大重构。完整版（full）和精简版（mini）现在都封装在 &lt;code&gt;@layer reset&lt;/code&gt; 层中，让样式覆盖行为可控；全部采用逻辑属性，基于 Lightning CSS 构建，压缩后体积仅约 1KB。&lt;/li&gt;
&lt;/ol&gt;
&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/c9a2e011c2ba4ff08eeb860a6873f198~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5pmT5b6X6L-36Lev5LqG:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792974824&amp;amp;x-signature=1Uxta%2B%2FKThlxTs3%2BgthoaR2CsJs%3D&quot; width=&quot;100%&quot; alt=&quot;image-20261018135249617&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;
&lt;ol start=&quot;2&quot;&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fh3.dev%2Fblog%2Fv2&quot; target=&quot;_blank&quot; title=&quot;https://h3.dev/blog/v2&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;H3 v2&lt;/a&gt;&lt;/strong&gt;：基于 Web 标准重构的 UnJS HTTP 框架 —— 作为 Nitro 和 Nuxt 服务层底层的轻量 HTTP 框架，v2 版本现已稳定。本次重写围绕标准的 &lt;code&gt;Request&lt;/code&gt; / &lt;code&gt;Response&lt;/code&gt; 对象构建，同一个应用可在 Node、Bun、Deno、Workers，甚至浏览器中运行。&lt;/li&gt;
&lt;/ol&gt;
&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/909a38ef43694bd6816b621d5b5ea8d7~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5pmT5b6X6L-36Lev5LqG:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792974824&amp;amp;x-signature=JLdHLC6U3PUZ2HIxVCjxPM6dzoY%3D&quot; width=&quot;100%&quot; alt=&quot;image-20261018135917595&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;
&lt;ol start=&quot;3&quot;&gt;
&lt;li&gt;&lt;strong&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fmathiasbynens%2Fhe&quot; target=&quot;_blank&quot; title=&quot;https://github.com/mathiasbynens/he&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;he 2.0&lt;/a&gt;&lt;/strong&gt;：可靠的 HTML 实体编解码器。时隔八年迎来新版本，现已改为 ESM 模块并支持命名导出。&lt;/li&gt;
&lt;/ol&gt;
&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/6dc0d7eb2d1f4237af1332c5352b3f12~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5pmT5b6X6L-36Lev5LqG:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792974824&amp;amp;x-signature=pCG4nKq%2Fno%2BVz67Dxz%2Bc5N6amJ8%3D&quot; width=&quot;100%&quot; alt=&quot;image-20261018140103641&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;
&lt;p&gt;🚀🚀🚀 &lt;em&gt;以上资讯文章选自常见周刊，如 JavaScript Weekly 等，周刊内容也会不断优化改进，希望你们能够喜欢。&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;💖 &lt;strong&gt;欢迎关注微信公众号：栗子前端&lt;/strong&gt;&lt;/p&gt;</description><link>https://juejin.cn/post/7697531208898445353</link><guid isPermaLink="false">https://juejin.cn/post/7697531208898445353</guid><pubDate>Mon, 19 Oct 2026 00:33:44 GMT</pubDate><author>晓得迷路了</author><category>前端</category><category>后端</category><category>JavaScript</category><category>CSS</category></item><item><title>从零构建 Mastra RAG MVP：DeepSeek、SiliconFlow 与 PostgreSQL/pgvector 实战</title><description>&lt;p&gt;最近我在尝试使用 &lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fmastra.ai%2F&quot; target=&quot;_blank&quot; title=&quot;https://mastra.ai/&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;Mastra&lt;/a&gt; 构建一个简单的 RAG（Retrieval-Augmented Generation，检索增强生成）应用。目标并不是一开始就做完整的知识库平台，而是先打通一条最小可用链路：&lt;strong&gt;把自己的 Markdown 技术文章导入向量数据库，然后用自然语言提问，让模型根据文章内容回答问题。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这次选用的技术组合是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mastra&lt;/strong&gt;：组织 Agent 和 Tool。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;DeepSeek&lt;/strong&gt;：负责理解问题、决定调用检索工具并生成答案。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SiliconFlow&lt;/strong&gt;：提供 &lt;code&gt;BAAI/bge-m3&lt;/code&gt; Embedding 模型，将文本转换为向量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL + pgvector&lt;/strong&gt;：保存并检索文档向量。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TypeScript&lt;/strong&gt;：实现导入脚本、检索服务和命令行问答。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这篇文章记录 MVP 的实现思路、关键代码，以及真正遇到的几次错误。代码按这次项目的实现方式整理；Mastra 和 AI SDK 的具体类型、方法签名可能随版本调整，复制时应以项目安装版本为准。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 data-id=&quot;heading-0&quot;&gt;一、先理解要实现的 RAG 链路&lt;/h2&gt;
&lt;p&gt;普通 LLM 并不知道我最近在 GitHub 上修改了什么，也不一定知道 Health Master 项目为什么从 MD5 迁移到 Argon2id。RAG 的思路是先检索外部知识，再将检索到的上下文提供给模型。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;Markdown 技术文章
       │
       ▼
   MDocument
       │
       ▼
  Chunking（文本切分）
       │
       ▼
SiliconFlow / BAAI/bge-m3
       │
       ▼
Embedding（1024 维）
       │
       ▼
PostgreSQL / pgvector
       │
       ├──────────────────────────────┐
       │                              │
       ▼                              │
 用户自然语言提问                      │
       │                              │
       ▼                              │
  Mastra Agent（DeepSeek）            │
       │                              │
       ▼                              │
 knowledgeSearchTool                  │
       │                              │
       ▼                              │
 问题 Embedding → 相似度检索 ───────────┘
       │
       ▼
 返回相关 Chunk + source
       │
       ▼
 DeepSeek 组织答案
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里有两个阶段：&lt;strong&gt;Ingestion（知识入库）&lt;/strong&gt; 和 &lt;strong&gt;Retrieval + Generation（检索与生成）&lt;/strong&gt;。Embedding 模型和生成模型可以来自不同服务商；两者不需要是同一个模型。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-1&quot;&gt;二、项目初始化&lt;/h2&gt;
&lt;p&gt;在一个 Node.js / TypeScript 项目中安装依赖：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;npm install @mastra/core @mastra/rag @mastra/pg @ai-sdk/openai-compatible ai dotenv
npm install -D typescript tsx
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;项目按职责拆分：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;mastra-rag-app/
├── src/
│   ├── mastra/
│   │   ├── agents/
│   │   │   └── knowledge.agent.ts
│   │   ├── lib/
│   │   │   ├── embedding.ts
│   │   │   └── vector-store.ts
│   │   ├── services/
│   │   │   ├── knowledge.service.ts
│   │   │   └── retrieval.service.ts
│   │   └── tools/
│   │       └── knowledge-search.tool.ts
│   └── scripts/
│       ├── init-vector-db.ts
│       ├── ingest.ts
│       ├── search.ts
│       └── chat.ts
├── .env
└── package.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;环境变量示例：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-dotenv&quot; lang=&quot;dotenv&quot;&gt;DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mastra_rag
SILICONFLOW_API_KEY=your-siliconflow-api-key
SILICONFLOW_BASE_URL=https://api.siliconflow.cn/v1
DEEPSEEK_API_KEY=your-deepseek-api-key
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要把真实密钥提交到 Git 仓库。建议将 &lt;code&gt;.env&lt;/code&gt; 加入 &lt;code&gt;.gitignore&lt;/code&gt;，另提供不含密钥的 &lt;code&gt;.env.example&lt;/code&gt;。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-2&quot;&gt;PostgreSQL 的准备工作&lt;/h3&gt;
&lt;p&gt;数据库需要安装 &lt;code&gt;pgvector&lt;/code&gt; 扩展。确保使用的是支持 pgvector 的 PostgreSQL 实例，然后在目标数据库执行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-sql&quot; lang=&quot;sql&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;CREATE&lt;/span&gt; EXTENSION IF &lt;span class=&quot;hljs-keyword&quot;&gt;NOT&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;EXISTS&lt;/span&gt; vector;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意：安装数据库扩展与创建 Mastra 使用的向量表是两件不同的事。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-3&quot;&gt;三、配置 SiliconFlow Embedding&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;src/mastra/lib/embedding.ts&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { createOpenAICompatible } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@ai-sdk/openai-compatible&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; apiKey = process.&lt;span class=&quot;hljs-property&quot;&gt;env&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;SILICONFLOW_API_KEY&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!apiKey) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;SILICONFLOW_API_KEY is missing&quot;&lt;/span&gt;);
}

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; siliconflow = &lt;span class=&quot;hljs-title function_&quot;&gt;createOpenAICompatible&lt;/span&gt;({
  &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;siliconflow&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;baseURL&lt;/span&gt;: process.&lt;span class=&quot;hljs-property&quot;&gt;env&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;SILICONFLOW_BASE_URL&lt;/span&gt; ?? &lt;span class=&quot;hljs-string&quot;&gt;&quot;https://api.siliconflow.cn/v1&quot;&lt;/span&gt;,
  apiKey,
});

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; embeddingModel = siliconflow.&lt;span class=&quot;hljs-title function_&quot;&gt;embeddingModel&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;BAAI/bge-m3&quot;&lt;/span&gt;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;BAAI/bge-m3&lt;/code&gt; 在这次调用中返回 &lt;strong&gt;1024 维向量&lt;/strong&gt;。这决定了后续创建 pgvector 索引时的 &lt;code&gt;dimension&lt;/code&gt; 参数。&lt;/p&gt;
&lt;p&gt;这里踩过两个坑。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-4&quot;&gt;坑一：Embedding 请求返回 404&lt;/h3&gt;
&lt;p&gt;一开始配置的 &lt;code&gt;baseURL&lt;/code&gt; 没有包含 &lt;code&gt;/v1&lt;/code&gt;，请求最终命中了错误的路径。改为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;https://api.siliconflow.cn/v1
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;之后才进入正确的 Embedding 接口。对于 OpenAI-compatible Provider，&lt;strong&gt;Base URL 是否包含版本前缀&lt;/strong&gt;必须核对服务商文档。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-5&quot;&gt;坑二：Embedding 请求返回 401&lt;/h3&gt;
&lt;p&gt;当时一度怀疑 &lt;code&gt;.env&lt;/code&gt; 没有加载、API Key 无效，或者 SDK 的请求格式有问题。于是分别用原生 &lt;code&gt;fetch&lt;/code&gt; 和 SDK 做最小化测试：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;dotenv/config&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { embed } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;ai&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { embeddingModel } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;../mastra/lib/embedding&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; { embedding } = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;embed&lt;/span&gt;({
  &lt;span class=&quot;hljs-attr&quot;&gt;model&lt;/span&gt;: embeddingModel,
  &lt;span class=&quot;hljs-attr&quot;&gt;value&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;测试 Embedding 是否正常&quot;&lt;/span&gt;,
});

&lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;Dimensions:&quot;&lt;/span&gt;, embedding.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原生 HTTP 请求返回 &lt;code&gt;200&lt;/code&gt;，SDK 测试也能输出 &lt;code&gt;Dimensions: 1024&lt;/code&gt;，说明服务本身和模型调用没有问题。最终发现实际项目中的配置把 &lt;strong&gt;&lt;code&gt;SILICONFLOW&lt;/code&gt; 拼成了 &lt;code&gt;SILOCONFLOW&lt;/code&gt;&lt;/strong&gt;，导致读取了错误的环境变量名。&lt;/p&gt;
&lt;p&gt;这个排错过程的经验是：&lt;strong&gt;先用最小请求证明第三方 API 可用，再逐层检查业务代码&lt;/strong&gt;，不要一开始就把所有问题归因于框架。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-6&quot;&gt;四、配置 PostgreSQL 向量存储&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;src/mastra/lib/vector-store.ts&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { &lt;span class=&quot;hljs-title class_&quot;&gt;PgVector&lt;/span&gt; } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@mastra/pg&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;INDEX_NAME&lt;/span&gt; = &lt;span class=&quot;hljs-string&quot;&gt;&quot;knowledge_base&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; vectorStore = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;PgVector&lt;/span&gt;({
  &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;knowledge-vectors&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;connectionString&lt;/span&gt;: process.&lt;span class=&quot;hljs-property&quot;&gt;env&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;DATABASE_URL&lt;/span&gt;!,
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;INDEX_NAME&lt;/code&gt; 是后续写入和查询时使用的名称，必须保持一致。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-7&quot;&gt;坑三：Vector table 不存在&lt;/h3&gt;
&lt;p&gt;Embedding 调用成功后，导入时遇到：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;MastraError: Vector table &quot;knowledge_base&quot; does not exist
id: MASTRA_VECTOR_PG_DESCRIBE_INDEX_FAILED
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原因很直接：&lt;strong&gt;配置 PgVector 实例并不会自动替我们创建 &lt;code&gt;knowledge_base&lt;/code&gt; 表&lt;/strong&gt;。需要先创建索引对应的存储结构。&lt;/p&gt;
&lt;p&gt;创建 &lt;code&gt;src/scripts/init-vector-db.ts&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;dotenv/config&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { vectorStore, &lt;span class=&quot;hljs-variable constant_&quot;&gt;INDEX_NAME&lt;/span&gt; } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;../mastra/lib/vector-store&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;main&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; vectorStore.&lt;span class=&quot;hljs-title function_&quot;&gt;createIndex&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;indexName&lt;/span&gt;: &lt;span class=&quot;hljs-variable constant_&quot;&gt;INDEX_NAME&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;dimension&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;1024&lt;/span&gt;,
  });

  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`Vector index &quot;&lt;span class=&quot;hljs-subst&quot;&gt;${INDEX_NAME}&lt;/span&gt;&quot; initialized`&lt;/span&gt;);
}

&lt;span class=&quot;hljs-title function_&quot;&gt;main&lt;/span&gt;().&lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;error&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;error&lt;/span&gt;(error);
  process.&lt;span class=&quot;hljs-property&quot;&gt;exitCode&lt;/span&gt; = &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;首次运行前先执行初始化脚本。若索引已经存在，是否允许重复执行取决于所安装的 Mastra 版本；不应假设 &lt;code&gt;createIndex()&lt;/code&gt; 一定是幂等的。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-8&quot;&gt;五、将 Markdown 切分并导入向量库&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;src/mastra/services/knowledge.service.ts&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { &lt;span class=&quot;hljs-title class_&quot;&gt;MDocument&lt;/span&gt; } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@mastra/rag&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { embedMany } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;ai&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { embeddingModel } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;../lib/embedding&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { vectorStore, &lt;span class=&quot;hljs-variable constant_&quot;&gt;INDEX_NAME&lt;/span&gt; } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;../lib/vector-store&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;ingest&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;text: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;, source: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable language_&quot;&gt;document&lt;/span&gt; = &lt;span class=&quot;hljs-title class_&quot;&gt;MDocument&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;fromText&lt;/span&gt;(text);

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; chunks = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-variable language_&quot;&gt;document&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;chunk&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;strategy&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;recursive&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;maxSize&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;512&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;overlap&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;50&lt;/span&gt;,
  });

  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (chunks.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; === &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { &lt;span class=&quot;hljs-attr&quot;&gt;count&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt; };
  }

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; { embeddings } = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;embedMany&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;model&lt;/span&gt;: embeddingModel,
    &lt;span class=&quot;hljs-attr&quot;&gt;values&lt;/span&gt;: chunks.&lt;span class=&quot;hljs-title function_&quot;&gt;map&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;chunk&lt;/span&gt;) =&amp;gt;&lt;/span&gt; chunk.&lt;span class=&quot;hljs-property&quot;&gt;text&lt;/span&gt;),
  });

  &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; vectorStore.&lt;span class=&quot;hljs-title function_&quot;&gt;upsert&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;indexName&lt;/span&gt;: &lt;span class=&quot;hljs-variable constant_&quot;&gt;INDEX_NAME&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;vectors&lt;/span&gt;: embeddings,
    &lt;span class=&quot;hljs-attr&quot;&gt;metadata&lt;/span&gt;: chunks.&lt;span class=&quot;hljs-title function_&quot;&gt;map&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;chunk, index&lt;/span&gt;) =&amp;gt;&lt;/span&gt; ({
      &lt;span class=&quot;hljs-attr&quot;&gt;text&lt;/span&gt;: chunk.&lt;span class=&quot;hljs-property&quot;&gt;text&lt;/span&gt;,
      source,
      &lt;span class=&quot;hljs-attr&quot;&gt;chunkIndex&lt;/span&gt;: index,
    })),
  });

  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { &lt;span class=&quot;hljs-attr&quot;&gt;count&lt;/span&gt;: chunks.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;source&lt;/code&gt; 保存文档来源，例如 GitHub Raw Markdown URL。后面检索到 Chunk 时，可以用它展示引用来源。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-9&quot;&gt;编写导入脚本&lt;/h3&gt;
&lt;p&gt;下面是一个适合本地 Markdown 文件的 CLI 示例，创建 &lt;code&gt;src/scripts/ingest.ts&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;dotenv/config&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { readFile } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;node:fs/promises&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { resolve } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;node:path&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { ingest } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;../mastra/services/knowledge.service&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; file = process.&lt;span class=&quot;hljs-property&quot;&gt;argv&lt;/span&gt;[&lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;];
&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!file) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;Usage: npm run ingest -- &amp;lt;markdown-file&amp;gt; [source-url]&quot;&lt;/span&gt;);
}

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; text = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;readFile&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;resolve&lt;/span&gt;(file), &lt;span class=&quot;hljs-string&quot;&gt;&quot;utf8&quot;&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; source = process.&lt;span class=&quot;hljs-property&quot;&gt;argv&lt;/span&gt;[&lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;] ?? file;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; result = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;ingest&lt;/span&gt;(text, source);

&lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`Ingested &lt;span class=&quot;hljs-subst&quot;&gt;${result.count}&lt;/span&gt; chunks from &lt;span class=&quot;hljs-subst&quot;&gt;${source}&lt;/span&gt;`&lt;/span&gt;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行示例：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;npm run ingest -- ./articles/golang-argon2id-password-hashing.md \
  https://raw.githubusercontent.com/damingerdai/damingerdai.github.io/refs/heads/master/source/_posts/back-end/golang-argon2id-password-hashing.md
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这篇 Argon2id 文章有 YAML Front Matter、标题、代码块和表格。当前 MVP 直接使用递归切分策略，能够工作，但不是专门针对 Markdown 语义结构优化的切分方式。后续可以考虑按标题层级分段，并避免将代码块截断。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;当前限制&lt;/strong&gt;：上面的 &lt;code&gt;upsert&lt;/code&gt; 没有提供稳定的 Chunk ID。重复导入同一篇文章时，可能产生重复向量。幂等导入、文档更新和删除尚未实现。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-id=&quot;heading-10&quot;&gt;六、先不接 Agent，验证向量检索&lt;/h2&gt;
&lt;p&gt;将检索独立成服务有一个好处：出现问题时，可以明确区分是 &lt;strong&gt;Embedding/向量检索不准确&lt;/strong&gt;，还是 &lt;strong&gt;Agent/LLM 没有正确使用检索结果&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;创建 &lt;code&gt;src/mastra/services/retrieval.service.ts&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { embed } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;ai&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { embeddingModel } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;../lib/embedding&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { vectorStore, &lt;span class=&quot;hljs-variable constant_&quot;&gt;INDEX_NAME&lt;/span&gt; } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;../lib/vector-store&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;retrieve&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;query: &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;, topK = &lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; { embedding } = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;embed&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;model&lt;/span&gt;: embeddingModel,
    &lt;span class=&quot;hljs-attr&quot;&gt;value&lt;/span&gt;: query,
  });

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; results = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; vectorStore.&lt;span class=&quot;hljs-title function_&quot;&gt;query&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;indexName&lt;/span&gt;: &lt;span class=&quot;hljs-variable constant_&quot;&gt;INDEX_NAME&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;queryVector&lt;/span&gt;: embedding,
    topK,
  });

  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; results.&lt;span class=&quot;hljs-title function_&quot;&gt;map&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;result&lt;/span&gt;) =&amp;gt;&lt;/span&gt; ({
    &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;: result.&lt;span class=&quot;hljs-property&quot;&gt;id&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;score&lt;/span&gt;: result.&lt;span class=&quot;hljs-property&quot;&gt;score&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;text&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;String&lt;/span&gt;(result.&lt;span class=&quot;hljs-property&quot;&gt;metadata&lt;/span&gt;?.&lt;span class=&quot;hljs-property&quot;&gt;text&lt;/span&gt; ?? &lt;span class=&quot;hljs-string&quot;&gt;&quot;&quot;&lt;/span&gt;),
    &lt;span class=&quot;hljs-attr&quot;&gt;source&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;String&lt;/span&gt;(result.&lt;span class=&quot;hljs-property&quot;&gt;metadata&lt;/span&gt;?.&lt;span class=&quot;hljs-property&quot;&gt;source&lt;/span&gt; ?? &lt;span class=&quot;hljs-string&quot;&gt;&quot;&quot;&lt;/span&gt;),
  }));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;创建 &lt;code&gt;src/scripts/search.ts&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;dotenv/config&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { retrieve } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;../mastra/services/retrieval.service&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; query = process.&lt;span class=&quot;hljs-property&quot;&gt;argv&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;slice&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;join&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot; &quot;&lt;/span&gt;) || &lt;span class=&quot;hljs-string&quot;&gt;&quot;为什么使用 Argon2id？&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; results = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;retrieve&lt;/span&gt;(query, &lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;);

&lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [index, result] &lt;span class=&quot;hljs-keyword&quot;&gt;of&lt;/span&gt; results.&lt;span class=&quot;hljs-title function_&quot;&gt;entries&lt;/span&gt;()) {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`\n--- Result &lt;span class=&quot;hljs-subst&quot;&gt;${index + &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;}&lt;/span&gt; ---`&lt;/span&gt;);
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;Score:&quot;&lt;/span&gt;, result.&lt;span class=&quot;hljs-property&quot;&gt;score&lt;/span&gt;);
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;Source:&quot;&lt;/span&gt;, result.&lt;span class=&quot;hljs-property&quot;&gt;source&lt;/span&gt;);
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;Text:&quot;&lt;/span&gt;, result.&lt;span class=&quot;hljs-property&quot;&gt;text&lt;/span&gt;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;npm run search -- &lt;span class=&quot;hljs-string&quot;&gt;&quot;Health Master 为什么使用 Argon2id？&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;score&lt;/code&gt; 是检索相似度得分，&lt;strong&gt;不是模型答案的正确率，也不能直接当作可信度百分比&lt;/strong&gt;。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-11&quot;&gt;七、将检索包装成 Mastra Tool&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;src/mastra/tools/knowledge-search.tool.ts&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { createTool } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@mastra/core/tools&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { z } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;zod&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { retrieve } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;../services/retrieval.service&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; knowledgeSearchTool = &lt;span class=&quot;hljs-title function_&quot;&gt;createTool&lt;/span&gt;({
  &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;knowledge-search&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;description&lt;/span&gt;:
    &lt;span class=&quot;hljs-string&quot;&gt;&quot;Search the knowledge base for relevant information about stored documents.&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;inputSchema&lt;/span&gt;: z.&lt;span class=&quot;hljs-title function_&quot;&gt;object&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;query&lt;/span&gt;: z.&lt;span class=&quot;hljs-title function_&quot;&gt;string&lt;/span&gt;().&lt;span class=&quot;hljs-title function_&quot;&gt;describe&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;Search query&quot;&lt;/span&gt;),
  }),
  &lt;span class=&quot;hljs-attr&quot;&gt;outputSchema&lt;/span&gt;: z.&lt;span class=&quot;hljs-title function_&quot;&gt;object&lt;/span&gt;({
    &lt;span class=&quot;hljs-attr&quot;&gt;results&lt;/span&gt;: z.&lt;span class=&quot;hljs-title function_&quot;&gt;array&lt;/span&gt;(
      z.&lt;span class=&quot;hljs-title function_&quot;&gt;object&lt;/span&gt;({
        &lt;span class=&quot;hljs-attr&quot;&gt;source&lt;/span&gt;: z.&lt;span class=&quot;hljs-title function_&quot;&gt;string&lt;/span&gt;(),
        &lt;span class=&quot;hljs-attr&quot;&gt;text&lt;/span&gt;: z.&lt;span class=&quot;hljs-title function_&quot;&gt;string&lt;/span&gt;(),
        &lt;span class=&quot;hljs-attr&quot;&gt;score&lt;/span&gt;: z.&lt;span class=&quot;hljs-title function_&quot;&gt;number&lt;/span&gt;(),
      }),
    ),
  }),
  &lt;span class=&quot;hljs-attr&quot;&gt;execute&lt;/span&gt;: &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; ({ query }) =&amp;gt; {
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; results = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;retrieve&lt;/span&gt;(query, &lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;);
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; {
      &lt;span class=&quot;hljs-attr&quot;&gt;results&lt;/span&gt;: results.&lt;span class=&quot;hljs-title function_&quot;&gt;map&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;{ source, text, score }&lt;/span&gt;) =&amp;gt;&lt;/span&gt; ({
        source,
        text,
        score,
      })),
    };
  },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里没有直接使用封装好的向量查询工具，而是把自己的 &lt;code&gt;retrieve()&lt;/code&gt; 包装成 Mastra Tool。这样未来要添加权限过滤、去重或 Reranking，可以只修改检索服务。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-12&quot;&gt;八、使用 DeepSeek 创建 Knowledge Agent&lt;/h2&gt;
&lt;p&gt;创建 &lt;code&gt;src/mastra/agents/knowledge.agent.ts&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { &lt;span class=&quot;hljs-title class_&quot;&gt;Agent&lt;/span&gt; } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@mastra/core/agent&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { createOpenAICompatible } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@ai-sdk/openai-compatible&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { knowledgeSearchTool } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;../tools/knowledge-search.tool&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; deepseek = &lt;span class=&quot;hljs-title function_&quot;&gt;createOpenAICompatible&lt;/span&gt;({
  &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;deepseek&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;baseURL&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;https://api.deepseek.com/v1&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;apiKey&lt;/span&gt;: process.&lt;span class=&quot;hljs-property&quot;&gt;env&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;DEEPSEEK_API_KEY&lt;/span&gt;!,
});

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; knowledgeAgent = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Agent&lt;/span&gt;({
  &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;knowledge-agent&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;Knowledge Agent&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;instructions&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;`
你是一个基于私人知识库的中文技术问答助手。
回答知识库相关问题时先调用 knowledgeSearchTool 检索。
根据检索到的材料回答，避免编造项目细节。
如果资料不足，请明确说明。
尽可能注明文档来源；不要编造来源链接。
直接回答问题，不要输出“我现在去搜索”等过程说明。
`&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;model&lt;/span&gt;: deepseek.&lt;span class=&quot;hljs-title function_&quot;&gt;chatModel&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;deepseek-chat&quot;&lt;/span&gt;),
  &lt;span class=&quot;hljs-attr&quot;&gt;tools&lt;/span&gt;: { knowledgeSearchTool },
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里采用的是 &lt;strong&gt;Agent 自主调用 Tool&lt;/strong&gt; 的方式。即使在指令中要求先检索，也不意味着从程序逻辑上强制每次执行检索。若未来要求严格的“先检索再回答”，应该把 &lt;code&gt;retrieve()&lt;/code&gt; 放到 Agent 调用之前，由应用程序控制检索流程。&lt;/p&gt;
&lt;p&gt;创建 &lt;code&gt;src/scripts/chat.ts&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;dotenv/config&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { knowledgeAgent } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;../mastra/agents/knowledge.agent&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; question =
  process.&lt;span class=&quot;hljs-property&quot;&gt;argv&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;slice&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;join&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot; &quot;&lt;/span&gt;) ||
  &lt;span class=&quot;hljs-string&quot;&gt;&quot;Health Master 为什么从 MD5 迁移到 Argon2id？&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; response = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; knowledgeAgent.&lt;span class=&quot;hljs-title function_&quot;&gt;generate&lt;/span&gt;(question);
&lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(response.&lt;span class=&quot;hljs-property&quot;&gt;text&lt;/span&gt;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;npm run chat -- &lt;span class=&quot;hljs-string&quot;&gt;&quot;Health Master 为什么从 MD5 迁移到 Argon2id？&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在实际测试中，Agent 能找到文章中与 PR #333 相关的内容，并回答出具体的工程决策，例如：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;引入 &lt;code&gt;pkg/pwd&lt;/code&gt; 统一密码哈希与验证逻辑。&lt;/li&gt;
&lt;li&gt;对旧 MD5 密码采用&lt;strong&gt;要求用户重置密码&lt;/strong&gt;，而不是登录成功后静默升级哈希的方案。&lt;/li&gt;
&lt;li&gt;使用 &lt;code&gt;errcode.ErrLegacyPasswordResetRequired&lt;/code&gt; 表示旧密码需要重置。&lt;/li&gt;
&lt;li&gt;将 &lt;code&gt;users.password&lt;/code&gt; 字段扩展为 &lt;code&gt;character varying(255)&lt;/code&gt;，容纳 Argon2id 的 PHC 字符串。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这些都是知识库文章里的项目细节，比仅仅回答“Argon2id 比 MD5 更安全”更能证明检索增强的实际价值。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-13&quot;&gt;九、调试 Agent 的 Tool Calls&lt;/h2&gt;
&lt;p&gt;初次调试时，我尝试这样打印工具调用：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(call.&lt;span class=&quot;hljs-property&quot;&gt;toolName&lt;/span&gt;);
&lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(call.&lt;span class=&quot;hljs-property&quot;&gt;input&lt;/span&gt;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结果全是 &lt;code&gt;undefined&lt;/code&gt;。这并不是 Tool 执行失败，而是&lt;strong&gt;读取错了返回对象的字段路径&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;我随后直接使用 Node.js 的 &lt;code&gt;inspect()&lt;/code&gt; 打印原始结构：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { inspect } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;node:util&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [index, step] &lt;span class=&quot;hljs-keyword&quot;&gt;of&lt;/span&gt; response.&lt;span class=&quot;hljs-property&quot;&gt;steps&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;entries&lt;/span&gt;()) {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`\n========== STEP &lt;span class=&quot;hljs-subst&quot;&gt;${index + &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;}&lt;/span&gt; ==========`&lt;/span&gt;);
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;Tool Calls:&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-title function_&quot;&gt;inspect&lt;/span&gt;(step.&lt;span class=&quot;hljs-property&quot;&gt;toolCalls&lt;/span&gt;, { &lt;span class=&quot;hljs-attr&quot;&gt;depth&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;6&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;colors&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt; }));
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;Tool Results:&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-title function_&quot;&gt;inspect&lt;/span&gt;(step.&lt;span class=&quot;hljs-property&quot;&gt;toolResults&lt;/span&gt;, { &lt;span class=&quot;hljs-attr&quot;&gt;depth&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;6&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;colors&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt; }));
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;Step Text:&quot;&lt;/span&gt;, step.&lt;span class=&quot;hljs-property&quot;&gt;text&lt;/span&gt;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这次安装版本中，Tool Call 的实际结构是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;{
  &lt;span class=&quot;hljs-attr&quot;&gt;type&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;tool-call&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;payload&lt;/span&gt;: {
    &lt;span class=&quot;hljs-attr&quot;&gt;toolCallId&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;call_...&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;toolName&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;knowledgeSearchTool&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;args&lt;/span&gt;: { &lt;span class=&quot;hljs-attr&quot;&gt;query&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;Health Master MD5 迁移 Argon2id 原因&quot;&lt;/span&gt; }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Tool Result 的关键结构则是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;{
  &lt;span class=&quot;hljs-attr&quot;&gt;type&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;tool-result&quot;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;payload&lt;/span&gt;: {
    &lt;span class=&quot;hljs-attr&quot;&gt;toolName&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;knowledgeSearchTool&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;result&lt;/span&gt;: {
      &lt;span class=&quot;hljs-attr&quot;&gt;results&lt;/span&gt;: [
        { &lt;span class=&quot;hljs-attr&quot;&gt;source&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;https://...&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;text&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;...&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;score&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;0.7279&lt;/span&gt; }
      ]
    }
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;因此要读取：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;call.&lt;span class=&quot;hljs-property&quot;&gt;payload&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;toolName&lt;/span&gt;;
call.&lt;span class=&quot;hljs-property&quot;&gt;payload&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;args&lt;/span&gt;;
result.&lt;span class=&quot;hljs-property&quot;&gt;payload&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;result&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;results&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-14&quot;&gt;实际运行结果&lt;/h3&gt;
&lt;p&gt;这次测试有两个 Step：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 1：两次工具调用&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;knowledgeSearchTool
  query: Health Master MD5 迁移 Argon2id 原因

knowledgeSearchTool
  query: 密码哈希算法选择 Argon2id
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两次调用分别返回 5 个相关 Chunk。第一组最高分约为 &lt;code&gt;0.728&lt;/code&gt;，第二组最高分约为 &lt;code&gt;0.731&lt;/code&gt;。返回片段来自同一篇 Markdown 文章，包含迁移原因、密码验证逻辑、Memory-hard、Salt 和旧 MD5 密码处理等内容。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Step 2：生成答案&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这一步不再调用工具，而是直接输出中文回答，并在末尾列出原始 Markdown URL。最终 &lt;code&gt;response.text&lt;/code&gt; 与 Step 2 的回答内容一致。&lt;/p&gt;
&lt;p&gt;前一次测试曾出现英文开头：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;I&#39;ll search the knowledge base for information about this migration.
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;说明模型有时会把检索前的说明文本也放进响应中。后续运行中这段英文没有再出现，但这属于模型行为，不应该把提示词约束视为绝对保证。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-15&quot;&gt;关于来源引用&lt;/h3&gt;
&lt;p&gt;当前实现将 &lt;code&gt;source&lt;/code&gt; 存在向量 metadata 中，模型会尝试在回答中引用来源。不过，&lt;strong&gt;最终答案中的引用文字仍然由模型生成&lt;/strong&gt;。更严格的做法是由应用程序直接汇总工具返回的 &lt;code&gt;source&lt;/code&gt; 和 Chunk ID，再与答案一起展示，避免出现模型编造或错误拼接来源的情况。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-16&quot;&gt;十、package.json 脚本汇总&lt;/h2&gt;
&lt;p&gt;可以添加以下脚本：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-json&quot; lang=&quot;json&quot;&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;scripts&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-punctuation&quot;&gt;{&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;&quot;db:init&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;tsx src/scripts/init-vector-db.ts&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;&quot;ingest&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;tsx src/scripts/ingest.ts&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;&quot;search&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;tsx src/scripts/search.ts&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;&quot;chat&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;tsx src/scripts/chat.ts&quot;&lt;/span&gt;
  &lt;span class=&quot;hljs-punctuation&quot;&gt;}&lt;/span&gt;
&lt;span class=&quot;hljs-punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;建议按以下顺序验证：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;# 1. 确保 PostgreSQL 已启动，并创建 pgvector 扩展&lt;/span&gt;

&lt;span class=&quot;hljs-comment&quot;&gt;# 2. 首次创建向量表&lt;/span&gt;
npm run db:init

&lt;span class=&quot;hljs-comment&quot;&gt;# 3. 导入一篇 Markdown&lt;/span&gt;
npm run ingest -- ./articles/golang-argon2id-password-hashing.md \
  https://raw.githubusercontent.com/damingerdai/damingerdai.github.io/refs/heads/master/source/_posts/back-end/golang-argon2id-password-hashing.md

&lt;span class=&quot;hljs-comment&quot;&gt;# 4. 先验证纯向量检索&lt;/span&gt;
npm run search -- &lt;span class=&quot;hljs-string&quot;&gt;&quot;为什么 Argon2id 需要 Salt？&quot;&lt;/span&gt;

&lt;span class=&quot;hljs-comment&quot;&gt;# 5. 最后测试 Agent&lt;/span&gt;
npm run chat -- &lt;span class=&quot;hljs-string&quot;&gt;&quot;Health Master 为什么从 MD5 迁移到 Argon2id？&quot;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 data-id=&quot;heading-17&quot;&gt;十一、MVP 已经完成什么，还缺什么？&lt;/h2&gt;
&lt;p&gt;目前已经验证的能力：&lt;/p&gt;





























































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;能力&lt;/th&gt;&lt;th&gt;状态&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;Markdown 文本切分&lt;/td&gt;&lt;td&gt;已实现&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;SiliconFlow BGE-M3 Embedding&lt;/td&gt;&lt;td&gt;已验证，1024 维&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;PostgreSQL/pgvector 向量存储&lt;/td&gt;&lt;td&gt;已验证&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;问题向量化与相似度检索&lt;/td&gt;&lt;td&gt;已验证&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;DeepSeek + Mastra Agent&lt;/td&gt;&lt;td&gt;已验证&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Agent 多次调用检索 Tool&lt;/td&gt;&lt;td&gt;已在日志中确认&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;返回文章来源&lt;/td&gt;&lt;td&gt;初步实现&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;幂等导入 / 增量更新&lt;/td&gt;&lt;td&gt;未实现&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Markdown 语义切分&lt;/td&gt;&lt;td&gt;未实现&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Reranking / 检索去重&lt;/td&gt;&lt;td&gt;未实现&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;引用真实性的程序化校验&lt;/td&gt;&lt;td&gt;未实现&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;自动化 RAG Evaluation&lt;/td&gt;&lt;td&gt;未实现&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Web UI / 流式聊天&lt;/td&gt;&lt;td&gt;未实现&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;下一步我打算优先做 &lt;strong&gt;Document Lifecycle（文档生命周期管理）&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;为每篇文档及 Chunk 生成稳定 ID。&lt;/li&gt;
&lt;li&gt;使用内容 Hash 判断文档是否变化，避免无意义的重复 Embedding。&lt;/li&gt;
&lt;li&gt;文档更新时替换旧 Chunk；文档删除时清理对应向量。&lt;/li&gt;
&lt;li&gt;改进 Markdown 切分，保留标题层级和代码块上下文。&lt;/li&gt;
&lt;li&gt;再考虑 Reranking、质量评估和 Web UI。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 data-id=&quot;heading-18&quot;&gt;十二、总结&lt;/h2&gt;
&lt;p&gt;这次实现最大的收获，不是“成功调用了一个大模型 API”，而是把 RAG 拆成了几个能够独立验证的环节：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Embedding 失败时，先检查 API URL、环境变量和最小 SDK 调用。&lt;/li&gt;
&lt;li&gt;向量入库失败时，检查数据库扩展和 Mastra 对应的索引/表是否存在。&lt;/li&gt;
&lt;li&gt;Agent 回答不准确时，先看检索结果，再看 Tool Calls，最后才分析提示词。&lt;/li&gt;
&lt;li&gt;不要把相似度分数误认为正确率，也不要把模型输出的引用当成天然可信的来源证明。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;对于个人技术博客知识库而言，这已经是一个可运行的 RAG MVP。下一阶段的重点不应急着堆叠功能，而是让文档导入、更新、检索和引用变得稳定、可重复、可观察。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-19&quot;&gt;参考资料&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fmastra.ai%2Fdocs&quot; target=&quot;_blank&quot; title=&quot;https://mastra.ai/docs&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;Mastra 官方文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fapi-docs.deepseek.com%2F&quot; target=&quot;_blank&quot; title=&quot;https://api-docs.deepseek.com/&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;DeepSeek API 文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fdocs.siliconflow.cn%2F&quot; target=&quot;_blank&quot; title=&quot;https://docs.siliconflow.cn/&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;SiliconFlow 文档&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fpgvector%2Fpgvector&quot; target=&quot;_blank&quot; title=&quot;https://github.com/pgvector/pgvector&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;pgvector&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fdamingerdai%2Fhealth-master%2Fpull%2F333&quot; target=&quot;_blank&quot; title=&quot;https://github.com/damingerdai/health-master/pull/333&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;Health Master PR #333&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fraw.githubusercontent.com%2Fdamingerdai%2Fdamingerdai.github.io%2Frefs%2Fheads%2Fmaster%2Fsource%2F_posts%2Fback-end%2Fgolang-argon2id-password-hashing.md&quot; target=&quot;_blank&quot; title=&quot;https://raw.githubusercontent.com/damingerdai/damingerdai.github.io/refs/heads/master/source/_posts/back-end/golang-argon2id-password-hashing.md&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;从密码哈希迁移到 Argon2id：一次 Go 项目中的密码安全实践（Markdown 原文）&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 data-id=&quot;heading-20&quot;&gt;源代码&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fdamingerdai%2Fmastra-rag-app&quot; target=&quot;_blank&quot; title=&quot;https://github.com/damingerdai/mastra-rag-app&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;mastra-rag-app&lt;/a&gt;&lt;/p&gt;</description><link>https://juejin.cn/post/7697593470718001203</link><guid isPermaLink="false">https://juejin.cn/post/7697593470718001203</guid><pubDate>Sun, 18 Oct 2026 11:29:34 GMT</pubDate><author>大明二代</author><category>人工智能</category><category>JavaScript</category><category>数据库</category></item><item><title>AI 流式传输工程指南：模型吐出半截 JSON，前端如何边收边渲染？</title><description>&lt;blockquote&gt;
&lt;p&gt;本文是&lt;strong&gt;通俗实战版&lt;/strong&gt;。保留全部工程精华、性能实测对比与跨框架（原生 JS / Vue 3 / React）开箱即用代码，8 分钟即可读完并直接落地到项目中。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/7dfa5970c8a742a7b38d047c46a2d8b0~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg54yr5aS0Xw==:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792927986&amp;amp;x-signature=TrUYGDaxDuI%2FbyyZdNDfWSZaYFg%3D&quot; alt=&quot;截屏2026-10-18 18.53.02.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h2 data-id=&quot;heading-0&quot;&gt;一、为什么不能等 JSON 吐完再解析？&lt;/h2&gt;
&lt;p&gt;做普通 AI 聊天时，模型吐出的是纯文本或 Markdown，前端收到一个字就往屏幕上追加一个字（打字机效果）。&lt;/p&gt;
&lt;p&gt;但现在越来越多的 AI 功能要求模型直接返回 &lt;strong&gt;JSON 结构化数据&lt;/strong&gt;，让前端渲染成交互组件：&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;典型业务场景&lt;/th&gt;&lt;th&gt;模型输出的 JSON 结构&lt;/th&gt;&lt;th&gt;边收边渲染带来的体验提升&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;商品 / 知识卡片&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{ title, summary, price, tags }&lt;/code&gt;&lt;/td&gt;&lt;td&gt;标题先跳出来，摘要逐字填充，不用盯着空白卡片干等&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;流式数据表格&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{ columns, rows: [...] }&lt;/code&gt;&lt;/td&gt;&lt;td&gt;表格一行接一行“长”出来，长表格首屏等待时间缩短 80%&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;智能表单填充&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{ name, phone, address }&lt;/code&gt;&lt;/td&gt;&lt;td&gt;表单项逐个自动回填，用户能实时看到 AI 正在填哪一格&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Agent 工具调用&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;tool_calls[].function.arguments&lt;/code&gt;&lt;/td&gt;&lt;td&gt;OpenAI 兼容接口的流式工具参数天生就是半截 JSON 碎片，边收边解析才能显示“正在搜索：xxx”&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;模型生成一段完整的 JSON 往往需要几百个 Token、耗时 5~15 秒。如果傻等到最后一个 &lt;code&gt;}&lt;/code&gt; 才调用 &lt;code&gt;JSON.parse()&lt;/code&gt;，用户就得对着白屏干等十几秒——流式传输也就失去了意义。&lt;/p&gt;
&lt;p&gt;要在模型只吐出“半截 JSON”时就实时渲染 UI，全链路其实只需要闯过三关：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-mermaid&quot; lang=&quot;mermaid&quot;&gt;graph LR
    A(&amp;amp;#34;第一关：协议剥壳&amp;lt;br/&amp;gt;从 SSE 信封取出 content 碎片&amp;amp;#34;) --&amp;gt; B(&amp;amp;#34;第二关：结构补全&amp;lt;br/&amp;gt;把 {&#39;title&#39;:&#39;降噪耳 补成合法 JSON&amp;amp;#34;)
    B --&amp;gt; C(&amp;amp;#34;第三关：平滑渲染&amp;lt;br/&amp;gt;按帧节流 + 默认值兜底 + 防闪烁&amp;amp;#34;)

    %% 针对掘金渲染器的样式微调
    style A fill:#e8f4fc,stroke:#1890ff,stroke-width:1px,color:#333
    style B fill:#fcf8e3,stroke:#faad14,stroke-width:1px,color:#333
    style C fill:#ebfbf2,stroke:#52c41a,stroke-width:1px,color:#333
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 data-id=&quot;heading-1&quot;&gt;二、先避坑：流式接口里有“两层 JSON”，别混为一谈&lt;/h2&gt;
&lt;p&gt;很多刚接触流式开发的同学会问：“我在写 SSE 流式接收时，不是已经用 &lt;code&gt;buffer&lt;/code&gt; 处理过半截 JSON 了吗？”&lt;/p&gt;
&lt;p&gt;其实在流式结构化输出里，存在&lt;strong&gt;外层信封&lt;/strong&gt;和&lt;strong&gt;内层业务数据&lt;/strong&gt;两层完全不同的 JSON：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-plain&quot; lang=&quot;plain&quot;&gt;外层（SSE 信封 JSON，一行一条，几毫秒就完整）：
data: {&quot;choices&quot;:[{&quot;delta&quot;:{&quot;content&quot;:&quot;{\&quot;title\&quot;:&quot;}}]}
data: {&quot;choices&quot;:[{&quot;delta&quot;:{&quot;content&quot;:&quot;\&quot;降噪耳&quot;}}]}
data: {&quot;choices&quot;:[{&quot;delta&quot;:{&quot;content&quot;:&quot;机\&quot;}&quot;}}]}

内层（把所有 delta.content 拼起来的业务 JSON，要好几秒才完整）：
第 1 刻：{&quot;title&quot;:
第 2 刻：{&quot;title&quot;:&quot;降噪耳
第 3 刻：{&quot;title&quot;:&quot;降噪耳机&quot;}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这两层的处理策略截然不同：&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;维度&lt;/th&gt;&lt;th&gt;外层：SSE 传输信封&lt;/th&gt;&lt;th&gt;内层：业务内容 JSON（本文主角）&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;长什么样&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;choices&quot;:[{&quot;delta&quot;:{&quot;content&quot;:&quot;降噪&quot;}}]}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;所有 &lt;code&gt;content&lt;/code&gt; 累加拼出的 &lt;code&gt;{&quot;title&quot;:&quot;降噪耳机&quot;,...}&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;多久能凑齐&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;收到换行符 &lt;code&gt;\n&lt;/code&gt; 就齐了（通常几毫秒）&lt;/td&gt;&lt;td&gt;模型吐完最后一个 Token 才齐（几秒到几十秒）&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;处理方式&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;用 &lt;code&gt;buffer&lt;/code&gt; 按 &lt;code&gt;\n&lt;/code&gt; 切行，凑齐一行再 &lt;code&gt;JSON.parse&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;不能干等&lt;/strong&gt;：必须把半截字符串临时“补全”成合法 JSON&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;浏览器原生的 &lt;code&gt;JSON.parse&lt;/code&gt; 是个“全有或全无”的严格解析器——只要末尾少一个引号或括号，它立刻抛 &lt;code&gt;SyntaxError&lt;/code&gt;，绝不会把前面已经解析好的字段还给你：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;parse&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;{&quot;title&quot;:&quot;AI 周&#39;&lt;/span&gt;);             &lt;span class=&quot;hljs-comment&quot;&gt;// ❌ SyntaxError: Unterminated string&lt;/span&gt;
&lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;parse&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;{&quot;title&quot;:&quot;AI 周报&quot;,&quot;items&quot;:[&#39;&lt;/span&gt;); &lt;span class=&quot;hljs-comment&quot;&gt;// ❌ SyntaxError: Unexpected end of JSON input&lt;/span&gt;
&lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;parse&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;{&quot;ok&quot;:tr&#39;&lt;/span&gt;);                    &lt;span class=&quot;hljs-comment&quot;&gt;// ❌ SyntaxError: Unexpected end of JSON input&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;所以在整个生成过程中，除了最后一刻，直接调 &lt;code&gt;JSON.parse&lt;/code&gt; 100% 会失败。我们需要一个**“尽力而为”的补全器**。&lt;/p&gt;
&lt;hr&gt;
&lt;h2 data-id=&quot;heading-2&quot;&gt;三、核心原理：半截 JSON 是怎么被“补”出来的？&lt;/h2&gt;
&lt;p&gt;生产环境里我们直接调用成熟小库（如 &lt;code&gt;partial-json&lt;/code&gt;），但花 2 分钟看懂它的原理，你就能明白为什么有些字段会提前出现、有些字段会被暂时丢弃。&lt;/p&gt;
&lt;p&gt;模型可能在任意字符处停下，补全器的工作其实就是**“去烂尾、补引号、补括号”**三步：&lt;/p&gt;















































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;断在哪里&lt;/th&gt;&lt;th&gt;当前收到的半截文本&lt;/th&gt;&lt;th&gt;补全策略&lt;/th&gt;&lt;th&gt;补全后的合法 JSON&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;字符串值中间&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;title&quot;:&quot;AI 周&lt;/code&gt;&lt;/td&gt;&lt;td&gt;补右引号 &lt;code&gt;&quot;&lt;/code&gt; + 补右括号 &lt;code&gt;}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;title&quot;:&quot;AI 周&quot;}&lt;/code&gt;（文字可逐字展示）&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;转义符中间&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;text&quot;:&quot;中\u4e&lt;/code&gt;&lt;/td&gt;&lt;td&gt;削掉未完成的 &lt;code&gt;\u4e&lt;/code&gt;，再补 &lt;code&gt;&quot;}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;text&quot;:&quot;中&quot;}&lt;/code&gt;（防非法转义报错）&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;半截 Key&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;title&quot;:&quot;周报&quot;,&quot;ite&lt;/code&gt;&lt;/td&gt;&lt;td&gt;Key 没写完毫无意义，回退删掉 &lt;code&gt;&quot;ite&lt;/code&gt;，再补 &lt;code&gt;}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;title&quot;:&quot;周报&quot;}&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;有 Key 没 Value&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;title&quot;:&quot;周报&quot;,&quot;items&quot;:&lt;/code&gt;&lt;/td&gt;&lt;td&gt;回退删掉悬空的 &lt;code&gt;&quot;items&quot;:&lt;/code&gt;，再补 &lt;code&gt;}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;title&quot;:&quot;周报&quot;}&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;尾逗号&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;[{&quot;name&quot;:&quot;A&quot;},&lt;/code&gt;&lt;/td&gt;&lt;td&gt;删掉末尾悬空的 &lt;code&gt;,&lt;/code&gt;，再补 &lt;code&gt;]&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;[{&quot;name&quot;:&quot;A&quot;}]&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;半截布尔 / null&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;ok&quot;:tr&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;t&lt;/code&gt; 开头只可能是 &lt;code&gt;true&lt;/code&gt;，顺手补全再补 &lt;code&gt;}&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;{&quot;ok&quot;:true}&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;计算机是怎么知道最后该补 &lt;code&gt;]}&lt;/code&gt; 还是 &lt;code&gt;}]}&lt;/code&gt; 的？答案是一个简单的&lt;strong&gt;栈（Stack）&lt;/strong&gt;：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;记引号状态&lt;/strong&gt;：从头扫描字符，遇到未转义的 &lt;code&gt;&quot;&lt;/code&gt; 就切换“是否在字符串内”的状态；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;记括号栈&lt;/strong&gt;：在引号外部，每遇到 &lt;code&gt;{&lt;/code&gt; 就往栈里记一个 &lt;code&gt;}&lt;/code&gt;，遇到 &lt;code&gt;[&lt;/code&gt; 就记一个 &lt;code&gt;]&lt;/code&gt;；遇到闭合的 &lt;code&gt;}&lt;/code&gt; 或 &lt;code&gt;]&lt;/code&gt; 就消掉栈顶那一个；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;截断并补齐&lt;/strong&gt;：需要渲染时，先把末尾悬空的逗号、冒号或半截 Key 削掉；如果停在字符串值中间就补一个 &lt;code&gt;&quot;&lt;/code&gt;；最后把栈里剩下的括号&lt;strong&gt;倒序拼到末尾&lt;/strong&gt;。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;下面这 35 行极简代码就能跑通核心流程（帮你吃透原理的演示版）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;miniCompleteJson&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;partialStr&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; inString = &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; escaped = &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; closers = []; &lt;span class=&quot;hljs-comment&quot;&gt;// 栈：记录还欠哪些闭合括号&lt;/span&gt;

  &lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; ch &lt;span class=&quot;hljs-keyword&quot;&gt;of&lt;/span&gt; partialStr) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (inString) {
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (escaped) escaped = &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
      &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (ch === &lt;span class=&quot;hljs-string&quot;&gt;&#39;\\&#39;&lt;/span&gt;) escaped = &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;;
      &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (ch === &lt;span class=&quot;hljs-string&quot;&gt;&#39;&quot;&#39;&lt;/span&gt;) inString = &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
      &lt;span class=&quot;hljs-keyword&quot;&gt;continue&lt;/span&gt;;
    }
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (ch === &lt;span class=&quot;hljs-string&quot;&gt;&#39;&quot;&#39;&lt;/span&gt;) inString = &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (ch === &lt;span class=&quot;hljs-string&quot;&gt;&#39;{&#39;&lt;/span&gt;) closers.&lt;span class=&quot;hljs-title function_&quot;&gt;push&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;}&#39;&lt;/span&gt;);
    &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (ch === &lt;span class=&quot;hljs-string&quot;&gt;&#39;[&#39;&lt;/span&gt;) closers.&lt;span class=&quot;hljs-title function_&quot;&gt;push&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;]&#39;&lt;/span&gt;);
    &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (ch === &lt;span class=&quot;hljs-string&quot;&gt;&#39;}&#39;&lt;/span&gt; || ch === &lt;span class=&quot;hljs-string&quot;&gt;&#39;]&#39;&lt;/span&gt;) closers.&lt;span class=&quot;hljs-title function_&quot;&gt;pop&lt;/span&gt;();
  }

  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; fixed = partialStr;
  &lt;span class=&quot;hljs-comment&quot;&gt;// 1. 如果停在字符串内部：先去掉末尾可能悬空的反斜杠，再补双引号&lt;/span&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (inString) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (escaped) fixed = fixed.&lt;span class=&quot;hljs-title function_&quot;&gt;slice&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, -&lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;);
    fixed += &lt;span class=&quot;hljs-string&quot;&gt;&#39;&quot;&#39;&lt;/span&gt;;
  }
  &lt;span class=&quot;hljs-comment&quot;&gt;// 2. 削掉末尾悬空的逗号、冒号或刚写了一半的 key（如 ,&quot;pri 或 ,&quot;price&quot;:）&lt;/span&gt;
  fixed = fixed.&lt;span class=&quot;hljs-title function_&quot;&gt;replace&lt;/span&gt;(&lt;span class=&quot;hljs-regexp&quot;&gt;/,\s*&quot;[^&quot;]*&quot;?\s*:?\s*$/&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;replace&lt;/span&gt;(&lt;span class=&quot;hljs-regexp&quot;&gt;/[,:\s]+$/&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-comment&quot;&gt;// 3. 倒序补齐所有未闭合的括号&lt;/span&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; fixed + closers.&lt;span class=&quot;hljs-title function_&quot;&gt;reverse&lt;/span&gt;().&lt;span class=&quot;hljs-title function_&quot;&gt;join&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt;);
}

&lt;span class=&quot;hljs-comment&quot;&gt;// 实测效果：&lt;/span&gt;
&lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;miniCompleteJson&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;{&quot;title&quot;:&quot;降噪耳机&quot;,&quot;tags&quot;:[&quot;蓝牙&quot;,&#39;&lt;/span&gt;));
&lt;span class=&quot;hljs-comment&quot;&gt;// =&amp;gt; &#39;{&quot;title&quot;:&quot;降噪耳机&quot;,&quot;tags&quot;:[&quot;蓝牙&quot;]}&#39;&lt;/span&gt;
&lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;miniCompleteJson&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;{&quot;title&quot;:&quot;降噪耳机&quot;,&quot;pri&#39;&lt;/span&gt;));
&lt;span class=&quot;hljs-comment&quot;&gt;// =&amp;gt; &#39;{&quot;title&quot;:&quot;降噪耳机&quot;}&#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 data-id=&quot;heading-3&quot;&gt;四、前端落地三板斧：不报错、不跳动、不卡顿&lt;/h2&gt;
&lt;p&gt;能解析出半截对象只是第一步。如果直接把半成品丢给页面渲染，往往会遇到&lt;strong&gt;组件白屏报错、数字疯狂闪烁、长 JSON 越滚越卡&lt;/strong&gt;三大问题。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-4&quot;&gt;第一板斧：给半成品套一层“默认值视图函数”（防报错）&lt;/h3&gt;
&lt;p&gt;流式传输刚开始时，解析出来的可能是 &lt;code&gt;{}&lt;/code&gt;，下一秒是 &lt;code&gt;{ title: &quot;降噪&quot; }&lt;/code&gt;，数组 &lt;code&gt;specs&lt;/code&gt; 还是 &lt;code&gt;undefined&lt;/code&gt;。如果在模板里直接写 &lt;code&gt;data.specs.map(...)&lt;/code&gt;，第一帧就会报错崩溃。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;标准做法&lt;/strong&gt;：写一个纯函数 &lt;code&gt;toCardView&lt;/code&gt;，把“任何字段都可能缺”的半成品，转换成结构固定的安全对象：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;toCardView&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;partial&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; {
    &lt;span class=&quot;hljs-attr&quot;&gt;title&lt;/span&gt;: &lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; partial?.&lt;span class=&quot;hljs-property&quot;&gt;title&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;string&#39;&lt;/span&gt; ? partial.&lt;span class=&quot;hljs-property&quot;&gt;title&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;summary&lt;/span&gt;: &lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; partial?.&lt;span class=&quot;hljs-property&quot;&gt;summary&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;string&#39;&lt;/span&gt; ? partial.&lt;span class=&quot;hljs-property&quot;&gt;summary&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt;,
    &lt;span class=&quot;hljs-comment&quot;&gt;// ⚠️ 注意：价格还没生成出来时一定要给 null（显示骨架屏），别给 0（否则用户会以为商品免费！）&lt;/span&gt;
    &lt;span class=&quot;hljs-attr&quot;&gt;price&lt;/span&gt;: &lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; partial?.&lt;span class=&quot;hljs-property&quot;&gt;price&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;number&#39;&lt;/span&gt; ? partial.&lt;span class=&quot;hljs-property&quot;&gt;price&lt;/span&gt; : &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;tags&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;Array&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;isArray&lt;/span&gt;(partial?.&lt;span class=&quot;hljs-property&quot;&gt;tags&lt;/span&gt;)
      ? partial.&lt;span class=&quot;hljs-property&quot;&gt;tags&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;filter&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;t&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; t === &lt;span class=&quot;hljs-string&quot;&gt;&#39;string&#39;&lt;/span&gt;)
      : [],
    &lt;span class=&quot;hljs-attr&quot;&gt;specs&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;Array&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;isArray&lt;/span&gt;(partial?.&lt;span class=&quot;hljs-property&quot;&gt;specs&lt;/span&gt;)
      ? partial.&lt;span class=&quot;hljs-property&quot;&gt;specs&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;map&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;s&lt;/span&gt;) =&amp;gt;&lt;/span&gt; ({ &lt;span class=&quot;hljs-attr&quot;&gt;label&lt;/span&gt;: s?.&lt;span class=&quot;hljs-property&quot;&gt;label&lt;/span&gt; ?? &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;value&lt;/span&gt;: s?.&lt;span class=&quot;hljs-property&quot;&gt;value&lt;/span&gt; ?? &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt; }))
      : [],
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-5&quot;&gt;第二板斧：管住“不稳定”的数字与高度（防闪烁跳动）&lt;/h3&gt;
&lt;p&gt;流式卡片最影响观感的就是画面乱跳，记住这四条实战经验：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;拒绝半截数字闪烁（&lt;code&gt;8&lt;/code&gt; → &lt;code&gt;89&lt;/code&gt; → &lt;code&gt;899&lt;/code&gt;）&lt;/strong&gt;：
字符串逐字出现很自然，但价格如果从 &lt;code&gt;¥8&lt;/code&gt; 变成 &lt;code&gt;¥89&lt;/code&gt; 再变成 &lt;code&gt;¥899&lt;/code&gt;，看起来就像 Bug。用 &lt;code&gt;partial-json&lt;/code&gt; 解析时，只要&lt;strong&gt;排除 &lt;code&gt;Allow.NUM&lt;/code&gt;&lt;/strong&gt;（只允许字符串、对象、数组处于半截状态），解析器就会等数字后面的逗号或括号出现、数字彻底定型后才返回该字段！&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;判断字段是否已写完（加打字光标）&lt;/strong&gt;：
既然 JSON 是按顺序生成的，&lt;strong&gt;只要后面的字段已经出现（或者流已结束），前面的字段就一定写完了&lt;/strong&gt;。我们可以据此给“正在生成的最后一个字段”加上闪烁光标样式。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;固定高度 + 骨架屏占位&lt;/strong&gt;：
标题、价格区域提前用 CSS 占好最小高度，值为空或 &lt;code&gt;null&lt;/code&gt; 时显示灰色骨架条（Skeleton），避免文字填入瞬间把整张卡片撑高一截。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schema 字段顺序设计（先短后长）&lt;/strong&gt;：
大模型会严格按照你在 Prompt 或 Structured Outputs Schema 中定义的字段顺序输出。把 &lt;code&gt;title&lt;/code&gt;、&lt;code&gt;price&lt;/code&gt; 等短字段放在最前面，把长文本 &lt;code&gt;summary&lt;/code&gt; 放在后面，卡片骨架 1 秒内就能立住。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;在控制台观察一帧一帧的真实产出，数据是这样“长”出来的：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-plain&quot; lang=&quot;plain&quot;&gt;帧  1: { title: &quot;降噪耳&quot;,       summary: &quot;&quot;,                 price: null, specs: [] }
帧  3: { title: &quot;降噪耳机 X1&quot;,  summary: &quot;主动降噪，续航 30&quot;, price: null, specs: [] }
帧  5: { title: &quot;降噪耳机 X1&quot;,  summary: &quot;主动降噪，续航 30 小时&quot;, price: 899, specs: [] }
帧  9: { title: &quot;降噪耳机 X1&quot;,  summary: &quot;...&quot;,              price: 899,  specs: [{ label: &quot;重量&quot;, value: &quot;250g&quot; }] }
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-6&quot;&gt;第三板斧：节流更新，别让解析退化成 &lt;span class=&quot;math math-inline&quot;&gt;&lt;span class=&quot;katex&quot;&gt;&lt;span class=&quot;katex-mathml&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;O&lt;/mi&gt;&lt;mo stretchy=&quot;false&quot;&gt;(&lt;/mo&gt;&lt;msup&gt;&lt;mi&gt;n&lt;/mi&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/msup&gt;&lt;mo stretchy=&quot;false&quot;&gt;)&lt;/mo&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;O(n^2)&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;&lt;span class=&quot;katex-html&quot; aria-hidden=&quot;true&quot;&gt;&lt;span class=&quot;base&quot;&gt;&lt;span class=&quot;strut&quot; style=&quot;height:1.0641em;vertical-align:-0.25em;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02778em;&quot;&gt;O&lt;/span&gt;&lt;span class=&quot;mopen&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mord&quot;&gt;&lt;span class=&quot;mord mathnormal&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;msupsub&quot;&gt;&lt;span class=&quot;vlist-t&quot;&gt;&lt;span class=&quot;vlist-r&quot;&gt;&lt;span class=&quot;vlist&quot; style=&quot;height:0.8141em;&quot;&gt;&lt;span style=&quot;top:-3.063em;margin-right:0.05em;&quot;&gt;&lt;span class=&quot;pstrut&quot; style=&quot;height:2.7em;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;sizing reset-size6 size3 mtight&quot;&gt;&lt;span class=&quot;mord mtight&quot;&gt;2&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;mclose&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;（防卡顿）&lt;/h3&gt;
&lt;p&gt;大模型吐 Token 极快，10 毫秒内可能连发四五个 chunk。如果&lt;strong&gt;每收到一个 chunk 就立刻全量补全 + &lt;code&gt;JSON.parse&lt;/code&gt; + 刷新 UI&lt;/strong&gt;，当 JSON 达到几十 KB（例如长表格）时，页面会越来越卡。&lt;/p&gt;
&lt;p&gt;在一份 107 KB（1500 行表格，约 2.7 万个 chunk）的真实基准测试中，不同策略的耗时差距高达 &lt;strong&gt;60 倍&lt;/strong&gt;：&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;解析与渲染策略&lt;/th&gt;&lt;th&gt;2.7 万个 chunk 总耗时&lt;/th&gt;&lt;th&gt;为什么差这么多？&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;A. 每个 chunk 都重头扫描 + &lt;code&gt;JSON.parse&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;约 32.7 秒&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;每次都把前面攒下的几万字重扫一遍，工作量呈平方级（&lt;span class=&quot;math math-inline&quot;&gt;&lt;span class=&quot;katex&quot;&gt;&lt;span class=&quot;katex-mathml&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;O&lt;/mi&gt;&lt;mo stretchy=&quot;false&quot;&gt;(&lt;/mo&gt;&lt;msup&gt;&lt;mi&gt;n&lt;/mi&gt;&lt;mn&gt;2&lt;/mn&gt;&lt;/msup&gt;&lt;mo stretchy=&quot;false&quot;&gt;)&lt;/mo&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;O(n^2)&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;&lt;span class=&quot;katex-html&quot; aria-hidden=&quot;true&quot;&gt;&lt;span class=&quot;base&quot;&gt;&lt;span class=&quot;strut&quot; style=&quot;height:1.0641em;vertical-align:-0.25em;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02778em;&quot;&gt;O&lt;/span&gt;&lt;span class=&quot;mopen&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mord&quot;&gt;&lt;span class=&quot;mord mathnormal&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;msupsub&quot;&gt;&lt;span class=&quot;vlist-t&quot;&gt;&lt;span class=&quot;vlist-r&quot;&gt;&lt;span class=&quot;vlist&quot; style=&quot;height:0.8141em;&quot;&gt;&lt;span style=&quot;top:-3.063em;margin-right:0.05em;&quot;&gt;&lt;span class=&quot;pstrut&quot; style=&quot;height:2.7em;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;sizing reset-size6 size3 mtight&quot;&gt;&lt;span class=&quot;mord mtight&quot;&gt;2&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&quot;mclose&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;）爆炸&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;B. 增量扫描，但每个 chunk 都 &lt;code&gt;JSON.parse&lt;/code&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;约 8.3 秒&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;扫描快了，但 27000 次 &lt;code&gt;JSON.parse&lt;/code&gt; 依然吃满 CPU&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;C. &lt;strong&gt;按帧/定时节流（每 50 个 chunk 解析一次）&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;strong&gt;约 0.53 秒&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;解析次数从 27000 次降到 500 多次，丝滑不掉帧&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;所以在前端落地上，&lt;strong&gt;千万不要在 &lt;code&gt;reader.read()&lt;/code&gt; 循环里直接调 &lt;code&gt;parse&lt;/code&gt; 和更新状态&lt;/strong&gt;，而是加一道节流阀：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;动画敏感型（普通卡片）&lt;/strong&gt;：用 &lt;code&gt;requestAnimationFrame&lt;/code&gt; 对齐屏幕刷新率（60Hz 下约 16ms 最多解析渲染一次）；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;重组件 / 虚拟DOM框架（React / Vue 大表格）&lt;/strong&gt;：如果觉得每秒 60 次组件重渲染还是太频，可以直接用 &lt;strong&gt;50ms ~ 80ms 的定时器节流&lt;/strong&gt;（每秒只更新 12~20 次），人眼看起来依然非常连贯，而框架重渲染开销直接再降 70%。&lt;/li&gt;
&lt;/ul&gt;
&lt;hr&gt;
&lt;h2 data-id=&quot;heading-7&quot;&gt;五、开箱即用：纯 JS 核心 + 原生 / Vue / React 接入&lt;/h2&gt;
&lt;p&gt;为了让代码在任何项目里都能直接复制使用，我们把逻辑拆成两层：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;底层核心函数（纯 JavaScript，零框架依赖）&lt;/strong&gt;：负责流读取、剥离代码围栏、节流解析与结束校验，通过 &lt;code&gt;onUpdate&lt;/code&gt; 回调吐出数据；&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;UI 接入层&lt;/strong&gt;：无论你用原生 DOM、Vue 3 还是 React，都只需要不到 10 行代码就能接上。&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-id=&quot;heading-8&quot;&gt;5.1 核心流式解析函数（纯 JS，框架无关）&lt;/h3&gt;
&lt;p&gt;先安装轻量库：&lt;code&gt;npm i partial-json&lt;/code&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// partialStream.js —— 纯 JS 实现，可在原生、Vue、React、小程序中通用&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { parse, &lt;span class=&quot;hljs-title class_&quot;&gt;Allow&lt;/span&gt; } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;partial-json&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { toCardView } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;./cardView.js&#39;&lt;/span&gt;;

&lt;span class=&quot;hljs-comment&quot;&gt;/**
 * 找到 JSON 正文起点：兼容模型在开头输出 &quot;好的，结果如下：&quot; 或 ```json 围栏
 */&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;extractJsonBody&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;raw&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; fenceMatch = raw.&lt;span class=&quot;hljs-title function_&quot;&gt;match&lt;/span&gt;(&lt;span class=&quot;hljs-regexp&quot;&gt;/```(?:json)?\s*\n?([\s\S]*)/i&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (fenceMatch) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; fenceMatch[&lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;].&lt;span class=&quot;hljs-title function_&quot;&gt;replace&lt;/span&gt;(&lt;span class=&quot;hljs-regexp&quot;&gt;/\s*```\s*$/&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt;);
  }
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; firstBracket = raw.&lt;span class=&quot;hljs-title function_&quot;&gt;search&lt;/span&gt;(&lt;span class=&quot;hljs-regexp&quot;&gt;/[{[]/&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; firstBracket === -&lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt; ? &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt; : raw.&lt;span class=&quot;hljs-title function_&quot;&gt;slice&lt;/span&gt;(firstBracket);
}

&lt;span class=&quot;hljs-comment&quot;&gt;/**
 * 消费流式 JSON 文本，按指定间隔节流产出安全的视图对象
 * &lt;span class=&quot;hljs-doctag&quot;&gt;@param&lt;/span&gt; {&lt;span class=&quot;hljs-type&quot;&gt;ReadableStream&lt;/span&gt;} &lt;span class=&quot;hljs-variable&quot;&gt;body&lt;/span&gt; - fetch 响应的 response.body
 * &lt;span class=&quot;hljs-doctag&quot;&gt;@param&lt;/span&gt; {&lt;span class=&quot;hljs-type&quot;&gt;(state: { data: any, done: boolean &lt;/span&gt;}) =&amp;gt; void} onUpdate - 状态回调
 * &lt;span class=&quot;hljs-doctag&quot;&gt;@param&lt;/span&gt; {&lt;span class=&quot;hljs-type&quot;&gt;number&lt;/span&gt;} &lt;span class=&quot;hljs-variable&quot;&gt;throttleMs&lt;/span&gt; - 节流间隔（默认 40ms，约 25fps，兼顾流畅度与框架性能）
 */&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;consumePartialJson&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;body, onUpdate, throttleMs = &lt;span class=&quot;hljs-number&quot;&gt;40&lt;/span&gt;&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; reader = body.&lt;span class=&quot;hljs-title function_&quot;&gt;pipeThrough&lt;/span&gt;(&lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;TextDecoderStream&lt;/span&gt;()).&lt;span class=&quot;hljs-title function_&quot;&gt;getReader&lt;/span&gt;();
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; rawText = &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; timerId = &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;;

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;flush&lt;/span&gt; = (&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) =&amp;gt; {
    timerId = &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; jsonCandidate = &lt;span class=&quot;hljs-title function_&quot;&gt;extractJsonBody&lt;/span&gt;(rawText);
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!jsonCandidate.&lt;span class=&quot;hljs-title function_&quot;&gt;trim&lt;/span&gt;()) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;

    &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
      &lt;span class=&quot;hljs-comment&quot;&gt;// 只允许字符串、对象、数组处于半截状态；排除 Allow.NUM 防止数字闪烁&lt;/span&gt;
      &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; partialObj = &lt;span class=&quot;hljs-title function_&quot;&gt;parse&lt;/span&gt;(jsonCandidate, &lt;span class=&quot;hljs-title class_&quot;&gt;Allow&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;STR&lt;/span&gt; | &lt;span class=&quot;hljs-title class_&quot;&gt;Allow&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;OBJ&lt;/span&gt; | &lt;span class=&quot;hljs-title class_&quot;&gt;Allow&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;ARR&lt;/span&gt;);
      &lt;span class=&quot;hljs-title function_&quot;&gt;onUpdate&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;data&lt;/span&gt;: &lt;span class=&quot;hljs-title function_&quot;&gt;toCardView&lt;/span&gt;(partialObj), &lt;span class=&quot;hljs-attr&quot;&gt;done&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt; });
    } &lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt; {
      &lt;span class=&quot;hljs-comment&quot;&gt;// 刚收到一两个字符尚无法解析时，静默跳过等下一拍&lt;/span&gt;
    }
  };

  &lt;span class=&quot;hljs-keyword&quot;&gt;while&lt;/span&gt; (&lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; { done, value } = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; reader.&lt;span class=&quot;hljs-title function_&quot;&gt;read&lt;/span&gt;();
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (done) &lt;span class=&quot;hljs-keyword&quot;&gt;break&lt;/span&gt;;

    rawText += value; &lt;span class=&quot;hljs-comment&quot;&gt;// 若后端是标准 SSE，这里改为提取出的 delta.content 即可&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (timerId === &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;) {
      timerId = &lt;span class=&quot;hljs-built_in&quot;&gt;setTimeout&lt;/span&gt;(flush, throttleMs);
    }
  }

  &lt;span class=&quot;hljs-comment&quot;&gt;// 流结束：清理定时器，用原生 JSON.parse 做最终完整性校验&lt;/span&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (timerId !== &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;) &lt;span class=&quot;hljs-built_in&quot;&gt;clearTimeout&lt;/span&gt;(timerId);
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; finalJson = &lt;span class=&quot;hljs-title function_&quot;&gt;extractJsonBody&lt;/span&gt;(rawText).&lt;span class=&quot;hljs-title function_&quot;&gt;replace&lt;/span&gt;(&lt;span class=&quot;hljs-regexp&quot;&gt;/}\s*[^}]*$/&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;}&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; finalObj = &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;parse&lt;/span&gt;(finalJson); &lt;span class=&quot;hljs-comment&quot;&gt;// 若中途断流不完整，这里会如实抛错进入上层 catch&lt;/span&gt;
  &lt;span class=&quot;hljs-title function_&quot;&gt;onUpdate&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;data&lt;/span&gt;: &lt;span class=&quot;hljs-title function_&quot;&gt;toCardView&lt;/span&gt;(finalObj), &lt;span class=&quot;hljs-attr&quot;&gt;done&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt; });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 为什么这里默认用了 &lt;code&gt;setTimeout(..., 40)&lt;/code&gt; 而不是固定死 &lt;code&gt;requestAnimationFrame&lt;/code&gt;？&lt;/strong&gt;
在原生 DOM 里用 &lt;code&gt;rAF&lt;/code&gt;（约 16ms 一次）很轻快；但在 React / Vue 等框架中，16ms 触发一次响应式更新意味着每秒要走 60 次组件重渲染（Diff）。设为 &lt;strong&gt;40ms ~ 50ms 节流&lt;/strong&gt;（每秒更新 20~25 次），肉眼看打字机依然丝滑，同时能把 React / Vue 的渲染负担降低 60% 以上。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-id=&quot;heading-9&quot;&gt;5.2 三端接入示例（任选其一）&lt;/h3&gt;
&lt;p&gt;有了上面的 &lt;code&gt;consumePartialJson&lt;/code&gt;，在任何技术栈里接入都只需要几行：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;写法 A：原生 JS（直接操作 DOM）&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; titleEl = &lt;span class=&quot;hljs-variable language_&quot;&gt;document&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;querySelector&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;#card-title&#39;&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; summaryEl = &lt;span class=&quot;hljs-variable language_&quot;&gt;document&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;querySelector&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;#card-summary&#39;&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; priceEl = &lt;span class=&quot;hljs-variable language_&quot;&gt;document&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;querySelector&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;#card-price&#39;&lt;/span&gt;);

&lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;start&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; res = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;fetch&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;/api/product-card&#39;&lt;/span&gt;, { &lt;span class=&quot;hljs-attr&quot;&gt;method&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;POST&#39;&lt;/span&gt; });
  &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;consumePartialJson&lt;/span&gt;(res.&lt;span class=&quot;hljs-property&quot;&gt;body&lt;/span&gt;, &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;{ data, done }&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
    titleEl.&lt;span class=&quot;hljs-property&quot;&gt;textContent&lt;/span&gt; = data.&lt;span class=&quot;hljs-property&quot;&gt;title&lt;/span&gt; || &lt;span class=&quot;hljs-string&quot;&gt;&#39;标题生成中...&#39;&lt;/span&gt;;
    summaryEl.&lt;span class=&quot;hljs-property&quot;&gt;textContent&lt;/span&gt; = data.&lt;span class=&quot;hljs-property&quot;&gt;summary&lt;/span&gt;;
    priceEl.&lt;span class=&quot;hljs-property&quot;&gt;textContent&lt;/span&gt; = data.&lt;span class=&quot;hljs-property&quot;&gt;price&lt;/span&gt; !== &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt; ? &lt;span class=&quot;hljs-string&quot;&gt;`¥&lt;span class=&quot;hljs-subst&quot;&gt;${data.price}&lt;/span&gt;`&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&#39;价格计算中...&#39;&lt;/span&gt;;
  });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;写法 B：Vue 3（Composition API）&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-vue&quot; lang=&quot;vue&quot;&gt;&amp;lt;script setup&amp;gt;
import { ref } from &#39;vue&#39;;
import { consumePartialJson } from &#39;./partialStream&#39;;
import { toCardView } from &#39;./cardView&#39;;

const card = ref(toCardView({}));
const isDone = ref(false);

async function generate() {
  const res = await fetch(&#39;/api/product-card&#39;, { method: &#39;POST&#39; });
  await consumePartialJson(res.body, ({ data, done }) =&amp;gt; {
    card.value = data;
    isDone.value = done;
  });
}
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;写法 C：React（Hooks）&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-jsx&quot; lang=&quot;jsx&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { useState } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;react&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { consumePartialJson } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;./partialStream&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { toCardView } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;./cardView&#39;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;ProductCard&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [state, setState] = &lt;span class=&quot;hljs-title function_&quot;&gt;useState&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;data&lt;/span&gt;: &lt;span class=&quot;hljs-title function_&quot;&gt;toCardView&lt;/span&gt;({}), &lt;span class=&quot;hljs-attr&quot;&gt;done&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt; });

  &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;generate&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; res = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;fetch&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;/api/product-card&#39;&lt;/span&gt;, { &lt;span class=&quot;hljs-attr&quot;&gt;method&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;POST&#39;&lt;/span&gt; });
    &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;consumePartialJson&lt;/span&gt;(res.&lt;span class=&quot;hljs-property&quot;&gt;body&lt;/span&gt;, setState);
  }
  &lt;span class=&quot;hljs-comment&quot;&gt;// 下面正常渲染 state.data 即可&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 data-id=&quot;heading-10&quot;&gt;六、工具怎么选？一张表对号入座&lt;/h2&gt;
&lt;p&gt;不用纠结重复造轮子，按你的项目情况直接选：&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;你的场景&lt;/th&gt;&lt;th&gt;推荐方案&lt;/th&gt;&lt;th&gt;特点与适用理由&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;常规前端项目（自建 SSE / Fetch 流）&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;partial-json&lt;/code&gt;&lt;/td&gt;&lt;td&gt;零依赖、体积小，支持通过 &lt;code&gt;Allow&lt;/code&gt; 位掩码关掉半截数字防闪烁。&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;模型不听话，爱输出单引号、注释、尾逗号&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;jsonrepair&lt;/code&gt;&lt;/td&gt;&lt;td&gt;容错能力极强，不仅能补全截断，还能把各种不规范的“坏 JSON”修成标准格式。&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;Next.js / React 全栈项目（已用 AI SDK）&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;Vercel AI SDK (&lt;code&gt;streamText&lt;/code&gt; + &lt;code&gt;Output.object&lt;/code&gt;)&lt;/td&gt;&lt;td&gt;前后端一体，配合客户端 &lt;code&gt;useObject&lt;/code&gt; Hook 自动搞定补全、节流和 Schema 校验。&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;strong&gt;超大 JSON、几千行的流式列表&lt;/strong&gt;&lt;/td&gt;&lt;td&gt;&lt;code&gt;@streamparser/json&lt;/code&gt;&lt;/td&gt;&lt;td&gt;事件驱动的真正 &lt;span class=&quot;math math-inline&quot;&gt;&lt;span class=&quot;katex&quot;&gt;&lt;span class=&quot;katex-mathml&quot;&gt;&lt;math xmlns=&quot;http://www.w3.org/1998/Math/MathML&quot;&gt;&lt;semantics&gt;&lt;mrow&gt;&lt;mi&gt;O&lt;/mi&gt;&lt;mo stretchy=&quot;false&quot;&gt;(&lt;/mo&gt;&lt;mi&gt;n&lt;/mi&gt;&lt;mo stretchy=&quot;false&quot;&gt;)&lt;/mo&gt;&lt;/mrow&gt;&lt;annotation encoding=&quot;application/x-tex&quot;&gt;O(n)&lt;/annotation&gt;&lt;/semantics&gt;&lt;/math&gt;&lt;/span&gt;&lt;span class=&quot;katex-html&quot; aria-hidden=&quot;true&quot;&gt;&lt;span class=&quot;base&quot;&gt;&lt;span class=&quot;strut&quot; style=&quot;height:1em;vertical-align:-0.25em;&quot;&gt;&lt;/span&gt;&lt;span class=&quot;mord mathnormal&quot; style=&quot;margin-right:0.02778em;&quot;&gt;O&lt;/span&gt;&lt;span class=&quot;mopen&quot;&gt;(&lt;/span&gt;&lt;span class=&quot;mord mathnormal&quot;&gt;n&lt;/span&gt;&lt;span class=&quot;mclose&quot;&gt;)&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt; 解析器，每当数组里有一个元素完整闭合就回调一次，完全不重扫前文（代价是拿不到半截字符串）。&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;hr&gt;
&lt;h2 data-id=&quot;heading-11&quot;&gt;七、上线前必看的 3 条生产安全底线&lt;/h2&gt;
&lt;p&gt;半截 JSON 补全出来的对象“长得很像完整数据”，这在生产环境里最容易埋雷。上线前务必检查这三件事：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;半截数据“只做展示，绝不提交”&lt;/strong&gt;：
网络中断、用户点击“停止生成”、或者模型触发最大长度截断（&lt;code&gt;finish_reason: &quot;length&quot;&lt;/code&gt;）时，补全器依然会给你一个合法的 JSON 对象，但里面少了一半字段。&lt;strong&gt;写数据库、提交表单、触发下一步业务接口，必须以 &lt;code&gt;done === true&lt;/code&gt; 且通过原生 &lt;code&gt;JSON.parse&lt;/code&gt;（或 &lt;code&gt;zod.safeParse&lt;/code&gt;）完整校验为唯一标准。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agent 工具调用参数在完整前绝不执行&lt;/strong&gt;：
假设模型正在生成删除目录的工具调用，半截参数 &lt;code&gt;{&quot;path&quot;: &quot;/data&lt;/code&gt; 补全后是合法的 &lt;code&gt;{&quot;path&quot;: &quot;/data&quot;}&lt;/code&gt;，但模型原本要写的是 &lt;code&gt;/data/tmp/cache&lt;/code&gt;。流式阶段解析出的参数&lt;strong&gt;只能用于 UI 预览&lt;/strong&gt;（例如显示“&lt;em&gt;正在准备清理目录：/data...&lt;/em&gt;”），真正执行工具必须等流结束并校验通过。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;警惕结构化字段里的恶意链接与 XSS&lt;/strong&gt;：
结构化不等于安全。如果卡片包含 &lt;code&gt;link&lt;/code&gt;、&lt;code&gt;avatar&lt;/code&gt; 等 URL 字段，渲染到 &lt;code&gt;&amp;lt;a href={link}&amp;gt;&lt;/code&gt; 前必须校验协议是否为 &lt;code&gt;https://&lt;/code&gt;，防止模型被诱导输出 &lt;code&gt;javascript:alert(1)&lt;/code&gt; 伪协议；普通文本字段坚持走文本插值（如 &lt;code&gt;textContent&lt;/code&gt;、&lt;code&gt;{{ title }}&lt;/code&gt;、&lt;code&gt;{title}&lt;/code&gt;），不要直接塞进 &lt;code&gt;innerHTML&lt;/code&gt; / &lt;code&gt;v-html&lt;/code&gt;。&lt;/li&gt;
&lt;/ol&gt;</description><link>https://juejin.cn/post/7697622920013021219</link><guid isPermaLink="false">https://juejin.cn/post/7697622920013021219</guid><pubDate>Sun, 18 Oct 2026 11:22:47 GMT</pubDate><author>猫头_</author><category>前端</category><category>JavaScript</category><category>LLM</category></item><item><title>element-plus表格使用:filter-method过滤导致出现无数据</title><description>&lt;h4 data-id=&quot;heading-0&quot;&gt;前端表格进行过滤&lt;/h4&gt;
&lt;p&gt;背景介绍：我们表格的过滤是后端做的&lt;br&gt;
但是不知道为啥，在进行迁移的时候&lt;br&gt;
表格过滤写上了 &lt;code&gt;@filter-change=&quot;handleFilterChange&quot;&lt;/code&gt;变成了前端过滤，&lt;br&gt;
导致服务端返回数据后，表格显示的却是：暂无数据
解决办法：去掉 &lt;code&gt;@filter-change=&quot;handleFilterChange&quot;&lt;/code&gt; 不要变成前端过滤&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ini&quot; lang=&quot;ini&quot;&gt;&amp;lt;template&amp;gt;
  &amp;lt;div&amp;gt;
    &amp;lt;el-button &lt;span class=&quot;hljs-attr&quot;&gt;type&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;primary&quot;&lt;/span&gt; @click=&lt;span class=&quot;hljs-string&quot;&gt;&quot;setTableData&quot;&lt;/span&gt;&amp;gt;设置数据&amp;lt;/el-button&amp;gt;
     &amp;lt;el-table
          &lt;span class=&quot;hljs-attr&quot;&gt;ref&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;normalTable&quot;&lt;/span&gt;
          :&lt;span class=&quot;hljs-attr&quot;&gt;data&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;tableData&quot;&lt;/span&gt;  
          &lt;span class=&quot;hljs-attr&quot;&gt;style&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;width: 100%&quot;&lt;/span&gt; 
          &lt;span class=&quot;hljs-attr&quot;&gt;max-height&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;440px&quot;&lt;/span&gt;
          @&lt;span class=&quot;hljs-attr&quot;&gt;filter-change&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleFilterChange&quot;&lt;/span&gt;
        &amp;gt;
          &amp;lt;el-table-column
            &lt;span class=&quot;hljs-attr&quot;&gt;v-for&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;(column, index) in tableDataOption&quot;&lt;/span&gt;
            :&lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;index&quot;&lt;/span&gt;
            :&lt;span class=&quot;hljs-attr&quot;&gt;prop&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;column.prop&quot;&lt;/span&gt;
            :&lt;span class=&quot;hljs-attr&quot;&gt;label&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;column.label&quot;&lt;/span&gt;
            :&lt;span class=&quot;hljs-attr&quot;&gt;min-width&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;120&quot;&lt;/span&gt;
            :&lt;span class=&quot;hljs-attr&quot;&gt;column-key&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;column.prop&quot;&lt;/span&gt;
            :&lt;span class=&quot;hljs-attr&quot;&gt;filters&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;column.filter ? column.filterOptions : null&quot;&lt;/span&gt;
            :&lt;span class=&quot;hljs-attr&quot;&gt;filter-multiple&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot; false&quot;&lt;/span&gt;
            :&lt;span class=&quot;hljs-attr&quot;&gt;filter-method&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;column.filter ? filterHandler : null&quot;&lt;/span&gt;
          /&amp;gt;
        &amp;lt;/el-table&amp;gt;
  &amp;lt;/div&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup &lt;span class=&quot;hljs-attr&quot;&gt;lang&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;ts&quot;&lt;/span&gt;&amp;gt;
import { ref } from &#39;vue&#39;&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;


let &lt;span class=&quot;hljs-attr&quot;&gt;tableData&lt;/span&gt; = ref&amp;lt;any[]&amp;gt;([])
const &lt;span class=&quot;hljs-attr&quot;&gt;tableDataOption&lt;/span&gt; = ref&amp;lt;any[]&amp;gt;([])
// 过滤方法
const &lt;span class=&quot;hljs-attr&quot;&gt;filterHandler&lt;/span&gt; = (value: any, row: any, column: any) =&amp;gt; {
  const &lt;span class=&quot;hljs-attr&quot;&gt;property&lt;/span&gt; = column.property
  // 过滤方法，返回 true 表示保留该行，否则不保留
  return  row&lt;span class=&quot;hljs-section&quot;&gt;[property]&lt;/span&gt; === value
}
function setTableData(){
  setTimeout(()=&amp;gt;{
      &lt;span class=&quot;hljs-attr&quot;&gt;tableDataOption.value&lt;/span&gt;= [ 
        { &lt;span class=&quot;hljs-string&quot;&gt;&quot;prop&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;bucket&quot;&lt;/span&gt;,
         &lt;span class=&quot;hljs-string&quot;&gt;&quot;label&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;桶名称&quot;&lt;/span&gt;, 
         &lt;span class=&quot;hljs-string&quot;&gt;&quot;filter&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, 
          filterOptions: [
            { &lt;span class=&quot;hljs-string&quot;&gt;&quot;text&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;A1&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;value&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;A1&quot;&lt;/span&gt; },
            { &lt;span class=&quot;hljs-string&quot;&gt;&quot;text&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;B1&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;value&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;002&quot;&lt;/span&gt; }
          ] 
       }, 
        { 
          &lt;span class=&quot;hljs-string&quot;&gt;&quot;prop&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;projectID&quot;&lt;/span&gt;, 
          &lt;span class=&quot;hljs-string&quot;&gt;&quot;label&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;企业项目&quot;&lt;/span&gt;, 
          &lt;span class=&quot;hljs-string&quot;&gt;&quot;filter&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;, 
          filterOptions: [ ] 
        } 
      ]
    &lt;span class=&quot;hljs-attr&quot;&gt;tableData.value&lt;/span&gt;= [
      {
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;bucket&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;A1&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;childInfo&quot;&lt;/span&gt;: [],
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;projectID&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;default&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;id&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;001&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;name&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;bucket-1e1f&quot;&lt;/span&gt;,
      },
      {
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;bucket&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;B1&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;childInfo&quot;&lt;/span&gt;: [],
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;projectID&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;default&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;id&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;bucket-272e&quot;&lt;/span&gt;,
        &lt;span class=&quot;hljs-string&quot;&gt;&quot;name&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;bucket-272e&quot;&lt;/span&gt;,
      },
    ]
  },1300)
}
function handleFilterChange(newFilters: Record&amp;lt;string, string&lt;span class=&quot;hljs-section&quot;&gt;[]&lt;/span&gt;&amp;gt;) {
  console.log(newFilters)
}

&amp;lt;/script&amp;gt;

&amp;lt;style scoped&amp;gt;

&amp;lt;/style&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/db7a871d414148c686d707a7ccdfe6c9~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5oiR55qEZGl25Lii5LqG6IK_5LmI5Yqe:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792922290&amp;amp;x-signature=GEwzZEVsz1gOyMqCmmTBwMzPJRA%3D&quot; alt=&quot;2026-10-18 172250&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h4 data-id=&quot;heading-1&quot;&gt;filters 的介绍&lt;/h4&gt;
&lt;p&gt;filters 表格列中数据过滤的选项,数组格式。&lt;br&gt;
数组中的元素需要有 text 和 value 属性。&lt;br&gt;
filter-method 是一个方法，它用于决定某些数据是否显示，会传入三个参数：value, row 和 column。&lt;br&gt;
该方法返回 true 表示保留该行，否则不保留。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-2&quot;&gt;使用 filter-method 进行过滤需要注意的点&lt;/h4&gt;
&lt;p&gt;filter-method 进行过滤是纯前端完成的。&lt;br&gt;
如果是后端进行过滤，就不要使用 filter-method了，会导致表格出现无数据的情况。&lt;br&gt;
该方法返回 true 表示保留该行，否则不保留。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-3&quot;&gt;filter-change=&quot;handleFilterChange&quot;&lt;/h4&gt;
&lt;p&gt;当表格的过滤条件发生变化的时候会触发该事件 filter-change&lt;br&gt;
比如: 使用 filter-method 方法进行过滤后，就会触发这个方法。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-4&quot;&gt;设置过滤后，重新设置表格中的数据也会自动展示过滤后的数据&lt;/h4&gt;
&lt;p&gt;表格最初有10条数据，我们使用过滤filter-change后，展示了1条数据；&lt;br&gt;
就算接口返回来8条数据，表格也只会展示1条数据（会自动过滤，只展示符合条件的数据）&lt;/p&gt;</description><link>https://juejin.cn/post/7697626465453654026</link><guid isPermaLink="false">https://juejin.cn/post/7697626465453654026</guid><pubDate>Sun, 18 Oct 2026 09:58:11 GMT</pubDate><author>我的div丢了肿么办</author><category>前端</category><category>JavaScript</category><category>Vue.js</category></item><item><title>向全世界售卖你的产品：做了一款教程引导插件</title><description>&lt;blockquote&gt;
&lt;p&gt;在AI时代我们需要做的是：将自己的能力、技能打包成一次制作多次售卖的商品，让全球都可以购买使用！&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;😵‍💫事情的起因是这样的，我看到同事在制作操作文档的时候非常耗时，基本的操作是：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-mermaid&quot; lang=&quot;mermaid&quot;&gt;graph TD
进入页面 --&amp;gt; 截图框选
截图框选 --&amp;gt; 页面信息脱敏
页面信息脱敏 --&amp;gt; 编辑步骤说明
编辑步骤说明 --&amp;gt; 保存使用word或者pdf发给对方
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;😵‍💫拿到操作文档的，操作起来更难受，需要在页面和文档之间反复切换。稍不留神看错了步骤，就要从头找好久才能接上步骤。&lt;/p&gt;
&lt;p&gt;🤔我此时在想能不能把这个过程变得简单一些，&lt;strong&gt;能不能将教程搬到真实页面上进行引导指引&lt;/strong&gt;，于是就有了&lt;strong&gt;roadbook&lt;/strong&gt;插件的诞生。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;使用 &lt;strong&gt;roadbook&lt;/strong&gt; 只需要三步：录制、分享、跟练&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-id=&quot;heading-0&quot;&gt;一、录制&lt;/h2&gt;
&lt;p&gt;就按照日常操作一遍。&lt;/p&gt;
&lt;p align=&quot;center&quot;&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/7b2c5ac126cf442c9a3f7aa36b40685b~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5piv5rGf6L-q5ZGA:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792977871&amp;amp;x-signature=j82yXp%2Fpw7O60EvMbuAGpSWI8h4%3D&quot; alt=&quot;image.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h2 data-id=&quot;heading-1&quot;&gt;二、分享&lt;/h2&gt;
&lt;p&gt;点击分享，对敏感信息脱敏。&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/9098f47ad5064423a25d14a3b9346e04~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5piv5rGf6L-q5ZGA:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792977871&amp;amp;x-signature=XO%2FlWd9vkA%2B0zPvEZkiayWwvYLE%3D&quot; alt=&quot;image.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h2 data-id=&quot;heading-2&quot;&gt;三、跟练&lt;/h2&gt;
&lt;p&gt;打开链接，输入密码，选择跟练方式：
链接：&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Ftryroadbook.com%2Fp%2Fh9jpmggt&quot; target=&quot;_blank&quot; title=&quot;https://tryroadbook.com/p/h9jpmggt&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;tryroadbook.com/p/h9jpmggt&lt;/a&gt;
密码：g7Ri-diLB-aNXc-RGai&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/fbf752d1b0b74069be19acbdee18f666~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5piv5rGf6L-q5ZGA:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792977871&amp;amp;x-signature=YNZbjoKud0U5%2FPlPLFv03tnfuEs%3D&quot; alt=&quot;image.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/cf8712903a6c428989f43593228a252e~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5piv5rGf6L-q5ZGA:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792977871&amp;amp;x-signature=SU2loT8yhOoPrBWuZpB7ukybWQw%3D&quot; alt=&quot;image.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/27a50cf0eaf040a2a4609a76bec265f1~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5piv5rGf6L-q5ZGA:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792977871&amp;amp;x-signature=Sx3D5as0CyGwtmuVjA47PB3q%2FT4%3D&quot; alt=&quot;image.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;没有装插件可以悬浮跟练：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/10e4f6f3aa0e47f79dfbfb9d50bd0042~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg5piv5rGf6L-q5ZGA:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792977871&amp;amp;x-signature=M7ZnF53WQ3ZnanblbfbXgFs5atw%3D&quot; alt=&quot;image.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h2 data-id=&quot;heading-3&quot;&gt;结尾&lt;/h2&gt;
&lt;p&gt;我都做了哪些内容：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;首先试探需求，看看有没有人搜索这样的需求，有就做，没有就说明没有需求，那就果断放弃。&lt;/li&gt;
&lt;li&gt;开通谷歌开发者身份，这个得有一个可以国际支付的卡。&lt;/li&gt;
&lt;li&gt;AI写代码，完成基础功能，接入支付功能。&lt;/li&gt;
&lt;li&gt;AI制作封面、宣传视频。&lt;/li&gt;
&lt;li&gt;搜索优化、宣传。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;&lt;strong&gt;感兴趣的可以去看看，给提提意见，有什么意见欢迎评论区讨论～&lt;/strong&gt;&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;官网地址：&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Ftryroadbook.com%2F&quot; target=&quot;_blank&quot; title=&quot;https://tryroadbook.com/&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;tryroadbook.com/&lt;/a&gt;  官网是全球可以购买使用的，也算是出海项目了.&lt;/p&gt;
&lt;/blockquote&gt;</description><link>https://juejin.cn/post/7697626465453506570</link><guid isPermaLink="false">https://juejin.cn/post/7697626465453506570</guid><pubDate>Sun, 18 Oct 2026 08:41:14 GMT</pubDate><author>是江迪呀</author><category>前端</category><category>后端</category><category>JavaScript</category></item><item><title>高级篇：进阶多跳 RAG：子问题拆解 + 循环检索 + 智能规划，搞定复杂链式问题</title><description>&lt;h3 data-id=&quot;heading-0&quot;&gt;前言&lt;/h3&gt;
&lt;p&gt;单轮 RAG 只能回答「一跳就能查到答案」的问题，但现实中很多问题是链式的：需要先知道 A，才能查到 B，再根据 B 推出 C。直接把原始问题向量化检索，往往因为语义偏移命中不了正确答案。&lt;/p&gt;
&lt;p&gt;本文是系列最终篇，我们把 RAG 升级到多跳推理版：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;自动把复杂问题拆成有序子问题&lt;/li&gt;
&lt;li&gt;按顺序逐个子问题检索，每步积累上下文&lt;/li&gt;
&lt;li&gt;智能规划器判断是否信息足够，可提前终止&lt;/li&gt;
&lt;li&gt;文档自动去重，避免重复内容干扰生成&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-id=&quot;heading-1&quot;&gt;一、多跳 RAG 完整架构&lt;/h3&gt;
&lt;p&gt;最终版的工作流是一个带循环的状态机：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-sql&quot; lang=&quot;sql&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;START&lt;/span&gt; → route_question
     ├─ simple → direct_answer → &lt;span class=&quot;hljs-keyword&quot;&gt;END&lt;/span&gt;
     └─ complex → decompose_question → retrieve → plan_next_step
                                                     ├─ retrieve（继续循环）
                                                     └─ generate → &lt;span class=&quot;hljs-keyword&quot;&gt;END&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心新增四个能力：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;decompose_question&lt;/strong&gt;：把原始问题拆成有序的子问题链&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;循环检索&lt;/strong&gt;：按顺序逐个检索子问题，每轮合并文档&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;plan_next_step&lt;/strong&gt;：每轮检索后评估信息是否足够，决定继续还是生成&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;mergeUnique&lt;/strong&gt;：多轮检索的文档自动去重，按相似度排序&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-id=&quot;heading-2&quot;&gt;二、状态扩展&lt;/h3&gt;
&lt;p&gt;为了支持循环和多跳，状态新增了多个控制字段：&lt;/p&gt;
&lt;p&gt;表格&lt;/p&gt;

































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;字段&lt;/th&gt;&lt;th&gt;作用&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;subQuestions&lt;/td&gt;&lt;td&gt;拆解后的子问题数组&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;nextSubIdx&lt;/td&gt;&lt;td&gt;当前检索到第几个子问题（指针）&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;currentQuery&lt;/td&gt;&lt;td&gt;当前正在检索的子问题&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;retrievalCount&lt;/td&gt;&lt;td&gt;已检索轮次&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;maxRetrievals&lt;/td&gt;&lt;td&gt;最大检索轮数，防止无限循环&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;plannedNext&lt;/td&gt;&lt;td&gt;下一步动作：retrieve /generate&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h3 data-id=&quot;heading-3&quot;&gt;三、核心模块逐段解析&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-4&quot;&gt;1. 子问题拆解节点&lt;/h4&gt;
&lt;p&gt;用 LLM 把复杂问题拆成有序子问题，要求每条都能独立检索，不能有指代。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;DecomposeSchema&lt;/span&gt; = z.&lt;span class=&quot;hljs-title function_&quot;&gt;object&lt;/span&gt;({
  &lt;span class=&quot;hljs-attr&quot;&gt;sub_questions&lt;/span&gt;: z.&lt;span class=&quot;hljs-title function_&quot;&gt;array&lt;/span&gt;(z.&lt;span class=&quot;hljs-title function_&quot;&gt;string&lt;/span&gt;()).&lt;span class=&quot;hljs-title function_&quot;&gt;min&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;max&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;8&lt;/span&gt;),
  &lt;span class=&quot;hljs-attr&quot;&gt;reason&lt;/span&gt;: z.&lt;span class=&quot;hljs-title function_&quot;&gt;string&lt;/span&gt;()
});

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;decomposeQuestionNode&lt;/span&gt; = &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; (&lt;span class=&quot;hljs-params&quot;&gt;state&lt;/span&gt;) =&amp;gt; {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;---DECOMPOSE_QUESTION---&quot;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; decomposer = model.&lt;span class=&quot;hljs-title function_&quot;&gt;withStructuredOutput&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;DecomposeSchema&lt;/span&gt;);
  
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; out = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; decomposer.&lt;span class=&quot;hljs-title function_&quot;&gt;invoke&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`
    你是《天龙八部》多跳问答的【子问题拆解器】。
    用户原始问题：&lt;span class=&quot;hljs-subst&quot;&gt;${state.question}&lt;/span&gt;
    
    任务：将问题拆成有序子问题列表 sub_questions，用于依次向量检索。
    要求：
    1. 链式推理、多层关系的问题，必须拆成多条；单跳即可答的也可只输出1条。
    2. 每条子问题必须是可独立检索的完整中文问句，禁止使用「他/她/此人」等指代。
    3. 顺序必须符合推理链：先搞清前置事实，再查后续结论。
    4. 不要把整句原题原样复制成唯一一条；不要拆成过碎的关键词列表。
    请输出 sub_questions 与简短 reason。
  `&lt;/span&gt;);

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; subQuestions = out.&lt;span class=&quot;hljs-property&quot;&gt;sub_questions&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;map&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-params&quot;&gt;s&lt;/span&gt; =&amp;gt;&lt;/span&gt; s.&lt;span class=&quot;hljs-title function_&quot;&gt;trim&lt;/span&gt;()).&lt;span class=&quot;hljs-title function_&quot;&gt;filter&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;Boolean&lt;/span&gt;);
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`拆解&lt;span class=&quot;hljs-subst&quot;&gt;${subQuestions.length}&lt;/span&gt;条子问题(&lt;span class=&quot;hljs-subst&quot;&gt;${out.reason}&lt;/span&gt;)`&lt;/span&gt;);
  subQuestions.&lt;span class=&quot;hljs-title function_&quot;&gt;forEach&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;q, i&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`[&lt;span class=&quot;hljs-subst&quot;&gt;${i+&lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;}&lt;/span&gt;] &lt;span class=&quot;hljs-subst&quot;&gt;${q}&lt;/span&gt;`&lt;/span&gt;));

  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; {
    subQuestions,
    &lt;span class=&quot;hljs-attr&quot;&gt;nextSubIdx&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;currentQuery&lt;/span&gt;: subQuestions[&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;]
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-5&quot;&gt;2. 文档去重合并&lt;/h4&gt;
&lt;p&gt;多轮检索会命中重复文档，用 Map 按 id 去重，保留相似度更高的版本，最终按分数降序。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;mergeUnique&lt;/span&gt; = (&lt;span class=&quot;hljs-params&quot;&gt;existingDocs, newDocs&lt;/span&gt;) =&amp;gt; {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; map = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Map&lt;/span&gt;();
  &lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; d &lt;span class=&quot;hljs-keyword&quot;&gt;of&lt;/span&gt; [...existingDocs, ...newDocs]) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; key = &lt;span class=&quot;hljs-title class_&quot;&gt;String&lt;/span&gt;(d.&lt;span class=&quot;hljs-property&quot;&gt;id&lt;/span&gt;);
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; prev = map.&lt;span class=&quot;hljs-title function_&quot;&gt;get&lt;/span&gt;(key);
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!prev || &lt;span class=&quot;hljs-title class_&quot;&gt;Number&lt;/span&gt;(d.&lt;span class=&quot;hljs-property&quot;&gt;score&lt;/span&gt;) &amp;gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Number&lt;/span&gt;(prev.&lt;span class=&quot;hljs-property&quot;&gt;score&lt;/span&gt;)) {
      map.&lt;span class=&quot;hljs-title function_&quot;&gt;set&lt;/span&gt;(key, d);
    }
  }
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Array&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;(map.&lt;span class=&quot;hljs-title function_&quot;&gt;values&lt;/span&gt;()).&lt;span class=&quot;hljs-title function_&quot;&gt;sort&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;a, b&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Number&lt;/span&gt;(b.&lt;span class=&quot;hljs-property&quot;&gt;score&lt;/span&gt;) - &lt;span class=&quot;hljs-title class_&quot;&gt;Number&lt;/span&gt;(a.&lt;span class=&quot;hljs-property&quot;&gt;score&lt;/span&gt;));
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-6&quot;&gt;3. 循环检索节点&lt;/h4&gt;
&lt;p&gt;根据 &lt;code&gt;nextSubIdx&lt;/code&gt; 指针取出当前子问题，执行检索，合并文档，指针后移。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ini&quot; lang=&quot;ini&quot;&gt;const &lt;span class=&quot;hljs-attr&quot;&gt;retrieveNode&lt;/span&gt; = async (state) =&amp;gt; {
  const &lt;span class=&quot;hljs-attr&quot;&gt;subs&lt;/span&gt; = state.subQuestions ?? []&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  const &lt;span class=&quot;hljs-attr&quot;&gt;idx&lt;/span&gt; = state.nextSubIdx ?? &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  const &lt;span class=&quot;hljs-attr&quot;&gt;q&lt;/span&gt; = subs[idx]?.trim()&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  const &lt;span class=&quot;hljs-attr&quot;&gt;round&lt;/span&gt; = state.retrievalCount + &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;

  console.log(`----第${round}轮，子问题 ${idx+1}/${subs.length}---`)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  console.log(`---查询 ${q} ---`)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;

  const &lt;span class=&quot;hljs-attr&quot;&gt;newDocs&lt;/span&gt; = await retrieveRelevantContent(q, state.k)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  const &lt;span class=&quot;hljs-attr&quot;&gt;merged&lt;/span&gt; = mergeUnique(state.documents ?? [], newDocs)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;

  console.log(`本轮命中${newDocs.length}条，累计去重后${merged.length}条`)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  return {
    documents: merged,
    retrievalCount: round,
    nextSubIdx: idx + 1,
    currentQuery: q
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-7&quot;&gt;4. 下一步规划节点&lt;/h4&gt;
&lt;p&gt;这是多跳 RAG 的「大脑」：综合已检索文档、剩余子问题、轮数上限，判断下一步动作。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ini&quot; lang=&quot;ini&quot;&gt;const &lt;span class=&quot;hljs-attr&quot;&gt;NextStepSchema&lt;/span&gt; = z.object({
  nextAction: z.enum(&lt;span class=&quot;hljs-section&quot;&gt;[&quot;retrieve&quot;, &quot;generate&quot;]&lt;/span&gt;),
  reason: z.string()
})&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;

const &lt;span class=&quot;hljs-attr&quot;&gt;planNextStepNode&lt;/span&gt; = async (state) =&amp;gt; {
  console.log(&quot;---PLAN_NEXT_STEP---&quot;)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  const &lt;span class=&quot;hljs-attr&quot;&gt;subs&lt;/span&gt; = state.subQuestions ?? []&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  const &lt;span class=&quot;hljs-attr&quot;&gt;nextIdx&lt;/span&gt; = state.nextSubIdx ?? &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  const &lt;span class=&quot;hljs-attr&quot;&gt;remaining&lt;/span&gt; = subs.length - nextIdx&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;

  // 拼接子问题状态和已检索文档摘要
  const &lt;span class=&quot;hljs-attr&quot;&gt;subList&lt;/span&gt; = subs.map((s, i) =&amp;gt; `&lt;span class=&quot;hljs-variable&quot;&gt;${i + 1}&lt;/span&gt;.&lt;span class=&quot;hljs-variable&quot;&gt;${s}&lt;/span&gt; 
    ${i &amp;lt; nextIdx ? &quot;已检索&quot;: &lt;span class=&quot;hljs-attr&quot;&gt;i&lt;/span&gt; === nextIdx ? &lt;span class=&quot;hljs-string&quot;&gt;&quot;(下一轮将检索)&quot;&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&quot;未检索&quot;&lt;/span&gt;}`).join(&lt;span class=&quot;hljs-string&quot;&gt;&quot;\n&quot;&lt;/span&gt;)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  
  const &lt;span class=&quot;hljs-attr&quot;&gt;docStr&lt;/span&gt; = state.documents.length === &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt; 
    ? &quot;(尚无检索结果)&quot;
    : state.documents.slice(0, 6).map((d, i) =&amp;gt; 
      `&lt;span class=&quot;hljs-section&quot;&gt;[${i + 1}]&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;score&lt;/span&gt;=&lt;span class=&quot;hljs-variable&quot;&gt;${Number(d.score).toFixed(4)}&lt;/span&gt; 第&lt;span class=&quot;hljs-variable&quot;&gt;${d.chapter_num}&lt;/span&gt;章：&lt;span class=&quot;hljs-variable&quot;&gt;${d.content.slice(0, 200)}&lt;/span&gt;`
    ).join(&quot;\n\n&quot;)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;

  const &lt;span class=&quot;hljs-attr&quot;&gt;prompt&lt;/span&gt; = `你是多跳 RAG 规划器。
  用户原始问题： ${state.question}
  子问题序列：
  ${subList}
  
  已检索轮次：${state.retrievalCount}&lt;span class=&quot;hljs-comment&quot;&gt;; 剩余未检索子问题条数：${remaining}&lt;/span&gt;
  最大检索轮数上限：${state.maxRetrievals}
  已召回文档摘要:
  ${docStr}

  请判断下一步：
  1）已有足够依据回答原始问题 → &lt;span class=&quot;hljs-attr&quot;&gt;nextAction&lt;/span&gt;=generate
  2) 仍缺关键事实、且还有未检索子问题、且未超轮数上限 → &lt;span class=&quot;hljs-attr&quot;&gt;nextAction&lt;/span&gt;=retrieve
  硬性规则：
  - 剩余子问题为0，必须 &lt;span class=&quot;hljs-attr&quot;&gt;nextAction&lt;/span&gt;=generate
  - 已达最大轮数，必须 &lt;span class=&quot;hljs-attr&quot;&gt;nextAction&lt;/span&gt;=generate
  `&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;

  const &lt;span class=&quot;hljs-attr&quot;&gt;planModel&lt;/span&gt; = model.withStructuredOutput(NextStepSchema)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  const { nextAction, reason } = await planModel.invoke(prompt)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  
  // 硬性规则兜底，防止模型判断失误
  let &lt;span class=&quot;hljs-attr&quot;&gt;finalNext&lt;/span&gt; = nextAction&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  if (state.retrievalCount &amp;gt;= state.maxRetrievals) &lt;span class=&quot;hljs-attr&quot;&gt;finalNext&lt;/span&gt; = &lt;span class=&quot;hljs-string&quot;&gt;&quot;generate&quot;&lt;/span&gt;&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  if (remaining &amp;lt;= 0) &lt;span class=&quot;hljs-attr&quot;&gt;finalNext&lt;/span&gt; = &lt;span class=&quot;hljs-string&quot;&gt;&quot;generate&quot;&lt;/span&gt;&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;

  console.log(`&lt;span class=&quot;hljs-section&quot;&gt;[决策]&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;plannedNext&lt;/span&gt;=&lt;span class=&quot;hljs-variable&quot;&gt;${finalNext}&lt;/span&gt; (模型建议=&lt;span class=&quot;hljs-variable&quot;&gt;${nextAction}&lt;/span&gt;)(&lt;span class=&quot;hljs-variable&quot;&gt;${reason}&lt;/span&gt;)`)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  return { plannedNext: finalNext }&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-8&quot;&gt;5. 条件循环与完整图&lt;/h4&gt;
&lt;p&gt;规划节点通过条件边形成循环，满足条件就继续检索，否则退出循环去生成答案。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-php&quot; lang=&quot;php&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;afterPlan&lt;/span&gt; = (state) =&amp;gt; state.plannedNext === &lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt; ? &lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&quot;generate&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;afterRoute&lt;/span&gt; = (state) =&amp;gt; state.strategy === &lt;span class=&quot;hljs-string&quot;&gt;&#39;simple&#39;&lt;/span&gt; ? &lt;span class=&quot;hljs-string&quot;&gt;&quot;direct_answer&quot;&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&quot;decompose_question&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;graph&lt;/span&gt; = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;StateGraph&lt;/span&gt;(GraphState)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;route_question&quot;&lt;/span&gt;, routeQuestionNode)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;direct_answer&quot;&lt;/span&gt;, directAnswerNode)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;decompose_question&quot;&lt;/span&gt;, decomposeQuestionNode)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt;, retrieveNode)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;plan_next_step&quot;&lt;/span&gt;, planNextStepNode)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;generate&quot;&lt;/span&gt;, generateNode)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addEdge&lt;/span&gt;(START, &lt;span class=&quot;hljs-string&quot;&gt;&quot;route_question&quot;&lt;/span&gt;)
  &lt;span class=&quot;hljs-comment&quot;&gt;// 路由分支&lt;/span&gt;
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addConditionalEdges&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;route_question&quot;&lt;/span&gt;, afterRoute, {
    &lt;span class=&quot;hljs-attr&quot;&gt;direct_answer&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;direct_answer&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;decompose_question&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;decompose_question&quot;&lt;/span&gt;
  })
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addEdge&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;decompose_question&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt;)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addEdge&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;plan_next_step&quot;&lt;/span&gt;)
  &lt;span class=&quot;hljs-comment&quot;&gt;// 循环分支：规划 → 继续检索 或 生成&lt;/span&gt;
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addConditionalEdges&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;plan_next_step&quot;&lt;/span&gt;, afterPlan, {
    &lt;span class=&quot;hljs-attr&quot;&gt;retrieve&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;generate&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;generate&quot;&lt;/span&gt;
  })
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addEdge&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;direct_answer&quot;&lt;/span&gt;, END)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addEdge&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;generate&quot;&lt;/span&gt;, END)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;compile&lt;/span&gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-9&quot;&gt;四、多跳问题运行演示&lt;/h3&gt;
&lt;p&gt;问题：《天龙八部》中四大恶人排行第二的是谁？此人之子在身世揭晓前，其生父在武林中的公开身份是什么？&lt;/p&gt;
&lt;p&gt;运行流程：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-scss&quot; lang=&quot;scss&quot;&gt;___ROUTE-QUESTION___
路由策略：complex 需要多步推理，涉及人物关系和身份细节

-&lt;span class=&quot;hljs-attr&quot;&gt;--DECOMPOSE_QUESTION---&lt;/span&gt;
拆解&lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;条子问题(需要先确定第二恶人是谁，再找其子，最后查生父身份)
&lt;span class=&quot;hljs-selector-attr&quot;&gt;[1]&lt;/span&gt; 《天龙八部》中四大恶人排行第二的人物是谁？
&lt;span class=&quot;hljs-selector-attr&quot;&gt;[2]&lt;/span&gt; 四大恶人排行第二之人的儿子是谁？
&lt;span class=&quot;hljs-selector-attr&quot;&gt;[3]&lt;/span&gt; 该人物之子的生父在武林中的公开身份是什么？

----第&lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;轮，子问题 &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;/&lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;---
---查询 《天龙八部》中四大恶人排行第二的人物是谁？ ---
本轮命中&lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;条，累计去重后&lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;条

-&lt;span class=&quot;hljs-attr&quot;&gt;--PLAN_NEXT_STEP---&lt;/span&gt;
&lt;span class=&quot;hljs-selector-attr&quot;&gt;[决策]&lt;/span&gt; plannedNext=retrieve (模型建议=retrieve)(已确定第二恶人是叶二娘，但还需其子与生父信息)

----第&lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;轮，子问题 &lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;/&lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;---
---查询 四大恶人排行第二之人的儿子是谁？ ---
本轮命中&lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;条，累计去重后&lt;span class=&quot;hljs-number&quot;&gt;8&lt;/span&gt;条

-&lt;span class=&quot;hljs-attr&quot;&gt;--PLAN_NEXT_STEP---&lt;/span&gt;
&lt;span class=&quot;hljs-selector-attr&quot;&gt;[决策]&lt;/span&gt; plannedNext=retrieve (模型建议=retrieve)(已知其子是虚竹，还需生父公开身份)

----第&lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;轮，子问题 &lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;/&lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;---
---查询 虚竹的生父在武林中的公开身份是什么？ ---
本轮命中&lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;条，累计去重后&lt;span class=&quot;hljs-number&quot;&gt;12&lt;/span&gt;条

-&lt;span class=&quot;hljs-attr&quot;&gt;--PLAN_NEXT_STEP---&lt;/span&gt;
&lt;span class=&quot;hljs-selector-attr&quot;&gt;[决策]&lt;/span&gt; plannedNext=generate (模型建议=generate)(所有关键信息已齐全)

&lt;span class=&quot;hljs-selector-attr&quot;&gt;[AI回答（流式）]&lt;/span&gt;
四大恶人排行第二的是「无恶不作」叶二娘。她的儿子是虚竹，在身世揭晓之前，虚竹的生父玄慈方丈在武林中的公开身份是少林寺方丈，同时也是当年雁门关惨案的带头大哥...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-10&quot;&gt;五、整套 RAG 演进总结&lt;/h3&gt;
&lt;p&gt;我们用四篇文章，从 0 到 1 完成了完整的演进路线：&lt;/p&gt;
&lt;p&gt;表格&lt;/p&gt;






























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;版本&lt;/th&gt;&lt;th&gt;核心能力&lt;/th&gt;&lt;th&gt;适用场景&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;向量入库版&lt;/td&gt;&lt;td&gt;EPUB 切片 + Milvus 建库&lt;/td&gt;&lt;td&gt;知识库构建&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;基础单轮 RAG&lt;/td&gt;&lt;td&gt;检索 → 生成&lt;/td&gt;&lt;td&gt;简单单跳问题&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;智能路由版&lt;/td&gt;&lt;td&gt;问题分类 + 双分支分流&lt;/td&gt;&lt;td&gt;混合问题场景，优化资源&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;多跳推理版&lt;/td&gt;&lt;td&gt;子问题拆解 + 循环检索 + 智能规划&lt;/td&gt;&lt;td&gt;复杂链式、多跳推理问题&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;这套架构可以直接复用到任何垂直领域知识库，只需要换一下数据集和 Prompt 就能落地。&lt;/p&gt;</description><link>https://juejin.cn/post/7697531208897134633</link><guid isPermaLink="false">https://juejin.cn/post/7697531208897134633</guid><pubDate>Sun, 18 Oct 2026 08:09:19 GMT</pubDate><author>YIAN</author><category>人工智能</category><category>JavaScript</category><category>LangChain</category></item><item><title>进阶篇：RAG 优化第一步：给你的问答系统加上智能路由，实现双分支分流</title><description>&lt;h3 data-id=&quot;heading-0&quot;&gt;言&lt;/h3&gt;
&lt;p&gt;基础版 RAG 有个很明显的浪费：不管什么问题都先走一遍向量检索。其实很多简单问题、常识问题，大模型本身就能准确回答，根本不需要查知识库，白白浪费了检索的时间和算力。&lt;/p&gt;
&lt;p&gt;本文是系列第三篇，我们给 RAG 加上一个「问题路由器」，让系统自动判断问题类型：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;简单问题&lt;/strong&gt;：直接让 LLM 回答，不走检索&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;复杂问题&lt;/strong&gt;：走 RAG 检索流程，基于知识库回答&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-id=&quot;heading-1&quot;&gt;一、架构升级：从直线到分支&lt;/h3&gt;
&lt;p&gt;升级后的工作流变成了带条件分支的结构：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-sql&quot; lang=&quot;sql&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;START&lt;/span&gt; → route_question
     ├─ simple → direct_answer → &lt;span class=&quot;hljs-keyword&quot;&gt;END&lt;/span&gt;
     └─ complex → retrieve → rag_generate → &lt;span class=&quot;hljs-keyword&quot;&gt;END&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;核心新增两个部分：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;route_question 路由节点&lt;/strong&gt;：用 LLM + 结构化输出判断问题类型&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;direct_answer 节点&lt;/strong&gt;：直接调用 LLM 回答简单问题&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-id=&quot;heading-2&quot;&gt;二、结构化输出的作用&lt;/h3&gt;
&lt;p&gt;路由节点需要稳定输出「simple/complex」两种结果，不能让 LLM 自由发挥文字。这里用 &lt;strong&gt;Zod 定义输出 Schema&lt;/strong&gt;，配合 LLM 的结构化输出能力，保证每次都返回符合格式的结果。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-3&quot;&gt;三、完整代码逐段解析&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-4&quot;&gt;1. 新增依赖与状态扩展&lt;/h4&gt;
&lt;p&gt;引入 Zod 用于结构化输出校验，状态中新增 &lt;code&gt;strategy&lt;/code&gt; 和 &lt;code&gt;routeReason&lt;/code&gt; 字段，记录路由结果和理由。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-php&quot; lang=&quot;php&quot;&gt;import { z } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;zod&#39;&lt;/span&gt;;

&lt;span class=&quot;hljs-comment&quot;&gt;// 状态扩展&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;GraphState&lt;/span&gt; = Annotation.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;Root&lt;/span&gt;({
  &lt;span class=&quot;hljs-attr&quot;&gt;question&lt;/span&gt;: Annotation,
  &lt;span class=&quot;hljs-attr&quot;&gt;k&lt;/span&gt;: Annotation,
  &lt;span class=&quot;hljs-attr&quot;&gt;strategy&lt;/span&gt;: Annotation,     // 路由策略：simple / complex
  &lt;span class=&quot;hljs-attr&quot;&gt;routeReason&lt;/span&gt;: Annotation,  // 路由理由
  &lt;span class=&quot;hljs-attr&quot;&gt;documents&lt;/span&gt;: Annotation,
  &lt;span class=&quot;hljs-attr&quot;&gt;generation&lt;/span&gt;: Annotation
})
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-5&quot;&gt;2. 定义路由 Schema&lt;/h4&gt;
&lt;p&gt;用 Zod 定义输出结构，&lt;code&gt;strategy&lt;/code&gt; 是枚举值，只能是 simple 或 complex，&lt;code&gt;reason&lt;/code&gt; 是判断理由。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-csharp&quot; lang=&quot;csharp&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; RouteSchema = z.&lt;span class=&quot;hljs-built_in&quot;&gt;object&lt;/span&gt;({
  strategy: z.&lt;span class=&quot;hljs-built_in&quot;&gt;enum&lt;/span&gt;([&lt;span class=&quot;hljs-string&quot;&gt;&quot;simple&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;complex&quot;&lt;/span&gt;]),
  reason: z.&lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;()
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-6&quot;&gt;3. 路由节点实现&lt;/h4&gt;
&lt;p&gt;给 LLM 加上结构化输出约束，传入路由规则和用户问题，返回判断结果并更新状态。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;routeQuestionNode&lt;/span&gt; = &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; (&lt;span class=&quot;hljs-params&quot;&gt;state&lt;/span&gt;) =&amp;gt; {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;___ROUTE-QUESTION___&#39;&lt;/span&gt;);
  
  &lt;span class=&quot;hljs-comment&quot;&gt;// 给模型加上结构化输出约束&lt;/span&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; router = model.&lt;span class=&quot;hljs-title function_&quot;&gt;withStructuredOutput&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;RouteSchema&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; route = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; router.&lt;span class=&quot;hljs-title function_&quot;&gt;invoke&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`
  你是问答路由器，请判断用户问题是否需要外部检索。
  规则：
  - simple: 常识问答、简短定义、无需特定小说细节即可回答。
  - complex: 需要《天龙八部》具体情节、人物关系、章节事实、原文细节或证据支持。
  
  用户问题： &lt;span class=&quot;hljs-subst&quot;&gt;${state.question}&lt;/span&gt;
  `&lt;/span&gt;);
  
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`路由策略：&lt;span class=&quot;hljs-subst&quot;&gt;${route.strategy}&lt;/span&gt; &lt;span class=&quot;hljs-subst&quot;&gt;${route.reason}&lt;/span&gt;`&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; {
    &lt;span class=&quot;hljs-attr&quot;&gt;strategy&lt;/span&gt;: route.&lt;span class=&quot;hljs-property&quot;&gt;strategy&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;routeReason&lt;/span&gt;: route.&lt;span class=&quot;hljs-property&quot;&gt;reason&lt;/span&gt;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-7&quot;&gt;4. 直接回答节点&lt;/h4&gt;
&lt;p&gt;对于 simple 类型的问题，直接调用 LLM 回答，不经过检索流程。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ini&quot; lang=&quot;ini&quot;&gt;const &lt;span class=&quot;hljs-attr&quot;&gt;directAnswerNode&lt;/span&gt; = async (state) =&amp;gt; {
  console.log(&#39;----DIRECT_ANSWER----&#39;)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  process.stdout.write(&quot;\n &lt;span class=&quot;hljs-section&quot;&gt;[AI 回答（流式）]&lt;/span&gt; \n&quot;)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  
  let &lt;span class=&quot;hljs-attr&quot;&gt;generation&lt;/span&gt; = &lt;span class=&quot;hljs-string&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  const &lt;span class=&quot;hljs-attr&quot;&gt;stream&lt;/span&gt; = await model.stream(`你是一个中文回答助手,
  请简洁回答问题。
  问题：${state.question}
  `)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;

  for await (const chunk of stream) {
    const &lt;span class=&quot;hljs-attr&quot;&gt;text&lt;/span&gt; = typeof chunk.content === &lt;span class=&quot;hljs-string&quot;&gt;&#39;string&#39;&lt;/span&gt; ? chunk.content : &lt;span class=&quot;hljs-string&quot;&gt;&quot;&quot;&lt;/span&gt;&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
    if (!text) continue&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
    generation += text&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
    process.stdout.write(text)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;
  }
  process.stdout.write(&quot;\n&quot;)&lt;span class=&quot;hljs-comment&quot;&gt;;&lt;/span&gt;

  return {
    documents: &lt;span class=&quot;hljs-section&quot;&gt;[]&lt;/span&gt;,
    generation
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-8&quot;&gt;5. 条件边与图构建&lt;/h4&gt;
&lt;p&gt;用 &lt;code&gt;addConditionalEdges&lt;/code&gt; 添加条件边，根据路由结果决定走哪个分支。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-php&quot; lang=&quot;php&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// 条件判断函数：根据 state.strategy 返回下一个节点名&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;decideNext&lt;/span&gt; = (state) =&amp;gt; (state.strategy === &lt;span class=&quot;hljs-string&quot;&gt;&#39;simple&#39;&lt;/span&gt; ? &lt;span class=&quot;hljs-string&quot;&gt;&quot;direct_answer&quot;&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt;);

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;graph&lt;/span&gt; = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;StateGraph&lt;/span&gt;(GraphState)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;route_question&quot;&lt;/span&gt;, routeQuestionNode)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;direct_answer&quot;&lt;/span&gt;, directAnswerNode)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt;, retrieveNode)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;rag_generate&quot;&lt;/span&gt;, generateNode)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addEdge&lt;/span&gt;(START, &lt;span class=&quot;hljs-string&quot;&gt;&quot;route_question&quot;&lt;/span&gt;)
  &lt;span class=&quot;hljs-comment&quot;&gt;// 条件分支：路由节点 → 直接回答 或 检索&lt;/span&gt;
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addConditionalEdges&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;route_question&quot;&lt;/span&gt;, decideNext, {
    &lt;span class=&quot;hljs-attr&quot;&gt;direct_answer&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;direct_answer&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;retrieve&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt;
  })
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addEdge&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;rag_generate&quot;&lt;/span&gt;)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addEdge&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;direct_answer&quot;&lt;/span&gt;, END)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;addEdge&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;rag_generate&quot;&lt;/span&gt;, END)
  .&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;compile&lt;/span&gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-9&quot;&gt;6. 主函数调用&lt;/h4&gt;
&lt;p&gt;和基础版类似，只是初始状态多了路由相关字段。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;main&lt;/span&gt; () {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; question = &lt;span class=&quot;hljs-string&quot;&gt;&quot;阿朱是怎么死的？&quot;&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; k = &lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;;

  &lt;span class=&quot;hljs-comment&quot;&gt;// Milvus 连接代码省略，与基础版一致...&lt;/span&gt;

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; result = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; graph.&lt;span class=&quot;hljs-title function_&quot;&gt;invoke&lt;/span&gt;({
    question,
    &lt;span class=&quot;hljs-attr&quot;&gt;k&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;Number&lt;/span&gt;.&lt;span class=&quot;hljs-built_in&quot;&gt;isFinite&lt;/span&gt;(k)?&lt;span class=&quot;hljs-attr&quot;&gt;k&lt;/span&gt;:&lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;strategy&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;routeReason&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;documents&lt;/span&gt;: [],
    &lt;span class=&quot;hljs-attr&quot;&gt;generation&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;&quot;&lt;/span&gt;
  });

  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;stringify&lt;/span&gt;(result));
}

&lt;span class=&quot;hljs-title function_&quot;&gt;main&lt;/span&gt;().&lt;span class=&quot;hljs-title function_&quot;&gt;then&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-params&quot;&gt;err&lt;/span&gt; =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;error&lt;/span&gt;(err));
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-10&quot;&gt;四、两种场景的运行效果&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-11&quot;&gt;场景 1：简单常识问题&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-csharp&quot; lang=&quot;csharp&quot;&gt;___ROUTE-QUESTION___
路由策略：simple 这是基础常识问题，无需检索小说内容

 [&lt;span class=&quot;hljs-meta&quot;&gt;AI 回答（流式）&lt;/span&gt;] 
阿朱是金庸小说《天龙八部》中的人物。
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-12&quot;&gt;场景 2：小说细节问题&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-markdown&quot; lang=&quot;markdown&quot;&gt;&lt;span class=&quot;hljs-strong&quot;&gt;__&lt;span class=&quot;hljs-emphasis&quot;&gt;_ROUTE-QUESTION_&lt;/span&gt;__&lt;/span&gt;
路由策略：complex 需要小说具体情节细节，必须检索原文

----检索中----
命中 5 条相关片段...

[AI回答（流式）]
阿朱是被萧峰一掌打死的...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-13&quot;&gt;五、升级收益与下一步&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-14&quot;&gt;本次升级带来的提升&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;节省资源&lt;/strong&gt;：常识问题跳过检索，减少向量数据库查询次数&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;响应更快&lt;/strong&gt;：简单问题少了一次检索耗时，延迟大幅降低&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;架构清晰&lt;/strong&gt;：路由逻辑独立，后续可以继续扩展更多分支&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-id=&quot;heading-15&quot;&gt;仍然存在的问题&lt;/h4&gt;
&lt;p&gt;对于&lt;strong&gt;多跳链式问题&lt;/strong&gt;，单轮检索还是不够。比如：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;《天龙八部》中四大恶人排行第二的是谁？此人之子在身世揭晓前，其生父在武林中的公开身份是什么？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这种问题需要先查「四大恶人第二是谁」，再查「他的儿子是谁」，再查「儿子的生父公开身份」，单轮检索根本答不准。&lt;/p&gt;
&lt;p&gt;下一篇，我们就进入终极版：&lt;strong&gt;子问题拆解 + 多轮循环检索 + 智能规划器，实现真正的多跳推理 RAG&lt;/strong&gt;。&lt;/p&gt;</description><link>https://juejin.cn/post/7697558807295557674</link><guid isPermaLink="false">https://juejin.cn/post/7697558807295557674</guid><pubDate>Sun, 18 Oct 2026 08:08:30 GMT</pubDate><author>YIAN</author><category>人工智能</category><category>JavaScript</category><category>LangChain</category></item><item><title>入门篇：LangGraph 入门实战：从零搭建最简单的 RAG 问答系统</title><description>&lt;h3 data-id=&quot;heading-0&quot;&gt;前言&lt;/h3&gt;
&lt;p&gt;有了向量知识库，下一步就是搭建真正的 RAG 问答系统。很多人一开始会写一堆零散的函数调用，但流程一复杂就难以维护。LangGraph 提供了基于状态机的工作流编排方式，让 RAG 的每一步都清晰可控。&lt;/p&gt;
&lt;p&gt;本文是系列第二篇，我们基于上一篇建好的《天龙八部》Milvus 向量库，用 LangGraph 搭建最基础的 RAG 流程：&lt;strong&gt;提问 → 向量检索 → 大模型生成答案&lt;/strong&gt;。虽然简单，但这是所有复杂 RAG 的雏形。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-1&quot;&gt;一、基础 RAG 的核心流程&lt;/h3&gt;
&lt;p&gt;最简单的 RAG 只有两个核心节点，形成一条直线工作流：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-sql&quot; lang=&quot;sql&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;START&lt;/span&gt; → retrieve（检索） → generate（生成） → &lt;span class=&quot;hljs-keyword&quot;&gt;END&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;retrieve 节点&lt;/strong&gt;：接收用户问题，去 Milvus 向量库检索最相似的 Top-K 文本片段&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;generate 节点&lt;/strong&gt;：把检索到的片段作为上下文，交给 LLM 结合问题生成答案&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-id=&quot;heading-2&quot;&gt;二、状态设计&lt;/h3&gt;
&lt;p&gt;LangGraph 的核心是「状态」，所有节点共享同一份状态数据，节点执行后更新状态，传递给下一个节点。&lt;/p&gt;
&lt;p&gt;我们定义的状态包含四个字段：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;question&lt;/code&gt;：用户原始问题&lt;/li&gt;
&lt;li&gt;&lt;code&gt;k&lt;/code&gt;：检索返回的片段数量&lt;/li&gt;
&lt;li&gt;&lt;code&gt;documents&lt;/code&gt;：检索到的文档列表&lt;/li&gt;
&lt;li&gt;&lt;code&gt;generation&lt;/code&gt;：最终生成的答案&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-arduino&quot; lang=&quot;arduino&quot;&gt;&lt;span class=&quot;hljs-type&quot;&gt;const&lt;/span&gt; GraphState = Annotation.&lt;span class=&quot;hljs-built_in&quot;&gt;Root&lt;/span&gt;({
  question: Annotation,   &lt;span class=&quot;hljs-comment&quot;&gt;// 用户问题&lt;/span&gt;
  k: Annotation,          &lt;span class=&quot;hljs-comment&quot;&gt;// 检索 Top-K&lt;/span&gt;
  documents: Annotation,  &lt;span class=&quot;hljs-comment&quot;&gt;// 检索到的文档&lt;/span&gt;
  generation: Annotation  &lt;span class=&quot;hljs-comment&quot;&gt;// 生成的回答&lt;/span&gt;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-3&quot;&gt;三、完整代码逐段解析&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-4&quot;&gt;1. 依赖与初始化&lt;/h4&gt;
&lt;p&gt;导入 LangGraph、Milvus 向量存储、大模型等依赖，初始化客户端。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-arduino&quot; lang=&quot;arduino&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;dotenv/config&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { ChatOpenAI, OpenAIEmbeddings } from &lt;span class=&quot;hljs-string&quot;&gt;&quot;@langchain/openai&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { Annotation, END, START, StateGraph } from &lt;span class=&quot;hljs-string&quot;&gt;&#39;@langchain/langgraph&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { Milvus } from &lt;span class=&quot;hljs-string&quot;&gt;&#39;@langchain/community/vectorstores/milvus&#39;&lt;/span&gt;;

&lt;span class=&quot;hljs-type&quot;&gt;const&lt;/span&gt; COLLECTION_NAME = &lt;span class=&quot;hljs-string&quot;&gt;&quot;ebook_collection&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-type&quot;&gt;const&lt;/span&gt; TOP_K = &lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;;

&lt;span class=&quot;hljs-comment&quot;&gt;// 初始化大模型&lt;/span&gt;
&lt;span class=&quot;hljs-type&quot;&gt;const&lt;/span&gt; model = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;ChatOpenAI&lt;/span&gt;({
  model: process.env.MODEL_NAME,
  temperature: &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 设为 0，保证回答稳定准确&lt;/span&gt;
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  apiKey: process.env.OPENAI_API_KEY
});

&lt;span class=&quot;hljs-comment&quot;&gt;// 初始化向量模型（与入库时保持一致）&lt;/span&gt;
&lt;span class=&quot;hljs-type&quot;&gt;const&lt;/span&gt; embeddings = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;OpenAIEmbeddings&lt;/span&gt;({
  model: &lt;span class=&quot;hljs-string&quot;&gt;&quot;text-embedding-v3&quot;&lt;/span&gt;,
  dimensions: &lt;span class=&quot;hljs-number&quot;&gt;1024&lt;/span&gt;,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  apiKey: process.env.OPENAI_API_KEY
});

let vectorStore;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-5&quot;&gt;2. 检索节点&lt;/h4&gt;
&lt;p&gt;封装 Milvus 相似度搜索，返回带分数、章节信息的文档列表。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;retrieveRelevantContent&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;question, k = TOP_K&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; docsWithScores = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; vectorStore.&lt;span class=&quot;hljs-title function_&quot;&gt;similaritySearchWithScore&lt;/span&gt;(question, k);
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; docsWithScores.&lt;span class=&quot;hljs-title function_&quot;&gt;map&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;[doc, score]&lt;/span&gt;) =&amp;gt;&lt;/span&gt; ({
      score,
      &lt;span class=&quot;hljs-attr&quot;&gt;content&lt;/span&gt;: doc.&lt;span class=&quot;hljs-property&quot;&gt;pageContent&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;: doc.&lt;span class=&quot;hljs-property&quot;&gt;metadata&lt;/span&gt;?.&lt;span class=&quot;hljs-property&quot;&gt;id&lt;/span&gt; ?? &lt;span class=&quot;hljs-string&quot;&gt;&quot;unknown&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;book_id&lt;/span&gt;: doc.&lt;span class=&quot;hljs-property&quot;&gt;metadata&lt;/span&gt;?.&lt;span class=&quot;hljs-property&quot;&gt;book_id&lt;/span&gt; ?? &lt;span class=&quot;hljs-string&quot;&gt;&quot;未知&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;chapter_num&lt;/span&gt;: doc.&lt;span class=&quot;hljs-property&quot;&gt;metadata&lt;/span&gt;?.&lt;span class=&quot;hljs-property&quot;&gt;chapter_num&lt;/span&gt; ?? &lt;span class=&quot;hljs-string&quot;&gt;&quot;未知&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;index&lt;/span&gt;: doc.&lt;span class=&quot;hljs-property&quot;&gt;metadata&lt;/span&gt;?.&lt;span class=&quot;hljs-property&quot;&gt;index&lt;/span&gt; ?? &lt;span class=&quot;hljs-string&quot;&gt;&quot;未知&quot;&lt;/span&gt;
    }));
  } &lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt;(error) {
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;检索内容时出错：&quot;&lt;/span&gt;, error.&lt;span class=&quot;hljs-property&quot;&gt;message&lt;/span&gt;);
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; [];
  }
}

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;retrieveNode&lt;/span&gt; = &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; (&lt;span class=&quot;hljs-params&quot;&gt;state&lt;/span&gt;) =&amp;gt; {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; documents = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;retrieveRelevantContent&lt;/span&gt;(state.&lt;span class=&quot;hljs-property&quot;&gt;question&lt;/span&gt;, state.&lt;span class=&quot;hljs-property&quot;&gt;k&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { documents }; &lt;span class=&quot;hljs-comment&quot;&gt;// 更新状态中的 documents&lt;/span&gt;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-6&quot;&gt;3. 生成节点&lt;/h4&gt;
&lt;p&gt;把检索到的文档拼接成上下文，构造 Prompt 交给 LLM 流式生成答案。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;generateNode&lt;/span&gt; = &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; (&lt;span class=&quot;hljs-params&quot;&gt;state&lt;/span&gt;) =&amp;gt; {
  &lt;span class=&quot;hljs-comment&quot;&gt;// 拼接上下文，标注片段序号和章节&lt;/span&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; context = state.&lt;span class=&quot;hljs-property&quot;&gt;documents&lt;/span&gt;
    .&lt;span class=&quot;hljs-title function_&quot;&gt;map&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;item, i&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;`[片段 &lt;span class=&quot;hljs-subst&quot;&gt;${i+&lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;}&lt;/span&gt;]
    章节: 第 &lt;span class=&quot;hljs-subst&quot;&gt;${item.chapter_num}&lt;/span&gt;章
    内容：&lt;span class=&quot;hljs-subst&quot;&gt;${item.content}&lt;/span&gt;
    `&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;join&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;\n\n----------\n\n&quot;&lt;/span&gt;);

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; prompt = &lt;span class=&quot;hljs-string&quot;&gt;`
  你是一个专业的《天龙八部》小说助手。基于小说内容回答问题，用准确，详细的语言。
  请根据以下《天龙八部》小说片段内容回答问题：
  &lt;span class=&quot;hljs-subst&quot;&gt;${context}&lt;/span&gt;
  用户问题：&lt;span class=&quot;hljs-subst&quot;&gt;${state.question}&lt;/span&gt;
  回答要求：
  1. 如果片段中有相关信息，请结合小说内容给出详细、准确的回答
  2. 可以综合多个片段的内容，提供完整的答案
  3. 如果片段中没有相关信息，请如实告知用户
  4. 回答要准确，符合小说的情节和人物设定
  5. 可以引用原文内容来支持你的回答
  AI 助手的回答：
  `&lt;/span&gt;;

  process.&lt;span class=&quot;hljs-property&quot;&gt;stdout&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;write&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;\n[AI回答（流式）]\n&quot;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; generation = &lt;span class=&quot;hljs-string&quot;&gt;&quot;&quot;&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; stream = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; model.&lt;span class=&quot;hljs-title function_&quot;&gt;stream&lt;/span&gt;(prompt);
  
  &lt;span class=&quot;hljs-comment&quot;&gt;// 流式输出，逐字打印&lt;/span&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; chunk &lt;span class=&quot;hljs-keyword&quot;&gt;of&lt;/span&gt; stream) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; text = &lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; chunk.&lt;span class=&quot;hljs-property&quot;&gt;content&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&quot;string&quot;&lt;/span&gt; ? chunk.&lt;span class=&quot;hljs-property&quot;&gt;content&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&quot;&quot;&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!text) &lt;span class=&quot;hljs-keyword&quot;&gt;continue&lt;/span&gt;;
    generation += text;
    process.&lt;span class=&quot;hljs-property&quot;&gt;stdout&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;write&lt;/span&gt;(text);
  }
  process.&lt;span class=&quot;hljs-property&quot;&gt;stdout&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;write&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;\n&quot;&lt;/span&gt;);

  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { generation };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-7&quot;&gt;4. 构建并编译图&lt;/h4&gt;
&lt;p&gt;把节点加入 StateGraph，定义边的连接关系，最后编译成可执行的图。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-arduino&quot; lang=&quot;arduino&quot;&gt;&lt;span class=&quot;hljs-type&quot;&gt;const&lt;/span&gt; graph = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;StateGraph&lt;/span&gt;(GraphState)
  .&lt;span class=&quot;hljs-built_in&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt;, retrieveNode)
  .&lt;span class=&quot;hljs-built_in&quot;&gt;addNode&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;generate&quot;&lt;/span&gt;, generateNode)
  .&lt;span class=&quot;hljs-built_in&quot;&gt;addEdge&lt;/span&gt;(START, &lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt;)    &lt;span class=&quot;hljs-comment&quot;&gt;// 起点 → 检索&lt;/span&gt;
  .&lt;span class=&quot;hljs-built_in&quot;&gt;addEdge&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;retrieve&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;generate&quot;&lt;/span&gt;) &lt;span class=&quot;hljs-comment&quot;&gt;// 检索 → 生成&lt;/span&gt;
  .&lt;span class=&quot;hljs-built_in&quot;&gt;addEdge&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;generate&quot;&lt;/span&gt;, END)      &lt;span class=&quot;hljs-comment&quot;&gt;// 生成 → 结束&lt;/span&gt;
  .&lt;span class=&quot;hljs-built_in&quot;&gt;compile&lt;/span&gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-8&quot;&gt;5. 主函数运行&lt;/h4&gt;
&lt;p&gt;连接 Milvus 向量库，传入问题调用图，输出检索结果和最终回答。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;main&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; question = &lt;span class=&quot;hljs-string&quot;&gt;&quot;阿朱的结局是什么？&quot;&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; kArg = &lt;span class=&quot;hljs-number&quot;&gt;5&lt;/span&gt;;

  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;连接到 Milvus...&quot;&lt;/span&gt;);
  &lt;span class=&quot;hljs-comment&quot;&gt;// 连接已有的向量集合&lt;/span&gt;
  vectorStore = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Milvus&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;fromExistingCollection&lt;/span&gt;(embeddings, {
    &lt;span class=&quot;hljs-attr&quot;&gt;collectionName&lt;/span&gt;: &lt;span class=&quot;hljs-variable constant_&quot;&gt;COLLECTION_NAME&lt;/span&gt;, 
    &lt;span class=&quot;hljs-attr&quot;&gt;url&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;localhost:19530&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;textField&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;content&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;primaryField&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;id&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;vectorField&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;vector&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;indexCreateOptions&lt;/span&gt;: {
      &lt;span class=&quot;hljs-attr&quot;&gt;metric_type&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;COSINE&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;index_type&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;HNSW&quot;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;param&lt;/span&gt;: { &lt;span class=&quot;hljs-attr&quot;&gt;M&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;16&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;efConstruction&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;200&lt;/span&gt; },
      &lt;span class=&quot;hljs-attr&quot;&gt;search_params&lt;/span&gt;: { &lt;span class=&quot;hljs-attr&quot;&gt;ef&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;64&lt;/span&gt; }
    }
  });

  vectorStore.&lt;span class=&quot;hljs-property&quot;&gt;indexSearchParams&lt;/span&gt; = {
    &lt;span class=&quot;hljs-attr&quot;&gt;metric_type&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;COSINE&quot;&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;params&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;stringify&lt;/span&gt;({&lt;span class=&quot;hljs-attr&quot;&gt;ef&lt;/span&gt;:&lt;span class=&quot;hljs-number&quot;&gt;64&lt;/span&gt;})
  }
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;已连接&#39;&lt;/span&gt;);

  &lt;span class=&quot;hljs-comment&quot;&gt;// 加载集合&lt;/span&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
    &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; vectorStore.&lt;span class=&quot;hljs-property&quot;&gt;client&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;loadCollection&lt;/span&gt;({&lt;span class=&quot;hljs-attr&quot;&gt;collection_name&lt;/span&gt;: &lt;span class=&quot;hljs-variable constant_&quot;&gt;COLLECTION_NAME&lt;/span&gt;})
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`集合 &lt;span class=&quot;hljs-subst&quot;&gt;${COLLECTION_NAME}&lt;/span&gt; 已加载`&lt;/span&gt;);
  } &lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt;(error) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!error.&lt;span class=&quot;hljs-property&quot;&gt;message&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;includes&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;already loaded&quot;&lt;/span&gt;)) &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; error;
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`集合已处于加载状态`&lt;/span&gt;);
  }

  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;=&quot;&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;repeat&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;80&lt;/span&gt;));
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`问题：&lt;span class=&quot;hljs-subst&quot;&gt;${question}&lt;/span&gt;`&lt;/span&gt;);
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;=&quot;&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;repeat&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;80&lt;/span&gt;));

  &lt;span class=&quot;hljs-comment&quot;&gt;// 调用图执行&lt;/span&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; result = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; graph.&lt;span class=&quot;hljs-title function_&quot;&gt;invoke&lt;/span&gt;({
    question,
    &lt;span class=&quot;hljs-attr&quot;&gt;k&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;Number&lt;/span&gt;.&lt;span class=&quot;hljs-built_in&quot;&gt;isFinite&lt;/span&gt;(kArg) ? kArg : &lt;span class=&quot;hljs-variable constant_&quot;&gt;TOP_K&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;documents&lt;/span&gt;: [],
    &lt;span class=&quot;hljs-attr&quot;&gt;generation&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;&quot;&lt;/span&gt;
  });

  &lt;span class=&quot;hljs-comment&quot;&gt;// 打印检索到的片段&lt;/span&gt;
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;\n检索相关内容：&quot;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (result.&lt;span class=&quot;hljs-property&quot;&gt;documents&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; === &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;) {
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;未找到相关内容&quot;&lt;/span&gt;);
  } &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; {
    result.&lt;span class=&quot;hljs-property&quot;&gt;documents&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;forEach&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;item, i&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`\n【片段 &lt;span class=&quot;hljs-subst&quot;&gt;${i+&lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;}&lt;/span&gt; 相似度:】&lt;span class=&quot;hljs-subst&quot;&gt;${item.score.toFixed(&lt;span class=&quot;hljs-number&quot;&gt;4&lt;/span&gt;)}&lt;/span&gt;`&lt;/span&gt;);
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`书籍: &lt;span class=&quot;hljs-subst&quot;&gt;${item.book_id}&lt;/span&gt;`&lt;/span&gt;);
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`章节: &lt;span class=&quot;hljs-subst&quot;&gt;${item.chapter_num}&lt;/span&gt;章`&lt;/span&gt;);
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`内容: &lt;span class=&quot;hljs-subst&quot;&gt;${item.content.substring(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;200&lt;/span&gt;)}&lt;/span&gt;...`&lt;/span&gt;);
    })
  }
}

&lt;span class=&quot;hljs-title function_&quot;&gt;main&lt;/span&gt;().&lt;span class=&quot;hljs-title function_&quot;&gt;catch&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-params&quot;&gt;err&lt;/span&gt; =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;error&lt;/span&gt;);
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-9&quot;&gt;四、运行效果&lt;/h3&gt;
&lt;p&gt;执行后会先输出检索到的 5 个最相关片段，然后流式输出最终答案：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-makefile&quot; lang=&quot;makefile&quot;&gt;================================================================================
问题：阿朱的结局是什么？
================================================================================

检索相关内容：

【片段 1 相似度:】0.8921
&lt;span class=&quot;hljs-section&quot;&gt;书籍: 1&lt;/span&gt;
&lt;span class=&quot;hljs-section&quot;&gt;章节: 23章&lt;/span&gt;
&lt;span class=&quot;hljs-section&quot;&gt;内容: 萧峰一掌击在阿朱胸口，阿朱身子直飞出去...&lt;/span&gt;

...（其余片段省略）

[AI回答（流式）]
阿朱最终死于萧峰的掌下。在小镜湖青石桥上，阿朱为了化解父亲段正淳与萧峰的仇怨，易容成段正淳的模样赴约，被悲愤中的萧峰一掌击中，最终死在萧峰怀中...
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-10&quot;&gt;五、基础 RAG 的局限&lt;/h3&gt;
&lt;p&gt;这版 RAG 虽然能跑通，但有明显的不足：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;所有问题都走检索&lt;/strong&gt;：哪怕是「1+1 等于几」这种常识，也要浪费一次向量检索&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;单轮检索能力有限&lt;/strong&gt;：遇到「段誉遇到的神仙姐姐画像是谁的弟子」这种多跳问题，一次检索很难命中答案&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;没有流程控制&lt;/strong&gt;：无法根据中间结果动态调整策略&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;下一篇，我们就来解决第一个问题：&lt;strong&gt;给 RAG 加上智能路由，让常识问题直接回答，复杂问题才走检索&lt;/strong&gt;。&lt;/p&gt;</description><link>https://juejin.cn/post/7697558807295541290</link><guid isPermaLink="false">https://juejin.cn/post/7697558807295541290</guid><pubDate>Sun, 18 Oct 2026 08:07:38 GMT</pubDate><author>YIAN</author><category>人工智能</category><category>JavaScript</category><category>LangChain</category></item><item><title>前置篇：手把手用 Milvus + LangChain 搭建小说向量知识库，《天龙八部》EPUB 一键入库</title><description>&lt;p&gt;本系列共四篇，从零到一完成从向量知识库搭建到多跳推理 RAG 的完整演进：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;前置篇&lt;/strong&gt;：Milvus 向量知识库搭建，《天龙八部》EPUB 一键入库&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;入门篇&lt;/strong&gt;：LangGraph 基础单轮 RAG，检索→生成最简流程&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;进阶篇&lt;/strong&gt;：智能路由分支 RAG，问题自动分流，常识直答、复杂检索&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;高级篇&lt;/strong&gt;：多跳推理 RAG，子问题拆解 + 循环检索 + 智能规划&lt;/li&gt;
&lt;/ol&gt;
&lt;hr&gt;
&lt;h2 data-id=&quot;heading-0&quot;&gt;前置篇：手把手用 Milvus + LangChain 搭建小说向量知识库，《天龙八部》EPUB 一键入库&lt;/h2&gt;
&lt;h3 data-id=&quot;heading-1&quot;&gt;前言&lt;/h3&gt;
&lt;p&gt;做 RAG（检索增强生成）的第一步，永远是搭建自己的向量知识库。很多人入门时卡在「怎么把自己的文档变成向量存进去」，尤其是结构化的电子书，直接扔进去不仅检索不准，还浪费算力。&lt;/p&gt;
&lt;p&gt;本文就以经典小说《天龙八部》EPUB 为例，从零到一带你完成：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;EPUB 电子书按章节加载与智能切片&lt;/li&gt;
&lt;li&gt;文本向量化生成 Embedding&lt;/li&gt;
&lt;li&gt;Milvus 向量数据库集合设计与索引创建&lt;/li&gt;
&lt;li&gt;流式批量插入，边处理边入库&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;全程基于 Node.js + LangChain + Milvus 原生 SDK，代码可直接运行，跑完就能拥有一个可用于 RAG 问答的小说向量库。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-2&quot;&gt;一、技术栈与前置准备&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-3&quot;&gt;核心依赖&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;@zilliz/milvus2-sdk-node&lt;/strong&gt;：Milvus 官方 Node.js SDK，用于集合管理与数据插入&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;@langchain/openai&lt;/strong&gt;：调用 Embedding 模型生成向量&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;@langchain/community/document_loaders/fs/epub&lt;/strong&gt;：EPUB 文件加载器，自动按章节拆分&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;@langchain/textsplitters&lt;/strong&gt;：递归字符文本拆分器，控制切片长度&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;dotenv&lt;/strong&gt;：环境变量管理&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-id=&quot;heading-4&quot;&gt;本地环境&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;本地启动 Milvus 服务（默认端口 19530，推荐 Docker 一键启动）&lt;/li&gt;
&lt;li&gt;可用的 OpenAI 格式 Embedding 接口&lt;/li&gt;
&lt;li&gt;准备好《天龙八部》EPUB 格式电子书&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-id=&quot;heading-5&quot;&gt;二、核心设计思路&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-6&quot;&gt;1. 为什么要做文本切片？&lt;/h4&gt;
&lt;p&gt;大段整章文本直接向量化会导致：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;向量语义过于宽泛，检索命中率低&lt;/li&gt;
&lt;li&gt;单次检索上下文过长，干扰生成答案&lt;/li&gt;
&lt;li&gt;超出 Embedding 模型最大输入长度&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;我们采用&lt;strong&gt;按章节拆分 + 二次固定长度切片&lt;/strong&gt;的策略，每片 500 字符，保留 50 字符重叠，保证上下文连贯性。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-7&quot;&gt;2. Milvus 集合字段设计&lt;/h4&gt;
&lt;p&gt;表格&lt;/p&gt;













































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;字段名&lt;/th&gt;&lt;th&gt;类型&lt;/th&gt;&lt;th&gt;作用&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;id&lt;/td&gt;&lt;td&gt;VarChar(100)&lt;/td&gt;&lt;td&gt;主键，由「书籍 ID_章节号_切片序号」组成，全局唯一&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;book_id&lt;/td&gt;&lt;td&gt;VarChar(100)&lt;/td&gt;&lt;td&gt;书籍 ID，支持多书籍混合入库&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;book_name&lt;/td&gt;&lt;td&gt;VarChar(200)&lt;/td&gt;&lt;td&gt;书籍名称&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;chapter_num&lt;/td&gt;&lt;td&gt;Int32&lt;/td&gt;&lt;td&gt;章节号，便于定位原文&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;index&lt;/td&gt;&lt;td&gt;Int32&lt;/td&gt;&lt;td&gt;章节内切片序号&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;content&lt;/td&gt;&lt;td&gt;VarChar(10000)&lt;/td&gt;&lt;td&gt;切片原文内容，用于生成答案&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;vector&lt;/td&gt;&lt;td&gt;FloatVector(1024)&lt;/td&gt;&lt;td&gt;文本向量，用于相似度检索&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h4 data-id=&quot;heading-8&quot;&gt;3. 索引选择&lt;/h4&gt;
&lt;p&gt;采用 &lt;strong&gt;IVF_FLAT 索引 + 余弦相似度（COSINE）&lt;/strong&gt; ，把向量聚类分桶，检索时只比对最相关的桶，在保证准确率的同时大幅提升检索速度。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-9&quot;&gt;三、完整代码逐段解析&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-10&quot;&gt;1. 依赖导入与常量定义&lt;/h4&gt;
&lt;p&gt;首先导入所需模块，定义全局常量，方便后续统一调整参数。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;dotenv/config&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { parse } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;node:path&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { 
  &lt;span class=&quot;hljs-title class_&quot;&gt;MilvusClient&lt;/span&gt;, 
  &lt;span class=&quot;hljs-title class_&quot;&gt;DataType&lt;/span&gt;,
  &lt;span class=&quot;hljs-title class_&quot;&gt;MetricType&lt;/span&gt;,
  &lt;span class=&quot;hljs-title class_&quot;&gt;IndexType&lt;/span&gt; 
} &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;@zilliz/milvus2-sdk-node&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { &lt;span class=&quot;hljs-title class_&quot;&gt;OpenAIEmbeddings&lt;/span&gt; } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@langchain/openai&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { &lt;span class=&quot;hljs-title class_&quot;&gt;EPubLoader&lt;/span&gt; } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@langchain/community/document_loaders/fs/epub&quot;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { &lt;span class=&quot;hljs-title class_&quot;&gt;RecursiveCharacterTextSplitter&lt;/span&gt; } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;@langchain/textsplitters&quot;&lt;/span&gt;;

&lt;span class=&quot;hljs-comment&quot;&gt;// 全局配置&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;COLLECTION_NAME&lt;/span&gt; = &lt;span class=&quot;hljs-string&quot;&gt;&#39;ebook_collection&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;VECTOR_DIM&lt;/span&gt; = &lt;span class=&quot;hljs-number&quot;&gt;1024&lt;/span&gt;;          &lt;span class=&quot;hljs-comment&quot;&gt;// 向量维度，与 Embedding 模型对应&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;CHUNK_SIZE&lt;/span&gt; = &lt;span class=&quot;hljs-number&quot;&gt;500&lt;/span&gt;;           &lt;span class=&quot;hljs-comment&quot;&gt;// 单切片字符数&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;EPUB_FILE&lt;/span&gt; = &lt;span class=&quot;hljs-string&quot;&gt;&#39;./天龙八部.epub&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;BOOK_NAME&lt;/span&gt; = &lt;span class=&quot;hljs-title function_&quot;&gt;parse&lt;/span&gt;(&lt;span class=&quot;hljs-variable constant_&quot;&gt;EPUB_FILE&lt;/span&gt;).&lt;span class=&quot;hljs-property&quot;&gt;name&lt;/span&gt;; &lt;span class=&quot;hljs-comment&quot;&gt;// 从文件名提取书名&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-11&quot;&gt;2. 初始化 Embedding 与 Milvus 客户端&lt;/h4&gt;
&lt;p&gt;Embedding 模型负责把文本转成 1024 维向量；Milvus 客户端连接本地向量数据库。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-arduino&quot; lang=&quot;arduino&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// 初始化向量模型&lt;/span&gt;
&lt;span class=&quot;hljs-type&quot;&gt;const&lt;/span&gt; embeddings = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;OpenAIEmbeddings&lt;/span&gt;({
  apiKey: process.env.OPENAI_API_KEY,
  model: process.env.EMBEDDINGS_MODEL_NAME,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL
  },
  dimensions: VECTOR_DIM
});

&lt;span class=&quot;hljs-comment&quot;&gt;// 初始化 Milvus 客户端&lt;/span&gt;
&lt;span class=&quot;hljs-type&quot;&gt;const&lt;/span&gt; client = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;MilvusClient&lt;/span&gt;({
  address: &lt;span class=&quot;hljs-string&quot;&gt;&#39;localhost:19530&#39;&lt;/span&gt;
});

&lt;span class=&quot;hljs-comment&quot;&gt;// 封装向量生成方法&lt;/span&gt;
&lt;span class=&quot;hljs-function&quot;&gt;async function &lt;span class=&quot;hljs-title&quot;&gt;getEmbedding&lt;/span&gt;&lt;span class=&quot;hljs-params&quot;&gt;(text)&lt;/span&gt; &lt;/span&gt;{
  &lt;span class=&quot;hljs-type&quot;&gt;const&lt;/span&gt; result = await embeddings.&lt;span class=&quot;hljs-built_in&quot;&gt;embedQuery&lt;/span&gt;(text);
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; result;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-12&quot;&gt;3. 创建集合与索引&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;ensureCollection&lt;/code&gt; 函数负责幂等性创建集合：不存在则创建，存在则跳过，最后保证集合处于加载状态。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-php&quot; lang=&quot;php&quot;&gt;async &lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;ensureCollection&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;bookId&lt;/span&gt;) &lt;/span&gt;{
  &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
    &lt;span class=&quot;hljs-comment&quot;&gt;// 检查集合是否已存在&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-variable constant_&quot;&gt;hasCollection&lt;/span&gt; = await client.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;hasCollection&lt;/span&gt;({
      &lt;span class=&quot;hljs-attr&quot;&gt;collection_name&lt;/span&gt;: COLLECTION_NAME
    });

    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!hasCollection.value) {
      console.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;创建集合...&#39;&lt;/span&gt;);
      &lt;span class=&quot;hljs-comment&quot;&gt;// 创建集合，定义所有字段&lt;/span&gt;
      await client.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;createCollection&lt;/span&gt;({
        &lt;span class=&quot;hljs-attr&quot;&gt;collection_name&lt;/span&gt;: COLLECTION_NAME,
        &lt;span class=&quot;hljs-attr&quot;&gt;fields&lt;/span&gt;: [
          { &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;id&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;data_type&lt;/span&gt;: DataType.VarChar, &lt;span class=&quot;hljs-attr&quot;&gt;max_length&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;100&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;is_primary_key&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt; },
          { &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;book_id&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;data_type&lt;/span&gt;: DataType.VarChar, &lt;span class=&quot;hljs-attr&quot;&gt;max_length&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;100&lt;/span&gt; },
          { &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;book_name&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;data_type&lt;/span&gt;: DataType.VarChar, &lt;span class=&quot;hljs-attr&quot;&gt;max_length&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;200&lt;/span&gt; },
          { &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;chapter_num&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;data_type&lt;/span&gt;: DataType.Int32 },
          { &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;index&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;data_type&lt;/span&gt;: DataType.Int32 },
          { &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;content&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;data_type&lt;/span&gt;: DataType.VarChar, &lt;span class=&quot;hljs-attr&quot;&gt;max_length&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;10000&lt;/span&gt; },
          { &lt;span class=&quot;hljs-attr&quot;&gt;name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;vector&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;data_type&lt;/span&gt;: DataType.FloatVector, &lt;span class=&quot;hljs-attr&quot;&gt;dim&lt;/span&gt;: VECTOR_DIM }
        ]
      });
      console.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;✓ 集合创建成功&#39;&lt;/span&gt;);

      &lt;span class=&quot;hljs-comment&quot;&gt;// 为向量字段创建索引&lt;/span&gt;
      console.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;创建索引...&#39;&lt;/span&gt;);
      await client.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;createIndex&lt;/span&gt;({
        &lt;span class=&quot;hljs-attr&quot;&gt;collection_name&lt;/span&gt;: COLLECTION_NAME,
        &lt;span class=&quot;hljs-attr&quot;&gt;field_name&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;vector&#39;&lt;/span&gt;,
        &lt;span class=&quot;hljs-attr&quot;&gt;index_type&lt;/span&gt;: IndexType.IVF_FLAT,
        &lt;span class=&quot;hljs-attr&quot;&gt;metric_type&lt;/span&gt;: MetricType.COSINE,
        &lt;span class=&quot;hljs-attr&quot;&gt;params&lt;/span&gt;: { &lt;span class=&quot;hljs-attr&quot;&gt;nlist&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;1024&lt;/span&gt; } // 聚类分桶数量
      });
      console.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;✓ 索引创建成功&#39;&lt;/span&gt;);
    } 
    
    &lt;span class=&quot;hljs-comment&quot;&gt;// 加载集合到内存，保证检索性能&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
      await client.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;loadCollection&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;collection_name&lt;/span&gt;: COLLECTION_NAME });
      console.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;✓ 集合已加载&#39;&lt;/span&gt;);
    } &lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt; (error) {
      console.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;✓ 集合已处于加载状态&#39;&lt;/span&gt;);
    }
  } &lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt; (error) {
    console.&lt;span class=&quot;hljs-title function_ invoke__&quot;&gt;error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;创建集合时出错:&#39;&lt;/span&gt;, error.message);
    &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; error;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-13&quot;&gt;4. 批量插入数据&lt;/h4&gt;
&lt;p&gt;&lt;code&gt;insertChunksBatch&lt;/code&gt; 接收一个章节的所有切片，并行生成向量，组装成 Milvus 要求的数据格式，批量插入。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;insertChunksBatch&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;chunks, bookId, chapterNum&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (chunks.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; === &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;;

    &lt;span class=&quot;hljs-comment&quot;&gt;// 并行生成每个切片的向量&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; insertData = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Promise&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;all&lt;/span&gt;(
      chunks.&lt;span class=&quot;hljs-title function_&quot;&gt;map&lt;/span&gt;(&lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; (chunk, chunkIndex) =&amp;gt; {
        &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; vector = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;getEmbedding&lt;/span&gt;(chunk);
        &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; {
          &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${bookId}&lt;/span&gt;_&lt;span class=&quot;hljs-subst&quot;&gt;${chapterNum}&lt;/span&gt;_&lt;span class=&quot;hljs-subst&quot;&gt;${chunkIndex}&lt;/span&gt;`&lt;/span&gt;,
          &lt;span class=&quot;hljs-attr&quot;&gt;book_id&lt;/span&gt;: bookId,
          &lt;span class=&quot;hljs-attr&quot;&gt;book_name&lt;/span&gt;: &lt;span class=&quot;hljs-variable constant_&quot;&gt;BOOK_NAME&lt;/span&gt;,
          &lt;span class=&quot;hljs-attr&quot;&gt;chapter_num&lt;/span&gt;: chapterNum,
          &lt;span class=&quot;hljs-attr&quot;&gt;index&lt;/span&gt;: chunkIndex,
          &lt;span class=&quot;hljs-attr&quot;&gt;content&lt;/span&gt;: chunk,
          &lt;span class=&quot;hljs-attr&quot;&gt;vector&lt;/span&gt;: vector
        };
      })
    );

    &lt;span class=&quot;hljs-comment&quot;&gt;// 批量插入&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; insertResult = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; client.&lt;span class=&quot;hljs-title function_&quot;&gt;insert&lt;/span&gt;({
      &lt;span class=&quot;hljs-attr&quot;&gt;collection_name&lt;/span&gt;: &lt;span class=&quot;hljs-variable constant_&quot;&gt;COLLECTION_NAME&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;data&lt;/span&gt;: insertData
    });

    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Number&lt;/span&gt;(insertResult.&lt;span class=&quot;hljs-property&quot;&gt;insert_cnt&lt;/span&gt;) || &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;;
  } &lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt; (error) {
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`插入章节 &lt;span class=&quot;hljs-subst&quot;&gt;${chapterNum}&lt;/span&gt; 的数据时出错:`&lt;/span&gt;, error.&lt;span class=&quot;hljs-property&quot;&gt;message&lt;/span&gt;);
    &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; error;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-14&quot;&gt;5. EPUB 流式处理&lt;/h4&gt;
&lt;p&gt;核心处理逻辑：加载 EPUB → 按章节拆分 → 逐章二次切片 → 边处理边插入。 采用流式处理的好处是不需要把整本书都加载到内存，处理大文件更稳定。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;loadAndProcessEPubStreaming&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;bookId&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`\n开始加载 EPUB 文件: &lt;span class=&quot;hljs-subst&quot;&gt;${EPUB_FILE}&lt;/span&gt;`&lt;/span&gt;);
    
    &lt;span class=&quot;hljs-comment&quot;&gt;// 加载 EPUB，自动按章节拆分&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; loader = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;EPubLoader&lt;/span&gt;(&lt;span class=&quot;hljs-variable constant_&quot;&gt;EPUB_FILE&lt;/span&gt;, { &lt;span class=&quot;hljs-attr&quot;&gt;splitChapters&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt; });
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; documents = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; loader.&lt;span class=&quot;hljs-title function_&quot;&gt;load&lt;/span&gt;();
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`✓ 加载完成，共 &lt;span class=&quot;hljs-subst&quot;&gt;${documents.length}&lt;/span&gt; 个章节\n`&lt;/span&gt;);

    &lt;span class=&quot;hljs-comment&quot;&gt;// 初始化文本拆分器&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; textSplitter = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;RecursiveCharacterTextSplitter&lt;/span&gt;({
      &lt;span class=&quot;hljs-attr&quot;&gt;chunkSize&lt;/span&gt;: &lt;span class=&quot;hljs-variable constant_&quot;&gt;CHUNK_SIZE&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;chunkOverlap&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;50&lt;/span&gt;, &lt;span class=&quot;hljs-comment&quot;&gt;// 相邻切片重叠 50 字符，避免语义断裂&lt;/span&gt;
    });

    &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; totalInserted = &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;;

    &lt;span class=&quot;hljs-comment&quot;&gt;// 逐章处理&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; chapterIndex = &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;; chapterIndex &amp;lt; documents.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt;; chapterIndex++) {
      &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; chapter = documents[chapterIndex];
      &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; chapterContent = chapter.&lt;span class=&quot;hljs-property&quot;&gt;pageContent&lt;/span&gt;;
      
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`处理第 &lt;span class=&quot;hljs-subst&quot;&gt;${chapterIndex + &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;}&lt;/span&gt;/&lt;span class=&quot;hljs-subst&quot;&gt;${documents.length}&lt;/span&gt; 章...`&lt;/span&gt;);
      
      &lt;span class=&quot;hljs-comment&quot;&gt;// 二次切片&lt;/span&gt;
      &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; chunks = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; textSplitter.&lt;span class=&quot;hljs-title function_&quot;&gt;splitText&lt;/span&gt;(chapterContent);
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`  拆分为 &lt;span class=&quot;hljs-subst&quot;&gt;${chunks.length}&lt;/span&gt; 个片段`&lt;/span&gt;);
      
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (chunks.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; === &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;) {
        &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`  跳过空章节\n`&lt;/span&gt;);
        &lt;span class=&quot;hljs-keyword&quot;&gt;continue&lt;/span&gt;;
      }

      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`  生成向量并插入中...`&lt;/span&gt;);
      &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; insertedCount = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;insertChunksBatch&lt;/span&gt;(chunks, bookId, chapterIndex + &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;);
      totalInserted += insertedCount;
      
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`  ✓ 已插入 &lt;span class=&quot;hljs-subst&quot;&gt;${insertedCount}&lt;/span&gt; 条记录（累计: &lt;span class=&quot;hljs-subst&quot;&gt;${totalInserted}&lt;/span&gt;）\n`&lt;/span&gt;);
    }

    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`\n总共插入 &lt;span class=&quot;hljs-subst&quot;&gt;${totalInserted}&lt;/span&gt; 条记录\n`&lt;/span&gt;);
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; totalInserted;
  } &lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt; (error) {
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;加载 EPUB 文件时出错:&#39;&lt;/span&gt;, error.&lt;span class=&quot;hljs-property&quot;&gt;message&lt;/span&gt;);
    &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; error;
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-15&quot;&gt;6. 主函数入口&lt;/h4&gt;
&lt;p&gt;串联整个流程：连接 Milvus → 确保集合存在 → 处理 EPUB 并入库。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-lua&quot; lang=&quot;lua&quot;&gt;async &lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title&quot;&gt;main&lt;/span&gt;&lt;span class=&quot;hljs-params&quot;&gt;()&lt;/span&gt;&lt;/span&gt; {
  try {
    console.&lt;span class=&quot;hljs-built_in&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;=&#39;&lt;/span&gt;.&lt;span class=&quot;hljs-keyword&quot;&gt;repeat&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;80&lt;/span&gt;));
    console.&lt;span class=&quot;hljs-built_in&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;电子书向量知识库构建工具&#39;&lt;/span&gt;);
    console.&lt;span class=&quot;hljs-built_in&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;=&#39;&lt;/span&gt;.&lt;span class=&quot;hljs-keyword&quot;&gt;repeat&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;80&lt;/span&gt;));

    console.&lt;span class=&quot;hljs-built_in&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;\n连接 Milvus...&#39;&lt;/span&gt;);
    await client.connectPromise;
    console.&lt;span class=&quot;hljs-built_in&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;✓ 已连接\n&#39;&lt;/span&gt;);

    const bookId = &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;;
    await ensureCollection(bookId);
    await loadAndProcessEPubStreaming(bookId);

    console.&lt;span class=&quot;hljs-built_in&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;=&#39;&lt;/span&gt;.&lt;span class=&quot;hljs-keyword&quot;&gt;repeat&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;80&lt;/span&gt;));
    console.&lt;span class=&quot;hljs-built_in&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;处理完成！向量知识库已就绪&#39;&lt;/span&gt;);
    console.&lt;span class=&quot;hljs-built_in&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;=&#39;&lt;/span&gt;.&lt;span class=&quot;hljs-keyword&quot;&gt;repeat&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;80&lt;/span&gt;));
  } catch (&lt;span class=&quot;hljs-built_in&quot;&gt;error&lt;/span&gt;) {
    console.&lt;span class=&quot;hljs-built_in&quot;&gt;error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;\n错误:&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-built_in&quot;&gt;error&lt;/span&gt;.message);
    console.&lt;span class=&quot;hljs-built_in&quot;&gt;error&lt;/span&gt;(&lt;span class=&quot;hljs-built_in&quot;&gt;error&lt;/span&gt;.stack);
    process.&lt;span class=&quot;hljs-built_in&quot;&gt;exit&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;);
  }
}

main();
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-16&quot;&gt;四、运行效果&lt;/h3&gt;
&lt;p&gt;执行脚本后，控制台会输出完整的处理进度：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-markdown&quot; lang=&quot;markdown&quot;&gt;================================================================================
&lt;span class=&quot;hljs-section&quot;&gt;电子书向量知识库构建工具
================================================================================&lt;/span&gt;

连接 Milvus...
✓ 已连接

创建集合...
✓ 集合创建成功
创建索引...
✓ 索引创建成功
✓ 集合已加载

开始加载 EPUB 文件: ./天龙八部.epub
✓ 加载完成，共 50 个章节

处理第 1/50 章...
  拆分为 12 个片段
  生成向量并插入中...
  ✓ 已插入 12 条记录（累计: 12）

...（中间章节省略）

总共插入 1286 条记录

================================================================================
&lt;span class=&quot;hljs-section&quot;&gt;处理完成！向量知识库已就绪
================================================================================&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-17&quot;&gt;五、总结与预告&lt;/h3&gt;
&lt;p&gt;至此，我们已经把《天龙八部》整本书变成了 1000+ 条带向量的文本片段，存入了 Milvus 向量库。这是所有 RAG 应用的地基，接下来的问答系统都将基于这个知识库运行。&lt;/p&gt;
&lt;p&gt;下一篇，我们将基于这个向量库，用 LangGraph 搭建&lt;strong&gt;最简单的单轮 RAG 问答系统&lt;/strong&gt;，实现「提问 → 向量检索 → LLM 生成答案」的完整流程。&lt;/p&gt;</description><link>https://juejin.cn/post/7697531208897101865</link><guid isPermaLink="false">https://juejin.cn/post/7697531208897101865</guid><pubDate>Sun, 18 Oct 2026 08:06:54 GMT</pubDate><author>YIAN</author><category>人工智能</category><category>JavaScript</category><category>LangChain</category></item><item><title>React 任务轮询，为什么不能只写一个 setInterval？</title><description>&lt;p&gt;假设任务页面每秒查询一次进度，而某次请求要两秒多才返回。这时继续按固定节奏发请求，同一个任务就可能出现多次查询同时在途。再切到另一个任务，旧请求的返回还可能更新页面。&lt;/p&gt;
&lt;p&gt;第六篇讨论后台任务如何等待和重试。这一篇看浏览器一侧：查询什么时候发起，什么时候结束，页面离开后谁还可以提交结果。&lt;/p&gt;
&lt;p&gt;Vimorph 的视频翻译、音色设计页面都有轮询调用。本文从当前共享函数的边界出发，提供独立的 React + TypeScript 示例，补上取消和旧结果保护；示例使用本地替身，不调用业务接口，也不代表产品页面已经具备下面的全部保护。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-0&quot;&gt;固定间隔不会等待异步回调&lt;/h2&gt;
&lt;p&gt;setInterval 会按自己的节奏调用回调。把回调写成 async，并不会让定时器等它返回的 Promise 完成后再计时。对耗时不稳定的查询，可以选择“本轮结束，再等一个间隔”的调度方式。&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FWindow%2FsetInterval%23ensure_that_execution_duration_is_shorter_than_interval_frequency&quot; target=&quot;_blank&quot; title=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Window/setInterval#ensure_that_execution_duration_is_shorter_than_interval_frequency&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;MDN 的说明&lt;/a&gt;也讨论了耗时超过间隔的情况。&lt;/p&gt;
&lt;p&gt;这样得到的是请求间的休息时间，不是严格每秒一次。假设查询花 2.5 秒，正常间隔设为 1 秒，下一次大约在这一轮开始 3.5 秒后发起；这些数字只解释调度关系，不是接口性能数据。&lt;/p&gt;
&lt;p&gt;轮询还需要明确结束条件：任务已完成，不再查询；临时查询失败，适当延后；页面不再关注这个任务，停止本地轮询。最后一种情况不需要把服务器任务也标成失败。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-1&quot;&gt;共享函数的能力与页面接入&lt;/h2&gt;
&lt;p&gt;当前 &lt;code&gt;frontend/lib/polling.ts&lt;/code&gt; 中的 startPolling 已经在 await tick() 之后安排下一轮，并用局部 inFlight 防止同一实例重叠。tick 返回 false 时停止；抛错时进入退避分支。&lt;/p&gt;
&lt;p&gt;这里的“同一实例”很关键。React Effect 清理旧实例后，可以建立新实例；两者没有共享的请求锁。现有 stop() 清理调度与可见性监听，不会取消已经执行的 tick，也不会拦截 tick 内部的 setState。&lt;/p&gt;
&lt;p&gt;错误能否到达调度器同样取决于接入方式。当前部分视频翻译回调内部 catch 后正常返回，外层就收不到拒绝，不能据此触发错误退避。本文示例让查询错误继续抛出，由轮询器统一安排下一次查询。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-2&quot;&gt;请求结束后再安排下一次&lt;/h2&gt;
&lt;p&gt;本例把轮询器缩到一个实例、一条调用链。首次通过零延时定时器启动，之后只在当前 tick 结算后建立下一次定时器，不再提供手动并行执行入口。&lt;/p&gt;





















&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;操作&lt;/th&gt;&lt;th&gt;职责&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;清除定时器&lt;/td&gt;&lt;td&gt;撤销尚未开始的下一轮&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;AbortController.abort()&lt;/td&gt;&lt;td&gt;通知配合 AbortSignal 的请求停止&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;返回后检查 signal&lt;/td&gt;&lt;td&gt;阻止失效实例继续更新页面&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/1013cf02162341068b7bee4ba0c61ddf~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgVmltb3JwaFRlY2g=:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792855990&amp;amp;x-signature=9ObvGMXqaZ2FmiimclGpVC2aFig%3D&quot; alt=&quot;task-polling-lifecycle.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;下面是完整的 &lt;code&gt;src/poller.ts&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;PollingOptions&lt;/span&gt; = {
  &lt;span class=&quot;hljs-attr&quot;&gt;intervalMs&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;;
  &lt;span class=&quot;hljs-attr&quot;&gt;maxIntervalMs&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;;
  &lt;span class=&quot;hljs-comment&quot;&gt;// true: 继续查询；false: 已到终态，不再查询。&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;tick&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;signal: AbortSignal&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-built_in&quot;&gt;boolean&lt;/span&gt;&amp;gt;;
  &lt;span class=&quot;hljs-attr&quot;&gt;onError&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;error: &lt;span class=&quot;hljs-built_in&quot;&gt;unknown&lt;/span&gt;, nextDelayMs: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;&lt;/span&gt;) =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt; | &lt;span class=&quot;hljs-title class_&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt;&amp;gt;;
};

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;startPolling&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;options: PollingOptions&lt;/span&gt;): { &lt;span class=&quot;hljs-attr&quot;&gt;stop&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt; } {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; { intervalMs, maxIntervalMs, tick, onError } = options;
  &lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; value &lt;span class=&quot;hljs-keyword&quot;&gt;of&lt;/span&gt; [intervalMs, maxIntervalMs]) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!&lt;span class=&quot;hljs-title class_&quot;&gt;Number&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;isSafeInteger&lt;/span&gt;(value) || value &amp;lt;= &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt; || value &amp;gt; &lt;span class=&quot;hljs-number&quot;&gt;2_147_483_647&lt;/span&gt;) {
      &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;RangeError&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;轮询间隔必须是有效的正整数毫秒数&#39;&lt;/span&gt;);
    }
  }
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (maxIntervalMs &amp;lt; intervalMs) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;RangeError&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;最大退避间隔不能小于正常查询间隔&#39;&lt;/span&gt;);
  }

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; controller = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;AbortController&lt;/span&gt;();
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; stopped = &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;timer&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;ReturnType&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;setTimeout&lt;/span&gt;&amp;gt; | &lt;span class=&quot;hljs-literal&quot;&gt;undefined&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; delayMs = intervalMs;

  &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;stop&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (stopped) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
    stopped = &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (timer !== &lt;span class=&quot;hljs-literal&quot;&gt;undefined&lt;/span&gt;) &lt;span class=&quot;hljs-built_in&quot;&gt;clearTimeout&lt;/span&gt;(timer);
    timer = &lt;span class=&quot;hljs-literal&quot;&gt;undefined&lt;/span&gt;;
    controller.&lt;span class=&quot;hljs-title function_&quot;&gt;abort&lt;/span&gt;();
  }

  &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;run&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) {
    timer = &lt;span class=&quot;hljs-literal&quot;&gt;undefined&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (stopped) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;keepPolling&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;boolean&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
      keepPolling = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;tick&lt;/span&gt;(controller.&lt;span class=&quot;hljs-property&quot;&gt;signal&lt;/span&gt;);
    } &lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt; (error) {
      &lt;span class=&quot;hljs-comment&quot;&gt;// 只依据本实例是否停止，不凭错误名猜测取消。&lt;/span&gt;
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (stopped) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
      delayMs = &lt;span class=&quot;hljs-title class_&quot;&gt;Math&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;min&lt;/span&gt;(delayMs * &lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;, maxIntervalMs);
      &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
        &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;onError&lt;/span&gt;(error, delayMs);
      } &lt;span class=&quot;hljs-keyword&quot;&gt;catch&lt;/span&gt; {
        &lt;span class=&quot;hljs-comment&quot;&gt;// 错误展示回调自身失败时，停止实例，避免未处理的 Promise 拒绝。&lt;/span&gt;
        &lt;span class=&quot;hljs-title function_&quot;&gt;stop&lt;/span&gt;();
        &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
      }
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!stopped) timer = &lt;span class=&quot;hljs-built_in&quot;&gt;setTimeout&lt;/span&gt;(run, delayMs);
      &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
    }
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (stopped) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!keepPolling) {
      &lt;span class=&quot;hljs-title function_&quot;&gt;stop&lt;/span&gt;();
      &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
    }
    delayMs = intervalMs;
    timer = &lt;span class=&quot;hljs-built_in&quot;&gt;setTimeout&lt;/span&gt;(run, delayMs);
  }

  &lt;span class=&quot;hljs-comment&quot;&gt;// 首轮也经过调度，effect 在执行前清理时可以撤销它。&lt;/span&gt;
  timer = &lt;span class=&quot;hljs-built_in&quot;&gt;setTimeout&lt;/span&gt;(run, &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { stop };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;正常查询后恢复 intervalMs；查询失败后延迟翻倍，受 maxIntervalMs 限制。示例页面使用 1 秒基础间隔和 8 秒上限，因此连续故障的等待间隔为 2、4、8、8 秒。间隔参数要求处于有效范围的正安全整数；最大间隔不能小于基础间隔，两者相等时按固定间隔运行。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;指数退避只控制重试频率，不会自动判断错误是否应该重试，也不会自动限制总重试次数。&lt;/strong&gt; 如果错误持续发生，达到8秒上限后仍会继续尝试。调用方可以在 &lt;code&gt;onError&lt;/code&gt; 中调用本次轮询返回的 &lt;code&gt;stop()&lt;/code&gt;，处理已确认不可恢复的错误，或落实失败预算、任务截止时间。本例没有内置HTTP错误分类；429、503和网络异常是否重试，都应结合接口协议与当前业务判断。&lt;/p&gt;
&lt;p&gt;stop 先设置停止标记，再清定时器和中止 signal。即使在途 Promise 后来才 resolve 或 reject，run 也会返回，不再安排下一轮。轮询器会等待 onError 通知结算；通知自身抛错或拒绝就停止实例，避免产生未处理的拒绝。在通知期间调用 stop，结算后也不会重新启动轮询。页面中的通知只是同步更新提示。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;AbortSignal&lt;/code&gt; 只是取消通知。底层查询不配合时，已经开始的Promise可能继续执行；stop不能强制终止任意Promise。停止后不再调度，由轮询器负责；阻止旧结果更新页面，还需要下面的结果归属检查。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-3&quot;&gt;在 React 中限定结果的归属&lt;/h2&gt;
&lt;p&gt;调度器只能决定何时再运行，无法撤回 tick 已经做过的页面更新。所以检查必须放在 await query(signal) 后、setState 前。&lt;/p&gt;
&lt;p&gt;每次 Effect 创建自己的轮询器，也就拥有独立的 signal。cleanup 调用 stop 后，这个旧 signal 会一直保持 aborted；新实例使用另一个 signal。即使查询实现忽略取消，旧回调仍会放弃结果。&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Freact.dev%2Freference%2Freact%2FuseEffect%23fetching-data-with-effects&quot; target=&quot;_blank&quot; title=&quot;https://react.dev/reference/react/useEffect#fetching-data-with-effects&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;React 官方示例&lt;/a&gt;也在清理后忽略过时响应。&lt;/p&gt;
&lt;p&gt;以下为完整的 &lt;code&gt;src/App.tsx&lt;/code&gt;。它依赖上面的 poller 和下一节的 fake-api；TaskPanel 随 task.id 变化重新挂载，停止、重新查询和卸载按钮都在这份文件中。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-tsx&quot; lang=&quot;tsx&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { useEffect, useState } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;react&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { createFakeQuery, &lt;span class=&quot;hljs-keyword&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Mode&lt;/span&gt;, &lt;span class=&quot;hljs-keyword&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Task&lt;/span&gt;, &lt;span class=&quot;hljs-keyword&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;TaskSnapshot&lt;/span&gt; } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;./fake-api&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { startPolling } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;./poller&#39;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;labels&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;Record&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-title class_&quot;&gt;Mode&lt;/span&gt;, &lt;span class=&quot;hljs-built_in&quot;&gt;string&lt;/span&gt;&amp;gt; = {
  &lt;span class=&quot;hljs-attr&quot;&gt;normal&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;正常查询&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;slow&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;慢请求（2.5 秒）&#39;&lt;/span&gt;,
  &lt;span class=&quot;hljs-attr&quot;&gt;flaky&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;前两次失败&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;stubborn&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;忽略取消的慢请求&#39;&lt;/span&gt;,
};

&lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;TaskPanel&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;{ task }: { task: Task }&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [snapshot, setSnapshot] = useState&amp;lt;&lt;span class=&quot;hljs-title class_&quot;&gt;TaskSnapshot&lt;/span&gt; | &lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;&amp;gt;(&lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [message, setMessage] = &lt;span class=&quot;hljs-title function_&quot;&gt;useState&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;等待首次查询&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [requestCount, setRequestCount] = &lt;span class=&quot;hljs-title function_&quot;&gt;useState&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [stopped, setStopped] = &lt;span class=&quot;hljs-title function_&quot;&gt;useState&lt;/span&gt;(&lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [session, setSession] = &lt;span class=&quot;hljs-title function_&quot;&gt;useState&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;);

  &lt;span class=&quot;hljs-title function_&quot;&gt;useEffect&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
    &lt;span class=&quot;hljs-title function_&quot;&gt;setMessage&lt;/span&gt;(stopped ? &lt;span class=&quot;hljs-string&quot;&gt;&#39;已停止本地查询&#39;&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&#39;等待首次查询&#39;&lt;/span&gt;);
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (stopped) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
    &lt;span class=&quot;hljs-title function_&quot;&gt;setSnapshot&lt;/span&gt;(&lt;span class=&quot;hljs-literal&quot;&gt;null&lt;/span&gt;);
    &lt;span class=&quot;hljs-title function_&quot;&gt;setRequestCount&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;);
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; query = &lt;span class=&quot;hljs-title function_&quot;&gt;createFakeQuery&lt;/span&gt;(task);
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; polling = &lt;span class=&quot;hljs-title function_&quot;&gt;startPolling&lt;/span&gt;({
      &lt;span class=&quot;hljs-attr&quot;&gt;intervalMs&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;1000&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;maxIntervalMs&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;8000&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;tick&lt;/span&gt;: &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; (signal) =&amp;gt; {
        &lt;span class=&quot;hljs-title function_&quot;&gt;setRequestCount&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;value&lt;/span&gt;) =&amp;gt;&lt;/span&gt; value + &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;);
        &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; result = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;query&lt;/span&gt;(signal);
        &lt;span class=&quot;hljs-comment&quot;&gt;// 请求可能没有响应 abort；旧实例仍必须放弃更新页面。&lt;/span&gt;
        &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (signal.&lt;span class=&quot;hljs-property&quot;&gt;aborted&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
        &lt;span class=&quot;hljs-title function_&quot;&gt;setSnapshot&lt;/span&gt;(result);
        &lt;span class=&quot;hljs-title function_&quot;&gt;setMessage&lt;/span&gt;(result.&lt;span class=&quot;hljs-property&quot;&gt;status&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;succeeded&#39;&lt;/span&gt; ? &lt;span class=&quot;hljs-string&quot;&gt;&#39;任务完成，停止查询&#39;&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&#39;处理中，等待下次查询&#39;&lt;/span&gt;);
        &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; result.&lt;span class=&quot;hljs-property&quot;&gt;status&lt;/span&gt; !== &lt;span class=&quot;hljs-string&quot;&gt;&#39;succeeded&#39;&lt;/span&gt;;
      },
      &lt;span class=&quot;hljs-attr&quot;&gt;onError&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;error, nextDelayMs&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
        &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; detail = error &lt;span class=&quot;hljs-keyword&quot;&gt;instanceof&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Error&lt;/span&gt; ? error.&lt;span class=&quot;hljs-property&quot;&gt;message&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&#39;查询失败&#39;&lt;/span&gt;;
        &lt;span class=&quot;hljs-title function_&quot;&gt;setMessage&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${detail}&lt;/span&gt;；&lt;span class=&quot;hljs-subst&quot;&gt;${nextDelayMs / &lt;span class=&quot;hljs-number&quot;&gt;1000&lt;/span&gt;}&lt;/span&gt; 秒后重试`&lt;/span&gt;);
      },
    });
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; polling.&lt;span class=&quot;hljs-title function_&quot;&gt;stop&lt;/span&gt;();
  }, [task, stopped, session]);

  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; (
    &lt;span class=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;section&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;panel&quot;&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;aria-labelledby&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;task-heading&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;task-top&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;h2&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;task-heading&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;任务 #{task.id}&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;h2&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;span&lt;/span&gt;&amp;gt;&lt;/span&gt;{labels[task.mode]}&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;span&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;state&quot;&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;role&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;status&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;{message}&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;progress&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;style&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;{{&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;width:&lt;/span&gt; `${&lt;span class=&quot;hljs-attr&quot;&gt;snapshot&lt;/span&gt;?&lt;span class=&quot;hljs-attr&quot;&gt;.progress&lt;/span&gt; ?? &lt;span class=&quot;hljs-attr&quot;&gt;0&lt;/span&gt;}%` }} /&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;stats&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;span&lt;/span&gt;&amp;gt;&lt;/span&gt;示意进度 &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;strong&lt;/span&gt;&amp;gt;&lt;/span&gt;{snapshot?.progress ?? 0}%&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;strong&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;span&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;span&lt;/span&gt;&amp;gt;&lt;/span&gt;当前实例请求数 &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;strong&lt;/span&gt;&amp;gt;&lt;/span&gt;{requestCount}&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;strong&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;span&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;actions&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;onClick&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;{()&lt;/span&gt; =&amp;gt;&lt;/span&gt; setStopped(true)} disabled={stopped}&amp;gt;停止查询&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt;&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;onClick&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;{()&lt;/span&gt; =&amp;gt;&lt;/span&gt; { setStopped(false); setSession((value) =&amp;gt; value + 1); }}&amp;gt;重新查询&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;hint&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;停止的是页面查询，不会取消服务器任务。重新查询会重置这个本地替身。&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;section&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
  );
}

&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;default&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;App&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [mode, setMode] = useState&amp;lt;&lt;span class=&quot;hljs-title class_&quot;&gt;Mode&lt;/span&gt;&amp;gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;normal&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [task, setTask] = useState&amp;lt;&lt;span class=&quot;hljs-title class_&quot;&gt;Task&lt;/span&gt;&amp;gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;mode&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;normal&#39;&lt;/span&gt; });
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [visible, setVisible] = &lt;span class=&quot;hljs-title function_&quot;&gt;useState&lt;/span&gt;(&lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; (
    &lt;span class=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;main&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;eyebrow&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;REACT + TYPESCRIPT · LOCAL DEMO&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;h1&lt;/span&gt;&amp;gt;&lt;/span&gt;让任务轮询有始有终&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;h1&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;intro&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;请求结束后再调度，失败时退避，离开后放弃旧结果。&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;toolbar&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;label&lt;/span&gt;&amp;gt;&lt;/span&gt;模拟场景&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;select&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;value&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;{mode}&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;onChange&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;{(event)&lt;/span&gt; =&amp;gt;&lt;/span&gt; setMode(event.target.value as Mode)}&amp;gt;
          {Object.entries(labels).map(([value, text]) =&amp;gt; &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;option&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;{value}&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;value&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;{value}&lt;/span&gt;&amp;gt;&lt;/span&gt;{text}&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;option&lt;/span&gt;&amp;gt;&lt;/span&gt;)}
        &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;select&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;label&lt;/span&gt;&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;primary&quot;&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;onClick&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;{()&lt;/span&gt; =&amp;gt;&lt;/span&gt; { setTask({ id: task.id + 1, mode }); setVisible(true); }}&amp;gt;切换到新任务&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt;&amp;gt;&lt;/span&gt;
        &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;onClick&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;{()&lt;/span&gt; =&amp;gt;&lt;/span&gt; setVisible((value) =&amp;gt; !value)}&amp;gt;{visible ? &#39;卸载任务面板&#39; : &#39;挂载任务面板&#39;}&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt;&amp;gt;&lt;/span&gt;
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;
      {visible ? &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;TaskPanel&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;key&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;{task.id}&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;task&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;{task}&lt;/span&gt; /&amp;gt;&lt;/span&gt; : &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;section&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;className&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;panel&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt;&amp;gt;&lt;/span&gt;任务面板已卸载，本地查询已停止。&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;section&lt;/span&gt;&amp;gt;&lt;/span&gt;}
      &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;aside&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;strong&lt;/span&gt;&amp;gt;&lt;/span&gt;可操作的检查&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;strong&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt;&amp;gt;&lt;/span&gt;选择慢请求，观察请求不会按每秒叠加。选择失败场景，观察 2 秒、4 秒退避。选择忽略取消的慢请求，启动后切换新任务，旧响应不会覆盖新任务。&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt;&amp;gt;&lt;/span&gt;所有状态来自受控本地替身；没有调用 Vimorph 业务接口。&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;p&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;aside&lt;/span&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;main&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;
  );
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里同时用了组件 key 和 signal 检查：前者让不同任务有各自的面板状态，后者阻止旧异步回调提交结果。Effect 依赖的是保持稳定的 task 对象；每次切换才创建新对象，不能在每次渲染时临时构造同一个任务的对象，否则会反复重建轮询。&lt;/p&gt;
&lt;p&gt;同一任务重新查询时，&lt;code&gt;session&lt;/code&gt; 会递增，促使旧Effect先执行cleanup，再建立新实例。因此，已经在查询时再次点击“重新查询”，也会替换旧实例。停止、重查与任务切换都使用同一条清理路径；每个实例自己的signal用于隔离晚到结果。&lt;/p&gt;
&lt;p&gt;取消只能减少支持取消的请求继续占用资源，不能代替结果归属检查。旧查询若不响应 abort，切换后仍可能与新实例的查询同时在途；本例保证旧实例不再提交结果，没有承诺跨实例的所有物理请求都互斥。&lt;/p&gt;
&lt;p&gt;错误分支不靠错误名称猜测用户取消。实例停止后，晚到的成功和错误都因结果已失效而被丢弃；这并不表示某个普通网络错误被重新分类成了取消错误。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-4&quot;&gt;用受控查询复现晚到响应&lt;/h2&gt;
&lt;p&gt;为了让几种时序可以重复观察，&lt;code&gt;createFakeQuery&lt;/code&gt; 提供正常、慢请求、前两次失败，以及故意忽略取消的慢请求四种模式。完整的 &lt;code&gt;src/fake-api.ts&lt;/code&gt; 如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Mode&lt;/span&gt; = &lt;span class=&quot;hljs-string&quot;&gt;&#39;normal&#39;&lt;/span&gt; | &lt;span class=&quot;hljs-string&quot;&gt;&#39;slow&#39;&lt;/span&gt; | &lt;span class=&quot;hljs-string&quot;&gt;&#39;flaky&#39;&lt;/span&gt; | &lt;span class=&quot;hljs-string&quot;&gt;&#39;stubborn&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Task&lt;/span&gt; = { &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;; &lt;span class=&quot;hljs-attr&quot;&gt;mode&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;Mode&lt;/span&gt; };
&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;type&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;TaskSnapshot&lt;/span&gt; = {
  &lt;span class=&quot;hljs-attr&quot;&gt;taskId&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;;
  &lt;span class=&quot;hljs-attr&quot;&gt;status&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;processing&#39;&lt;/span&gt; | &lt;span class=&quot;hljs-string&quot;&gt;&#39;succeeded&#39;&lt;/span&gt;;
  &lt;span class=&quot;hljs-attr&quot;&gt;progress&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;;
  &lt;span class=&quot;hljs-attr&quot;&gt;requests&lt;/span&gt;: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;;
};

&lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;wait&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;ms: &lt;span class=&quot;hljs-built_in&quot;&gt;number&lt;/span&gt;, signal: AbortSignal, ignoreAbort: &lt;span class=&quot;hljs-built_in&quot;&gt;boolean&lt;/span&gt;&lt;/span&gt;): &lt;span class=&quot;hljs-title class_&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt;&amp;gt; {
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;timer&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;ReturnType&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;setTimeout&lt;/span&gt;&amp;gt; | &lt;span class=&quot;hljs-literal&quot;&gt;undefined&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;onAbort&lt;/span&gt;: (&lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt;) | &lt;span class=&quot;hljs-literal&quot;&gt;undefined&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;try&lt;/span&gt; {
    &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-built_in&quot;&gt;void&lt;/span&gt;&amp;gt;(&lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;resolve, reject&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!ignoreAbort &amp;amp;&amp;amp; signal.&lt;span class=&quot;hljs-property&quot;&gt;aborted&lt;/span&gt;) {
        &lt;span class=&quot;hljs-title function_&quot;&gt;reject&lt;/span&gt;(signal.&lt;span class=&quot;hljs-property&quot;&gt;reason&lt;/span&gt;);
        &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt;;
      }
      timer = &lt;span class=&quot;hljs-built_in&quot;&gt;setTimeout&lt;/span&gt;(resolve, ms);
      onAbort = &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;reject&lt;/span&gt;(signal.&lt;span class=&quot;hljs-property&quot;&gt;reason&lt;/span&gt;);
      &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!ignoreAbort) {
        signal.&lt;span class=&quot;hljs-title function_&quot;&gt;addEventListener&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;abort&#39;&lt;/span&gt;, onAbort, { &lt;span class=&quot;hljs-attr&quot;&gt;once&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt; });
        &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (signal.&lt;span class=&quot;hljs-property&quot;&gt;aborted&lt;/span&gt;) &lt;span class=&quot;hljs-title function_&quot;&gt;onAbort&lt;/span&gt;();
      }
    });
  } &lt;span class=&quot;hljs-keyword&quot;&gt;finally&lt;/span&gt; {
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (timer !== &lt;span class=&quot;hljs-literal&quot;&gt;undefined&lt;/span&gt;) &lt;span class=&quot;hljs-built_in&quot;&gt;clearTimeout&lt;/span&gt;(timer);
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (onAbort) signal.&lt;span class=&quot;hljs-title function_&quot;&gt;removeEventListener&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;abort&#39;&lt;/span&gt;, onAbort);
  }
}

&lt;span class=&quot;hljs-comment&quot;&gt;// 受控本地替身：不访问服务器，不表示真实任务耗时或进度。&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;createFakeQuery&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;task: Task&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; requests = &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;async&lt;/span&gt; (&lt;span class=&quot;hljs-attr&quot;&gt;signal&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;AbortSignal&lt;/span&gt;): &lt;span class=&quot;hljs-title class_&quot;&gt;Promise&lt;/span&gt;&amp;lt;&lt;span class=&quot;hljs-title class_&quot;&gt;TaskSnapshot&lt;/span&gt;&amp;gt; =&amp;gt; {
    requests += &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; count = requests;
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; slow = task.&lt;span class=&quot;hljs-property&quot;&gt;mode&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;slow&#39;&lt;/span&gt; || task.&lt;span class=&quot;hljs-property&quot;&gt;mode&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;stubborn&#39;&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;wait&lt;/span&gt;(slow ? &lt;span class=&quot;hljs-number&quot;&gt;2500&lt;/span&gt; : &lt;span class=&quot;hljs-number&quot;&gt;250&lt;/span&gt;, signal, task.&lt;span class=&quot;hljs-property&quot;&gt;mode&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;stubborn&#39;&lt;/span&gt;);
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (task.&lt;span class=&quot;hljs-property&quot;&gt;mode&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;flaky&#39;&lt;/span&gt; &amp;amp;&amp;amp; count &amp;lt;= &lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;) {
      &lt;span class=&quot;hljs-keyword&quot;&gt;throw&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;模拟查询失败；下次将按退避间隔重试&#39;&lt;/span&gt;);
    }
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; completed = task.&lt;span class=&quot;hljs-property&quot;&gt;mode&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;flaky&#39;&lt;/span&gt; ? count - &lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt; : count;
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; {
      &lt;span class=&quot;hljs-attr&quot;&gt;taskId&lt;/span&gt;: task.&lt;span class=&quot;hljs-property&quot;&gt;id&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;status&lt;/span&gt;: completed &amp;gt;= &lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt; ? &lt;span class=&quot;hljs-string&quot;&gt;&#39;succeeded&#39;&lt;/span&gt; : &lt;span class=&quot;hljs-string&quot;&gt;&#39;processing&#39;&lt;/span&gt;,
      &lt;span class=&quot;hljs-attr&quot;&gt;progress&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;Math&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;min&lt;/span&gt;(completed * &lt;span class=&quot;hljs-number&quot;&gt;34&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;100&lt;/span&gt;),
      &lt;span class=&quot;hljs-attr&quot;&gt;requests&lt;/span&gt;: count,
    };
  };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;配合 signal 的等待会在注册监听后再次检查 aborted，正常完成和取消都清理监听及计时器。忽略取消的模式则故意等到结果返回，用于检查页面端的保护，而不是假装所有第三方 API 都支持取消。&lt;/p&gt;
&lt;p&gt;请求序号和进度由这个本地闭包生成。重新查询会新建替身、重置计数；它没有重新提交真实生成任务。状态 processing / succeeded 也是示例约定，实际接 HTTP 接口时应先校验响应结构，再按协议判断是否终态。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-5&quot;&gt;运行与观察&lt;/h2&gt;
&lt;p&gt;已有 React + TypeScript 项目可以按文中的 src 路径保存三个完整文件，并让入口渲染 App。完整交付工程还包含入口、样式、依赖锁文件与测试；本次使用 React 19.2.7、TypeScript 5.9.3、Vite 8.2.1。&lt;/p&gt;
&lt;p&gt;本地交付包中的 &lt;code&gt;examples/react-task-polling-demo.zip&lt;/code&gt; 解压后，根目录为 react-task-polling-demo。公开附件地址尚未提供，因此这里不放无法访问的下载链接。使用满足 package.json 中 engines 要求的 Node，在工程目录运行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;cd react-task-polling-demo
npm ci
npm test
npm run build
npm run dev
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;选择模拟场景后，点击“切换到新任务”应用它：&lt;/p&gt;

























&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;场景&lt;/th&gt;&lt;th&gt;观察内容&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;正常查询&lt;/td&gt;&lt;td&gt;三次成功响应后结束查询&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;慢请求&lt;/td&gt;&lt;td&gt;本轮结束后才开始计算下一次间隔&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;前两次失败&lt;/td&gt;&lt;td&gt;显示 2 秒、4 秒退避，后续正常查询恢复&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;忽略取消的慢请求&lt;/td&gt;&lt;td&gt;查询发出后切换任务或卸载，旧结果不再提交&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;停止按钮只结束当前页面的查询，后台任务是否取消必须走另一套业务接口。示例没有该接口。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-6&quot;&gt;验证范围与接入边界&lt;/h2&gt;
&lt;p&gt;此前已实际运行原共享函数的5项测试、教学工程的22项测试，以及2项独立边界检查，原始记录继续保留。本次保留核心实现，新增10项参数与长期pending边界测试，教学工程合计32项通过，0失败、0跳过，TypeScript检查与页面构建也通过。测试使用可控Promise、假定时器和受控触发，没有靠增加等待时间掩盖竞态。&lt;/p&gt;
&lt;p&gt;教学测试覆盖慢请求不重叠、退避封顶与成功恢复、终态停止、首次执行前停止、在途取消、忽略取消的晚到结果及错误回调。原业务测试里页面隐藏用例使用 document stub，不是浏览器兼容性测试；也不能根据测试名就认定它验证了成功后的全部退避恢复。&lt;/p&gt;
&lt;p&gt;此前的浏览器记录覆盖正常完成、停止与重新查询、故障恢复、任务切换和卸载；本次补查了在途停止后立即重查、快速切换任务、同任务连续重查以及在途卸载四组页面操作。界面检查只说明可见行为，不用它证明所有物理请求互斥或枚举内部定时器；相应停止调度行为由受控测试另行断言。&lt;/p&gt;
&lt;p&gt;下面是保留的本地Fake API示例截图，进度来自受控替身，不是Vimorph正式工作台：&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/75dd1e7efdbd46629b84f121308cf647~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgVmltb3JwaFRlY2g=:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792855990&amp;amp;x-signature=%2FkgQqLro%2BhBqbGQSc%2FOKC5TKeYg%3D&quot; alt=&quot;demo.jpg&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;示例没有单次请求timeout，也没有任务级deadline：前者限制一次查询持续多久，后者限制整项任务可以等待多久。若查询一直pending，本实例不会再发下一条，但也不会自行判定超时；真实HTTP客户端需要配置请求超时，并把失败交给明确的重试或停止策略。异步onError若一直不结算，也会暂停在通知阶段。&lt;/p&gt;
&lt;p&gt;页面隐藏自动暂停和最大失败次数没有内置。停止查询或请求超时，都不代表服务器或供应商后台任务已取消；取消后台任务需要另外的业务接口。&lt;/p&gt;
&lt;p&gt;本轮运行环境为 Node 26.7.0。真实供应方、Vimorph 业务端到端、Safari、移动端和后台计时器节流未验证。开发入口启用了 StrictMode，但不把一次页面操作当成所有组件生命周期组合的证明。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-7&quot;&gt;让一次轮询有明确的结束方式&lt;/h2&gt;
&lt;p&gt;持续查询不需要持续制造请求。把下一轮放在本轮结算之后，再给实例设置明确的停止与结果提交规则，就能把请求频率和页面生命周期连接起来。&lt;/p&gt;
&lt;p&gt;接入页面时，先看清三件事：查询错误是否还能传到调度器，清理是否通知了在途请求，晚到结果是否仍有资格更新当前任务。把这些约定写进调用处，比单独封装一个定时器更有用。&lt;/p&gt;</description><link>https://juejin.cn/post/7697431539588218907</link><guid isPermaLink="false">https://juejin.cn/post/7697431539588218907</guid><pubDate>Sat, 17 Oct 2026 15:33:10 GMT</pubDate><author>VimorphTech</author><category>前端</category><category>TypeScript</category><category>JavaScript</category></item><item><title>镜游小导：运行在 Rokid AR 眼镜上的景区实景讲解智能体</title><description>&lt;h2 data-id=&quot;heading-0&quot;&gt;&lt;strong&gt;一、项目简介&lt;/strong&gt;&lt;/h2&gt;
&lt;h4 data-id=&quot;heading-1&quot;&gt;1.1 解决什么问题&lt;/h4&gt;
&lt;p&gt;传统景区讲解存在三个痛点：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;低头成本高&lt;/strong&gt;：手机/讲解器需要低头或手持，既危险又打断游览；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;被动且嘈杂&lt;/strong&gt;：跟团扩音器无法自主控制节奏，信息与大部队绑定；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;交互太重&lt;/strong&gt;：屏幕点按在走动中很别扭，AR 卡片一多就遮挡视线。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;镜游小导的目标不是做「景区百科」，而是做&lt;strong&gt;一个安静、随时在身边的向导&lt;/strong&gt;：只在用户看向某处、并点头确认时，才轻轻开口。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-2&quot;&gt;1.2 目标用户&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;自由行游客、博物馆/古迹爱好者；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;带老人小孩、不便手持设备的家庭游客；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;希望「看不打扰、听不费劲」的轻量交互人群。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-id=&quot;heading-3&quot;&gt;1.3 一句话成果&lt;/h4&gt;
&lt;p&gt;戴上 Rokid 眼镜走进西湖或故宫，无需掏手机：停步看两秒，它轻声问一句；点点头，眼前景物上方浮出讲解卡片，耳边同步播讲；想深入了解就开口追问；走出景区，它自动休息。&lt;/p&gt;
&lt;hr&gt;
&lt;h3 data-id=&quot;heading-4&quot;&gt;&lt;strong&gt;二、场景与用户意图&lt;/strong&gt;&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-5&quot;&gt;2.1 触发场景&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;进园即值守&lt;/strong&gt;：GPS 判定进入景区范围，智能体自动进入值守，不打扰；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;视线停留触发&lt;/strong&gt;：用户停下、注视某处景物约 2 秒，判定为「有意了解」；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;主动唤起&lt;/strong&gt;：任意时刻语音唤醒、按确认键（Enter/GlobalHook）、点头都可唤起；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;离园休眠&lt;/strong&gt;：连续若干次定位在景区外，自动收卡片、退出值守。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-id=&quot;heading-6&quot;&gt;2.2 智能体如何理解与承接需求&lt;/h4&gt;
&lt;p&gt;核心是**「上下文 + 意图分流」**：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;上下文&lt;/strong&gt;：当前是否在景区（&lt;code&gt;inPark&lt;/code&gt;）、当前景区（&lt;code&gt;activePark&lt;/code&gt;）、当前点位（&lt;code&gt;currentPoi&lt;/code&gt;）、当前模式（&lt;code&gt;mode&lt;/code&gt;）；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;意图分流&lt;/strong&gt;：同一条语音「要不要讲」，在不同模式下含义不同——&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;在 &lt;code&gt;ask&lt;/code&gt; 下 → 判断题（讲 / 不讲 / 没听清）；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在 &lt;code&gt;qa&lt;/code&gt;/&lt;code&gt;explain&lt;/code&gt; 下 → 追问内容或「停下/换一个」指令；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;在 &lt;code&gt;scan&lt;/code&gt; 下 → 全局指令（退出休息 / 附近讲讲）。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;询问答复的判定就有明确的优先级（否定优先，避免「不好」被当成「好」）：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;handleAskAnswer(text) {
 &amp;nbsp;const positive = /(讲|要|好|行|可以|开始|嗯|介绍|听听|来吧|继续)/.test(text);
 &amp;nbsp;const negative = /(不用|不要|算了|不讲|先不|不了|没事|走|逛|再说)/.test(text);
 &amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (positive &amp;amp;&amp;amp; !negative) { this.confirmAsk(); &lt;span class=&quot;hljs-built_in&quot;&gt;return&lt;/span&gt;; }
 &amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (negative) { this.declineAsk(&lt;span class=&quot;hljs-string&quot;&gt;&#39;好的，那您先逛着，需要再叫我。&#39;&lt;/span&gt;); &lt;span class=&quot;hljs-built_in&quot;&gt;return&lt;/span&gt;; }
 &amp;nbsp;this.speakQuiet(&lt;span class=&quot;hljs-string&quot;&gt;&#39;嗯？要讲还是先走？&#39;&lt;/span&gt;); // 没听清再问一次
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-id=&quot;heading-7&quot;&gt;&lt;strong&gt;三、方案设计&lt;/strong&gt;&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-8&quot;&gt;3.1 AIUI 智能体定义&lt;/h4&gt;
&lt;p&gt;项目以 Rokid &lt;strong&gt;AIUI 开放智能体&lt;/strong&gt;形态交付：页面用 &lt;code&gt;.ink&lt;/code&gt; 描述，运行在眼镜端智能体运行时，直接调用系统能力。&lt;code&gt;AGENTS.md&lt;/code&gt; 里声明了智能体的身份、能力与技能：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;身份&lt;/strong&gt;：&lt;code&gt;镜游小导&lt;/code&gt; v1.0.0，景区 AR 实景讲解智能体；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;权限（Capabilities）&lt;/strong&gt;：&lt;code&gt;geolocation&lt;/code&gt;、&lt;code&gt;microphone&lt;/code&gt;、&lt;code&gt;network&lt;/code&gt;、&lt;code&gt;audio&lt;/code&gt;、&lt;code&gt;accelerometer&lt;/code&gt;、&lt;code&gt;gyroscope&lt;/code&gt;；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;技能（Skills）&lt;/strong&gt;：&lt;code&gt;speech-recognition&lt;/code&gt;、&lt;code&gt;speech-synthesis&lt;/code&gt;、&lt;code&gt;scenic-explanation&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-id=&quot;heading-9&quot;&gt;3.2 能力调用关系&lt;/h4&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;用户意图&lt;/th&gt;&lt;th&gt;依赖能力&lt;/th&gt;&lt;th&gt;实现载体&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;判断是否在景区&lt;/td&gt;&lt;td&gt;定位&lt;/td&gt;&lt;td&gt;&lt;code&gt;navigator.geolocation&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;视线停留锁定景物&lt;/td&gt;&lt;td&gt;陀螺仪/加速度计&lt;/td&gt;&lt;td&gt;&lt;code&gt;AbsoluteOrientationSensor&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;语音确认/追问&lt;/td&gt;&lt;td&gt;麦克风&lt;/td&gt;&lt;td&gt;&lt;code&gt;SpeechRecognition&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;讲解播报&lt;/td&gt;&lt;td&gt;扬声器&lt;/td&gt;&lt;td&gt;&lt;code&gt;speechSynthesis&lt;/code&gt; / &lt;code&gt;wx.speech.playTTS&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;生成讲解词/回答&lt;/td&gt;&lt;td&gt;网络 + 大模型&lt;/td&gt;&lt;td&gt;&lt;code&gt;LanguageModel&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;景区外联网兜底&lt;/td&gt;&lt;td&gt;网络&lt;/td&gt;&lt;td&gt;OSM Overpass（&lt;code&gt;fetch&lt;/code&gt;）&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h4 data-id=&quot;heading-10&quot;&gt;3.3 页面与卡片设计&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;HUD（常显）&lt;/strong&gt;：模式 chip + 景区名 + 距离 + 聆听态，下一行常显定位坐标；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;讲解卡片（Overlay）&lt;/strong&gt;：点名称/副标题 + 最多 4 行正文 + 提示 + 实时识别文字；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Footer&lt;/strong&gt;：状态文案 + 错误提示；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;视觉约束&lt;/strong&gt;：单色绿 CSS 变量、480×352 小屏适配、卡片轻量不压视线中心。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-id=&quot;heading-11&quot;&gt;3.4 状态流转&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt; &amp;nbsp; &amp;nbsp; &amp;nbsp;  ┌───────── 走出景区 ─────────┐
 &amp;nbsp; &amp;nbsp; &amp;nbsp;  ▼ &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;  │
 &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-built_in&quot;&gt;sleep&lt;/span&gt; ──进园──► scan ──视线停留/唤起──► ask
 &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;  ▲ &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;  │ 确认
 &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;  │ &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;  ▼
 &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;  │◄──返回/离点── explain ──讲完──► qa
 &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;  └────────返回键/离园────────────────┘
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;code&gt;sleep&lt;/code&gt; 景区外；&lt;code&gt;scan&lt;/code&gt; 值守；&lt;code&gt;ask&lt;/code&gt; 轻声询问；&lt;code&gt;explain&lt;/code&gt; 讲解中；&lt;code&gt;qa&lt;/code&gt; 追问。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;任意状态：返回键可逐级退出；离园统一回 &lt;code&gt;sleep&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-id=&quot;heading-12&quot;&gt;&lt;strong&gt;四、AIUI 实现&lt;/strong&gt;&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-13&quot;&gt;4.1 项目结构&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;境游小导/
├── AGENTS.md &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;  &lt;span class=&quot;hljs-comment&quot;&gt;# 智能体身份/能力/技能声明&lt;/span&gt;
├── app.json &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 页面注册 + 权限声明&lt;/span&gt;
├── app.js &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 应用生命周期&lt;/span&gt;
├── package.json
├── pages/
│ &amp;nbsp; └── index/
│ &amp;nbsp; &amp;nbsp; &amp;nbsp; └── index.ink &amp;nbsp; &amp;nbsp;  &lt;span class=&quot;hljs-comment&quot;&gt;# 唯一页面：状态机 + 交互 + 样式&lt;/span&gt;
├── lib/
│ &amp;nbsp; ├── pois.js &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp;  &lt;span class=&quot;hljs-comment&quot;&gt;# 重点景区素材 + 地理计算&lt;/span&gt;
│ &amp;nbsp; ├── scenic-areas.js &amp;nbsp;  &lt;span class=&quot;hljs-comment&quot;&gt;# 全国 360 家 5A 名称库（生成产物）&lt;/span&gt;
│ &amp;nbsp; └── geo-lookup.js &amp;nbsp; &amp;nbsp;  &lt;span class=&quot;hljs-comment&quot;&gt;# 运行时 OSM 联网兜底&lt;/span&gt;
└── tools/
 &amp;nbsp;  ├── build-scenic-areas.mjs &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# xlsx → scenic-areas.js&lt;/span&gt;
 &amp;nbsp;  └── data/5a-areas.xlsx &amp;nbsp; &amp;nbsp; &amp;nbsp; &lt;span class=&quot;hljs-comment&quot;&gt;# 5A 经纬度源数据&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-14&quot;&gt;4.2 配置：&lt;code&gt;app.json&lt;/code&gt; 与 &lt;code&gt;package.json&lt;/code&gt;&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;{
 &amp;nbsp;&lt;span class=&quot;hljs-string&quot;&gt;&quot;pages&quot;&lt;/span&gt;: [&lt;span class=&quot;hljs-string&quot;&gt;&quot;pages/index/index&quot;&lt;/span&gt;],
 &amp;nbsp;&lt;span class=&quot;hljs-string&quot;&gt;&quot;permissions&quot;&lt;/span&gt;: [&lt;span class=&quot;hljs-string&quot;&gt;&quot;GEOLOCATION&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;RECORD_AUDIO&quot;&lt;/span&gt;],
 &amp;nbsp;&lt;span class=&quot;hljs-string&quot;&gt;&quot;window&quot;&lt;/span&gt;: { &lt;span class=&quot;hljs-string&quot;&gt;&quot;navigationBarTitleText&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;镜游小导&quot;&lt;/span&gt; }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;小坑：权限名必须大写（&lt;/em&gt;&lt;code&gt;GEOLOCATION&lt;/code&gt;&lt;em&gt;/&lt;/em&gt;&lt;code&gt;RECORD_AUDIO&lt;/code&gt;&lt;em&gt;），否则真机运行时会报&lt;/em&gt; &lt;code&gt;Geolocation permission &#39;GEOLOCATION&#39; is required by the app manifest&lt;/code&gt;&lt;em&gt;。&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h4 data-id=&quot;heading-15&quot;&gt;4.3 Ink 页面解剖&lt;/h4&gt;
&lt;p&gt;一个 &lt;code&gt;.ink&lt;/code&gt; 文件三段式：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;&amp;lt;script &lt;span class=&quot;hljs-built_in&quot;&gt;type&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;application/json&quot;&lt;/span&gt; def&amp;gt;{ &lt;span class=&quot;hljs-string&quot;&gt;&quot;navigationBarTitleText&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;镜游小导&quot;&lt;/span&gt; }&amp;lt;/script&amp;gt;

&amp;lt;script setup&amp;gt;
import wx from &lt;span class=&quot;hljs-string&quot;&gt;&#39;wx&#39;&lt;/span&gt;;
import { findPark, nearestParkCenter, distanceMeters, bearingDegrees, nearestPoi } from &lt;span class=&quot;hljs-string&quot;&gt;&#39;../../lib/pois.js&#39;&lt;/span&gt;;
import { lookupNearbyArea } from &lt;span class=&quot;hljs-string&quot;&gt;&#39;../../lib/geo-lookup.js&#39;&lt;/span&gt;;
&lt;span class=&quot;hljs-built_in&quot;&gt;export&lt;/span&gt; default {
 &amp;nbsp;data: { mode: &lt;span class=&quot;hljs-string&quot;&gt;&#39;sleep&#39;&lt;/span&gt;, inPark: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;, overlayVisible: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;, /* ... */ },
 &amp;nbsp;&lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-title&quot;&gt;onLoad&lt;/span&gt;&lt;/span&gt;() { /* 初始化 GPS/传感器/语音 */ },
 &amp;nbsp;&lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-title&quot;&gt;onVoiceWakeup&lt;/span&gt;&lt;/span&gt;() { /* 语音唤醒 */ },
 &amp;nbsp;onHeadGesture(e) { /* 点头=确认，摇头=拒绝 */ },
 &amp;nbsp;onKeyDown(e) { /* Enter/GlobalHook 主键 */ },
 &amp;nbsp;/* ... 方法与状态机 ... */
};
&amp;lt;/script&amp;gt;

&amp;lt;page&amp;gt;
 &amp;nbsp;&amp;lt;view class=&lt;span class=&quot;hljs-string&quot;&gt;&quot;stage&quot;&lt;/span&gt;&amp;gt;
 &amp;nbsp; &amp;nbsp;&amp;lt;view class=&lt;span class=&quot;hljs-string&quot;&gt;&quot;hud&quot;&lt;/span&gt;&amp;gt;…模式 chip / 景区名 / 距离 / 聆听中…&amp;lt;/view&amp;gt;
 &amp;nbsp; &amp;nbsp;&amp;lt;view class=&lt;span class=&quot;hljs-string&quot;&gt;&quot;overlay&quot;&lt;/span&gt; ink:&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;{{overlayVisible}}&quot;&lt;/span&gt;&amp;gt;…讲解卡片…&amp;lt;/view&amp;gt;
 &amp;nbsp; &amp;nbsp;&amp;lt;view class=&lt;span class=&quot;hljs-string&quot;&gt;&quot;footer&quot;&lt;/span&gt;&amp;gt;…状态 / 错误…&amp;lt;/view&amp;gt;
 &amp;nbsp;&amp;lt;/view&amp;gt;
&amp;lt;/page&amp;gt;

&amp;lt;style&amp;gt;…单色绿主题、480×352 适配…&amp;lt;/style&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;&amp;lt;page&amp;gt;&lt;/code&gt; 用 &lt;code&gt;ink:if&lt;/code&gt; / &lt;code&gt;ink:for&lt;/code&gt; 做条件与列表渲染；&lt;code&gt;setData({...})&lt;/code&gt; 驱动视图更新。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-16&quot;&gt;4.4 定位：双通道 + 看门狗&lt;/h4&gt;
&lt;p&gt;不同宿主对定位能力支持不一，因此同时打开「持续订阅」和「单次取点」两条通道：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;&lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-title&quot;&gt;startGeoWatch&lt;/span&gt;&lt;/span&gt;() { /* watchPosition 持续订阅，出错时 clearWatch 并重试 */ }
&lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-title&quot;&gt;pollGeoOnce&lt;/span&gt;&lt;/span&gt;() &amp;nbsp; { /* getCurrentPosition 单次取点，兼容仅支持单次定位的宿主 */ }
&lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-title&quot;&gt;startGeoHealth&lt;/span&gt;&lt;/span&gt;() { // 看门狗：长时间收不到定位就补一次轮询
 &amp;nbsp;this.geoHealthTimer = setInterval(() =&amp;gt; {
 &amp;nbsp; &amp;nbsp;const limit = this.data.inPark ? GEO_INSIDE_SILENT_MS : GEO_OUTSIDE_SILENT_MS;
 &amp;nbsp; &amp;nbsp;if (now - this.lastFixAt &amp;gt;= limit &amp;amp;&amp;amp; now - this.lastPollAt &amp;gt;= limit) this.pollGeoOnce();
  }, GEO_POLL_MS);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;GPS_OPTIONS&lt;/code&gt; 中 &lt;code&gt;maximumAge: 0&lt;/code&gt; 让宿主机不使用缓存旧坐标，模拟位置变化才能即时生效。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-17&quot;&gt;4.5 视线停留锁定&lt;/h4&gt;
&lt;p&gt;没有眼动 API，用头部姿态近似视线。四元数转 yaw/pitch，姿态稳定 2 秒即「停留锁定」：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;handlePoseAngles(yaw, pitch) {
 &amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (this.data.mode !== &lt;span class=&quot;hljs-string&quot;&gt;&#39;scan&#39;&lt;/span&gt; || !this.data.inPark) &lt;span class=&quot;hljs-built_in&quot;&gt;return&lt;/span&gt; this.resetDwell();
 &amp;nbsp;const now = Date.now();
 &amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (this.anchorYaw === null) { this.anchorYaw = yaw; this.anchorPitch = pitch; this.stableSince = now; &lt;span class=&quot;hljs-built_in&quot;&gt;return&lt;/span&gt;; }
 &amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (angleDelta(yaw, this.anchorYaw) &amp;gt; DWELL_YAW_LIMIT || Math.abs(pitch - this.anchorPitch) &amp;gt; DWELL_PITCH_LIMIT) {
 &amp;nbsp; &amp;nbsp;this.anchorYaw = yaw; this.anchorPitch = pitch; this.stableSince = now; &lt;span class=&quot;hljs-built_in&quot;&gt;return&lt;/span&gt;;
  }
 &amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (this.stableSince &amp;amp;&amp;amp; now - this.stableSince &amp;gt;= DWELL_MS) {
 &amp;nbsp; &amp;nbsp;this.onDwellLocked(((yaw % &lt;span class=&quot;hljs-number&quot;&gt;360&lt;/span&gt;) + &lt;span class=&quot;hljs-number&quot;&gt;360&lt;/span&gt;) % &lt;span class=&quot;hljs-number&quot;&gt;360&lt;/span&gt;);
  }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;锁定后，用「方位角差 + 距离」在讲解范围内选出最正对视线的点位：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;pickPoiByGaze(heading) {
 &amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;for&lt;/span&gt; (const poi of this.activePois) {
 &amp;nbsp; &amp;nbsp;const d = distanceMeters(this.lat, this.lon, poi.latitude, poi.longitude);
 &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (d &amp;gt; (poi.talkRangeMeters || 90)) &lt;span class=&quot;hljs-built_in&quot;&gt;continue&lt;/span&gt;;
 &amp;nbsp; &amp;nbsp;const diff = angleDelta(heading, bearingDegrees(this.lat, this.lon, poi.latitude, poi.longitude));
 &amp;nbsp; &amp;nbsp;&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (diff &amp;lt;= HEADING_TOLERANCE) bestAligned = { poi, diff }; // 60° 容差
  }
 &amp;nbsp;&lt;span class=&quot;hljs-built_in&quot;&gt;return&lt;/span&gt; bestAligned?.poi || (best?.distance &amp;lt;= 25 ? best.poi : null); // 超近距离兜底
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-18&quot;&gt;4.6 数据层：三级景区&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;重点景区&lt;/strong&gt; &lt;code&gt;PARKS&lt;/code&gt;：西湖（7 点）、故宫（5 点），带精确坐标与讲稿，支持逐点讲解；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;全国 5A 名称库&lt;/strong&gt; &lt;code&gt;SCENIC_AREAS&lt;/code&gt;：360 家，仅名称 + 坐标 + 半径，进园讲景区层面，讲解词由大模型生成；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;联网兜底&lt;/strong&gt; &lt;code&gt;lookupNearbyArea&lt;/code&gt;：都不命中时用 OSM Overpass 查周边命名景点，拼出动态景区。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;// lib/pois.js —— 命中优先级：内置重点 &amp;gt; 名称库 &amp;gt; null
&lt;span class=&quot;hljs-built_in&quot;&gt;export&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; findPark(latitude, longitude) {
  const parkHit = matchIn(PARKS, latitude, longitude, &lt;span class=&quot;hljs-string&quot;&gt;&#39;park&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (parkHit) &lt;span class=&quot;hljs-built_in&quot;&gt;return&lt;/span&gt; parkHit.item;
  const areaHit = matchIn(SCENIC_AREAS, latitude, longitude, &lt;span class=&quot;hljs-string&quot;&gt;&#39;area&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-built_in&quot;&gt;return&lt;/span&gt; areaHit ? areaToPark(areaHit.item) : null;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;景点数据由脚本从 Excel 生成，保证可维护、不手改：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;// tools/build-scenic-areas.mjs：xlsx → lib/scenic-areas.js
// 半径启发式：urban(古城/寺/塔/博物馆…)→2000m；large(山/湖/湿地…)→8000m；否则 3000m
&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-id=&quot;heading-19&quot;&gt;4.7 卡片渲染与播讲&lt;/h4&gt;
&lt;p&gt;最多同时 4 行，讲一句推一行、旧行上滚；按句号断句，逐句「显示 + TTS」：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;pushOverlayLine(text) {
  const next = (this.data.overlayLines || []).concat([{ &lt;span class=&quot;hljs-built_in&quot;&gt;id&lt;/span&gt;: makeId(&lt;span class=&quot;hljs-string&quot;&gt;&#39;line&#39;&lt;/span&gt;), text }]);
  &lt;span class=&quot;hljs-keyword&quot;&gt;while&lt;/span&gt; (next.length &amp;gt; MAX_OVERLAY_LINES) next.shift();
  this.setData({ overlayLines: next });
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;TTS 有 &lt;code&gt;speechSynthesis&lt;/code&gt; 与 &lt;code&gt;wx.speech.playTTS&lt;/code&gt; 两条实现，带超时兜底，可随时打断。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-20&quot;&gt;&lt;strong&gt;五、交互与体验&lt;/strong&gt;&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-21&quot;&gt;5.1 重要界面&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;HUD 行&lt;/strong&gt;：&lt;code&gt;[值守] 杭州西湖 距断桥残雪 42 米 [聆听中]&lt;/code&gt;，下一行常显 &lt;code&gt;30.25930,120.15260 · 断桥残雪 42 米&lt;/code&gt;；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;讲解卡片&lt;/strong&gt;：&lt;code&gt;断桥残雪 / 西湖十景 · 白堤东端&lt;/code&gt; + 正文逐句浮现 + 提示「点头或按确认键，返回键跳过」；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Footer&lt;/strong&gt;：&lt;code&gt;值守中 · 注视景物两秒我会轻声问，按确认键可随时叫我&lt;/code&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-id=&quot;heading-22&quot;&gt;5.2 操作路径&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;进园 → 轻声「已进入杭州西湖…」→ 值守；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;停步注视 2 秒 → 卡片浮现「要不要我给您讲讲？」+ 语音；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;点头 / 按确认键 / 说「要」→ 讲解（卡片逐句 + 播讲）；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;开口打断 → 追问，只聊当前点位；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;返回键 / 说「结束」/ 走出范围 → 回值守或休眠。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h4 data-id=&quot;heading-23&quot;&gt;5.3 反馈机制&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;语音&lt;/strong&gt;：进园/离园/询问/讲解全部用轻声 TTS，不刺耳；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;视觉&lt;/strong&gt;：模式 chip 变色、聆听态 chip、实时识别文字回显；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;冷却&lt;/strong&gt;：跳过过的点位 60 秒内不再打扰，避免反复询问；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;容错&lt;/strong&gt;：定位异常、语音不可用均以 footer 文案告知，不弹窗打断。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-id=&quot;heading-24&quot;&gt;&lt;strong&gt;六、调试与验证&lt;/strong&gt;&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-25&quot;&gt;6.1 预览&lt;/h4&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;AIUI Studio / Craft 预览&lt;/strong&gt;（&lt;code&gt;js.rokid.com/craft&lt;/code&gt;）：改完 &lt;code&gt;.ink&lt;/code&gt; 即时预览 HUD 与卡片布局；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;预览里用模拟坐标（如断桥坐标）验证进园、锁定、讲解全流程。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;img src=&quot;https://p9-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/03e55c4d022b49c6ba05e00aac389ca5~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAg55So5oi3ODg5NTYxOTU0ODE3MQ==:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792848279&amp;amp;x-signature=cnk4voubWH3wSuBGOh92Hjz4T70%3D&quot; alt=&quot;&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;h4 data-id=&quot;heading-26&quot;&gt;6.2 回归测试（Node 桩件）&lt;/h4&gt;
&lt;p&gt;把 &lt;code&gt;lib/*.js&lt;/code&gt; 与页面脚本抽取到 Node 环境，用桩件替换 &lt;code&gt;wx&lt;/code&gt;、传感器、语音，做了三套断言：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;定位/坐标健壮性：&lt;strong&gt;22 条&lt;/strong&gt;；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;主流程状态机：&lt;strong&gt;19 条&lt;/strong&gt;；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;多景区 + OSM 动态景区：&lt;strong&gt;15 条&lt;/strong&gt;。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 data-id=&quot;heading-27&quot;&gt;&lt;strong&gt;七、成果与复盘&lt;/strong&gt;&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-28&quot;&gt;7.1 可运行成果&lt;/h4&gt;
&lt;p&gt;一个可在 Rokid AR 眼镜上运行的多景区讲解智能体：内置西湖/故宫 12 个点位，覆盖全国 360 家 5A 的景区层面讲解，任何其他地方也能联网兜底识别周边景点。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-29&quot;&gt;7.2 遇到的问题与解决&lt;/h4&gt;




























































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;问题&lt;/th&gt;&lt;th&gt;现象&lt;/th&gt;&lt;th&gt;解决方案&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;定位权限&lt;/td&gt;&lt;td&gt;真机报 manifest 缺少 GEOLOCATION&lt;/td&gt;&lt;td&gt;&lt;code&gt;app.json&lt;/code&gt; 声明大写权限&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;经纬度填反&lt;/td&gt;&lt;td&gt;模拟器坐标颠倒/被截断&lt;/td&gt;&lt;td&gt;&lt;code&gt;normalizeCoordinates&lt;/code&gt; 自动纠正并提示&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;只支持单次定位的宿主&lt;/td&gt;&lt;td&gt;&lt;code&gt;watchPosition&lt;/code&gt; 不回调&lt;/td&gt;&lt;td&gt;双通道 + 看门狗轮询&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;&lt;code&gt;watchPosition&lt;/code&gt; 断线&lt;/td&gt;&lt;td&gt;出错后永久失联&lt;/td&gt;&lt;td&gt;错误回调 &lt;code&gt;clearWatch&lt;/code&gt; 并置 &lt;code&gt;watchId=null&lt;/code&gt; 后重试&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Overpass 406&lt;/td&gt;&lt;td&gt;联网查询被拒&lt;/td&gt;&lt;td&gt;请求头必须带 &lt;code&gt;User-Agent&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;联网识别只触发一次&lt;/td&gt;&lt;td&gt;出界计数一直增长，&lt;code&gt;===3&lt;/code&gt; 不再命中&lt;/td&gt;&lt;td&gt;改用 &lt;code&gt;&amp;gt;=&lt;/code&gt; / 按格变化触发&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;离园被旧请求拉回&lt;/td&gt;&lt;td&gt;在途请求返回后自动重新进园&lt;/td&gt;&lt;td&gt;请求 token 机制，离园即作废在途结果&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;OSM 顶掉内置名&lt;/td&gt;&lt;td&gt;边界处名称抖动&lt;/td&gt;&lt;td&gt;内置景区优先 + 围栏容差&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;卡片残留&lt;/td&gt;&lt;td&gt;退出后卡片卡在休眠界面&lt;/td&gt;&lt;td&gt;退出/休眠统一 &lt;code&gt;hideOverlay&lt;/code&gt;&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;否定词误判&lt;/td&gt;&lt;td&gt;「不好」被当成确认&lt;/td&gt;&lt;td&gt;否定判定优先于肯定&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;h4 data-id=&quot;heading-30&quot;&gt;7.3 后续迭代计划&lt;/h4&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;视线标定&lt;/strong&gt;：真机上对 yaw→正北做一次标定，提升锁定精度；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;持续收音&lt;/strong&gt;：追问模式在静音后自动续听，减少重复唤醒；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;数据后台化&lt;/strong&gt;：景区与讲稿改为后台实时下发，支持运营与热更新；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;能力增强&lt;/strong&gt;：室内定位 / 视觉 SLAM 辅助（弱 GPS 场景）、多语言、无障碍音效；&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;体验打磨&lt;/strong&gt;：卡片层级与视觉降噪、语音打断的即时性、离园提醒的柔和度。&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;</description><link>https://juejin.cn/post/7697205221651218447</link><guid isPermaLink="false">https://juejin.cn/post/7697205221651218447</guid><pubDate>Sat, 17 Oct 2026 13:24:39 GMT</pubDate><author>用户8895619548171</author><category>人工智能</category><category>JavaScript</category></item><item><title>Vue3 事件：常用用法与开发高频坑点</title><description>&lt;p&gt;&lt;strong&gt;前言：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Vue3 项目里，事件处理（@click、@change、@keyup 等）是最常见的内置事件。很多时候开发遇到的相关坑点问题不在&quot;语法不会&quot;，而在&lt;strong&gt;传参、修饰符、target 区分、异步访问、类型推断&lt;/strong&gt;这些细节上。本文分享一下日常开发里真实会用到事件的写法，以及那些常见的&lt;strong&gt;报了诡异 bug、排查半天的坑&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;一、事件的基础写法：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 最简单的用法&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;不传任何参数时，事件处理函数会自动收到原生事件对象作为第一个参数：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-xml&quot; lang=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;click&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleClick&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;点击&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;

&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;setup&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;javascript&quot;&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;handleClick&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;e&lt;/span&gt;) {
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(e) &lt;span class=&quot;hljs-comment&quot;&gt;// 原生 Event 对象&lt;/span&gt;
  }
&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;2. 需要自定义参数 + 事件对象&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;只要传了自定义参数，就必须手动把&lt;/strong&gt; &lt;strong&gt;$event&lt;/strong&gt; &lt;strong&gt;一起传进去&lt;/strong&gt;，否则事件对象会被覆盖掉：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-xml&quot; lang=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;click&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleClick(&#39;商品A&#39;, $event)&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;点击&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;

&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;setup&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;javascript&quot;&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;handleClick&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;name, e&lt;/span&gt;) {
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(name) &lt;span class=&quot;hljs-comment&quot;&gt;// &#39;商品A&#39;&lt;/span&gt;
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(e.&lt;span class=&quot;hljs-property&quot;&gt;target&lt;/span&gt;) &lt;span class=&quot;hljs-comment&quot;&gt;// 事件对象&lt;/span&gt;
  }
&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是最经典的坑：&lt;strong&gt;带自定义参数却忘了&lt;/strong&gt; &lt;strong&gt;$event&lt;/strong&gt; &lt;strong&gt;，函数里拿到的&lt;/strong&gt; &lt;strong&gt;e&lt;/strong&gt; &lt;strong&gt;其实是自定义值，根本不是事件对象&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;二、常用事件场景：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 获取当前点击的元素&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-xml&quot; lang=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;click&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleClick&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;
    &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;span&lt;/span&gt;&amp;gt;&lt;/span&gt;内容&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;span&lt;/span&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;

&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;setup&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;javascript&quot;&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;handleClick&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;e&lt;/span&gt;) {
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(e.&lt;span class=&quot;hljs-property&quot;&gt;target&lt;/span&gt;) &lt;span class=&quot;hljs-comment&quot;&gt;// 真正点击的元素（可能是 span）&lt;/span&gt;
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(e.&lt;span class=&quot;hljs-property&quot;&gt;currentTarget&lt;/span&gt;) &lt;span class=&quot;hljs-comment&quot;&gt;// 绑定事件的那个元素（div）&lt;/span&gt;
  }
&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;target&lt;/strong&gt; &lt;strong&gt;和&lt;/strong&gt; &lt;strong&gt;currentTarget&lt;/strong&gt; &lt;strong&gt;是区分难度最高的两个概念，几乎每个项目都会有人踩坑：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;e.target：用户&lt;strong&gt;实际点击&lt;/strong&gt;的元素（子元素）&lt;/li&gt;
&lt;li&gt;e.currentTarget：&lt;strong&gt;绑定事件监听的元素&lt;/strong&gt;（父元素，始终不变）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;嵌套结构里，点击 span 时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;target → span&lt;/li&gt;
&lt;li&gt;currentTarget → div&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;业务上想拿&quot;绑定事件的那个节点&quot;，务必用currentTarget。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 输入框的输入事件&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-xml&quot; lang=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;input&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;input&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleInput&quot;&lt;/span&gt; /&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;

&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;setup&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;javascript&quot;&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;handleInput&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;e&lt;/span&gt;) {
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(e.&lt;span class=&quot;hljs-property&quot;&gt;target&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;value&lt;/span&gt;) &lt;span class=&quot;hljs-comment&quot;&gt;// 当前输入的值&lt;/span&gt;
  }
&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;3. 键盘事件（回车、按键判断）&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-xml&quot; lang=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;input&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;keyup&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleKey&quot;&lt;/span&gt; /&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;

&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;setup&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;javascript&quot;&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;handleKey&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;e&lt;/span&gt;) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (e.&lt;span class=&quot;hljs-property&quot;&gt;key&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;Enter&#39;&lt;/span&gt;) {
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;按下了回车&#39;&lt;/span&gt;)
    }
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (e.&lt;span class=&quot;hljs-property&quot;&gt;key&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;Escape&#39;&lt;/span&gt;) {
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;按下了 Esc&#39;&lt;/span&gt;)
    }
  }
&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;4. 阻止冒泡和默认行为&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;优先用修饰符，简单清晰：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-xml&quot; lang=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;&amp;lt;!-- 阻止冒泡 --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;click.stop&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleClick&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;…&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;

&lt;span class=&quot;hljs-comment&quot;&gt;&amp;lt;!-- 阻止默认行为（如表单提交、a 标签跳转） --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;form&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;submit.prevent&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleSubmit&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;…&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;form&lt;/span&gt;&amp;gt;&lt;/span&gt;

&lt;span class=&quot;hljs-comment&quot;&gt;&amp;lt;!-- 组合 --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;a&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;href&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;…&quot;&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;click.prevent.stop&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleClick&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;链接&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;a&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;需要在函数里动态判断时，再用事件对象方法：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-scss&quot; lang=&quot;scss&quot;&gt;  function &lt;span class=&quot;hljs-built_in&quot;&gt;handleClick&lt;/span&gt;(e) {
    if (某种条件) {
      e&lt;span class=&quot;hljs-selector-class&quot;&gt;.stopPropagation&lt;/span&gt;() &lt;span class=&quot;hljs-comment&quot;&gt;// 阻止冒泡&lt;/span&gt;
    }
    
    if (另一种条件) {
      e&lt;span class=&quot;hljs-selector-class&quot;&gt;.preventDefault&lt;/span&gt;() &lt;span class=&quot;hljs-comment&quot;&gt;// 阻止默认行为&lt;/span&gt;
    }
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;三、开发高频坑点汇总：&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;坑点 1：传了自定义参数却忘了 $event&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-xml&quot; lang=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-comment&quot;&gt;&amp;lt;!-- 错误 --&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;click&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleClick(10)&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;点击&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;

&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;setup&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;javascript&quot;&gt;
  &lt;span class=&quot;hljs-comment&quot;&gt;// 你以为 e 是事件对象，其实是 10&lt;/span&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;handleClick&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;e&lt;/span&gt;) {
    &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(e.&lt;span class=&quot;hljs-property&quot;&gt;target&lt;/span&gt;)  &lt;span class=&quot;hljs-comment&quot;&gt;// undefined，报错！&lt;/span&gt;
  }
&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;解决：需要事件对象就手动传&lt;/strong&gt; &lt;strong&gt;$event&lt;/strong&gt; &lt;strong&gt;。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;坑点 2：参数顺序写反&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-xml&quot; lang=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-comment&quot;&gt;&amp;lt;!-- 模板里顺序 --&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;click&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handle(&#39;A&#39;, $event)&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;点击&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;button&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;

&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;setup&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;javascript&quot;&gt;
  &lt;span class=&quot;hljs-comment&quot;&gt;// 函数里顺序必须和模板完全一致&lt;/span&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;handle&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;name, e&lt;/span&gt;) {
    &lt;span class=&quot;hljs-comment&quot;&gt;// 正确：name=&#39;A&#39;，e=事件对象&lt;/span&gt;
  }
&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;顺序反了不会报错，但取值全乱，属于最隐蔽的坑。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;坑点 3：target 和 currentTarget 混用&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;上面讲过，嵌套 DOM 里两者不同。用错了会导致：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;想拿父级数据，却取到子元素的属性（undefined）&lt;/li&gt;
&lt;li&gt;样式 / class 改到了错误节点&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;规则：凡是&quot;绑定事件的那个元素&quot;，一律用&lt;/strong&gt; &lt;strong&gt;currentTarget&lt;/strong&gt; &lt;strong&gt;。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;坑点 4：修饰符和函数内阻止逻辑重复&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-xml&quot; lang=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-comment&quot;&gt;&amp;lt;!-- 用了 .stop --&amp;gt;&lt;/span&gt;
  &lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;click.stop&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleClick&quot;&lt;/span&gt;&amp;gt;&lt;/span&gt;…&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;div&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;template&lt;/span&gt;&amp;gt;&lt;/span&gt;

&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt; &lt;span class=&quot;hljs-attr&quot;&gt;setup&lt;/span&gt;&amp;gt;&lt;/span&gt;&lt;span class=&quot;javascript&quot;&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;handleClick&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;e&lt;/span&gt;) {
    e.&lt;span class=&quot;hljs-title function_&quot;&gt;stopPropagation&lt;/span&gt;() &lt;span class=&quot;hljs-comment&quot;&gt;// 又手动阻止一次&lt;/span&gt;
  }
&lt;/span&gt;&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;/&lt;span class=&quot;hljs-name&quot;&gt;script&lt;/span&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;重复阻止一般不会报错，但会让逻辑混乱、排查困难。建议统一规范：&lt;/strong&gt;&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;简单、固定的场景：用模板修饰符&lt;/li&gt;
&lt;li&gt;需要条件判断的动态场景：只用函数内方法，别混用&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;坑点 5：异步回调里直接使用事件对象&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;  &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;handleClick&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;e&lt;/span&gt;) {
    &lt;span class=&quot;hljs-built_in&quot;&gt;setTimeout&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(e.&lt;span class=&quot;hljs-property&quot;&gt;target&lt;/span&gt;)  &lt;span class=&quot;hljs-comment&quot;&gt;// 可能拿到 null / undefined&lt;/span&gt;
    }, &lt;span class=&quot;hljs-number&quot;&gt;500&lt;/span&gt;)
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;原生事件对象在事件处理完后会被&lt;strong&gt;复用/回收&lt;/strong&gt;，异步里再访问内容不稳定。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;解决：先缓存需要的值。&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;  &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;handleClick&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;e&lt;/span&gt;) {
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; el = e.&lt;span class=&quot;hljs-property&quot;&gt;target&lt;/span&gt;
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; val = e.&lt;span class=&quot;hljs-property&quot;&gt;target&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;value&lt;/span&gt;
    &lt;span class=&quot;hljs-built_in&quot;&gt;setTimeout&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
      &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(el, val) &lt;span class=&quot;hljs-comment&quot;&gt;// 用缓存值，安全&lt;/span&gt;
    }, &lt;span class=&quot;hljs-number&quot;&gt;500&lt;/span&gt;)
  }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;坑点 6：键盘事件用 keyCode 已废弃&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ini&quot; lang=&quot;ini&quot;&gt;// 老写法，已废弃
if (&lt;span class=&quot;hljs-attr&quot;&gt;e.keyCode&lt;/span&gt; === &lt;span class=&quot;hljs-number&quot;&gt;13&lt;/span&gt;) { … }
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;用现代写法：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ini&quot; lang=&quot;ini&quot;&gt;if (&lt;span class=&quot;hljs-attr&quot;&gt;e.key&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;Enter&#39;&lt;/span&gt;) { … }
if (&lt;span class=&quot;hljs-attr&quot;&gt;e.code&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;Enter&#39;&lt;/span&gt;) { … }
&lt;/code&gt;&lt;/pre&gt;
&lt;ul&gt;
&lt;li&gt;e.key：表示按键的字符 / 名称（如 &#39;Enter&#39;、&#39;a&#39;、&#39;ArrowUp&#39;）&lt;/li&gt;
&lt;li&gt;e.code：表示物理按键位置（如 &#39;KeyA&#39;、&#39;ArrowUp&#39;）&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;区分中英文输入法布局时，用 e.code 更可靠。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;坑点 7：事件绑定到组件标签上（组件事件）&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;给自定义组件用&lt;/strong&gt; &lt;strong&gt;@click&lt;/strong&gt; &lt;strong&gt;时，默认监听的是组件自定义事件，不是原生 DOM 事件：&lt;/strong&gt;&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-xml&quot; lang=&quot;xml&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;&amp;lt;!-- 这样写，默认不会触发原生 click --&amp;gt;&lt;/span&gt;
&lt;span class=&quot;hljs-tag&quot;&gt;&amp;lt;&lt;span class=&quot;hljs-name&quot;&gt;MyButton&lt;/span&gt; @&lt;span class=&quot;hljs-attr&quot;&gt;click&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleClick&quot;&lt;/span&gt; /&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果想让事件落到底层原生元素上，需要：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-arduino&quot; lang=&quot;arduino&quot;&gt;&amp;lt;MyButton @click=&lt;span class=&quot;hljs-string&quot;&gt;&quot;handleClick&quot;&lt;/span&gt; @click.capture=&lt;span class=&quot;hljs-string&quot;&gt;&quot;…&quot;&lt;/span&gt; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者在组件内部显式 defineEmits：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-scss&quot; lang=&quot;scss&quot;&gt;&lt;span class=&quot;hljs-comment&quot;&gt;// MyButton.vue&lt;/span&gt;
const emit = &lt;span class=&quot;hljs-built_in&quot;&gt;defineEmits&lt;/span&gt;([&#39;click&#39;])
function &lt;span class=&quot;hljs-built_in&quot;&gt;onClick&lt;/span&gt;(e) {
  &lt;span class=&quot;hljs-built_in&quot;&gt;emit&lt;/span&gt;(&#39;click&#39;, e)
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;简单记：&lt;strong&gt;原生 DOM 元素直接用&lt;/strong&gt; &lt;strong&gt;@click&lt;/strong&gt; &lt;strong&gt;没问题；自定义组件上想监听原生事件，要么组件内部&lt;/strong&gt; &lt;strong&gt;emit&lt;/strong&gt; &lt;strong&gt;，要么用&lt;/strong&gt; &lt;strong&gt;.capture&lt;/strong&gt; &lt;strong&gt;等原生修饰符。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;四、事件常用修饰符一览:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/44ac92e4b38447048b4e4a609da35d4f~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgVG9ueei0uQ==:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792820598&amp;amp;x-signature=Cl0p5HKsYLthcXPWlgBFPjGhjG0%3D&quot; alt=&quot;image.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;常用按键快捷写法：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ini&quot; lang=&quot;ini&quot;&gt;&amp;lt;input @&lt;span class=&quot;hljs-attr&quot;&gt;keyup.enter&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;submit&quot;&lt;/span&gt; /&amp;gt;
&amp;lt;input @&lt;span class=&quot;hljs-attr&quot;&gt;keyup.esc&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;cancel&quot;&lt;/span&gt; /&amp;gt;
&amp;lt;input @&lt;span class=&quot;hljs-attr&quot;&gt;keyup.ctrl.enter&lt;/span&gt;=&lt;span class=&quot;hljs-string&quot;&gt;&quot;save&quot;&lt;/span&gt; /&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;五、事件处理函数几种写法的取舍:&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;img src=&quot;https://p3-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/596c87bd88ed4b1e8b8829a4bdfa75df~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgVG9ueei0uQ==:q75.awebp?rk3s=f64ab15b&amp;amp;x-expires=1792820598&amp;amp;x-signature=P4HB1Mf%2FMCKw%2FCbhQFL%2Bqnbr5iA%3D&quot; alt=&quot;image.png&quot; loading=&quot;lazy&quot; referrerpolicy=&quot;no-referrer&quot;&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;六、开发规范建议:&lt;/strong&gt;&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;绑定事件元素的节点操作，一律用&lt;/strong&gt; &lt;strong&gt;currentTarget&lt;/strong&gt;，避免嵌套踩坑。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;需要事件对象 + 自定义参数时，别漏&lt;/strong&gt; &lt;strong&gt;$event&lt;/strong&gt; &lt;strong&gt;，别写反顺序。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;固定阻止逻辑用修饰符，动态判断用函数内方法，别混用。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;异步里先缓存事件对象的属性，别直接延迟访问。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;键盘判断用&lt;/strong&gt; &lt;strong&gt;e.key&lt;/strong&gt; &lt;strong&gt;/&lt;/strong&gt; &lt;strong&gt;e.code&lt;/strong&gt; &lt;strong&gt;，别用废弃的&lt;/strong&gt; &lt;strong&gt;keyCode&lt;/strong&gt; &lt;strong&gt;。&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;自定义组件的事件按&quot;组件事件&quot;处理，要原生事件就内部&lt;/strong&gt; &lt;strong&gt;emit&lt;/strong&gt; &lt;strong&gt;或加原生修饰符。&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;</description><link>https://juejin.cn/post/7697181229841809450</link><guid isPermaLink="false">https://juejin.cn/post/7697181229841809450</guid><pubDate>Sat, 17 Oct 2026 05:43:18 GMT</pubDate><author>Tony费</author><category>前端</category><category>Vue.js</category><category>JavaScript</category></item><item><title>曝光埋点别放在组件渲染里：连续可见与去重范围怎么验收</title><description>&lt;p&gt;列表组件更新一次，曝光数就加一次；用户明明没滚到那张卡片，后台却已经收到曝光。这类问题通常不是报表计算慢，而是把“内容被创建”误当成“内容进入了约定的可见范围”。曝光埋点应先写触发契约，再把浏览器信号接到契约上。&lt;/p&gt;
&lt;p&gt;本文由 SensorFlow 项目维护方整理，AI 协助起草；代码仅使用离线合成观测，不代表真实用户、浏览器兼容性实测或产品内置能力。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-0&quot;&gt;一次曝光究竟证明了什么&lt;/h2&gt;
&lt;p&gt;本文把一次合格曝光定义为：同一页面实例里，同一内容与位置组合，在页面可见时，元素几何相交比例持续不低于约定阈值，达到持续时间后最多产生一个候选事件。它不证明用户读懂、记住或点击，也不证明候选事件已送达服务端。&lt;/p&gt;
&lt;p&gt;示例使用比例 0.5、连续 1000 毫秒，只是为了让测试边界清晰，不是广告计费标准，也不是所有列表的推荐值。产品、前端、分析和测试应共同决定真实阈值；不要用“业内都是这样”替代定义。&lt;/p&gt;
&lt;p&gt;DOM 节点创建、接口成功返回、组件 mounted 和虚拟列表复用，都可以发生在用户尚未看到内容时。相反，同一条内容因为状态更新重新渲染，不应自动成为一次新曝光。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-1&quot;&gt;浏览器提供观测，业务负责解释&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FIntersection_Observer_API&quot; target=&quot;_blank&quot; title=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;Intersection Observer&lt;/a&gt; 可以异步观测元素与视口或指定根的相交变化；&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FIntersectionObserverEntry%2FintersectionRatio&quot; target=&quot;_blank&quot; title=&quot;https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry/intersectionRatio&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;intersectionRatio&lt;/a&gt; 描述面积比例。它们提供的是几何信号，不是人类注意力证明。&lt;/p&gt;
&lt;p&gt;还要检查 &lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FDocument%2FvisibilityState&quot; target=&quot;_blank&quot; title=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Document/visibilityState&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;document.visibilityState&lt;/a&gt;。切到后台后，不能仅因为上一次元素比例合格，就继续累计一次“前台连续曝光”。普通相交观测也不能独自保证元素未被遮挡；透明、覆盖、复杂容器以及可选的 trackVisibility 支持都需要单独评估。&lt;/p&gt;
&lt;p&gt;实现时可以分四步：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;在页面或路由进入时建立页面实例范围，确定内容ID和位置ID。&lt;/li&gt;
&lt;li&gt;将相交、页面前后台及计时检查转换为同一时钟上的观测。&lt;/li&gt;
&lt;li&gt;只在持续区间未中断且达到阈值时创建候选曝光。&lt;/li&gt;
&lt;li&gt;创建后固定事件ID交给上报层；传输重试不重新触发业务曝光。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;相交回调不会保证恰好在1000毫秒再次执行。真实适配器还需要到期检查，并在到期时重新确认页面和元素条件。下面的程序只验证已经提供的观测序列，不会替你注册监听、定时器或发请求。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-2&quot;&gt;用边界样本验收，而不是盯着总数猜&lt;/h2&gt;
&lt;p&gt;保存为 &lt;code&gt;impression.cjs&lt;/code&gt;，执行 &lt;code&gt;node impression.cjs&lt;/code&gt;。代码只依赖 Node 标准库；所有字符串ID均为合成标识，没有请求、Cookie、DOM或用户数据。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; assert = &lt;span class=&quot;hljs-built_in&quot;&gt;require&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;node:assert/strict&#39;&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;detector&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; since = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Map&lt;/span&gt;(), fired = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Set&lt;/span&gt;();
  &lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; last = -&lt;span class=&quot;hljs-title class_&quot;&gt;Infinity&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;observe&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;at, item, slot, ratio, visible&lt;/span&gt;) {
    &lt;span class=&quot;hljs-title function_&quot;&gt;assert&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;Number&lt;/span&gt;.&lt;span class=&quot;hljs-built_in&quot;&gt;isFinite&lt;/span&gt;(at) &amp;amp;&amp;amp; at &amp;gt;= &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt; &amp;amp;&amp;amp; at &amp;gt;= last);
    &lt;span class=&quot;hljs-title function_&quot;&gt;assert&lt;/span&gt;(&lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; item === &lt;span class=&quot;hljs-string&quot;&gt;&#39;string&#39;&lt;/span&gt; &amp;amp;&amp;amp; item.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; &amp;gt; &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;);
    &lt;span class=&quot;hljs-title function_&quot;&gt;assert&lt;/span&gt;(&lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; slot === &lt;span class=&quot;hljs-string&quot;&gt;&#39;string&#39;&lt;/span&gt; &amp;amp;&amp;amp; slot.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; &amp;gt; &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;);
    &lt;span class=&quot;hljs-title function_&quot;&gt;assert&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;Number&lt;/span&gt;.&lt;span class=&quot;hljs-built_in&quot;&gt;isFinite&lt;/span&gt;(ratio) &amp;amp;&amp;amp; ratio &amp;gt;= &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt; &amp;amp;&amp;amp; ratio &amp;lt;= &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;);
    assert.&lt;span class=&quot;hljs-title function_&quot;&gt;equal&lt;/span&gt;(&lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; visible, &lt;span class=&quot;hljs-string&quot;&gt;&#39;boolean&#39;&lt;/span&gt;);
    last = at;
    &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; key = &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;stringify&lt;/span&gt;([item, slot]);
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!visible) since.&lt;span class=&quot;hljs-title function_&quot;&gt;clear&lt;/span&gt;();
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!visible || ratio &amp;lt; &lt;span class=&quot;hljs-number&quot;&gt;0.5&lt;/span&gt;) {
      since.&lt;span class=&quot;hljs-title function_&quot;&gt;delete&lt;/span&gt;(key);
      &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
    }
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (fired.&lt;span class=&quot;hljs-title function_&quot;&gt;has&lt;/span&gt;(key)) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!since.&lt;span class=&quot;hljs-title function_&quot;&gt;has&lt;/span&gt;(key)) since.&lt;span class=&quot;hljs-title function_&quot;&gt;set&lt;/span&gt;(key, at);
    &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (at - since.&lt;span class=&quot;hljs-title function_&quot;&gt;get&lt;/span&gt;(key) &amp;lt; &lt;span class=&quot;hljs-number&quot;&gt;1000&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
    fired.&lt;span class=&quot;hljs-title function_&quot;&gt;add&lt;/span&gt;(key);
    &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;;
  };
}
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; first = &lt;span class=&quot;hljs-title function_&quot;&gt;detector&lt;/span&gt;();
assert.&lt;span class=&quot;hljs-title function_&quot;&gt;equal&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;first&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;0.5&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;), &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;);
assert.&lt;span class=&quot;hljs-title function_&quot;&gt;equal&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;first&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;999&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;0.5&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;), &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;);
assert.&lt;span class=&quot;hljs-title function_&quot;&gt;equal&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;first&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;1000&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;0.5&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;), &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;);
assert.&lt;span class=&quot;hljs-title function_&quot;&gt;equal&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;first&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;2000&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;), &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; interrupted = &lt;span class=&quot;hljs-title function_&quot;&gt;detector&lt;/span&gt;();
&lt;span class=&quot;hljs-title function_&quot;&gt;interrupted&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;);
&lt;span class=&quot;hljs-title function_&quot;&gt;interrupted&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;700&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;0.49&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;);
&lt;span class=&quot;hljs-title function_&quot;&gt;interrupted&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;800&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;);
assert.&lt;span class=&quot;hljs-title function_&quot;&gt;equal&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;interrupted&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;1000&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;), &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;);
assert.&lt;span class=&quot;hljs-title function_&quot;&gt;equal&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;interrupted&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;1800&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;), &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; background = &lt;span class=&quot;hljs-title function_&quot;&gt;detector&lt;/span&gt;();
&lt;span class=&quot;hljs-title function_&quot;&gt;background&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;);
&lt;span class=&quot;hljs-title function_&quot;&gt;background&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;800&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;);
assert.&lt;span class=&quot;hljs-title function_&quot;&gt;equal&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;background&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;1200&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;), &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;);
assert.&lt;span class=&quot;hljs-title function_&quot;&gt;equal&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;background&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;2200&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;), &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;);
assert.&lt;span class=&quot;hljs-title function_&quot;&gt;throws&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;background&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;2100&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;));
assert.&lt;span class=&quot;hljs-title function_&quot;&gt;throws&lt;/span&gt;(&lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;detector&lt;/span&gt;()(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1.1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;));
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; anotherPage = &lt;span class=&quot;hljs-title function_&quot;&gt;detector&lt;/span&gt;();
&lt;span class=&quot;hljs-title function_&quot;&gt;anotherPage&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;);
assert.&lt;span class=&quot;hljs-title function_&quot;&gt;equal&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;anotherPage&lt;/span&gt;(&lt;span class=&quot;hljs-number&quot;&gt;1000&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;card-a&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;feed&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;), &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;);
&lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;PASS: 11 synthetic impression checks&#39;&lt;/span&gt;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;999毫秒不触发、1000毫秒触发，能防止“看起来差不多”的边界漂移。700毫秒时比例跌破阈值后，重新进入要从800毫秒重新计时；切后台也会清空尚未合格的持续区间。同一页面实例内已经触发的卡片再次观察不重复，新页面实例则重新建立自己的去重范围。&lt;/p&gt;
&lt;p&gt;这里用 &lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FJavaScript%2FReference%2FGlobal_Objects%2FSet&quot; target=&quot;_blank&quot; title=&quot;https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;Set&lt;/a&gt; 保存已经触发的逻辑键，用 &lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fnodejs.org%2Fapi%2Fassert.html&quot; target=&quot;_blank&quot; title=&quot;https://nodejs.org/api/assert.html&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;Node strict assert&lt;/a&gt; 让失败明确中断。它是内存教学模型，不具备进程恢复、跨标签页共享或分布式去重。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-3&quot;&gt;去重键不要跟着节点身份走&lt;/h2&gt;
&lt;p&gt;虚拟列表可能把同一个节点先用于内容A，再用于内容B。如果用DOM节点本身作为唯一键，后者可能被错误压掉；如果每次渲染都生成随机新键，前者又可能被重复计数。键应来自明确的业务范围，而不是渲染框架的实现细节。&lt;/p&gt;
&lt;p&gt;本文选择“页面实例＋内容＋位置”。实际业务也可能选择“会话＋内容”或“内容展示机会ID”；这些口径不能混着比较。同一内容在主列表和推荐栏出现，是否应该算两个机会，需要先回答报表要解释哪个位置，而不是在查询时临时拼补。&lt;/p&gt;
&lt;p&gt;多卡片适配器在收到页面隐藏信号时，应及时向模型提供这一状态，使全部未完成区间失效。卡片卸载、换内容或退出观察时也要取消相应的等待检查。模型假设影响条件的状态变化已按顺序提供；中途信号丢失时，不能仅用首尾两点证明真实连续可见。示例没有处理资源释放、观察数据丢失、乱序补报或完整设备生命周期，不能直接贴进生产当成完整SDK。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-4&quot;&gt;上线前保留三层验收证据&lt;/h2&gt;
&lt;p&gt;前端测试先证明候选事件何时产生：卡片未进入视口、短暂划过、反复进出、切后台、虚拟列表换内容和路由返回都要覆盖。真实设备测试再检查时序、遮挡、定时节流及低版本行为，Node断言不能替代这一步。&lt;/p&gt;
&lt;p&gt;接收测试证明一个固定事件ID在重试后怎样落地；报表测试最后证明“候选次数、唯一曝光、曝光人数”分别取什么范围。一个事件在控制台出现，只覆盖了这条链路的一小部分。&lt;/p&gt;
&lt;p&gt;运营对点击率有疑问时，先把点击与曝光的内容、位置、页面范围和统计窗口对齐。不要拿全站点击人数除某个组件的曝光次数，也不要因为用户先点击就倒推出缺失的曝光事实。下一步最有效的行动，是把上面的合成边界换成自己组件的真实场景测试，而不是先改报表颜色。&lt;/p&gt;</description><link>https://juejin.cn/post/7697056782357168171</link><guid isPermaLink="false">https://juejin.cn/post/7697056782357168171</guid><pubDate>Fri, 16 Oct 2026 09:51:43 GMT</pubDate><author>SensorFlow</author><category>前端</category><category>JavaScript</category></item><item><title>REA 火了：一个 Electron 按钮的调用链，它能追到哪一步？</title><description>&lt;p&gt;今天刷掘金榜单，连续看到几篇 REA。名字也很直接：Reverse Engineer Anything，逆向一切。&lt;/p&gt;
&lt;p&gt;看完介绍，我想试一个小问题：给它一个 Electron 项目的源码目录，它能不能顺着「添加草稿」这个按钮，找到最后处理数据的代码？&lt;/p&gt;
&lt;p&gt;结果有点意思。它配上了两条 IPC 通道，也标出了代码位置；但页面里的调用，还有一部分停在 &lt;code&gt;unknown&lt;/code&gt;。&lt;/p&gt;
&lt;p&gt;先交代这次实际做了什么：准备了一个自写的 5 文件样本，用 REA 6.5.0 做静态分析。没有启动 Electron，也没有让模型复刻界面。这篇记录的是工具输出和源码复核。&lt;/p&gt;
&lt;p&gt;&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fmorluto%2Frea&quot; target=&quot;_blank&quot; title=&quot;https://github.com/morluto/rea&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;REA&lt;/a&gt; 是给 AI Agent 使用的逆向分析工具，也提供 CLI。它可以把代码结构和分析证据交给模型，继续追踪某个功能。这里先单独跑 CLI，看看模型拿到的材料到底是什么。&lt;/p&gt;
&lt;p&gt;样本很小：&lt;code&gt;main.js&lt;/code&gt; 管主进程，&lt;code&gt;preload.js&lt;/code&gt; 暴露桥接方法，&lt;code&gt;renderer.js&lt;/code&gt; 处理页面操作，再加一个 HTML 和 &lt;code&gt;package.json&lt;/code&gt;。功能只有添加、统计和清空草稿。&lt;/p&gt;
&lt;p&gt;本机用 Node 24.19.0，REA 固定为 6.5.0。首次安装停在 Frida 的依赖安装脚本，后来使用已下载的缓存、跳过安装脚本，完成了静态分析。下面是成功执行的命令，样本路径换成了占位符：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;npm --cache /private/tmp/rea-demo-npm-cache --offline --ignore-scripts \
  &lt;span class=&quot;hljs-built_in&quot;&gt;exec&lt;/span&gt; --&lt;span class=&quot;hljs-built_in&quot;&gt;yes&lt;/span&gt; --package rea-agents@6.5.0 -- \
  rea analyze-javascript-application /absolute/path/to/sample-app --json \
  &amp;gt; application-evidence.json
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这里的 &lt;code&gt;--offline&lt;/code&gt; 依赖前一次已经下载的缓存，首次安装不能直接照抄。常规运行方式见&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fgithub.com%2Fmorluto%2Frea%2Fblob%2Fmain%2Fdocs%2Fcli.md&quot; target=&quot;_blank&quot; title=&quot;https://github.com/morluto/rea/blob/main/docs/cli.md&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;官方 CLI 文档&lt;/a&gt;。跳过安装脚本只在本文的 JavaScript 分析中验证过，原生分析能力没有测试。&lt;/p&gt;
&lt;p&gt;输出中，3 个 JavaScript 文件均解析成功，找到了 1 个 BrowserWindow、1 个 preload 入口和 1 个桥接 API。更具体的结果是下面这组代码。&lt;/p&gt;
&lt;p&gt;&lt;code&gt;preload.js&lt;/code&gt; 把添加方法交给页面：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;contextBridge.&lt;span class=&quot;hljs-title function_&quot;&gt;exposeInMainWorld&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;drafts&#39;&lt;/span&gt;, {
  &lt;span class=&quot;hljs-attr&quot;&gt;add&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;text&lt;/span&gt;) =&amp;gt;&lt;/span&gt; ipcRenderer.&lt;span class=&quot;hljs-title function_&quot;&gt;invoke&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;draft:add&#39;&lt;/span&gt;, text),
  &lt;span class=&quot;hljs-attr&quot;&gt;list&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; ipcRenderer.&lt;span class=&quot;hljs-title function_&quot;&gt;invoke&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;draft:list&#39;&lt;/span&gt;),
  &lt;span class=&quot;hljs-attr&quot;&gt;onCleared&lt;/span&gt;: &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;callback&lt;/span&gt;) =&amp;gt;&lt;/span&gt;
    ipcRenderer.&lt;span class=&quot;hljs-title function_&quot;&gt;on&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;draft:cleared&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;callback&lt;/span&gt;())
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;main.js&lt;/code&gt; 接收添加请求：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;let&lt;/span&gt; drafts = [];

ipcMain.&lt;span class=&quot;hljs-title function_&quot;&gt;handle&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;draft:add&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-function&quot;&gt;(&lt;span class=&quot;hljs-params&quot;&gt;_event, text&lt;/span&gt;) =&amp;gt;&lt;/span&gt; {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; draft = { &lt;span class=&quot;hljs-attr&quot;&gt;id&lt;/span&gt;: drafts.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; + &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;text&lt;/span&gt;: &lt;span class=&quot;hljs-title class_&quot;&gt;String&lt;/span&gt;(text).&lt;span class=&quot;hljs-title function_&quot;&gt;trim&lt;/span&gt;() };
  drafts.&lt;span class=&quot;hljs-title function_&quot;&gt;push&lt;/span&gt;(draft);
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; draft;
});
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;REA 将 preload 中的 &lt;code&gt;invoke&lt;/code&gt; 和 main 中的 &lt;code&gt;handle&lt;/code&gt; 配了起来。下面是原始输出中这条关系的部分字段，省略了 ID 等内容：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-json&quot; lang=&quot;json&quot;&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;{&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;channel&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;draft:add&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;pairing_basis&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;unique-exact-literal-channel&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;handler_path&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;main.js&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;
  &lt;span class=&quot;hljs-attr&quot;&gt;&quot;handler_operation&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;handle&quot;&lt;/span&gt;
&lt;span class=&quot;hljs-punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是根据唯一、相同的通道名，定位到了另一端的处理函数。&lt;code&gt;draft:list&lt;/code&gt; 也完成了配对。&lt;/p&gt;
&lt;p&gt;对接手陌生项目的人来说，这种结果有用：至少知道接下来该打开哪个文件，而不是只拿到一段「这是一个草稿管理程序」的概括。不过，这条静态关系还不能证明运行时真的调用成功。&lt;/p&gt;
&lt;p&gt;接着看页面代码：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-variable language_&quot;&gt;window&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;drafts&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;add&lt;/span&gt;(editor.&lt;span class=&quot;hljs-property&quot;&gt;value&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; drafts = &lt;span class=&quot;hljs-keyword&quot;&gt;await&lt;/span&gt; &lt;span class=&quot;hljs-variable language_&quot;&gt;window&lt;/span&gt;.&lt;span class=&quot;hljs-property&quot;&gt;drafts&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;list&lt;/span&gt;();
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;人对照刚才的 preload，很容易理解这里想调用什么。但在这次输出的语义图里，这两个调用仍然有未解析记录。工具找到了桥接 API，不代表页面到主进程的每一段关系都已经自动串好。&lt;/p&gt;
&lt;p&gt;清空功能也一样。源码里有 &lt;code&gt;mainWindow.webContents.send(&#39;draft:cleared&#39;)&lt;/code&gt;，preload 里有对应监听；这次结构图识别了监听，却没有生成主进程一侧对应的发送边。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;所以，报告里没找到某条关系，不能直接写成代码里没有这段逻辑。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;这也是把报告交给模型时最值得保留的信息。只给模型一张整理得很漂亮的调用图，把 &lt;code&gt;unknown&lt;/code&gt; 和限制说明删掉，它后面就更难区分哪些是工具找到的，哪些需要回到源码继续查。&lt;/p&gt;
&lt;p&gt;还有一个小细节：草稿存到哪里了？&lt;/p&gt;
&lt;p&gt;回看上面的 &lt;code&gt;main.js&lt;/code&gt;，处理函数只是往数组里放数据。检查这个样本的源码，没有实现磁盘持久化。因此「找到了添加草稿的处理代码」可以说，「已经验证草稿保存成功，重启也不会丢」就说过头了。后一句还需要运行应用、重启和核对结果。&lt;/p&gt;
&lt;p&gt;如果下一步接到 AI 编程助手里，我会把任务缩小到这几件事：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;沿着添加草稿的功能继续查。每一步给出文件和行号，区分工具已建立的关系与需要读源码补充的关系。遇到 unknown，说明缺什么证据。最后检查数据存在哪里，并列出还需要怎样的运行验证。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;下一步可以固定这份证据和问题，比较不同模型会不会主动追查 &lt;code&gt;unknown&lt;/code&gt;。编程助手如果已通过 &lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fmoyi-api.com%2Fdocs%2Fapi-integration&quot; target=&quot;_blank&quot; title=&quot;https://moyi-api.com/docs/api-integration&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;moyi API&lt;/a&gt; 接入支持的模型，就能沿用模型入口，REA 的工具连接仍需单独配置。这次没有做模型联调，因此还不能评价哪种组合效果更好。&lt;/p&gt;
&lt;p&gt;跑完这个小样本，我更愿意把 REA 用在「帮我找到该读的代码」上。找到位置后，继续读、继续验证，比直接让 AI 给整个软件下结论踏实得多。&lt;/p&gt;
&lt;p&gt;你们接手陌生项目时，会先让 AI 通读整个仓库，还是先挑一个具体功能，把调用过程追到底？&lt;/p&gt;</description><link>https://juejin.cn/post/7697024318929043496</link><guid isPermaLink="false">https://juejin.cn/post/7697024318929043496</guid><pubDate>Fri, 16 Oct 2026 09:20:26 GMT</pubDate><author>日耗10亿Token被老板赶出门</author><category>人工智能</category><category>Electron</category><category>JavaScript</category></item><item><title>能解码不等于能信任：JWT 的签名到底验的是什么</title><description>&lt;blockquote&gt;
&lt;p&gt;任何人都能读出 JWT 里的内容——它只是 Base64URL，不是加密。真正决定这个令牌能否被信任的是签名，而签名的验证有几种经典的绕过方式。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-id=&quot;heading-0&quot;&gt;先亲手造一个，再亲手读出来&lt;/h2&gt;
&lt;p&gt;关于 JWT 的所有误解，都能靠亲手造一个来消除。不需要任何依赖，Node 自带的 &lt;code&gt;crypto&lt;/code&gt; 就够：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; { createHmac } &lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;node:crypto&#39;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;b64u&lt;/span&gt; = (&lt;span class=&quot;hljs-params&quot;&gt;obj&lt;/span&gt;) =&amp;gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Buffer&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;stringify&lt;/span&gt;(obj)).&lt;span class=&quot;hljs-title function_&quot;&gt;toString&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;);
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; secret = &lt;span class=&quot;hljs-string&quot;&gt;&#39;dev-secret-not-a-real-key&#39;&lt;/span&gt;;

&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; h = &lt;span class=&quot;hljs-title function_&quot;&gt;b64u&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;alg&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;HS256&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;typ&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;JWT&#39;&lt;/span&gt; });
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; p = &lt;span class=&quot;hljs-title function_&quot;&gt;b64u&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;sub&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;u_1024&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;role&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;user&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;exp&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;1790000000&lt;/span&gt; });
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; s = &lt;span class=&quot;hljs-title function_&quot;&gt;createHmac&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;sha256&#39;&lt;/span&gt;, secret).&lt;span class=&quot;hljs-title function_&quot;&gt;update&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${h}&lt;/span&gt;.&lt;span class=&quot;hljs-subst&quot;&gt;${p}&lt;/span&gt;`&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;digest&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;);

&lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${h}&lt;/span&gt;.&lt;span class=&quot;hljs-subst&quot;&gt;${p}&lt;/span&gt;.&lt;span class=&quot;hljs-subst&quot;&gt;${s}&lt;/span&gt;`&lt;/span&gt;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我在 Node v22.17.1 / macOS 26.5.2 上跑出来的结果，本文后面所有例子都基于这一个令牌：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs&quot;&gt;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1XzEwMjQiLCJyb2xlIjoidXNlciIsImV4cCI6MTc5MDAwMDAwMH0.1Uq490UTCjQu_qA7RScjHcHUgT65G2nBnogsqjkr3P0
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;三段用点号隔开，长度分别是 36 / 63 / 43 个字符。把前两段丢回去解码，得到的就是你刚刚写进去的那两个 JSON 对象——一个字节都没变：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-title class_&quot;&gt;Buffer&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;(h, &lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;toString&lt;/span&gt;(); &lt;span class=&quot;hljs-comment&quot;&gt;// {&quot;alg&quot;:&quot;HS256&quot;,&quot;typ&quot;:&quot;JWT&quot;}&lt;/span&gt;
&lt;span class=&quot;hljs-title class_&quot;&gt;Buffer&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;(p, &lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;toString&lt;/span&gt;(); &lt;span class=&quot;hljs-comment&quot;&gt;// {&quot;sub&quot;:&quot;u_1024&quot;,&quot;role&quot;:&quot;user&quot;,&quot;exp&quot;:1790000000}&lt;/span&gt;
&lt;span class=&quot;hljs-title class_&quot;&gt;Buffer&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;(s, &lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;).&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt;;     &lt;span class=&quot;hljs-comment&quot;&gt;// 32&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;第三段解码出来是 32 个&lt;strong&gt;字节&lt;/strong&gt;而不是文本——那是 HMAC-SHA256 的输出，256 位。它不是「加密后的前两段」，前两段并没有被藏起来，它只是对前两段算出来的一个固定长度的摘要。顺带解释一个人人都见过、很少有人追问的现象：&lt;strong&gt;几乎所有 JWT 都以 &lt;code&gt;eyJ&lt;/code&gt; 开头&lt;/strong&gt;，因为紧凑序列化的 header JSON 必然以 &lt;code&gt;{&quot;&lt;/code&gt; 加一个字母开头，而 &lt;code&gt;{&quot;a&lt;/code&gt; 编码出来是 &lt;code&gt;eyJh&lt;/code&gt;、&lt;code&gt;{&quot;z&lt;/code&gt; 是 &lt;code&gt;eyJ6&lt;/code&gt;，前三个字符恒为 &lt;code&gt;eyJ&lt;/code&gt;。这不是什么魔法前缀，只是 Base64 的对齐结果。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-1&quot;&gt;Base64URL 不是加密，而且它和 Base64 差三个字符&lt;/h2&gt;
&lt;p&gt;「JWT 是加密的吗」这个问题之所以反复被问，很大程度上是因为那一长串东西看起来像密文。它不是。Base64URL 是编码，编码是可逆的、不需要密钥的公开变换，它和 Base64 的区别只有三处：&lt;code&gt;+&lt;/code&gt; 换成 &lt;code&gt;-&lt;/code&gt;、&lt;code&gt;/&lt;/code&gt; 换成 &lt;code&gt;_&lt;/code&gt;、末尾的 &lt;code&gt;=&lt;/code&gt; 填充去掉。用五个字节就能把这三处差异一次看全：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; b = &lt;span class=&quot;hljs-title class_&quot;&gt;Buffer&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;([&lt;span class=&quot;hljs-number&quot;&gt;0xfb&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;0xef&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;0xbe&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;0x3f&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;0xff&lt;/span&gt;]);
b.&lt;span class=&quot;hljs-title function_&quot;&gt;toString&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;base64&#39;&lt;/span&gt;);    &lt;span class=&quot;hljs-comment&quot;&gt;// &#39;++++P/8=&#39;&lt;/span&gt;
b.&lt;span class=&quot;hljs-title function_&quot;&gt;toString&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;); &lt;span class=&quot;hljs-comment&quot;&gt;// &#39;----P_8&#39;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这三处差异的用意是让编码结果能安全地出现在 URL 路径、查询串和 HTTP 头里，不必再做一次百分号转义。但它也埋了一个很容易踩的坑：&lt;strong&gt;很多命令行 base64 工具既不认 &lt;code&gt;-&lt;/code&gt; 和 &lt;code&gt;_&lt;/code&gt;，也不容忍缺失的填充&lt;/strong&gt;，而且出错时不吭声。在 macOS 上直接把 payload 段喂给 &lt;code&gt;base64 -d&lt;/code&gt;：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;T=&lt;span class=&quot;hljs-string&quot;&gt;&#39;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1XzEwMjQiLCJyb2xlIjoidXNlciIsImV4cCI6MTc5MDAwMDAwMH0.1Uq490UTCjQu_qA7RScjHcHUgT65G2nBnogsqjkr3P0&#39;&lt;/span&gt;
&lt;span class=&quot;hljs-built_in&quot;&gt;echo&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;&lt;span class=&quot;hljs-variable&quot;&gt;$T&lt;/span&gt;&quot;&lt;/span&gt; | &lt;span class=&quot;hljs-built_in&quot;&gt;cut&lt;/span&gt; -d. -f2 | &lt;span class=&quot;hljs-built_in&quot;&gt;base64&lt;/span&gt; -d
&lt;span class=&quot;hljs-comment&quot;&gt;# {&quot;sub&quot;:&quot;u_1024&quot;,&quot;role&quot;:&quot;user&quot;,&quot;exp&quot;:179000000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它的退出码是 0，没有任何警告。payload 段是 63 个字符，63 除以 4 余 3，最后那组三个字符还差一个 &lt;code&gt;=&lt;/code&gt; 才完整，于是 FreeBSD 版 base64（macOS 自带的就是它）把这不完整的一组整个丢掉了——少了两个字符 &lt;code&gt;0}&lt;/code&gt;。注意后果的形状：花括号没了，输出连合法 JSON 都不是；更阴险的是 &lt;code&gt;exp&lt;/code&gt; 从 1790000000 变成了 179000000，少一个零，&lt;strong&gt;看起来仍然是个像模像样的时间戳&lt;/strong&gt;。这一段里恰好没有 &lt;code&gt;-&lt;/code&gt; 和 &lt;code&gt;_&lt;/code&gt;，所以它栽的纯粹是填充；换成签名段就是另一种栽法——那一段里有一个 &lt;code&gt;_&lt;/code&gt;，&lt;code&gt;base64 -d&lt;/code&gt; 同样一声不吭，吐出 30 个字节，而真正的 HMAC-SHA256 签名是 32 个字节。换成 Node 的 &lt;code&gt;base64url&lt;/code&gt; 解码就没有这个问题：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;node -e &lt;span class=&quot;hljs-string&quot;&gt;&quot;console.log(Buffer.from(process.argv[1].split(&#39;.&#39;)[1],&#39;base64url&#39;).toString())&quot;&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&quot;&lt;span class=&quot;hljs-variable&quot;&gt;$T&lt;/span&gt;&quot;&lt;/span&gt;
&lt;span class=&quot;hljs-comment&quot;&gt;# {&quot;sub&quot;:&quot;u_1024&quot;,&quot;role&quot;:&quot;user&quot;,&quot;exp&quot;:1790000000}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;结论很直接：payload 里的每一项声明，对任何拿到令牌的人都是明文可读的。用户 ID、角色、租户编号放进去没问题，因为它们本来就是这个用户自己的身份；但内部主键、手机号、邮箱、任何你不愿写进日志的字段，都不该出现在那里。&lt;strong&gt;签名保证的是「没被改过」，不是「看不见」。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 data-id=&quot;heading-2&quot;&gt;签名覆盖的是前两段，而且只覆盖前两段&lt;/h2&gt;
&lt;p&gt;签名的输入是 &lt;code&gt;header段 + &quot;.&quot; + payload段&lt;/code&gt; 这个字符串——注意是&lt;strong&gt;编码后&lt;/strong&gt;的两段原文，不是解码出来的对象。所以只要这两段里任何一个字节变了，签名就对不上。改 payload 会被抓到，改 header 里一个跟算法毫不相干的字段也会：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;sign&lt;/span&gt; = (&lt;span class=&quot;hljs-params&quot;&gt;h, p&lt;/span&gt;) =&amp;gt; &lt;span class=&quot;hljs-title function_&quot;&gt;createHmac&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;sha256&#39;&lt;/span&gt;, secret).&lt;span class=&quot;hljs-title function_&quot;&gt;update&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${h}&lt;/span&gt;.&lt;span class=&quot;hljs-subst&quot;&gt;${p}&lt;/span&gt;`&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;digest&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;);

&lt;span class=&quot;hljs-comment&quot;&gt;// 把 role 从 user 改成 admin，签名段沿用原来的&lt;/span&gt;
&lt;span class=&quot;hljs-title function_&quot;&gt;sign&lt;/span&gt;(h, &lt;span class=&quot;hljs-title function_&quot;&gt;b64u&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;sub&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;u_1024&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;role&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;admin&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;exp&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;1790000000&lt;/span&gt; })) === s; &lt;span class=&quot;hljs-comment&quot;&gt;// false&lt;/span&gt;
&lt;span class=&quot;hljs-comment&quot;&gt;// 只把 typ 从 JWT 改成 at+jwt，签名段沿用原来的&lt;/span&gt;
&lt;span class=&quot;hljs-title function_&quot;&gt;sign&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;b64u&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;alg&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;HS256&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;typ&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;at+jwt&#39;&lt;/span&gt; }), p) === s;                   &lt;span class=&quot;hljs-comment&quot;&gt;// false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;两次都是 &lt;code&gt;false&lt;/code&gt;。把这件事说到位：&lt;strong&gt;签名验证通过，能证明的只有一句话——这个 header 与 payload 的组合，确实由持有密钥的一方签发过，并且此后一个字节都没改。&lt;/strong&gt; 它不能证明的东西比能证明的多得多：不能证明令牌没过期，不能证明它没被吊销，不能证明它是发给你这个服务的，也不能证明用户此刻还有那个角色。这些都得靠另外的检查，而且那些检查跟密码学没有半点关系。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-3&quot;&gt;病根只有一句话：验证方读了令牌自称的算法&lt;/h2&gt;
&lt;p&gt;下面这个验证函数写得非常自然，自然到很多人第一次看不出问题在哪：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;naiveVerify&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;token, hmacKey, rsaPublicKey&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [h, p, s] = token.&lt;span class=&quot;hljs-title function_&quot;&gt;split&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;.&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; alg = &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;parse&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;Buffer&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;(h, &lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;toString&lt;/span&gt;()).&lt;span class=&quot;hljs-property&quot;&gt;alg&lt;/span&gt;; &lt;span class=&quot;hljs-comment&quot;&gt;// ← 病根在这一行&lt;/span&gt;
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (alg === &lt;span class=&quot;hljs-string&quot;&gt;&#39;none&#39;&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;;
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (alg === &lt;span class=&quot;hljs-string&quot;&gt;&#39;HS256&#39;&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; s === &lt;span class=&quot;hljs-title function_&quot;&gt;createHmac&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;sha256&#39;&lt;/span&gt;, hmacKey).&lt;span class=&quot;hljs-title function_&quot;&gt;update&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${h}&lt;/span&gt;.&lt;span class=&quot;hljs-subst&quot;&gt;${p}&lt;/span&gt;`&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;digest&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (alg === &lt;span class=&quot;hljs-string&quot;&gt;&#39;RS256&#39;&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;createVerify&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;RSA-SHA256&#39;&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;update&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${h}&lt;/span&gt;.&lt;span class=&quot;hljs-subst&quot;&gt;${p}&lt;/span&gt;`&lt;/span&gt;)
    .&lt;span class=&quot;hljs-title function_&quot;&gt;verify&lt;/span&gt;(rsaPublicKey, &lt;span class=&quot;hljs-title class_&quot;&gt;Buffer&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;(s, &lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;));
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;问题在于 &lt;code&gt;alg&lt;/code&gt; 是从 header 里读出来的，而 header 是攻击者可以随便改的那两段之一。于是「怎么验这个令牌」这个决定，被交给了那个等待被验证的令牌自己。&lt;/p&gt;
&lt;p&gt;最短的利用方式就是 &lt;code&gt;alg: none&lt;/code&gt;。规范里保留这个取值，本意是留给「完整性由传输层等其他手段保证」的场景，但落到一个会照着 header 办事的实现上，它等于一句「不用验」：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; forged = &lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${b64u({ alg: &lt;span class=&quot;hljs-string&quot;&gt;&#39;none&#39;&lt;/span&gt;, typ: &lt;span class=&quot;hljs-string&quot;&gt;&#39;JWT&#39;&lt;/span&gt; })}&lt;/span&gt;.&lt;span class=&quot;hljs-subst&quot;&gt;${b64u({ sub: &lt;span class=&quot;hljs-string&quot;&gt;&#39;u_1024&#39;&lt;/span&gt;, role: &lt;span class=&quot;hljs-string&quot;&gt;&#39;admin&#39;&lt;/span&gt;, exp: &lt;span class=&quot;hljs-number&quot;&gt;1790000000&lt;/span&gt; })}&lt;/span&gt;.`&lt;/span&gt;;
&lt;span class=&quot;hljs-comment&quot;&gt;// eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1XzEwMjQiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3OTAwMDAwMDB9.&lt;/span&gt;
&lt;span class=&quot;hljs-title function_&quot;&gt;naiveVerify&lt;/span&gt;(forged, secret);  &lt;span class=&quot;hljs-comment&quot;&gt;// true —— role 已经是 admin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意末尾那个孤零零的点号：第三段是空的，因为攻击者把签名整个删掉了。伪造这个令牌不需要任何密钥，不需要爆破，甚至不需要看懂 HMAC 是什么——把 header 里的 &lt;code&gt;HS256&lt;/code&gt; 改成 &lt;code&gt;none&lt;/code&gt;、把 payload 改成想要的样子、把签名段清空，三步。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-4&quot;&gt;同一个病根的第二副面孔：HS256 / RS256 混淆&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;alg: none&lt;/code&gt; 太出名了，今天大多数库都会显式拒绝它。但只要「算法由令牌自称」这个病根还在，同一个洞就会换个形状再出现一次，而且这次不需要任何一个可疑的取值。&lt;/p&gt;
&lt;p&gt;前提是服务端用 RS256：私钥签发，&lt;strong&gt;公钥&lt;/strong&gt;验签。公钥按定义是公开的——它可能挂在 &lt;code&gt;/.well-known/jwks.json&lt;/code&gt; 上，可能就印在你的 API 文档里。攻击者拿到这份公钥，把 header 的 &lt;code&gt;alg&lt;/code&gt; 改成 HS256，然后&lt;strong&gt;把公钥的文本当作 HMAC 的密钥&lt;/strong&gt;去签名：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; pub = publicKey.&lt;span class=&quot;hljs-title function_&quot;&gt;export&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;type&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;spki&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;format&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;pem&#39;&lt;/span&gt; });  &lt;span class=&quot;hljs-comment&quot;&gt;// 公开的东西&lt;/span&gt;
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; hh = &lt;span class=&quot;hljs-title function_&quot;&gt;b64u&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;alg&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;HS256&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;typ&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;JWT&#39;&lt;/span&gt; });
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; pp = &lt;span class=&quot;hljs-title function_&quot;&gt;b64u&lt;/span&gt;({ &lt;span class=&quot;hljs-attr&quot;&gt;sub&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;u_1024&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;role&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;admin&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;exp&lt;/span&gt;: &lt;span class=&quot;hljs-number&quot;&gt;1790000000&lt;/span&gt; });
&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; forged = &lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${hh}&lt;/span&gt;.&lt;span class=&quot;hljs-subst&quot;&gt;${pp}&lt;/span&gt;.&lt;span class=&quot;hljs-subst&quot;&gt;${createHmac(&lt;span class=&quot;hljs-string&quot;&gt;&#39;sha256&#39;&lt;/span&gt;, pub).update(&lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${hh}&lt;/span&gt;.&lt;span class=&quot;hljs-subst&quot;&gt;${pp}&lt;/span&gt;`&lt;/span&gt;).digest(&lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;)}&lt;/span&gt;`&lt;/span&gt;;

&lt;span class=&quot;hljs-title function_&quot;&gt;naiveVerify&lt;/span&gt;(forged, pub, pub);  &lt;span class=&quot;hljs-comment&quot;&gt;// true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;我实测的结果就是 &lt;code&gt;true&lt;/code&gt;。成立的原因很朴素：验证方手上只有一份密钥材料，也就是那个公钥；它读到 header 说「这是 HS256」，就把这份材料当成 HMAC 的共享密钥用了。而 HMAC 的验证与签发用的是同一个密钥——于是一个本该只能验证、不能签发的公钥，被降格成了一个人人都有的签发密钥。顺带一提，两种令牌在外形上差别很明显：RS256（2048 位）的签名段是 342 个字符，HS256 的是 43 个，肉眼就能分辨；但没有任何代码会去看这个。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-5&quot;&gt;正确的做法：算法由验证方规定&lt;/h2&gt;
&lt;p&gt;两种攻击的修法是同一个，而且只有一句话：&lt;strong&gt;验证方自己决定接受哪个算法，用哪把密钥，然后拿令牌去对照——绝不反过来问令牌。&lt;/strong&gt; header 里的 &lt;code&gt;alg&lt;/code&gt; 从此只有一个用途，就是和你规定的那个比一下，不相等就直接拒绝。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;function&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;verify&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;token, { algorithms, secret, now = &lt;span class=&quot;hljs-built_in&quot;&gt;Math&lt;/span&gt;.floor(&lt;span class=&quot;hljs-built_in&quot;&gt;Date&lt;/span&gt;.now() / &lt;span class=&quot;hljs-number&quot;&gt;1000&lt;/span&gt;), leeway = &lt;span class=&quot;hljs-number&quot;&gt;60&lt;/span&gt; }&lt;/span&gt;) {
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; parts = token.&lt;span class=&quot;hljs-title function_&quot;&gt;split&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;.&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (parts.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; !== &lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { &lt;span class=&quot;hljs-attr&quot;&gt;ok&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;why&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;不是三段结构&#39;&lt;/span&gt; };
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; [h, p, s] = parts;

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; head = &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;parse&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;Buffer&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;(h, &lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;toString&lt;/span&gt;());
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (!algorithms.&lt;span class=&quot;hljs-title function_&quot;&gt;includes&lt;/span&gt;(head.&lt;span class=&quot;hljs-property&quot;&gt;alg&lt;/span&gt;)) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { &lt;span class=&quot;hljs-attr&quot;&gt;ok&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;why&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;`算法 &lt;span class=&quot;hljs-subst&quot;&gt;${head.alg}&lt;/span&gt; 不在允许列表内`&lt;/span&gt; };

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; expect = &lt;span class=&quot;hljs-title class_&quot;&gt;Buffer&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;(&lt;span class=&quot;hljs-title function_&quot;&gt;createHmac&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;sha256&#39;&lt;/span&gt;, secret).&lt;span class=&quot;hljs-title function_&quot;&gt;update&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;`&lt;span class=&quot;hljs-subst&quot;&gt;${h}&lt;/span&gt;.&lt;span class=&quot;hljs-subst&quot;&gt;${p}&lt;/span&gt;`&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;digest&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;), &lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; got = &lt;span class=&quot;hljs-title class_&quot;&gt;Buffer&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;(s, &lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;);
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (got.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; !== expect.&lt;span class=&quot;hljs-property&quot;&gt;length&lt;/span&gt; || !&lt;span class=&quot;hljs-title function_&quot;&gt;timingSafeEqual&lt;/span&gt;(got, expect)) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { &lt;span class=&quot;hljs-attr&quot;&gt;ok&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;why&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;签名不匹配&#39;&lt;/span&gt; };

  &lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; c = &lt;span class=&quot;hljs-title class_&quot;&gt;JSON&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;parse&lt;/span&gt;(&lt;span class=&quot;hljs-title class_&quot;&gt;Buffer&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;from&lt;/span&gt;(p, &lt;span class=&quot;hljs-string&quot;&gt;&#39;base64url&#39;&lt;/span&gt;).&lt;span class=&quot;hljs-title function_&quot;&gt;toString&lt;/span&gt;());
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; c.&lt;span class=&quot;hljs-property&quot;&gt;exp&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;number&#39;&lt;/span&gt; &amp;amp;&amp;amp; now &amp;gt; c.&lt;span class=&quot;hljs-property&quot;&gt;exp&lt;/span&gt; + leeway) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { &lt;span class=&quot;hljs-attr&quot;&gt;ok&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;why&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;已过期&#39;&lt;/span&gt; };
  &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (&lt;span class=&quot;hljs-keyword&quot;&gt;typeof&lt;/span&gt; c.&lt;span class=&quot;hljs-property&quot;&gt;nbf&lt;/span&gt; === &lt;span class=&quot;hljs-string&quot;&gt;&#39;number&#39;&lt;/span&gt; &amp;amp;&amp;amp; now + leeway &amp;lt; c.&lt;span class=&quot;hljs-property&quot;&gt;nbf&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { &lt;span class=&quot;hljs-attr&quot;&gt;ok&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;false&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;why&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;尚未生效&#39;&lt;/span&gt; };
  &lt;span class=&quot;hljs-keyword&quot;&gt;return&lt;/span&gt; { &lt;span class=&quot;hljs-attr&quot;&gt;ok&lt;/span&gt;: &lt;span class=&quot;hljs-literal&quot;&gt;true&lt;/span&gt;, &lt;span class=&quot;hljs-attr&quot;&gt;claims&lt;/span&gt;: c };
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;同样的三个令牌喂给它，实测输出是：原令牌 &lt;code&gt;{&quot;ok&quot;:true,...}&lt;/code&gt;，&lt;code&gt;alg: none&lt;/code&gt; 伪造 &lt;code&gt;{&quot;ok&quot;:false,&quot;why&quot;:&quot;算法 none 不在允许列表内&quot;}&lt;/code&gt;，一个过期的令牌 &lt;code&gt;{&quot;ok&quot;:false,&quot;why&quot;:&quot;已过期&quot;}&lt;/code&gt;。而 HS256/RS256 混淆那个令牌，只要把允许列表写成 &lt;code&gt;[&#39;RS256&#39;]&lt;/code&gt;，它在算法那一关就被拦下了——实测返回 &lt;code&gt;{&quot;ok&quot;:false,&quot;why&quot;:&quot;算法 HS256 不在允许列表内&quot;}&lt;/code&gt;，根本走不到验签那一步。&lt;/p&gt;
&lt;p&gt;几个细节值得单独点出来。&lt;strong&gt;算法列表是白名单，不是黑名单&lt;/strong&gt;——只拒绝 &lt;code&gt;none&lt;/code&gt; 是不够的，上一节那个例子里没有出现任何可疑取值。&lt;strong&gt;密钥要和算法绑死&lt;/strong&gt;，HS256 用共享密钥、RS256 用公钥，不要让同一份材料被两条分支共用，那正是混淆攻击的落脚点。&lt;strong&gt;签名比对要用 &lt;code&gt;timingSafeEqual&lt;/code&gt;&lt;/strong&gt;，而不是 &lt;code&gt;===&lt;/code&gt;；字符串比较可能在第一个不同的字符处就返回，于是耗时会随着攻击者猜对的前缀变长而变化。&lt;strong&gt;顺序不能反&lt;/strong&gt;：先验签名，再读声明。签名没通过之前，payload 里的每一个字段都是攻击者写的。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-6&quot;&gt;&lt;code&gt;exp&lt;/code&gt; 只是一个数字，不检查就等于没有&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;exp&lt;/code&gt;、&lt;code&gt;nbf&lt;/code&gt;、&lt;code&gt;iat&lt;/code&gt; 都是普通的 JSON 字段，签名只保证它们没被改过，不会替你比较它们。这一点在实测里非常直观：我用一个 &lt;code&gt;exp&lt;/code&gt; 为 1789000000（2026-09-10T00:26:40Z，写这篇文章时是四天前）的令牌，用正确的密钥签好，再交给前面那个 &lt;code&gt;naiveVerify&lt;/code&gt;——它返回 &lt;code&gt;true&lt;/code&gt;。签名确实是有效的，令牌确实是我签发的，而它确实已经过期了。&lt;strong&gt;「签名有效」和「令牌可用」是两个问题，第二个问题的答案不在密码学里，在你的那行 &lt;code&gt;if&lt;/code&gt; 里。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;按 RFC 7519，这三个字段的类型都是 NumericDate，也就是&lt;strong&gt;以秒为单位&lt;/strong&gt;的 Unix 时间戳。JavaScript 的 &lt;code&gt;Date&lt;/code&gt; 要的是毫秒，两者差三个数量级，于是这里成了一个高频 bug 点：把 &lt;code&gt;Date.now()&lt;/code&gt; 直接写进 &lt;code&gt;exp&lt;/code&gt;，令牌会变成永不过期；把 &lt;code&gt;exp&lt;/code&gt; 直接丢给 &lt;code&gt;new Date()&lt;/code&gt;，会得到 1970 年的某一天，于是每个请求都 401。关于这个千倍差在实践中还有哪些形状，可以看&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fgoodtoolkit.com%2Fzh%2Fblog%2Ftimestamp-pitfalls%2F&quot; target=&quot;_blank&quot; title=&quot;https://goodtoolkit.com/zh/blog/timestamp-pitfalls/&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;时间戳的三个坑&lt;/a&gt;。另外别忘了留一点时钟容差（上面那段代码里的 &lt;code&gt;leeway&lt;/code&gt;）：签发方和验证方是两台机器，NTP 有漂移，几十秒的容差能挡掉一大类「刚签发就说过期」的诡异报障。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-7&quot;&gt;吊销是签名在结构上就表达不了的一件事&lt;/h2&gt;
&lt;p&gt;过期至少还写在令牌里，吊销连写都没处写。回头看上一节那个 &lt;code&gt;verify()&lt;/code&gt; 函数：它从头到尾没有做任何一次 I/O——没查数据库，没发网络请求，全部计算都在本地完成。这正是 JWT 被选中的理由（验证方不必和签发方共享会话存储），但同一件事反过来说就是：&lt;strong&gt;令牌一旦签发出去，服务端就不再持有对它的任何控制。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;具体后果是这样的：用户改了密码，那个在三十分钟前签发、还有二十分钟才到期的令牌，照样能用；管理员把某个账号停用了，已经发出去的令牌照样能用；你把 &lt;code&gt;role&lt;/code&gt; 从 admin 降成 user，旧令牌里那个 &lt;code&gt;admin&lt;/code&gt; 照样有效，直到它自己过期为止。这些都不是实现 bug，而是「验证方只做本地计算」这个设计的必然推论——它没有任何渠道知道你在服务端改了什么。&lt;/p&gt;
&lt;p&gt;减损的办法只有两条，而且都要付代价。&lt;strong&gt;一是把 &lt;code&gt;exp&lt;/code&gt; 压短&lt;/strong&gt;，比如访问令牌只给 5 到 15 分钟，配一个可以在服务端撤销的刷新令牌；代价是刷新逻辑本身的复杂度，以及刷新接口成了新的攻击面。&lt;strong&gt;二是引入一份需要查询的状态&lt;/strong&gt;——按 &lt;code&gt;jti&lt;/code&gt; 建吊销名单，或者在用户记录上放一个版本号、令牌里带上同一个版本号，不一致就拒绝；代价是每次验证都要读一次那份状态，于是「无状态验证」这个卖点被还了回去。没有第三条路：要么容忍一个已知长度的时间窗口，要么花一次查询把窗口关掉。选哪个是业务决定，但&lt;strong&gt;必须是一个被明确做出的决定&lt;/strong&gt;，而不是「我们用了 JWT，所以踢下线应该是即时的吧」。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-8&quot;&gt;这个解码器能告诉你什么，不能告诉你什么&lt;/h2&gt;
&lt;p&gt;本站的 &lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fgoodtoolkit.com%2Fzh%2Ft%2Fjwt-decoder%2F&quot; target=&quot;_blank&quot; title=&quot;https://goodtoolkit.com/zh/t/jwt-decoder/&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;JWT 解码工具&lt;/a&gt;只做解码，不验签。原因不是没实现，而是验签需要密钥，而&lt;strong&gt;向你索取生产环境的签名密钥这个动作本身就是错的&lt;/strong&gt;——尤其是 HMAC，验证用的密钥和签发用的是同一个，交出去就等于交出了以你的名义签发任意令牌的能力。一个网页可以承诺「不上传」，但你没有办法在粘贴之前验证这个承诺；解码则完全不需要密钥，所以这个工具只做那件能安全做完的事。&lt;/p&gt;
&lt;p&gt;还有一个边界更靠前：解码器只处理三段结构。如果你粘进去的东西有五段，那多半不是 JWT 而是 JWE——RFC 7516 的紧凑序列化是「受保护头.加密密钥.初始向量.密文.认证标签」五段，其中真正的载荷是加密过的，没有密钥就是一堆二进制，任何解码器都读不出人话。段数不对的时候，先确认你手上的到底是哪一种东西，再去怀疑工具。&lt;/p&gt;
&lt;p&gt;把边界写清楚：这个工具能告诉你这个令牌&lt;strong&gt;说了什么&lt;/strong&gt;——三段各自的内容、&lt;code&gt;exp&lt;/code&gt; 换算成本地时间是什么时候、按你本机的钟算它过没过期。它不能告诉你这个令牌&lt;strong&gt;是不是真的&lt;/strong&gt;。一个能干净解码、格式完好、还没到期的令牌，完全可以是前面那种三步伪造出来的东西。真正回答「能不能信」的只有持有密钥的那台服务器，而它回答这个问题的方式，就是本文中间那几节反复说的那一句：算法由它自己规定，不问令牌。&lt;/p&gt;
&lt;hr&gt;
&lt;p&gt;原文发表于 &lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fgoodtoolkit.com%2Fzh%2Fblog%2Fjwt-decode-vs-verify%2F&quot; target=&quot;_blank&quot; title=&quot;https://goodtoolkit.com/zh/blog/jwt-decode-vs-verify/&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;GoodToolkit&lt;/a&gt;。
文中用到的 JWT 解码工具在 &lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fgoodtoolkit.com%2Fzh%2Ft%2Fjwt-decoder%2F&quot; target=&quot;_blank&quot; title=&quot;https://goodtoolkit.com/zh/t/jwt-decoder/&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;这里&lt;/a&gt;，解码全在浏览器内完成，令牌不会离开你的设备。&lt;/p&gt;</description><link>https://juejin.cn/post/7696889902295138354</link><guid isPermaLink="false">https://juejin.cn/post/7696889902295138354</guid><pubDate>Fri, 16 Oct 2026 08:49:45 GMT</pubDate><author>harris1481543529758</author><category>前端</category><category>JavaScript</category></item><item><title>浏览器为什么加载不了超大图片</title><description>&lt;h2 data-id=&quot;heading-0&quot;&gt;请求明明是 200，为什么浏览器还是加载不了超大图片？&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;一张 87 MB 的 JPEG 在 Network 面板中返回 &lt;code&gt;200 OK&lt;/code&gt;，但页面上却只显示“图片加载失败”。问题究竟出在网络、组件，还是浏览器？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 data-id=&quot;heading-1&quot;&gt;问题现象&lt;/h3&gt;
&lt;p&gt;最近在项目中做了一个成果文件预览功能，图片使用 Element Plus 的 &lt;code&gt;ElImageViewer&lt;/code&gt; 打开。大部分图片都能正常预览，但有几张地图成果始终打不开。&lt;/p&gt;
&lt;p&gt;打开 Chrome DevTools 后可以看到：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;请求状态是 &lt;code&gt;200 OK&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;响应类型是 &lt;code&gt;image/jpeg&lt;/code&gt;；&lt;/li&gt;
&lt;li&gt;文件已经完整下载；&lt;/li&gt;
&lt;li&gt;单张图片大约 40–91 MB；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 仍然触发了 &lt;code&gt;error&lt;/code&gt; 事件。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这说明问题已经不在 HTTP 请求阶段，而是发生在图片解码阶段。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-2&quot;&gt;200 OK 不等于图片能显示&lt;/h3&gt;
&lt;p&gt;浏览器展示一张网络图片，至少要经过以下步骤：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;发起 HTTP 请求。&lt;/li&gt;
&lt;li&gt;下载 JPEG、PNG 或 WebP 压缩数据。&lt;/li&gt;
&lt;li&gt;浏览器解析图片格式。&lt;/li&gt;
&lt;li&gt;将压缩数据解码成内存中的像素位图。&lt;/li&gt;
&lt;li&gt;上传给 GPU 并绘制到页面。&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Network 面板中的 &lt;code&gt;200 OK&lt;/code&gt; 只能证明前两步基本成功，不代表后面的解码和绘制一定成功。&lt;/p&gt;
&lt;p&gt;这也是为什么有时候请求看起来完全正常，页面上却仍然出现破图图标。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-3&quot;&gt;真正需要关注的不是文件大小，而是像素数量&lt;/h3&gt;
&lt;p&gt;这张无法打开的图片参数如下：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;文件格式：JPEG
文件大小：约 87 MB
图片尺寸：34253 × 37794
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;总像素数为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;34253 × 37794 = 1,294,557,882
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是约 &lt;strong&gt;12.95 亿像素&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;JPEG 文件虽然只有 87 MB，但它只是磁盘和网络传输时的压缩体积。浏览器展示图片时，需要将它解码成未压缩的像素数据。&lt;/p&gt;
&lt;p&gt;如果按 RGBA 四通道计算，每个像素需要 4 字节：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;34253 × 37794 × 4
≈ 5,178,231,528 Bytes
≈ 4.82 GiB
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;也就是说，一张看起来只有 87 MB 的 JPEG，解码后可能需要接近 &lt;strong&gt;5 GB 内存&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这还没有计算：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;压缩数据本身占用的内存；&lt;/li&gt;
&lt;li&gt;图片解码过程中的临时缓冲区；&lt;/li&gt;
&lt;li&gt;浏览器渲染进程的其他内存；&lt;/li&gt;
&lt;li&gt;GPU 纹理可能产生的额外副本；&lt;/li&gt;
&lt;li&gt;旋转、缩放或 Canvas 处理时产生的新位图。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;因此，图片加载失败是一个很合理的结果。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-4&quot;&gt;Element Plus 有图片大小限制吗？&lt;/h3&gt;
&lt;p&gt;本次项目使用的是 &lt;code&gt;ElImageViewer&lt;/code&gt;，起初很容易怀疑是组件限制了文件大小。&lt;/p&gt;
&lt;p&gt;但查看组件实现后会发现，它最终仍然是通过普通的 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 加载图片：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-vue&quot; lang=&quot;vue&quot;&gt;&amp;lt;img
  :src=&quot;url&quot;
  :style=&quot;imgStyle&quot;
  @load=&quot;handleImgLoad&quot;
  @error=&quot;handleImgError&quot;
/&amp;gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;ElImageViewer&lt;/code&gt; 提供的是遮罩层、缩放、旋转和拖拽等交互，并没有额外设置几十 MB 或几百 MB 的文件上限。&lt;/p&gt;
&lt;p&gt;真正失败的是浏览器底层图片解码。&lt;/p&gt;
&lt;p&gt;而且浏览器的可用上限并不是一个统一的“文件大小”数值，它会受到以下因素影响：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;图片的宽度和高度；&lt;/li&gt;
&lt;li&gt;像素总量；&lt;/li&gt;
&lt;li&gt;颜色通道与位深；&lt;/li&gt;
&lt;li&gt;操作系统和浏览器实现；&lt;/li&gt;
&lt;li&gt;当前页面及渲染进程的可用内存；&lt;/li&gt;
&lt;li&gt;GPU 支持的最大纹理尺寸。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;所以不应该用“小于 100 MB 就能打开”这种简单规则判断。一张压缩率很高、但尺寸极大的图片，可能比文件更大但像素更少的图片更容易出问题。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-5&quot;&gt;如何定位这类问题？&lt;/h3&gt;
&lt;h4 data-id=&quot;heading-6&quot;&gt;1. 先看 Network，不要只看页面上的破图&lt;/h4&gt;
&lt;p&gt;重点确认：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;Status Code: 200
Content-Type: image/jpeg
Content-Length: 91376018
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;如果状态码不是 200，先排查鉴权、路径、跨域、代理或静态资源映射。&lt;/p&gt;
&lt;p&gt;如果状态码是 200，还需要检查 &lt;code&gt;Content-Type&lt;/code&gt; 是否真的是图片。有些接口即使返回了登录页或 JSON 错误信息，状态码也可能是 200。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-7&quot;&gt;2. 监听 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 的 load 和 error&lt;/h4&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; image = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Image&lt;/span&gt;();

image.&lt;span class=&quot;hljs-property&quot;&gt;onload&lt;/span&gt; = &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;log&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;加载成功&quot;&lt;/span&gt;, {
    &lt;span class=&quot;hljs-attr&quot;&gt;width&lt;/span&gt;: image.&lt;span class=&quot;hljs-property&quot;&gt;naturalWidth&lt;/span&gt;,
    &lt;span class=&quot;hljs-attr&quot;&gt;height&lt;/span&gt;: image.&lt;span class=&quot;hljs-property&quot;&gt;naturalHeight&lt;/span&gt;
  });
};

image.&lt;span class=&quot;hljs-property&quot;&gt;onerror&lt;/span&gt; = &lt;span class=&quot;hljs-function&quot;&gt;&lt;span class=&quot;hljs-params&quot;&gt;event&lt;/span&gt; =&amp;gt;&lt;/span&gt; {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;console&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;error&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&quot;图片下载成功，但解码或渲染失败&quot;&lt;/span&gt;, event);
};

image.&lt;span class=&quot;hljs-property&quot;&gt;src&lt;/span&gt; = imageUrl;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;需要注意：浏览器通常不会在 &lt;code&gt;error&lt;/code&gt; 事件中提供详细的底层解码原因，所以还需要结合文件大小和图片尺寸判断。&lt;/p&gt;
&lt;h4 data-id=&quot;heading-8&quot;&gt;3. 在服务器或本地查看真实尺寸&lt;/h4&gt;
&lt;p&gt;使用 ImageMagick：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;identify large-map.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;magick identify large-map.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 macOS 上也可以使用：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;sips -g pixelWidth -g pixelHeight large-map.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;不要只看资源管理器中的“87 MB”，宽度、高度和总像素量才是排查解码问题的关键。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-9&quot;&gt;方案一：后端生成预览图，原图单独下载&lt;/h3&gt;
&lt;p&gt;这是最通用、实现成本也最低的方案。&lt;/p&gt;
&lt;p&gt;文件上传后，后端同时保存：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;originalUrl&lt;/code&gt;：原始大图，用于下载或专业处理；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;previewUrl&lt;/code&gt;：最长边为 6000–8000 px 的预览图，用于浏览器展示；&lt;/li&gt;
&lt;li&gt;&lt;code&gt;thumbnailUrl&lt;/code&gt;：列表页小图，例如 400–800 px。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;使用 ImageMagick 生成预览图：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-bash&quot; lang=&quot;bash&quot;&gt;magick large-map.jpg \
  -resize &lt;span class=&quot;hljs-string&quot;&gt;&quot;8000x8000&amp;gt;&quot;&lt;/span&gt; \
  -strip \
  -quality 85 \
  preview.jpg
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;&amp;gt;&lt;/code&gt; 表示只缩小比目标尺寸更大的图片，不会放大小图。&lt;/p&gt;
&lt;p&gt;前端优先使用预览地址：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-ts&quot; lang=&quot;ts&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; displayUrl = file.&lt;span class=&quot;hljs-property&quot;&gt;previewUrl&lt;/span&gt; || file.&lt;span class=&quot;hljs-property&quot;&gt;staticUrl&lt;/span&gt;;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;并在界面中保留“下载原图”入口。&lt;/p&gt;
&lt;p&gt;这种方式既保证了预览体验，也不会丢失原始高清文件。&lt;/p&gt;
&lt;p&gt;这个阈值只是业务保护策略，不是浏览器的统一硬限制。应该结合目标设备、图片内容和实测结果调整。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-10&quot;&gt;为什么不能在前端先用 Canvas 压缩？&lt;/h3&gt;
&lt;p&gt;这是一个很常见的想法：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;先把原图加载到 Canvas，然后缩小不就可以了吗？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;问题是，想要将图片绘制到 Canvas，浏览器仍然需要先解码原图。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-js&quot; lang=&quot;js&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; image = &lt;span class=&quot;hljs-keyword&quot;&gt;new&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Image&lt;/span&gt;();
image.&lt;span class=&quot;hljs-property&quot;&gt;src&lt;/span&gt; = hugeImageUrl;

image.&lt;span class=&quot;hljs-property&quot;&gt;onload&lt;/span&gt; = &lt;span class=&quot;hljs-function&quot;&gt;() =&amp;gt;&lt;/span&gt; {
  &lt;span class=&quot;hljs-comment&quot;&gt;// 只有原图解码成功后，才能执行到这里&lt;/span&gt;
  context.&lt;span class=&quot;hljs-title function_&quot;&gt;drawImage&lt;/span&gt;(image, &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;0&lt;/span&gt;, targetWidth, targetHeight);
};
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但现在失败的正是 &lt;code&gt;image.onload&lt;/code&gt; 之前的解码阶段，所以 Canvas 无法作为这个问题的通用补救手段。&lt;/p&gt;
&lt;p&gt;即使某些设备勉强可以解码，创建超大 Canvas 本身也会遇到尺寸与内存限制。&lt;/p&gt;
&lt;h3 data-id=&quot;heading-11&quot;&gt;总结&lt;/h3&gt;
&lt;p&gt;当图片在 Network 面板中返回 &lt;code&gt;200 OK&lt;/code&gt;，但页面仍然加载失败时，不要只盯着跨域、组件或文件体积。&lt;/p&gt;
&lt;p&gt;需要关注的是：&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;code&gt;200 OK&lt;/code&gt; 只代表 HTTP 请求成功，不代表图片解码成功。&lt;/li&gt;
&lt;li&gt;压缩文件体积不等于解码后的内存占用。&lt;/li&gt;
&lt;li&gt;对于 RGBA 图片，可以先用 &lt;code&gt;宽 × 高 × 4&lt;/code&gt; 估算基础内存。&lt;/li&gt;
&lt;/ol&gt;</description><link>https://juejin.cn/post/7697087660390547466</link><guid isPermaLink="false">https://juejin.cn/post/7697087660390547466</guid><pubDate>Fri, 16 Oct 2026 08:29:38 GMT</pubDate><author>chenCode</author><category>前端</category><category>JavaScript</category></item><item><title>HTTP 200，页面仍是旧状态：定位一次前后端字段错配</title><description>&lt;p&gt;AI SaaS工程诊断实战（一）&lt;/p&gt;
&lt;p&gt;点了‘确认订单’，请求返回200，页面还停在草稿。继续给AI一句‘再检查一下前端’，它可能加刷新、加等待，或者把两种字段都兼容。画面终于变了，却没有说清楚问题在哪里。&lt;/p&gt;
&lt;p&gt;遇到这类问题，我会先选定一次操作，把入口、请求、响应、取值和页面放在同一条链上看。下面给一个能本地重做的最小例子。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;本文为构造演示，与我的AI Coding系列共用教学素材。字段错误是人为设置的，不是某个模型或IDE的实测故障；不使用客户资料。文稿由AI辅助整理，示例与结论按文中方式核对。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 data-id=&quot;heading-0&quot;&gt;1. 先观察这个不一致&lt;/h2&gt;
&lt;p&gt;服务里有两个独立对象，初始状态均为&lt;code&gt;draft&lt;/code&gt;。两个页面使用相同处理逻辑，分别确认各自对象；关键差别是前端读取字段。&lt;/p&gt;
&lt;p&gt;确认接口返回：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-json&quot; lang=&quot;json&quot;&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;{&lt;/span&gt;&lt;span class=&quot;hljs-attr&quot;&gt;&quot;id&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;hljs-string&quot;&gt;&quot;broken&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;,&lt;/span&gt;&lt;span class=&quot;hljs-attr&quot;&gt;&quot;status&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;:&lt;/span&gt;&lt;span class=&quot;hljs-string&quot;&gt;&quot;confirmed&quot;&lt;/span&gt;&lt;span class=&quot;hljs-punctuation&quot;&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;故障页面却这样取值：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; next = data.&lt;span class=&quot;hljs-property&quot;&gt;state&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (next !== &lt;span class=&quot;hljs-literal&quot;&gt;undefined&lt;/span&gt;) {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;document&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;getElementById&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;display&#39;&lt;/span&gt;).&lt;span class=&quot;hljs-property&quot;&gt;textContent&lt;/span&gt; = next;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;响应提供&lt;code&gt;status&lt;/code&gt;，页面读取&lt;code&gt;state&lt;/code&gt;，结果为&lt;code&gt;undefined&lt;/code&gt;。条件分支没有更新文本，页面继续显示&lt;code&gt;draft&lt;/code&gt;。服务端再读已经是&lt;code&gt;confirmed&lt;/code&gt;，所以‘请求成功’‘服务状态改变’和‘界面显示正确’是这里需要分别检查的三件事。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-1&quot;&gt;2. 沿一次操作查六个位置&lt;/h2&gt;








































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;位置&lt;/th&gt;&lt;th&gt;检查动作&lt;/th&gt;&lt;th&gt;本例应观察到什么&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;入口&lt;/td&gt;&lt;td&gt;确认点了哪个对象&lt;/td&gt;&lt;td&gt;故障页操作对象为broken&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;请求&lt;/td&gt;&lt;td&gt;看方法、路径与对象ID&lt;/td&gt;&lt;td&gt;POST /api/orders/broken/confirm&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;服务结果&lt;/td&gt;&lt;td&gt;再次读取同一对象&lt;/td&gt;&lt;td&gt;GET返回status=confirmed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;响应&lt;/td&gt;&lt;td&gt;对照实际层级、字段与值&lt;/td&gt;&lt;td&gt;顶层status，值confirmed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;消费&lt;/td&gt;&lt;td&gt;找前端实际读取位置&lt;/td&gt;&lt;td&gt;代码读取state，值undefined&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;展示&lt;/td&gt;&lt;td&gt;读取页面最后状态&lt;/td&gt;&lt;td&gt;仍是draft&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;本例的断点就在响应与前端消费之间。真实项目也可能是环境不一致、操作对象错误、缓存或异步任务尚未完成；这张表帮助定位，不把同一种表象都归为字段问题。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-2&quot;&gt;3. 依据约定决定改哪边&lt;/h2&gt;
&lt;p&gt;本例约定成功响应的顶层字段为&lt;code&gt;status&lt;/code&gt;，所以将前端改为：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-javascript&quot; lang=&quot;javascript&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;const&lt;/span&gt; next = data.&lt;span class=&quot;hljs-property&quot;&gt;status&lt;/span&gt;;
&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; (next !== &lt;span class=&quot;hljs-literal&quot;&gt;undefined&lt;/span&gt;) {
  &lt;span class=&quot;hljs-variable language_&quot;&gt;document&lt;/span&gt;.&lt;span class=&quot;hljs-title function_&quot;&gt;getElementById&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;display&#39;&lt;/span&gt;).&lt;span class=&quot;hljs-property&quot;&gt;textContent&lt;/span&gt; = next;
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这是一处最小修正。&lt;code&gt;data.state ?? data.status&lt;/code&gt;在这里没有必要，因为示例不存在两个需要兼容的正式版本。如果真实服务有版本迁移，兼容可能合理，但要说明兼容谁、何时删除。&lt;/p&gt;
&lt;p&gt;反过来，如果项目的权威约定是&lt;code&gt;state&lt;/code&gt;，该调整的可能是服务端。先找实际约定，再决定改动位置。不要拿本文的字段名当成通用规范。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-3&quot;&gt;4. 修正之后要检查什么&lt;/h2&gt;
&lt;p&gt;在两个独立页面分别执行确认后，对照以下结果：&lt;/p&gt;



































&lt;table&gt;&lt;thead&gt;&lt;tr&gt;&lt;th&gt;观察项&lt;/th&gt;&lt;th&gt;/broken&lt;/th&gt;&lt;th&gt;/fixed&lt;/th&gt;&lt;/tr&gt;&lt;/thead&gt;&lt;tbody&gt;&lt;tr&gt;&lt;td&gt;POST状态&lt;/td&gt;&lt;td&gt;200&lt;/td&gt;&lt;td&gt;200&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;返回status&lt;/td&gt;&lt;td&gt;confirmed&lt;/td&gt;&lt;td&gt;confirmed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;前端读取&lt;/td&gt;&lt;td&gt;state→undefined&lt;/td&gt;&lt;td&gt;status→confirmed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;页面展示&lt;/td&gt;&lt;td&gt;draft&lt;/td&gt;&lt;td&gt;confirmed&lt;/td&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;再执行GET&lt;/td&gt;&lt;td&gt;confirmed&lt;/td&gt;&lt;td&gt;confirmed&lt;/td&gt;&lt;/tr&gt;&lt;/tbody&gt;&lt;/table&gt;
&lt;p&gt;修正页只是证明本次操作中页面消费字段与响应一致。服务使用进程内字典，&lt;strong&gt;没有数据库持久化&lt;/strong&gt;；重启会重置。登录、权限、并发、异步任务、缓存和错误提示均未作为本篇验证结论。&lt;/p&gt;
&lt;p&gt;这里还留有一个刻意简化：缺字段时静默不更新。生产代码需要按实际契约校验结果并展示错误，不应把这段分支直接当成完整异常处理。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-4&quot;&gt;5. 在本地重做&lt;/h2&gt;
&lt;p&gt;把文末代码保存为&lt;code&gt;demo.py&lt;/code&gt;，使用Python 3运行：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;python demo.py
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;分别打开：&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;http://127.0.0.1:8767/broken
http://127.0.0.1:8767/fixed
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;点击‘确认订单’。页面会显示请求、HTTP状态、实际响应、取值字段、页面状态及一次GET再读。用完在终端停止服务。本例仅监听本机，用于教学；Python自带的简单HTTP服务不作为生产部署方案。&lt;/p&gt;
&lt;h2 data-id=&quot;heading-5&quot;&gt;6. 下次可以直接填这张检查卡&lt;/h2&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-text&quot; lang=&quot;text&quot;&gt;本次操作/对象/环境：
期望结果/实际结果：
请求方法、路径、对象ID：
响应状态、层级、字段和类型：
独立读取结果及依据：
前端实际取值与写入位置：
页面最后状态：
断点、改动与验证结果：
尚未检查的环节：
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;先让AI按卡片列证据和未知，再提出修改。一次请求返回成功之后，继续看用户期待的结果有没有发生；把这条链说明白，修复范围通常也更容易确定。&lt;/p&gt;
&lt;p&gt;—— Linksass｜AI SaaS独立开发者&lt;/p&gt;
&lt;h2 data-id=&quot;heading-6&quot;&gt;完整演示代码&lt;/h2&gt;
&lt;p&gt;代码仅使用Python标准库。两个页面和API都由同一服务提供。&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;hljs language-python&quot; lang=&quot;python&quot;&gt;&lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; json
&lt;span class=&quot;hljs-keyword&quot;&gt;from&lt;/span&gt; http.server &lt;span class=&quot;hljs-keyword&quot;&gt;import&lt;/span&gt; BaseHTTPRequestHandler, HTTPServer

orders = {&lt;span class=&quot;hljs-string&quot;&gt;&quot;broken&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;draft&quot;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&quot;fixed&quot;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&quot;draft&quot;&lt;/span&gt;}
PAGE = &lt;span class=&quot;hljs-string&quot;&gt;&#39;&#39;&#39;&amp;lt;!doctype html&amp;gt;&amp;lt;html lang=&quot;zh-CN&quot;&amp;gt;&amp;lt;meta charset=&quot;utf-8&quot;&amp;gt;
&amp;lt;title&amp;gt;前后端字段契约演示&amp;lt;/title&amp;gt;&amp;lt;style&amp;gt;body{font:18px system-ui;max-width:850px;margin:50px auto;line-height:1.8}pre{background:#eef2f6;padding:20px;white-space:pre-wrap}button{font:inherit;padding:8px 20px}&amp;lt;/style&amp;gt;
&amp;lt;h1&amp;gt;前后端字段契约：MODE&amp;lt;/h1&amp;gt;
&amp;lt;p&amp;gt;构造演示：同一响应，分别读取 state / status。服务只在本机运行，存储为进程内字典。&amp;lt;/p&amp;gt;
&amp;lt;p&amp;gt;当前展示：&amp;lt;strong id=&quot;display&quot;&amp;gt;draft&amp;lt;/strong&amp;gt;&amp;lt;/p&amp;gt;
&amp;lt;button id=&quot;confirm&quot;&amp;gt;确认订单&amp;lt;/button&amp;gt;&amp;lt;pre id=&quot;evidence&quot;&amp;gt;尚未操作&amp;lt;/pre&amp;gt;
&amp;lt;script&amp;gt;
document.getElementById(&#39;confirm&#39;).onclick = async () =&amp;gt; {
  const response = await fetch(&#39;/api/orders/MODE/confirm&#39;, {method:&#39;POST&#39;});
  const data = await response.json();
  const next = data.FIELD;
  if (next !== undefined) document.getElementById(&#39;display&#39;).textContent = next;
  const rereadResponse = await fetch(&#39;/api/orders/MODE&#39;);
  const reread = await rereadResponse.json();
  document.getElementById(&#39;evidence&#39;).textContent = JSON.stringify({
    request: &#39;POST /api/orders/MODE/confirm&#39;, http: response.status,
    response: data, frontend_field: &#39;FIELD&#39;, frontend_value: next ?? &#39;(undefined)&#39;,
    displayed: document.getElementById(&#39;display&#39;).textContent,
    reread_http: rereadResponse.status, reread: reread
  }, null, 2);
};
&amp;lt;/script&amp;gt;&amp;lt;/html&amp;gt;&#39;&#39;&#39;&lt;/span&gt;

&lt;span class=&quot;hljs-keyword&quot;&gt;class&lt;/span&gt; &lt;span class=&quot;hljs-title class_&quot;&gt;Handler&lt;/span&gt;(&lt;span class=&quot;hljs-title class_ inherited__&quot;&gt;BaseHTTPRequestHandler&lt;/span&gt;):
    &lt;span class=&quot;hljs-keyword&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;reply&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;self, payload, code=&lt;span class=&quot;hljs-number&quot;&gt;200&lt;/span&gt;, html=&lt;span class=&quot;hljs-literal&quot;&gt;False&lt;/span&gt;&lt;/span&gt;):
        content = payload.encode(&lt;span class=&quot;hljs-string&quot;&gt;&#39;utf-8&#39;&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; html &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; json.dumps(payload).encode(&lt;span class=&quot;hljs-string&quot;&gt;&#39;utf-8&#39;&lt;/span&gt;)
        self.send_response(code)
        self.send_header(&lt;span class=&quot;hljs-string&quot;&gt;&#39;Content-Type&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;text/html; charset=utf-8&#39;&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; html &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;application/json&#39;&lt;/span&gt;)
        self.send_header(&lt;span class=&quot;hljs-string&quot;&gt;&#39;Content-Length&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-built_in&quot;&gt;str&lt;/span&gt;(&lt;span class=&quot;hljs-built_in&quot;&gt;len&lt;/span&gt;(content)))
        self.end_headers()
        self.wfile.write(content)

    &lt;span class=&quot;hljs-keyword&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;do_GET&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;self&lt;/span&gt;):
        mode = self.path.strip(&lt;span class=&quot;hljs-string&quot;&gt;&#39;/&#39;&lt;/span&gt;)
        &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; mode &lt;span class=&quot;hljs-keyword&quot;&gt;in&lt;/span&gt; orders:
            self.reply(PAGE.replace(&lt;span class=&quot;hljs-string&quot;&gt;&#39;MODE&#39;&lt;/span&gt;, mode).replace(&lt;span class=&quot;hljs-string&quot;&gt;&#39;FIELD&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;state&#39;&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; mode == &lt;span class=&quot;hljs-string&quot;&gt;&#39;broken&#39;&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt; &lt;span class=&quot;hljs-string&quot;&gt;&#39;status&#39;&lt;/span&gt;), html=&lt;span class=&quot;hljs-literal&quot;&gt;True&lt;/span&gt;)
        &lt;span class=&quot;hljs-keyword&quot;&gt;elif&lt;/span&gt; self.path.startswith(&lt;span class=&quot;hljs-string&quot;&gt;&#39;/api/orders/&#39;&lt;/span&gt;) &lt;span class=&quot;hljs-keyword&quot;&gt;and&lt;/span&gt; self.path.rsplit(&lt;span class=&quot;hljs-string&quot;&gt;&#39;/&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;)[-&lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;] &lt;span class=&quot;hljs-keyword&quot;&gt;in&lt;/span&gt; orders:
            key = self.path.rsplit(&lt;span class=&quot;hljs-string&quot;&gt;&#39;/&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;)[-&lt;span class=&quot;hljs-number&quot;&gt;1&lt;/span&gt;]
            self.reply({&lt;span class=&quot;hljs-string&quot;&gt;&#39;id&#39;&lt;/span&gt;: key, &lt;span class=&quot;hljs-string&quot;&gt;&#39;status&#39;&lt;/span&gt;: orders[key]})
        &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt;:
            self.reply({&lt;span class=&quot;hljs-string&quot;&gt;&#39;error&#39;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;not found&#39;&lt;/span&gt;}, &lt;span class=&quot;hljs-number&quot;&gt;404&lt;/span&gt;)

    &lt;span class=&quot;hljs-keyword&quot;&gt;def&lt;/span&gt; &lt;span class=&quot;hljs-title function_&quot;&gt;do_POST&lt;/span&gt;(&lt;span class=&quot;hljs-params&quot;&gt;self&lt;/span&gt;):
        parts = self.path.strip(&lt;span class=&quot;hljs-string&quot;&gt;&#39;/&#39;&lt;/span&gt;).split(&lt;span class=&quot;hljs-string&quot;&gt;&#39;/&#39;&lt;/span&gt;)
        &lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; &lt;span class=&quot;hljs-built_in&quot;&gt;len&lt;/span&gt;(parts) == &lt;span class=&quot;hljs-number&quot;&gt;4&lt;/span&gt; &lt;span class=&quot;hljs-keyword&quot;&gt;and&lt;/span&gt; parts[:&lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;] == [&lt;span class=&quot;hljs-string&quot;&gt;&#39;api&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-string&quot;&gt;&#39;orders&#39;&lt;/span&gt;] &lt;span class=&quot;hljs-keyword&quot;&gt;and&lt;/span&gt; parts[&lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;] &lt;span class=&quot;hljs-keyword&quot;&gt;in&lt;/span&gt; orders &lt;span class=&quot;hljs-keyword&quot;&gt;and&lt;/span&gt; parts[&lt;span class=&quot;hljs-number&quot;&gt;3&lt;/span&gt;] == &lt;span class=&quot;hljs-string&quot;&gt;&#39;confirm&#39;&lt;/span&gt;:
            key = parts[&lt;span class=&quot;hljs-number&quot;&gt;2&lt;/span&gt;]
            orders[key] = &lt;span class=&quot;hljs-string&quot;&gt;&#39;confirmed&#39;&lt;/span&gt;
            self.reply({&lt;span class=&quot;hljs-string&quot;&gt;&#39;id&#39;&lt;/span&gt;: key, &lt;span class=&quot;hljs-string&quot;&gt;&#39;status&#39;&lt;/span&gt;: orders[key]})
        &lt;span class=&quot;hljs-keyword&quot;&gt;else&lt;/span&gt;:
            self.reply({&lt;span class=&quot;hljs-string&quot;&gt;&#39;error&#39;&lt;/span&gt;: &lt;span class=&quot;hljs-string&quot;&gt;&#39;not found&#39;&lt;/span&gt;}, &lt;span class=&quot;hljs-number&quot;&gt;404&lt;/span&gt;)

&lt;span class=&quot;hljs-keyword&quot;&gt;if&lt;/span&gt; __name__ == &lt;span class=&quot;hljs-string&quot;&gt;&#39;__main__&#39;&lt;/span&gt;:
    &lt;span class=&quot;hljs-built_in&quot;&gt;print&lt;/span&gt;(&lt;span class=&quot;hljs-string&quot;&gt;&#39;Demo: http://127.0.0.1:8767/broken and /fixed&#39;&lt;/span&gt;, flush=&lt;span class=&quot;hljs-literal&quot;&gt;True&lt;/span&gt;)
    HTTPServer((&lt;span class=&quot;hljs-string&quot;&gt;&#39;127.0.0.1&#39;&lt;/span&gt;, &lt;span class=&quot;hljs-number&quot;&gt;8767&lt;/span&gt;), Handler).serve_forever()

&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;参考：&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fdocs.python.org%2F3.12%2Flibrary%2Fhttp.server.html&quot; target=&quot;_blank&quot; title=&quot;https://docs.python.org/3.12/library/http.server.html&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;Python http.server&lt;/a&gt;、&lt;a href=&quot;https://link.juejin.cn/?target=https%3A%2F%2Fdeveloper.mozilla.org%2Fen-US%2Fdocs%2FWeb%2FAPI%2FResponse%2Fok&quot; target=&quot;_blank&quot; title=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Response/ok&quot; ref=&quot;nofollow noopener noreferrer&quot;&gt;MDN Response.ok&lt;/a&gt;。&lt;/p&gt;</description><link>https://juejin.cn/post/7697075251927072806</link><guid isPermaLink="false">https://juejin.cn/post/7697075251927072806</guid><pubDate>Fri, 16 Oct 2026 08:06:20 GMT</pubDate><author>用户660267611879</author><category>前端</category><category>JavaScript</category></item></channel></rss>