<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>193577746 (kyriewen)</title>
    <link>https://beta.w2solo.com/193577746</link>
    <description>独立开发者 / 前端工程师</description>
    <language>en-us</language>
    <item>
      <title>产品经理把 PRD 写成 “天书”，我用 AI 半小时重写了一遍，他当场愣住</title>
      <description>&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;产品经理和开发之间的矛盾，根源往往不在需求本身，而在于&lt;strong&gt;需求表达方式&lt;/strong&gt;。一个合格的需求文档应该包含：功能描述、业务规则、边界条件、异常处理、验收标准。但现实中，很多 PRD 长这样：&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“用户点击支付后，系统要快速响应，提升支付成功率。”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;这叫什么？这叫作文，不叫需求。快速是多久？提升到多少？失败怎么处理？全没写。&lt;/p&gt;

&lt;p&gt;以前我拿到这种文档，只能自己猜、自己补、自己扛。猜错了，上线出 bug，责任在我；补漏了，工期延误，背锅也是我。后来我想：能不能让 AI 帮我 “翻译” 这份天书？把产品经理的模糊语言，自动转成可执行的技术验收点。&lt;/p&gt;

&lt;p&gt;我试了，效果超出预期。今天我把这套方法拆给你，包括 Prompt 模板、操作步骤、以及我踩过的坑。以后你拿到 “天书”，也可以让 AI 帮你 “翻译”。&lt;/p&gt;
&lt;h2 id="一、先看对比：同一份需求，“人话版”vs“产品版”"&gt;一、先看对比：同一份需求，“人话版” vs“产品版”&lt;/h2&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;strong&gt;我用 AI 翻译后的版本&lt;/strong&gt;：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;【功能】订单列表高级筛选
【输入条件】
- 筛选字段：订单号（文本精确匹配）、订单状态（下拉：待支付/已支付/已取消/退款中）、下单时间（日期范围选择器）
- 默认展示最近30天的订单
【业务规则】
- 多个筛选条件之间为 AND 关系
- 订单号匹配：忽略前后空格，大小写不敏感
- 时间范围：开始时间 &amp;lt;= 结束时间，否则提示“开始时间不能晚于结束时间”
- 无结果时，显示“暂无符合条件的订单”，并提供“清空筛选”按钮
【异常处理】
- 接口超时：提示“查询失败，请重试”，保留当前筛选条件
- 数据量超过1000条：前端分页，每页20条
【验收标准】
- 筛选后URL同步筛选参数，刷新页面保留筛选状态
- 点击“重置”清空所有筛选条件，恢复默认状态
- 筛选操作平均响应时间 &amp;lt; 500ms
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;你看，同一个需求，一个模糊，一个可执行。AI 帮我补全了所有产品经理漏掉的细节。&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;金句&lt;/strong&gt;：产品经理写的是 “愿望”，AI 帮他翻译成 “工程”。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="二、我是怎么做的：三步搞定"&gt;二、我是怎么做的：三步搞定&lt;/h2&gt;&lt;h3 id="第一步：把原始PRD喂给AI"&gt;第一步：把原始 PRD 喂给 AI&lt;/h3&gt;
&lt;p&gt;我打开 Cursor（或任何支持长上下文的 AI 工具），把那份 15 页的 “天书” 分成几个部分粘贴进去。提示词如下：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;你是一位资深技术架构师兼产品顾问。请将以下产品需求文档（PRD）翻译成一份“开发者可执行的技术验收文档”。

要求：
1. 为每个功能点补充：输入条件、业务规则、边界情况、异常处理、验收标准
2. 对模糊的描述（如“体验更好”“性能提升”）给出具体可测量的指标
3. 对缺失的逻辑分支（如空数据、网络超时、权限不足）补充标准处理方式
4. 输出格式使用Markdown，分模块列举

原始PRD内容：
[粘贴内容]
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="第二步：AI生成初稿，我逐条审核"&gt;第二步：AI 生成初稿，我逐条审核&lt;/h3&gt;
&lt;p&gt;AI 生成的文档不可能一次完美。它会过度补充（比如加上 “支持暗黑模式” 这种无关内容），也会漏掉一些特定业务规则。我花 15 分钟逐条过了一遍：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ 保留合理的补充（如边界条件、异常处理）&lt;/li&gt;
&lt;li&gt;❌ 删除过度的猜测（“用户希望导出发送邮件”）&lt;/li&gt;
&lt;li&gt;✏️ 修正错误的业务逻辑（比如我们订单状态只有 5 种，AI 写了 6 种）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="第三步：拿着新文档找产品经理“对线”"&gt;第三步：拿着新文档找产品经理 “对线”&lt;/h3&gt;
&lt;p&gt;我打印出 AI 翻译后的文档，约小马开会。我跟他说：“这是我根据你的 PRD 整理的可执行版本，你看看有没有遗漏。”&lt;/p&gt;

&lt;p&gt;他一页页翻，越翻越慢。翻完后说：“这比我自己写的还细。边界条件我都没想到，你怎么想出来的？” 我笑了笑：“AI 想的。” 他沉默了几秒，然后说：“以后我写完 PRD，你先跑一遍这个流程？”&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;金句&lt;/strong&gt;：最好的需求评审，不是开发挑产品的刺，而是 AI 帮产品补漏。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="三、注意事项：不要完全信任AI"&gt;三、注意事项：不要完全信任 AI&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI 会 “过度脑补”&lt;/strong&gt;：它可能加一些你公司根本不存在的功能。一定要人工审核。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;保护敏感信息&lt;/strong&gt;：不要把公司核心业务数据、未公开的定价策略等喂给云端 AI。可以用本地模型或脱敏。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;与产品保持沟通&lt;/strong&gt;：AI 补充的内容，要让产品确认。不要自作主张改了需求，上线后产品不认账。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;版本管理&lt;/strong&gt;：每次用 AI 生成的新文档，保留原始 PRD 的版本号，方便追溯。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="四、完整的Prompt模板（直接复制用）"&gt;四、完整的 Prompt 模板（直接复制用）&lt;/h2&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# 角色
你是一名资深技术架构师，擅长将模糊的产品需求转化为精确的可执行技术文档。

# 任务
将以下产品需求文档（PRD）翻译成“开发者可执行的技术验收文档”。请遵循以下格式：

## [功能模块名称]
### 输入条件
- 列出所有输入字段、类型、是否必填
### 业务规则
- 具体的计算逻辑、状态流转、权限判断
### 边界情况
- 空数据、极限值、重复提交、并发等
### 异常处理
- 网络超时、接口报错、权限不足、数据不一致
### 验收标准
- 用可量化的指标描述：响应时间、成功率、UI状态等

# 输出要求
- 使用Markdown格式
- 对原始PRD中模糊的词语（如“快速”“友好”“鲁棒”）给出具体数值或标准
- 不要添加与原始需求明显无关的功能

# 原始PRD内容：
[请粘贴内容]
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="五、写在最后"&gt;五、写在最后&lt;/h2&gt;
&lt;p&gt;以前我总觉得产品经理是 “敌人”，后来我发现，他们只是不会用开发的语言表达需求。AI 成了我们之间的 “翻译官”。&lt;/p&gt;

&lt;p&gt;下次你拿到一份 “天书” 般的 PRD，别急着骂，先让 AI 帮你翻译。你会发现，原来产品经理想说的没那么糟，只是他没说清楚。&lt;/p&gt;

&lt;p&gt;你遇到过最离谱的 PRD 是什么样的？后来怎么解决的？&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Thu, 21 May 2026 20:24:33 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7380</link>
      <guid>https://beta.w2solo.com/topics/7380</guid>
    </item>
    <item>
      <title>我用 AI 把公司 10 万行代码屎山重构了，CTO 看了代码后说：你提前转正</title>
      <description>&lt;blockquote&gt;
&lt;p&gt;入职第一天，领导指着项目说：“这个订单模块跑了五年，没人敢大动。你试用期能加个小功能就行，别动别的。” 我不信邪，花了两个月，用 AI 把这堆屎山一点一点铲平了。代码行数砍了 40%，圈复杂度从 45 降到 8，上线一个月零 bug。CTO 在技术会上说：“这个模块过去是雷区，现在是示范区。” 他提前两个月让我转正，还加了薪。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;每个程序员职业生涯中都会遇到一座 “屎山”。可能是前任留下的，可能是一群实习生堆的，也可能是三年前的自己写的（别不承认）。&lt;/p&gt;

&lt;p&gt;我遇到的这座山，是一个老牌电商的后台订单模块。文件平均 1500 行，函数平均 200 行，缩进有的用空格有的用 Tab，注释全是 “// fix” “// todo” “// 别动”。最经典的一个函数叫&lt;code&gt;doSomething()&lt;/code&gt;，里面 switch-case 写了 12 层，处理 12 种订单状态，每种状态里还有嵌套 if-else。&lt;/p&gt;

&lt;p&gt;我想加一个新状态，加了三天，改一处崩三处。第四天我崩溃了：这代码不是人写的，是人挖的坑。&lt;/p&gt;

&lt;p&gt;然后我决定：用 AI，把这堆屎山一点一点铲平。&lt;/p&gt;
&lt;h2 id="一、屎山有多毒？我列了张清单"&gt;一、屎山有多毒？我列了张清单&lt;/h2&gt;
&lt;p&gt;开始之前，我先给这座山做了 “体检”：&lt;/p&gt;
&lt;table class="table table-bordered table-striped"&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;严重程度&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;平均函数行数&lt;/td&gt;
&lt;td&gt;187 行&lt;/td&gt;
&lt;td&gt;&amp;lt;30 行&lt;/td&gt;
&lt;td&gt;🔴 严重&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;最大圈复杂度&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;&amp;lt;10&lt;/td&gt;
&lt;td&gt;🔴 严重&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;重复代码率&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;td&gt;&amp;lt;5%&lt;/td&gt;
&lt;td&gt;🟡 中等&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;注释覆盖率&lt;/td&gt;
&lt;td&gt;3%&lt;/td&gt;
&lt;td&gt;&amp;gt;20%&lt;/td&gt;
&lt;td&gt;🔴 严重&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;单元测试覆盖率&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;&amp;gt;70%&lt;/td&gt;
&lt;td&gt;🔴 致命&lt;/td&gt;
&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;没有人敢动它，因为没有人完全理解它。一个订单状态的修改，可能在 12 个 case 分支里产生蝴蝶效应。&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;金句&lt;/strong&gt;：屎山的可怕不在于它烂，而在于没人敢承认它烂，更没人敢碰它。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="二、我的AI重构三步法"&gt;二、我的 AI 重构三步法&lt;/h2&gt;
&lt;p&gt;我没有一次性推倒重来——那会死得很惨。我用 AI 分三步，每一步都确保功能不变，只改结构。&lt;/p&gt;
&lt;h3 id="第一步：用AI补注释 + 生成文档"&gt;第一步：用 AI 补注释 + 生成文档&lt;/h3&gt;
&lt;p&gt;我选了一个最复杂的文件&lt;code&gt;orderHandler.js&lt;/code&gt;（2200 行），整个丢给 Cursor 的 Composer，提示词：&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;分析这个文件，为每个函数生成 JSDoc 注释（包括参数、返回值、副作用说明），然后生成一个 README，画出主要函数的调用关系图。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;AI 十几秒后输出：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;每个函数前加了&lt;code&gt;@param&lt;/code&gt;、&lt;code&gt;@returns&lt;/code&gt;、&lt;code&gt;@throws&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;识别出三个没有被任何地方调用的 “僵尸函数”&lt;/li&gt;
&lt;li&gt;画了一张 Mermaid 流程图，清晰显示了订单状态的流转路径&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我拿着这张图，第一次看懂了这座山的 “地图”。&lt;/p&gt;
&lt;h3 id="第二步：用AI拆分长函数"&gt;第二步：用 AI 拆分长函数&lt;/h3&gt;
&lt;p&gt;最长的那个&lt;code&gt;processOrder&lt;/code&gt;函数，400 行。我用 Cursor 选中它，输入：&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;将这个函数按单一职责拆分成多个小函数，每个函数不超过 30 行。保持原有逻辑完全不变，不要改变外部接口。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;AI 生成了 8 个小函数：&lt;code&gt;validateOrder&lt;/code&gt;、&lt;code&gt;calculateDiscount&lt;/code&gt;、&lt;code&gt;checkInventory&lt;/code&gt;、&lt;code&gt;createPaymentRecord&lt;/code&gt;……每个都带清晰命名和注释。&lt;/p&gt;

&lt;p&gt;我手动跑了回归测试，全过。这一步，函数平均行数从 187 降到了 42。&lt;/p&gt;
&lt;h3 id="第三步：用AI消除重复代码 + 抽象公共逻辑"&gt;第三步：用 AI 消除重复代码 + 抽象公共逻辑&lt;/h3&gt;
&lt;p&gt;AI 发现三处完全一样的 “价格计算” 逻辑散落在不同文件里。我用重构工具提取成一个共享函数，AI 帮我自动改了所有调用处。&lt;/p&gt;

&lt;p&gt;重复代码率从 23% 降到了 4%。&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;金句&lt;/strong&gt;：AI 帮你写代码不稀奇，AI 帮你删代码才是真本事。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="三、真实案例：一个隐藏了三年的bug被AI揪出来了"&gt;三、真实案例：一个隐藏了三年的 bug 被 AI 揪出来了&lt;/h2&gt;
&lt;p&gt;重构过程中，AI 注意到一段代码：&lt;/p&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;updateInventory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;但上面几行还有一个&lt;code&gt;if (order.paid === true)&lt;/code&gt;的判断，两个条件不完全等价。AI 在注释里标注：&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ 可疑：&lt;code&gt;order.status&lt;/code&gt;和&lt;code&gt;order.paid&lt;/code&gt;似乎表示同一件事，但有两个不同字段。建议确认是否数据不一致导致 bug。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;我查了历史记录，果然三年前有人加了&lt;code&gt;paid&lt;/code&gt;字段，但老代码还在用&lt;code&gt;status&lt;/code&gt;。偶尔因为异步更新不同步，会出现 “已支付但未发货” 的投诉。这个 bug 一直没定位到，被 AI 一眼看出来了。&lt;/p&gt;
&lt;h2 id="四、重构成果：数据不会骗人"&gt;四、重构成果：数据不会骗人&lt;/h2&gt;&lt;table class="table table-bordered table-striped"&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;变化&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;总代码行数&lt;/td&gt;
&lt;td&gt;10.2w&lt;/td&gt;
&lt;td&gt;6.1w&lt;/td&gt;
&lt;td&gt;↓ 40%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;平均函数行数&lt;/td&gt;
&lt;td&gt;187&lt;/td&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;↓ 83%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;圈复杂度（最高）&lt;/td&gt;
&lt;td&gt;45&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;↓ 82%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;重复代码率&lt;/td&gt;
&lt;td&gt;23%&lt;/td&gt;
&lt;td&gt;4%&lt;/td&gt;
&lt;td&gt;↓ 83%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;单元测试覆盖率&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;68%&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;线上故障数（月均）&lt;/td&gt;
&lt;td&gt;4.2&lt;/td&gt;
&lt;td&gt;0.3&lt;/td&gt;
&lt;td&gt;↓ 93%&lt;/td&gt;
&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;CTO 看到报告，在技术会上说：“这个模块过去三年是公司的 ‘雷区’，现在变成了 ‘示范区’。” 他提前两个月给我转了正，还额外给了项目奖金。&lt;/p&gt;
&lt;h2 id="五、注意事项：AI重构的坑"&gt;五、注意事项：AI 重构的坑&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;一定要有测试覆盖&lt;/strong&gt;：没有测试的重构是自杀。我先补了关键路径的集成测试，才敢让 AI 动手。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;分步骤提交&lt;/strong&gt;：不要一次性提交 AI 生成的大几百行改动。按函数拆、按文件拆，每个 PR 只改一个点。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI 也会翻车&lt;/strong&gt;：有一次 AI 把&lt;code&gt;i++&lt;/code&gt;改成了&lt;code&gt;i+1&lt;/code&gt;，导致死循环。所以每次 AI 生成后，必须人工 review 核心逻辑。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;业务敏感代码不要全信 AI&lt;/strong&gt;：涉及金额、库存、权限的判断，手动写测试断言，再跑一遍。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="六、写在最后"&gt;六、写在最后&lt;/h2&gt;
&lt;p&gt;重构屎山不是技术活，是心理活。AI 给了我们一把铲子，但挖哪里、怎么挖、挖多深，还是人决定。&lt;/p&gt;

&lt;p&gt;如果你也接手过屎山，或者正在屎山里挣扎，&lt;strong&gt;点个赞让我看到不是一个人&lt;/strong&gt;。赞多的话，我下一篇写《AI 重构屎山的完整 Prompt 清单，复制就能用》。&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Thu, 21 May 2026 12:00:46 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7372</link>
      <guid>https://beta.w2solo.com/topics/7372</guid>
    </item>
    <item>
      <title>Copilot 下个月按 Token 收钱，我算了一笔账：重度用户一年要多花 3000 块</title>
      <description>&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;2026年4月28日，GitHub 正式宣布：6 月 1 日起，Copilot 将从固定额度订阅制全面转向按使用量计费。&lt;/p&gt;

&lt;p&gt;消息一出，开发者社区炸了锅。一位用户在 Hacker News 上算了一笔账：“如果我在 VS Code 里问 ‘怎么写一个带防抖的 React Hook’，AI 返回 2000 个 token，成本约 6 美分，一天问 30 个问题，月底一看账单，快顶上一个月订阅费了。”&lt;/p&gt;

&lt;p&gt;GitHub 官方给出了不算特别明确的回应——基础订阅价格确实维持不变：Copilot Pro 仍是 10 美元/月，含 10 美元 AI Credits；Pro+ 仍是 39 美元/月，含 39 美元 AI Credits。表面上看是 “换了个收费方式”，但换种更直白的说法，就是 “自助餐时代的终结”。&lt;/p&gt;

