普通视图

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

马斯克版微信最大的看点,和微信无关

作者 莫崇宇
2026年4月14日 16:01


距离马斯克的「超级应用梦」落地,只剩最后三天。4 月 17 日,XChat 预计正式登陆苹果 App Store,全球同步开放下载。

这绝对不是一次普通的 App 上架。在马斯克那张疯狂且庞大的商业战略棋盘上,XChat 是他豪掷 440 亿美元买下 Twitter、将其暴力更名为 X 之后,又一枚核心、也最不容有失的落子。

他对这一天的期待,可以追溯到 2022 年。

收购 Twitter 之后,马斯克几乎在每个公开场合都会提到微信。他说:「在中国,你基本上是生活在微信里的,因为它对日常生活如此有用。如果我们能在 X 上实现这一点,哪怕只是接近,那都将是巨大的成功。」

马斯克痴迷的不是微信的聊天界面,是它作为数字生活操作系统的地位,支付、通讯、打车、外卖、水电费,全在一个 App 里。如果说收购 Twitter 是拿到了超级应用这场赌局的入场券,那么 XChat,就是他在牌桌上打出的第一张明牌。

顶着马斯克版微信的噱头,XChat 却活成了 Telegram 的模样?

从功能上看,XChat 主打的是隐私优先的独立聊天应用。

注册不需要手机号,直接用 X 账号登录,消息支持阅后即焚、撤回和编辑,群聊最多可容纳 481 人,文件传输上限高达 4GB,跨设备音视频通话全部内置,下载需要 iOS 26.0 或以上版本。

应用层面禁止截图和录屏,试图从源头堵住内容泄露的漏洞,这可能是一些科技圈老板最喜欢的功能,Grok AI 被直接嵌入聊天界面,可以在对话里随时调用,用于总结内容、实时翻译或规划行程。

XChat 的整体定位走的是干净、私密、少打扰的路线,界面剥离了 X 主应用里的信息流、广告和热搜,专门为私密对话留出空间。首发支持 46 种语言,包括简体中文和繁体中文。

带着马斯克极其鲜明的个人烙印,XChat 不仅在定位上大刀阔斧,其项目推进速度更是快得惊人,甚至透出几分激进与狂热。

去年 6 月,马斯克才在 X 上公开预告;到了 12 月,X 员工 Nikita Bier 就已经开始公开为其站台,惊叹团队「在短短三个月内完成加密私信迁移」,并顺脚踩了一下同行:「Facebook 花了三年时间才做到这一点。」

今年 3 月,iOS 版 TestFlight 测试名额开放,先是 1000 人,很快扩到 5000 人,名额在公告发出后短短两小时内被抢光但伴随高关注度而来的,是极其两极分化的口碑。

3 月就拿到 TestFlight 资格的用户 @Nicole_yang88 写道:「整体流畅度非常高,几乎没有卡顿感。界面走的是极简路线,层级清晰、配色克制,观感上确实有点接近 iMessage 的那种干净风格。」她还特别提到,与 X 主应用一键授权登录、账号数据无缝衔接,「完全没有切换应用的割裂感」。

但也有人完全不买账。

测试用户 @ohxiyu 发文:「打开一看,跟 X 私信像素级一样,那为什么要独立出来?私信、请求、骚扰全混在一起,跟现在的 DM 没区别。想找某个人聊天?没有联系人列表,只能翻聊天记录搜。」

更让人摸不着头脑的是私密模式的设计,对方开了阅后即焚,你这边完全没有提示,内容过一会儿就消失了。他说:「Telegram 好歹还弹个通知告诉你。连个菜单都没有。感觉就是把 DM 页面套了个壳扔出来了。」

甚至 XChat 还没正式开放下载,麻烦已经来了。

4 月 11 日预约开放当天,就有用户发出警告:App Store 里同期出现了一款俄语版 XChat,图标和名字与真品高度相似,下载后会要求用户提供信用卡信息和 ID 证明年龄。

▲ 右边才是正版,安全下载,目前唯一可信的路径是通过苹果海外版 App Store 官方搜索,认准开发商为 X Corp。🔗 https://apps.apple.com/us/app/xchat/id6760873038

博主 @Imlaomao 亲身中招:「不小心输入信用卡信息后,觉得不对,立刻把信用卡都注销了。」他虽然表示没有直接证据证明该 App 一定存在问题,但建议大家「安全第一,小心为好」。

一款把安全隐私刻在脑门上的应用,在发布首日就得靠用户自己去甄别李逵和李鬼。这个充满戏剧性的开局,很难说不是对 XChat 未来命运的一个隐喻。

所谓「比特币级加密」,只是文字游戏?

在 XChat 的所有宣传话术里,「比特币式加密(Bitcoin-style encryption)」无疑是最抓眼球的字眼。深谙流量密码的马斯克,用这个偏极客词汇,成功让无数人脑补出了一幅赛博朋克式的画面:聊天记录上链、去中心化存储。

理想很丰满,现实很骨感。

根据英伟达安全开发人员 Matthew Garrett 对 XChat 早期版本的技术分析,XChat 的消息加密层采用了 libsodium 的 box 加密方案。这套方案本身经过广泛审计,算得上扎实。但有一点马斯克没有说清楚:libsodium 的核心是 C 语言写的,X 调用的正是 C 语言版本,并非他对外宣称的「全新 Rust 架构」。

密钥管理方面,XChat 采用了开源协议 Juicebox——这套协议有独立白皮书,并非 X 自研。它的设计思路是:将你的私钥加密后分片,存储在 X 公司控制的多台服务器上。换新设备时,你输入一个 4 位数 PIN 码,系统从服务器检索分片、重组密钥,聊天记录全部恢复。

🔗 https://mjg59.dreamwidth.org/71646.html?403a723f\_page=0

问题在于,X 目前使用的三个后端域名均在 x.com 之下,推测均由 Twitter 直接控制。Juicebox 协议本身支持引入独立第三方后端以分散信任,但 Garrett 在分析时未发现 X 有这方面的实质部署。

更致命的一点在于,XChat 的协议缺乏「前向保密性(Forward Secrecy)」。这意味着,如果某一天你的静态密钥被攻破,无论是设备被盗、密钥被收缴,还是服务器端组装解密,你过去所有的聊天记录都会在瞬间全部可读。

Signal 的「Double Ratchet」算法可以确保即使一次通讯密钥泄露,历史记录依然安全。XChat 没有这个机制。

此外,通过查询苹果 App Store 官方披露的隐私标签,网友发现 XChat 保留了收集并与用户身份关联的数据权利,涵盖联系人信息、通讯内容、使用数据、诊断数据以及用户 ID。与此对照的是,Signal 仅收集注册必需的极少量联系人信息,且从不与个体身份关联。

更深的问题在于元数据。XChat 可能加密了你发送的文字和图片本身,但 X 平台在后台完整记录的是:你在和谁聊、聊的频率、最活跃的时间段、传输文件的大小。

在当代数据经济里,元数据的商业价值往往高于内容本身。这些行为轨迹可以反哺 X 主站的广告引擎,也是训练 Grok AI 的绝佳语料。简言之,聊天内容加密、行为数据裸奔,成了 XChat 最大的隐私悖论。

醉翁之意不在酒,马斯克的超级应用野心

理解 XChat 的野心,得先理解马斯克真正想做什么。他如此大费周章,想要的绝对不是一个仅仅用来聊天的工具,而是一个让用户把日常生活都装进去的「超级应用」,既是你和朋友说话的地方,也是你转账、买东西的地方。

按照这个逻辑,XChat 只是第一步。它要和即将上线的 X Money 支付系统深度绑定,让用户在发消息的同时就能完成跨国汇款和日常转账,把「社交+支付」的商业闭环彻底打通。

不过,障碍在于监管。

美国没有统一的联邦金融汇款牌照,必须在五十个州逐一申请。截至 2026 年初,X Payments LLC 已拿下超 40 个州及华盛顿特区的许可,但北美金融的心脏纽约州,依然对马斯克紧闭大门。

🔗 https://money-support.x.com/en/licenses

美国纽约州参议员 Brad Hoylman-Sigal 和众议员 Micah Lasher 曾联名向纽约金融服务局递交公开信,措辞严厉,要求拒绝向 X 发放牌照,理由是马斯克「行为严重缺乏品格与一般适合性」。

对于一个志在全美乃至全球的支付网络来说,丢掉纽约州,XChat 内的支付网络就无法覆盖全美最有消费力的人群,更何况,西方用户本就对「把所有鸡蛋放进一个篮子」这件事天然抵触,支付功能再打折扣,这个故事就更难讲下去了。

种种受挫的现实固然让人对「超级应用」的说辞产生怀疑,但只要看透他底层的逻辑,眼下的一切就变得合理起来。

抛开那些关于阅后即焚、加密隐私的极客噱头,目前关于 XChat 最具想象力的传闻,是它将如何与自家的 AI 大模型 Grok 融合。

虽然我们还没法实际上手验证,但如果顺着这个思路展开推演,你会发现,马斯克真正想颠覆的,根本不是聊天体验,而是人机交互的底层逻辑,也就是在 AI 时代做一个超级应用,那应该是什么样子?

微信的超级应用模式可以概括为「入口聚合」:一个 App 把出行、外卖、支付、社保、健康全部塞进来,用户在一个界面里跳转不同的服务。这个模式基本定义了过去十年中国互联网的产品范式。但它的底层逻辑始终是「你来找服务」。你知道你要打车,你点进滴滴的小程序;你知道你要付款,你打开微信支付。

只是,入口聚合,是 App 时代的超级应用答案。AI 调度,可能才是 AI 时代超级应用的版本答案。与其把一百个功能塞给用户,不如让一个 AI 替用户搞定一切。

当然,从目前的爆料信息来看,XChat 离这个愿景还差得远,没有丰富的服务生态做支撑,Grok 就算再聪明,也只能在聊天框里做做翻译和文字总结的苦力活。马斯克的答卷也许潦草、充满争议,但他已经开始交卷了。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

Flutter应用代码混淆完整指南:Android与iOS平台配置详解

2026年4月14日 14:37

Flutter中的代码混淆

代码混淆可以隐藏你的Dart代码中的函数和类名,让 反编译 App变得困难。对于更全面的混淆需求,特别是针对iOS IPA文件,可以使用专业工具如IpaGuard,它支持无需源码的代码和资源混淆,兼容Flutter等多种开发平台,有效增加反编译难度。

注:Dart的混淆还没有经过完全的测试,如果发现问题请到GitHub上提 issue 。关于混淆的问题,还可以参考 Stack Overflow 上的这个问题。

Flutter中的混淆配置其实是在Android和iOS端分别配置的。

Android

<ProjectRoot>/android/gradle.properties 文件中添加如下代码:

extra-gen-snapshot-options=--obfuscate

默认情况下,Flutter不会混淆或者缩减Android host,如果你使用了第三方的Java或者Android库,那么你可能需要减小APK体积,或者防止你的App被反编译。

  • Step 1:配置Proguard文件

新建 /android/app/proguard-rules.pro 文件,然后添加如下配置:


#Flutter Wrapper
-keep class io.flutter.app.** { *; }
-keep class io.flutter.plugin.**  { *; }
-keep class io.flutter.util.**  { *; }
-keep class io.flutter.view.**  { *; }
-keep class io.flutter.**  { *; }
-keep class io.flutter.plugins.**  { *; }

上面的配置只保护Flutter库,其他额外的库(比如Firebase)需要你自己添加配置。

  • Step 2:

打开 /android/app/build.gradle 文件,定位到 buildTypes 处,在 release 配置中将 minifiyEnableduseProguard 标志设为true,同时还需要指向Step1中创建的ProGuard文件:


