普通视图

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

关于 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 给他们增加的是配置复杂度,不是价值。

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…

从 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 风险。

【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包的作用

做中后台业务,为什么我不建议你用 Tailwind CSS?

作者 ErpanOmer
2026年4月14日 10:33

1___f27S-qQF2CAASt5bOwqg.png

大家好,我又来了😁

最近我接手了一个隔壁组转过来的中后台重构项目。

交接的时候,对方的技术负责人特意跟我强调,说这个项目采用了最新的技术栈,全面拥抱了 Tailwind CSS,开发体验极其丝滑。

我当时心里还挺期待,毕竟这两年 Tailwind 的风刮得太大了,各种国内外大佬都在疯狂带货。结果周末我抽空把代码拉下来,打开 VSCode 准备梳理一下业务主流程。盯了屏幕不到十分钟,我感觉自己的眼睛快瞎了。

光说理论没意思,我直接给你们截取一段真实的、承载了各种表单校验和状态联动的业务侧边栏组件。你们自己品鉴一下所谓的极致开发体验👇:

const OrderCard = ({ order, isAdmin, isExpanded }) => {
  return (
    <div 
      className={`flex flex-col w-full p-5 mb-4 border rounded-lg shadow-sm transition-all duration-300 ${
        isAdmin ? 'bg-red-50 border-red-200' : 'bg-white border-gray-200'
      } ${
        isExpanded ? 'max-h-[800px]' : 'max-h-24 overflow-hidden'
      } hover:shadow-md cursor-pointer`}
    >
      <div className='flex items-center justify-between pb-3 mb-3 border-b border-gray-100'>
        <span className='text-sm font-semibold text-gray-800 truncate w-[60%]'>
          {order.id}
        </span>
        <span 
          className={`px-2 py-1 text-xs rounded-full ${
            order.status === 'PAID' 
              ? 'bg-green-100 text-green-700' 
              : 'bg-orange-100 text-orange-700'
          }`}
        >
          {order.statusText}
        </span>
      </div>
      {/* 内部极其复杂的业务字段渲染... */}
    </div>
  );
}

代码跑得通吗?当然跑得通。UI 还原度高吗?也挺高。 但是作为接下来要维护这个项目的组长,我只觉得一阵头皮发麻。

很多前端新人,甚至是做惯了 C 端 独立开发的兄弟,对 Tailwind 简直是顶礼膜拜。因为它不用取名字,不用在 JSCSS 文件之间来回切换,写起来确实有快感。

但作为趟过无数中后台项目的深水区,我今天必须给这股跟风热潮泼一盆冷水: 在绝大多数重型中后台业务场景里,Tailwind CSS 并不是什么神兵利器,而是给后期维护带来不方便。

来,咱们拿真实代码说话,看看它到底是怎么摧毁中后台工程的👇。


彻底掩盖你的业务主线?

做中后台系统,最难的从来不是画 UI,而是处理极度复杂的数据状态流转。

上面那段代码最大的问题在于信噪比极低。作为一个接手代码的前端,我点开这个文件,首先想看的是:这个组件在不同权限、不同展开状态下,业务逻辑是怎么走的?

但在 Tailwind 的体系下,我的视线全被 flexp-5mb-4max-h-[800px] 这种毫无业务价值的视觉原子类给强暴了。如果退回到老古董的 CSS Modules 方案,这段代码在 JS 侧应该长什么样?咱们对比一下:

import classNames from 'classnames';
import styles from './OrderCard.module.less';

const OrderCard = ({ order, isAdmin, isExpanded }) => {
  return (
    <div 
      className={classNames(styles.orderCard, {[styles.adminMode]: isAdmin,
        [styles.expanded]: isExpanded
      })}
    >
      <div className={styles.cardHeader}>
        <span className={styles.orderId}>{order.id}</span>
        <span 
          className={classNames(styles.statusBadge, {
            [styles.statusPaid]: order.status === 'PAID',
            [styles.statusPending]: order.status === 'PENDING'
          })}
        >
          {order.statusText}
        </span>
      </div>
      {/* 业务字段一目了然 */}
    </div>
  );
}

发现区别了吗?重构后的 JSX 变得极其纯粹。我只通过 adminModeexpanded 这种类名,就极其清晰地传达了业务语义。至于那个订单编号到底占百分之几十的宽度,那是 UI 层 该关心的事情,它安静地待在 .less 文件里,绝不会来污染我的业务主逻辑。


跟现成的组件库水土不服

中后台业务是不可能脱离 Ant DesignElement Plus 这类重型组件库的。而组件库的本质,是封装好了一整套内部的 DOM 结构和 ClassName 规范。

这就带来了一个极其致命的冲突:当你用 Tailwind 去覆盖 Ant Design 的内部样式时,你会写出极其恶心的 Hack 代码😖。

比如产品经理要求:在这个特定页面里,把 Ant Design 表格的表头背景色改成浅蓝色,单元格的 padding 改小一点。

正常的 Less 做法是用样式穿透,精确打击:

/* 样式文件覆盖 */
.myCustomTable {
  :deep(.ant-table-thead > tr > th) {
    background-color: #f0f8ff;
    padding: 8px 16px;
  }
}

你知道那个拥抱 Tailwind 的小伙子是怎么写的吗?为了不写 CSS 文件,他强行使用了 Tailwind v3+ 的任意变体语法:

// Tailwind 强行覆盖组件库内部样式
<Table 
  className='[&_.ant-table-thead>tr>th]:bg-blue-50[&_.ant-table-thead>tr>th]:py-2 [&_.ant-table-thead>tr>th]:px-4 [&_.ant-table-tbody>tr>td]:text-gray-600[&_.ant-table-tbody>tr>td]:py-2' 
  dataSource={data} 
  columns={columns} 
/>

这段代码合进主分支的时候,我都替后续维护的兄弟感到悲哀😢。

这玩意儿连换行都没有,密密麻麻挤在一起。未来如果要做主题切换,或者升级 Ant Design 导致内部类名变了,谁敢去动这坨连正则都极难匹配的字符串?

不仅没有提高开发效率,反而为了强行凑 Tailwind 的语法,写出了一堆极难维护的代码。


负边距引发的问题

一行代码被写出来的成本,远远低于它在未来三年里被维护的成本。

Tailwind 本质上就是披着 ClassName 外衣的行内样式。它把所有的样式固化在了 HTML 结构上。

设想真实的维护场景,前任开发为了让一个按钮和旁边的输入框对齐,极其随意地写了一个向左偏移负间距的类名:

<Button className='-ml-2 mt-1'>提交</Button>

半年后你接手这个需求,产品要求在这俩元素中间加一个 Icon。你看着这个 -ml-2mt-1 会陷入极其痛苦的挣扎:他当时为什么要写负边距?是因为外层父元素加了错的 padding 导致的?还是为了抵消 Button 内部自带的 margin?🤷‍♂️

在传统 CSS 中,我们往往会留有注释说明抵消输入框自带的右侧留白。但在 Tailwind 里,没有注释的容身之所。

为了保证不出线上 Bug,你绝对不敢删掉那个 -ml-2。你会选择在它后面再打个补丁,加个 pl-4 试图把它顶回来。 第二年,另一个接手的同事遇到了错位,又在后面补了一个 mt-[-5px]🤣🤣🤣。

日积月累,HTML 标签上的类名越来越长,死代码和冲突代码全堆在 DOM 上,最终变成一座没人敢碰的屎山💩。


别瞎搞,先认清你的场景🫡

说了这么多,难道 Tailwind 真的就是垃圾吗?当然绝对不是。

如果你在做偏 C端 的炫酷落地页、做 SaaS 官网,或者你是独立开发者,没有沉重的历史包袱,不需要配合复杂的重型组件库,那 Tailwind 绝对是神作。它自带极其优秀的设计规范,能让你极速堆出好看的界面。

但咱们讨论的是中后台。中后台是干嘛的?团队十几个人来回交接,动辄几百个页面,充斥着极其复杂的表单联动和权限校验,生命周期长达五年甚至十年。

在这种重型项目中,保持业务逻辑的纯粹性,分离关注点,远比你少写几行 CSS 要重要一万倍。

好了,今天分享到这,谢谢大家😁

谢谢大家.gif

RainbowKit快速集成多链钱包连接:从“一键连接”到“多链切换”的实战踩坑

作者 竹林818
2026年4月14日 10:02

背景

上个月,我接手了一个多链DeFi聚合器前端项目的迭代。这个项目需要让用户能在同一个界面里,无缝操作他们在 Ethereum Mainnet、Polygon、Arbitrum 甚至 Optimism 上的资产。产品经理的原话是:“我们要让用户感觉像是在操作一个统一的账户,而不是在几个不同的区块链之间来回横跳。”

第一版的前端用的是比较原始的方案:自己用 ethers.js 写连接逻辑,然后手动管理不同网络的 Provider 和 Signer。代码里到处都是 if (chainId === 1) { ... } else if (chainId === 137) { ... },维护起来简直是噩梦。更头疼的是钱包切换网络时的状态同步问题,经常出现 UI 上显示的是 Polygon,但实际请求发到了 Ethereum 的 RPC 节点上。

所以这次重构,我的核心目标就是找一个能“优雅”处理多链连接和状态管理的库。社区里不少人推荐 RainbowKit + wagmi 的组合,号称是“React 生态下连接钱包的最佳实践”。我心想,这应该能省不少事吧?事实证明,我还是太天真了。

问题分析

我一开始的想法很简单:照着 RainbowKit 官方文档,安装、配置、把 <ConnectButton /> 一扔,不就完事了吗?文档里那个“Get Started in 5 minutes”的标语看着特别诱人。

我快速搭了个 demo:

npm install @rainbow-me/rainbowkit wagmi viem

然后按照指南配置了 WagmiProviderRainbowKitProvider,只加了一个 Ethereum Mainnet 的配置。跑起来一看,连接 MetaMask 确实没问题,按钮漂亮,弹窗也专业。

但问题马上就来了: 当我想让用户切换到 Polygon 网络时,我发现 RainbowKit 自带的网络切换器(就是那个点击连接按钮后下拉菜单里的“Switch Network”选项)里,只有我配置的 Ethereum。我明明需要支持多条链啊!

我第一反应是:“是不是配置里没加其他链?” 翻回文档仔细看,发现 wagmi 的 configureChains 函数需要传入一个链的数组。我原来只传了 [mainnet],于是赶紧加上了 polygon 和 arbitrum。

import { mainnet, polygon, arbitrum } from 'wagmi/chains';
const { chains, publicClient, webSocketPublicClient } = configureChains(
  [mainnet, polygon, arbitrum], // 这里!把需要的链都放进去
  [publicProvider()]
);

改完重启,网络切换器里果然出现了三个网络选项。我兴致勃勃地点了“Polygon”,MetaMask 弹窗提示我切换网络,我点了“批准”。然后……页面没有任何变化。钱包扩展显示网络已经切到了 Polygon,但我的应用 UI 上,当前网络仍然显示为“Ethereum”,而且发起的 RPC 请求还是指向 Ethereum 的节点。

这就是我遇到的第一个核心问题:链的配置加进去了,但 wagmi 的客户端状态(chainId, account)与钱包的实际状态没有自动同步。 我需要手动去“监听”钱包的网络切换事件,并更新到 wagmi 的 store 里。这显然不是“开箱即用”该有的样子。

核心实现

第一步:配置正确的多链客户端

我意识到,光配置 chains 不够,还得让 wagmi 的客户端能正确响应钱包的网络切换。关键在于 createConfig 时传入的 publicClientwebSocketPublicClient 函数。这个函数会根据当前的 chainId 返回对应链的客户端。

这里有个坑:如果你用了像 publicProvider() 这样的公共提供商,它可能对某些链(特别是 Layer2 或测试网)的支持不稳定或速度慢。 为了更好的体验,最好为每条链配置一个可靠的 RPC 端点,比如来自 Infura 或 Alchemy 的。

我的改进方案是使用 jsonRpcProvider 来为每条链指定专属的 RPC URL。同时,我学会了要用 chain.id 作为判断条件,动态返回客户端。

import { configureChains, createConfig } from 'wagmi';
import { publicProvider } from 'wagmi/providers/public';
import { jsonRpcProvider } from 'wagmi/providers/jsonRpc';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';