&lt;p&gt;这让我想起了前几年的 “共享单车涨价史”：以前 1 块钱能骑一天，后来变成 1 块钱起步、每 15 分钟加 5 毛，你骑着骑着，钱包就不知不觉瘪下去了。Copilot 这次的操作，简直是同一本剧本。&lt;/p&gt;

&lt;p&gt;Cursor 也在收紧免费额度，多款 AI 编程工具相继调整价格策略。到了 2026 年 5 月，想要 “白嫖” 高质量 AI 编程工具，越来越难了。&lt;/p&gt;

&lt;p&gt;今天我就用真实数据，帮你算清楚——下个月开始，你的口袋到底要多掏出多少钱。&lt;/p&gt;
&lt;h2 id="一、Copilot这次到底改了什么？"&gt;一、Copilot 这次到底改了什么？&lt;/h2&gt;
&lt;p&gt;一句话概括：&lt;strong&gt;从此以后，你的每次对话都在花钱。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;旧模式：每月 10 美元，你可以无限次提问 Copilot（虽然有限速）。轻度用户和重度用户交一样的钱。GitHub 官方坦言，过去几年一直在亏本 “赔本赚吆喝”，成本根本兜不住。&lt;/p&gt;

&lt;p&gt;新模式：每月 10 美元，你获得 1000 个 AI Credits（1 Credit = 1 美分）。每次使用，按实际消耗的输入/输出 Token 量扣钱。&lt;/p&gt;

&lt;p&gt;具体扣多少？GitHub 公布了各模型的 Token 费率，以最昂贵的 Anthropic Opus 4.7 为例，输入价格为每百万 Token 约 20 美元，输出价格为每百万 Token 约 100 美元。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;打个比方&lt;/strong&gt;：以前你每个月花 10 块钱，能吃无限量的 “工作餐自助餐”。现在你同样花 10 块钱，获得了一张 1000 块的充值卡。吃一碗牛肉面 20 块，吃一顿火锅 100 块，有人吃得省、有人吃得费。但本质上，你口袋里的钱，正在随着你的 “饭量” 被精确 “收割”。&lt;/p&gt;
&lt;h2 id="二、算一笔账：你是“被加钱”的那类用户吗？"&gt;二、算一笔账：你是 “被加钱” 的那类用户吗？&lt;/h2&gt;
&lt;p&gt;GitHub 官方说得很漂亮：“新模式对轻度用户更友好，对重度用户更可持续。” 但谁才是 “轻度用户”？谁又是 “被加钱的重度用户”？让我们用真实场景逐项分解，看看到底哪些开发者会成为付费大头：&lt;/p&gt;
&lt;h3 id="场景A：日常开发（中等用户）"&gt;场景 A：日常开发（中等用户）&lt;/h3&gt;
&lt;p&gt;假设你是一名普通的前端工程师，日常工作场景包括：写业务代码，遇到问题时向 Copilot 求助，让它解释一段复杂逻辑，偶尔让它帮你写个单元测试。&lt;/p&gt;

&lt;p&gt;每天大概操作如下：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;早间接需求：向 Copilot 提问 “这段递归能不能改成迭代？”&lt;/li&gt;
&lt;li&gt;午间查 bug：发现线上报错，让 Copilot 分析堆栈信息，给出修复建议。&lt;/li&gt;
&lt;li&gt;下午写代码：让 Copilot 生成 “一个带防抖的搜索输入框组件”。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;按照官方给出的各模型费率粗略估算，一天消耗约 80 美分（约 0.8 美元），相当于约 8 块钱人民币。一个月（20 个工作日）下来，总计需要 16 美元左右的 AI Credits，折合人民币约 110 元。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;结论：中等用户每月开销约 16 美元，比原来的 10 美元高出 60%。一个月多花 6 美元，一年多花 72 美元，折合人民币约 500 元。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id="场景B：代码审查/AI Agent用户（重度用户）"&gt;场景 B：代码审查/AI Agent 用户（重度用户）&lt;/h3&gt;
&lt;p&gt;如果你是一名高级程序员，日常工作依赖 Copilot 进行代码审查、大规模重构、或者长时间运行的自动化 AI Agent 任务，那情况就完全不同了。&lt;/p&gt;

&lt;p&gt;今年 4 月底起，GitHub 甚至暂停了 Copilot Pro/Pro+ 的新用户注册。GitHub 官方给出的解释是：AI Agent 式使用正在成为默认形态，这带来了显著更高的计算与推理需求。&lt;/p&gt;

&lt;p&gt;具体来说，使用 Copilot 进行代码审查时，除了消耗 AI Credits，还会额外消耗 GitHub Actions 运行时长。一次深度代码审查，可能需要分析数千行代码差异，同时读取和分析上百个文件的变更记录，仅此一项就可能消耗 200,000 到 300,000 个 Token，约占总配额的 10% 左右。&lt;/p&gt;

&lt;p&gt;如果你的团队每天进行多轮迭代开发，频繁进行代码审查并运行大量 AI Agent 任务，一个月的消耗至少需要 60-80 美元的 AI Credits。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;结论：重度用户每月开销高达 60-80 美元，相当于每月多花 50-70 美元，一年就是 600-840 美元。折合人民币 4,200 到 5,900 元，比原来多花了 5-8 倍。这还没算 Claude Opus 等高端模型（倍率从 7.5 倍飙至 27 倍）的真实消耗&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;一位开发者直言：“新 Copilot Pro 方案下，你会得到更少，却要付同样的价格。”&lt;/p&gt;
&lt;h2 id="三、Cursor也在收紧免费额度，“白嫖”AI编程越来越难"&gt;三、Cursor 也在收紧免费额度，“白嫖” AI 编程越来越难&lt;/h2&gt;
&lt;p&gt;不仅是 Copilot 在 “涨价”。Cursor 的免费额度过去能支撑一个月的轻度使用，如今是明显不够用了。有用户感叹：“20 美元会员只给你 20 美元 API 额度，超了就只能走 Auto 或 Composer 2 模型，但这俩模型完全不是一回事”。&lt;/p&gt;

&lt;p&gt;以前 C 端用户能薅不少羊毛，现在厂商们开始通过 “精准定价” 让重度用户 “上套”，再用省下来的钱补贴轻度用户，进而盘活整个定价体系。&lt;/p&gt;
&lt;h2 id="四、我该怎么办？三套方案供你选"&gt;四、我该怎么办？三套方案供你选&lt;/h2&gt;&lt;h3 id="方案一：控制用量 + 换模型"&gt;方案一：控制用量 + 换模型&lt;/h3&gt;
&lt;p&gt;如果你离不开 Copilot 或 Cursor，可以尝试以下操作：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;降低提问复杂度，避免让 AI 执行 “跨整个仓库的深层次分析”。&lt;/li&gt;
&lt;li&gt;手动设置消费上限：GitHub 已为企业用户提供按用户/成本中心设置预算限额的功能，个人用户可在 5 月初通过 “费用预览” 功能提前预估成本。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;更换模型&lt;/strong&gt;：Copilot 内部模型的费率存在巨大差异。尽可能选择费率更低的新版模型，避免使用昂贵的 Opus 高端模型处理日常任务。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="方案二：换到其他免费/低价的AI编程工具"&gt;方案二：换到其他免费/低价的 AI 编程工具&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Codeium&lt;/strong&gt;：免费额度相对宽松，补全能力接近早期 Copilot。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codex CLI&lt;/strong&gt;：开源命令行工具，免费但需自备 API Key。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continue + 本地模型&lt;/strong&gt;：在 VS Code 中自部署开源模型（如 CodeLlama、DeepSeek Coder），不花一分钱。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="方案三：自部署 + 本地模型"&gt;方案三：自部署 + 本地模型&lt;/h3&gt;
&lt;p&gt;如果你技术实力允许，可以在本地搭建开源模型服务。优点是零成本、数据不出门；缺点是模型能力有限，硬件门槛较高，需要一台配置不错的电脑或 GPU 服务器。&lt;/p&gt;
&lt;h2 id="五、结尾"&gt;五、结尾&lt;/h2&gt;
&lt;p&gt;我那天晚上回到家之后，拿着计算器对着账单，忍不住叹了口气。&lt;/p&gt;

&lt;p&gt;从互联网的 “免费午餐” 时代，到现在的 “按 token 收钱”，很难说哪一边更好。唯一确认的是，AI 编程工具正在用一种更隐蔽、也更精准的方式，把你的生产力悄悄计价。以前是 “你学得快不快”，现在是 “你用得省不省”。&lt;/p&gt;

&lt;p&gt;最近几年，我们早已习惯了 “免费试用，满意后付费” 的互联网商业模式。而今天，这些 AI 公司正在用一次次 “限流、涨价、调整定价策略”，教会所有开发者一个朴素道理：&lt;strong&gt;天下没有免费的算力，每一行 AI 生成的代码，背后都有它的价签。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;你在用的 AI 编程工具，每个月花掉你多少钱？下个月开始准备跳槽换哪家？还是打算像我一样继续硬扛？评论区见。&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Wed, 20 May 2026 11:55:09 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7366</link>
      <guid>https://beta.w2solo.com/topics/7366</guid>
    </item>
    <item>
      <title>面试官让我手写 Promise，我打开 Cursor 三秒生成，他愣了两秒说 “你过了”</title>
      <description>&lt;blockquote&gt;
&lt;p&gt;上个月面了一家中厂，技术面第二轮，面试官笑眯眯地说：“来，手写一个 Promise。” 我脑子嗡了一下——这题我背过，但那是三年前。真要默写，肯定漏一堆边界。我看了他一眼，问：“可以用 AI 吗？” 他愣了一下，说：“你试试。” 我打开 Cursor，对着 Composer 说：“帮我实现一个符合 Promise/A+ 规范的 Promise，包含 then、catch、finally。” 三秒后代码生成。他看了两秒，说：“你过了。”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;img src="https://img.way2solo.com/photo/193577746/cfe64f36-abe4-42c4-95e8-dea801c29ba0.png?imageView2/2/w/1920/q/100" title="" alt=""&gt;&lt;/p&gt;
&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;手写 Promise，面试经典老题。但 2026 年了，还有多少人在面试前夜死磕&lt;code&gt;resolve&lt;/code&gt;、&lt;code&gt;reject&lt;/code&gt;、&lt;code&gt;then&lt;/code&gt;的链式调用？我不是说这东西不该学——理解原理很重要。但面试时要你一字不差默写出来，意义在哪？工作中你真的会自己写一个 Promise 吗？不会，你用原生或者蓝鸟。&lt;/p&gt;

&lt;p&gt;这周我面了三家公司，两家允许用 AI 辅助编码，一家连 Stack Overflow 都不让开。结果呢？允许 AI 的那两家我拿到了 offer，不让的那家我连二面都没进。不是因为我不会写 Promise，而是因为&lt;strong&gt;他们考察的还是五年前的能力模型&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;今天我就把那场面试的完整过程复盘给你：我是怎么用 Cursor 生成标准 Promise 实现的，面试官为什么认可，以及如果面试官不让你用 AI，你应该怎么回应。最后附一份可以直接复制的手写 Promise 代码（带详细注释），你拿去背也行，拿去让 AI 生成也行。&lt;/p&gt;
&lt;h2 id="一、为什么“手写Promise”还是一道高频题？"&gt;一、为什么 “手写 Promise” 还是一道高频题？&lt;/h2&gt;
&lt;p&gt;这题活了快十年了。从 ES6 诞生到现在，面前端必问。面试官想考察的点其实不是你会不会用 Promise，而是：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;你对异步编程的理解深度（微任务、状态流转、链式调用）&lt;/li&gt;
&lt;li&gt;你代码的健壮性（边界处理、错误冒泡、值穿透）&lt;/li&gt;
&lt;li&gt;你是否理解 Promise/A+ 规范（而不是只背了个大概）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;但问题是，&lt;strong&gt;这些能力真的需要默写几百行代码来验证吗？&lt;/strong&gt; 一个能讲清楚 Promise 原理、能说对 then 的返回值为什么是新的 Promise、能解释微任务队列顺序的候选人，即使写代码时借助了 AI，他也合格了。&lt;/p&gt;
&lt;h2 id="二、面试现场：我是怎么用Cursor“作弊”的"&gt;二、面试现场：我是怎么用 Cursor“作弊” 的&lt;/h2&gt;
&lt;p&gt;面试官出了题，我没有立刻敲。我说：“我平时主力工具是 Cursor，我可以用它辅助编码吗？我可以当场解释每一行代码的作用。” 他犹豫了一下，说：“那你试试，但你要讲清楚。”&lt;/p&gt;

&lt;p&gt;我打开 Cursor 的 Composer（快捷键 Cmd+K），输入：&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;实现一个符合 Promise/A+ 规范的 Promise 类。要求：状态只能从 pending 变为 fulfilled 或 rejected，且不可逆；then 方法返回一个新的 Promise；支持链式调用；支持值穿透；支持异步执行；错误捕获。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;三秒后，Cursor 生成了完整代码。我一行一行给面试官解释：&lt;/p&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nx"&gt;MyPromise&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;constructor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onFulfilledCallbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onRejectedCallbacks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fulfilled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onFulfilledCallbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onRejectedCallbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;executor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onFulfilled&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onRejected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;onFulfilled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;onFulfilled&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;onFulfilled&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;onRejected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;onRejected&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;onRejected&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;promise2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;MyPromise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fulfilled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;queueMicrotask&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;onFulfilled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nx"&gt;resolvePromise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;queueMicrotask&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;onRejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="nx"&gt;resolvePromise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onFulfilledCallbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;queueMicrotask&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;onFulfilled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
              &lt;span class="nx"&gt;resolvePromise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onRejectedCallbacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;queueMicrotask&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;onRejected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
              &lt;span class="nx"&gt;resolvePromise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;promise2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onRejected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onRejected&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;this&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MyPromise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MyPromise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;callback&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;MyPromise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;MyPromise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;MyPromise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promises&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;MyPromise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promises&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;promises&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;MyPromise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;val&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;idx&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;val&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;promises&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="nx"&gt;reject&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;static&lt;/span&gt; &lt;span class="nx"&gt;race&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promises&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;MyPromise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;promises&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;MyPromise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 辅助函数：处理then返回值&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;resolvePromise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Chaining cycle detected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;MyPromise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;then&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;then&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;function&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;called&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;called&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;called&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;resolvePromise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;promise2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;called&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;called&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;called&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;面试官听完，问了我两个问题：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;“为什么 then 里面要用 queueMicrotask？” —— 为了模拟原生 Promise 的微任务行为。&lt;/li&gt;
&lt;li&gt;“resolvePromise 里为什么要判断循环引用？” —— 防止&lt;code&gt;const p = new Promise((resolve) =&amp;gt; { resolve(p); })&lt;/code&gt;这类死循环。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;我答出来了。他点了点头，没有继续问。&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;金句&lt;/strong&gt;：面试官让你手写 Promise，不是要你默写 API，而是看你知不知道 “为什么要这么写”。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="三、面试官为什么认可我用AI？"&gt;三、面试官为什么认可我用 AI？&lt;/h2&gt;
&lt;p&gt;我把代码解释清楚后，面试官说了一句话：“你能讲明白，说明你懂原理。工具只是手段，不是目的。”&lt;/p&gt;

&lt;p&gt;这个时代，会背代码已经不值钱了。AI 30 秒就能生成一个标准 Promise 实现。真正的能力是：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;你能不能判断 AI 生成的代码对不对？&lt;/li&gt;
&lt;li&gt;你能不能优化它（比如去掉冗余逻辑、调整性能）？&lt;/li&gt;
&lt;li&gt;你能不能把它集成到更大的系统里？&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;所以，如果你下次面试遇到 “手写 XXX”，大胆问：“我用 AI 辅助可以吗？我保证每一行都能解释清楚。” 大部分开明的面试官会同意，甚至会更欣赏你——因为你展示了&lt;strong&gt;真实的工作方式&lt;/strong&gt;，而不是应试技巧。&lt;/p&gt;
&lt;h2 id="四、如果面试官不让用AI，怎么办？"&gt;四、如果面试官不让用 AI，怎么办？&lt;/h2&gt;
&lt;p&gt;也简单。你告诉他：我可以手写关键结构，但完整实现需要很多边界处理代码。要不我写核心流程，再口述其他部分？&lt;/p&gt;

&lt;p&gt;然后你快速写出骨架：构造函数 + resolve/reject + then 的基本逻辑（省略 resolvePromise 里的细节）。面试官通常不会真让你写全，你展示出理解就够了。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;千万不要硬背代码&lt;/strong&gt;。背错了比不会更尴尬。&lt;/p&gt;
&lt;h2 id="五、实测数据：手写Promise到底有多长？"&gt;五、实测数据：手写 Promise 到底有多长？&lt;/h2&gt;
&lt;p&gt;我统计了一下：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;符合 Promise/A+ 规范的标准实现（含静态方法）：约 &lt;strong&gt;150-200 行&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;手写完整代码（不含注释），熟练开发者需要 &lt;strong&gt;15-20 分钟&lt;/strong&gt;。&lt;/li&gt;
&lt;li&gt;用 Cursor 生成 + 人工 review：&lt;strong&gt;3 分钟生成，5 分钟 review&lt;/strong&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;你在面试中愿意花 20 分钟默写，还是花 8 分钟解释原理 + 让 AI 生成？&lt;/p&gt;
&lt;h2 id="六、注意事项（坑点）"&gt;六、注意事项（坑点）&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;如果你用 AI 生成，一定要能解释每一段的作用&lt;/strong&gt;。面试官随时会打断问：“为什么这里有 queueMicrotask？”“为什么 then 要返回新 Promise？” 答不上来，就是减分项。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI 生成的代码可能不符合你公司的命名风格&lt;/strong&gt;。没关系，手动改一下变量名。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;不要完全依赖 AI&lt;/strong&gt;。至少自己手写过一两次，理解核心难点（比如状态流转、微任务队列、值穿透）。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="七、写在最后"&gt;七、写在最后&lt;/h2&gt;
&lt;p&gt;我最终拿到了那家公司的 offer。入职后我问面试官，当时为什么同意我用 AI？他说：“因为我们团队每天都在用 Cursor。招一个不会用 AI 的人进来，反而是累赘。”&lt;/p&gt;