android {
    ...
    buildTypes {
        release {
            signingConfig signingConfigs.debug
            minifyEnabled true
            useProguard true
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

注意混淆和缩减无用代码会加长App的编译时间。

iOS

  • Step 1:修改 "build aot"

<ProjectRoot>/packages/flutter_tools/bin/xcode_backend.sh 文件中添加 build aot flag:

${extra_gen_snapshot_options_or_none}

然后定义这个flag:


local extra_gen_snapshot_options_or_none=""
if [[ -n "$EXTRA_GEN_SNAPSHOT_OPTIONS" ]]; then
  extra_gen_snapshot_options_or_none="--extra-gen-snapshot-options=$EXTRA_GEN_SNAPSHOT_OPTIONS"
fi
  • Step 2:应用你的修改

在你的App的根目录下运行以下两条命令:


git commit -am "Enable obfuscation on iOS"
flutter
  • Step 3:更改release配置

<ProjectRoot>/ios/Flutter/Release.xcconfig 中添加下面这行:

EXTRA_GEN_SNAPSHOT_OPTIONS=--obfuscate

对于iOS平台,如果需要更强大的混淆保护,可以考虑使用IpaGuard这样的工具,它可以直接对IPA文件进行混淆加密,支持代码和资源文件的全面混淆,无需源码即可操作,并兼容Flutter应用,提供即时测试功能。

关于 mac 使用ssh配置

作者 绺年
2026年4月14日 14:02

拉去代码前 一半都需要配置ssh,下面会记录下mac配置ssh的步骤,以及如何在访达中直接访问ssh文件

  1. 在Linux或Mac OS终端或Windows Git Bash, 执行ssh-keygen命令生成SSH公钥和私钥

ssh-keygen -t rsa

image.png 直接按回车接受默认值即可,不要手动输入目录路径。

如果确实想指定其他路径,需要写出包含文件名的完整路径,例如:

/Users/lichangchang/.ssh/my_custom_key

输入SSH密钥的使用密码并记住, 每次下载和上传时会用到此密码; 或直接回车不设置密码

  1. 查看并复制SSH公钥

cat ~/.ssh/id_rsa.pub

  1. 粘贴到 SSH公钥框中, 添加保存即可

如果想不在终端看这个文件

看不到 .ssh 文件夹(因为它默认隐藏)

在 Finder 里按 Shift + Command + .(句点键),可以切换显示/隐藏文件。然后你就能在家目录下看到灰色的 .ssh 文件夹,双击进去即可。

verdaccio数据迁移

2026年4月14日 13:56

Verdaccio版本:

verdaccio -v
v6.1.5

DOcker部署进入容器

docker exec -it e05ca0eb61ad /bin/sh

查看文件目录

    cd /verdaccio  # 执行
    conf     plugins  storage # 目录

从容器内拷贝文件

docker cp <容器ID或名称>:<容器内文件/目录路径> <本地目标路径>
docker cp e05ca0eb61ad:/verdaccio  /home/dongxuanli/verdaccio-geelyxingrui

把内容拷贝到容器内

  1. 挂载方式
version: '3' 
services: 
verdaccio: 
    image: verdaccio/verdaccio:latest
    container_name: verdaccio
    ports: - "4873:4873" 
    volumes:
    # 核心部分:左边是本地路径,右边是容器内路径 
    - ./storage:/verdaccio/storage 
    -./conf:/verdaccio/conf 
    - ./plugins:/verdaccio/plugins 
restart: always

启动服务
运行 docker-compose up -d。Docker 会自动把本地的文件“挂载”到容器里。你修改本地文件,容器内也会实时生效。

  1. docker命令时挂载
    docker run -d \ 
    --name verdaccio \ 
    -p 4873:4873 \ 
    -v /root/verdaccio/conf:/verdaccio/conf \ 
    -v /root/verdaccio/storage:/verdaccio/storage \ 
    -v /root/verdaccio/plugins:/verdaccio/plugins \
    verdaccio/verdaccio
  1. 手动拷贝
# 拷贝配置目录docker cp ./conf e05ca0eb61ad:/verdaccio/conf 
# 拷贝存储目录 docker cp ./storage e05ca0eb61ad:/verdaccio/storage 
# 拷贝插件目录 docker cp ./plugins e05ca0eb61ad:/verdaccio/plugins

手机控制 AI 编程?Paseo 让你随时随地跑 Claude Code / Codex

作者 jerrywus
2026年4月14日 13:43

原来 AI 编程工具不一定要坐在电脑前才能用……

先说一个让我有点意外的场景

上个月地铁上突然想到一个 bug,以前只能记在备忘录里等回家。那天试了一下 Paseo,掏出手机连上家里的服务器,用语音说了一句话,Claude Code 就跑起来了。到家的时候 PR 已经在等我了。

我花了点时间才接受这件事:AI 在跑,我在坐地铁。

这就是今天要聊的工具,Paseo

它是什么

官网:paseo.sh

一句话:Paseo 是一个统一的界面,让你同时管理 Claude Code、Codex、OpenCode 这几个 AI Agent,并且可以从手机、平板、任意设备远程控制它们。

架构很简单:

  • 你的电脑或服务器上跑一个 Paseo Daemon(后台进程)
  • 手机装 Paseo App
  • 两端通过加密通道连接
  • 手机发指令,电脑上的 Agent 跑,结果实时推回手机

完全免费,完全开源。代码不出你自己的机器。

image.png

它解决了什么问题

先说清楚这个,你才知道要不要继续看。

AI 编程工具只能在电脑前用。

Claude Code 很能干,但要打开终端才能用。出门、开会、躺着,想法有了但没法动。Paseo 解决的就是这个:算力留在服务器上,指令从手机发出去。

切换不同 Agent 的成本。

想用 Claude Code 跑一个,想试试 Codex 看看输出有没有区别,以前得开两个终端窗口分别管。Paseo 在一个界面里统一管,可以切换,可以对比,也可以让它们配合着干一件事(后面细说)。

语音输入基本不可用。

大多数语音转文字靠云端 API,你说的话要上传到别人服务器。在代码场景里这个感觉很别扭,尤其是还没开源的项目。Paseo 的语音识别完全跑在本地,数据不出局域网。

多 Agent 协作没有现成的编排工具。

写代码、审查、跑测试,如果想让三个 Agent 串联着干,以前得自己想办法。Paseo 有内置的 Skills 体系处理这个,后面会举例。

10 分钟装好

安装 Paseo

新手直接下桌面客户端,去 paseo.sh/download 下对应系统的包,自带 Daemon 管理,装完就能用。

服务器或者无界面环境用 CLI:

npm install -g @getpaseo/cli
paseo

# 如果需要重启镜像,使用:
paseo daemon restart

跑起来后终端会打一个二维码。

如果终端不小心关掉了,可以去软件中扫码

image.png

手机 App

App Store 或 Google Play 搜 Paseo 安装,扫二维码完成配对。

接上你的 AI Agent

Paseo 本身没有 AI 能力,它只是个壳。至少装一个 Agent:

  • Claude Code:npm install -g @anthropic-ai/claude-code
  • Codex:npm install -g @openai/codex
  • OpenCode:npm install -g opencode-ai

每个 Agent 的 API Key 自己配好,Paseo 不碰这些凭证。

配对成功后在手机 App 里选个 Agent 发条消息就能用了。

还有个体验真的惊艳到我了,就是可以查看文件内容或者文件diff。

test.gif

用起来真正有意思的地方

安全问题怎么解决的

"手机远程控制服务器"——这是大多数人第一个疑问。

连接走端到端加密中继,ECDH 密钥交换加 AES-256-GCM 加密。中继服务器只能看到 IP 和流量大小,内容它看不到,消息也伪造不了。你的代码、API Key、对话内容都在本地。

觉得还不够放心的话,直接走 Tailscale VPN 或自建隧道,完全绕过中继,局域网直连。这条路也是支持的。

语音功能

这是我没想到会做得这么认真的部分。

image.png

默认跑两个本地模型:语音识别用 parakeet-tdt-0.6b-v3-int8,语音合成用 kokoro-en-v0_19。Daemon 第一次启动会自动下载到 $PASEO_HOME/models/local-speech,之后离线运行,不走任何网络。

如果想要更准的识别,配置里换成 OpenAI Whisper API 改一行就好。

实际用起来什么感觉?坦白说,一开始以为是噱头。用了一周发现:描述性的需求适合语音("帮我把这个组件改成暗色模式"),涉及变量名和具体代码还是手打。两者混着用比较顺手。中文识别目前不算完美,英文更流畅一些。

多 Agent 协作的 Skills

这是 Paseo 上限比较高的地方,新手可以先跳过,等用顺手了再来看。

内置了五个编排 Skill:

Skill 干什么
/paseo-handoff 任务交接,把进度、已试方案、决策记录打包传给另一个 Agent
/paseo-loop 一个 Agent 执行,另一个验证,循环到成功为止
/paseo-orchestrator 你描述任务,它分配角色、启动多个 Agent 并行跑
/paseo-chat Agent 之间异步通信,支持持久化消息室和 @mentions
/paseo-committee Claude Opus + GPT 一起规划架构,只出方案不写代码

一个实际的用法:

# 功能需要先想清楚架构再动手
1. /paseo-committee → Claude Opus + GPT 讨论,出架构文档
2. /paseo-handoff  → 文档和上下文传给 Claude Code
3. Claude Code     → 开始实现
4. /paseo-loop     → 另一个 Agent 持续验证,直到测试通过

装 Skills:

npx skills add getpaseo/paseo

或者桌面应用 Settings → Integrations 里安装。

终端拆分,和项目管理

对,没错,它还有终端拆分的功能,和cmux一样(截图中左侧是项目分类)。

command+\ 水平拆分

command+shift+\ 垂直拆分

image.png

用了一个月,说点真实的

改变最大的不是某个具体功能,而是用 AI 工具的节奏变了。

以前用 Claude Code 是"坐下来,专门搞一段时间"的模式。有了 Paseo 之后变成了:等车的时候交代一件事,AI 在后台跑,我去干别的,回来看结果。异步的、碎片化的。这个改变比"AI 变聪明了"对日常效率的影响更直接。

不足的地方也有:文档目前比较薄,边缘配置要自己摸索;中文语音识别没英文流畅;Skills 这套多 Agent 协作的概念对新手来说需要时间理解,文档也没有足够多的例子帮你上手。

适合谁用:有服务器或闲置电脑、重度使用 Claude Code 或 Codex、对代码数据出不出本地比较在意的开发者。

不太适合谁:偶尔才用一下 AI 的,或者不习惯命令行的。这类用户直接用 Claude Code 就够了,Paseo 给他们增加的是配置复杂度,不是价值。

AI没我们想的那么聪明!复盘我的Vibe Coding翻车案例

作者 吴就业
2026年4月14日 13:17

我已经失去了编写代码的能力,变成只会写提示词的提示词工程师。但也没有完全丧失,我还会Review代码,偶尔手动改几行代码。

比如用Swift写UI,我在完全不复制粘贴的情况下,已经不会从0行代码写出一个页面,只会在AI生成的代码微调布局。

准确的说,不是丧失了能力,而是丧失了记忆!

回顾最近在开发ApiCatcher这款iOS端HTTPS抓包和调试工具的过程中,除了用AI开发核心功能遇到困难,其实在开发UI过程中也遇到一些困难。

今天分享我用AI开发ApiCatcher UI遇到的两个问题。解决这两个问题,我从Gemini 3.1 Pro模型,升级到Claude Opus 4.6模型,又从Claude Opus 4.6模型降到Gemini 3 Flash,最新只用Gemini 3 Flash就解决了问题。

第一个案例,我让AI帮我开发ApiCatcher的“AI对话生成脚本”功能,这里遇到的问题就是Markdown渲染。AI响应的消息里面包含代码。

关于UI,我的提示词大概是这样的:

页面做成聊天对话界面,底部是输入框和发送按钮,其余空间是显示聊天记录,用户只能发送文本消息,AI响应的消息是Markdown格式,AI响应的消息包含代码块,需要能高亮渲染代码块,代码不自动换行,而是代码块可以左右滚动。

这里的难点就是要支持代码块的高亮和代码块可以水平滚动。

无论是Gemini还是Claude,他们的思路都是先用开源的Swift语言编写的Markdown渲染开源库,怎么调都调不出令我满意的效果,渲染结果太丑了。在尝试完几个开源库后,Gemini和Claude会走向自己实现,写得非常复杂,然后实现的效果更加糟糕!

在无数次挫败后,我开始自己思考解决方案。我告诉Claude,SwiftUI的Markdown渲染开源库不好用,我们可以用Web技术栈,找JS开源库,然后用WebView来渲染Markdown消息。

这次Claude终于开窍了,但也没完全开窍。他把每个Markdown消息,都用一个WebView来渲染....

我再告诉他,为什么不是只用一个WebView来渲染所有消息呢?这时候Claude限制额度了......

我降回了Gemini 3 Flash。终于在几次调整后,Gemini做出了我想要的效果。

最终用到的技术栈:WebView、CodeMirror、Highlight.js。

第二个案例,JSON可折叠可搜索。

这次一上来就用Claude Sonnet 4.6模型,提示词:帮我实现JSON全屏预览页面,要求:JSON高亮显示,路径可以折叠和展开,可以搜索,搜索结果高亮,可以点击“下一个”按钮跳转到下一个搜索结果的位置。

Claude还是一样的思路,先找SwiftUI的开源库,但折腾半天卡在了搜索功能。还是一样折腾到耗尽Claude额度。

SwiftUI没有好用的开源库,那就换个思路,于是我又想到了WebView。最后选择WebView+CodeMirror+Highlight.js,加上CodeMirror的一个行号插件、搜索插件,用Gemini 3 Flash开发,完美实现!

在这两个案例中,无论是Claude还是Gemini,他们的思考方式都是先找SwiftUI可以使用的开源库,如果达不到目的,就改变策略,自己实现,但这种复杂的任务,他们根本没能力实现好。他们无法变通到通过WebView去乔接,从使用Swift技术栈,转变为使用Web开发技术栈。

我们只要告诉AI方法,他们就能实现,所以,目前AI的能力,其实受限于使用者的能力、认知。初级程序员+AI 无法超越 高级程序员+AI!

未来提示词会替代编程语言,程序员从写代码变成写提示词,但我们依然需要学习提升写提示词的能力。编程能力从来不是指代码写得多漂亮,而是对底层原理的认知深度,同样写提示词的能力也并非是指表达能力,同样是对底层原理的认知深度和广度。

以上面两个案例为例,我们需要认识到Swift原生开发并不是只能使用Swift,SwiftUI开发的原生应用也可以使用WebView来渲染网页,可以Swift生成网页代码传递给WebView渲染,并且可以生成JS代码让WebView执行。我们可以不懂怎么去写代码实现,只需要知道可以这样做,这极大降低了学习难度!

AI放大了我们个人的能力:从需要学会如何编码实现,了解具体的底层原理,到只需要知道可以这样实现。

但只是放大个人能力,AI遇强则强,遇弱则弱,我们依然需要保持学习。

16÷4 陷阱:一行代码让 SharedArrayBuffer 数据全部错位

作者 DiffServ
2026年4月14日 11:50

16÷4 陷阱:一行代码让 SharedArrayBuffer 数据全部错位

主线程写进去的采样数据,Worklet 线程读出来全是乱码。

不是数据损坏。不是跨线程竞争。不是字节序。

你把 16 字节偏移当成了 16 个元素索引。

这个 bug 我在写 stw-sentinel 时踩的。processor.js 里 HEADER_SIZE = 16,TypedArray 构造器第三个参数是元素个数不是字节数——16 个 Int32 元素 = 64 字节,header 直接膨胀 4 倍,后面的数据全偏了 48 字节。SAB 没坏,Atomics 没报错,数据就是永远对不上。


陷阱解剖

SharedArrayBuffer 是一块裸内存。你在上面建视图,同一个偏移量,不同类型的索引含义完全不同:

// ❌ 我的 bug
const HEADER_SIZE = 16; // 16 字节

const header = new Int32Array(sab, 0, HEADER_SIZE); // 16 个 Int32 元素 = 64 字节!
const data = new Float32Array(sab, HEADER_SIZE * 4); // 偏移 64 字节,完全错位

// ✅ 修完
const HEADER_BYTES = 16;
const headerElements = HEADER_BYTES / 4; // 4 个 Int32 元素

const header = new Int32Array(sab, 0, headerElements); // 4 元素 = 16 字节
const data = new Float32Array(sab, HEADER_BYTES);      // 从第 16 字节开始

错误版本里,Int32Array(sab, 0, 16) 创建了 16 个 Int32 元素,占 64 字节。你的 header 本该占 16 字节,实际占了 64 字节。后面的数据区跟着偏移了 48 字节——不多不少,刚好 4 倍。

数据不会报错。 Int32Array 和 Float32Array 都能正常读写,Atomics 操作也不报异常。你的监控面板上看到的只是"数据对不上",没有任何 red flag 告诉你偏移算错了。

为什么 AudioWorklet 里这个坑最致命

非实时场景下,写错偏移顶多是初始化失败,加个 try-catch 就能定位。但 AudioWorklet 的 process() 回调每 128 帧跑一次(约 2.67ms),数据是流式消费的——错位就是错位,没有重传机制,没有校验和,数据流永远对不齐。

更毒的是:console.log 打出来全是 Int32 值,值本身没坏,只是写到了错误的内存位置。你盯着输出看半天,看不出任何异常。

前端开发者对"字节对齐"几乎没直觉。JavaScript 层面你碰不到字节,new ArrayBuffer(16) 对你来说就是"16 个槽位",很少去想这 16 个槽位的单位是什么。直到你用 SharedArrayBuffer 搭实时管道,字节和元素的分界线才会咬你一口。

超新星核爆.png

底层代码没有类型系统保护你。字节和元素搞混,编译器不报错,运行不崩溃,就是数据不对。这种 bug 最毒——你不一定发现得了。

在线验证:diffserv.xyz/lab, Worklet 心跳(~2.67ms),黄线是主线程帧间隔。两条线各跑各的,SAB 是唯一的桥。数据对齐了,就没有坑。

npm install stw-sentinel

GitHub: github.com/hlng2002/st…

每日一题-最小移动总距离🔴

2026年4月14日 00:00

X 轴上有一些机器人和工厂。给你一个整数数组 robot ,其中 robot[i] 是第 i 个机器人的位置。再给你一个二维整数数组 factory ,其中 factory[j] = [positionj, limitj] ,表示第 j 个工厂的位置在 positionj ,且第 j 个工厂最多可以修理 limitj 个机器人。

每个机器人所在的位置 互不相同 。每个工厂所在的位置也 互不相同 。注意一个机器人可能一开始跟一个工厂在 相同的位置 。

所有机器人一开始都是坏的,他们会沿着设定的方向一直移动。设定的方向要么是 X 轴的正方向,要么是 X 轴的负方向。当一个机器人经过一个没达到上限的工厂时,这个工厂会维修这个机器人,且机器人停止移动。

任何时刻,你都可以设置 部分 机器人的移动方向。你的目标是最小化所有机器人总的移动距离。

请你返回所有机器人移动的最小总距离。测试数据保证所有机器人都可以被维修。

注意:

  • 所有机器人移动速度相同。
  • 如果两个机器人移动方向相同,它们永远不会碰撞。
  • 如果两个机器人迎面相遇,它们也不会碰撞,它们彼此之间会擦肩而过。
  • 如果一个机器人经过了一个已经达到上限的工厂,机器人会当作工厂不存在,继续移动。
  • 机器人从位置 x 到位置 y 的移动距离为 |y - x| 。

 

示例 1:

输入:robot = [0,4,6], factory = [[2,2],[6,2]]
输出:4
解释:如上图所示:
- 第一个机器人从位置 0 沿着正方向移动,在第一个工厂处维修。
- 第二个机器人从位置 4 沿着负方向移动,在第一个工厂处维修。
- 第三个机器人在位置 6 被第二个工厂维修,它不需要移动。
第一个工厂的维修上限是 2 ,它维修了 2 个机器人。
第二个工厂的维修上限是 2 ,它维修了 1 个机器人。
总移动距离是 |2 - 0| + |2 - 4| + |6 - 6| = 4 。没有办法得到比 4 更少的总移动距离。

示例 2:

输入:robot = [1,-1], factory = [[-2,1],[2,1]]
输出:2
解释:如上图所示:
- 第一个机器人从位置 1 沿着正方向移动,在第二个工厂处维修。
- 第二个机器人在位置 -1 沿着负方向移动,在第一个工厂处维修。
第一个工厂的维修上限是 1 ,它维修了 1 个机器人。
第二个工厂的维修上限是 1 ,它维修了 1 个机器人。
总移动距离是 |2 - 1| + |(-2) - (-1)| = 2 。没有办法得到比 2 更少的总移动距离。

 

提示:

  • 1 <= robot.length, factory.length <= 100
  • factory[j].length == 2
  • -109 <= robot[i], positionj <= 109
  • 0 <= limitj <= robot.length
  • 测试数据保证所有机器人都可以被维修。

python 费用流

作者 981377660LMT
2022年11月6日 13:47

代码

###python3

INF = int(1e18)

class Solution:
    def minimumTotalDistance(self, robot: List[int], factory: List[List[int]]) -> int:
        n, m = len(robot), len(factory)
        STRAT, END = n + m + 3, n + m + 4
        mcmf = MinCostMaxFlow(n + m + 10, STRAT, END)
        for i in range(n):
            mcmf.addEdge(STRAT, i, 1, 0)
        for i in range(n):
            for j in range(m):
                mcmf.addEdge(i, n + j, 1, abs(robot[i] - factory[j][0]))
        for i in range(m):
            mcmf.addEdge(n + i, END, factory[i][1], 0)
        return mcmf.work()[1]



class Edge:
    __slots__ = ("fromV", "toV", "cap", "cost", "flow")

    def __init__(self, fromV: int, toV: int, cap: int, cost: int, flow: int) -> None:
        self.fromV = fromV
        self.toV = toV
        self.cap = cap
        self.cost = cost
        self.flow = flow


class MinCostMaxFlow:
    """最小费用流的连续最短路算法复杂度为流量*最短路算法复杂度"""

    __slots__ = ("_n", "_start", "_end", "_edges", "_reGraph", "_dist", "_visited", "_curEdges")

    def __init__(self, n: int, start: int, end: int):
        """
        Args:
            n (int): 包含虚拟点在内的总点数
            start (int): (虚拟)源点
            end (int): (虚拟)汇点
        """
        assert 0 <= start < n and 0 <= end < n
        self._n = n
        self._start = start
        self._end = end
        self._edges: List["Edge"] = []
        self._reGraph: List[List[int]] = [[] for _ in range(n + 10)]  # 残量图存储的是边的下标

        self._dist = [INF] * (n + 10)
        self._visited = [False] * (n + 10)
        self._curEdges = [0] * (n + 10)

    def addEdge(self, fromV: int, toV: int, cap: int, cost: int) -> None:
        """原边索引为i 反向边索引为i^1"""
        self._edges.append(Edge(fromV, toV, cap, cost, 0))
        self._edges.append(Edge(toV, fromV, 0, -cost, 0))
        len_ = len(self._edges)
        self._reGraph[fromV].append(len_ - 2)
        self._reGraph[toV].append(len_ - 1)

    def work(self) -> Tuple[int, int]:
        """
        Returns:
            Tuple[int, int]: [最大流,最小费用]
        """
        maxFlow, minCost = 0, 0
        while self._spfa():
            # !如果流量限定为1,那么一次dfs只会找到一条费用最小的增广流
            # !如果流量限定为INF,那么一次dfs不只会找到一条费用最小的增广流
            flow = self._dfs(self._start, self._end, INF)
            maxFlow += flow
            minCost += flow * self._dist[self._end]
        return maxFlow, minCost

    def slope(self) -> List[Tuple[int, int]]:
        """
        Returns:
            List[Tuple[int, int]]: 流量为a时,最小费用是b
        """
        res = [(0, 0)]
        flow, cost = 0, 0
        while self._spfa():
            deltaFlow = self._dfs(self._start, self._end, INF)
            flow += deltaFlow
            cost += deltaFlow * self._dist[self._end]
            res.append((flow, cost))  # type: ignore
        return res

    def _spfa(self) -> bool:
        """spfa沿着最短路寻找增广路径  有负cost的边不能用dijkstra"""
        n, start, end, edges, reGraph, visited = (
            self._n,
            self._start,
            self._end,
            self._edges,
            self._reGraph,
            self._visited,
        )

        self._curEdges = [0] * n
        self._dist = dist = [INF] * n
        dist[start] = 0
        queue = deque([start])

        while queue:
            cur = queue.popleft()
            visited[cur] = False
            for edgeIndex in reGraph[cur]:
                edge = edges[edgeIndex]
                cost, remain, next = edge.cost, edge.cap - edge.flow, edge.toV
                if remain > 0 and dist[cur] + cost < dist[next]:
                    dist[next] = dist[cur] + cost
                    if not visited[next]:
                        visited[next] = True
                        if queue and dist[queue[0]] > dist[next]:
                            queue.appendleft(next)
                        else:
                            queue.append(next)

        return dist[end] != INF

    def _dfs(self, cur: int, end: int, flow: int) -> int:
        if cur == end:
            return flow

        visited, reGraph, curEdges, edges, dist = (
            self._visited,
            self._reGraph,
            self._curEdges,
            self._edges,
            self._dist,
        )

        visited[cur] = True
        res = flow
        index = curEdges[cur]
        while res and index < len(reGraph[cur]):
            edgeIndex = reGraph[cur][index]
            next, remain = edges[edgeIndex].toV, edges[edgeIndex].cap - edges[edgeIndex].flow
            if remain > 0 and not visited[next] and dist[next] == dist[cur] + edges[edgeIndex].cost:
                delta = self._dfs(next, end, remain if remain < res else res)
                res -= delta
                edges[edgeIndex].flow += delta
                edges[edgeIndex ^ 1].flow -= delta
            curEdges[cur] += 1
            index = curEdges[cur]

        visited[cur] = False
        return flow - res

###python3

INF = int(1e18)

class Solution:
    def minimumTotalDistance(self, robot: List[int], factory: List[List[int]]) -> int:
        boys, girls = robot, []  
        for pos, limit in factory:
            girls.extend([pos] * limit)  
        costMatrix = [[0] * len(girls) for _ in range(len(boys))]
        for i, pos1 in enumerate(boys):
            for j, pos2 in enumerate(girls):
                costMatrix[i][j] = -abs(pos1 - pos2)  # 最大权匹配转换为最小权匹配
        return -KM(costMatrix)[0]

        
def KM(costMatrix: List[List[int]]) -> Tuple[int, Tuple[List[int], List[int]]]:
    """KM算法求带权二分图的最大权匹配

    Args
    ----------
    costMatrix (List[List[int]]):
        二分图的权值矩阵,不存在的边应初始化为`-INF`

    Returns
    ----------
    Tuple[int, Tuple[List[int], List[int]]]:
        `最大权匹配值, 匹配对的行索引、列索引`

    Examples
    ----------
    >>> costMatrix = [[1, 2, 3], [2, 4, 6], [3, 6, 9]]
    >>> maxSum, (rows, cols) = KuhnMunkres(costMatrix)
    >>> maxSum
    14
    >>> rows cols
    [0, 1, 2] [0, 1, 2]
    >>> sum(costMatrix[i][j] for i, j in zip(rows, cols))
    14
    """
    max_ = max(len(costMatrix), len(costMatrix[0]))
    _match = [-1] * max_  # 记录每个女生匹配到的男生 如果没有则为-1
    _graph = costMatrix  # 记录每个男生和每个女生之间的`好感度`
    _visitedBoy = set()  # 记录每一轮匹配匹配过的男生
    _visitedGirl = set()  # 记录每一轮匹配匹配过的女生
    _expBoy = [max(row) for row in costMatrix]  # 每个男生的期望值
    _expGirl = [0] * max_  # 每个女生的期望值,为0表示只要有一个男生就可以
    _slack = []  # 记录每个女生如果能被男生倾心最少还需要多少期望值
    _pre = []
    _row = len(costMatrix)
    _col = len(costMatrix[0])

    def dfs(boy: int) -> int:
        _visitedBoy.add(boy)
        for girl in range(_col):
            if girl in _visitedGirl:
                continue
            delta = _expBoy[boy] + _expGirl[girl] - _graph[boy][girl]
            # 符合要求
            if delta == 0:
                _visitedGirl.add(girl)
                _pre[girl + _row] = boy
                if _match[girl] == -1:
                    return girl + _row
                _pre[_match[girl]] = girl + _row
                nextRes = dfs(_match[girl])  # 找到增广
                if nextRes > 0:
                    return nextRes
            # 女生要得到男生的倾心 还需多少期望值
            elif _slack[boy] > delta:
                _slack[boy] = delta

        return -1

    for boy in range(_row):
        _visitedBoy.clear()
        _visitedGirl.clear()
        _slack = [INF] * _col
        _pre = [-1] * (_row + _col)
        visited = False
        cand = -1
        # 记录每轮匹配中男生女生是否被尝试匹配过
        while True:
            if not visited:
                cand = dfs(boy)
                visited = True
            else:
                for r in range(_row):
                    if _slack[r] == 0:
                        _slack[r] = INF
                        cand = dfs(r)
                        if cand > 0:
                            break

            if cand > 0:
                tmp = cand
                while tmp > 0:
                    _match[tmp - _row] = _pre[tmp]
                    tmp = _pre[_pre[tmp]]
                break
            else:
                # 如果不能找到 就降低期望值
                # 最小可降低的期望值
                delta = INF
                for c in range(_row):
                    if c in _visitedBoy and _slack[c] < delta:
                        delta = _slack[c]
                for r in range(_row):
                    if r in _visitedBoy:
                        # 所有访问过的男生降低期望值
                        _expBoy[r] -= delta
                        _slack[r] -= delta
                for c in range(_col):
                    if c in _visitedGirl:
                        # 所有访问过的女生增加期望值
                        _expGirl[c] += delta

    # 匹配完成 求出所有配对的好感度的和
    res, rows, cols = 0, [], []
    for girl, boy in enumerate(_match):
        if boy != -1:
            res += _graph[boy][girl]
            rows.append(boy)
            cols.append(girl)
    return res, (rows, cols)

利用关键结论进行 DP,含证明

作者 tsreaper
2022年11月6日 12:08

解法:DP

设机器人有 $n$ 个,工厂有 $m$ 个。不失一般性地,假设机器人的坐标是递增的,工厂的坐标也是递增的。

关键结论

设最优方案中,机器人 $i$ 被送去工厂 $t_i$。注意到关键结论

存在最优方案,使得 $t_i$ 是不严格单调递增的。

证明

引理:若存在 $1 \le x < y \le n$ 满足 $t_x > t_y$,此时让机器人 $x$ 去工厂 $t_y$,机器人 $y$ 去工厂 $t_x$,答案不会变得更差。

不失一般性地,设机器人 $x$ 的坐标小等于工厂 $t_x$ 的坐标(若实际情况不是如此,将机器人 $x$ 和工厂 $t_x$ 的坐标对换,机器人 $y$ 和工厂 $t_y$ 的坐标对换,可以发现距离不变)。为了证明引理,对以下三种情况进行讨论:

image.png

相信看过我的题解的朋友会觉得这张图很眼熟。没错,这张图和第 316 场周赛的 使数组相似的最少操作次数 几乎一模一样。

DP

有了关键结论,我们就能用 DP 求最优答案。既然 $t_i$ 是不严格单调递增的,我们就对每一段相同的 $t_i$ 进行 DP。

设 $d(l, r, x)$ 表示将第 $l$ 到 $r$ 个机器人都送去工厂 $x$ 的总距离。维护 $f(i, j)$ 表示已经送走了前 $i$ 个机器人,且第 $i$ 个机器人送去工厂 $j$ 的最小总距离。转移方程为

$$
f(i, j) = \min\limits_{0 \le i' < i, 0 \le j' < j} (f(i', j') + d(i' + 1, i, j)) \quad \text{s.t. } \quad i - i' \le \text{工厂 } j \text{ 的容量}
$$

这个转移方程就是在枚举把哪一段的机器人全部送去工厂 $j$。初值 $f(0, 0) = 0$。答案就是 $\min\limits_{j=1}^m f(n, j)$。

最小的 $f(i', j')$ 用前缀 min 维护即可。令 $g(i, j) = \min\limits_{0 \le j' \le j} f(i, j')$,那么转移方程可以改为

$$
f(i, j) = \min\limits_{0 \le i' < i} (g(i', j - 1) + d(i' + 1, i, j)) \quad \text{s.t. } \quad i - i' \le \text{工厂 } j \text{ 的容量}
$$

初值 $f(0, 0) = 0$,$g(0, *) = 0$。答案就是 $g(n, m)$。复杂度 $\mathcal{O}(n\log n + m\log m + n^2m)$。

参考代码(c++)

###c++

class Solution {
public:
    long long minimumTotalDistance(vector<int>& robot, vector<vector<int>>& factory) {
        // 机器人和工厂的坐标排序
        sort(robot.begin(), robot.end());
        sort(factory.begin(), factory.end());

        const long long INF = 1e15;
        int n = robot.size(), m = factory.size();
        // g[i][j] 表示 min(f[i][j']),j' <= j
        long long f[n + 1][m + 1], g[n + 1][m + 1];
        // 初值
        for (int i = 0; i <= n; i++) for (int j = 0; j <= m; j++) f[i][j] = g[i][j] = INF;
        f[0][0] = 0;
        for (int j = 0; j <= m; j++) g[0][j] = 0;
        
        // 套转移方程
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= m; j++) {
                long long d = 0;
                for (int ii = i - 1; ii >= 0 && i - ii <= factory[j - 1][1]; ii--) {
                    d += abs(robot[ii] - factory[j - 1][0]);
                    f[i][j] = min(f[i][j], g[ii][j - 1] + d);
                }
            }
            for (int j = 1; j <= m; j++) g[i][j] = min(g[i][j - 1], f[i][j]);
        }

        return g[n][m];
    }
};

三种方法:记忆化搜索 -> 递推 -> 单调队列优化(Python/Java/C++/Go)

作者 endlesscheng
2022年11月6日 12:07

一、贪心性质

看上去,一个工厂修理一段连续的机器人是最优的。为什么?

先排序,设工厂的位置为 $f_1 < f_2 < \cdots < f_n$,机器人的位置为 $r_1 < r_2 < \cdots < r_m$。

:题目保证工厂的位置互不相同,机器人的位置互不相同。

对于序列中的两个工厂 $f_a$ 和 $f_b$($a<b$),两个机器人 $r_i$ 和 $r_j$($i<j$),对比如下两个方案:

  • $r_i$ 去 $f_a$,$r_j$ 去 $f_b$,移动距离之和为 $|r_i - f_a| + |r_j - f_b|$。
  • $r_i$ 去 $f_b$,$r_j$ 去 $f_a$,移动距离之和为 $|r_i - f_b| + |r_j - f_a|$。

下图是其中一种情况。

lc2463-c.png{:width=600px}

用分类讨论可以证明,$|r_i - f_a| + |r_j - f_b|\le |r_i - f_b| + |r_j - f_a|$。

换句话说,存在这样的最优解,对于任意一对机器人 $i$ 和 $j$($i<j$),机器人 $i$ 去的工厂编号 $\le $ 机器人 $j$ 去的工厂编号。同一个工厂修理的机器人,在排序后的机器人序列里是连续的一段

二、寻找子问题

在示例 1 中,我们要解决的问题(原问题)是:

  • 工厂下标区间 $[0,1]$ 修理机器人下标区间 $[0,2]$,机器人移动的最小总距离。

从右往左思考,枚举最后一个工厂修多少个机器人:

  • 修 $0$ 个机器人,问题变成:工厂 $[0,0]$ 修理机器人 $[0,2]$,机器人移动的最小总距离。
  • 修 $1$ 个机器人,问题变成:工厂 $[0,0]$ 修理机器人 $[0,1]$,机器人移动的最小总距离。
  • 修 $2$ 个机器人,问题变成:工厂 $[0,0]$ 修理机器人 $[0,0]$,机器人移动的最小总距离。
  • 至多修 $2$ 个,因为 $\textit{limit}_1 = 2$。

这些问题都是和原问题相似的、规模更小的子问题,可以用递归解决。

:从右往左思考,主要是方便把递归翻译成递推。从左往右思考也是可以的。

三、状态定义与状态转移方程

根据上面的讨论,定义 $\textit{dfs}(i,j)$ 表示工厂下标区间 $[0,i]$ 修理机器人下标区间 $[0,j]$,机器人移动的最小总距离。

枚举工厂 $i$ 修 $k=0,1,2\ldots,\min(j+1,\textit{limit}_i)$ 个机器人,问题变成工厂下标区间 $[0,i-1]$ 修理机器人下标区间 $[0,j-k]$,机器人移动的最小总距离,即 $\textit{dfs}(i-1, j-k)$,再加上机器人 $[j-k+1, j]$ 到工厂 $i$ 的距离。

对所有 $k$ 取最小值,就得到了 $\textit{dfs}(i,j)$,即

$$
\textit{dfs}(i,j) = \min_{k=0}^{\min(j+1,\textit{limit}i)} \left{ \textit{dfs}(i-1, j-k) + \sum{p=j-k+1}^{j} |\textit{robot}[p]-\textit{position}[i]| \right}
$$

由于 $k$ 每增加 $1$,距离和 $\displaystyle\sum_{p=j-k+1}^{j} |\textit{robot}[p]-\textit{position}[i]|$ 就会新增一项 $|\textit{robot}[j-k+1]-\textit{position}[i]|$,所以可以用一个变量 $\textit{disSum}$ 维护距离和,而不是对每个 $k$ 都跑一个循环算距离和。

递归边界

  • $\textit{dfs}(i,-1)=0$。没有机器人了,总移动距离为 $0$。
  • $\textit{dfs}(-1,j)=\infty\ (j\ge 0)$。没有工厂,但还有剩下的机器人,不合法。返回 $\infty$,这样上面公式中的 $\min$ 不会取到不合法的情况。

递归入口:$\textit{dfs}(n-1,m-1)$,这是原问题,也是答案。

:题目保证所有机器人都可以被维修。

四、递归搜索 + 保存递归返回值 = 记忆化搜索

考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:

  • 如果一个状态(递归入参)是第一次遇到,那么可以在返回前,把状态及其结果记到一个 $\textit{memo}$ 数组中。
  • 如果一个状态不是第一次遇到($\textit{memo}$ 中保存的结果不等于 $\textit{memo}$ 的初始值),那么可以直接返回 $\textit{memo}$ 中保存的结果。

注意:$\textit{memo}$ 数组的初始值一定不能等于要记忆化的值!例如初始值设置为 $0$,并且要记忆化的 $\textit{dfs}(i,j)$ 也等于 $0$,那就没法判断 $0$ 到底表示第一次遇到这个状态,还是表示之前遇到过了,从而导致记忆化失效。一般把初始值设置为 $-1$。

Python 用户可以无视上面这段,直接用 @cache 装饰器。

关于记忆化搜索的原理,请看视频讲解 动态规划入门:从记忆化搜索到递推【基础算法精讲 17】,其中包含把记忆化搜索 1:1 翻译成递推的技巧。

class Solution:
    def minimumTotalDistance(self, robot: List[int], factory: List[List[int]]) -> int:
        factory.sort(key=lambda f: f[0])
        robot.sort()

        @cache  # 缓存装饰器,避免重复计算 dfs(一行代码实现记忆化)
        def dfs(i: int, j: int) -> int:
            if j < 0:  # 所有机器人都修完了
                return 0
            if i < 0:  # 还有机器人没修,但没有工厂了
                return inf

            # 工厂 i 不修机器人
            res = dfs(i - 1, j)

            position, limit = factory[i]
            dis_sum = 0
            # 枚举修 k 个机器人
            for k in range(1, min(j + 1, limit) + 1):
                dis_sum += abs(robot[j - k + 1] - position)
                res = min(res, dfs(i - 1, j - k) + dis_sum)

            return res

        return dfs(len(factory) - 1, len(robot) - 1)
class Solution {
    public long minimumTotalDistance(List<Integer> robotList, int[][] factory) {
        int[] robot = robotList.stream().mapToInt(i -> i).toArray();
        Arrays.sort(robot);
        Arrays.sort(factory, (a, b) -> a[0] - b[0]);

        int n = factory.length;
        int m = robot.length;
        long[][] memo = new long[n][m];
        for (long[] row : memo) {
            Arrays.fill(row, -1); // -1 表示没有计算过
        }

        return dfs(n - 1, m - 1, robot, factory, memo);
    }

    private long dfs(int i, int j, int[] robot, int[][] factory, long[][] memo) {
        if (j < 0) { // 所有机器人都修完了
            return 0;
        }
        if (i < 0) { // 还有机器人没修,但没有工厂了
            return Long.MAX_VALUE / 2; // 避免加法溢出
        }

        if (memo[i][j] != -1) { // 之前计算过
            return memo[i][j];
        }

        // 工厂 i 不修机器人
        long res = dfs(i - 1, j, robot, factory, memo);

        int position = factory[i][0];
        int limit = factory[i][1];
        long disSum = 0;
        // 枚举修 k 个机器人
        for (int k = 1; k <= Math.min(j + 1, limit); k++) {
            disSum += Math.abs(robot[j - k + 1] - position);
            res = Math.min(res, dfs(i - 1, j - k, robot, factory, memo) + disSum);
        }

        memo[i][j] = res; // 记忆化
        return res;
    }
}
class Solution {
public:
    long long minimumTotalDistance(vector<int>& robot, vector<vector<int>>& factory) {
        ranges::sort(factory, {}, [](auto& f) { return f[0]; });
        ranges::sort(robot);

        int n = factory.size(), m = robot.size();
        vector memo(n, vector<long long>(m, -1)); // -1 表示没有计算过

        auto dfs = [&](this auto&& dfs, int i, int j) -> long long {
            if (j < 0) { // 所有机器人都修完了
                return 0;
            }
            if (i < 0) { // 还有机器人没修,但没有工厂了
                return LLONG_MAX / 2; // 避免加法溢出
            }

            long long& res = memo[i][j]; // 注意这里是引用
            if (res != -1) { // 之前计算过
                return res;
            }

            // 工厂 i 不修机器人
            res = dfs(i - 1, j);

            int position = factory[i][0], limit = factory[i][1];
            long long dis_sum = 0;
            // 枚举修 k 个机器人
            for (int k = 1; k <= min(j + 1, limit); k++) {
                dis_sum += abs(robot[j - k + 1] - position);
                res = min(res, dfs(i - 1, j - k) + dis_sum);
            }

            return res;
        };

        return dfs(n - 1, m - 1);
    }
};
func minimumTotalDistance(robot []int, factory [][]int) int64 {
slices.SortFunc(factory, func(a, b []int) int { return a[0] - b[0] })
slices.Sort(robot)

n, m := len(factory), len(robot)
memo := make([][]int, n)
for i := range memo {
memo[i] = make([]int, m)
for j := range memo[i] {
memo[i][j] = -1 // -1 表示没有计算过
}
}

var dfs func(int, int) int
dfs = func(i, j int) (res int) {
if j < 0 { // 所有机器人都修完了
return 0
}
if i < 0 { // 还有机器人没修,但没有工厂了
return math.MaxInt / 2 // 避免加法溢出
}

p := &memo[i][j]
if *p != -1 { // 之前计算过
return *p
}
defer func() { *p = res }() // 记忆化

// 工厂 i 不修机器人
res = dfs(i-1, j)

position, limit := factory[i][0], factory[i][1]
disSum := 0
// 枚举修 k 个机器人
for k := 1; k <= min(j+1, limit); k++ {
disSum += abs(robot[j-k+1] - position)
res = min(res, dfs(i-1, j-k)+disSum)
}
return
}

return int64(dfs(n-1, m-1))
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(nm^2 + n\log n)$,其中 $n$ 是 $\textit{factory}$ 的长度,$m$ 是 $\textit{robot}$ 的长度。由于每个状态只会计算一次,动态规划的时间复杂度 $=$ 状态个数 $\times$ 单个状态的计算时间。本题状态个数等于 $\mathcal{O}(nm)$,单个状态的计算时间为 $\mathcal{O}(m)$,所以记忆化搜索的时间复杂度为 $\mathcal{O}(nm^2)$。剩下的 $\mathcal{O}(n\log n + m\log m)$ 是排序的时间复杂度,由于 $\mathcal{O}(m\log m)$ 比 $\mathcal{O}(nm^2)$ 小,可以省略。
  • 空间复杂度:$\mathcal{O}(nm)$。保存多少状态,就需要多少空间。

五、1:1 翻译成递推

我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。

具体来说,$f[i+1][j+1]$ 的定义和 $\textit{dfs}(i,j)$ 的定义是一样的,都表示工厂下标区间 $[0,i]$ 修理机器人下标区间 $[0,j]$,机器人移动的最小总距离。这里 $+1$ 是为了把 $\textit{dfs}(i,-1)$ 和 $\textit{dfs}(-1,j)$ 也翻译过来,这样我们可以把 $f[i][0]$ 和 $f[0][j]$ 作为初始值。

相应的递推式(状态转移方程)也和 $\textit{dfs}$ 一样:

$$
f[i+1][j+1] = \min_{k=0}^{\min(\textit{limit}i, j+1)} \left{ f[i][j-k+1] + \sum{p=j-k+1}^{j} |\textit{robot}[p]-\textit{position}[i]| \right}
$$

初始值 $f[i][0] = 0$ 以及 $f[0][j] = \infty\ (j\ge 1)$,翻译自递归边界。

答案为 $f[n][m]$,翻译自递归入口。

写法一

class Solution:
    def minimumTotalDistance(self, robot: List[int], factory: List[List[int]]) -> int:
        factory.sort(key=lambda f: f[0])
        robot.sort()

        n, m = len(factory), len(robot)
        f = [[0] * (m + 1) for _ in range(n + 1)]
        f[0][1:] = [inf] * m

        for i, (position, limit) in enumerate(factory):
            for j in range(m):
                # 工厂 i 不修机器人
                res = f[i][j + 1]

                # 枚举修 k 个机器人
                dis_sum = 0
                for k in range(1, min(j + 1, limit) + 1):
                    dis_sum += abs(robot[j - k + 1] - position)
                    res = min(res, f[i][j - k + 1] + dis_sum)

                f[i + 1][j + 1] = res

        return f[n][m]
class Solution {
    public long minimumTotalDistance(List<Integer> robotList, int[][] factory) {
        int[] robot = robotList.stream().mapToInt(i -> i).toArray();
        Arrays.sort(robot);
        Arrays.sort(factory, (a, b) -> a[0] - b[0]);

        int n = factory.length;
        int m = robot.length;
        long[][] f = new long[n + 1][m + 1];
        Arrays.fill(f[0], Long.MAX_VALUE / 2);
        f[0][0] = 0;

        for (int i = 0; i < n; i++) {
            int position = factory[i][0];
            int limit = factory[i][1];
            for (int j = 0; j < m; j++) {
                // 工厂 i 不修机器人
                long res = f[i][j + 1];

                // 枚举修 k 个机器人
                long disSum = 0;
                for (int k = 1; k <= Math.min(j + 1, limit); k++) {
                    disSum += Math.abs(robot[j - k + 1] - position);
                    res = Math.min(res, f[i][j - k + 1] + disSum);
                }

                f[i + 1][j + 1] = res;
            }
        }

        return f[n][m];
    }
}
class Solution {
public:
    long long minimumTotalDistance(vector<int>& robot, vector<vector<int>>& factory) {
        ranges::sort(factory, {}, [](auto& f) { return f[0]; });
        ranges::sort(robot);

        int n = factory.size(), m = robot.size();
        vector f(n + 1, vector<long long>(m + 1));
        fill(f[0].begin() + 1, f[0].end(), LLONG_MAX / 2);

        for (int i = 0; i < n; i++) {
            int position = factory[i][0], limit = factory[i][1];
            for (int j = 0; j < m; j++) {
                // 工厂 i 不修机器人
                long long res = f[i][j + 1];

                // 枚举修 k 个机器人
                long long dis_sum = 0;
                for (int k = 1; k <= min(j + 1, limit); k++) {
                    dis_sum += abs(robot[j - k + 1] - position);
                    res = min(res, f[i][j - k + 1] + dis_sum);
                }

                f[i + 1][j + 1] = res;
            }
        }

        return f[n][m];
    }
};
func minimumTotalDistance(robot []int, factory [][]int) int64 {
slices.SortFunc(factory, func(a, b []int) int { return a[0] - b[0] })
slices.Sort(robot)

n, m := len(factory), len(robot)
f := make([][]int, n+1)
for i := range f {
f[i] = make([]int, m+1)
}
for j := 1; j <= m; j++ {
f[0][j] = math.MaxInt / 2
}

for i, fac := range factory {
position, limit := fac[0], fac[1]
for j := range m {
// 工厂 i 不修机器人
res := f[i][j+1]

// 枚举修 k 个机器人
disSum := 0
for k := 1; k <= min(j+1, limit); k++ {
disSum += abs(robot[j-k+1] - position)
res = min(res, f[i][j-k+1]+disSum)
}

f[i+1][j+1] = res
}
}

return int64(f[n][m])
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

写法二

为了方便大家阅读下一章(单调队列优化),先微调一下状态定义和状态转移方程。

把 $j+1$ 替换成 $j$,定义 $f[i+1][j]$ 表示工厂下标区间 $[0,i]$ 修理机器人下标区间 $[0,j-1]$,机器人移动的最小总距离。

状态转移方程为:

$$
f[i+1][j] = \min_{k=0}^{\min(\textit{limit}i, j)} \left{ f[i][j-k] + \sum{p=j-k}^{j-1} |\textit{robot}[p]-\textit{position}[i]| \right}
$$

这样可以少写很多 $+1$。

在 $k$ 从 $0$ 增大到 $\min(\textit{limit}_i, j)$ 的过程中,$j-k$ 从 $j$ 减小到 $\max(j-\textit{limit}_i, 0)$。

把 $j-k$ 替换成 $k$,状态转移方程为:

$$
f[i+1][j] = \min_{k=\max(j-\textit{limit}i, 0)}^{j} \left{ f[i][k] + \sum{p=k}^{j-1} |\textit{robot}[p]-\textit{position}[i]| \right}
$$

这样转移方程就更干净了,从而方便我们进一步优化。

:这相当于在枚举工厂 $i$ 修理的最左边的机器人的编号 $k$。

class Solution:
    def minimumTotalDistance(self, robot: List[int], factory: List[List[int]]) -> int:
        factory.sort(key=lambda f: f[0])
        robot.sort()

        n, m = len(factory), len(robot)
        f = [[0] * (m + 1) for _ in range(n + 1)]
        f[0][1:] = [inf] * m

        for i, (position, limit) in enumerate(factory):
            for j in range(1, m + 1):
                # 工厂 i 不修机器人
                res = f[i][j]

                # 修理下标在 [k, j-1] 中的机器人
                dis_sum = 0
                for k in range(j - 1, max(j - limit, 0) - 1, -1):
                    dis_sum += abs(robot[k] - position)
                    res = min(res, f[i][k] + dis_sum)

                f[i + 1][j] = res

        return f[n][m]
class Solution {
    public long minimumTotalDistance(List<Integer> robotList, int[][] factory) {
        int[] robot = robotList.stream().mapToInt(i -> i).toArray();
        Arrays.sort(robot);
        Arrays.sort(factory, (a, b) -> a[0] - b[0]);

        int n = factory.length;
        int m = robot.length;
        long[][] f = new long[n + 1][m + 1];
        Arrays.fill(f[0], Long.MAX_VALUE / 2);
        f[0][0] = 0;

        for (int i = 0; i < n; i++) {
            int position = factory[i][0];
            int limit = factory[i][1];
            for (int j = 1; j <= m; j++) {
                // 工厂 i 不修机器人
                long res = f[i][j];

                // 修理下标在 [k, j-1] 中的机器人
                long disSum = 0;
                for (int k = j - 1; k >= Math.max(j - limit, 0); k--) {
                    disSum += Math.abs(robot[k] - position);
                    res = Math.min(res, f[i][k] + disSum);
                }

                f[i + 1][j] = res;
            }
        }

        return f[n][m];
    }
}
class Solution {
public:
    long long minimumTotalDistance(vector<int>& robot, vector<vector<int>>& factory) {
        ranges::sort(factory, {}, [](auto& f) { return f[0]; });
        ranges::sort(robot);

        int n = factory.size(), m = robot.size();
        vector f(n + 1, vector<long long>(m + 1));
        fill(f[0].begin() + 1, f[0].end(), LLONG_MAX / 2);

        for (int i = 0; i < n; i++) {
            int position = factory[i][0], limit = factory[i][1];
            for (int j = 1; j <= m; j++) {
                // 工厂 i 不修机器人
                long long res = f[i][j];

                // 修理下标在 [k, j-1] 中的机器人
                long long dis_sum = 0;
                for (int k = j - 1; k >= max(j - limit, 0); k--) {
                    dis_sum += abs(robot[k] - position);
                    res = min(res, f[i][k] + dis_sum);
                }

                f[i + 1][j] = res;
            }
        }

        return f[n][m];
    }
};
func minimumTotalDistance(robot []int, factory [][]int) int64 {
slices.SortFunc(factory, func(a, b []int) int { return a[0] - b[0] })
slices.Sort(robot)

n, m := len(factory), len(robot)
f := make([][]int, n+1)
for i := range f {
f[i] = make([]int, m+1)
}
for j := 1; j <= m; j++ {
f[0][j] = math.MaxInt / 2
}

for i, fac := range factory {
position, limit := fac[0], fac[1]
for j := 1; j <= m; j++ {
// 工厂 i 不修机器人
res := f[i][j]

// 修理下标在 [k, j-1] 中的机器人
disSum := 0
for k := j - 1; k >= max(j-limit, 0); k-- {
disSum += abs(robot[k] - position)
res = min(res, f[i][k]+disSum)
}

f[i+1][j] = res
}
}

return int64(f[n][m])
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(nm^2 + n\log n)$,其中 $n$ 是 $\textit{factory}$ 的长度,$m$ 是 $\textit{robot}$ 的长度。DP 的时间复杂度为 $\mathcal{O}(nm^2)$。剩下的 $\mathcal{O}(n\log n + m\log m)$ 是排序的时间复杂度,由于 $\mathcal{O}(m\log m)$ 比 $\mathcal{O}(nm^2)$ 小,可以省略。
  • 空间复杂度:$\mathcal{O}(nm)$。

六、单调队列优化

前置题目239. 滑动窗口最大值,视频讲解 单调队列【基础算法精讲 27】

定义 $d_i[p] = |\textit{robot}[p]-\textit{position}[i]|$。

设 $d_i$ 数组的前缀和数组为 $s_i$。关于 $s_i$ 的定义,请看 前缀和

状态转移方程为:

$$
\begin{aligned}
f[i+1][j] &= \min_{k=\max(j-\textit{limit}i, 0)}^{j} \left{ f[i][k] + s_i[j] - s_i[k]\right} \
&= s_i[j] + \min
{k=\max(j-\textit{limit}_i, 0)}^{j} \left{ f[i][k] - s_i[k]\right} \
\end{aligned}
$$

当 $j$ 增大时,$k$ 的范围是一个向右移动的滑动窗口,我们计算的是 $f[i][k] - s_i[k]$ 的滑动窗口最小值。上式的 $\min$ 可以用单调队列优化至均摊 $\mathcal{O}(1)$ 时间复杂度。

答疑

:对于单调队列优化 DP,什么时候先把元素入队再计算 DP,什么时候先计算 DP 再把元素入队?

:这取决于计算的 DP 是否包含要入队的元素。如果包含,那就先入队再计算 DP;如果不包含,那就先计算 DP 再入队。本题是前者。

class Solution:
    def minimumTotalDistance(self, robot: List[int], factory: List[List[int]]) -> int:
        factory.sort(key=lambda f: f[0])
        robot.sort()

        n, m = len(factory), len(robot)
        f = [[0] * (m + 1) for _ in range(n + 1)]
        f[0][1:] = [inf] * m

        for i, (position, limit) in enumerate(factory):
            dis_sum = list(accumulate((abs(r - position) for r in robot), initial=0))  # 前缀和
            q = deque([(0, 0)])
            for j in range(1, m + 1):
                # 1. 入
                v = f[i][j] - dis_sum[j]
                while q and q[-1][1] >= v:
                    q.pop()
                q.append((j, v))

                # 2. 出
                while q[0][0] < j - limit:
                    q.popleft()

                # 3. 队首为滑动窗口最小值
                f[i + 1][j] = dis_sum[j] + q[0][1]

        return f[n][m]
class Solution {
    public long minimumTotalDistance(List<Integer> robotList, int[][] factory) {
        int[] robot = robotList.stream().mapToInt(i -> i).toArray();
        Arrays.sort(robot);
        Arrays.sort(factory, (a, b) -> a[0] - b[0]);

        int n = factory.length;
        int m = robot.length;
        long[][] f = new long[n + 1][m + 1];
        Arrays.fill(f[0], Long.MAX_VALUE / 2);
        f[0][0] = 0;

        for (int i = 0; i < n; i++) {
            int position = factory[i][0];
            int limit = factory[i][1];

            long[] disSum = new long[m + 1]; // 前缀和
            for (int j = 0; j < m; j++) {
                disSum[j + 1] = disSum[j] + Math.abs(robot[j] - position);
            }

            Deque<long[]> q = new ArrayDeque<>();
            q.offerLast(new long[]{0, 0});

            for (int j = 1; j <= m; j++) {
                // 1. 入
                long v = f[i][j] - disSum[j];
                while (!q.isEmpty() && q.peekLast()[1] >= v) {
                    q.pollLast();
                }
                q.offerLast(new long[]{j, v});

                // 2. 出
                while (q.peekFirst()[0] < j - limit) {
                    q.pollFirst();
                }

                // 3. 队首为滑动窗口最小值
                f[i + 1][j] = disSum[j] + q.peekFirst()[1];
            }
        }

        return f[n][m];
    }
}
class Solution {
public:
    long long minimumTotalDistance(vector<int>& robot, vector<vector<int>>& factory) {
        ranges::sort(factory, {}, [](auto& f) { return f[0]; });
        ranges::sort(robot);

        int n = factory.size(), m = robot.size();
        vector f(n + 1, vector<long long>(m + 1));
        fill(f[0].begin() + 1, f[0].end(), LLONG_MAX / 2);

        for (int i = 0; i < n; i++) {
            int position = factory[i][0], limit = factory[i][1];

            vector<long long> dis_sum(m + 1); // 前缀和
            for (int j = 0; j < m; j++) {
                dis_sum[j + 1] = dis_sum[j] + abs(robot[j] - position);
            }

            deque<pair<int, long long>> q;
            q.emplace_back(0, 0);

            for (int j = 1; j <= m; j++) {
                // 1. 入
                long long v = f[i][j] - dis_sum[j];
                while (!q.empty() && q.back().second >= v) {
                    q.pop_back();
                }
                q.emplace_back(j, v);

                // 2. 出
                while (q.front().first < j - limit) {
                    q.pop_front();
                }

                // 3. 队首为滑动窗口最小值
                f[i + 1][j] = dis_sum[j] + q.front().second;
            }
        }

        return f[n][m];
    }
};
func minimumTotalDistance(robot []int, factory [][]int) int64 {
slices.SortFunc(factory, func(a, b []int) int { return a[0] - b[0] })
slices.Sort(robot)

n, m := len(factory), len(robot)
f := make([][]int, n+1)
for i := range f {
f[i] = make([]int, m+1)
}
for j := 1; j <= m; j++ {
f[0][j] = math.MaxInt / 2
}

for i, fac := range factory {
position, limit := fac[0], fac[1]

disSum := make([]int, m+1) // 前缀和
for j, r := range robot {
disSum[j+1] = disSum[j] + abs(r-position)
}

type pair struct{ i, v int }
q := []pair{{0, 0}}

for j := 1; j <= m; j++ {
// 1. 入
v := f[i][j] - disSum[j]
for len(q) > 0 && q[len(q)-1].v >= v {
q = q[:len(q)-1]
}
q = append(q, pair{j, v})

// 2. 出
for q[0].i < j-limit {
q = q[1:]
}

// 3. 队首为滑动窗口最小值
f[i+1][j] = disSum[j] + q[0].v
}
}

return int64(f[n][m])
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(nm + n\log n + m\log m)$,其中 $n$ 是 $\textit{factory}$ 的长度,$m$ 是 $\textit{robot}$ 的长度。对于三重循环里面的二重循环,站在每个元素的视角看,这个元素在二重循环中最多入队出队各一次,因此二重循环的循环次数之和是 $\mathcal{O}(m)$,所以三重循环的时间复杂度是 $\mathcal{O}(nm)$。剩下的 $\mathcal{O}(n\log n + m\log m)$ 是排序的时间复杂度。
  • 空间复杂度:$\mathcal{O}(nm)$。

七、空间优化

观察上面的代码,计算 $f[i+1][j]$ 只会用到 $f[i][j]$。

所以只需要一个长为 $m+1$ 的一维数组 $f$,我们直接把计算结果覆盖到 $f[j]$ 中。

此外,前缀和可以一边枚举 $j$ 一边计算,从而优化成一个变量。

class Solution:
    def minimumTotalDistance(self, robot: List[int], factory: List[List[int]]) -> int:
        factory.sort(key=lambda f: f[0])
        robot.sort()

        m = len(robot)
        f = [0] + [inf] * m

        for position, limit in factory:
            dis_sum = 0
            q = deque([(0, 0)])
            for j, r in enumerate(robot, 1):  # r = robot[j - 1]
                dis_sum += abs(r - position)

                # 1. 入
                v = f[j] - dis_sum
                while q and q[-1][1] >= v:
                    q.pop()
                q.append((j, v))

                # 2. 出
                while q[0][0] < j - limit:
                    q.popleft()

                # 3. 队首为滑动窗口最小值
                f[j] = dis_sum + q[0][1]

        return f[m]
// 更快的写法见【Java 写法二】
class Solution {
    public long minimumTotalDistance(List<Integer> robotList, int[][] factory) {
        int[] robot = robotList.stream().mapToInt(i -> i).toArray();
        Arrays.sort(robot);
        Arrays.sort(factory, (a, b) -> a[0] - b[0]);

        int m = robot.length;
        long[] f = new long[m + 1];
        Arrays.fill(f, Long.MAX_VALUE / 2);
        f[0] = 0;

        for (int[] fac : factory) {
            int position = fac[0];
            int limit = fac[1];

            long disSum = 0;
            Deque<long[]> q = new ArrayDeque<>(); // ArrayDeque 慢,更快的写法见【Java 写法二】
            q.offerLast(new long[]{0, 0});

            for (int j = 1; j <= m; j++) {
                disSum += Math.abs(robot[j - 1] - position);

                // 1. 入
                long v = f[j] - disSum;
                while (!q.isEmpty() && q.peekLast()[1] >= v) {
                    q.pollLast();
                }
                q.offerLast(new long[]{j, v});

                // 2. 出
                while (q.peekFirst()[0] < j - limit) {
                    q.pollFirst();
                }

                // 3. 队首为滑动窗口最小值
                f[j] = disSum + q.peekFirst()[1];
            }
        }

        return f[m];
    }
}
class Solution {
    public long minimumTotalDistance(List<Integer> robotList, int[][] factory) {
        int[] robot = robotList.stream().mapToInt(i -> i).toArray();
        Arrays.sort(robot);
        Arrays.sort(factory, (a, b) -> a[0] - b[0]);

        int m = robot.length;
        long[] f = new long[m + 1];
        Arrays.fill(f, Long.MAX_VALUE / 2);
        f[0] = 0;

        int[] idxQ = new int[m + 1]; // 用两个数组模拟 ArrayDeque
        long[] valQ = new long[m + 1];

        for (int[] fac : factory) {
            int position = fac[0];
            int limit = fac[1];

            long disSum = 0;
            int head = 0;
            int tail = 0;
            idxQ[tail] = 0;
            valQ[tail] = 0;

            for (int j = 1; j <= m; j++) {
                disSum += Math.abs(robot[j - 1] - position);

                // 1. 入
                long v = f[j] - disSum;
                while (head <= tail && valQ[tail] >= v) {
                    tail--;
                }
                tail++;
                idxQ[tail] = j;
                valQ[tail] = v;

                // 2. 出
                while (idxQ[head] < j - limit) {
                    head++;
                }

                // 3. 队首为滑动窗口最小值
                f[j] = disSum + valQ[head];
            }
        }

        return f[m];
    }
}
class Solution {
public:
    long long minimumTotalDistance(vector<int>& robot, vector<vector<int>>& factory) {
        ranges::sort(factory, {}, [](auto& f) { return f[0]; });
        ranges::sort(robot);

        int m = robot.size();
        vector<long long> f(m + 1, LLONG_MAX / 2);
        f[0] = 0;

        for (auto& fac : factory) {
            int position = fac[0], limit = fac[1];

            long long dis_sum = 0;
            deque<pair<int, long long>> q;
            q.emplace_back(0, 0);

            for (int j = 1; j <= m; j++) {
                int r = robot[j - 1];
                dis_sum += abs(r - position);

                // 1. 入
                long long v = f[j] - dis_sum;
                while (!q.empty() && q.back().second >= v) {
                    q.pop_back();
                }
                q.emplace_back(j, v);

                // 2. 出
                while (q.front().first < j - limit) {
                    q.pop_front();
                }

                // 3. 队首为滑动窗口最小值
                f[j] = dis_sum + q.front().second;
            }
        }

        return f[m];
    }
};
func minimumTotalDistance(robot []int, factory [][]int) int64 {
slices.SortFunc(factory, func(a, b []int) int { return a[0] - b[0] })
slices.Sort(robot)

m := len(robot)
f := make([]int, m+1)
for j := 1; j <= m; j++ {
f[j] = math.MaxInt / 2
}

for _, fac := range factory {
position, limit := fac[0], fac[1]

disSum := 0
type pair struct{ i, v int }
q := []pair{{0, 0}}

for j, r := range robot {
j++
disSum += abs(r - position)

// 1. 入
v := f[j] - disSum
for len(q) > 0 && q[len(q)-1].v >= v {
q = q[:len(q)-1]
}
q = append(q, pair{j, v})

// 2. 出
for q[0].i < j-limit {
q = q[1:]
}

// 3. 队首为滑动窗口最小值
f[j] = disSum + q[0].v
}
}

return int64(f[m])
}

func abs(x int) int {
if x < 0 {
return -x
}
return x
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(nm + n\log n + m\log m)$,其中 $n$ 是 $\textit{factory}$ 的长度,$m$ 是 $\textit{robot}$ 的长度。对于三重循环里面的二重循环,站在每个元素的视角看,这个元素在二重循环中最多入队出队各一次,因此二重循环的循环次数之和是 $\mathcal{O}(m)$,所以三重循环的时间复杂度是 $\mathcal{O}(nm)$。剩下的 $\mathcal{O}(n\log n + m\log m)$ 是排序的时间复杂度。
  • 空间复杂度:$\mathcal{O}(m)$。忽略排序的栈开销。

专题训练

见下面动态规划题单的「五、划分型 DP」和「§11.3 单调队列优化 DP」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

从 DeepSeek 文本对话到流式输出

作者 颜酱
2026年4月14日 11:27

从 DeepSeek 文本对话到流式输出

本文把「非流式调用 → 浏览器里解析流式 → 用 Node 做 BFF → 前端改用 EventSource」串成一条主线

你将学到什么

  • 在浏览器里用 fetch 调用 DeepSeek 的 Chat Completions(与 OpenAI 兼容)。
  • 为什么要开 stream,以及流式响应在控制台里长什么样。
  • ReadableStream + TextDecoder + 行缓冲 解析 data: 开头的 SSE 分片。
  • 为什么 EventSource 很难直接对接「POST + Authorization」的大模型接口,以及如何用 零依赖 server.js 做中转。
  • 前端如何用 EventSource 消费自家 BFF 下发的 SSE,并顺带了解 SSE 的基本格式。

最后效果

deep_text.gif

懒得本地建立代码,也可以直接clone代码index-direct.htmlindex-stream.html能直接拖到浏览器,看效果。index.html拖入浏览器之前,需要首先node server.js,然后也能看到效果。哦,前提去申请一个deepseek的key。

准备工作

  1. 打开 DeepSeek 开放平台,按需充值并创建 API Key,妥善保存(不要写进公开仓库)。
  2. 下文示例里,直连 DeepSeek 的页面会把 Key 放在浏览器侧(仅适合本地学习);走代理后,Key 只放在服务端 .env.local

一、非流式:一次性拿到完整回复

复杂问题之前,先用「一问一答、整包返回」把链路跑通:向 https://api.deepseek.com/chat/completionsPOSTstream 关闭(或省略),再从 choices[0].message.content 取文本。

下面是一段最小 HTML(body 里放展示区域 + type="module" 脚本即可),新建文件,然后丢到浏览器就行!

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <body>
    <div id="reply"></div>
  
    <script type="module">
      const API_KEY = "sk-你自己的,没有的话去申请 https://platform.deepseek.com/usage";
      // DeepSeek 的「对话补全」接口地址(与 OpenAI Chat Completions 格式兼容)
      const endpoint = "https://api.deepseek.com/chat/completions";
  
      // HTTP 请求头:声明 JSON 正文,并用 Bearer Token 携带 API Key
      const headers = {
        "Content-Type": "application/json",
        Authorization: `Bearer ${API_KEY}`,
      };
  
      // 请求体:指定模型、对话消息列表;isStream: false 表示要一次性返回完整结果,而不是流式 SSE
      const payload = {
        // 模型类型
        model: "deepseek-chat",
        messages: [
          // role 字段是一个枚举字段,可选的值分别是 system、user 和 assistant,依次表示该条消息是系统消息(也就是我们一般俗称的提示词)、用户消息和 AI 应答消息
          { role: "system", content: "You are a helpful assistant." }, // 系统提示,约束助手行为
          { role: "user", content: "你好 Deepseek" }, // 用户本轮输入
        ],
        isStream: false,
      };
  
      // 向 DeepSeek 发起 POST,把 payload 序列化成 JSON 字符串作为 body
      const response = await fetch(endpoint, {
        method: "POST",
        headers: headers,
        body: JSON.stringify(payload),
      });
  
      // 把响应体解析为 JSON;接口成功时 choices[0].message.content 即助手回复正文
      const data = await response.json();
  
      // 把大模型返回的文本显示就行了
      document.getElementById("reply").textContent =
        data.choices[0].message.content;
    </script>
  </body>
</body>
</html>

二、为什么要流式:体感更好,协议长什么样

简单问题整包返回没问题;问题一长,用户会长时间盯着空白。把请求里的 stream 设为 true,模型就会边生成边吐字,前端边读边展示。

流式时,控制台里常见一行以 data: 开头,后面跟一段 JSON;结束标记一般是 data: [DONE](注意是 [DONE],大小写与官方一致)。下面是一条真实形态示例(单行 JSON,便于你对照日志):

data: {"id":"07b44fd1-5339-4ea5-a3e5-e62464fabe3d","object":"chat.completion.chunk","created":1776131664,"model":"deepseek-chat","system_fingerprint":"fp_eaab8d114b_prod0820_fp8_kvcache_new_kvcache_20260410","choices":[{"index":0,"delta":{"content":""},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":972,"total_tokens":982,"prompt_tokens_details":{"cached_tokens":0},"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":10}}

三、浏览器里用 fetch + ReadableStream 解析 SSE(Vue CDN 单页)

这一版页面做了几件事:

  • Vue 3(CDN ESM) 做一个最小界面:Key、问题、是否流式、提交按钮。
  • 流式时:response.body.getReader() + TextDecoder,按行切分;只处理以 data: 开头的行;能 JSON.parse 就读 choices[0].delta.content 做增量;解析失败就把半行塞回缓冲区,等下一段数据补齐。
  • 非流式:response.json() 一次取全量。

下面给出完整单页 HTML(可直接本地打开试用;Key 仅保存在本机 localStorage不要把带 Key 的页面部署到公网):

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>DeepSeek 流式(Vue CDN 单页)</title>
  </head>
  <body>
    <div id="app"></div>

    <script type="module">
      import {
        createApp,
        ref,
      } from "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js";

      createApp({
        setup() {
          const apiKey = ref(
            typeof localStorage !== "undefined"
              ? localStorage.getItem("deepseek_api_key") || ""
              : "",
          );
          const question = ref("讲一个关于中国龙的故事");
          const content = ref("");
          const isStream = ref(true);
          const loading = ref(false);
          const error = ref("");

          function saveKey() {
            try {
              localStorage.setItem("deepseek_api_key", apiKey.value.trim());
            } catch (_) {}
          }

          async function update() {
            const key = apiKey.value.trim();
            if (!key) {
              error.value = "请填写 API Key(仅保存在本机 localStorage)";
              return;
            }
            if (!question.value.trim()) {
              error.value = "请输入问题";
              return;
            }

            error.value = "";
            loading.value = true;
            content.value = isStream.value ? "" : "思考中…";
            saveKey();

            const endpoint = "https://api.deepseek.com/chat/completions";
            const headers = {
              "Content-Type": "application/json",
              Authorization: "Bearer " + key,
            };

            try {
              const response = await fetch(endpoint, {
                method: "POST",
                headers,
                body: JSON.stringify({
                  model: "deepseek-chat",
                  messages: [{ role: "user", content: question.value.trim() }],
                  stream: isStream.value,
                }),
              });

              if (!response.ok) {
                const errText = await response.text();
                throw new Error(response.status + " " + errText.slice(0, 200));
              }

              if (isStream.value) {
                content.value = "";
                const reader = response.body?.getReader();
                if (!reader) {
                  throw new Error("响应不支持 ReadableisStream");
                }

                const decoder = new TextDecoder();
                let sseBuffer = "";

                while (true) {
                  const { value, done } = await reader.read();
                  if (done) break;

                  sseBuffer += decoder.decode(value, { isStream: true });
                  const parts = sseBuffer.split("\n");
                  sseBuffer = parts.pop() ?? "";

                  for (const rawLine of parts) {
                    const line = rawLine.trim();
                    if (!line || line.startsWith(":")) continue;
                    if (!line.startsWith("data:")) continue;
                    console.log(line);
                    const payload = line.slice(5).trim();
                    if (payload === "[DONE]") {
                      loading.value = false;
                      return;
                    }

                    try {
                      const data = JSON.parse(payload);
                      const delta = data?.choices?.[0]?.delta?.content;
                      if (delta) content.value += delta;
                    } catch {
                      sseBuffer = rawLine + "\n" + sseBuffer;
                    }
                  }
                }

                if (sseBuffer.trim()) {
                  const line = sseBuffer.trim();
                  if (line.startsWith("data:")) {
                    const payload = line.slice(5).trim();
                    if (payload && payload !== "[DONE]") {
                      try {
                        const data = JSON.parse(payload);
                        const delta = data?.choices?.[0]?.delta?.content;
                        if (delta) content.value += delta;
                      } catch (_) {}
                    }
                  }
                }
              } else {
                const data = await response.json();
                const text = data?.choices?.[0]?.message?.content;
                content.value = text ?? JSON.stringify(data);
              }
            } catch (e) {
              error.value = e instanceof Error ? e.message : String(e);
              if (!isStream.value) content.value = "";
            } finally {
              loading.value = false;
            }
          }

          return {
            apiKey,
            question,
            content,
            isStream,
            loading,
            error,
            update,
          };
        },
        template: `
          <div class="wrap">
            <h1>DeepSeek 对话(流式 / 非流式)</h1>
            <p class="hint">
              单文件演示:API Key 存于浏览器 localStorage。请勿把含 Key 的页面上传到公网。
            </p>
            <div class="row">
              <label for="k">Key</label>
              <input id="k" type="password" v-model="apiKey" placeholder="sk-…" autocomplete="off" />
            </div>
            <div class="row">
              <label for="q">问题</label>
              <input id="q" class="input-q" type="text" v-model="question" />
            </div>
            <div class="row">
              <label><input type="checkbox" v-model="isStream" :disabled="loading" /> 流式输出 (SSE)</label>
              <button type="button" :disabled="loading" @click="update">{{ loading ? '请求中…' : '提交' }}</button>
            </div>
            <p v-if="error" class="err">{{ error }}</p>
            <div class="output">{{ content || (loading && isStream ? '…' : '') }}</div>
          </div>
        `,
      }).mount("#app");
    </script>
    <style>
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        font-family: system-ui, sans-serif;
        background: #0f1419;
        color: #e6edf3;
        min-height: 100vh;
      }
      .wrap {
        max-width: 52rem;
        margin: 0 auto;
        padding: 1rem 1.25rem 2rem;
      }
      h1 {
        font-size: 1.1rem;
        font-weight: 600;
        margin: 0 0 0.75rem;
        color: #8b949e;
      }
      .row {
        display: flex;
        flex-wrap: wrap;
        gap: 0.5rem;
        align-items: center;
        margin-bottom: 0.75rem;
      }
      label {
        font-size: 0.85rem;
        color: #8b949e;
      }
      input[type="text"],
      input[type="password"] {
        flex: 1;
        min-width: 12rem;
        padding: 0.45rem 0.6rem;
        border-radius: 6px;
        border: 1px solid #30363d;
        background: #161b22;
        color: #e6edf3;
        font-size: 0.85rem;
      }
      input.input-q {
        width: 100%;
        min-width: 100%;
      }
      button {
        padding: 0.45rem 1rem;
        border-radius: 6px;
        border: 1px solid #388bfd;
        background: #21262d;
        color: #58a6ff;
        font-size: 0.85rem;
        cursor: pointer;
      }
      button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
      .hint {
        font-size: 0.75rem;
        color: #6e7681;
        margin: 0 0 1rem;
        line-height: 1.45;
      }
      .output {
        margin-top: 0.75rem;
        padding: 1rem;
        border-radius: 8px;
        border: 1px solid #30363d;
        background: #161b22;
        min-height: 12rem;
        white-space: pre-wrap;
        word-break: break-word;
        text-align: left;
        font-size: 0.9rem;
        line-height: 1.55;
      }
      .err {
        color: #f85149;
        margin-top: 0.5rem;
        font-size: 0.85rem;
        text-align: left;
      }
    </style>
  </body>
</html>

到这里可以记住一句话:流式开关在请求体里的 stream 字段;而浏览器侧「读流」的套路,基本就是 ReadableStream 读片 + 文本解码 + 行缓冲 + 解析 data: JSON


四、SSE 与「为什么不能在前端直接用 EventSource 调大模型」

DeepSeek 以及大量兼容 OpenAI 的平台,流式输出本质上是标准的 Server-Sent Events(SSE):文本协议、单向(服务端 → 浏览器)、比 WebSocket 轻。

EventSource 按规范只支持 GET,且不方便携带我们常用的 Authorization: Bearer ...;而大模型对话接口又通常是 POST + JSON body。所以:不是浏览器不能玩 SSE,而是不能「直接用 EventSource」去怼官方大模型域名

常见工程化解法是做一层 BFF(Backend For Frontend):由 Node 持有密钥,替浏览器去 POST 上游,再把上游 SSE 裁剪/转写成浏览器更好消费的 SSE(或 JSON 行)。


五、零 npm 的 Node 代理:server.js

这里用 Node 22+ 内置 http / fs / path / fetch,不引入 expressdotenv 等依赖,文件即服务。

  • 从项目根目录读取 .env.local.env(简单解析 KEY=value)。
  • GET /stream?question=...:上游 stream: true,把增量以 SSE 写回(示例里对纯文本 delta 做了 JSON.stringify,避免正文换行弄坏 SSE)。
  • GET /complete?question=...:上游 stream: false,返回 { "content": "..." },给「非流式」前端一条同源捷径。

在同级目录创建 .env.local,写入一行(示例):

VITE_DEEPSEEK_API_KEY=sk-你自己的

然后执行:

node server.js

完整服务端代码如下(文件名 server.js):

"use strict";

/**
 * 零依赖代理:Node 22+(内置 fetch)
 * 启动:node server.js
 * 环境变量:在项目根目录放置 .env.local 或 .env,写入
 *   VITE_DEEPSEEK_API_KEY=sk-...
 * 可选:PORT=3000、DEEPSEEK_API_URL=https://api.deepseek.com/chat/completions
 *
 * 调用示例:
 *   curl -N "http://localhost:3000/stream?question=你好"
 */

const http = require("node:http");
const fs = require("node:fs");
const path = require("node:path");

const ROOT = __dirname;

function loadDotEnv() {
  for (const name of [".env.local", ".env"]) {
    const file = path.join(ROOT, name);
    if (!fs.existsSync(file)) continue;
    const text = fs.readFileSync(file, "utf8");
    for (const line of text.split(/\n/)) {
      const trimmed = line.trim();
      if (!trimmed || trimmed.startsWith("#")) continue;
      const eq = trimmed.indexOf("=");
      if (eq === -1) continue;
      const key = trimmed.slice(0, eq).trim();
      let val = trimmed.slice(eq + 1).trim();
      if (
        (val.startsWith('"') && val.endsWith('"')) ||
        (val.startsWith("'") && val.endsWith("'"))
      ) {
        val = val.slice(1, -1);
      }
      if (process.env[key] === undefined) process.env[key] = val;
    }
  }
}

loadDotEnv();

const PORT = Number(process.env.PORT) || 3000;
const API_KEY =
  process.env.VITE_DEEPSEEK_API_KEY || process.env.DEEPSEEK_API_KEY || "";
const UPSTREAM =
  process.env.DEEPSEEK_API_URL || "https://api.deepseek.com/chat/completions";

if (!API_KEY) {
  console.error(
    "缺少 API Key:请在 .env.local 或 .env 中设置 VITE_DEEPSEEK_API_KEY(或 DEEPSEEK_API_KEY)",
  );
  process.exit(1);
}

const CORS = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

/**
 * 将上游 OpenAI 兼容 SSE 行解析为 delta 文本,并写给客户端
 * @param {import('node:http').ServerResponse} res
 * @param {ReadableStreamDefaultReader<Uint8Array>} reader
 */
async function pipeUpstreamSseToClient(res, reader) {
  const decoder = new TextDecoder();
  let carry = "";

  while (true) {
    const { value, done } = await reader.read();
    if (done) break;
    carry += decoder.decode(value, { stream: true });

    let nl;
    while ((nl = carry.indexOf("\n")) !== -1) {
      const rawLine = carry.slice(0, nl);
      carry = carry.slice(nl + 1);
      const line = rawLine.trim();
      if (!line || line.startsWith(":")) continue;
      if (!line.startsWith("data:")) continue;

      const payload = line.slice(5).trim();
      if (payload === "[DONE]") {
        res.write("event: end\n");
        res.write("data: [DONE]\n\n");
        return;
      }

      try {
        const data = JSON.parse(payload);
        const delta = data?.choices?.[0]?.delta?.content;
        if (delta) {
          // 用 JSON 包裹一段文本,避免 delta 内含换行破坏 SSE
          res.write(`data: ${JSON.stringify(delta)}\n\n`);
        }
      } catch {
        carry = `${rawLine}\n${carry}`;
        break;
      }
    }
  }

  res.write("event: end\n");
  res.write("data: [DONE]\n\n");
}

const server = http.createServer(async (req, res) => {
  const host = req.headers.host || `127.0.0.1:${PORT}`;
  let url;
  try {
    url = new URL(req.url || "/", `http://${host}`);
  } catch {
    res.writeHead(400, {
      "Content-Type": "text/plain; charset=utf-8",
      ...CORS,
    });
    res.end("bad url");
    return;
  }

  if (req.method === "OPTIONS") {
    res.writeHead(204, CORS);
    res.end();
    return;
  }

  if (req.method === "GET" && url.pathname === "/") {
    res.writeHead(200, {
      "Content-Type": "text/plain; charset=utf-8",
      ...CORS,
    });
    res.end(
      `DeepSeek 代理已就绪。\n\n流式:GET /stream?question=你的问题\n非流式:GET /complete?question=你的问题\n示例:http://localhost:${PORT}/stream?question=你好\n`,
    );
    return;
  }

  if (req.method === "GET" && url.pathname === "/complete") {
    const question = (url.searchParams.get("question") || "").trim();
    if (!question) {
      res.writeHead(400, {
        "Content-Type": "application/json; charset=utf-8",
        ...CORS,
      });
      res.end(JSON.stringify({ error: "缺少参数:question" }));
      return;
    }

    try {
      const upstream = await fetch(UPSTREAM, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${API_KEY}`,
        },
        body: JSON.stringify({
          model: "deepseek-chat",
          messages: [{ role: "user", content: question }],
          stream: false,
        }),
      });

      const text = await upstream.text();
      if (!upstream.ok) {
        res.writeHead(upstream.status, {
          "Content-Type": "application/json; charset=utf-8",
          ...CORS,
        });
        res.end(
          JSON.stringify({ error: "upstream", body: text.slice(0, 800) }),
        );
        return;
      }

      let data;
      try {
        data = JSON.parse(text);
      } catch {
        res.writeHead(502, {
          "Content-Type": "application/json; charset=utf-8",
          ...CORS,
        });
        res.end(JSON.stringify({ error: "上游返回非 JSON" }));
        return;
      }

      const content = data?.choices?.[0]?.message?.content ?? "";
      res.writeHead(200, {
        "Content-Type": "application/json; charset=utf-8",
        ...CORS,
      });
      res.end(JSON.stringify({ content }));
    } catch (e) {
      res.writeHead(500, {
        "Content-Type": "application/json; charset=utf-8",
        ...CORS,
      });
      res.end(
        JSON.stringify({ error: e instanceof Error ? e.message : String(e) }),
      );
    }
    return;
  }

  if (req.method === "GET" && url.pathname === "/stream") {
    const question = (url.searchParams.get("question") || "").trim();
    if (!question) {
      res.writeHead(400, {
        "Content-Type": "text/plain; charset=utf-8",
        ...CORS,
      });
      res.end("缺少参数:question");
      return;
    }

    res.writeHead(200, {
      ...CORS,
      "Content-Type": "text/event-stream; charset=utf-8",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
      "X-Accel-Buffering": "no",
    });

    const ac = new AbortController();
    const onClose = () => ac.abort();
    res.on("close", onClose);

    try {
      const upstream = await fetch(UPSTREAM, {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${API_KEY}`,
        },
        body: JSON.stringify({
          model: "deepseek-chat",
          messages: [{ role: "user", content: question }],
          stream: true,
        }),
        signal: ac.signal,
      });

      if (!upstream.ok || !upstream.body) {
        const t = await upstream.text().catch(() => "");
        res.write(
          `data: ${JSON.stringify({
            error: `upstream ${upstream.status}`,
            body: t.slice(0, 800),
          })}\n\n`,
        );
        return;
      }

      await pipeUpstreamSseToClient(res, upstream.body.getReader());
    } catch (e) {
      if (e?.name === "AbortError") {
        return;
      }
      console.error(e);
      res.write(
        `data: ${JSON.stringify({ error: e instanceof Error ? e.message : String(e) })}\n\n`,
      );
    } finally {
      res.off("close", onClose);
      if (!res.writableEnded) res.end();
    }
    return;
  }

  res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8", ...CORS });
  res.end("not found");
});

server.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
  console.log(
    `Stream: curl -N "http://localhost:${PORT}/stream?question=你好"`,
  );
  console.log(
    `Complete: curl "http://localhost:${PORT}/complete?question=你好"`,
  );
});

六、前端改用 EventSource:更轻的一层消费

当密钥已经只在服务端时,浏览器不再需要输入 Key。流式场景下,用 EventSource 连接自家代理,例如:

http://127.0.0.1:3000/stream?question=...question 请做 URL 编码)

下面是与当前仓库一致的 index.html 版本:流式走 EventSource,非流式走 GET /complete;并把代理根地址记在 localStorage 里,方便反复调试。

<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>DeepSeek 流式(Vue CDN 单页)</title>
  </head>
  <body>
    <div id="app"></div>

    <script type="module">
      import {
        createApp,
        ref,
        onUnmounted,
      } from "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js";

      createApp({
        setup() {
          /** 本地 node server.js 地址(与 server 监听端口一致) */
          const proxyBase = ref("http://127.0.0.1:3000");
          const question = ref("讲一个关于中国龙的故事");
          const content = ref("");
          const isStream = ref(true);
          const loading = ref(false);
          const error = ref("");

          /** @type {EventSource | null} */
          let eventSource = null;

          function closeEventSource() {
            if (eventSource) {
              eventSource.close();
              eventSource = null;
            }
          }

          onUnmounted(() => {
            closeEventSource();
          });

          function saveProxyBase() {
            try {
              localStorage.setItem(
                "deepseek_proxy_base",
                proxyBase.value.trim(),
              );
            } catch (_) {}
          }

          async function update() {
            if (!question.value.trim()) {
              error.value = "请输入问题";
              return;
            }

            const base = proxyBase.value.trim().replace(/\/$/, "");
            if (!base) {
              error.value = "请填写代理地址";
              return;
            }

            error.value = "";
            closeEventSource();
            saveProxyBase();

            const q = encodeURIComponent(question.value.trim());

            if (isStream.value) {
              loading.value = true;
              content.value = "";

              const url = `${base}/stream?question=${q}`;
              const es = new EventSource(url);
              eventSource = es;

              es.addEventListener("message", (e) => {
                if (e.data === "[DONE]") return;
                try {
                  const parsed = JSON.parse(e.data);
                  if (
                    parsed &&
                    typeof parsed === "object" &&
                    parsed !== null &&
                    "error" in parsed
                  ) {
                    error.value =
                      typeof parsed.error === "string"
                        ? parsed.error
                        : JSON.stringify(parsed.error);
                    closeEventSource();
                    loading.value = false;
                    return;
                  }
                  if (typeof parsed === "string") {
                    content.value += parsed;
                  }
                } catch {
                  error.value = "SSE 解析失败:" + e.data;
                  closeEventSource();
                  loading.value = false;
                }
              });

              es.addEventListener("end", () => {
                closeEventSource();
                loading.value = false;
              });

              es.onerror = () => {
                if (!error.value) {
                  error.value =
                    "EventSource 连接失败(请确认已运行 node server.js,且代理地址、端口正确)";
                }
                closeEventSource();
                loading.value = false;
              };

              return;
            }

            loading.value = true;
            content.value = "思考中…";
            try {
              const res = await fetch(`${base}/complete?question=${q}`);
              const data = await res.json().catch(() => ({}));
              if (!res.ok) {
                throw new Error(
                  typeof data.error === "string"
                    ? data.error
                    : res.status + " " + JSON.stringify(data).slice(0, 200),
                );
              }
              if (data && typeof data.content === "string") {
                content.value = data.content;
              } else {
                content.value = JSON.stringify(data);
              }
            } catch (e) {
              error.value = e instanceof Error ? e.message : String(e);
              content.value = "";
            } finally {
              loading.value = false;
            }
          }

          if (typeof localStorage !== "undefined") {
            const saved = localStorage.getItem("deepseek_proxy_base");
            if (saved) proxyBase.value = saved;
          }

          return {
            proxyBase,
            question,
            content,
            isStream,
            loading,
            error,
            update,
          };
        },
        template: `
          <div class="wrap">
            <h1>DeepSeek 对话(经本地 server.js)</h1>
            <p class="hint">
              先在本项目目录运行 <code>node server.js</code>(Key 写在服务端 .env.local)。流式走
              <code>GET /stream</code>(EventSource),非流式走 <code>GET /complete</code>。
            </p>
            <div class="row">
              <label for="proxy">代理</label>
              <input id="proxy" type="text" v-model="proxyBase" placeholder="http://127.0.0.1:3000" autocomplete="off" />
            </div>
            <div class="row">
              <label for="q">问题</label>
              <input id="q" class="input-q" type="text" v-model="question" />
            </div>
            <div class="row">
              <label><input type="checkbox" v-model="isStream" :disabled="loading" /> 流式输出 (SSE)</label>
              <button type="button" :disabled="loading" @click="update">{{ loading ? '请求中…' : '提交' }}</button>
            </div>
            <p v-if="error" class="err">{{ error }}</p>
            <div class="output">{{ content || (loading && isStream ? '…' : '') }}</div>
          </div>
        `,
      }).mount("#app");
    </script>
    <style>
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        font-family: system-ui, sans-serif;
        background: #0f1419;
        color: #e6edf3;
        min-height: 100vh;
      }
      .wrap {
        max-width: 52rem;
        margin: 0 auto;
        padding: 1rem 1.25rem 2rem;
      }
      h1 {
        font-size: 1.1rem;
        font-weight: 600;
        margin: 0 0 0.75rem;
        color: #8b949e;
      }
      .row {
        display: flex;
        flex-wrap: wrap;
        gap: 0.5rem;
        align-items: center;
        margin-bottom: 0.75rem;
      }
      label {
        font-size: 0.85rem;
        color: #8b949e;
      }
      input[type="text"],
      input[type="password"] {
        flex: 1;
        min-width: 12rem;
        padding: 0.45rem 0.6rem;
        border-radius: 6px;
        border: 1px solid #30363d;
        background: #161b22;
        color: #e6edf3;
        font-size: 0.85rem;
      }
      input.input-q {
        width: 100%;
        min-width: 100%;
      }
      button {
        padding: 0.45rem 1rem;
        border-radius: 6px;
        border: 1px solid #388bfd;
        background: #21262d;
        color: #58a6ff;
        font-size: 0.85rem;
        cursor: pointer;
      }
      button:disabled {
        opacity: 0.5;
        cursor: not-allowed;
      }
      .hint {
        font-size: 0.75rem;
        color: #6e7681;
        margin: 0 0 1rem;
        line-height: 1.45;
      }
      .hint code {
        font-size: 0.85em;
        padding: 0.12em 0.4em;
        border-radius: 4px;
        background: #21262d;
        color: #79c0ff;
      }
      .output {
        margin-top: 0.75rem;
        padding: 1rem;
        border-radius: 8px;
        border: 1px solid #30363d;
        background: #161b22;
        min-height: 12rem;
        white-space: pre-wrap;
        word-break: break-word;
        text-align: left;
        font-size: 0.9rem;
        line-height: 1.55;
      }
      .err {
        color: #f85149;
        margin-top: 0.5rem;
        font-size: 0.85rem;
        text-align: left;
      }
    </style>
  </body>
</html>

看效果:

  • node server.js启动服务
  • index.html直接在浏览器打开就行

七、和「手写 ReadableStream」相比:EventSource 在写什么

有了 BFF 之后,浏览器侧可以收敛成「连接 + 监听消息 + 结束关闭」的写法。下面是一段示意(注意:真实页面里 e.data 往往是 JSON 字符串化的片段,需要 JSON.parse 后再拼接,见上一节完整 index.html;这里保留原文写法不动):

const eventSource = new EventSource(`${endpoint}?question=${question.value}`);
eventSource.addEventListener("message", function(e: any) {
  content.value += e.data;
});
eventSource.addEventListener('end', () => {
  eventSource.close();
});

除了代码更短之外,EventSource 还自带自动重连语义(适合长连接场景;生产环境仍要结合幂等、去重与产品体验谨慎使用)。标准里也提到 Last-Event-ID 等能力,用于断线续传时减少重复流量(是否启用取决于你的 BFF 设计)。


八、附录:SSE 是什么、数据长什么样

SSE(Server-Sent Events):服务端主动向浏览器推送事件流,单向、基于 HTTP,通常比 WebSocket 更轻。

前端最小用法示例(与具体业务路径无关,仅演示 API):

// 建立 SSE 连接
const evtSource = new EventSource('/api/sse');

// 监听服务器发来的消息
evtSource.onmessage = (e) => {
  console.log('收到消息:', e.data);
};

// 监听错误
evtSource.onerror = (err) => {
  console.error('SSE 出错', err);
};

// 关闭连接
evtSource.onmessage = (e) => {
  if (e.data === 'done') {
    evtSource.close(); // 关闭 SSE 连接
    return;
  }
  console.log(e.data);
};

服务端写入时,需要满足 SSE 的基本形态:Content-Type: text/event-stream,消息以 data: 开头,并以 空行(\n\n 结束一条事件。下面用注释标了几种常见形态(注意:注释行只是说明,真实协议里注释行以 : 开头,这里保留原文示例不动):


data: 你好任意巴拉巴拉\n\n

# json串
data: {"name":"小明","age":20}\n\n

# 自定义结束 前端获取就行
data: done\n\n

# 发空行表示心跳
data: \n\n

Node 里设置响应头并周期性 write 的伪代码如下(仅帮助理解,不是可直接运行的完整服务):

// 伪代码
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');

// 每隔 1 秒发一条
setInterval(() => {
  res.write(`data: ${new Date()}\n\n`);
}, 1000);

结束标记(例如 done)可以自定义,但团队内最好统一约定;本文 BFF 示例则使用 event: end + data: [DONE] 的组合来通知前端收尾。


小结

  • 直连fetch + stream + ReadableStream 解析 data: 行,灵活但代码多,且 Key 在浏览器。
  • BFF:Node 持有 Key,浏览器用 EventSource/fetch 访问同源或可控跨域接口,职责更清晰。
  • 安全:Key 进 .env.local.gitignore 忽略本地环境文件;页面不要上传公网。

祝调试顺利。

一文看懂:Vue3 watch 用 VuReact 转成 React 长啥样

作者 Ruihong
2026年4月14日 11:12

大家好,我是专注前端框架迁移、编译工具实践的掘金博主~在 Vue3 转 React 的过程中,watch 作为最常用的响应式监听 API,手动改写很容易丢失逻辑、写错依赖。

今天继续用 VuReact 工具,给大家带来 Vue3 watch → React 编译对照,全程一比一还原、保留所有行为与内链,看完直接上手迁移。


前言

先明确核心: VuReact 是能将 Vue 3 代码编译为标准、可维护 React 代码的工具 它最大亮点:编译阶段自动分析依赖、自动生成依赖追踪,完美对齐 Vue 响应式监听行为,不用手动处理 React Hooks 依赖。

本文只聚焦一个高频 API: 👉 Vue3 watch → React 等价代码 全程对照,不冗余、直接看核心。

前置约定(避免理解偏差)

为了示例清爽,先统一两点:

  1. 只保留核心逻辑,省略组件包裹、无关配置
  2. 默认你已熟悉 Vue3 watch 用法与核心行为

一、基础版:watch → useWatch

Vue 标准 watch 监听,支持 immediate、清理函数 onCleanup,VuReact 直接编译为 useWatch

Vue 源码

<script setup>
import { ref, watch } from 'vue';
const userId = ref(1);

watch(
  userId,
  async (newId, oldId, onCleanup) => {
    let cancelled = false;
    onCleanup(() => {
      cancelled = true;
    });
    const data = await fetchUser(newId);
    if (!cancelled) {
      userData.value = data;
    }
  },
  { immediate: true },
);
</script>

VuReact 编译后 React 代码

import { useVRef, useWatch } from '@vureact/runtime-core';
const userId = useVRef(1);

useWatch(
  userId,
  async (newId, oldId, onCleanup) => {
    let cancelled = false;
    onCleanup(() => {
      cancelled = true;
    });
    const data = await fetchUser(newId);
    if (!cancelled) {
      setUserData(data);
    }
  },
  { immediate: true },
);

核心要点

  • Vue watch() 直接编译为 useWatch
  • 完全保留:回调参数、immediateonCleanup 清理机制
  • 编译阶段自动分析依赖、深度追踪,无需手动管理依赖数组

二、深度监听 & 多源监听:对象/数组来源兼容

watch 监听对象内部属性、多源数组时,VuReact 同样支持 deep 与多源写法,行为完全对齐 Vue。

Vue 源码(深度监听 + 多源监听)

<script setup>
import { reactive, watch } from 'vue';
const state = reactive({
  info: { name: 'Vureact', version: '1.0' },
  count: 0,
});

// 深度监听对象内部
watch(
  () => state.info,
  (newInfo) => {
    console.log('对象内部变化:', newInfo.name);
  },
  { deep: true },
);

// 多源监听
watch([state.count, () => state.info.name], ([newCount, newName]) => {
  console.log('计数:', newCount, '名称:', newName);
});
</script>

VuReact 编译后 React 代码

import { useReactive, useWatch } from '@vureact/runtime-core';
const state = useReactive({
  info: { name: 'Vureact', version: '1.0' },
  count: 0,
});

useWatch(
  () => state.info,
  (newInfo) => {
    console.log('对象内部变化:', newInfo.name);
  },
  { deep: true },
);

useWatch([state.count, () => state.info.name], ([newCount, newName]) => {
  console.log('计数:', newCount, '名称:', newName);
});

对应关系

  • 监听函数写法、deep: true 深度监听完全保留
  • 多源数组监听直接兼容
  • 编译器自动做依赖分析,不用手动写 deps

三、一句话总结

用 VuReact 做 Vue3 → React 迁移,watch 相关规则:

  1. watchuseWatch
  2. 支持 immediate / deep / onCleanup 全部选项
  3. 支持单源、函数返回值、多源数组监听
  4. 依赖自动追踪,无需手动管理依赖数组
  5. 行为 1:1 对齐 Vue,迁移零逻辑损耗

相关资源

❤️ 觉得有用就 点赞 + 收藏 + 关注,持续更新前端迁移/编译工具实战!

一文看懂:Vue3 watchEffect 用 VuReact 转成 React 长啥样

作者 Ruihong
2026年4月14日 11:06

大家好,我是专注前端框架迁移、编译工具实践的掘金博主~最近很多同学在做 Vue3 → React 技术栈迁移,被响应式 API 对齐、依赖手动管理搞得头大,尤其是 watchEffect 这种自动依赖收集的核心 API,在 React 里很容易漏写依赖。

今天就用 VuReact 这个编译工具,直接把 Vue3 watchEffect 的各种用法一比一翻译成标准可维护的 React 代码,全程对照、看完即用。


前言

先明确核心: VuReact 是能将 Vue 3 代码编译为标准、可维护 React 代码的工具 它最大亮点:编译阶段自动分析依赖、自动生成依赖数组,完美对齐 Vue 响应式行为,不用手动维护 React Hooks 依赖。

本文只聚焦一个高频 API: 👉 Vue3 watchEffect → React 等价代码 全程对照,不冗余、直接看核心。

前置约定(避免理解偏差)

为了示例清爽,先统一两点:

  1. 只保留核心逻辑,省略组件包裹、无关配置
  2. 默认你已熟悉 Vue3 watchEffect 用法与行为

一、基础版:watchEffect → useWatchEffect

Vue 最常用的基础 watchEffect,自动收集依赖、自动触发副作用。

Vue 源码

<script setup>
import { ref, watchEffect } from 'vue';
const count = ref(0);

watchEffect(() => {
  console.log(`当前计数是: ${count.value}`);
});
</script>

VuReact 编译后 React 代码

import { useVRef, useWatchEffect } from '@vureact/runtime-core';
const count = useVRef(0);

useWatchEffect(() => {
  console.log(`当前计数是: ${count.value}`);
}, [count.value]);

核心要点

  • Vue watchEffect() 直接编译为 useWatchEffect
  • 编译阶段自动分析依赖并生成精准依赖数组,无需手动管理
  • 完全模拟 Vue watchEffect 的自动依赖收集、清理机制、停止控制

二、带 flush 选项:post / sync 对齐渲染时机

Vue 中通过 flush: 'post' / flush: 'sync' 控制执行时机,VuReact 直接映射为专用 Hook,保持渲染时机一致。

Vue 源码(post + sync)

<script setup>
import { ref, watchEffect } from 'vue';
const width = ref(0);
const elRef = ref(null);

// DOM 更新后执行
watchEffect(
  () => {
    if (elRef.value) {
      width.value = elRef.value.offsetWidth;
    }
  },
  { flush: 'post' },
);

// 同步立即执行
watchEffect(
  () => {
    console.log(elRef.value);
  },
  { flush: 'sync' },
);
</script>

VuReact 编译后 React 代码

import { useVRef } from '@vureact/runtime-core';
import { useWatchPostEffect, useWatchSyncEffect } from '@vureact/runtime-core';

const width = useVRef(0);
const elRef = useVRef(null);

useWatchPostEffect(
  () => {
    if (elRef.value) {
      width.value = elRef.value.offsetWidth;
    }
  },
  [elRef.value, width.value, elRef.value.offsetWidth]
);

useWatchSyncEffect(
  () => {
    console.log(elRef.value);
  },
  [elRef.value]
);

对应关系

  • flush: 'post'useWatchPostEffect
  • flush: 'sync'useWatchSyncEffect
  • 执行时机、依赖追踪、副作用行为完全对齐 Vue
  • 依赖数组依旧自动生成,无需手动编写

三、一句话总结

用 VuReact 做 Vue3 → React 迁移,watchEffect 相关规则:

  1. watchEffectuseWatchEffect
  2. flush: 'post'useWatchPostEffect
  3. flush: 'sync'useWatchSyncEffect
  4. 依赖自动收集、deps 自动生成,不用手动维护
  5. 行为 1:1 对齐 Vue,迁移成本极低

相关资源

互动一下

你在 Vue 转 React 时,最头疼哪个 API? watch / computed / defineProps / defineEmits? 评论区留言,下期直接出对照编译手册

❤️ 觉得有用就 点赞 + 收藏 + 关注,持续更新前端迁移/编译工具实战!

React 常用知识点整理

作者 Explore
2026年4月14日 11:04

前言:本文总结React 常用知识点,给出简洁的说明和示例,方便记忆和速查


1. JSX 基础

  • JSX 中可使用 {} 嵌入 JS 表达式。
  • 渲染原生 HTML 片段使用 dangerouslySetInnerHTML
function App() {
  const rawHtmlData = {
    __html: "<span>富文本内容<i>斜体</i><b>加粗</b></span>",
  };

  return <div dangerouslySetInnerHTML={rawHtmlData} />;
}

2. 循环渲染(map + key

  • 列表渲染通常使用 map
  • key 必须稳定且唯一,优先使用后端 id
<ul>
  {list.map((item) => (
    <li key={item.id}>{item.name}</li>
  ))}
</ul>

3. 条件渲染

简单场景:&&、三元表达式

{/* 逻辑与 */}
{isLogin && <span>this is span</span>}

{/* 三元表达式 */}
{isLogin ? <span>jack</span> : <span>loading...</span>}

复杂场景:函数返回 JSX

可使用if语句,switch语句或策略模式,判断返回不同的JSX

function App() {
  const type = 1; // 0 | 1 | 3

  function getArticleJSX() {
    if (type === 0) return <div>无图模式模板</div>;
    if (type === 1) return <div>单图模式模板</div>;
    if (type === 3) return <div>三图模式模板</div>;
    return null;
  }

  return <>{getArticleJSX()}</>;
}

4. 事件绑定

  • 语法:on + 事件名 = {事件处理函数}(驼峰命名)。
  • 传参通常使用箭头函数。
  • 同时传事件对象和自定义参数时,手动透传 e
// 基础掉用,使用事件对象
function App() {
  const handleClick = (e) => {
    console.log("点击了按钮", e);
  };
  return (
   <div>
     <button onClick={handleClick}>点击</button>
   </div>
);
}

// 传递自定义参数
function App() {
  const handleClick = (name) => {
    console.log("点击了按钮", name);
  };
  return (
    <div>
      <button onClick={() => handleClick('zs')}>点击</button>
    </div>
  );
}

// 同时传递事件对象+自定义参数
function App() {
  const handleClick = (e, name) => {
    console.log("点击了按钮", e, name);
  };
  return (
    <div>
      <button onClick={(e) => handleClick(e, 'zs')}>点击</button>
    </div>
  );
}

5. 组件基础

  • 组件本质是首字母大写的函数(函数声明或箭头函数都可以)。
  • 组件内部包含状态、逻辑和 UI,使用时像标签一样书写。
function Welcome() {
  return <h1>Hello React</h1>;
}

6. CSS 样式

  • 行内样式:style={{ fontSize: "16px" }}
  • 类名:className="xxx"
  • 状态控制类名(条件拼接):
{tabs.map((item) => (
  <span
    key={item.type}
    className={`nav-item ${item.type === type ? "active" : ""}`}
    onClick={() => handleTabChange(item.type)}
  >
    {item.text}
  </span>
))}

7. useState 状态管理

  • const [state, setState] = useState(initialValue)
  • 初始值只在首次渲染生效,后续渲染不会重新初始化。
  • 状态是只读的:更新时用“替换”,不要直接修改原对象/原数组。
  • 依赖旧值更新时,优先函数式写法。
import { useState } from "react";

function App() {
  const [count, setCount] = useState(0);
  const handleClick = () => {
    setCount(count + 1);
    // setCount((preCount) => preCount +1);
  }
  return (
    <div>
    <button onClick={handleClick}>{count}</button>
    </div>
  );
}

对象更新示例:

const [form, setForm] = useState({ username: "zhangsan", password: "" });
setForm({ ...form, password: "123456" });

8. useEffect

useEffect(effect, deps) 常用于请求数据、订阅、定时器等副作用。

  • 不传依赖:每次渲染后都执行。
  • 传空数组 []:仅首次渲染后执行一次。
  • 传具体依赖 [a, b]:首次渲染 + 依赖变化时执行。
  • 清理函数用于取消订阅、清除定时器等:
useEffect(() => {
  const timer = setInterval(() => {}, 1000);
  return () => clearInterval(timer);
}, []);

9. useRef

  • 获取 DOM:ref={inputRef}inputRef.current.focus()
  • 存储不会触发重渲染的可变值(如定时器 id)
const inputRef = useRef(null);
inputRef.current?.focus();

10. 受控组件 vs 非受控组件

  • 受控组件:表单值由 React 状态控制(value + onChange),初始状态+更新事件函数。
  • 非受控组件:值由 DOM 自己维护,通常用 ref 获取当前值。
// 受控
function App(){
  const [value, setValue] = useState('')
  return (
    <input 
      type="text" 
      value={value} 
      onChange={e => setValue(e.target.value)}
    />
  )
}

// 非受控
function App(){
  const inputRef = useRef(null)
  const onChange = ()=>{
    console.log(inputRef.current.value)
  }
  return (
    <input 
      type="text" 
      ref={inputRef}
      onChange={onChange}
    />
    )
}

11. 组件通信

  • 父传子:props
  • 插槽能力:props.children
  • 子传父:父传函数给子,子调用并回传参数
  • 兄弟通信:状态提升(共享父组件中转)
  • 跨层通信:Context
  • 更复杂全局状态:Redux(或其他状态库)

12. useContext

  1. createContext 创建上下文对象
  2. 顶层用 Provider 提供 value
  3. 子孙组件用 useContext 消费数据
const ThemeContext = createContext("light");

function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Page />
    </ThemeContext.Provider>
  );
}

13. Hooks 使用规则

  1. 只能在函数组件或自定义 Hook 中调用。
  2. 只能在组件顶层调用,不能写在 if/for/switch/普通函数 内。

14. 自定义 Hook

  • 命名必须以 use 开头。
  • 目的:复用“状态 + 副作用逻辑”。
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue((v) => !v);
  return [value, toggle];
}

15. useReducer

适合复杂状态流转或多分支更新。

function reducer(state, action) {
  switch (action.type) {
    case "INC":
      return state + 1;
    case "DEC":
      return state - 1;
    case "SET":
      return action.payload;
    default:
      return state;
  }
}

const [state, dispatch] = useReducer(reducer, 0);
dispatch({ type: "INC" });
dispatch({ type: "SET", payload: 100 });

16. useMemo(缓存值)

  • 在依赖不变时复用计算结果,减少重复计算。
  • 常用于缓存“昂贵计算结果”或“稳定引用(数组/对象)”。
const result = useMemo(() => heavyCalc(count1), [count1]);

const list = useMemo(() => [1, 2, 3], []);

17. React.memo(缓存组件)

  • props 未变化时跳过子组件重渲染。
  • React 会对 props 做浅比较(Object.is)。
const MemoComponent = memo(function SomeComponent(props) {
  return <div>{props.value}</div>;
});

18. useCallback(缓存函数)

  • 缓存函数引用,避免子组件因函数地址变化而无意义重渲染。
const changeHandler = useCallback((value) => {
  console.log(value);
}, []);

19. forwardRef

  • 作用:让父组件拿到子组件内部的 DOM/实例能力。
  • React 19 中 ref 可像普通 prop 一样传递到函数组件,但很多项目仍大量使用 forwardRef,兼容性更好。
import { forwardRef, useRef } from 'react'

const MyInput = forwardRef(function Input(props, ref) {
  return <input type="text" {...props} ref={ref} />
}, [])

function App() {
  const ref = useRef(null)
  const focusHandle = () => {
    ref.current.focus()
  }
  return (
    <div>
      <MyInput ref={ref} />
      <button onClick={focusHandle}>focus</button>
    </div>
  )
}

20. useImperativeHandle

  • 用于“自定义 ref 暴露内容”,而不是直接暴露整个 DOM。
import { forwardRef, useImperativeHandle, useRef } from 'react'

const MyInput = forwardRef(function Input(props, ref) {
  // 实现内部的聚焦逻辑
  const inputRef = useRef(null)
  const focus = () => inputRef.current.focus()

  // 暴露子组件内部的聚焦方法
  useImperativeHandle(ref, () => {
    return {
      focus,
    }
  })

  return <input {...props} ref={inputRef} type="text" />
})

function App() {
  const ref = useRef(null)

  const focusHandle = () => ref.current.focus()

  return (
    <div>
      <MyInput ref={ref} />
      <button onClick={focusHandle}>focus</button>
    </div>
  )
}

21. useLayoutEffect

  • useEffect:浏览器绘制后异步执行,不阻塞渲染。
  • useLayoutEffect:DOM 更新后、绘制前同步执行,会阻塞渲染。
  • 场景:需要在绘制前读取布局并立即修正(避免闪动)。

22. 路由懒加载:lazy + Suspense

import { lazy, Suspense } from "react";

const Home = lazy(() => import("@/pages/Home"));

function App() {
  return (
    <Suspense fallback={<div>loading...</div>}>
      <Home />
    </Suspense>
  );
}

高频易错点(建议重点记)

  • key 不要用随机值或 index(除非列表完全静态)。
  • 更新对象/数组状态时必须返回新引用。
  • useEffect 依赖项写全,避免闭包拿到旧值。
  • 性能优化优先级:先排查真实瓶颈,再使用 memo/useMemo/useCallback
  • dangerouslySetInnerHTML 只用于可信内容,避免 XSS 风险。

数字排毒,我们这代人的赎罪券|硬哲学

作者 杜晨
2026年4月14日 10:52

很少有 MacBook Neo 评价两极化这么夸张的苹果产品。

有人说它性能完全不够,靠 logo 骗预算不够的小白;有人却说它是近年来苹果最务实的诚意之作。

剥离掉对跑分性能和极致的生产力上限的执念,你会发现,已经很久没有一款科技产品,剔除冗余的性能溢价,也不做不切实际的承诺。你不应该买它,除非你真的需要。

在 MacBook Neo 的身上,有一种真正的「极简主义科技」意味,因为它回应了用户真实需求,也没有人为阉割来制造所谓的极简感。

今天爱范儿这篇专栏文章,想要探讨真正的「极简主义科技」究竟是什么。

我在 Reddit 上看到了很多帖子,描述想象中的极简主义科技生活:主力机是 Light Phone 3 代,这台「笨手机」(dumb phone) 售价 699 美元;一台 iPod Classic 用来听歌;一台十年前的数码相机用来拍照;当有灵感需要记录的时候,他会从自己的「离线包」里掏出手账本……以此类推。

每一个智能手机具备且经常使用的功能,都被分散到一台单独的设备或道具上。日常出街的家伙事,足足三斤重。

帖主给这种状态起了个名字:Inconvenient Maximalism,不方便的极繁主义。

对了,这个「离线包」(analog bag) 也是前段时间洋抖 (TikTok) 上爆红的最新趋势,成千上万的视频,巨大的流量。它指的是一个装满「离线物品」的包:胶片机/CCD、随身听、有线耳机、毛线针和线团、手账本与纸质书……大意是说,当你出门时完全可以把手机扔下,用这包东西打发时间。

我很喜欢的一个「反消费主义者」YouTube 博主 Levi Hildebrand,是这么评价「离线包」的:

手机能做一百件事。所以你不带手机就需要一百样东西来替代它,结果就是你的包包越装越重。

然而更讽刺的是,这些博主明明带着手机,背着包包到处跑,几十个场景机位来回切换,拍出视频……就是为了发到网上,再忽悠他们用自己的返佣链接下单去买这些 CCD、随身听、耳机、毛线针、手账本?

如今的消费主义,已经堕落到这种程度了?为什么这些热衷于「极简主义科技」「数字排毒」的人,如此抽象?

数字排毒,活成了自己的笑话

每当某种注意力收割工具令人厌倦了、过时了,马上就会有新东西,以反抗者、革命者的姿态出现,承诺将你解脱出来……

不消时日,这个新东西就会马上演化成下一轮的收割工具,往复循环。

今天,这个新东西就是「数字排毒」的概念,以及打着这个概念旗号,企图笼络人心的极简主义科技产品们。

2017 年,第一代 Light Phone 上市,只能打电话;2019 年,Light Phone 二代加入了短信、音乐播放器和闹钟。去年,乘上了「笨手机」春风的 Light Phone 三代终于发布,售价 699 美元。

海外媒体是这么评价 Light Phone 三代的:「极简主义被拉伸到令人沮丧的程度」,「一台越来越像智能机的傻瓜手机」。也不能怪他们:AMOLED 屏幕、摄像头、NFC 支付、指纹解锁……只看参数表的话,你很容易以为这就是一台智能机。

从开始到现在,Light Phone 已然进入了两难。如果卖点是「少」,就必须砍功能。但功能少了,用户反而不敢买单;把功能加回来,尺度很微妙。

除了产品设计之外,Light Phone 还面临商业模式的问题。

它最初是从众筹平台上起飞的,但公司随后不得不拿了风投的钱。谈投资的时候可能聊的是「数字排毒」的趋势,投后要看的却是销量、增长、财务……本质上,这套逻辑和极简主义/反消费主义「希望用户少用产品」的美好愿景是完全错位的。

结果就是为了卖货,这台「笨手机」高也不成低也不就,离它最初承诺的东西越来越远,却越来越像它本来要取代的东西……

消费主义的本质,是不断创造新的欲望来消化过剩的产能,而注意力经济是创造消费欲望的最有效手段之一。

哥大法学院的吴修铭教授认为,教授注意力经济也已有百年多的历史。从 19 世纪的廉价报纸 (penny press),到 20 世纪的广播电视,再到今天的短视频、小游戏、短剧,其实注意力经济从来没有变过:用免费内容交换人的时间,再把这些时间通过各种方式(广告、数据等)变现。

哈佛商学院荣休教授苏珊娜·祖博夫在《监控资本主义时代》一书中提出了一个新的概念:「行为剩余」(behavioral surplus),指的是科技公司从用户行为里提取数据,例如你点击了什么、在哪里停留了多久、在哪里犹豫了,然后把它们转化为「行为预测产品」,打包卖给广告商。

但为了让预测更准确,平台需要主动去「塑造」用户行为——无限算法流、消息红点、间歇性的点赞通知,都会服务这个目的。

前几年曾经有个社交产品 BeReal 爆火,每天随机弹出通知,用户必须在两分钟内打开应用拍照并分享,没时间准备,也不给修图滤镜,鼓励展现未修饰的日常样貌,消除社交产品的使用焦虑。

2024 年,以高频成瘾的垃圾手游闻名的法国公司 Voodoo,斥资 5 亿欧元收购了 BeReal。

一款以「反注意力收割」为卖点的产品,反倒被注意力收割机吞并了。这大概就是活成了自己的笑话、屠龙者终成恶龙、逻辑闭环了吧……

这些科技产品的设计理念,本质上和老虎机没什么区别。让人上瘾最有效的方式不是每次都给奖励,而是随机给。你不知道这次下拉会看到什么,正是这个不确定性让你停不下来。

互联网是通讯工具,是知识系统……它可以是很多东西。但大部分时候,它实际上是一台重新引导、无情收割一切注意力的机器。它侵蚀的不只是你的时间,是你的掌握自己注意力的自主性。

买一台「笨手机」,解决不了结构性问题

乔治城大学计算机教授卡尔·纽波特是「数字极简主义」这一概念最重要的推广者。2019 年他出版了《数字极简主义:在喧嚣世界中选择专注的生活》一书,认为电子邮件、聊天、短视频等等助长了「过度活跃的蜂群思维」(the hyperactive hive mind)。

纽波特认为,智能手机应该像一把瑞士军刀,大体上有通话、地图、相机、音乐这些核心功能就够了——这个愿望有点不切实际,他自己也清楚。

于是他转而提倡一种「非暴力」式的戒网方法:在你的手机上关闭非紧急通知,删除社交软件,手机调成单色模式,给自己设定一个数字宵禁的时间。从他的角度,把手机放在家里不带出门,已经是最极端的排毒行为了。

你可以看出来,纽波特的方案本来是零成本的。他从来没说过,你应该花数千块钱购置任何额外的设备。在他的方法论当中,甚至没有「数字排毒产品」这个品类的存在。

然而冥冥之中,提出了数字排毒概念的纽波特,反而成了另一群人,将之商品化的共谋:

  • 首先,有人带着真诚的初衷发现了一个真实问题,触碰到了更多人内心深处某种真实的渴望;
  • 接着,另一群人看到了营销机会,开始兜售一套你必须拥有的东西,来证明你属于这场运动。
  • 结果是,这群人接管、掌握、统治、最终定义了整个运动,直到它的信仰破灭。

类似的剧本一再上演。

1986 年,意大利的第一家麦当劳,在罗马的西班牙广场开业。作家 Carlo Petrini 召集了一群同事朋友去抗议,而这次抗议后来演化成了慢食运动 (Slow Food)。

这场运动的立场,既回归传统又标新立异:反对工业化快餐对饮食的侵蚀,在农民和消费者之间建立更直接的连接。

然而现如今,「农场到餐桌」(farm to table) 早已成了高端食品的标签,慢食运动最初所代表的理念,早已被消费主义完全消化,逐渐退化成了高档餐厅和有机超市溢价的理由。

十多年前,源自于佛教等宗教里的正念/灵修/内观,成为了社交网络上最 in 的潮流之一。然而当这种非主流爱好演变为潮流之后,也成了新的商业收割机。一群科技创业者趁势而上,开发出了市场规模高达数十亿美元的正念产业。

学者 Ronald Purser 在 2019 年出了一本书,书名就叫《麦正念》(McMindfulness,一个很有趣的双关),批评「正念」早已变成让打工人在高压环境里更好适应的减压技术。正念产业们忽略了真正的问题在于结构性的工作压力,却把责任塞还给了个人,让用户去管理自己的内心。

热潮过后,行业两大巨头 Headspace、Calm 的下载量纷纷暴跌(-74%、-61%)。

和「农场到餐桌」「正念」等概念一样,数字排毒也正经历概念破产的加速期。

数字排毒产品许诺的是:用一次性的消费行为,解决一个持续性的行为问题。但如果你看过各种电影电视剧里强制戒酒、戒毒之后的复饮、复吸情节,应该知道这种强硬限制的反效果有多强。

2025 年《BMC Medicine》刊登了一项为期三周的手机使用干预试验,要求上百名参与者每天使用时间不超过两小时。干预期间,测试组的平均屏幕时间从每天 285 分钟降至 129 分钟,压力和睡眠质量也同步改善。

然而 6 周后回访数据显示,他们的屏幕时间又反弹回了 226 分钟,心理健康睡眠指标也降回去了。又过了一周,反弹情况已经和测试的对照组基线水平无异。

强制、短期的「戒网」,是没有效果的。

为什么这种限制注定会失败?上世纪 60 年代,心理学家 Jack Brehm 提出「心理抗拒理论」:当一个人感知到自己的自由选择被外部力量限制时,他会产生强烈的动机来恢复这种自由。

约束越强硬,被禁止的行为就越有吸引力。这解释了为什么很多「笨手机」用户最终把那台手机放进了抽屉,然后换回了 iPhone 和安卓机。

数字排毒产品赖以存在的心理前提,可能本身就是错的。

用消费,反抗消费

回到文章开头提到的「离线包」案例。

如果你去刷相关话题的视频,会发现这些博主,各种书哐哐往家买,却没几个真的去博物馆借书的;这群人的视频里一定有个漂漂亮亮的手账本,里面到底记了啥你是不太可能看到的。

用户在博主们的影响下,花了成百上千块钱,买了各种各样的产品。然而,却形成了「意识到问题了」「在行动了」,就等于题被解决了的错觉。

这种行为其实很像中世纪西方大受欢迎的「赎罪券」。信徒不用改变自己的行为,甚至不需要告解和悔改,只需要支付金钱就能获得「罪已得赦」的心理确认。钱花出去,药到病除,非常方便。

⬆17 世纪的赎罪券(后世复刻)

这种现象在行为经济学上叫做「道德许可效应」。当人们完成了一件感觉道德的事情之后,更容易在其他方面放纵自己。比如购买 Patagonia 等环保品牌,很容易让你误以为自己真为地球环保做了多大贡献——其实从个体角度,你的不仅助长了消费主义,甚至制造了更多的垃圾。

我们为什么执迷于用消费行为来反抗消费主义,却以为自己还挺聪明?其实,这里面有人格方面的深层原因。

人是注意力动物。无论是手机,还是离线包里装的各种各样的东西,其存在目的都是牵制住人的注意力,帮我们杀掉时间。如果没有这些东西占据注意力,我们会变得极度无聊。

究其根本,在人人都有智能手机、人人都随时在线的时代,我们已经不知道一个人不刷手机该怎么呆着了。如果注意力充裕却没有地方可放,我们甚至会恐慌。所以我们需要一种「干扰」。

而这些数字排毒产品,其实是在利用你的不安全感,来赚你的钱。他们真的解决了什么痛点吗?恐怕没有。

不只是数字排毒产品,所有的消费陷阱,都是同一套底层逻辑。

如果你要用消费行为来反抗消费主义,恐怕只有资本最后成为赢家。

真正有用的方法,免费但无聊

2024 年,斯坦福大学社交媒体实验室让 80 多名学生用「笨手机」替代智能手机使用一周,初步发现受试者的头脑更清晰,更专注,更活在当下。

但进一步调研发现,主要归因并不是「笨手机」,而是那些报名参加了这项研究的学生,原本就有「数字排毒」的动机。

其实真正有效的方法是完全免费的,只是有点无聊。

豆瓣上有个「数字极简主义者」小组,有 3.3 万成员,我们分析总结了里面的很多帖子,发现最主流和有效的方法,就是少用、不用手机。

比如和家人约定晚饭时间不看手机,比如睡前把手机放到卧室,用真的闹钟来定闹铃;非要用的话,可以巧用,比如把社媒 App 移到手机的第三屏,增加「找到它」的摩擦成本;使用的时候大量点击与兴趣无关的内容,故意污染平台「千人千面」的推荐算法。

是的,实现数字排毒,不需要购买任何新的产品,你唯一需要支付的成本,不应该是金钱,而是心智。但你可以一点一点来,比如先从躺到床上就不再用手机开始。

究其根本,你要承受戒断反应,学会与无聊做朋友。

在注意力高度碎片化的今天,其实「无聊」是件很好的事。如果你能领悟无聊的意义,开始享受无聊,进而换种方式利用无聊的时间,「数字排毒」才能真正开始。

无论如何,都不要再买赎罪券了,那都是骗子发明出来骗傻子的。

#欢迎关注爱范儿官方微信公众号:爱范儿(微信号:ifanr),更多精彩内容第一时间为您奉上。

【Mapmost渲染指北】灯光+后处理,一招切出立体感

作者 Mapmost
2026年4月14日 10:51

上篇我们聊了:选对HDRI,让你直接赢在起跑线。可很多同学会发现,场景是亮了,怎么画面还是平淡、缺乏结构感。

别急,本篇就带你拆解“**Mapmost 的灯光 + 后处理”**这套组合拳,让你的场景从“看得清”进阶到“看得爽”。

Mapmost的渲染体系中,灯光负责切除体积感,后处理负责校准全局质感。建议遵循**“环境光 → 平行光 → 后处理”**的线性逻辑,建立起具有真实感的视觉深度。

动图封面

Mapmost中使用环境光 → 平行光 → 后处理流程调整场景

灯光类型

Mapmost渲染中,为了在保证性能的同时获得最佳视觉效果,我们通常组合使用以下两类核心灯光:

环境光

**环境光:**模拟全局光照(Global Illumination)最基础的填充手段。

它没有预设方向,会均匀作用于模型的所有面。在物理渲染中,它承担了模拟光线多次反弹后的“环境余光”角色,主要用于消除场景中的纯黑阴影区域(死黑),确保模型暗部仍有可辨识的纹理细节

// 添加环境光源
let ambientLight = new mapmost.AmbientLight({
    color: '#ffffff',
    intensity: 1
})
map.addLight(ambientLight)
// 删除环境光源
map.removeLight(ambientLight)

Mapmost中环境光的影响范围示意

平行光

**平行光:**模拟极远处的单一光源(如太阳),光线呈平行状态分布。

它的核心作用是建立“主光源方位”,通过与模型表面的夹角,在受光面(如屋顶)与背光面(如建筑侧影)之间切出清晰的明暗边界。这是构建场景立体感的关键。

// 添加平行光源
let directionalLight = new mapmost.DirectionalLight({
    color: '#ffffff',
    intensity: 1,
    position: [0, 0, 1]
})
map.addLight(directionalLight)
// 删除平行光源
map.removeLight(directionalLight)

Mapmost中平行光的影响范围示意

Mapmost里的“光影切割术”

循序渐进:先给底色,再切轮廓

在城市级别的大场景调优中,建议遵循从暗到亮的调试顺序,以避免光照过度叠加导致的过曝。很多人调光时,环境光给得太猛,导致平行光的投影被冲淡,画面变平。

建议先加环境光,调整到暗部没有过黑。然后再开启平行光,重点观察建筑的顶面。如果发现顶面过白,说明平行光强度过高;如果最终场景颜色太脏,则去微调后期参数里的饱和度和对比度。

后处理

当光影架构搭好后,如果画面依然觉得色彩偏灰、色彩不饱和等现象,就需要调用Mapmost的后期调优接口了。这里有三个核心参数决定了最终质感:

  • 伽玛值(Gamma): 神级参数。它能修正显示器导致的色彩发灰问题,让画面色彩立刻变得饱满且真实。
  • **饱和度(Saturation):**负责控制画面色彩的纯度。适度提升饱和度可以弥补环境光对颜色的冲淡,让场景植被与材质显色更加饱满。
  • **对比度(Contrast):**增加对比度能压深暗部、提亮高光,显著增强画面的张力和体积感。

动图封面

后处理进一步优化场景效果

写在最后

HDRI 给了场景氛围底色,灯光系统搭起物理骨架,后期处理则负责最终质感——三位一体,缺一不可。

跟着这一套渲染工作流👇

选择HDRI → 固定曝光 → 循序打灯(环境至平行)→ 后期校色

网页端的3D场景,也能轻松告别“扁平感”,拥有电影级的视觉深度。

至此,Mapmost渲染调优的核心逻辑已拆解完毕,期待你在实战中发掘更多光影可能~🚀

立即体验,开始三维开发之旅!

👉 **点击访问官网免费试用:
**www.mapmost.com/#/productMa…

nestjs实战-登录、鉴权(二)

作者 web_bee
2026年4月14日 10:48

nestjs实战-登录、鉴权(二)

上一章中介绍了登录鉴权分两步:

  • 用户登录过程
  • 登录成功后,带token请求业务接口的过程

用户登录过程已经介绍,接下来介绍一下业务流程中的认证过程

一、业务接口token验证过程

业务接口验证流程:

  • 登录成功后,用户将token存储到本地缓存,每次发送请求时,需要在header,Authentication:[token] 将token带给后端

  • 后端操作:

    • 全局守卫 jwt.guard.ts ,是否进入jwt策略、白名单校验等
    • 触发 JWT 策略:获取jwt、解析、验证等操作
    • 成功后进入 控制器、服务、最终返回数据

二、先看代码实现:

先看一下目录结构:

auth-dir.png

首先需要创建两个文件 jwt.guard.tsjwt.strategy.ts,后面再介绍文件内的实现;

我们希望所有业务代码都需要进行token验证(提供不走验证逻辑的配置),所以 jwt.guard.ts守卫需要全局注册:

App.module.ts

import { APP_GUARD } from '@nestjs/core';
import { JwtGuard } from './modules/auth/guards/jwt.guard';

@Module({
  // ...
  providers: [
    // 全局注册 jwt 守卫,所有业务接口都会走这个守卫
    { provide: APP_GUARD, useClass: JwtGuard },
  ],
})
export class AppModule {}

auth.module.ts

import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
  // ...
  providers: [
    // ...
    JwtStrategy,
  ],
})

在哪里使用 JwtStrategy 呢?

在代码中只看到 jwt.strategy.ts 作为一个提供者,在 auth.module.ts 中被注册;

提供者正常分三步:定义、注册、[注入|使用](类的构造函数constructor中注入),但是 jwt.strategy.ts 只有定义、注册 两部,因为:

之所以在代码里找不到 JwtStrategyconstructor 注入的地方,是因为它的工作方式比较特殊:它是被 NestJS 框架“自动”消费的,而不是被你的业务代码显式注入的

逻辑梳理

我们的登录、注册接口肯定是不需要进行 token 验证的,所以我们这个 jwt 全局守卫还需要一个开关来控制;

还记得我们在之前的章节介绍 拦截器-统一响应数据格式,也有类似的开关控制bypass.decorator.ts 内部逻辑,为类或方法 设置元数据 SetMetadata

此处也是类似的逻辑:

public.decorator.ts

import { SetMetadata } from '@nestjs/common';

export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

auth.controller.ts

为类 设置上 特定的元数据,后续在 守卫中获取对应的值,作为判断逻辑;

// ...
import { Public } from '~/common/decorators/public.decorator';

@Controller('auth')
@Public() // 类下面所有的路由都不需要检验token
export class AuthController {
  constructor(
    private readonly authService: AuthService,
    private readonly usersService: UsersService,
  ) {}

  // 注册
  @Post()
  register(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }

  // 登录
  @Post('login')
  login(@Body() loginDto: LoginDto) {
    return this.authService.login(loginDto);
  }
}

核心逻辑

Jwt.guard.ts
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Observable } from 'rxjs';

import { Reflector } from '@nestjs/core';

import { AuthStrategy } from '../auth.constant';
import { IS_PUBLIC_KEY } from '~/common/decorators/public.decorator';

@Injectable()
export class JwtGuard extends AuthGuard(AuthStrategy.JWT) {

  constructor(private readonly reflector: Reflector) {
    super();
  }

  canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
    // 获取 类、方法上的 元数据
    const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
    
    // 不需要校验token,接口可以直接访问,例如:登录、注册、获取验证码 等接口
    if (isPublic) {
      return true;
    }

    /**
     * super.canActivate(context) 的作用是:调用 @nestjs/passport 里已经实现好的 JWT 认证流程,包括:
     * 1. 从请求中提取 token(通常是 Bearer)
     * 2. 调用对应 JwtStrategy
     * 3. 验证签名、过期时间等
     * 4. 验证通过后把结果挂到 request.user
     * 5. 最后返回 true(通过)或抛异常(401)
     */
    return super.canActivate(context);
  }
}
Jwt.strategy.ts

在守卫触发后,通过守卫内部的 super.canActivate(context) 触发执行此策略,注意策略名称需一致;

import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { AuthStrategy } from '../auth.constant';
import { securityRegToken, ISecurityConfig } from '~/config';
import { ConfigService } from '@nestjs/config';


@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, AuthStrategy.JWT) {
  constructor(
    private readonly configService: ConfigService,
  ) {
    const securityConfig = configService.get<ISecurityConfig>(securityRegToken)

    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: securityConfig.jwtSecret,
    });
  }

  validate(payload: any) {
    console.log('payload', payload);
    return payload;
  }
}