const { chains, publicClient, webSocketPublicClient } = configureChains(
  [mainnet, polygon, arbitrum],
  [
    jsonRpcProvider({
      rpc: (chain) => {
        // 为每条链配置独立的 RPC URL
        if (chain.id === mainnet.id) {
          return { http: `https://eth-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_KEY` };
        }
        if (chain.id === polygon.id) {
          return { http: `https://polygon-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_KEY` };
        }
        if (chain.id === arbitrum.id) {
          return { http: `https://arb-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_KEY` };
        }
        // 对于没有配置的链,回退到公共提供商(可选)
        return null;
      },
    }),
    // 可以加一个 publicProvider 作为后备,增加鲁棒性
    publicProvider(),
  ]
);

const wagmiConfig = createConfig({
  autoConnect: true, // 这个很重要,允许页面刷新后自动重连
  publicClient,
  webSocketPublicClient,
});

第二步:处理网络切换与状态同步

配置好了客户端,但网络切换后状态不同步的问题还在。经过一番搜索和阅读源码,我发现需要正确使用 wagmi 提供的 hooks 来获取和监听网络状态。

核心是 useAccountuseNetwork 这两个 hook。useAccount 可以拿到连接账户的地址和连接状态,而 useNetwork 能拿到当前激活的链(chain)以及一个 switchNetwork 函数。

这里注意一个细节: useNetwork 返回的 chain 对象,代表的是 wagmi 内部当前记录的活动链。它应该和钱包扩展里显示的链保持一致。如果不一致,说明状态同步有问题。

我写了一个自定义的 NetworkStatus 组件来展示和调试状态:

import { useAccount, useNetwork } from 'wagmi';

export function NetworkStatus() {
  const { address, isConnected } = useAccount();
  const { chain } = useNetwork();

  return (
    <div>
      <p>连接状态: {isConnected ? '已连接' : '未连接'}</p>
      <p>账户地址: {address}</p>
      <p>当前网络: {chain?.name ?? '未知'}</p>
      <p>链ID: {chain?.id ?? 'N/A'}</p>
    </div>
  );
}

把这个组件放到页面上后,我发现当我从钱包扩展里切换网络时,这个组件显示的网络信息会延迟几秒,但最终会更新到正确的链。这说明 wagmi 的监听机制是工作的,只是有延迟。对于用户体验来说,这个延迟可能有点长。

第三步:实现主动网络切换与错误处理

产品要求用户能在我们的应用内直接点击一个按钮就切换到目标网络,而不是非得去点 RainbowKit 连接按钮的下拉菜单。这就需要用到 useNetwork 返回的 switchNetwork 函数。

我封装了一个 ChainSwitcher 组件:

import { useNetwork } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';

export function ChainSwitcher() {
  const { chain, chains, switchNetwork } = useNetwork();

  const handleSwitch = async (targetChainId: number) => {
    if (!switchNetwork) {
      console.error('switchNetwork 函数不可用');
      return;
    }
    try {
      await switchNetwork(targetChainId);
      // 切换成功后的逻辑(比如显示一个成功提示)可以在这里处理
      console.log(`已切换到链ID: ${targetChainId}`);
    } catch (error) {
      // 这里是个大坑!错误处理非常重要。
      console.error('切换网络失败:', error);
      // 常见的错误:用户拒绝了切换请求,或者钱包不支持该网络。
      // 对于不支持的链,你可能需要提示用户手动添加网络。
    }
  };

  return (
    <div>
      <p>当前网络: {chain?.name}</p>
      <button onClick={() => handleSwitch(mainnet.id)} disabled={chain?.id === mainnet.id}>
        切换到 Ethereum
      </button>
      <button onClick={() => handleSwitch(polygon.id)} disabled={chain?.id === polygon.id}>
        切换到 Polygon
      </button>
      <button onClick={() => handleSwitch(arbitrum.id)} disabled={chain?.id === arbitrum.id}>
        切换到 Arbitrum
      </button>
    </div>
  );
}

这里有个至关重要的坑: switchNetwork 函数可能会失败,而且失败原因多种多样。最常见的是用户在小狐狸钱包弹窗里点了“拒绝”。但还有一种隐晦的情况:如果用户的钱包(比如某些移动端钱包)里根本没有配置你目标链的网络信息,switchNetwork 调用会静默失败或者抛出难以解析的错误。

为了解决“钱包未添加该网络”的问题,wagmi v1 有一个 addNetwork 动作,但在更现代的版本中,更常见的做法是准备好网络的添加参数,在 switchNetwork 失败时(尤其是捕捉到特定的错误码,如 4902),引导用户或自动调用钱包的 wallet_addEthereumChain RPC 方法。RainbowKit 和 wagmi 的内部逻辑其实已经处理了部分这种情况,但了解其原理对于调试很有帮助。

第四步:与 RainbowKit 组件深度集成

虽然我自己写了切换按钮,但 RainbowKit 自带的那个连接按钮和下拉菜单仍然是我的主要 UI。我需要确保它的行为符合预期。

RainbowKit 的 <ConnectButton /> 组件有一个 chainStatus 属性,可以控制链状态信息的显示方式。我发现在多链环境下,设置为 "icon""full" 体验更好,用户能一眼看到当前是哪个网络。

import { ConnectButton } from '@rainbow-me/rainbowkit';

function MyApp() {
  return (
    <div>
      <ConnectButton chainStatus="icon" /> {/* 显示网络图标 */}
      {/* 或者 */}
      <ConnectButton chainStatus="full" /> {/* 显示网络名称 */}
    </div>
  );
}

另外,RainbowKit 的模态框(即点击连接按钮后弹出的那个窗口)里显示的可选网络列表,完全来源于你传给 RainbowKitProviderchains 属性。这和我之前配置 wagmi 的 chains 是同一个数组,确保了一致性。

import { RainbowKitProvider } from '@rainbow-me/rainbowkit';
import '@rainbow-me/rainbowkit/styles.css'; // 别忘了引入样式!

function App({ children }) {
  return (
    <WagmiProvider config={wagmiConfig}>
      <RainbowKitProvider chains={chains}> {/* 就是这里! */}
        {children}
      </RainbowKitProvider>
    </WagmiProvider>
  );
}

完整代码

下面是一个整合了以上所有要点的、可运行的 App.tsx 示例:

import React from 'react';
import { configureChains, createConfig, WagmiProvider, useAccount, useNetwork } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { publicProvider } from 'wagmi/providers/public';
import { jsonRpcProvider } from 'wagmi/providers/jsonRpc';
import { RainbowKitProvider, ConnectButton, lightTheme } from '@rainbow-me/rainbowkit';
import '@rainbow-me/rainbowkit/styles.css';

// 1. 配置多链和提供商
const { chains, publicClient, webSocketPublicClient } = configureChains(
  [mainnet, polygon, arbitrum],
  [
    jsonRpcProvider({
      rpc: (chain) => {
        // 替换为你自己的 RPC 端点,这里用公共端点示例
        if (chain.id === mainnet.id) return { http: 'https://ethereum.publicnode.com' };
        if (chain.id === polygon.id) return { http: 'https://polygon-rpc.com' };
        if (chain.id === arbitrum.id) return { http: 'https://arb1.arbitrum.io/rpc' };
        return null;
      },
    }),
    publicProvider(),
  ]
);

// 2. 创建 wagmi 配置
const wagmiConfig = createConfig({
  autoConnect: true,
  publicClient,
  webSocketPublicClient,
});

// 3. 自定义网络状态显示组件
function NetworkStatus() {
  const { address, isConnected } = useAccount();
  const { chain } = useNetwork();
  return (
    <div style={{ border: '1px solid #ccc', padding: '1rem', margin: '1rem 0' }}>
      <h3>网络状态</h3>
      <p>连接: {isConnected ? '是' : '否'}</p>
      <p>地址: {address ? `${address.slice(0, 6)}...${address.slice(-4)}` : '无'}</p>
      <p>网络: {chain?.name || '未知'}</p>
      <p>链ID: {chain?.id || 'N/A'}</p>
    </div>
  );
}

// 4. 自定义链切换器组件
function ChainSwitcher() {
  const { chain, switchNetwork } = useNetwork();
  const targetChains = [mainnet, polygon, arbitrum];

  return (
    <div style={{ margin: '1rem 0' }}>
      <h3>快速切换网络</h3>
      {targetChains.map((targetChain) => (
        <button
          key={targetChain.id}
          onClick={() => switchNetwork?.(targetChain.id)}
          disabled={!switchNetwork || chain?.id === targetChain.id}
          style={{ marginRight: '0.5rem', padding: '0.5rem 1rem' }}
        >
          切换到 {targetChain.name}
        </button>
      ))}
      {!switchNetwork && <p style={{ color: 'orange' }}>请先连接钱包以切换网络</p>}
    </div>
  );
}

// 5. 主应用组件
function AppContent() {
  return (
    <div style={{ maxWidth: '800px', margin: '2rem auto', fontFamily: 'sans-serif' }}>
      <h1>RainbowKit 多链集成 Demo</h1>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }}>
        <h2>钱包连接</h2>
        <ConnectButton chainStatus="full" />
      </div>

      <NetworkStatus />
      <ChainSwitcher />

      <div style={{ marginTop: '2rem', padding: '1rem', backgroundColor: '#f5f5f5' }}>
        <h3>功能说明</h3>
        <ul>
          <li>点击右上角按钮连接钱包(支持 MetaMask, Coinbase Wallet 等)。</li>
          <li>连接后,“网络状态”区域会显示地址和当前网络。</li>
          <li>使用“快速切换网络”按钮或 RainbowKit 按钮下拉菜单中的“Switch Network”来切换链。</li>
          <li>切换时请留意钱包扩展的确认弹窗。</li>
        </ul>
      </div>
    </div>
  );
}

// 6. 根组件,注入 Providers
export default function App() {
  return (
    <WagmiProvider config={wagmiConfig}>
      <RainbowKitProvider
        chains={chains}
        theme={lightTheme({
          accentColor: '#3B82F6',
          borderRadius: 'medium',
        })}
      >
        <AppContent />
      </RainbowKitProvider>
    </WagmiProvider>
  );
}

踩坑记录

  1. switchNetwork 静默失败(用户拒绝):最初我的切换按钮点击后,如果用户在钱包弹窗里点了拒绝,页面没有任何反馈。这非常糟糕。解决方法:用 try...catch 包裹 switchNetwork 调用,并在 catch 块中给用户友好的提示(例如使用 toast 通知)。
  2. RPC 端点不可用或限速:一开始用了免费的公共 RPC,在 Polygon 上经常请求失败或超时,导致余额查询等功能出错。解决方法:申请 Alchemy 或 Infura 的免费层服务,为每条链配置专属的 RPC URL,稳定性和速度大幅提升。
  3. 页面刷新后连接状态丢失:虽然设置了 autoConnect: true,但有时刷新后钱包还是显示未连接。排查发现autoConnect 依赖于钱包提供商和本地存储的会话缓存,有些钱包(特别是移动端钱包应用)可能不支持或行为不一致。缓解方案:在 UI 上做好“未连接”状态的引导,始终显示连接按钮。
  4. RainbowKit 主题与项目样式冲突:RainbowKit 的模态框样式有时会被项目全局 CSS 覆盖,导致布局错乱。解决方法:确保 import '@rainbow-me/rainbowkit/styles.css'; 语句放在项目全局样式之后,或者使用 CSS-in-JS 方案提高样式优先级。也可以直接使用 theme 属性深度定制,覆盖其 CSS 变量。

小结

这次折腾让我明白,RainbowKit 提供的确实是“快速入门”的便利,但真要实现生产级可用的多链体验,必须深入理解其底层依赖 wagmi 的状态管理机制,并亲手处理好网络切换的边界情况和错误。现在,我的聚合器前端终于有了一个稳定、用户友好的多链钱包连接底座,我可以把更多精力放在业务逻辑上了。下一步,我打算深入研究 wagmi 的 useContractReaduseContractWrite 如何与多链场景结合,实现真正的跨链合约调用。

antdv-next/x:面向 Vue 的 AI 组件体系

作者 carl_chen
2026年4月14日 09:35

写在前面

antdv-next/x 的核心价值,是让 Ant Design X 的源设计体系在 Vue 中可复用、可扩展、可落地。