&lt;p&gt;2026 年的前端面试，已经不是在考 “你会不会写”，而是在考 “你会不会用工具写”。手写 Promise 仍然是一道好题，但考核的重点已经变了。如果你还在靠死记硬背准备面试，可能会越来越吃力。&lt;/p&gt;

&lt;p&gt;你在面试中用 AI 工具被质疑过吗？后来怎么解释的？&lt;strong&gt;点个赞让我看到有多少人偷偷用过。&lt;/strong&gt;&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Tue, 19 May 2026 15:47:37 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7359</link>
      <guid>https://beta.w2solo.com/topics/7359</guid>
    </item>
    <item>
      <title>一口气讲清楚 Monorepo、Turborepo、pnpm、Changesets 到底是什么？</title>
      <description>&lt;blockquote&gt;
&lt;p&gt;你肯定遇到过这种情况：项目里同时有前端、后端、公共组件，放在一个仓库嫌乱，拆成多个仓库又改一个公共函数要在五个项目里各改一遍。于是出现了 Monorepo、Turborepo、pnpm、Changesets 这四个词。它们不是互相替代，而是分别解决工程化中不同层面的问题。读完之后，你会明白它们各自解决什么、技术原理是什么、彼此之间是什么关系，以及在实际项目中该如何组合使用。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;hr&gt;
&lt;h2 id="一、先搞清楚一件事：为什么会有这些工具？"&gt;一、先搞清楚一件事：为什么会有这些工具？&lt;/h2&gt;
&lt;p&gt;前端工程化发展到今天，一个中型项目往往包含多个应用（Web、小程序、Node 服务）和多个共享包（UI 组件库、工具函数、类型定义）。传统的多仓库（Polyrepo）模式有两个致命痛点：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;代码复用难&lt;/strong&gt;：改一个公共函数，要在 5 个仓库里各改一遍，还要各自发版。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;依赖管理乱&lt;/strong&gt;：每个仓库都重复安装 &lt;code&gt;react&lt;/code&gt;、&lt;code&gt;lodash&lt;/code&gt;，磁盘空间爆炸，版本不同步还容易出 bug。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;于是工程界开始借鉴谷歌、Facebook 的做法，把多个项目放进&lt;strong&gt;同一个仓库&lt;/strong&gt;——这就是 Monorepo。但光放进去还不够，你还需要：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;一个&lt;strong&gt;包管理器&lt;/strong&gt;来高效处理依赖（pnpm）&lt;/li&gt;
&lt;li&gt;一个&lt;strong&gt;构建编排器&lt;/strong&gt;来加速构建和任务执行（Turborepo）&lt;/li&gt;
&lt;li&gt;一个&lt;strong&gt;版本管理工具&lt;/strong&gt;来帮你自动发版和生成 changelog（Changesets）&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这四个工具不是互相替代，而是互补的，分别解决前端工程化中不同层面的问题。&lt;/p&gt;

&lt;hr&gt;
&lt;h2 id="二、Monorepo：把多个项目放进同一个“家”"&gt;二、Monorepo：把多个项目放进同一个 “家”&lt;/h2&gt;&lt;h3 id="2.1 一句话定义"&gt;2.1 一句话定义&lt;/h3&gt;
&lt;p&gt;Monorepo 是一种代码仓库组织策略，在&lt;strong&gt;一个 Git 仓库&lt;/strong&gt;里管理多个相互独立但又相互依赖的项目（应用、库、服务）。&lt;/p&gt;
&lt;h3 id="2.2 跟 Polyrepo 有什么区别？"&gt;2.2 跟 Polyrepo 有什么区别？&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;Polyrepo（多仓库）&lt;/th&gt;
&lt;th&gt;Monorepo（单仓库）&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;代码复用&lt;/td&gt;
&lt;td&gt;发布 npm 包或复制粘贴&lt;/td&gt;
&lt;td&gt;直接通过 &lt;code&gt;workspace&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;tr&gt;
&lt;td&gt;跨项目改动&lt;/td&gt;
&lt;td&gt;改一个公共函数需改 N 个仓库&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;可通过 CODEOWNERS 实现目录级权限&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI/CD&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;需理解 workspaces、任务编排等概念&lt;/td&gt;
&lt;/tr&gt;
&lt;/table&gt;&lt;h3 id="2.3 一个简单的目录结构"&gt;2.3 一个简单的目录结构&lt;/h3&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;my-monorepo/
├── apps/              # 应用程序
│   ├── web/           # React 前端
│   ├── admin/         # 后台管理系统
│   └── api/           # Node 后端
├── packages/          # 共享包
│   ├── ui/            # 组件库
│   ├── utils/         # 工具函数
│   └── config/        # 共享配置（ESLint、TS）
├── package.json
├── pnpm-workspace.yaml # 工作区配置
└── turbo.json          # Turborepo 配置
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="2.4 技术挑战"&gt;2.4 技术挑战&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;依赖提升带来的幽灵依赖&lt;/strong&gt;：项目可能引用未在自身 &lt;code&gt;package.json&lt;/code&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;：需要合理的 CODEOWNERS 和分支策略，避免一个人改崩整个仓库。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;
&lt;h2 id="三、pnpm：比 npm 更聪明的包管理器"&gt;三、pnpm：比 npm 更聪明的包管理器&lt;/h2&gt;&lt;h3 id="3.1 一句话定义"&gt;3.1 一句话定义&lt;/h3&gt;
&lt;p&gt;pnpm 是一个高性能的包管理器，它通过&lt;strong&gt;内容可寻址存储&lt;/strong&gt;和&lt;strong&gt;符号链接&lt;/strong&gt;实现多项目间依赖的全局去重，比 npm/yarn 更快、更省磁盘空间。&lt;/p&gt;
&lt;h3 id="3.2 跟 npm / yarn 有什么区别？"&gt;3.2 跟 npm / yarn 有什么区别？&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;npm / yarn（传统）&lt;/th&gt;
&lt;th&gt;pnpm&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;依赖存储&lt;/td&gt;
&lt;td&gt;每个项目 &lt;code&gt;node_modules&lt;/code&gt; 都复制一份依赖&lt;/td&gt;
&lt;td&gt;全局 store 存储一份，通过硬链接复用&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;磁盘占用&lt;/td&gt;
&lt;td&gt;100 个项目 = 100 份 react&lt;/td&gt;
&lt;td&gt;100 个项目 = 1 份 react&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;tr&gt;
&lt;td&gt;Monorepo 支持&lt;/td&gt;
&lt;td&gt;需要 &lt;code&gt;workspaces&lt;/code&gt; 配置&lt;/td&gt;
&lt;td&gt;原生支持，通过 &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/table&gt;&lt;h3 id="3.3 怎么配置 pnpm workspace？"&gt;3.3 怎么配置 pnpm workspace？&lt;/h3&gt;
&lt;p&gt;在项目根目录创建 &lt;code&gt;pnpm-workspace.yaml&lt;/code&gt;：&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;packages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;apps/*"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;packages/*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后执行 &lt;code&gt;pnpm install&lt;/code&gt;。pnpm 会自动把 &lt;code&gt;apps/&lt;/code&gt; 和 &lt;code&gt;packages/&lt;/code&gt; 下的每个子目录当作一个 workspace 包，并通过符号链接让它们互相引用。&lt;/p&gt;
&lt;h3 id="3.4 常用命令"&gt;3.4 常用命令&lt;/h3&gt;&lt;pre class="highlight shell"&gt;&lt;code&gt;pnpm &lt;span class="nb"&gt;install&lt;/span&gt;                 &lt;span class="c"&gt;# 安装所有依赖&lt;/span&gt;
pnpm add react &lt;span class="nt"&gt;-w&lt;/span&gt;            &lt;span class="c"&gt;# 给根目录添加依赖（-w 表示 workspace root）&lt;/span&gt;
pnpm &lt;span class="nt"&gt;--filter&lt;/span&gt; web add lodash &lt;span class="c"&gt;# 只给 web 应用添加 lodash&lt;/span&gt;
pnpm &lt;span class="nt"&gt;--filter&lt;/span&gt; web dev        &lt;span class="c"&gt;# 只运行 web 应用的 dev 脚本&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="3.5 技术挑战"&gt;3.5 技术挑战&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;原生工具链兼容性&lt;/strong&gt;：一些旧的 npm 脚本或工具假设 &lt;code&gt;node_modules&lt;/code&gt; 是平铺结构，在 pnpm 下可能不工作（可通过 &lt;code&gt;shamefully-hoist&lt;/code&gt; 解决）。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;学习成本&lt;/strong&gt;：开发者需要理解 &lt;code&gt;--filter&lt;/code&gt;、workspace 协议（&lt;code&gt;"ui": "workspace:*"&lt;/code&gt;）等概念。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;
&lt;h2 id="四、Turborepo：让构建任务“快如闪电”"&gt;四、Turborepo：让构建任务 “快如闪电”&lt;/h2&gt;&lt;h3 id="4.1 一句话定义"&gt;4.1 一句话定义&lt;/h3&gt;
&lt;p&gt;Turborepo 是一个&lt;strong&gt;高性能的任务编排器&lt;/strong&gt;，专门为 Monorepo 设计。它会缓存每个任务的输入输出，第二次运行相同输入时直接跳过执行，从而实现秒级重构建。&lt;/p&gt;
&lt;h3 id="4.2 跟普通 npm run 脚本有什么区别？"&gt;4.2 跟普通 &lt;code&gt;npm run&lt;/code&gt; 脚本有什么区别？&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tr&gt;
&lt;th&gt;维度&lt;/th&gt;
&lt;th&gt;普通脚本&lt;/th&gt;
&lt;th&gt;Turborepo&lt;/th&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;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;tr&gt;
&lt;td&gt;依赖感知&lt;/td&gt;
&lt;td&gt;无&lt;/td&gt;
&lt;td&gt;自动识别 &lt;code&gt;dependsOn&lt;/code&gt;，按拓扑顺序构建&lt;/td&gt;
&lt;/tr&gt;
&lt;/table&gt;&lt;h3 id="4.3 Turborepo 工作原理"&gt;4.3 Turborepo 工作原理&lt;/h3&gt;
&lt;p&gt;Turborepo 用 &lt;strong&gt;管道（pipeline）&lt;/strong&gt; 定义任务之间的关系。一个典型的 &lt;code&gt;turbo.json&lt;/code&gt;：&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://turbo.build/schema.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"globalDependencies"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"**/.env.*"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pipeline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^build"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;      &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;先构建依赖包，再构建当前包&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dist/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;".next/**"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"dev"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"cache"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;               &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;开发模式不缓存&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"persistent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"^lint"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;先跑依赖包的&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;lint&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"test"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"dependsOn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;       &lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;先&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;build&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;再&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;test&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;缓存机制&lt;/strong&gt;：Turbo 会计算任务输入（源代码、依赖的 task 输出、环境变量）的哈希值。如果哈希值没有变化，直接输出之前缓存的产物，执行时间从分钟级降为毫秒级。&lt;/p&gt;
&lt;h3 id="4.4 技术挑战"&gt;4.4 技术挑战&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;缓存失效过于保守&lt;/strong&gt;：如果你的任务输入包含不必要的大文件（如 &lt;code&gt;node_modules&lt;/code&gt;），缓存命中率会很低。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;远程缓存需要服务器&lt;/strong&gt;：团队共享缓存需要自己部署或使用 Vercel 的 Remote Cache。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;
&lt;h2 id="五、Changesets：版本管理不再痛苦"&gt;五、Changesets：版本管理不再痛苦&lt;/h2&gt;&lt;h3 id="5.1 一句话定义"&gt;5.1 一句话定义&lt;/h3&gt;
&lt;p&gt;Changesets 是一个用于 Monorepo 的&lt;strong&gt;版本管理和 changelog 生成工具&lt;/strong&gt;。它让你在提交代码时记录变更意图，然后一键批量发布所有需要升级的包。&lt;/p&gt;
&lt;h3 id="5.2 为什么需要它？"&gt;5.2 为什么需要它？&lt;/h3&gt;
&lt;p&gt;在 Monorepo 中，你改了 &lt;code&gt;packages/utils&lt;/code&gt;，可能会同时影响 &lt;code&gt;apps/web&lt;/code&gt; 和 &lt;code&gt;apps/admin&lt;/code&gt;。如果手动去修改这些包的 &lt;code&gt;package.json&lt;/code&gt; 版本号，并各自生成 changelog，非常繁琐且容易漏。Changesets 自动化了这个流程。&lt;/p&gt;
&lt;h3 id="5.3 工作流程"&gt;5.3 工作流程&lt;/h3&gt;&lt;pre class="highlight plaintext"&gt;&lt;code&gt;开发者改代码
    ↓
pnpm changeset      # 交互式选择要升级的包、填写变更描述
    ↓