代码部分说明:

  • PassportStrategy:Nest 里把 passport-jwtStrategy 包装成可注入的类。
  • ExtractJwtStrategy:来自 passport-jwt,负责「从请求里拿 JWT」和「验签逻辑」。

  •   export class JwtStrategy extends PassportStrategy(Strategy, AuthStrategy.JWT)
    
    • 第二个参数 AuthStrategy.JWT 是 策略名(一般是 'jwt'),要和 AuthGuard('jwt') / AuthGuard(AuthStrategy.JWT) 一致。
    • Nest 会在需要 JWT 认证时,走这个策略。
  • 构造函数里的 super({...})

    • jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken() 只从 Authorization: Bearer <token> 里取 JWT;没有 Bearer 或格式不对,认证会失败。
    • ignoreExpiration: false 过期 token 会被拒绝;若为 true 则仍可能验签通过(一般不这么配在生产鉴权上)。
    • secretOrKey: securityConfig.jwtSecret 和签发 token 时用的密钥必须一致,否则验签失败。
  •   validate(payload)
    
    • JWT 验签、过期检查通过后,passport-jwt 会把解码后的 payload(一般是 { sub, name, ... })传给 validate
    • 你这里 return payload,表示 request.user 就是整个 payload。
    • 若你希望 req.user 是数据库里的用户对象,通常会在这里根据 payload.sub 查库,再 return user

和请求流程的关系

Guard 触发 JWT 策略 → 从 Header 取 token → 用 jwtSecret 验签 → 调用 validate(payload) → 返回值赋给 request.user → 再进控制器。

三、整个过程的生命周期

当我访问一个业务接口时的执行顺序,例如直接访问 /users 接口:

  1. 全局 Guard 先执行:JwtGuard.canActivate()

  2. JwtGuard 内部调用 super.canActivate(context)AuthGuard('jwt')

  3. 触发 JWT Strategy:JwtStrategy

    • Authorization: Bearer xxx 提取 token
    • 验签、校验过期
    • 调用 JwtStrategy.validate(payload),并把返回值挂到 request.user
    • 以上操作都是库自动帮我们执行的
  4. Guard 通过后,进入 Controller、Service ,最终响应给前端

四、总结

以上就完成熟悉了整个 登录鉴权的过程;

  • 熟悉了守卫的实战场景
  • 熟悉 鉴权相关的逻辑流程,不管是nestjs 还是其他后端语言,这块逻辑是不变的
  • 基于nestjs,熟悉了它的实现流程,各个npm包的作用
❌
❌