如果你正在做 AI 产品,这意味着你不用从零搭一套“聊天+生成+引用+反馈”的界面体系,也不用在一致性和开发效率之间反复取舍。

antdv-next/x 把这些高频能力沉淀成可复用的 Vue 组件,让团队可以更快上线、更稳迭代。

为什么现在需要它?

传统组件库解决的是通用页面问题,但 AI 产品面临的是另一套体验挑战:

  • 回答要流式呈现,状态要可感知
  • 输入不只是文本,还包括 Prompt 组织与附件
  • 多轮会话需要上下文切换与管理
  • 输出结果需要复制、重试、反馈、引用溯源

这些能力如果每个项目都重写一遍,代价会非常高:

  • 开发周期被拉长
  • 交互风格难统一
  • 后续维护与扩展成本持续上升

antdv-next/x 带来的价值

1. 更快落地 AI 界面

开箱即用的 Vue 3 AI 组件,减少重复造轮子,把时间投入到业务差异化能力上。

2. 更统一的产品体验

提炼自 Ant Design X 的交互语言与视觉风格,并与 antdv-next 体验协同,降低“页面像拼起来的”割裂感。

3. 更灵活的场景扩展

不仅能做 Chat,还能覆盖 Agent 任务流、知识问答、附件处理、推理过程展示等复合场景。

4. 更稳的工程基础

内建 Markdown 增强渲染能力,支持流式渲染、公式、代码高亮、Mermaid;同时提供 TypeScript 类型支持,并兼容 SSR 与 Electron。

设计取向:融合,而非照搬

antdv-next/x 不是 React 方案的机械迁移,而是面向 Vue 生态的原生化实现:

  • 保留 Vue 的表达习惯(Slots、Composition API)
  • 强化可定制渲染,适应 AI 场景快速变化
  • 对齐 antdv-next 的主题与开发体验

适合谁用?

  • 已经基于 antdv-next 开发业务系统的团队
  • 正在搭建 AI 助手、Copilot、问答、Agent 类产品的团队
  • 需要“快速上线 + 长期可维护”并重的团队

写在最后

如果你希望在 Vue 生态里,以更低成本交付高质量 AI 界面,antdv-next/x 是一条非常务实的路径。

npm install @antdv-next/x

欢迎试用,也欢迎在 GitHub 提 Issue 一起共建。

从“谁调用指向谁”到“手写Bind源码”,彻底搞懂JavaScript的this机制

作者 荒野码农
2026年4月14日 08:31

JavaScript中的this,大概是前端圈子里最让人“又爱又恨”的概念了。爱它,是因为它带来了极大的灵活性,让函数可以随意“借用”;恨它,是因为它像个变色龙,指哪打哪,稍不留神就指向了全局对象window,让你对着undefined发呆。

很多开发者背熟了“谁调用指向谁”的口诀,但遇到复杂场景依然会翻车。今天,我们不妨跳出死记硬背的怪圈,像面试一样,把这个“磨人的小妖精”彻底看透。

核心机制:this的“四象限”法则(附权威出处)

首先,我们要纠正一个直觉上的误区:this的指向,不取决于函数定义在哪里,而完全取决于函数被调用的方式

如果把JavaScript的执行上下文比作一个舞台,那么this就是舞台上的聚光灯。灯打在哪里,取决于导演(调用者)怎么安排,而不是演员(函数)站在哪。

** 官方出处在哪里?**
这些规则并不是社区总结的“野路子”,而是有着严格的官方定义。在 ECMAScript 规范(ECMA-262)中,有一个核心抽象操作叫做 OrdinaryCallBindThis 以及 ResolveThisBinding

规范中明确定义了 this 绑定的优先级逻辑,简单来说就是:

  1. 箭头函数:继承外层作用域的 this(词法作用域)。
  2. new 绑定:通过 `` 内部方法创建新对象并绑定 this
  3. 显式绑定:通过 `` 内部方法中的 thisArgument 强制指定。
  4. 隐式/默认绑定:作为兜底规则,依据调用位置决定。

我们可以把这复杂的规范总结为以下四种(优先级由高到低):

1. new绑定(优先级最高)
当你使用new关键字调用函数时,JavaScript引擎会执行一系列操作:创建一个新对象、将原型连接、执行构造函数,并最终将this绑定到这个新创建的对象上。

function Person(name) {
  this.name = name; // this 指向新实例
}
const p = new Person('Jack');

2. 显式绑定(call/apply/bind)
这是最直接的手段,你明确告诉JS引擎:“嘿,这个函数执行的时候,this必须是这个对象。”

  • call:立即执行,参数逐个传入。
  • apply:立即执行,参数以数组形式传入。
  • bind:不立即执行,返回一个this被永久锁定的新函数。

3. 隐式绑定(上下文调用)
当函数作为对象的方法被调用时(如obj.foo()),this指向该对象。这是最符合直觉的,但也是最容易出问题的(后面会讲)。

4. 默认绑定(兜底规则)
如果以上都不是,那么就是独立函数调用。

  • 非严格模式:this指向全局对象(浏览器中是window)。
  • 严格模式('use strict'):thisundefined

深度解析:bind的“霸道”与call的“临时”

在显式绑定中,bind 是一个非常特殊的存在。很多开发者知道它能改变 this,但往往忽略了它的不可篡改性

bind 的绑定是“一锤子买卖”。

当你使用 bind 创建了一个新函数后,这个新函数的 this 就被永久锁定了。后续无论你如何使用 callapply 甚至再次 bind,都无法改变它第一次被绑定的 this 指向。

const obj1 = { name: '张三' };
const obj2 = { name: '李四' };

function sayHi() {
  console.log(`你好,我是 ${this.name}`);
}

// 1. 使用 bind 创建一个新函数,this 永久锁定为 obj1
const boundSayHi = sayHi.bind(obj1);

// 2. 尝试用 call 强行修改 this 为 obj2
boundSayHi.call(obj2); 
// 输出: "你好,我是 张三"  <-- 这里的 this 依然是 obj1,call 失效了!

// 3. 尝试再次 bind 修改为 obj2
const doubleBound = boundSayHi.bind(obj2);
doubleBound();
// 输出: "你好,我是 张三"  <-- 再次 bind 也无效,第一次绑定才是王道

为什么 bind 这么“霸道”?
这其实是由 bind 的实现机制决定的。当调用 func.bind(obj) 时,JavaScript 引擎返回了一个全新的闭包函数。这个新函数内部保存了对 obj 的引用,并且硬编码了执行逻辑。当你调用这个“绑定函数”时,它内部会直接执行类似 原函数.apply(被锁定的obj, 参数) 的逻辑。

你在外部再怎么折腾(用 callapply),都穿透不了这层“保护壳”。

唯一的例外:new
虽然 bind 锁死了 this,但如果你用 new 来调用这个被 bind 过的函数,new 的优先级更高,this 会指向新生成的实例,而不是 bind 锁定的对象。

灵魂拷问:为什么要设计得这么复杂?

你可能会问:为什么不像Java那样,this永远指向实例对象呢?

这要追溯到JavaScript诞生的那个“疯狂的十天”。Brendan Eich在设计JS时,为了让这门语言既轻量又灵活,引入了这种动态绑定机制。

1. 历史背景与执行机制
JavaScript的变量查找(自由变量)是静态的,遵循词法作用域,看的是“函数写在哪”;而this的绑定是动态的,属于执行上下文,看的是“函数怎么调”。这种设计让JS在诞生之初就具备了极强的“函数借用”能力。

2. 鸭子类型与灵活性
这种机制让JS实现了著名的“鸭子类型”——只要一个对象长得像鸭子(有length属性,有索引),走起来像鸭子,那它就能被当成鸭子用。这为后来的“借用方法”奠定了基础。

3. 错误的修正
当然,这种设计也有副作用。在非严格模式下,独立函数调用会导致this默认指向window,极易造成全局变量污染。ES5引入的严格模式,以及ES6引入的箭头函数,都是为了解决这些“历史遗留问题”。

进阶玩法:万物皆可“借”

理解了this的动态本质,我们就能解锁JavaScript的高级用法。

案例:Array.prototype.slice.call(arguments)
这是前端面试中的经典考题,也是老代码中常见的“魔术”。
arguments是一个类数组对象,它有length和索引,但没有数组的方法(如slicemap)。
但是,Array.prototype.slice函数内部并不检查this是不是真正的数组,它只关心this有没有length和索引。

于是,我们通过call,强行把slice函数内部的this指向了arguments

function logArgs() {
  // 借用数组的 slice 方法,将 arguments 转为真数组
  const args = Array.prototype.slice.call(arguments);
  console.log(args);
}

这就是“反柯里化”的雏形:把原本属于特定对象的方法,解放出来,变成一个通用的函数。

终极武器:箭头函数

ES6的箭头函数是this机制的一个“特例”。它没有自己的this,它的this继承自定义时所在的词法作用域。

这意味着:

  • 它不受调用方式影响。
  • 它无法被callapplybind修改。
  • 它完美解决了回调函数中this丢失的问题。
const obj = {
  name: 'Tom',
  sayHi: function() {
    setTimeout(() => {
      // 这里的 this 继承自 sayHi 的 this,即 obj
      console.log(this.name);
    }, 1000);
  }
};

硬核原理:手写源码大揭秘

为了彻底理解 this 的底层机制,我们不仅要会用,还要能手写。下面我们将亲手实现 callapplybindnew,看看它们内部到底是如何操作 this 的。

1. 手写 myCall
call 的核心逻辑是:将函数作为对象的一个临时属性,然后通过该对象调用这个函数,最后删除这个临时属性。

Function.prototype.myCall = function(context, ...args) {
  // 1. 处理 context,默认指向 window (浏览器环境)
  // 如果传入 null 或 undefined,也指向 window
  context = context || window;
  
  // 2. 创建一个唯一的属性名 (Symbol),防止覆盖 context 上原有的同名属性
  const fnSymbol = Symbol('fn');
  
  // 3. 将当前函数(即调用 myCall 的函数,通过 this 指向)赋值给 context
  context[fnSymbol] = this;
  
  // 4. 执行这个函数。此时函数内的 this 就指向了 context,并传入参数 args
  const result = context[fnSymbol](...args);
  
  // 5. 删除临时属性,保持 context 的“洁净”
  delete context[fnSymbol];
  
  // 6. 返回函数的执行结果
  return result;
};

2. 手写 myApply
applycall 几乎一样,唯一的区别是参数接收形式不同(数组形式)。

Function.prototype.myApply = function(context, argsArray) {
  // 1. 处理 context,默认指向 window
  context = context || window;
  
  // 2. 创建唯一属性名
  const fnSymbol = Symbol('fn');
  
  // 3. 将当前函数赋值给 context
  context[fnSymbol] = this;
  
  let result;
  // 4. 核心区别:判断参数是否为数组,并决定是否展开
  if (Array.isArray(argsArray)) {
    // 如果是数组,就展开参数调用
    result = context[fnSymbol](...argsArray);
  } else {
    // 如果没有参数或参数不是数组,直接调用
    result = context[fnSymbol]();
  }
  
  // 5. 删除临时属性
  delete context[fnSymbol];
  
  // 6. 返回执行结果
  return result;
};

3. 手写 myBind
bind 稍微复杂一点,它需要返回一个函数(闭包),并且要处理 new 的情况。

Function.prototype.myBind = function(context, ...bindArgs) {
  const originalFunc = this; // 1. 保存调用 bind 的原函数
  
  // 2. 返回一个新的函数(闭包)
  const boundFunc = function(...callArgs) {
    // 3. 判断是否通过 new 调用 boundFunc
    // 如果是 new 调用,this 指向新创建的实例;否则指向 bind 传入的 context
    const isNew = this instanceof boundFunc;
    const finalThis = isNew ? this : context;
    
    // 4. 合并参数:bind 时传入的参数 + 调用时传入的参数 (实现柯里化)
    const finalArgs = [...bindArgs, ...callArgs];
    
    // 5. 使用 apply 执行原函数,并传入合并后的 this 和参数
    return originalFunc.apply(finalThis, finalArgs);
  };
  
  // 6. 维护原型链,确保 new boundFunc() 时能继承原函数的原型
  boundFunc.prototype = Object.create(originalFunc.prototype);
  
  // 7. 返回这个新的绑定函数
  return boundFunc;
};