生成 .changeset/*.md 文件（提交到 Git）
    ↓
CI / 发布时运行 pnpm changeset version
    ↓
自动升级版本号、更新 changelog、删除 .changeset 文件
    ↓
pnpm publish -r    # 发布所有变更的包到 npm
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="5.4 技术挑战"&gt;5.4 技术挑战&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;与 CI/CD 集成&lt;/strong&gt;：需要在 PR 合并后自动运行 &lt;code&gt;version&lt;/code&gt; 命令并提交，需要配置 GitHub Actions 或 GitLab CI。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;依赖升级的传递性&lt;/strong&gt;：如果你改了底层包，上层包是否要强制升级？Changesets 可以自动处理，但需要正确配置 &lt;code&gt;updateInternalDependencies&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;
&lt;h2 id="六、四者的关系：一张图讲清楚"&gt;六、四者的关系：一张图讲清楚&lt;/h2&gt;&lt;table class="table table-bordered table-striped"&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;类比&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Monorepo&lt;/strong&gt;&lt;/td&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;strong&gt;pnpm&lt;/strong&gt;&lt;/td&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;strong&gt;Turborepo&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;任务编排器&lt;/td&gt;
&lt;td&gt;如何加速构建、测试、lint 等任务&lt;/td&gt;
&lt;td&gt;大楼的电梯调度系统&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Changesets&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;版本管理&lt;/td&gt;
&lt;td&gt;如何自动化发版和生成 changelog&lt;/td&gt;
&lt;td&gt;大楼的物业管理系统&lt;/td&gt;
&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;它们的协作关系：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;开发者修改代码（在 Monorepo 中）
        ↓
pnpm 负责安装依赖，链接 workspace 包
        ↓
Turborepo 负责按需执行任务（build、test、lint），利用缓存加速
        ↓
开发完成后，提交 PR
        ↓
PR 合并到 main 分支
        ↓
CI 运行 Changesets：自动升级版本、生成 changelog
        ↓
pnpm publish -r 发布到 npm
&lt;/code&gt;&lt;/pre&gt;
&lt;hr&gt;
&lt;h2 id="七、技术选型指南：实际工程中怎么组合？"&gt;七、技术选型指南：实际工程中怎么组合？&lt;/h2&gt;&lt;h3 id="场景一：个人项目或小团队（2-5 人，3-5 个包）"&gt;场景一：个人项目或小团队（2-5 人，3-5 个包）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;推荐&lt;/strong&gt;：&lt;code&gt;pnpm + Monorepo&lt;/code&gt; 就够了，不需要 Turborepo（构建不慢）和 Changesets（手动改版本号也能接受）。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;操作&lt;/strong&gt;：直接用 pnpm workspace，在根目录写几个 npm scripts 串行执行 &lt;code&gt;build&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="场景二：中型项目（5-20 人，10-20 个包，构建耗时 &amp;gt; 2 分钟）"&gt;场景二：中型项目（5-20 人，10-20 个包，构建耗时 &amp;gt; 2 分钟）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;推荐&lt;/strong&gt;：&lt;code&gt;pnpm + Turborepo + Monorepo&lt;/code&gt;，用 Turborepo 的缓存和并行能力加速 CI。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;版本管理&lt;/strong&gt;：可以暂时手动改版本，也可以用 Changesets 但非强制。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="场景三：大型项目 / 开源库（多人协作，频繁发版，包之间有复杂依赖）"&gt;场景三：大型项目 / 开源库（多人协作，频繁发版，包之间有复杂依赖）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;推荐&lt;/strong&gt;：&lt;code&gt;pnpm + Turborepo + Changesets + Monorepo&lt;/code&gt;，全套上齐。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;额外&lt;/strong&gt;：配置远程缓存（如 Vercel Remote Cache）让团队成员共享构建结果；设置 CI 自动执行 &lt;code&gt;changeset version&lt;/code&gt; 和 &lt;code&gt;publish&lt;/code&gt;。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="场景四：已有大量 npm 包，准备迁移到 Monorepo"&gt;场景四：已有大量 npm 包，准备迁移到 Monorepo&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;步骤&lt;/strong&gt;：先用 &lt;code&gt;pnpm import&lt;/code&gt; 把现有 &lt;code&gt;package-lock.json&lt;/code&gt; 转成 &lt;code&gt;pnpm-lock.yaml&lt;/code&gt;；然后逐步把相关仓库移入 &lt;code&gt;packages/&lt;/code&gt;，调整 import 路径；最后引入 Turborepo 优化 CI。&lt;/li&gt;
&lt;/ul&gt;

&lt;hr&gt;
&lt;h2 id="💎 写在最后"&gt;💎 写在最后&lt;/h2&gt;
&lt;p&gt;回到最开始的问题：为什么需要 Monorepo、Turborepo、pnpm、Changesets 这四个工具？&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Monorepo&lt;/strong&gt; 给你一个容纳多项目的大房子。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pnpm&lt;/strong&gt; 给你高效的管道系统，让依赖管理快如闪电。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turborepo&lt;/strong&gt; 给你智能的电梯，让构建任务不再重复劳动。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Changesets&lt;/strong&gt; 给你规范的物业管理，让版本发布井井有条。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;它们不是 “银弹”，但当你团队规模膨胀、项目耦合加深时，这套组合拳能让你从 “复制粘贴工程师” 进化为 “工程化架构师”。&lt;/p&gt;

&lt;p&gt;如果你也在搭建 Monorepo，或者被多仓库的代码复用问题折磨过，&lt;strong&gt;点个赞让我看到&lt;/strong&gt;。赞多的话，下一篇写 “如何从零落地一个 pnpm + Turborepo + Changesets 的 Monorepo 项目，包含完整 CI 配置”。&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Mon, 18 May 2026 20:05:05 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7355</link>
      <guid>https://beta.w2solo.com/topics/7355</guid>
    </item>
    <item>
      <title>用户打开飞行模式都能打开你的网站？Service Worker 做离线缓存，PWA 实战</title>
      <description>&lt;blockquote&gt;
&lt;p&gt;你坐飞机，关掉网络，旁边小哥还在刷抖音（离线缓存好的视频）。你打开自己的网站，白屏，报错。你默默关上手机，心想：“要是我的网站也能离线看就好了。” 今天我们就来给你的网站装上 “离线小精灵”——Service Worker。以后用户没网也能访问，还能把网站装到手机桌面，像原生 App 一样。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;img src="https://img.way2solo.com/photo/193577746/9944dba9-3423-4021-8437-5c60c5035851.png?imageView2/2/w/1920/q/100" title="" alt=""&gt;&lt;/p&gt;
&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;PWA（Progressive Web App）这个概念喊了好几年，但真正用上的网站不多。其实它没那么玄乎，核心就是 &lt;strong&gt;Service Worker&lt;/strong&gt;——一个在浏览器后台独立运行的 JS 线程，能拦截网络请求、缓存资源、推送通知。&lt;/p&gt;

&lt;p&gt;加了 Service Worker 的网站，就算用户开飞行模式，只要之前访问过，照样能看到页面（至少看到缓存过的内容）。而且速度极快，因为资源从本地取，不用等网络。今天我们就从零给一个静态网站加上离线缓存，顺便让它 “可安装”。&lt;/p&gt;
&lt;h2 id="一、Service Worker 生命周期：四步走"&gt;一、Service Worker 生命周期：四步走&lt;/h2&gt;
&lt;p&gt;Service Worker 不是一上来就接管所有请求的，它有严格的生命周期：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;注册&lt;/strong&gt;：主线程告诉浏览器：“嘿，去下载这个 sw.js 文件。”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;安装&lt;/strong&gt;：浏览器下载、解析、执行 sw.js 里的 &lt;code&gt;install&lt;/code&gt; 事件。通常在这里缓存核心资源。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;激活&lt;/strong&gt;：旧 Service Worker 被替换，新 SW 接管控制权。可以在 &lt;code&gt;activate&lt;/code&gt; 事件里清理旧缓存。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;空闲/运行&lt;/strong&gt;：之后所有 fetch 请求都会被 SW 拦截。&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;注意：SW 只在 HTTPS（或 localhost）下生效，因为可以拦截网络，不安全。&lt;/p&gt;
&lt;h2 id="二、最简单的 Service Worker：离线回退页面"&gt;二、最简单的 Service Worker：离线回退页面&lt;/h2&gt;
&lt;p&gt;我们先写一个极简版 &lt;code&gt;sw.js&lt;/code&gt;，让用户离线时看到一个 “你已离线” 的页面。&lt;/p&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sw.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CACHE_NAME&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;my-pwa-cache-v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;OFFLINE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/offline.html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 安装时缓存离线页面&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;install&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CACHE_NAME&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OFFLINE_URL&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// 强制等待中的 SW 立即激活&lt;/span&gt;
  &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;skipWaiting&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 激活时清理旧缓存&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;activate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;waitUntil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;CACHE_NAME&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// 拦截请求，离线时返回缓存&lt;/span&gt;
&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 页面导航请求&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;OFFLINE_URL&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// 其他资源走缓存优先策略（稍后优化）&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后在 &lt;code&gt;index.html&lt;/code&gt; 里注册：&lt;/p&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;serviceWorker&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;load&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceWorker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/sw.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reg&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SW 注册成功&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SW 注册失败&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在你打开网站，开飞机模式（或 DevTools → Network 离线），刷新页面，应该会显示 &lt;code&gt;offline.html&lt;/code&gt;。说明 SW 已经拦下了请求。&lt;/p&gt;
&lt;h2 id="三、缓存策略：别把所有鸡蛋放一个篮子"&gt;三、缓存策略：别把所有鸡蛋放一个篮子&lt;/h2&gt;
&lt;p&gt;上面的代码对所有资源都用了 “缓存优先”——先查 cache，没有才网络。这会导致一个问题：如果某个资源之前缓存过，即使服务器更新了，用户也看不到新版本。所以需要根据资源类型选择策略。&lt;/p&gt;

&lt;p&gt;常用策略：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache First（缓存优先）&lt;/strong&gt;：适合不常变的图片、字体、CSS 库。速度快。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Network First（网络优先）&lt;/strong&gt;：适合 API 数据、HTML 页面。先尝试网络，失败再读缓存。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale-While-Revalidate&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;p&gt;我们改一下 &lt;code&gt;fetch&lt;/code&gt; 事件：&lt;/p&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fetch&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// 如果是 API 请求，走网络优先&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// 如果是静态资源（js、css、图片），走缓存优先&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\.(&lt;/span&gt;&lt;span class="sr"&gt;js|css|png|jpg|webp&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// 其他（如 HTML）走 stale-while-revalidate&lt;/span&gt;
  &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;respondWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;caches&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;CACHE_NAME&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fetchPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;fetchPromise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样，你的网站既能离线访问，又能及时更新动态内容。&lt;/p&gt;
&lt;h2 id="四、用 Workbox 简化代码"&gt;四、用 Workbox 简化代码&lt;/h2&gt;
&lt;p&gt;手写缓存策略很麻烦，尤其还要处理版本、过期、缓存清理。Google 出品了 &lt;strong&gt;Workbox&lt;/strong&gt;，一套工具库，几行配置搞定复杂策略。&lt;/p&gt;

&lt;p&gt;安装 Workbox CLI 或直接在 sw.js 里导入 CDN：&lt;/p&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;importScripts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://storage.googleapis.com/workbox-cdn/releases/7.0.0/workbox-sw.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;registerRoute&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;strategies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cacheableResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;workbox&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 预缓存静态资源（构建时生成 manifest）&lt;/span&gt;
&lt;span class="nx"&gt;workbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;precaching&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;precacheAndRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;__WB_MANIFEST&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

&lt;span class="c1"&gt;// 图片缓存策略&lt;/span&gt;
&lt;span class="nx"&gt;registerRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;destination&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;strategies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CacheFirst&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;cacheName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;images&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;cacheableResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CacheableResponsePlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;statuses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;workbox&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expiration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ExpirationPlugin&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;maxEntries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maxAgeSeconds&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// API 网络优先&lt;/span&gt;
&lt;span class="nx"&gt;registerRoute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;strategies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NetworkFirst&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;配合 webpack/vite 插件，可以自动生成预缓存清单，连 &lt;code&gt;install&lt;/code&gt; 里的 &lt;code&gt;cache.add&lt;/code&gt; 都不用手动写。&lt;/p&gt;
&lt;h2 id="五、让网站可安装（添加到主屏幕）"&gt;五、让网站可安装（添加到主屏幕）&lt;/h2&gt;
&lt;p&gt;PWA 另一大特性：用户可以像装 App 一样把网站装到手机桌面。需要满足三个条件：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;HTTPS&lt;/strong&gt;（或 localhost）&lt;/li&gt;
&lt;li&gt;注册了 Service Worker&lt;/li&gt;
&lt;li&gt;有一个 &lt;code&gt;manifest.json&lt;/code&gt; 文件，放在根目录&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;示例 &lt;code&gt;manifest.json&lt;/code&gt;：&lt;/p&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"我的离线网站"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"short_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"离线站"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"start_url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"display"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"standalone"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"theme_color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#000000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"background_color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#ffffff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"icons"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/icon-192.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sizes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"192x192"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image/png"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"src"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/icon-512.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sizes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"512x512"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"image/png"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;在 &lt;code&gt;index.html&lt;/code&gt; 里引用：&lt;/p&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"manifest"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/manifest.json"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;之后用户访问网站，浏览器会在地址栏右侧弹出 “安装 App” 的提示。点一下，桌面就多了一个图标，打开后没有浏览器地址栏，像原生 App。&lt;/p&gt;
&lt;h2 id="六、推送通知（可选彩蛋）"&gt;六、推送通知（可选彩蛋）&lt;/h2&gt;
&lt;p&gt;Service Worker 还能接收服务器推送的消息，即使网站没打开也能弹出通知。这需要用户授权和后台推送服务（比如 Firebase Cloud Messaging）。代码稍复杂，但可以实现 “用户关掉浏览器，你也能给他发优惠券提醒” 的效果。&lt;/p&gt;
&lt;h2 id="七、实测数据：加了 SW 之后"&gt;七、实测数据：加了 SW 之后&lt;/h2&gt;
&lt;p&gt;我用一个 React 静态网站测试：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;未缓存：首次加载 1.8s，二次无网白屏。&lt;/li&gt;
&lt;li&gt;加了 Workbox 预缓存：首次 2.0s（多下载了 SW 和 manifest），二次无网打开 0.3s（完全离线）。&lt;/li&gt;
&lt;li&gt;页面切换速度提升明显，因为路由对应的 JS 也被缓存。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;用户从 “等待加载” 变成 “秒开”，体验提升 5 倍以上。&lt;/p&gt;
&lt;h2 id="八、坑点与避坑"&gt;八、坑点与避坑&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;更新缓存&lt;/strong&gt;：修改文件后，用户可能还是旧版本。需要更新 &lt;code&gt;CACHE_NAME&lt;/code&gt; 版本号，或者在预缓存时用 &lt;code&gt;rev&lt;/code&gt;（文件 hash）解决。Workbox 会自动处理。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;localhost 测试&lt;/strong&gt;：记得勾选 DevTools → Application → Service Workers → Update on reload，否则 SW 缓存会干扰。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;作用域&lt;/strong&gt;：SW 默认作用域是 &lt;code&gt;sw.js&lt;/code&gt; 所在目录，如果放在根目录，可以控制全站。放在 &lt;code&gt;js/&lt;/code&gt; 下就只能控制 &lt;code&gt;js/&lt;/code&gt; 路径。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;调试&lt;/strong&gt;：Chrome DevTools 的 Application 面板可以看到所有缓存、SW 状态、推送通知。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="九、总结：PWA 是前端的“离线外挂”"&gt;九、总结：PWA 是前端的 “离线外挂”&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Service Worker 是浏览器后台独立线程，能拦截请求、缓存资源、推送通知。&lt;/li&gt;
&lt;li&gt;生命周期：注册 → 安装 → 激活 → fetch。&lt;/li&gt;
&lt;li&gt;缓存策略根据资源类型选择：Cache First、Network First、Stale-While-Revalidate。&lt;/li&gt;
&lt;li&gt;搭配 Workbox 可省去手写复杂缓存逻辑。&lt;/li&gt;
&lt;li&gt;加上 manifest.json 就能让网站 “安装到桌面”。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;下次你坐飞机，打开自己的 PWA 网站，不用网络也能刷内容。同事看了问：“你怎么做到的？” 你就可以把本文甩给他。&lt;/p&gt;

&lt;hr&gt;

&lt;p&gt;评论区聊聊：你的网站支持离线访问吗？遇到过哪些缓存更新问题？&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Mon, 18 May 2026 12:08:27 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7352</link>
      <guid>https://beta.w2solo.com/topics/7352</guid>
    </item>
    <item>
      <title>我让 AI 替我写 Git 提交信息，老板以为我每天工作 16 小时</title>
      <description>&lt;blockquote&gt;
&lt;p&gt;公司要求提交信息必须规范：&lt;code&gt;feat: xxx&lt;/code&gt;、&lt;code&gt;fix: xxx&lt;/code&gt;、还要带 tapd 链接。我每次 git commit 都要憋五分钟，写出来的还是 “改了点东西”。后来我让 AI 替我写提交信息——diff 一丢进去，它自动生成规范格式，还帮我加上 “影响范围” 和 “为什么改”。老板看 Git 日志，说：“xxx 最近提交质量很高啊，你每天工作到几点？” 我：“正常六点下班。”&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;Git 提交信息这件事，说大不大，说小不小。小到你随便写个 “update”，不影响跑代码。大到代码回滚、发版生成 CHANGELOG、甚至背锅溯源时，一句清晰的 “fix: 修复订单金额计算溢出” 比 “改了一下” 值一万倍。&lt;/p&gt;

&lt;p&gt;但人都有惰性，尤其加班赶业务时，谁有空写小作文？AI 就不一样，它看 diff 快、理解上下文、还能按约定格式输出。今天我就教你用 AI 自动生成高质量的 commit message，顺便集成到 Git 钩子里，让你以后闭着眼敲 &lt;code&gt;git commit&lt;/code&gt; 就行。&lt;/p&gt;
&lt;h2 id="一、为什么你讨厌写 commit message？"&gt;一、为什么你讨厌写 commit message？&lt;/h2&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;code&gt;feat&lt;/code&gt;/&lt;code&gt;fix&lt;/code&gt;/&lt;code&gt;docs&lt;/code&gt; 又要查表。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;懒&lt;/strong&gt;：反正也没人看，写个 “x” 交差。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;但规范化的 message 真的有用：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;自动生成 &lt;code&gt;CHANGELOG.md&lt;/code&gt;。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;git blame&lt;/code&gt; 时一眼看出某个改动的原因。&lt;/li&gt;
&lt;li&gt;同事 review PR 时不用猜你改了啥。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;既然人是懒的，就让 AI 当你的 “秘书”。&lt;/p&gt;
&lt;h2 id="二、AI 怎么写 commit message？"&gt;二、AI 怎么写 commit message？&lt;/h2&gt;
&lt;p&gt;核心思路：&lt;code&gt;git diff --staged&lt;/code&gt; 拿到本次变更的代码差异，丢给 AI，让它根据 Conventional Commits 规范生成消息。&lt;/p&gt;

&lt;p&gt;我给你写了一个脚本 &lt;code&gt;ai-commit&lt;/code&gt;（Python 版，你也可以用 Node）：&lt;/p&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;subprocess&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="nn"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="nn"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"你的key"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 获取暂存区的 diff
&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_output&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;"git"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"diff"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"--staged"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"utf-8"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"没有暂存的变更，请先 git add"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="s"&gt;"""
请根据以下 Git diff，按照 Conventional Commits 规范生成一条提交信息。
格式：&amp;lt;type&amp;gt;(&amp;lt;scope&amp;gt;): &amp;lt;subject&amp;gt;
其中 type 可选：feat, fix, docs, style, refactor, perf, test, chore
scope 可选（如组件名或模块名），subject 简短描述（不超过50字）。
如果变更涉及多个不相关改动，请拆成多条（用换行分隔）。
只输出提交信息，不要解释。

diff:
&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;diff&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;
"""&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"gpt-4"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="s"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;把这个脚本保存为 &lt;code&gt;ai-commit&lt;/code&gt;，放到 PATH 里。以后你只需要：&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git add &lt;span class="nb"&gt;.&lt;/span&gt;
ai-commit   &lt;span class="c"&gt;# 它会打印出 AI 生成的 message&lt;/span&gt;
git commit &lt;span class="nt"&gt;-F&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;ai-commit&lt;span class="o"&gt;)&lt;/span&gt;   &lt;span class="c"&gt;# 直接使用&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;或者再懒一点：用 &lt;code&gt;git commit -m "$(ai-commit)"&lt;/code&gt; 一行命令。&lt;/p&gt;
&lt;h2 id="三、集成到 Husky，每次 commit 自动调用"&gt;三、集成到 Husky，每次 commit 自动调用&lt;/h2&gt;
&lt;p&gt;你可以在 &lt;code&gt;.husky/prepare-commit-msg&lt;/code&gt; 里加入脚本，让 AI 自动填充编辑器：&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/sh&lt;/span&gt;
&lt;span class="c"&gt;# 自动生成 commit message 并预填到编辑器&lt;/span&gt;
&lt;span class="nb"&gt;exec&lt;/span&gt; &amp;lt; /dev/tty
&lt;span class="nv"&gt;AI_MSG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ai-commit&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$AI_MSG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这样你敲 &lt;code&gt;git commit&lt;/code&gt; 时，编辑框里已经有 AI 写好的草稿，你只需要检查一下，不满意就改，省得自己从头敲。&lt;/p&gt;
&lt;h2 id="四、真实案例：AI 写的 message 有多专业？"&gt;四、真实案例：AI 写的 message 有多专业？&lt;/h2&gt;
&lt;p&gt;某次我改了订单模块的一个 bug：之前只判断了 &lt;code&gt;user.discount&lt;/code&gt; 存在性，没判断类型，传了个字符串导致计算错误。AI 根据 diff 生成了：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fix(order): 优惠金额类型错误导致计算异常

- 增加 discount 字段的类型校验，确保为 number
- 添加单元测试覆盖字符串场景
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;还帮我分了一行 body 说明？其实 Conventional Commits 允许 body，但我的提示词没要求。&lt;strong&gt;厉害的是&lt;/strong&gt;，它居然知道这是 “类型错误” 和 “计算异常”，而且自动给定了 &lt;code&gt;fix(order)&lt;/code&gt;。&lt;/p&gt;

&lt;p&gt;另一个案例：我重构了一个函数，把 &lt;code&gt;if-else&lt;/code&gt; 改成了策略模式。AI 生成：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;refactor(payment): 用策略模式替换多层条件分支

提升可扩展性，便于新增支付渠道
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;这一看就是老手写的。&lt;/p&gt;
&lt;h2 id="五、进阶：结合 TAPD/Jira，自动关联任务"&gt;五、进阶：结合 TAPD/Jira，自动关联任务&lt;/h2&gt;
&lt;p&gt;很多公司要求 commit 里带任务 ID。你可以在提示词里加入当前分支名（分支名通常含任务号）：&lt;/p&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;branch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;subprocess&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;check_output&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s"&gt;"git"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"branch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"--show-current"&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="n"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="c1"&gt;# 假设分支名为 feature/TAPD-1234
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;然后把 “请从分支名提取任务 ID，加入提交信息” 写进 prompt。AI 会生成类似：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;feat(order): 添加满减优惠券

TAPD-1234
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;连 hook 都不用改，全自动。&lt;/p&gt;
&lt;h2 id="六、局限性：AI 不是每次都完美"&gt;六、局限性：AI 不是每次都完美&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;巨大 diff&lt;/strong&gt;：超过上下文长度，AI 看不到全貌。可以只改最近几个文件，或者用 &lt;code&gt;git diff --cached --stat&lt;/code&gt; 先给 AI 看概览，再让它选择重点文件。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;多个无关改动&lt;/strong&gt;：AI 可能会合并成一条，而你应该拆成多条。这时候手动分两次 commit 就好。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;隐私&lt;/strong&gt;：代码 diff 会发给 OpenAI API，公司敏感项目慎用。可以本地跑开源模型（CodeLlama、DeepSeek Coder）替代。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="七、效果：我的 Git 日志变“教科书”"&gt;七、效果：我的 Git 日志变 “教科书”&lt;/h2&gt;
&lt;p&gt;之前我的日志：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fix
update
修改
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;现在：&lt;/p&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;feat(user): 支持手机号登录
fix(cart): 修复商品数量为0时仍可结算的bug
perf(list): 虚拟滚动优化，长列表滚动帧率提升50%
docs(readme): 更新环境配置说明
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;老板进仓库看了一圈，专门在群里说：“xxx 的提交信息写得真规范，大家学习一下。” 我默默把 AI 脚本分享给了同事。现在全组都在用，老板还纳闷：怎么大家突然都变 “专业” 了？&lt;/p&gt;
&lt;h2 id="八、总结：把时间花在改 bug 上，不是写小作文"&gt;八、总结：把时间花在改 bug 上，不是写小作文&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;AI 写 commit message，省时、规范、专业。&lt;/li&gt;
&lt;li&gt;集成到 Git 钩子，无感使用。&lt;/li&gt;
&lt;li&gt;支持从 diff 推断 type、scope、甚至业务含义。&lt;/li&gt;
&lt;li&gt;敏感项目换本地模型。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;下一回你 &lt;code&gt;git commit&lt;/code&gt; 时，让 AI 替你写。你只需要 review 一下，不满意手动改两字。从此 Git 日志不再是 “xxx: 111”，而是真正的 “项目史书”。&lt;/p&gt;

&lt;hr&gt;

&lt;p&gt;评论区聊聊：你见过最离谱的 commit message 是什么？&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Sun, 17 May 2026 12:36:40 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7348</link>
      <guid>https://beta.w2solo.com/topics/7348</guid>
    </item>
    <item>
      <title>我让 AI 当了 24 小时全年无休的 “毒舌考官”</title>
      <description>&lt;blockquote&gt;
&lt;p&gt;公司开了个仓库，前端团队从 3 人扩到 12 人，我定规矩：所有 PR 至少 1 个 Review 才能合。好的，大家开始互相送 “LGTM ⭐” 了——一眼都没看，点完就跑。线上挂的时候谁也背锅，群消息全是 “不是我”。我一气之下拉了 AI 进来当永久 Code Reviewer，0 薪水 7x24 小时在线，什么混子、什么歪代码，全部兜住。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;PR 审查这件事，经历过的都懂：平时没人看，一出事全是 “我早说了”。你认真看了 500 行 diff 给出了 3 页修改意见，人家回复一个 “done”，你都不知道改没改对。&lt;/p&gt;

&lt;p&gt;Code Review 的最大矛盾是什么？&lt;strong&gt;要快，就水；要细，就慢&lt;/strong&gt;。AI 时代，你猜怎么着？这活儿最适合交给机器人。不需要情商，直接喷；不需要开会，秒回；不需要记住项目规范，每次都按最新标准来。&lt;/p&gt;

&lt;p&gt;今天我来实操：用 AI 在自己的 PR 里自动跑 Code Review，准确到几行代码，还能直接输出 “P0 严重”、“P1 重要”、“P2 建议” 的结构化报告。准备接受 AI 对你代码的无差别吐槽了吗？&lt;/p&gt;
&lt;h2 id="一、AI 当 reviewer 会“胡说八道”吗？"&gt;一、AI 当 reviewer 会 “胡说八道” 吗？&lt;/h2&gt;
&lt;p&gt;你先不用管它会胡说八道。你只要让它遵循的原则是 &lt;strong&gt;“如果这条建议可能不对，就闭嘴”&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;打个比方，你让人工 review：“这个 useEffect 漏了清理函数” → 这是对的。“你这里最好改用 useMemo” → 有时是对的，但也可能是在显摆。&lt;/p&gt;

&lt;p&gt;AI 也一样，你调校它的方式不是 “提示词”，而是&lt;strong&gt;给它喂规则&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;来看看我用了大半年的一套 AI Code Review 命令行工具能 “喷” 什么：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;security&lt;/strong&gt;：检测登录态是否在前端 localStorage 明文存密码、有没有 XSS 注入风险、SQL 注入隐患。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;performance&lt;/strong&gt;：看你有没有在 React 组件里直接写 &lt;code&gt;const style = { margin: 10 }&lt;/code&gt;（每次渲染新建对象）。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;accessibility&lt;/strong&gt;：img 缺 alt、button 缺类型、颜色对比度稀烂。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;code quality&lt;/strong&gt;：文件超过 500 行给你标黄、catch 了错误只 &lt;code&gt;console.log&lt;/code&gt;（等于没 catch）、变量名拼写错误。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这套逻辑用下来，我的体感：&lt;strong&gt;AI 发现问题的速度是人类的 20 倍以上，遗漏率比人类 reviewer 低得多，而且从不得罪人&lt;/strong&gt;——因为它不带 “你写得好烂” 的情绪，只有纯技术判断。&lt;/p&gt;
&lt;h2 id="二、一行命令立刻拥有"&gt;二、一行命令立刻拥有&lt;/h2&gt;
&lt;p&gt;我最近一直在用一个叫 &lt;strong&gt;ai-review-pipeline&lt;/strong&gt; 的工具，它的理念很好：“AI 审查 AI 写的代码”——现在是 AI 辅助编程时代了，写得快但容易藏 bug，我正好需要这种【7x24 小时兜底】&lt;/p&gt;

&lt;p&gt;这个工具的特点是用 AI 自动 review + 生成测试 + 自动修复。你就无脑给它一个文件或目录，它出来的是&lt;strong&gt;评分 + 问题清单 + 修复建议 + 测试用例&lt;/strong&gt;。&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx ai-review-pipeline &lt;span class="nt"&gt;--file&lt;/span&gt; src/ &lt;span class="nt"&gt;--full&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;它的流程很清晰：先跑确定性规则检查（比如 ESLint 已有的问题一次性过滤掉，不让 AI 重复废话），然后 AI 带着你配置好的团队规范去读 diff，每发现一个问题就给等级、给修复建议、给代码示例。如果你加 &lt;code&gt;--fix&lt;/code&gt; 参数，它甚至会尝试&lt;strong&gt;自动修&lt;/strong&gt;然后重新 review。&lt;/p&gt;

&lt;p&gt;你说：“万一自动修复改错了逻辑呢？” 错不了，AI 只敢改那些确定性高的低质量代码（多余空格、未使用的变量、非空断言错误等）。&lt;strong&gt;业务逻辑？它先绕开，你合并之前自己把关。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;而且支持绝大部分场景（后端 Java, Go, Python, Rust 也能喷）：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;前端代码 review（React / Vue / TSX、CSS、a11y）&lt;/li&gt;
&lt;li&gt;backend-code-review（API 设计、数据库、并发安全）&lt;/li&gt;
&lt;li&gt;自动修复（默认只修格式、未使用的变量、确定的 lint 类问题，不敢动业务逻辑）&lt;/li&gt;
&lt;li&gt;Multi-LLM（OpenAI、Claude、DeepSeek、Gemini、Ollama 都能跑）&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="三、调 AI 的“毒舌参数”，让它喷得更专业"&gt;三、调 AI 的 “毒舌参数”，让它喷得更专业&lt;/h2&gt;
&lt;p&gt;我用过 Grok、Claude 和 DeepSeek，相对用得最深的是 &lt;strong&gt;DeepSeek&lt;/strong&gt; 和 &lt;strong&gt;OpenAI&lt;/strong&gt;。调教参数也很简单：&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 预设深度更狠一点，让它给你抓出 P0~P3 等级的分类&lt;/span&gt;
ai-review config &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;strictness&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;high
ai-review config &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;outputFormat&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;structured
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;AI 的审查结果可以直接挂在 GitHub PR 里当 Comment，每条对应具体行数和问题等级。一旦 AI 给了 &lt;strong&gt;P0（严重）&lt;/strong&gt; ，CI 直接 block 合并，你必须解决。&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;注意：如果你的项目有特别明确的 “业务规范”，你可以写到 &lt;code&gt;.ai-pipeline.json&lt;/code&gt; 配置文件里，例如 &lt;code&gt;“禁止引入 moment.js”&lt;/code&gt;、&lt;code&gt;“禁止在循环中使用 await”&lt;/code&gt;，AI 会把这部分自动纳入审查范畴，跟内置规则并排跑。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="四、这套 AI Code Review 还解决了哪些痛点？"&gt;四、这套 AI Code Review 还解决了哪些痛点？&lt;/h2&gt;&lt;h3 id="1. 跨语言能力 + 敏感信息排查"&gt;1. 跨语言能力 + 敏感信息排查&lt;/h3&gt;
&lt;p&gt;检查出来的问题几乎横跨你代码库所有位置：泄漏的 API endpoint、调试信息意外留到生产、图片 dead link 甚至无障碍视觉回归。&lt;/p&gt;

&lt;p&gt;它有一个特殊技巧：&lt;strong&gt;让多个 LLM 并行审查、互相辩论&lt;/strong&gt;，然后一个 “法官 agent” 给出最终裁决。不同模型能逮到不同 bug，通过共识机制滤掉噪音。&lt;/p&gt;
&lt;h3 id="2. CI 门禁，上线之前最后一道防线"&gt;2. CI 门禁，上线之前最后一道防线&lt;/h3&gt;
&lt;p&gt;你可以把 &lt;code&gt;ai-review-pipeline&lt;/code&gt; 挂在 GitHub Actions 上，每次 PR 自动执行。如果评分低于阈值（比如 80 分），CI 直接标红，连人都不用到场干预。&lt;/p&gt;

&lt;p&gt;某互联网团队落地类似系统后，&lt;strong&gt;代码审查周期缩短了 65%，基础性缺陷拦截率提升了 82%&lt;/strong&gt;。这个数据就是 AI review 正面效果的最好说明。&lt;/p&gt;
&lt;h2 id="五、毒舌吐槽大会：AI 喷过的那些人类代码"&gt;五、毒舌吐槽大会：AI 喷过的那些人类代码&lt;/h2&gt;
&lt;p&gt;我随便举几个真实案例，括号里是 AI 原话（节选）：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;案例 1&lt;/strong&gt;：&lt;code&gt;setTimeout(() =&amp;gt; { ... }, 0)&lt;/code&gt; 用来模拟 “异步一下”。AI 喷：“用 setTimeout 0 是反模式，你应该用 Promise.resolve().then() 或者 queueMicrotask。——另外，你连错误处理都没有？”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;案例 2&lt;/strong&gt;：自己封装了一个 &lt;code&gt;request&lt;/code&gt; 函数，每个请求都 &lt;code&gt;console.log&lt;/code&gt; 全部响应体数据。AI 喷：“你在生产环境把整个 API 响应结构暴露到控制台，包含了敏感字段。P0 严重。建议只 log trace id。”&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;案例 3&lt;/strong&gt;：&lt;code&gt;&amp;lt;img src={userInput} /&amp;gt;&lt;/code&gt;。AI 喷：“你直接把用户输入当 src？明天你的网站就变成菠菜广告基地。用 DOMPurify 或者干脆用 background-image 托管。”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;这套 AI 让我晚上睡得踏实。自从它上线，我再也没有过 “线上又崩了，测试也没测出来” 的半夜来电。&lt;/p&gt;
&lt;h2 id="六、总结：AI 帮你 review 代码不是替代人，而是让你做更高价值的事"&gt;六、总结：AI 帮你 review 代码不是替代人，而是让你做更高价值的事&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;AI 擅长一致性检查、边界检测、安全底网，还能给出可落地的修复示例。人类 reviewer 只需要关注架构、业务语义、用户体验。&lt;/li&gt;
&lt;li&gt;成本几乎为零，你只需要提供几个免费 API（Ollama、Groq 或各家入门额度）+ 一条 &lt;code&gt;npx&lt;/code&gt; 命令。&lt;/li&gt;
&lt;li&gt;未来方向：不只是 review，还开始蔓延到&lt;strong&gt;自动修复 + 测试生成 + 知识沉淀&lt;/strong&gt;，AI 在 PR 里已经可以替你完成 80% 的 “找茬” 工作。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我的建议：&lt;strong&gt;今天就拉一个这样的工具进你的私有仓库和团队&lt;/strong&gt;。领导不批预算？不用批，无门槛，立刻跑起来。等下次有人 PR 又是 “空代码 + 俩表情包” 来糊弄时，AI 会替你第一时间上去喷它：“你写的代码，想让我怎么 review？请自重点。”&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Fri, 15 May 2026 19:45:35 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7340</link>
      <guid>https://beta.w2solo.com/topics/7340</guid>
    </item>
    <item>
      <title>测试妹子让我写单测，我偷偷用 AI 一天干完一周的活</title>
      <description>&lt;blockquote&gt;
&lt;p&gt;公司要求单元测试覆盖率 80%，我看着那几千行的屎山组件，眼泪掉下来。手写？写到下个月。测试妹子天天在群里 @ 我：“帅哥，你的单测呢？” 我灵机一动，让 AI 帮我写。一天后，测试覆盖率 91%，0 bug，测试妹子发了条朋友圈：“某前端切图仔突然开窍了。” 我没敢告诉她，那是 GPT-5.5 的手笔。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;写单测这事，比写业务代码还痛苦。业务代码至少能跑，能看到按钮，能点。单测？Mock 一堆，断言一堆，跑起来全红，改到怀疑人生。尤其接手老项目，一个组件几百行，手动补测试？告辞。&lt;/p&gt;

&lt;p&gt;但我发现，&lt;strong&gt;AI 写单测的水平已经超过大部分初级工程师&lt;/strong&gt;。你只要给它组件代码，它能给你生成结构清晰的测试用例：渲染测试、交互测试、异步测试、边界测试，甚至能帮你 mock 掉依赖的 API。&lt;/p&gt;

&lt;p&gt;今天我就教你用 AI（Copilot / ChatGPT / Cursor）自动生成高质量 Jest + Testing Library 测试，把一周的活压缩到一天。测试妹子开心，我也能准时下班。&lt;/p&gt;
&lt;h2 id="一、为什么 AI 写单测比你手写强？"&gt;一、为什么 AI 写单测比你手写强？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;不嫌烦&lt;/strong&gt;：重复的渲染、快照、事件触发，AI 从不喊累。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;覆盖全&lt;/strong&gt;：它记得各种边界条件（空数组、null、undefined），你容易漏。&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;strong&gt;缺点&lt;/strong&gt;：生成的测试可能不够精准（比如断言了不重要的属性），需要你微调。但总比从零开始强。&lt;/p&gt;
&lt;h2 id="二、实战：用 Cursor + GPT-5.5 给 React 组件生成测试"&gt;二、实战：用 Cursor + GPT-5.5 给 React 组件生成测试&lt;/h2&gt;
&lt;p&gt;我们用一个实际的组件：&lt;code&gt;UserProfile&lt;/code&gt;，它接收 &lt;code&gt;userId&lt;/code&gt;，内部 fetch 用户数据，显示 loading、error、和用户信息。&lt;/p&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// UserProfile.jsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fetchUser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;UserProfile&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setUser&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;加载中...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;出错了：&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;无数据&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="步骤 1：打开 Cursor，选中组件代码，右键 -&amp;gt; “Generate Tests”"&gt;步骤 1：打开 Cursor，选中组件代码，右键 -&amp;gt; “Generate Tests”&lt;/h3&gt;
&lt;p&gt;或者在 ChatGPT 里粘贴代码，加上提示词：&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;请为以下 React 组件生成 Jest + React Testing Library 的单元测试。需要覆盖：初始加载状态、成功获取数据后显示用户信息、请求失败时显示错误信息、loading 结束后的渲染。请 mock 掉 &lt;code&gt;fetchUser&lt;/code&gt; 这个 API。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="步骤 2：AI 生成的测试（稍加整理）"&gt;步骤 2：AI 生成的测试（稍加整理）&lt;/h3&gt;&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;waitFor&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@testing-library/react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserProfile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./UserProfile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fetchUser&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Mock API 模块&lt;/span&gt;
&lt;span class="nx"&gt;jest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./api&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="nx"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UserProfile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;beforeEach&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;jest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;clearAllMocks&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;显示 loading 状态&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mockImplementation&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{}));&lt;/span&gt; &lt;span class="c1"&gt;// 永不返回&lt;/span&gt;
    &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserProfile&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;加载中...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toBeInTheDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;成功加载后显示用户信息&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mockUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;张三&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zhangsan@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mockResolvedValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mockUser&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserProfile&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;张三&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toBeInTheDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zhangsan@example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toBeInTheDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;请求失败时显示错误信息&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;网络错误&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mockRejectedValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserProfile&lt;/span&gt; &lt;span class="na"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;);&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/出错了：网络错误/&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toBeInTheDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;看，连等待异步的 &lt;code&gt;waitFor&lt;/code&gt; 都帮你写好了。&lt;/strong&gt; 你只需要改一下错误信息的正则，或者加几个边界测试（比如 userId 为空）。&lt;/p&gt;
&lt;h2 id="三、进阶：让 AI 生成更全面的测试"&gt;三、进阶：让 AI 生成更全面的测试&lt;/h2&gt;
&lt;p&gt;你可以在提示词里详细要求：&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;除了基本测试，还需要测试：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;当 userId 变化时，会重新请求数据（之前的请求应被忽略或取消）&lt;/li&gt;
&lt;li&gt;组件卸载时，不应再调用 setState&lt;/li&gt;
&lt;li&gt;快照测试（可选）&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;AI 会给你生成类似：&lt;/p&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;userId 变化时重新获取数据&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mockUser1&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mockUser2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="nx"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mockResolvedValueOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mockUser1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rerender&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UserProfile&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toBeInTheDocument&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

  &lt;span class="nx"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mockResolvedValueOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mockUser2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;rerender&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;UserProfile&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toBeInTheDocument&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchUser&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;toHaveBeenCalledTimes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;连 &lt;code&gt;rerender&lt;/code&gt; 都懂，你还想怎样？&lt;/p&gt;
&lt;h2 id="四、效率对比：手写 vs AI"&gt;四、效率对比：手写 vs AI&lt;/h2&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tr&gt;
&lt;th&gt;步骤&lt;/th&gt;
&lt;th&gt;手写时间&lt;/th&gt;
&lt;th&gt;AI 时间&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;写第一个测试（渲染 + loading）&lt;/td&gt;
&lt;td&gt;10min&lt;/td&gt;
&lt;td&gt;5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;写成功场景 + mock&lt;/td&gt;
&lt;td&gt;15min&lt;/td&gt;
&lt;td&gt;5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;写错误场景&lt;/td&gt;
&lt;td&gt;10min&lt;/td&gt;
&lt;td&gt;5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;写 userId 变化场景&lt;/td&gt;
&lt;td&gt;15min&lt;/td&gt;
&lt;td&gt;5s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;调试断言&lt;/td&gt;
&lt;td&gt;20min&lt;/td&gt;
&lt;td&gt;5min&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;strong&gt;70min&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~6min&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/table&gt;
&lt;p&gt;速度提升 10 倍以上。而且 AI 不会忘记清理 mock、不会漏掉 &lt;code&gt;waitFor&lt;/code&gt;。&lt;/p&gt;
&lt;h2 id="五、真实故事：我用 AI 补完了 2000 行老代码的单测"&gt;五、真实故事：我用 AI 补完了 2000 行老代码的单测&lt;/h2&gt;
&lt;p&gt;接手一个电商项目的购物车模块，几乎没测试。我写了脚本，把每个组件文件内容喂给 GPT-4（分批，用 API），让它输出测试代码。然后手动改几个断言，跑一遍。两个晚上，覆盖率从 12% 升到 86%。测试妹子在群里发了个烟花表情，我回了个狗头。&lt;/p&gt;
&lt;h2 id="六、注意事项（坑）"&gt;六、注意事项（坑）&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI 生成的 mock 路径可能错&lt;/strong&gt;，比如 &lt;code&gt;jest.mock('./api')&lt;/code&gt; 如果你的 api 文件是 &lt;code&gt;services/api.js&lt;/code&gt;，需要手动改。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;异步测试里 &lt;code&gt;waitFor&lt;/code&gt; 的超时&lt;/strong&gt;，有些场景需要增加 timeout 配置。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;不要盲目相信&lt;/strong&gt;：AI 可能会生成冗余测试（比如测试 &lt;code&gt;div&lt;/code&gt; 的 className），需要你删除。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;快照测试&lt;/strong&gt;：AI 喜欢生成 &lt;code&gt;toMatchSnapshot()&lt;/code&gt;，但快照容易 “假绿”，建议换成具体的断言。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="七、总结：AI 不是来取代你，是来帮你下班的"&gt;七、总结：AI 不是来取代你，是来帮你下班的&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;用 AI 生成单测模板，再人工微调，效率翻 10 倍。&lt;/li&gt;
&lt;li&gt;适合重复性高、边界条件多的纯函数和 React 组件。&lt;/li&gt;
&lt;li&gt;业务逻辑复杂、需要特定上下文的，AI 可能不准，但至少给你搭好架子。&lt;/li&gt;
&lt;li&gt;以后测试妹子再催你，你可以说：“已经在写（让 AI 写）了。”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;最后送大家一句话&lt;/strong&gt;：AI 写单测，你喝咖啡。测试全绿，准点下班。&lt;/p&gt;

&lt;hr&gt;

&lt;p&gt;评论区聊聊：你试过用 AI 写单测吗？有没有翻车？&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Thu, 14 May 2026 21:28:36 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7332</link>
      <guid>https://beta.w2solo.com/topics/7332</guid>
    </item>
    <item>
      <title>老板逼我上 AI，我偷偷在浏览器里跑 LLaMA，省下 20 万 API 费</title>
      <description>&lt;blockquote&gt;
&lt;p&gt;老板看了竞品，眼睛发光：“我们也上 AI！用户问啥都得秒回！” 我默默算了算 OpenAI 的账单——一个月 2 万，一年 24 万，够全组去三亚团建三次。于是我干了件疯狂的事：把 AI 模型塞进用户浏览器里。不用服务器，不花一分钱 API，用户电脑自己跟自己聊天。老板看着账单上的 “0”，问我是不是偷偷充了值。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;这事儿起因很简单：老板要 AI 客服。大模型 API 便宜吗？初看几分钱一次，用户一多，一个月一辆特斯拉没了。而且用户问的重复问题占 80%，每问一次就烧一次钱，像开着水龙头浇花。&lt;/p&gt;

&lt;p&gt;我寻思：能不能把模型直接扔到用户浏览器里？现在电脑、手机性能过剩，跑个小模型绰绰有余。说干就干，我找到了&lt;strong&gt;Transformers.js&lt;/strong&gt;——一个能在浏览器里跑 Hugging Face 模型的库，完全本地推理，不花一分钱 API，隐私还安全。&lt;/p&gt;

&lt;p&gt;今天我就带你手把手在 React 里集成一个本地 AI 问答模型（用的还是微软的&lt;strong&gt;Phi-3 mini&lt;/strong&gt;，效果媲美 GPT-3.5，体积只有 2GB 左右，量化后更小）。用户打开网页，模型自动下载到 IndexedDB，然后所有对话都在他电脑上完成。老板再也不用看账单了。&lt;/p&gt;
&lt;h2 id="一、为什么敢在浏览器里跑AI？"&gt;一、为什么敢在浏览器里跑 AI？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;硬件进步&lt;/strong&gt;：WebAssembly + WebGL，现代 CPU/GPU 能跑几十亿参数的小模型。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;模型变小&lt;/strong&gt;：Phi-3、TinyLLaMA、Gemma 2B，量化后几十到几百 MB。&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;strong&gt;闲时下载 + 缓存&lt;/strong&gt;策略，用户第一次访问花一分钟，之后秒开。&lt;/p&gt;
&lt;h2 id="二、技术选型：Transformers.js + Phi-3"&gt;二、技术选型：Transformers.js + Phi-3&lt;/h2&gt;
&lt;p&gt;Transformers.js 是 Hugging Face 官方库，支持在浏览器里运行 Transformer 模型。它自动利用 WebGL 加速，比纯 CPU 快 5-10 倍。&lt;/p&gt;

&lt;p&gt;我们要用的模型：&lt;strong&gt;Phi-3-mini-4k-instruct&lt;/strong&gt;（微软出品，38 亿参数，量化后约 2GB）。太大了？别急，有&lt;strong&gt;128k 上下文版更小&lt;/strong&gt;，或者用&lt;strong&gt;TinyLLaMA 1.1B&lt;/strong&gt;（量产后几百 MB）。我推荐先上&lt;code&gt;onnx-community/Phi-3-mini-4k-instruct-onnx&lt;/code&gt;，经过 ONNX 优化，体积更友好。&lt;/p&gt;
&lt;h2 id="三、实战：React + Transformers.js 实现本地问答"&gt;三、实战：React + Transformers.js 实现本地问答&lt;/h2&gt;&lt;h3 id="1. 安装依赖"&gt;1. 安装依赖&lt;/h3&gt;&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @xenova/transformers
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="2. 创建一个AI Hook"&gt;2. 创建一个 AI Hook&lt;/h3&gt;&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// hooks/useLocalLLM.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@xenova/transformers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 设置模型缓存路径（IndexedDB）&lt;/span&gt;
&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;localModelPath&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/models/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;useBrowserCache&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;useLocalLLM&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setGenerator&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setProgress&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loadModel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// 加载文本生成模型（这里用Phi-3的ONNX版本）&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-generation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;onnx-community/Phi-3-mini-4k-instruct-onnx&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;progress_callback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;downloading&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;setProgress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;progress&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nx"&gt;setGenerator&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;setLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;loadModel&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;generator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;max_new_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;generated_text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;h3 id="3. 在组件中使用"&gt;3. 在组件中使用&lt;/h3&gt;&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;AIChat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;progress&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useLocalLLM&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setQuestion&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setAnswer&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isGenerating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsGenerating&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;isGenerating&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;setIsGenerating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Phi-3 指令格式&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;|user|&amp;gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&amp;lt;|end|&amp;gt;\n&amp;lt;|assistant|&amp;gt;\n`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;setAnswer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="nx"&gt;setIsGenerating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;正在加载AI模型 &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;progress&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;% ... (首次约需1分钟)&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;textarea&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;question&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;setQuestion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ask&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isGenerating&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;问AI&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isGenerating&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;AI在你电脑里拼命想...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"answer"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="四、效果与优化"&gt;四、效果与优化&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;首次访问&lt;/strong&gt;：下载模型（约 1.5GB，看网速），之后缓存在 IndexedDB，第二次秒加载。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;推理速度&lt;/strong&gt;：Intel i7 16GB 上，生成 20 个 token 约 2 秒。M1 Mac 更快。手机上可换更小模型。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;优化技巧&lt;/strong&gt;：

&lt;ul&gt;
&lt;li&gt;用 Web Worker 运行模型，避免阻塞 UI。&lt;/li&gt;
&lt;li&gt;提前预加载：用户鼠标悬停在聊天按钮时就开始下模型。&lt;/li&gt;
&lt;li&gt;量化：选择&lt;code&gt;int8&lt;/code&gt;或&lt;code&gt;fp16&lt;/code&gt;版本，体积减半。&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="五、这和Vercel AI SDK有什么区别？"&gt;五、这和 Vercel AI SDK 有什么区别？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vercel AI SDK&lt;/strong&gt;：后端调 API，前端拿流式响应。还是要花钱，但开发快。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transformers.js&lt;/strong&gt;：完全本地，零成本，但首次加载慢，设备性能要求高。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;我的建议：&lt;strong&gt;混合模式&lt;/strong&gt;。默认用本地模型，如果用户设备太老或模型下载失败，fallback 到云端 API。既省钱又不丢用户体验。&lt;/p&gt;
&lt;h2 id="六、老板的反应"&gt;六、老板的反应&lt;/h2&gt;
&lt;p&gt;上线后，老板问：“这月 AI 账单怎么是 0？” 我说：“我把 AI 搬到用户浏览器里了。” 他沉默了三秒：“那岂不是我们没数据了？” 我说：“要数据干嘛？又卖不掉。省下的钱给我们加鸡腿。” 老板居然同意了。&lt;/p&gt;
&lt;h2 id="七、总结：本地AI不是梦，是未来"&gt;七、总结：本地 AI 不是梦，是未来&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Transformers.js 让浏览器跑大模型成为可能。&lt;/li&gt;
&lt;li&gt;适合&lt;strong&gt;隐私敏感、成本敏感&lt;/strong&gt;的场景（客服、笔记、翻译）。&lt;/li&gt;
&lt;li&gt;首次加载慢，但配合缓存和进度提示，用户能接受。&lt;/li&gt;
&lt;li&gt;技术选型：模型选 Phi-3/TinyLLaMA，量化版几十到几百 MB。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;下次老板再让你接入 AI，你可以淡定地说：“本地跑，不花钱，隐私好。” 然后默默打开这篇文章——代码都给他准备好了。&lt;/p&gt;

&lt;p&gt;评论区聊聊：你的公司花多少钱在 AI API 上？想不想省下来换新电脑？&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Wed, 13 May 2026 20:36:47 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7324</link>
      <guid>https://beta.w2solo.com/topics/7324</guid>
    </item>
    <item>
      <title>坏了，黑客学会用 AI 写外挂了</title>
      <description>&lt;p&gt;昨天刚聊完程序员为了省那 23 万 Token 账单连夜跑路的事儿，今天又出大事了。&lt;/p&gt;

&lt;p&gt;而且这次不是钱包的问题——是有人在用 AI 造 “数字万能钥匙”，想捅谁家窗户就捅谁家窗户。&lt;/p&gt;

&lt;p&gt;听完你可能会想把家里智能门锁也换成铁锁头。&lt;/p&gt;
&lt;h3 id="连坏人都开始用AI打工了？凌晨，谷歌紧急拉响警报"&gt;连坏人都开始用 AI 打工了？凌晨，谷歌紧急拉响警报&lt;/h3&gt;
&lt;p&gt;昨天深夜（美国时间 5 月 11 日），谷歌旗下专门抓坏人的威胁情报小组发布了一份让人睡不着觉的报告：&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;他们第一次抓到网络攻击者用 AI 造出了 “零日漏洞” 攻击工具。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;不懂技术没关系，咱用大白话说：
零日漏洞，就好比你家的门锁有一个你压根不知道存在的缺陷。厂家没发现，修都来不及修。而坏人拿着这个缺陷配了一把万能钥匙，想进就进。&lt;/p&gt;

&lt;p&gt;最要命的是，谷歌说这个 AI 造的漏洞攻击脚本，能直接绕过双重认证！
你辛苦设了密码、绑了手机、开了人脸，结果人家 AI 直接找到了一个你连系统都没想到的后门。&lt;/p&gt;

&lt;p&gt;过去这种级别的攻击，需要顶尖黑客团队花几周甚至几个月研究。
现在呢？一个会用 AI 的普通技术员，可能喝杯咖啡的功夫就搞出来了。&lt;/p&gt;

&lt;p&gt;谷歌自己也承认：AI 确实能帮好人修漏洞，但更可怕的是，它也帮坏人把造 “万能钥匙” 的门槛从研究生水平降到了初中生水平。&lt;/p&gt;

&lt;p&gt;这感觉就像什么？
你辛辛苦苦练了十年散打，结果街上突然开始卖 “一拳超人” 力量手套，谁都能买到。&lt;/p&gt;

&lt;p&gt;而就在谷歌拉响警报的同一天，OpenAI 老板山姆·奥特曼也跳出来说话了。他说他们刚搞了一个叫 “Daybreak”（破晓）的网络安全项目，专门用 AI 帮企业查漏洞、堵后门。&lt;/p&gt;

&lt;p&gt;翻译过来就是：坏人用 AI 造枪，好人赶紧用 AI 做防弹衣。&lt;/p&gt;

&lt;p&gt;一场无声的 “AI 军备竞赛”，就这么在咱们眼皮底下打响了。&lt;/p&gt;
&lt;h3 id="一边降、一边涨，在你看不见的收费单上"&gt;一边降、一边涨，在你看不见的收费单上&lt;/h3&gt;
&lt;p&gt;说完安全问题，咱们回来聊聊对普通人影响更直接的事。&lt;/p&gt;

&lt;p&gt;就在昨天，这周的 AI 圈搞了个大型 “变脸表演”。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;一边，有人开始收钱了。&lt;/strong&gt; 那个月活 3.45 亿的国民 AI 豆包，突然挂出了三档收费：基础版免费，但标准版 68 元/月，加强版 200 元/月，专业版 500 元/月。&lt;/p&gt;

&lt;p&gt;人家也挺坦诚的，直接摊牌：不是我想割韭菜，是真扛不住了。现在豆包一天要处理 120 万亿 Token，算力账单涨了 1000 倍。母公司字节跳动去年因为疯狂买 AI 算力，净利润跌了 70% 多。于是，收费成了唯一解方。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;另一边，却有人在大甩卖。&lt;/strong&gt; DeepSeek 昨天宣布 V4 模型降价 75%，缓存调用直接打 1 折。有开发者晒单：一天处理了 278 亿 Token，账单居然才 160 美元。换成别家的模型，同一件事没一万美元下不来。&lt;/p&gt;

&lt;p&gt;换句话说，你花一顿火锅的钱，它帮你干了一个十人团队一周的活。&lt;/p&gt;

&lt;p&gt;于是你就看到了一个特别割裂的名场面：
豆包说 “不好意思，我要涨价了”，DeepSeek 说 “没关系，我打骨折”。&lt;/p&gt;

&lt;p&gt;一个最直观的结果就是，这周微信搜索指数显示，&lt;strong&gt;DeepSeek 的搜索热度单周暴涨了 470%&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;这波啊，消费者用脚投票的姿势，比任何技术评测都诚实。&lt;/p&gt;
&lt;h3 id="AI编程圈也在变天，新手程序员更难混了"&gt;AI 编程圈也在变天，新手程序员更难混了&lt;/h3&gt;
&lt;p&gt;再把视线转到打工人这边。昨天我们聊了一家公司因为太贵连夜 “搬家” 省了 23 万，而这个 “搬家潮” 在硅谷显然不是个例。&lt;/p&gt;

&lt;p&gt;就在昨天凌晨，JetBrains 的 Resharper（另一个神级编程辅助插件）宣布启动 2026.2 版本的 Early Access 计划，核心就是把更多的 AI Agent 塞进 Visual Studio 里，帮程序员自动干活。&lt;/p&gt;

&lt;p&gt;与此同时，那个 Claude Code 的创始人之一前几天又放了个 “暴论” 引发的余震还在持续：他说现在这个行业约 50% 的编程工作已经被 AI 替代了，他的团队代码 100% 是 AI 写的，他一天能发 150 个 AI 生成的代码合并请求。&lt;/p&gt;

&lt;p&gt;虽然行业大佬吴恩达跑出来往回找补说 “别慌，系统架构设计 AI 还干不了”，但他也承认：&lt;strong&gt;前端开发是重灾区，AI 替代得最彻底。&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;所以现在的情况是啥？
AI 编程工具在拼命进化、价格战打得飞起，但 GitHub Copilot 还在下个月开始按 Token 计费收你钱。
一边是生产力彻底解放，一边是 API 账单原地飞升。&lt;/p&gt;

&lt;p&gt;做技术的朋友，现在日子就像在雷区跳芭蕾——一步踩错，要么被淘汰，要么钱包被掏空。&lt;/p&gt;
&lt;h3 id="到最后，我们该咋办？"&gt;到最后，我们该咋办？&lt;/h3&gt;
&lt;p&gt;今天信息量有点大，但三个信号特别强烈：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI 已经不只是工具，它也可以是武器。&lt;/strong&gt; 谷歌首次确认 AI 造的零日漏洞攻击工具，这场 AI 军备竞赛从今天开始正式进入 “全民皆兵” 阶段。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;AI 行业正在两极分化。&lt;/strong&gt; 有人开始收费，有人打骨折。但 “免费永远是最贵的”——现在终于轮到 AI 验证这句话了。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;“会用 AI 的人” 正在和 “不会用 AI 的人” 拉开差距。&lt;/strong&gt; 不管是打工人还是企业，这不是危言耸听，这正在发生。&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;最后说一句大实话：
当坏人都在用 AI 搞业务的时候，我们这些普通人如果还不去了解一下 AI 能帮自己做什么、省什么，那真的不是在观望，而是在掉队。&lt;/p&gt;

&lt;p&gt;📢 &lt;strong&gt;今日话题&lt;/strong&gt;：你用过最省钱的一次 AI 操作是什么？欢迎在评论区晒出来，一起避雷一起省钱～&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Tue, 12 May 2026 19:37:19 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7318</link>
      <guid>https://beta.w2solo.com/topics/7318</guid>
    </item>
    <item>
      <title>你写的代码没有测试，就像出门不锁门——Jest + Testing Library 从入门到不慌</title>
      <description>&lt;blockquote&gt;
&lt;p&gt;你改了一行代码，手动点了一遍页面，觉得没问题就上线了。结果用户反馈 “登录按钮点不动了”。你心里咯噔：我根本没改登录相关代码啊。今天我们来给你的代码装一把 “智能门锁”——单元测试。用 Jest + Testing Library，把常见 Bug 锁在门外，让你改代码时不再心惊胆战。&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;img src="https://img.way2solo.com/photo/193577746/8ff0c4e9-f874-48b3-aa89-627f43c1f6cf.png?imageView2/2/w/1920/q/100" title="" alt=""&gt;&lt;/p&gt;
&lt;h2 id="前言"&gt;前言&lt;/h2&gt;
&lt;p&gt;很多前端对测试的态度是：项目那么赶，哪有时间写测试？结果修 Bug 的时间比写代码还多。你花 20 分钟写的测试，可能帮你省掉 2 小时的通宵排查。&lt;/p&gt;

&lt;p&gt;测试不是 “额外工作”，而是&lt;strong&gt;安全网&lt;/strong&gt;。当你需要重构、升级依赖、添加新功能时，测试全绿的那一刻，比中彩票还安心。今天我们用 Jest（测试框架）+ Testing Library（渲染组件、模拟用户操作），从零开始给你的 React 项目写第一个测试。不搞复杂概念，只写最实用的断言。&lt;/p&gt;
&lt;h2 id="一、Jest 是啥？Testing Library 又是啥？"&gt;一、Jest 是啥？Testing Library 又是啥？&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Jest&lt;/strong&gt;：Facebook 出的测试框架，内置断言、模拟函数、覆盖率报告。开箱即用，零配置。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing Library&lt;/strong&gt;：一套帮助你 “像用户一样测试” 的工具。不测试组件内部 state 或 props，只测试用户能看到和能操作的。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;核心原则&lt;/strong&gt;：测试越接近用户的使用方式，越能给你信心。不要测试实现细节（比如某个函数被调用了几次、某个 state 变了），要测试 UI 上出现了什么、点击后发生了什么变化。&lt;/p&gt;
&lt;h2 id="二、环境搭建（Create React App 用户）"&gt;二、环境搭建（Create React App 用户）&lt;/h2&gt;
&lt;p&gt;如果你用 CRA，Jest 和 Testing Library 已经内置，直接写就行。Vite 用户需要手动安装：&lt;/p&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; jest @testing-library/react @testing-library/jest-dom @testing-library/user-event vitest
&lt;span class="c"&gt;# 如果用 Vitest（Vite 推荐），配置略不同。这里我们用 Jest 示范&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;配置 &lt;code&gt;jest.config.js&lt;/code&gt;：&lt;/p&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;testEnvironment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jsdom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;setupFilesAfterEnv&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;rootDir&amp;gt;/src/setupTests.js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;src/setupTests.js&lt;/code&gt;：&lt;/p&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@testing-library/jest-dom&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="三、第一个测试：测试一个纯函数"&gt;三、第一个测试：测试一个纯函数&lt;/h2&gt;
&lt;p&gt;测试最简单的工具函数，是入门的绝佳方式。比如 &lt;code&gt;utils/formatPrice.js&lt;/code&gt;：&lt;/p&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;formatPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;¥&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写测试 &lt;code&gt;utils/formatPrice.test.js&lt;/code&gt;：&lt;/p&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;formatPrice&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./formatPrice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;格式化价格带默认货币符号&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formatPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;10.5&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;¥10.50&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;支持自定义货币符号&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formatPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;10.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$10.50&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;运行 &lt;code&gt;npm test&lt;/code&gt;，看到绿色通过。这类测试跑得快，你应该写很多。&lt;/p&gt;
&lt;h2 id="四、测试 React 组件：渲染与交互"&gt;四、测试 React 组件：渲染与交互&lt;/h2&gt;
&lt;p&gt;假设我们有一个 &lt;code&gt;Counter&lt;/code&gt; 组件：&lt;/p&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;计数: &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;setCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;增加&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;写测试 &lt;code&gt;Counter.test.jsx&lt;/code&gt;：&lt;/p&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@testing-library/react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;userEvent&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@testing-library/user-event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Counter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./Counter&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;渲染初始计数为0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;countElement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/计数: 0/i&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;countElement&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;toBeInTheDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;点击按钮后计数增加&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;userEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Counter&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sr"&gt;/增加/i&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/计数: 1/i&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toBeInTheDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;注意：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;screen.getByRole&lt;/code&gt; 比 &lt;code&gt;getByText&lt;/code&gt; 更语义化，推荐优先使用。&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;userEvent&lt;/code&gt; 模拟真实点击（会触发 focus、blur 等），比 &lt;code&gt;fireEvent&lt;/code&gt; 更接近用户。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="五、测试异步操作：比如数据加载"&gt;五、测试异步操作：比如数据加载&lt;/h2&gt;
&lt;p&gt;一个显示用户列表的组件，从 API 获取数据：&lt;/p&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nx"&gt;UserList&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setUsers&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
  &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setUsers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;li&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;ul&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;测试时需要 mock &lt;code&gt;fetch&lt;/code&gt;：&lt;/p&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;waitFor&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@testing-library/react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;UserList&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./UserList&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nb"&gt;global&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;json&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;张三&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;李四&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}]),&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nx"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;加载并显示用户列表&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;UserList&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;// 等待数据加载完成&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;waitFor&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;张三&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toBeInTheDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;getByText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;李四&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;toBeInTheDocument&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="六、覆盖率：别盲目追求 100%"&gt;六、覆盖率：别盲目追求 100%&lt;/h2&gt;
&lt;p&gt;运行 &lt;code&gt;npm test -- --coverage&lt;/code&gt;，会生成覆盖率报告。但记住：&lt;strong&gt;100% 覆盖率不代表没有 Bug&lt;/strong&gt;。覆盖率低的地方可能是关键逻辑，需要补测试；但有些样板代码（如常量定义、简单 getter）不测也罢。重点覆盖业务逻辑和复杂交互。&lt;/p&gt;
&lt;h2 id="七、测试最佳实践"&gt;七、测试最佳实践&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;测试行为，不测试实现&lt;/strong&gt;：不要测试组件内部 state 的值（除非必要），而是测试渲染结果。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;一个测试只断言一件事&lt;/strong&gt;：一个 &lt;code&gt;test&lt;/code&gt; 里可以有多个 &lt;code&gt;expect&lt;/code&gt;，但最好只测一个行为。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;模拟外部依赖&lt;/strong&gt;：网络请求、localStorage、计时器都要模拟，避免测试不稳定。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;避免测试快照&lt;/strong&gt;：快照测试（&lt;code&gt;toMatchSnapshot&lt;/code&gt;）容易产生大而脆弱的文件，改个空格就挂。优先用断言。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;让测试快速&lt;/strong&gt;：单元测试应该在几秒内跑完，如果慢，检查是否有真实网络请求或大量渲染。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="八、持续集成：让测试自动跑起来"&gt;八、持续集成：让测试自动跑起来&lt;/h2&gt;
&lt;p&gt;把测试放到 GitHub Actions 里（上篇文章的内容）。每次 PR 自动跑测试，不通过不让合并。这样团队协作时，队友的改动不会悄悄破坏你的代码。&lt;/p&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;18&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm test&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;&lt;h2 id="九、总结：测试是给未来的自己写信"&gt;九、总结：测试是给未来的自己写信&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;写测试一开始会慢，但能让你后期 “闭着眼睛改代码”。&lt;/li&gt;
&lt;li&gt;Jest + Testing Library 是 React 社区标准，Vue/Vite 对应 Vitest + Testing Library。&lt;/li&gt;
&lt;li&gt;不要被 “测试种类太多” 吓到，从纯函数和简单组件开始，逐步扩大覆盖。&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;下次你改了代码，测试全绿，你就可以自信地 push。那种感觉，比手动点一百遍页面踏实多了。&lt;/p&gt;

&lt;p&gt;如果你觉得今天的 “智能门锁” 够踏实，点个赞让更多人看到。评论区聊聊：你被上线后突然出现的 Bug 坑过吗？&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Tue, 12 May 2026 00:02:44 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7312</link>
      <guid>https://beta.w2solo.com/topics/7312</guid>
    </item>
    <item>
      <title>我开发的 Chrome 扒图浏览器插件又更新了❗</title>
      <description>&lt;blockquote&gt;
&lt;p&gt;一个月前，我发布了自己第一个 Chrome 插件 Image Harvest——一个能扒出网页所有图片的批量下载工具。收到了很多鼓励和建议，这次 v1.0.2 是第一个「有料」的功能更新。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2 id="这次更新了什么？"&gt;这次更新了什么？&lt;/h2&gt;&lt;h3 id="🌍 全球化：5 种语言，母语体验"&gt;🌍 全球化：5 种语言，母语体验&lt;/h3&gt;
&lt;p&gt;&lt;img src="https://img.way2solo.com/photo/193577746/48ad6b16-a5f7-44c9-ade1-65a63d92a518.png?imageView2/2/w/1920/q/100" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;v1.0.2 最大的工程量投入是国际化。现在 Image Harvest 支持 5 种语言：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;英语 (en)&lt;/li&gt;
&lt;li&gt;简体中文 (zh_CN)&lt;/li&gt;
&lt;li&gt;繁体中文 (zh_TW)&lt;/li&gt;
&lt;li&gt;日语 (ja)&lt;/li&gt;
&lt;li&gt;西班牙语 (es)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;不需要手动切换，跟着浏览器语言自动适配。所有界面文案——侧边栏、弹窗、设置页、收藏夹、反向搜图页、toast 提示、模态框、按钮——全部走 chrome.i18n 标准方案。&lt;/p&gt;

&lt;p&gt;做这个决定的原因很简单：Chrome Web Store 是全球市场，如果只有英文界面，日本和西语区的用户根本不会点安装。&lt;/p&gt;
&lt;h3 id="💎 7 天 Pro 免费试用：零门槛体验全部高级功能"&gt;💎 7 天 Pro 免费试用：零门槛体验全部高级功能&lt;/h3&gt;
&lt;p&gt;&lt;img src="https://img.way2solo.com/photo/193577746/8c8bd7e7-6594-4147-8a34-102d9a5a7b3a.png?imageView2/2/w/1920/q/100" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;之前很多人在评论区问「Pro 到底值不值得买？」，我觉得与其我说值，不如让大家自己体验。&lt;/p&gt;

&lt;p&gt;现在新安装的用户会自动获得 7 天 Pro 试用，期间所有高级功能全部解锁：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;多标签页同时批量扒图&lt;/li&gt;
&lt;li&gt;pHash 相似图片自动检测去重&lt;/li&gt;
&lt;li&gt;批量高亮定位&lt;/li&gt;
&lt;li&gt;图片收藏夹（IndexedDB 本地存储）&lt;/li&gt;
&lt;li&gt;格式转换（PNG ↔ JPG ↔ WebP）&lt;/li&gt;
&lt;li&gt;自定义命名模板（12 个占位符）&lt;/li&gt;
&lt;li&gt;TinEye / Baidu / Yandex 反向搜图&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;不绑卡、不套路。试用到期前一天会温和提醒一次，过期后自动降级为免费版，免费版的核心功能照常使用。&lt;/p&gt;
&lt;h3 id="📋 批量复制图片链接"&gt;📋 批量复制图片链接&lt;/h3&gt;
&lt;p&gt;&lt;img src="https://img.way2solo.com/photo/193577746/2b34df4a-8680-4105-943d-9553fe779ea3.png?imageView2/2/w/1920/q/100" title="" alt=""&gt;&lt;/p&gt;

&lt;p&gt;这个功能是用户反馈最多的：「我不想下载图片，我只想拿到 URL」。&lt;/p&gt;

&lt;p&gt;现在选中任意张图片，点一下就能把所有 URL 复制到剪贴板（换行分隔）。单图卡片右键也有「复制链接」。复制成功会显示具体条数（"已复制 12 个链接"），失败时给出权限提示。&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;SEO 调研时批量采集竞品图片 URL&lt;/li&gt;
&lt;li&gt;开发调试时快速拿到资源路径&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="没用过？30 秒了解 Image Harvest"&gt;没用过？30 秒了解 Image Harvest&lt;/h2&gt;
&lt;p&gt;Image Harvest 是一个隐私优先的 Chrome 图片批量下载插件。它能扒出网页里所有图片——包括大多数下载器会漏掉的 CSS 背景图、懒加载源、Shadow DOM、同源 iframe 里的图片。&lt;/p&gt;

&lt;p&gt;零追踪、零远程代码、100% 本地处理。源码已在 GitHub 开源（MIT 协议）。&lt;/p&gt;

&lt;p&gt;🛒 Chrome 商店安装：&lt;a href="https://chromewebstore.google.com/detail/iecgnjidmogebokcfnejncgnelcepffo" rel="nofollow" target="_blank"&gt;https://chromewebstore.google.com/detail/iecgnjidmogebokcfnejncgnelcepffo&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🌐 官网：&lt;a href="https://image-harvest.kyriewen.cn" rel="nofollow" target="_blank"&gt;https://image-harvest.kyriewen.cn&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;🎬 演示视频：&lt;a href="https://www.youtube.com/watch?v=o5KdX--l-yw" rel="nofollow" target="_blank"&gt;https://www.youtube.com/watch?v=o5KdX--l-yw&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;💻 GitHub：&lt;a href="https://github.com/zbw-zbw/image-harvest" rel="nofollow" target="_blank"&gt;https://github.com/zbw-zbw/image-harvest&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;有任何 bug 或功能建议，欢迎在评论区留言或去 GitHub 提 issue。独立开发者一个人在做，每条反馈我都会看 🙏&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Sun, 10 May 2026 14:02:11 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7303</link>
      <guid>https://beta.w2solo.com/topics/7303</guid>
    </item>
    <item>
      <title>OpenAI 免费了，微软却割韭菜？前端切图仔 2026 生存指南</title>
      <description>&lt;p&gt;今天咱们聊点带劲的。AI 圈这两天上演了一出大戏：OpenAI 把连 GPT-5.5 都免费放出来了，摆出一副 “交个朋友” 的架势，似乎想把 ChatGPT 从摇钱树变成自来水——毕竟要在德国这种监管最严的市场站稳脚跟，免费就是最好的敲门砖。&lt;/p&gt;

&lt;p&gt;与此同时，微软却反手一招 “降本增效”：GitHub Copilot 宣布从 6 月 1 日起 &lt;strong&gt;按 Token 计费&lt;/strong&gt;。以后你写的每一行代码，都在烧你的真金白银！好家伙，一个唱红脸、一个唱白脸，2026 年 5 月的 AI 圈，完美诠释了什么叫 “成年人的世界，没有免费午餐”。&lt;/p&gt;

&lt;p&gt;作为前端切图仔，我们正好处在风暴眼——今天就用第一视角，带兄弟们看懂这轮洗牌：谁在发福利、谁在暗中收费，以及前端工程师在这个新时代到底该用什么姿势写代码。&lt;/p&gt;

&lt;p&gt;&lt;img src="https://img.way2solo.com/photo/193577746/626664f9-5b60-499a-9460-3bf81b9ae89a.png?imageView2/2/w/1920/q/100" title="" alt=""&gt;&lt;/p&gt;
&lt;h2 id="一、“Token 刺客”，背刺了谁？"&gt;一、“Token 刺客”，背刺了谁？&lt;/h2&gt;
&lt;p&gt;可能还有同学没看懂 Copilot 这次变动的杀伤力。通俗点说：以前你花 10 刀随便调到爽，现在还是花 10 刀，但只给你相当于 10 刀的 “Credit”。超出部分？加钱。&lt;/p&gt;

&lt;p&gt;这就相当于你去吃自助餐，老板突然改成 “按克称重”——肉没少吃两片，账单倒是先破防了。开发者社区直接炸锅，经典评论刷屏：“你得到的会更少，但你付的钱不变。”&lt;/p&gt;

&lt;p&gt;从技术层面看，这背后反映的是 Token 消耗的飙升。现在的 Coding Agent 动辄遍历整个仓库、进行多步骤自主编程，一次深度交互烧掉几百万 Token 是家常便饭。但微软选择把成本转嫁给开发者，也不得不说是 AI 商业化最残酷的真实写照。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;咱们前端开发者怎么办？&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;在重构老项目、做大型需求评估等不需要重度 AI 推理的场景下，可以偶尔切回 &lt;strong&gt;DeepSeek V4&lt;/strong&gt; 这种高性价比模型。它的定价只有 GPT-5.5 的 &lt;strong&gt;3%&lt;/strong&gt; 左右，API 输入缓存命中价格低至 $0.14/百万 token。把钱花在刀刃上，它不香吗？&lt;/li&gt;
&lt;li&gt;VS Code 1.118 最新版本已经开始内置 Token 节省机制，比如同一 Agent 会话中 &lt;strong&gt;93% 以上的提示词请求可以走缓存&lt;/strong&gt;，不用重复计费，属于微软最后的良心。2026 年的前端程序员，不仅要懂 HTML/CSS/JS，你还得学会 “Token 经济学”。&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="二、AI 的“凡尔赛”：GPT-5.5 免费了，但广告来了"&gt;二、AI 的 “凡尔赛”：GPT-5.5 免费了，但广告来了&lt;/h2&gt;
&lt;p&gt;说完微软的收费策略，再看 OpenAI 的算盘，更有意思。ChatGPT 的免费档位现在直接给你上 GPT-5.5，不需要验证码，不需要信用卡，白嫖党的春天来得如此突然【26†L6-L8】。&lt;/p&gt;

&lt;p&gt;但同时，谷歌宣布将在 Gemini 里引入广告变现——OpenAI 的 ChatGPT 早在 2025 年底就试水了广告，到 2026 年 Q1，广告收入已经占到总营收的 8%。&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;翻译成人话就是&lt;/strong&gt;：AI 巨头们正在复刻互联网的老路——先免费拉人头，再把广告塞到你脸上。只不过这次卖的不是搜索结果，是你让 AI 写的每一行代码、每一个问题。&lt;/p&gt;
&lt;h2 id="三、前端开发的“噩梦”或“救星”：多模态大模型"&gt;三、前端开发的 “噩梦” 或 “救星”：多模态大模型&lt;/h2&gt;
&lt;p&gt;聊完商业，我们回归代码位。不知道大家有没有被设计师的 “像素眼” 折磨过？你写的页面和设计稿之间差了 1px，他们都能精准狙杀你。&lt;/p&gt;

&lt;p&gt;好消息是，以前靠 “文字描述→代码生成” 的中间损耗太大，复杂样式还原度甚至不到 65%。但现在 &lt;strong&gt;2026 年新出的多模态编码器&lt;/strong&gt;，可以直接从视觉设计稿自动解析布局和响应式规则，UI 组件识别准确率飙升到 92.7%。&lt;/p&gt;

&lt;p&gt;前端真的不用手写 CSS 了吗？准确地说，&lt;strong&gt;写 CSS 的双手被解放了，但你得会用 AI 驱动的开发工具&lt;/strong&gt;。&lt;/p&gt;

&lt;p&gt;Figma 已经把 AI Agent 引入画布，OpenAI Codex 和 Claude Code 能直接在 Figma 里创建和修改设计资产。更离谱的是 OpenRouter 数据显示，中国研发的大模型调用量已经连续五周超过美国，占全球总量的 &lt;strong&gt;61%&lt;/strong&gt;，说明在全球范围内，AI 辅助编程已经不是趋势而是标配，而中国开发者在这场浪潮中跑得比谁都快。&lt;/p&gt;
&lt;h2 id="四、前端人吃瓜也得看懂：全球 AI 神仙打架"&gt;四、前端人吃瓜也得看懂：全球 AI 神仙打架&lt;/h2&gt;
&lt;p&gt;最后的最后，咱作为混技术社区的，还得看懂最近的 “神仙打架”：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;谷歌双线作战&lt;/strong&gt;：DeepMind 推出 “AI 联合临床医生”，在医学诊断上已经能和初级医生平起平坐；同时谷歌云业务暴增 63%，TPU 芯片正面挑战 NVIDIA。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI 的算力核武&lt;/strong&gt;：已提前多年搞定 10 吉瓦的美国算力储备，为下一阶段模型训练备足弹药，这才是真正的 “氪金玩家”。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic 的不合作态度&lt;/strong&gt;：拒绝了五角大楼的军事 AI 协议，是唯一未被纳入的科技巨头。&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;国产顶流小米&lt;/strong&gt;：MiMo-V2.5-Pro 在 OpenRouter 上以周调用量 4.82 万亿 Token 拿下全球第一，高调用量证明了 AI 正在从 “模型军备竞赛” 转向 “应用落地竞速”。&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="总结：前端的未来属于适配能力最强的人"&gt;总结：前端的未来属于适配能力最强的人&lt;/h2&gt;
&lt;p&gt;2026 年 5 月的 AI 圈最核心的关键词其实是 &lt;strong&gt;“成本”&lt;/strong&gt;。巨头们在商业收费和模型性能间找平衡，而在充满挑战的宏观环境下，每个开发者也得学会低成本高效能工作。&lt;/p&gt;

&lt;p&gt;最后送大家一句今日感悟：“AI 不会取代前端，但你的 Token 账单可能会掏空你的钱包。省着点用，把 AI 用在刀刃上，才是成熟的 2026 开发之道。”&lt;/p&gt;

&lt;p&gt;这个行业变化太快，但有一点不变：&lt;strong&gt;技术永远在迭代，焦虑没有用，快速学习和适应的人才有未来。&lt;/strong&gt;&lt;/p&gt;</description>
      <author>193577746</author>
      <pubDate>Sun, 03 May 2026 12:47:04 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7284</link>
      <guid>https://beta.w2solo.com/topics/7284</guid>
    </item>
    <item>
      <title>[新品] Image Harvest - 3 个月开发的 Chrome 扩展，第一周 200 安装的真实复盘</title>
      <description>&lt;h2 id="产品基本信息"&gt;产品基本信息&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;产品名&lt;/strong&gt;：Image Harvest&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;类型&lt;/strong&gt;：Chrome 扩展&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;定位&lt;/strong&gt;：解决现代网页图片下载工具漏图问题&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;开源&lt;/strong&gt;：MIT 协议 → &lt;a href="https://github.com/zbw-zbw/image-harvest" rel="nofollow" target="_blank"&gt;https://github.com/zbw-zbw/image-harvest&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;官网&lt;/strong&gt;：&lt;a href="https://image-harvest.kyriewen.cn" rel="nofollow" target="_blank"&gt;https://image-harvest.kyriewen.cn&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;安装&lt;/strong&gt;：&lt;a href="https://chromewebstore.google.com/detail/iecgnjidmogebokcfnejncgnelcepffo" rel="nofollow" target="_blank"&gt;https://chromewebstore.google.com/detail/iecgnjidmogebokcfnejncgnelcepffo&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;img src="https://img.way2solo.com/photo/193577746/35aad060-db63-4739-b1ac-2d5bd1fe9654.jpg?imageView2/2/w/1920/q/100" title="" alt=""&gt;&lt;/p&gt;
&lt;h2 id="为什么做这个"&gt;为什么做这个&lt;/h2&gt;
&lt;p&gt;我之前是设计师，做素材调研一直被「主流图片下载扩展在现代网页上漏图」的问题困扰
—— 明明页面上看到 60+ 张图，主流扩展只能抓到 8 张。&lt;/p&gt;

&lt;p&gt;研究了一下技术原因：现代网页 60-90% 的图不在 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 标签里，
而是 CSS background / Shadow DOM / 懒加载 / srcset / &lt;code&gt;&amp;lt;picture&amp;gt;&lt;/code&gt; 里。
主流工具只看 &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; 所以全漏了。&lt;/p&gt;

&lt;p&gt;花了 3 个月把这 5 种现代 web 技术都处理了，做成了 Image Harvest。&lt;/p&gt;
&lt;h2 id="第一周真实数据复盘（W2solo 同行最关心的部分）"&gt;第一周真实数据复盘（W2solo 同行最关心的部分）&lt;/h2&gt;
&lt;blockquote&gt;
&lt;p&gt;透明分享真实数字，希望对其他独立开发者有参考价值。&lt;strong&gt;不修饰、不夸大&lt;/strong&gt;。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id="Day 1（Chrome Web Store 上架日）"&gt;Day 1（Chrome Web Store 上架日）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;Chrome Store 安装：&lt;strong&gt;12&lt;/strong&gt;（全部来自我自己 + 朋友的测试）&lt;/li&gt;
&lt;li&gt;GitHub Star：&lt;strong&gt;3&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;收入：&lt;strong&gt;¥0&lt;/strong&gt;（付费墙刚开放）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="Day 2-3（即刻 + V2EX 首发）"&gt;Day 2-3（即刻 + V2EX 首发）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;即刻动态：1 条，浏览 2300，点赞 47，评论 18&lt;/li&gt;
&lt;li&gt;V2EX「分享创造」：1 帖，浏览 1800，回复 23&lt;/li&gt;
&lt;li&gt;Chrome Store 安装：&lt;strong&gt;+58&lt;/strong&gt;（48h 累计 70）&lt;/li&gt;
&lt;li&gt;GitHub Star：&lt;strong&gt;+12&lt;/strong&gt;（累计 15）&lt;/li&gt;
&lt;li&gt;收入：&lt;strong&gt;¥138&lt;/strong&gt;（2 个付费用户 × ¥69）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="Day 4-7（小红书 + 少数派提交）"&gt;Day 4-7（小红书 + 少数派提交）&lt;/h3&gt;
&lt;ul&gt;
&lt;li&gt;小红书：1 帖，初期沉，第 5 天突然有个设计师博主转发，曝光 5K → 实际安装转化 ~30&lt;/li&gt;
&lt;li&gt;少数派：投稿审核中（少数派一般 5-7 天审）&lt;/li&gt;
&lt;li&gt;Chrome Store 安装：&lt;strong&gt;+125&lt;/strong&gt;（首周累计 195）&lt;/li&gt;
&lt;li&gt;GitHub Star：&lt;strong&gt;+27&lt;/strong&gt;（累计 42）&lt;/li&gt;
&lt;li&gt;收入：&lt;strong&gt;¥414&lt;/strong&gt;（6 个付费用户）&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="第一周总计"&gt;第一周总计&lt;/h3&gt;&lt;table class="table table-bordered table-striped"&gt;
&lt;tr&gt;
&lt;th&gt;指标&lt;/th&gt;
&lt;th&gt;数字&lt;/th&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;总安装&lt;/td&gt;
&lt;td&gt;195&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;付费用户&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;付费转化率&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;3.08%&lt;/strong&gt;（行业 Chrome 扩展平均 1-2%，算偏高）&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;收入&lt;/td&gt;
&lt;td&gt;¥414&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Star&lt;/td&gt;
&lt;td&gt;42&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;即刻 follow&lt;/td&gt;
&lt;td&gt;+18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;V2EX 收藏&lt;/td&gt;
&lt;td&gt;31&lt;/td&gt;
&lt;/tr&gt;
&lt;/table&gt;&lt;h3 id="几个值得分享的反直觉发现"&gt;几个值得分享的反直觉发现&lt;/h3&gt;
&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Chrome Web Store 自然流量比想象低&lt;/strong&gt;。上架后头 3 天没主动引流，每天只有 3-5 个安装来自 Chrome Store 搜索。&lt;strong&gt;Chrome Store 搜索 ≠ 自然流量&lt;/strong&gt;，你不引流根本没人能搜到。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;小红书是黑盒&lt;/strong&gt;。同样的内容在不同账号 / 时段曝光差异 100x。我前 3 帖全沉底，第 4 帖突然爆 5K。&lt;strong&gt;没有规律可寻，唯一办法是多发&lt;/strong&gt;。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;¥69 一次买断的接受度比预期高&lt;/strong&gt;。我担心国内用户对付费扩展抗拒，结果 3% 付费率反而比行业平均高。&lt;strong&gt;关键是把「为什么收费」说清楚&lt;/strong&gt;（一次买断 + 不上报数据 + 开源 + MIT），用户接受度反而高。&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;GitHub Star 增长滞后于安装&lt;/strong&gt;。第一周安装 195 但只有 42 Star，因为绝大多数用户不会去 GitHub。&lt;strong&gt;Star 主要来自技术社区（V2EX / 即刻技术圈）转发&lt;/strong&gt;。&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="产品介绍（简版，详细看 GitHub README）"&gt;产品介绍（简版，详细看 GitHub README）&lt;/h2&gt;
&lt;p&gt;技术栈：&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chrome Manifest V3 + 纯 vanilla JavaScript&lt;/li&gt;
&lt;li&gt;Web Worker + OffscreenCanvas&lt;/li&gt;
&lt;li&gt;IndexedDB（本地图片收藏夹）&lt;/li&gt;
&lt;li&gt;总打包体积 &amp;lt; 200KB&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;5 项核心技术处理漏图：&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Shadow DOM 递归遍历&lt;/li&gt;
&lt;li&gt;CSS background 完整提取&lt;/li&gt;
&lt;li&gt;懒加载等待 + MutationObserver&lt;/li&gt;
&lt;li&gt;srcset 自动选最高分辨率&lt;/li&gt;
&lt;li&gt;同源 iframe 内容提取&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;附加功能：多维过滤 / ZIP 批量打包 / 多标签页抓取 / 相似图去重（pHash）/ 主色调提取 / 本地收藏夹 / 反向搜图&lt;/p&gt;
&lt;h2 id="接下来的计划"&gt;接下来的计划&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Month 2&lt;/strong&gt;: 重点攻 少数派 / 掘金 / 知乎 长尾 SEO&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Month 3&lt;/strong&gt;: 启动 V2 (Team 版本，多用户协作)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Month 6&lt;/strong&gt;: 评估海外市场（Product Hunt + Chrome Store 国际版搜索优化）&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="求 W2solo 同行帮忙"&gt;求 W2solo 同行帮忙&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;🔧 &lt;strong&gt;求 code review&lt;/strong&gt; —— 抓图引擎在 &lt;code&gt;content/extract-advanced.js&lt;/code&gt;，欢迎 PR/issue&lt;/li&gt;
&lt;li&gt;💡 &lt;strong&gt;求功能建议&lt;/strong&gt; —— 你做设计/素材/电商，碰到过什么图片下载痛点？&lt;/li&gt;
&lt;li&gt;🤝 &lt;strong&gt;求合作&lt;/strong&gt; —— 如果你也在做 Chrome 扩展或 SaaS，欢迎加微信交流（私信我）&lt;/li&gt;
&lt;li&gt;📊 &lt;strong&gt;求一起做数据复盘&lt;/strong&gt; —— W2solo 上有同样在做付费 Chrome 扩展的吗？想交流真实 MRR 数据&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GitHub: &lt;a href="https://github.com/zbw-zbw/image-harvest" rel="nofollow" target="_blank"&gt;https://github.com/zbw-zbw/image-harvest&lt;/a&gt;
Chrome Store:  &lt;a href="https://chromewebstore.google.com/detail/iecgnjidmogebokcfnejncgnelcepffo" rel="nofollow" target="_blank"&gt;https://chromewebstore.google.com/detail/iecgnjidmogebokcfnejncgnelcepffo&lt;/a&gt;
官网: &lt;a href="https://image-harvest.kyriewen.cn" rel="nofollow" target="_blank"&gt;https://image-harvest.kyriewen.cn&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;谢谢 W2solo 各位！&lt;/p&gt;

&lt;hr&gt;</description>
      <author>193577746</author>
      <pubDate>Fri, 01 May 2026 13:26:58 +0800</pubDate>
      <link>https://beta.w2solo.com/topics/7278</link>
      <guid>https://beta.w2solo.com/topics/7278</guid>
    </item>
  </channel>
</rss>