4. 手写 myNew
new 操作符其实做了四件事,我们把它翻译成代码:

function myNew(constructor, ...args) {
  // 1. 创建一个全新的空对象
  const obj = {};
  
  // 2. 将这个新对象的原型 () 指向构造函数的 prototype 属性
  // 这样新对象就能访问构造函数原型上的方法
  Object.setPrototypeOf(obj, constructor.prototype);
  
  // 3. 执行构造函数,并将函数内的 this 绑定到新创建的对象 obj 上
  const result = constructor.apply(obj, args);
  
  // 4. 判断构造函数的返回值:
  // 如果构造函数显式返回了一个对象,则 new 表达式的结果就是这个对象
  // 否则,返回新创建的 obj
  return (typeof result === 'object' && result !== null) ? result : obj;
}

通过手写这些代码,你会发现 this 的魔法其实就是属性赋值函数调用的组合拳。

实战演练:几道例题检验你的理解

光说不练假把式,来看几道经典的面试题,看看你是否真的掌握了 this 的奥义。

例题 1:多次 bind 的“套娃”游戏

function foo() {
  console.log(this.a);
}

const obj1 = { a: 1 };
const obj2 = { a: 2 };
const obj3 = { a: 3 };

const bar = foo.bind(obj1).bind(obj2).bind(obj3);
bar(); // 输出什么?

输出:1

解析:正如前文所述,bind 是硬绑定,且不可篡改。第一次 foo.bind(obj1) 返回的新函数已经将 this 锁定为 obj1。后续的 .bind(obj2).bind(obj3) 只是在包装这个已经锁定的函数,无法改变其内部的 this 指向。

例题 2:new 与 bind 的巅峰对决

function Person(name) {
  this.name = name;
}

const obj = { name: 'GlobalObj' };
const BoundPerson = Person.bind(obj);

const p = new BoundPerson('Jack');
console.log(p.name); // 输出什么?
console.log(obj.name); // 输出什么?

输出:'Jack', 'GlobalObj'

解析:虽然 bind 锁定了 this,但 new 的优先级更高。当使用 new BoundPerson() 时,JS 引擎会创建一个新的空对象,并忽略 bind 锁定的 obj,将 this 指向这个新实例。因此 p.name 是 'Jack',而原 obj 未受影响。

例题 3:箭头函数的“顽固”

const obj = {
  name: 'Tom',
  arrowFn: () => {
    console.log(this.name);
  }
};

const anotherObj = { name: 'Jerry' };
obj.arrowFn.call(anotherObj); // 输出什么?

输出:undefined (或 window.name)

解析:箭头函数没有自己的 this,它继承自定义时的外层作用域(在这里是全局作用域)。因此,无论你用 call 试图把它指向谁,它都“油盐不进”,依然指向定义时的 window(非严格模式下)。

例题 4:setTimeout 的“隐式丢失”

const user = {
  name: 'Jack',
  sayName: function() {
    console.log(this.name);
  }
};

setTimeout(user.sayName, 1000); // 输出什么?

输出:undefined (或 window)

解析:这是最经典的陷阱。setTimeout 接收的是一个函数引用 user.sayName。当定时器触发时,这个函数是作为独立函数被调用的(相当于 window.sayName()),发生了隐式丢失,this 指向了全局对象。
修正:使用箭头函数 setTimeout(() => this.sayName(), 1000)setTimeout(user.sayName.bind(user), 1000)

例题 5:DOM 事件中的“指向变动”

<button id="btn">点击我</button>
<script>
const btn = document.getElementById('btn');
btn.onclick = function() {
  console.log(this); // 输出什么?
}
</script>

输出:<button id="btn">...</button>

解析:在 DOM 事件处理函数中,this 通常指向触发事件的 DOM 元素。这是浏览器环境的特殊规则(属于隐式绑定的一种变体)。但如果你把事件处理函数写成箭头函数,this 就会指向定义时的 window,导致无法获取按钮元素。

避坑指南与总结

在实际开发中,我们还需要注意class语法下的陷阱。

  • 普通方法:定义在原型上,this动态绑定,可以借用。
  • 箭头函数属性:定义在实例上,this在定义时锁死,无法借用。

最后,送大家一张this绑定的优先级天梯图,助你彻底告别this困惑:

箭头函数(词法作用域) > new绑定 > bind硬绑定 > call/apply显式绑定 > 隐式绑定 > 默认绑定

理解this,本质上就是理解JavaScript的执行上下文和原型链机制。希望这篇文章能帮你把这块知识拼图完整地拼好!


程序员黑话

  • 箭头函数是“子承父业”,生来就随爹。
  • bind是“卖身契”,一签终身,除非你“重生”(new)。
  • call/apply是“临时工”,用完即走,不留痕迹。
  • 普通函数是“墙头草”,谁调用它就倒向谁。
  • new是“创世神”,它说要有this,于是就有了新对象。

React Router 实战指南:构建现代化前端路由系统

作者 Lee川
2026年4月13日 23:34

React Router 实战指南:构建现代化前端路由系统

一、前端路由的演进与重要性

在 Web 开发的早期,路由完全由后端控制,前端开发人员主要负责"切图"和静态页面制作。随着前后端分离架构的兴起,前端路由成为了现代单页应用(SPA)的核心组件,它使得前端能够独立管理页面导航,提供更流畅的用户体验。

React Router 作为 React 生态系统中最流行的路由解决方案,为我们提供了一套完整的路由管理工具,让我们能够轻松构建复杂的单页应用。

二、React Router 的两种实现方式

React Router 提供了两种主要的路由实现方式,各有其特点和适用场景:

HashRouter:兼容性优先

  • URL 格式:使用 # 符号作为路由分隔符,如 http://example.com/#/about
  • 实现原理:基于浏览器的锚点机制,通过监听 window.location.hash 的变化来触发路由更新
  • 优势
    • 兼容性极佳,支持所有现代浏览器
    • 无需服务器端配置,部署简单
    • 适合静态网站托管(如 GitHub Pages)
  • 劣势:URL 中包含 #,视觉上不够美观,不符合 RESTful 设计规范

BrowserRouter:现代性优先

  • URL 格式:使用标准的 URL 路径,如 http://example.com/about
  • 实现原理:基于 HTML5 History API,通过 pushStatereplaceState 方法管理路由
  • 优势
    • URL 更干净、美观,符合 RESTful 设计
    • 更好的 SEO 支持
    • 更符合现代 Web 应用的 URL 规范
  • 劣势
    • 需要服务器端配置,确保所有请求都指向同一个入口文件
    • 依赖 HTML5 History API,对旧浏览器支持有限(IE11 之前不兼容)

在实际项目中,我们通常使用 as Router 语法来提高代码可读性:

import { BrowserRouter as Router } from 'react-router-dom';

function App() {
  return (
    <Router>
      {/* 应用内容 */}
    </Router>
  );
}

三、路由类型详解

React Router 支持多种类型的路由,满足不同场景的需求:

1. 普通路由

最基础的路由类型,用于匹配固定路径:

<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />

2. 动态路由

通过参数捕获实现,适用于需要根据 ID 或其他参数显示不同内容的场景:

<Route path="/user/:id" element={<UserProfile />} />
<Route path="/product/:productID" element={<ProductDetail />} />

在组件中可以通过 useParams Hook 获取路由参数:

import { useParams } from 'react-router-dom';

function UserProfile() {
  const { id } = useParams();
  return <div>用户 ID: {id}</div>;
}

3. 通配路由

使用 * 匹配任意路径,常用于 404 页面:

<Route path="*" element={<NotFound />} />

4. 嵌套路由

通过 Outlet 组件实现路由嵌套,适用于复杂的页面结构:

<Route path="/product" element={<Product />}>
  <Route path=":productID" element={<ProductDetail />} />
  <Route path="new" element={<NewProduct />} />
</Route>

在父组件中使用 Outlet 渲染子路由:

import { Outlet } from 'react-router-dom';

function Product() {
  return (
    <div>
      <h1>产品列表</h1>
      <Outlet /> {/* 渲染子路由内容 */}
    </div>
  );
}

5. 鉴权路由

通过自定义组件实现路由守卫,控制页面访问权限:

<Route path="/pay" element={
  <ProtectRoute>
    <Pay />
  </ProtectRoute>
} />

6. 重定向路由

使用 Navigate 组件实现路由重定向:

<Route path="/old-path" element={<Navigate replace to="/new-path" />} />

四、路由优化策略

1. 组件懒加载

通过 React.lazySuspense 实现组件的按需加载,提高应用性能:

import { lazy, Suspense } from 'react';

const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));

function RouterConfig() {
  return (
    <Suspense fallback={<LoadingFallback />}>
      <Routes>
        {/* 路由配置 */}
      </Routes>
    </Suspense>
  );
}

2. 导航高亮

使用 useResolvedPathuseMatch 实现导航菜单的动态高亮:

const isActive = (to) => {
  const resolvedPath = useResolvedPath(to);
  const match = useMatch({
    path: resolvedPath.pathname,
    end: true
  });
  return match ? 'active' : '';
};

3. 路由历史管理

  • push:向历史栈添加新记录,用户可以通过浏览器的后退按钮返回
  • replace:替换当前历史记录,用户无法通过后退按钮返回上一个页面

五、单页应用的优势

单页应用(SPA)通过前端路由实现了以下优势:

  1. 更好的用户体验:页面切换无需重新加载,避免了页面"白屏"现象
  2. 更快的响应速度:只更新需要变化的部分,减少了网络请求
  3. 前后端分离:前端负责用户界面,后端负责数据处理,职责更加清晰
  4. 代码组织:通过组件化和路由管理,代码结构更加清晰

六、实际应用案例

让我们通过一个完整的导航组件来展示 React Router 的实际应用:

import { Link, useResolvedPath, useMatch } from 'react-router-dom';

function Navigation() {
  const isActive = (to) => {
    const resolvedPath = useResolvedPath(to);
    const match = useMatch({
      path: resolvedPath.pathname,
      end: true
    });
    return match ? 'active' : '';
  };

  return (
    <nav>
      <ul>
        <li className={isActive('/')}>
          <Link to="/">首页</Link>
        </li>
        <li className={isActive('/about')}>
          <Link to="/about">关于</Link>
        </li>
        <li className={isActive('/product')}>
          <Link to="/product">产品</Link>
        </li>
        <li className={isActive('/product/new')}>
          <Link to="/product/new">新产品</Link>
        </li>
        <li className={isActive('/product/123')}>
          <Link to="/product/123">产品详情</Link>
        </li>
      </ul>
    </nav>
  );
}

七、总结与展望

React Router 作为现代前端开发的核心工具之一,为我们提供了一套完整、灵活的路由解决方案。通过合理使用不同类型的路由和优化策略,我们可以构建出体验优秀、性能出色的单页应用。

随着 React 生态系统的不断发展,React Router 也在持续演进,为开发者提供更多强大的功能。未来,我们可以期待它在服务端渲染、微前端等领域发挥更大的作用,为前端开发带来更多可能性。

掌握 React Router 不仅是前端开发的基本技能,也是构建现代化 Web 应用的必要条件。通过不断实践和探索,我们可以充分发挥其潜力,创造出更加出色的用户体验。

前端渲染:从 CSR、SSR 到同构与手写 Vite+React SSR 实践

作者 San30
2026年4月13日 21:53

在现代全栈开发的日常中,尤其是当我们着手构建大型 Web 应用或负责 C 端核心业务时,总会不可避免地撞上一座大山:首屏加载性能与 SEO 优化

无论是早期的 jQuery 时代,还是如今由 React、Vue 主导的组件化时代,前端圈一直在围绕一个核心问题进行技术迭代:网页到底是谁组装的? 是用户的浏览器,还是远端的服务器?

本文将由浅入深,带你彻底厘清 CSR、SSR 的前世今生,探讨现代框架的“同构渲染”黑魔法,并最终从零手写一个基于 Vite + Express + React 的 SSR 服务,让你不仅知其然,更知其所以然。

一、 渲染模式的演进:CSR 与 SSR 的较量

要理解现代架构,我们必须先看懂历史。

1. CSR (Client Side Render) 客户端渲染

在单页应用(SPA)横行的今天,CSR 是我们最熟悉的模式。它的本质是:服务器先返回一个“空壳” HTML,所有的页面渲染、路由跳转、状态管理都在用户的浏览器端由 JavaScript 完成。

工作流程(四步走):

  1. 请求 HTML: 用户输入网址,浏览器向服务器发起请求。
  2. 返回空壳: 服务器仅返回一个极简的 HTML(通常只有一个 <div id="root"></div>)和一个打包好的庞大 JavaScript 文件(如 bundle.js)。
  3. JS 解析(白屏期): 浏览器开始下载并执行 JS 文件。在这个过程中,用户只能看到大白屏或骨架屏。
  4. 数据请求与组装: JS 运行时向后端 API 请求数据,拿到 JSON 数据后,在本地动态生成 DOM 元素并插入页面,画面最终呈现。

优缺点剖析:

  • 优势(体验与成本): 服务器压力极小,只负责吐静态资源和 API 数据。“炒菜组装”的算力消耗全部转移给了用户的设备。一旦首屏加载完成,后续交互(如路由切换、弹窗)均在本地计算,体验极其丝滑,媲美原生 APP。
  • 致命劣势(性能与 SEO): 必须等待 JS 下载并执行完毕才能看到内容,网络稍差就会导致严重的首屏白屏。更致命的是,SEO(搜索引擎优化)极差。由于爬虫大多不会去执行复杂的 JS 代码,它们抓取到的永远只是那个没有实质内容的“空壳 HTML”,导致网站毫无自然流量。

适用场景:

后台管理系统、SaaS 工具、重交互的 Web 应用(如在线文档、可视化大屏)。这些内部系统无需考虑 SEO,开发体验和操作流畅度才是第一要务。

2. SSR (Server Side Render) 服务端渲染

为了解决 CSR 的痛点,业界把目光重新投向了服务器。利用 Node.js 环境能够运行 JS 的特性,在服务器端提前把 React/Vue 组件和数据拼接好,直接生成完整的 HTML 字符串返回给浏览器。

工作流程:

  1. 发起请求: 用户访问页面。
  2. 服务端组装(核心): 服务器接收请求后,在后端直接调用接口拉取数据,然后将数据和 React 模板拼接,生成包含完整内容的 HTML。
  3. 返回成品: 将这套完整的 HTML 发送给浏览器。
  4. 直接展示: 浏览器拿到的是现成的 HTML DOM 树,直接渲染展示,用户瞬间就能看到满屏内容。

优缺点剖析:

  • 优势(性能与流量): 首屏加载极快,彻底告别白屏。SEO 完美,搜索引擎爬虫能直接抓取到丰富的页面文本,极其利于收录和排名。
  • 劣势(成本与复杂度): 服务器压力剧增。每个用户的每次访问都需要服务器去实时“炒菜”,高并发场景下极易成为性能瓶颈。此外,开发复杂度变高,需要处理 Node.js 环境与浏览器环境的差异(例如在 useEffect 触发前,服务端是没有 windowdocument 对象的)。

适用场景:

官网、新闻门户网站、电商商品详情页等高度依赖搜索引擎引流的 C 端页面。

二、 现代架构的答案:同构渲染 (Isomorphic Rendering)

非黑即白的时代已经过去。小孩子才做选择,现代前端全都要。为了结合 CSR 的极佳交互体验和 SSR 的首屏/SEO 优势,诞生了诸如 Next.js 和 Nuxt.js 这样的现代框架。它们的核心机制就是同构渲染

同构渲染的口诀很简单:首次访问 SSR + 后续交互 CSR

  1. SSR 阶段(极速首屏): 当你第一次打开网页时,服务器立刻将组装好的、带有完整内容的 HTML 返回。屏幕瞬间出现画面,爬虫也非常满意。
  2. 下载脚本: 浏览器在展示静态画面的同时,后台开始默默下载包含交互逻辑的 JS 文件。
  3. Hydration(水合/注水): JS 加载完成后,会在浏览器里重新执行一遍,并静默地“附着”到刚才那个静态的 HTML 页面上。它会对比当前的 DOM 树,不重建 DOM,只是给原本静态的按钮绑上事件,给表单注入状态。这个过程就像给干瘪的海绵注入水分,让它变得鲜活可交互。
  4. CSR 接管: “注水”完成后,页面彻底变成 SPA。后续点击链接只会请求数据,不再请求完整 HTML,体验恢复到极致丝滑。

三、 深水区实战:基于 Vite + Express 手写 SSR

纸上得来终觉浅,绝知此事要躬行。接下来,我们将抛开 Next.js 的封装,从底层原理出发,手写一个包含水合机制的 React SSR 应用。

1. 扫清工程化障碍:路径与 Vite 的角色

在编写 Node 服务端代码前,必须厘清两个概念:

A. 路径处理:path.resolve() vs path.join()

在处理静态资源时经常踩坑:

  • path.join():简单的字符串路径拼接。把 / 看作普通字符,结果可能是相对路径。
  • path.resolve():将路径解析为绝对路径。它会从右向左解析,把 / 看作根目录并丢弃左侧路径。若参数不足以构成绝对路径,则默认拼接当前工作目录(CWD)。

注意:在 ESM 中,不支持 CommonJS 的 __dirname,可以使用 path.resolve()(不传参时即为 CWD)来替代。

B. Vite 在 SSR 中的角色:包工头

在普通的 CSR 中,浏览器负责解析 JS。但在 SSR 中,Node 不认识 .jsx 语法。我们需要通过 createViteServer 将 Vite 以中间件的形式嵌入到 Express 中,让 Vite 接管编译工作。

Vite 提供的三大绝招:

  1. vite.middlewares:共享中间件,处理静态资源和热更新(HMR)。
  2. vite.transformIndexHtml:HTML 转换,将 HMR 客户端脚本注入原始模板。
  3. vite.ssrLoadModuleSSR 的灵魂 API。突破 Node 限制,在后台瞬时编译 React 组件,返回 Node 可直接运行的 ESM 模块对象。

2. 实战代码拆解

我们的目标是实现一套完整的同构渲染架构。

步骤一:HTML 骨架与挂载点 (index.html)

准备一个包含占位符的 HTML 文件。注意这里的 `` 标记,服务器等下会将渲染好的 React 组件代码替换到这里。同时引入客户端入口脚本。

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SSR 实战</title>
</head>
<body>
    <div id="root"><!--app-html--></div>
    <script type="module" src="/src/entry-client.jsx"></script>
</body>
</html>

步骤二:编写 React 组件 (App.jsx)

写一个极简的组件。加入 onClick 事件是为了验证后续的**水合(Hydration)**是否成功。如果只是单纯的静态 HTML 替换,点击是不会弹窗的。

export default function App() {
    return <h1 onClick={() => alert('水合成功!Hello Vite SSR')}>Hello Vite SSR</h1>
}

步骤三:双端入口设计

同构应用需要两个入口:一个给服务器执行,一个给浏览器执行。

服务端入口 (entry-server.jsx):

职责:将 React 组件渲染成纯粹的 HTML 字符串。不涉及任何 DOM 操作,不执行生命周期(如 useEffect)和事件绑定。

import React from 'react';
// react-dom/server 提供将组件渲染为 HTML 字符串的能力
import { renderToString } from 'react-dom/server';
import App from './App';

export function render() {
    console.log('Server is rendering the App...');
    return renderToString(<App />);
}

客户端入口 (entry-client.jsx):

职责:Hydration(水合)。浏览器接收到 HTML 后,在此处把事件监听等前端逻辑“粘”上去。

console.log('Client script is running...');
import React from 'react';
import { hydrateRoot } from 'react-dom/client';
import App from './App';

// 水合渲染:让服务器端的 HTML 字符串变成可交互的页面
// React 在前端再执行一次,与现有 DOM 对比,注入事件和逻辑
hydrateRoot(document.getElementById('root'), <App />);

步骤四:编写 Express 核心调度服务 (server.js)

这是整个架构的枢纽。Express 扮演 Web 服务器(洋葱模型式的中间件处理请求),Vite 扮演实时编译器。

import fs from 'fs';
import path from 'path';
import express from 'express';
import { createServer as createViteServer } from 'vite';

// 获取当前目录的绝对路径
const __dirname = path.resolve();
const app = express();

async function start() {
    console.log('SSR Server starting...');
    
    // 1. 以中间件模式创建 Vite 服务器
    const vite = await createViteServer({
        server: { middlewareMode: true }, // 关键:告诉 Vite 不要自己启动 HTTP 服务
        appType: 'custom'                 // 告诉 Vite 页面 HTML 的渲染由 Express 接管
    });
    
    // 2. 将 Vite 作为中间件注入到 Express
    // 处理静态资源、热更新逻辑
    app.use(vite.middlewares);

    // 3. 拦截所有请求,手写 SSR 逻辑
    app.use(async (req, res) => {
        try {
            // A. 同步读取原始的 index.html 模板
            let template = fs.readFileSync(
                path.resolve(__dirname, 'index.html'),
                'utf-8'
            );

            // B. 让 Vite 接管 HTML 转换
            // 这一步至关重要,它会注入 Vite 的 HMR 热更新脚本
            template = await vite.transformIndexHtml(req.url, template);

            // C. 加载服务器端入口文件
            // vite.ssrLoadModule 突破 Node 限制,动态编译 jsx 并返回模块对象
            const { render } = await vite.ssrLoadModule('/src/entry-server.jsx')
            
            // D. 在服务端执行 render,将 React 组件渲染成完整的 HTML 字符串
            const html = await render();
            
            // E. 将渲染出的 HTML 字符串替换到模板的占位符中
            template = template.replace('<!--app-html-->', html);
            
            // F. 将组装好的完整 HTML 返回给浏览器
            res.status(200).set({'Content-Type': 'text/html'}).end(template);
        } catch (error) {
            // 捕获编译错误,通过 Vite 修复堆栈追踪
            vite.ssrFixStacktrace(error);
            res.status(500).end(error.message);
        }
    })
}

app.listen(3000, () => {
    console.log('Server is running on port 3000');
});

start();

四、 总结

通过上述实践,我们走通了现代前端渲染的闭环:

  1. Express 接收到用户的 URL 请求。
  2. 读取本地 index.html,并通过 Vite 中间件 注入热更新代码。
  3. Vite 在后端即时编译 entry-server.jsx,调用 renderToString,快速炒出一盘“没有灵魂”(无交互)的完整 HTML 页面,发给浏览器。这就是 SSR 阶段,爬虫狂喜,用户秒看首屏。
  4. 浏览器解析 HTML,遇到 <script src="/src/entry-client.jsx"> 开始下载前端逻辑。
  5. 前端逻辑加载完毕,调用 hydrateRoot 进行水合,页面瞬间拥有了灵魂,点击事件生效。自此,页面由 CSR 接管

理解了这些底层 API 的流转,再去审视像 Next.js 这种高度封装的生产级 SSR 框架时,便能做到知根知底。现代全栈不仅仅是 API 搬运,深入掌控应用的生命周期和渲染管线,才是构建高性能 Web 系统的基石。

LangChain 教程 05|模型配置:AI 的大脑与推理引擎

作者 二十一_
2026年4月13日 21:16

LangChain 教程 05|模型配置:AI 的大脑与推理引擎

📖 本篇导读:这是 LangChain 系列教程的第 5 篇。本篇将深入讲解模型初始化、调用方式、工具调用、结构化输出和多模态处理。读完预计需要 10 分钟。


简单来说

模型是 AI 的大脑,负责思考、决策和生成内容。

就像人类的大脑一样,不同的模型有不同的能力:有的擅长逻辑推理,有的擅长创意写作,有的能看懂图片,有的能调用工具。


🎯 本节目标

读完本节,你将能够回答这些问题:

  • ❓ 如何初始化一个模型?initChatModel 和直接实例化有什么区别?
  • ❓ 模型的三种调用方式(invoke/stream/batch)分别适合什么场景?
  • ❓ 什么是工具调用(Tool Calling)?模型如何使用工具?
  • ❓ 结构化输出有什么用?如何让模型返回固定格式的数据?
  • ❓ 什么是多模态?模型如何处理和生成图片?

核心痛点与解决方案

痛点:模型使用的四大难题

痛点 传统做法 有多痛苦
模型切换困难 每个模型有不同的 API,重写集成代码 想换模型?半天时间没了
调用方式单一 只能同步调用,等半天才返回结果 用户体验差,等得黄花菜都凉了
输出格式混乱 自由文本,解析困难,容易出错 写一堆正则表达式,还经常出错
能力扩展有限 只能聊天,不能调用工具,不能处理图片 功能太单一,满足不了复杂需求

举个例子: 你先用 OpenAI,后来想试试 Anthropic。

传统做法:

解决:LangChain 统一接口

import { initChatModel } from "langchain";

// 1. 初始化 OpenAI
const openaiModel = await initChatModel("gpt-4.1");

// 2. 初始化 Anthropic
const anthropicModel = await initChatModel("claude-sonnet-4-5-20250929");

// 3. 调用方式完全一样
const response1 = await openaiModel.invoke("你好");
const response2 = await anthropicModel.invoke("你好");

// 4. 流式输出也一样
const stream1 = await openaiModel.stream("讲个故事");
const stream2 = await anthropicModel.stream("讲个故事");

效果对比:

指标 传统做法 LangChain
模型切换 重写集成代码 改一行字符串
调用方式 单一同步调用 三种方式(invoke/stream/batch)
输出格式 自由文本 结构化输出,格式统一
能力扩展 有限 工具调用、多模态、推理

模型集成对比:传统做法 vs LangChain


生活化类比:模型就像不同的专家

模型类型 类比 擅长什么 适合场景
GPT-4.1 全能科学家 逻辑推理、创意写作、工具调用 复杂任务,需要深度思考
Claude 3.5 文学教授 长文本理解、细腻表达 内容创作,文档分析
Gemini 2.5 多学科专家 多模态、代码、数学 图片分析,代码生成
Mistral Large 效率专家 速度快,成本低 高频简单任务
本地模型 私人顾问 数据隐私,无网络依赖 敏感数据处理

模型的能力层级

AI 模型能力层级金字塔

┌─────────────────────────────────────┐
│                                     │
│   🎯 基础能力:文本生成            │
│      ↓                              │
│   🛠️ 进阶能力:工具调用            │
│      ↓                              │
│   📊 高级能力:结构化输出            │
│      ↓                              │
│   🖼️ 专家能力:多模态                │
│      ↓                              │
│   🤔 顶级能力:推理                  │
│                                     │
└─────────────────────────────────────┘

基础用法:初始化与调用

初始化模型

方法一:使用 initChatModel(推荐)

import { initChatModel } from "langchain";

// 1. 最简单的方式
const model = await initChatModel("gpt-4.1");

// 2. 带参数初始化
const modelWithParams = await initChatModel(
  "claude-sonnet-4-5-20250929",
  {
    temperature: 0.7,  // 创造性(0-1)
    timeout: 30,        // 超时时间(秒)
    maxTokens: 1000,    // 最大输出长度
  }
);

💡 人话解读

  • initChatModel 是一个工厂函数,帮你自动创建模型实例
  • 第一个参数是模型标识,格式可以是 modelprovider:model
  • 第二个参数是模型参数,控制输出行为

方法二:直接实例化(高级用法)

import { ChatOpenAI } from "@langchain/openai";
import { ChatAnthropic } from "@langchain/anthropic";

// OpenAI
const openaiModel = new ChatOpenAI({
  model: "gpt-4.1",
  temperature: 0.1,
  apiKey: "sk-xxx",  // 可以直接传 API Key
});

// Anthropic
const anthropicModel = new ChatAnthropic({
  model: "claude-3.5-sonnet",
  temperature: 0.7,
});

💡 人话解读

  • 直接实例化更灵活,可以访问所有模型特定的参数
  • 需要安装对应的包:@langchain/openai@langchain/anthropic
  • 适合需要深度定制的场景

三种调用方式

模型三种调用方式对比

方式 作用 适合场景 示例
invoke 同步调用,返回完整结果 简单任务,需要完整结果 短文本生成,分类
stream 流式调用,实时返回结果 用户界面,长文本生成 聊天机器人,故事生成
batch 批量调用,并行处理多个请求 批量任务,提高效率 批量翻译,批量总结
1. invoke(同步调用)
// 单个消息
const response = await model.invoke("为什么天空是蓝色的?");
console.log(response.text);

// 对话历史
const conversation = [
  { role: "system", content: "你是一个英语翻译助手" },
  { role: "user", content: "翻译:我爱编程" },
  { role: "assistant", content: "I love programming." },
  { role: "user", content: "翻译:我喜欢 LangChain" },
];

const response2 = await model.invoke(conversation);
console.log(response2.text);

💡 人话解读

  • invoke 是最基本的调用方式
  • 可以传单个字符串,也可以传对话历史数组
  • 返回一个 AIMessage 对象,用 .text 获取文本内容
2. stream(流式调用)
// 基本流式调用
const stream = await model.stream("讲一个关于 AI 的故事,至少 500 字");

for await (const chunk of stream) {
  process.stdout.write(chunk.text);  // 实时输出,不换行
}

// 处理不同类型的内容块
const stream2 = await model.stream("天空为什么是蓝色的?");

for await (const chunk of stream2) {
  for (const block of chunk.contentBlocks) {
    if (block.type === "text") {
      console.log(`文本:${block.text}`);
    } else if (block.type === "reasoning") {
      console.log(`推理:${block.reasoning}`);
    }
  }
}

💡 人话解读

  • stream 返回一个异步迭代器,用 for await...of 循环处理
  • 每一个 chunk 是模型生成的一部分内容
  • 适合需要实时反馈的场景,用户体验更好
3. batch(批量调用)
const responses = await model.batch([
  "为什么苹果会掉下来?",
  "如何学习编程?",
  "什么是人工智能?",
]);

for (const response of responses) {
  console.log(response.text);
  console.log("---");
}

// 控制并发数
const responses2 = await model.batch(
  ["问题1", "问题2", "问题3", "问题4", "问题5"],
  {
    maxConcurrency: 2,  // 最多同时处理 2 个请求
  }
);

💡 人话解读

  • batch 接收一个数组,并行处理多个请求
  • 返回一个结果数组,顺序和输入一致
  • maxConcurrency 控制并发数,避免触发 API 速率限制

进阶能力:工具调用(Tool Calling)

什么是工具调用?

工具调用 是模型的超能力,让模型能够:

  • 🔍 搜索网络
  • 📊 查询数据库
  • 📧 发送邮件
  • 🎵 调用 API
  • 🧮 执行代码

就像人类使用工具一样:遇到问题,找合适的工具,使用工具,根据结果继续解决问题。

工具调用的流程

工具调用完整流程

┌─────────────────────────────────────┐
│                                     │
│   用户:"北京明天天气怎么样?"         │
│      ↓                              │
│   模型(思考):"需要查天气"         │
│      ↓                              │
│   模型(行动):调用 get_weather     │
│      ↓                              │
│   工具返回:"北京明天晴,25°C"        │
│      ↓                              │
│   模型(总结):"明天北京晴,适合出门"  │
│      ↓                              │
│   用户:收到回答                     │
│                                     │
└─────────────────────────────────────┘

实现工具调用

import { tool } from "langchain";
import * as z from "zod";
import { initChatModel } from "langchain";

// 1. 定义天气工具
const getWeather = tool(
  ({ location }) => `Weather in ${location}: Sunny, 25°C`,
  {
    name: "get_weather",
    description: "Get weather information for a location",
    schema: z.object({
      location: z.string().describe("The location to get weather for"),
    }),
  }
);

// 2. 初始化模型并绑定工具
const model = await initChatModel("gpt-4.1");
const modelWithTools = model.bindTools([getWeather]);

// 3. 调用模型
const response = await modelWithTools.invoke("北京明天天气怎么样?");

// 4. 处理工具调用
if (response.tool_calls) {
  // 执行工具
  const toolResults = [];
  for (const toolCall of response.tool_calls) {
    if (toolCall.name === "get_weather") {
      const result = await getWeather.invoke(toolCall);
      toolResults.push(result);
    }
  }
  
  // 将结果传回模型
  const finalResponse = await modelWithTools.invoke([
    { role: "user", content: "北京明天天气怎么样?" },
    response,
    ...toolResults,
  ]);
  
  console.log(finalResponse.text);
}

💡 人话解读

  • tool() 定义工具,告诉模型这个工具能做什么
  • bindTools() 给模型绑定工具,让模型知道有哪些工具可用
  • 模型返回 tool_calls 表示要调用工具
  • 执行工具后,将结果传回模型,模型会生成最终回答

并行工具调用

const modelWithTools = model.bindTools([getWeather, getTime]);

const response = await modelWithTools.invoke(
  "北京和上海的天气怎么样?现在几点了?"
);

// 模型可能会并行调用多个工具
console.log(response.tool_calls);
// [
//   { name: "get_weather", args: { location: "北京" } },
//   { name: "get_weather", args: { location: "上海" } },
//   { name: "getTime", args: {} }
// ]

💡 人话解读

  • 很多模型支持并行工具调用,提高效率
  • 模型会根据问题的独立性决定是否并行调用
  • 可以同时获取多个信息,不用等待一个工具执行完再执行下一个

高级能力:结构化输出

什么是结构化输出?

结构化输出 让模型返回固定格式的数据,而不是自由文本。

为什么要用?

  • ✅ 格式统一,方便后续处理
  • ✅ 类型安全,减少错误
  • ✅ 前端展示更方便
  • ✅ 与数据库、API 集成更容易

使用结构化输出

方式一:使用 Zod Schema(推荐)

import * as z from "zod";
import { initChatModel } from "langchain";

// 定义输出结构
const Movie = z.object({
  title: z.string().describe("电影标题"),
  year: z.number().describe("上映年份"),
  director: z.string().describe("导演"),
  rating: z.number().describe("评分,满分10分"),
  genres: z.array(z.string()).describe("电影类型"),
});

// 初始化模型
const model = await initChatModel("gpt-4.1");

// 绑定结构化输出
const modelWithStructure = model.withStructuredOutput(Movie);

// 调用模型
const response = await modelWithStructure.invoke(
  "提供《盗梦空间》的详细信息"
);

console.log(response);
// {
//   title: "盗梦空间",
//   year: 2010,
//   director: "克里斯托弗·诺兰",
//   rating: 9.3,
//   genres: ["科幻", "动作", "惊悚"]
// }

💡 人话解读

  • z.object() 定义输出结构,每个字段都有类型和描述
  • withStructuredOutput() 告诉模型返回这个格式
  • 模型会严格按照格式返回数据,自动验证类型

方式二:使用 JSON Schema

const jsonSchema = {
  "title": "Movie",
  "description": "电影信息",
  "type": "object",
  "properties": {
    "title": {
      "type": "string",
      "description": "电影标题"
    },
    "year": {
      "type": "integer",
      "description": "上映年份"
    }
  },
  "required": ["title", "year"]
};

const modelWithStructure = model.withStructuredOutput(
  jsonSchema,
  { method: "jsonSchema" }
);

💡 人话解读

  • JSON Schema 更通用,适合与其他系统集成
  • 但没有 Zod 的自动验证功能
  • 需要指定 method: "jsonSchema"

嵌套结构

import * as z from "zod";

const Actor = z.object({
  name: z.string().describe("演员姓名"),
  role: z.string().describe("扮演角色"),
});

const MovieDetails = z.object({
  title: z.string().describe("电影标题"),
  year: z.number().describe("上映年份"),
  director: z.string().describe("导演"),
  cast: z.array(Actor).describe("演员阵容"),
  budget: z.number().nullable().describe("预算,单位:百万美元"),
});

const modelWithStructure = model.withStructuredOutput(MovieDetails);

const response = await modelWithStructure.invoke(
  "提供《复仇者联盟4》的详细信息,包括主要演员"
);

💡 人话解读

  • 可以定义嵌套结构,比如电影包含演员数组
  • Zod 会自动处理嵌套结构的验证
  • 模型会返回完整的嵌套对象

专家能力:多模态

什么是多模态?

多模态 是模型的超能力,让模型能够:

  • 🖼️ 理解图片:看懂图片内容
  • 🎵 理解音频:听懂语音
  • 📹 理解视频:看懂视频内容
  • 🎨 生成图片:根据描述生成图片
  • 🎭 生成音频:生成语音

处理图片输入

import { initChatModel } from "langchain";
import { HumanMessage } from "@langchain/core/messages";

const model = await initChatModel("gpt-4.1"); // GPT-4 支持图片

// 方式一:使用图片 URL
const message = new HumanMessage({
  content: [
    {
      type: "text",
      text: "这张图片里有什么?",
    },
    {
      type: "image",
      image_url: {
        url: "https://example.com/cat.jpg",
      },
    },
  ],
});

// 方式二:使用 base64 编码的图片
const message2 = new HumanMessage({
  content: [
    {
      type: "text",
      text: "分析这张图片",
    },
    {
      type: "image",
      data: "base64-encoded-image-data",
      mimeType: "image/jpeg",
    },
  ],
});

const response = await model.invoke(message);
console.log(response.text);

💡 人话解读

  • 多模态模型需要接收特殊格式的消息
  • 消息内容是一个数组,包含文本和图片
  • 图片可以是 URL 或 base64 编码

生成图片输出

const model = await initChatModel("dall-e-3"); // DALL-E 支持生成图片

const response = await model.invoke(
  "生成一张可爱的小猫在草地上玩耍的图片,风格:卡通"
);

console.log(response.contentBlocks);
// [
//   {
//     type: "text",
//     text: "这是一张可爱的小猫在草地上玩耍的图片"
//   },
//   {
//     type: "image",
//     data: "base64-encoded-image-data",
//     mimeType: "image/jpeg"
//   }
// ]

💡 人话解读

  • 支持生成图片的模型会返回包含图片的内容块
  • contentBlocks 包含文本和图片数据
  • 可以将图片数据保存或直接显示

顶级能力:推理

什么是推理?

推理 是模型的高级能力,让模型能够:

  • 🤔 分步思考
  • 🧩 解决复杂问题
  • 🎯 逻辑分析
  • 🔍 演绎归纳

就像人类解题一样:遇到复杂问题,拆分成小步骤,一步步解决,最后得出结论。

查看模型的推理过程

const model = await initChatModel("gpt-4.1");

const stream = await model.stream(
  "为什么 1 + 1 = 2?请详细解释数学原理"
);

for await (const chunk of stream) {
  for (const block of chunk.contentBlocks) {
    if (block.type === "reasoning") {
      console.log(`🤔 ${block.reasoning}`);
    } else if (block.type === "text") {
      console.log(`💬 ${block.text}`);
    }
  }
}

输出示例:

🤔 要解释为什么 1 + 1 = 2,需要从数学基础说起...
🤔 首先,我们需要理解自然数的定义...
💬 为什么 1 + 1 = 2 是数学中的一个基本公理,它基于自然数的定义...
🤔 在皮亚诺公理中,1 的后继数被定义为 2...
💬 因此,根据自然数的定义和加法的定义,1 + 1 必然等于 2...

💡 人话解读

  • 支持推理的模型会在内容块中包含 reasoning 类型
  • 推理过程帮助模型理清思路,得出更准确的结论
  • 查看推理过程有助于理解模型的思考方式

业务场景:不同模型的最佳使用场景

不同模型最佳使用场景

场景 推荐模型 理由 调用方式
客服机器人 GPT-4.1 逻辑清晰,工具调用能力强 stream
创意写作 Claude 3.5 文笔优美,长文本能力强 invoke
图片分析 GPT-4o 多模态,理解图片能力强 invoke
代码生成 Gemini 2.5 代码能力强,多语言支持 invoke
批量处理 Mistral Large 速度快,成本低 batch
敏感数据 本地模型(Ollama) 数据隐私,无网络依赖 invoke

示例:智能客服机器人

需求:用户问天气,机器人查天气并回答

import { initChatModel, tool } from "langchain";
import * as z from "zod";

// 1. 定义天气工具
const getWeather = tool(
  ({ city }) => {
    // 实际项目中调用真实的天气 API
    return `Weather in ${city}: 25°C, sunny`;
  },
  {
    name: "get_weather",
    description: "获取指定城市的天气",
    schema: z.object({ city: z.string() }),
  }
);

// 2. 初始化模型
const model = await initChatModel("gpt-4.1");
const modelWithTools = model.bindTools([getWeather]);

// 3. 处理用户请求
async function handleUserQuery(query: string) {
  let messages = [{ role: "user", content: query }];
  let response = await modelWithTools.invoke(messages);
  messages.push(response);

  // 处理工具调用
  if (response.tool_calls) {
    for (const toolCall of response.tool_calls) {
      if (toolCall.name === "get_weather") {
        const toolResult = await getWeather.invoke(toolCall);
        messages.push(toolResult);
      }
    }
    
    // 获取最终回答
    response = await modelWithTools.invoke(messages);
  }

  return response.text;
}

// 测试
const result = await handleUserQuery("北京今天天气怎么样?");
console.log(result);
// Output: 北京今天天气晴朗,温度 25°C,适合户外活动。

常见问题与解决方案

问题 原因 解决方案
API Key 错误 环境变量没配置或配置错误 检查环境变量,确保 API Key 正确
模型不支持某个功能 模型能力有限 查看模型文档,选择支持该功能的模型
流式调用没反应 代码没处理异步迭代器 确保使用 for await...of 循环
结构化输出格式错误 Schema 定义不当或模型能力不足 简化 Schema,选择更强的模型
批量调用超时 并发数太高或请求太多 减少 maxConcurrency,分批处理
多模态不工作 模型不支持或格式错误 选择支持多模态的模型,检查消息格式

💡 调试技巧

  • 先从简单的 invoke 调用开始
  • 逐步添加功能:工具调用 → 结构化输出 → 多模态
  • console.log 打印中间结果
  • 检查模型的 profile 属性,了解模型能力

总结对比表

能力 描述 代表模型 适用场景
文本生成 生成文本内容 所有模型 聊天、写作、翻译
工具调用 调用外部工具 GPT-4.1, Claude 3.5 客服、助手、自动化
结构化输出 返回固定格式数据 GPT-4.1, Gemini 2.5 数据处理、API 集成
多模态 处理图片、音频 GPT-4o, Gemini 2.5 图片分析、内容创作
推理 分步思考,解决复杂问题 GPT-4.1, Claude 3.5 数学题、逻辑分析
速度 快速响应 Mistral Large 高频简单任务
隐私 本地运行,数据不离开设备 Ollama 本地模型 敏感数据处理

核心要点回顾

  1. 模型是 AI 的大脑:负责思考、决策和生成内容

  2. 三种初始化方式

    • initChatModel:简单快捷,推荐
    • 直接实例化:灵活定制,高级用法
    • 本地模型:隐私优先,无网络依赖
  3. 三种调用方式

    • invoke:同步调用,适合简单任务
    • stream:流式调用,适合用户界面
    • batch:批量调用,适合批量处理
  4. 四大超能力

    • 工具调用:模型使用工具解决问题
    • 结构化输出:返回固定格式数据
    • 多模态:处理和生成图片
    • 推理:分步思考,解决复杂问题
  5. 模型选择:根据任务需求选择合适的模型

    • 复杂任务:GPT-4.1, Claude 3.5
    • 图片处理:GPT-4o
    • 代码生成:Gemini 2.5
    • 速度优先:Mistral Large
    • 隐私优先:本地模型

记住:选择合适的模型,就像选择合适的专家**。不同的任务需要不同的专业知识,模型也一样。了解模型的能力边界,才能让 AI 发挥最大价值!🚀

深入浅出:彻底搞懂 WebSocket、SSE 与心跳机制

作者 San30
2026年4月13日 20:48

在当今的大前端与全栈开发中,实时通信技术已经成为了不可或缺的技能。特别是随着 ChatGPT 等大语言模型(LLM)的爆火,实时流式输出技术再次被推上风口浪尖。

很多前端同学对传统的 HTTP 协议了如指掌,但在面对多协议开发(如 WebSocket、SSE)时,往往会感到一丝陌生。在面试中,面试官也常常借此切入,考察候选人对 408 计算机网络底层协议的理解,以及浏览器(B/S架构)与传统客户端(C/S架构)在通信上的差异。

今天,我们将从业务场景选型出发,带你深入剖析 HTTP、SSE 与 WebSocket 的核心差异,并手把手带你用 Node.js (Koa) 实现一个支持多人在线的 Chat App,最后深入探讨长连接中必不可少的“心跳机制”。

一、 业务场景选型:为什么聊天应用必须用 WebSocket?

假设我们要开发一个多人在线聊天室(Chat App),面对这个需求,我们通常有三种技术栈选择。让我们来看看它们的优劣:

1. HTTP 协议(轮询方案)

HTTP 协议是基于传统的“请求-响应”模型的短连接通信。

  • 机制: 客户端发起请求,服务端返回响应。如果要获取最新消息,前端必须使用 setInterval 等方式不断发起 Fetch 或 Ajax 请求(这种技术称为短轮询)。
  • 痛点: 这种方式性能极差且十分复杂。即使 HTTP 协议可以通过 Connection: keep-alive 复用底层的 TCP/IP 通道,但其应用层依然是单向的“一问一答”模式。这会导致大量无效请求,极大地浪费服务器带宽和性能。

2. SSE (Server-Sent Events)

SSE 是一种轻量级的长连接持久化单向通道技术。

  • 机制: 建立连接后,服务器可以单向、持续地向客户端推送文本数据。
  • 适用场景: 它是 LLM(大语言模型)流式打字机输出的当下业务热点。非常适合“用户 Prompt 一次,LLM 流式输出”的场景。
  • 痛点: 聊天是双向的(既要收消息,又要发消息)。SSE 只能做到服务端持续推送,无法做到用户端向服务端的持续推送,因此不适合全双工的聊天应用。

3. WebSocket 协议(终极方案)

WebSocket 是 HTML5 提供的新特性,用于在 Web 端实现即时通讯。

  • 机制: 它是一种在浏览器和服务器之间建立“长连接”的协议,可以实现真正的双向实时全双工通信。
  • 优势: 一次连接,持续通信。服务器端和用户端都可以随时主动向对方推送数据,完美契合聊天应用实时收发、多人同步的需求。

📊 核心特性全方位对比

为了更直观地理解,我们可以通过下表快速对比这三种通信方式:

对比维度 HTTP SSE (Server-Sent Events) WebSocket
通信方式 单向(客户端发起,服务端响应) 单向(仅服务端向客户端推送) 双向 / 全双工(双方均可主动发送)
连接持久性 基于请求与响应,默认短连接(可 Keep-Alive) 长连接(持久化单向通道) 长连接(持久化双向通道)
数据格式 无限制(文本、二进制、JSON 等) 仅限文本(通常为 JSON 或纯文本) 文本帧或二进制帧
协议类型 HTTP/1.1, HTTP/2, HTTP/3 HTTP 协议 (Content-Type: text/event-stream) 独立协议:ws://wss://
浏览器兼容 完美支持所有浏览器 不支持 IE,现代浏览器支持良好 支持所有现代浏览器

二、 WebSocket 核心原理解析

1. 什么是 WebSocket?

简单来说,WebSocket = Web + Socket。

传统的 Socket 是基于 TCP/IP 的实时通讯双工协议,常用于 QQ、微信、端游等 C/S(客户端/服务器)架构中。而 HTML5 提供的 WebSocket 特性,成功将这种底层的双向通信能力带入了 B/S(浏览器/服务器)架构中。

2. 核心考点:101 状态码与协议升级

很多初学者会有疑问:既然 WebSocket 叫 ws://,那它和 http:// 还有关系吗?

答案是:WebSocket 的第一次握手,依然使用的是 HTTP 协议。

当我们在前端执行 new WebSocket('ws://localhost:3000/ws') 时,浏览器会先发送一个标准的 HTTP 请求,并在请求头中带上特殊标记(Upgrade: websocket)。

服务器收到请求后,如果同意升级,会返回 HTTP 101 Switching Protocols 状态码。在这之后,双方的通信通道正式切换为 WebSocket 协议,不再使用臃肿的 HTTP 请求头。

三、 实战:从零手写基于 Koa 的聊天室

接下来,我们将使用 Node.js 结合 Koa 框架,亲手实现一个极简但五脏俱全的聊天应用。

1. 环境准备

我们需要安装 Koa 以及使 Koa 支持 WebSocket 的中间件:

pnpm i koa koa-websocket

提示:Koa 原生只支持 HTTP 请求,koa-websocket 库的作用是劫持和升级 HTTP 协议,让 Koa 能够处理 WebSocket 通信。

2. 服务端代码解析 (server.js)

以下是完整的服务端代码与深度解析:

// 引入 Koa 框架与 koa-websocket 库
const Koa = require('koa');
const WebSocket = require('koa-websocket');

// 初始化 Koa 实例,并立即用 WebSocket() 将其包裹
const app = WebSocket(new Koa());

// 创建一个 Set 集合,用来保存所有当前连接到服务器的客户端
// 使用 Set 可以天然保证元素的唯一性,防止同一客户端被重复添加
const clients = new Set();

// ==========================================
// 1. HTTP 路由部分:给浏览器下发前端页面
// ==========================================
app.use(async (ctx) => {
    // 第一次与服务器通信使用 HTTP 协议,拿到前端 HTML 页面
    // 采用简单的服务端渲染 (SSR) 做法
    ctx.body = `
    <!DOCTYPE html>
    <html>
    <body>
        <div id="messages" style="height:300px;overflow-y:scroll;"></div>
        <input type="text" id="messageInput"/>
        <button onclick="sendMessage()">发送</button>
        <script>
        // 利用 HTML5 原生的 WebSocket API 发起连接
        // 协议变为 ws://
        const ws = new WebSocket('ws://localhost:3000/ws')

        // 监听来自服务端的推送消息
        ws.onmessage = function(event) {
            const messages = document.getElementById('messages');
            messages.innerHTML += '<div>' + event.data + '</div>';
        }

        // 发送消息函数
        function sendMessage() {
            const input = document.getElementById('messageInput');
            ws.send(input.value); // 通过 WebSocket 通道发给服务端
            input.value = '';
        }
        </script>
    </body>
    </html>
    `;
})

// ==========================================
// 2. WebSocket 路由部分:处理实时双向通信
// ==========================================
app.ws.use(async (ctx, next) => {
    // 客户端连接成功,将当前专属的 websocket 实例存入 Set 集合
    clients.add(ctx.websocket);

    // 监听客户端发来的 'message' 事件
    ctx.websocket.on('message', (message) => {
        // 【核心逻辑:广播 Broadcast】
        for(const client of clients) {
            // 遍历所有连接的人,将消息发给所有人(包括发送者自己)
            client.send(message.toString());
        }
    })

    // 监听断开连接事件(如用户关闭 Tab)
    ctx.websocket.on('close', () => {
        // 必须从集合中移除失效连接,防止广播报错与内存泄漏
        clients.delete(ctx.websocket);
    })
})

// ==========================================
// 3. 启动服务
// ==========================================
app.listen(3000, () => {
    console.log('Server is running on port 3000');
})

通过这段代码,我们清晰地看到了 HTTP 到 WebSocket 的演进:用户首先通过传统的 HTTP 请求获取页面,页面加载后,脚本中的 new WebSocket() 发起协议升级请求,彻底切换到高效的 ws 协议进行实时通信。

四、 进阶:深入理解心跳机制(Heartbeat)与断线重连

做完了基本的聊天功能,我们就算是掌握 WebSocket 了吗?还不够!在真实的生产环境中,网络情况极其复杂。WebSocket 和 SSE 都是长连接,既然是长连接,就必须面对一个致命问题:连接假死

1. 为什么需要心跳机制?

心跳机制是指客户端和服务端定期互相报平安,用来检测连接是否还活着的一种技术。我们需要它的主要原因包括:

  • 网络断开与掉线: 当用户突然断网(如走进电梯)、拔掉网线等,底层的 TCP 可能无法正常发出挥手包。服务端以为连接还在,客户端却已经掉线了。
  • 主动监测: 必须主动监测连接状态,通过发送 ping/pong 来测试链路是否通畅。

💡 延伸思考:TCP 不是自带 Keep-Alive 吗?

很多同学知道 HTTP Connection: keep-alive 底层复用 TCP 通道。TCP 协议确实也有自己的 Keep-Alive 探活机制,但它的默认探测周期极长(通常以小时计),且只能探测网络层面的死活,无法判断应用层进程是否卡死。因此,在业务代码中实现应用层的心跳机制是业界标准做法。

2. 心跳机制的核心实现思路

一个健壮的心跳机制通常包含以下经典的“三步曲”:

  • 第一步:定时发送 Ping

    客户端通过 setInterval 定期(例如每 30 秒)向服务端发送探测包。

    setInterval(() => {
        ws.send(JSON.stringify({type: 'ping'}));
    }, 30000);
    
  • 第二步:接收并响应 Pong

    服务器端收到消息后,解析判断如果 type === 'ping',则立即回传一个类型为 pong 的消息。

    if(msg.type === 'ping') {
        ws.send(JSON.stringify({type: 'pong'}));
    }
    
  • 第三步:超时判断 + 重连机制

    客户端在发送 Ping 之后,会启动一个超时定时器。如果在规定时间内没有收到服务端的 Pong 响应,客户端就可以判定当前连接已断开,进而触发前端的 UI 提示,并执行重连逻辑(Reconnection)。

五、 总结

回顾整篇文章,我们从业务痛点出发,明白了为什么在即时通讯场景下,轮询太慢、SSE 不适用,而 WebSocket 是最终解。我们剖析了 WebSocket 101 状态码 的底层升级原理,并用极简的代码手撸了一个基于 Koa 的全双工广播聊天室。最后,我们补齐了长连接应用走向生产环境的最后一块拼图——心跳机制。

消息总线 + 可插拔的消息插件管理系统

作者 LovroMance
2026年4月13日 19:38

消息总线 + 可插拔的消息插件管理系统

一个 高度解耦、易于扩展的消息处理系统。它由两个核心部分组成,各自分工明确,协同工作。 消息总线 负责“找人”(把消息派发给正确的处理器),而 可插拔插件系统 负责“提供人手”(管理和寻找能处理消息的具体逻辑)。两者结合,让系统能够灵活、动态地处理各种类型的消息。

优点:高度解耦、高拓展性

可插拔的消息插件管理系统
  1. 在全局,也就是在main.js中注册消息插件
import { registerBuiltinMessagePlugins } from '@/plugins/messages/registerBuiltinMessagePlugins'

registerBuiltinMessagePlugins()
  1. 新增一个插件 + 在注册入口挂进去
import { registerMessagePlugin } from '@/core/message/messageRegistry'
import { textMessagePlugin } from '@/plugins/messages/text'
import { imageMessagePlugin } from '@/plugins/messages/image'
import { codeMessagePlugin } from '@/plugins/messages/code'

let registered = false

export const registerBuiltinMessagePlugins = () => {
  if (registered) return
  ;[
    textMessagePlugin,
    imageMessagePlugin,
    codeMessagePlugin,
    aiReplyMessagePlugin,
    unsupportedMessagePlugin,
  ].forEach(registerMessagePlugin)

  registered = true
}
  1. 订阅者注册表 + 处理器工厂:记录谁能处理什么类型的消息,根据消息找到对应的处理逻辑
// 可插拔的消息插件管理系统
const plugins = new Map()

// 注册插件
export const registerMessagePlugin = (plugin) => {
  if (!plugin?.type) {
    throw new Error('message plugin must provide a type')
  }

  plugins.set(plugin.type, plugin)
  return plugin
}

// 获取插件
export const getMessagePlugin = (type) => {
  return plugins.get(type) || plugins.get('unsupported') || null
}

// 处理插件
export const resolveMessagePlugin = (message) => {
  if (!message) return getMessagePlugin('unsupported')

  if (message.type && plugins.has(message.type)) {
    return plugins.get(message.type)
  }

  return getMessagePlugin('unsupported')
}

那么这里涉及到插件本身的对象格式,以textMessagePlugin为例

import TextMessage from '@/plugins/messages/text/TextMessage.vue'
import { MESSAGE_TYPES } from '@/core/message/messageTypes'

export const textMessagePlugin = {
  type: MESSAGE_TYPES.TEXT,
  component: TextMessage,
  match(message) {
    return message?.type === MESSAGE_TYPES.TEXT
  },
  buildSendPayload(payload) {
    return {
      ...payload,
      attachment: payload?.attachment || null,
    }
  },
}
收消息链路
  1. websocket收到消息后分类消息类型 --> 分发消息处理 --> 消息总线 (统一入口分发)
const data = JSON.parse(event.data)
console.log('收到消息:', data)
const category = classifyWsMessage(data)
dispatchWsMessage(category, data)

设计这一块时实际上我突然间觉得这个bus有些多余,我明明可以直接在dispatch的时候就处理各种类型的逻辑,我为什么还要加入一个bus?

实际上bus是一种解耦合的处理方式,把 入口 - 处理器 分开,也许现在功能单一的情况下并不能很明显的显示他的作用,但如果我要再添加一些例如:日志、埋点、额外的同步操作,这时这种设计就会显得尤为清晰。

  1. 消息总线实现,提供事件订阅和发布功能
const getEventListeners = (eventName) => {
  if (!listeners.has(eventName)) {
    listeners.set(eventName, new Set())
  }

  return listeners.get(eventName)
}
  1. 第一步提到的分发dispatch会触发发布事件到消息总线,返回是否有处理器命中
export const emitMessageBus = async (eventName, payload) => {
  const eventListeners = listeners.get(eventName)
  if (!eventListeners?.size) return false

// 在这里之前订阅的事件就会循环触发,解耦合体现
  for (const handler of eventListeners) {
    await handler(payload)
  }

  return true
}
  1. 顺带一提这里的listeners也是一个map
const listeners = new Map()
  1. 订阅消息总线事件,返回一个取消订阅的函数
export const onMessageBus = (eventName, handler) => {
  const eventListeners = getEventListeners(eventName)
  eventListeners.add(handler)

// 这里的设计很巧妙 -> 利用回调函数来进行注册,同时返回的是一个指定该事件的取消订阅方法
// 例如const unsubscribe = onMessageBus('ws:chat', receiveMessage)
// 那么我执行unsubscribe方法就可以取消该事件的订阅
  return () => {
    eventListeners.delete(handler)
  }
}
渲染问题

消息总线处理完成后,就是页面的渲染问题,那么我在这里是用了封装组件的方式,通过获取type来选择渲染组件。不知道你们还记不记得一开始提到的 textMessagePlugin ,就是在这里定义的组件渲染。

这里就不过多的进行展示了,主要是为了分析这种设计模式。

PS:这是一个我当作学习技术的 IM 项目,如果感兴趣可以私信我。或者访问连接 github.com/LovroMance/… 这仅仅是一个不成熟的学习项目。

观察者模式/发布-订阅模式

想了想还是在这里简单说明一下这种设计模式

观察者模式

那么观察者也就是降级版的发布-订阅模式,或者说发布-订阅是基于观察者模式新增了调度中心的变种。

在这里代码来源于 前端充电宝,本人还是很喜欢里面的分享,很全面。所以这里就直接摘取他的例子来介绍了。

发布者:

// 定义发布者类
class Publisher {
  constructor() {
    this.observers = []
    console.log('Publisher created')
  }
  // 增加订阅者
  add(observer) {
    console.log('Publisher.add invoked')
    this.observers.push(observer)
  }
  // 移除订阅者
  remove(observer) {
    console.log('Publisher.remove invoked')
    this.observers.forEach((item, i) => {
      if (item === observer) {
        this.observers.splice(i, 1)
      }
    })
  }
  // 通知所有订阅者
  notify() {
    console.log('Publisher.notify invoked')
    this.observers.forEach((observer) => {
      observer.update(this)
    })
  }
}

订阅者:

// 定义订阅者类
class Observer {
    constructor() {
        console.log('Observer created')
    }
    update() {
        console.log('Observer.update invoked')
    }
}
发布 - 订阅模式

那么这个就是我上文提到了,为什么不直接在websocket的分发器中执行收到消息后的逻辑处理而是选择转发到消息总线的理由。也就是通过添加中间的调度中心来进行解耦合。

结语

以上便是我作为一名大三前端学生,在探索时的一些粗浅思考与总结。由于个人水平有限,且技术迭代日新月异,文中难免存在理解偏差或疏漏之处,恳请各位前辈、同仁不吝赐教。

写作的过程也是自我梳理与学习的过程,若有不当之处,还望大家多多包涵并指出。路漫漫其修远兮,吾将上下而求索,愿我们都能在技术的道路上保持好奇,持续精进。感谢阅读!

❌
❌