阅读视图

发现新文章,点击刷新页面。

网页都知道要双向握手才加载!从 URL 到页面渲染,单向喜欢连 DNS 都解析不通

前言

有的人像 UDP 情绪来的时候一句话发出去,回不回来无所谓,至少那一刻是真实的。有的人像 TCP 每一句话都小心翼翼的等待回应,每一步靠近都要对方确认,缺少哪一次握手就不敢继续。而她,她既不是 UDP 也不是 TCP,她只是对你不想通信。真正的残酷不是丢包,不是延迟,不是超时,而且你始终在发送,她一直在沉默,你把自己当成 TCP 一样努力维持连接,一次次,你好,在吗,怎么不回消息,像三次握手一样想建立稳定的关系,但她从未回过你的 SYN,连第一步都没想过要和你完成,所以别再自我欺骗了,不是你没发好,不是你格式错了,不是你不够温柔细心坚持,是她压根就不想接受你的数据流,她不主动,不是害羞,不是忙,不是不知道说什么,而是她没有把你加入她的连接表里,你再怎么重传,重连,等待确认,都改变不了一件事,她不想与你通信,这才是全部真相

敲下 URL 后,网页到底偷偷做了什么?

对着浏览器地址栏敲下https://www.baidu.com,按下回车的瞬间,短短几秒百度首页就出现在眼前,这背后到底藏着怎样的 “神仙操作”?其实从输入网址到页面渲染,就像一场精心编排的 网络接力赛,今天我们一起去现场看看。

image.png

一、第一步:DNS 解析,给网址找个 “身份证”

想要访问百度,浏览器首先得知道:www.baidu.com这个好记的网址,对应的真实 “网络地址”(IP 地址)是什么?这就像你想找朋友玩,光知道名字不够,还得知道他家的具体住址,DNS 解析干的就是这个 “查住址” 的活儿。

浏览器会先向本地域名服务器发起查询,如果本地服务器有缓存,直接就能拿到网页;要是本地没有,这场 “查地址” 的旅程 就会继续:

  • 本地服务器先问根域名服务器
  • 再问顶级域名服务器
  • 最后找到百度的目标域名服务器,直到拿到对应的 IP 地址

拿到 IP 后,服务器还会把这个地址存进 DNS 缓存,下次再访问百度,就能直接 “抄近路”,不用再反复查询了。这就是为什么我们第一次打开百度可能等几秒,但后面几乎不需要等待。

image.png

简单说,DNS 解析就是给网址匹配唯一 IP 的过程,有了这个 IP,浏览器才能精准找到百度的服务器,开启后续的通信。

二、第二步:TCP 三次握手,和服务器 “握个手交个朋友”

拿到了百度服务器的 IP 地址,浏览器还不能直接发请求,因为互联网上的数据传输,全靠 TCP 协议 “保驾护航”,而 TCP 协议要求通信双方必须先建立连接,这就是大名鼎鼎的 三次握手

  1. 浏览器(客户端)先给服务器发一个 SYN 包,相当于说:“嗨,我想和你建立连接,行不行?”,随后客户端进入等待状态
  2. 服务器收到后,回传一个 SYN-ACK 包,意思是:“我收到啦,我同意连接,你确认一下?”,服务器也进入等待状态
  3. 浏览器收到服务器的回应,再发一个 ACK 包:“收到你的同意啦,咱们连接建立成功!”,至此双方都进入正常通信状态

image.png

面试考点:为什么不是两次/四次,而是三次握手呢?

  • 两次握手只能确保服务器收到了客户端的请求,但客户端没法确认服务器是否真的准备好,很容易出现连接失败、数据传输出错的问题,会产生失效的半连接,浪费资源
  • 四次握手步骤冗余,没必要
  • 三次握手刚好能 双向验证收发能力,保证双方都正常可用,是建立可靠连接的最少必要次数

三、第三步:HTTP/HTTPS 通信,向服务器 “要资源”

连接建立成功后,就到了核心的 通信环节,浏览器要向百度服务器发送 “请求”,要到百度首页的相关资源,这一步的 “沟通语言” 就是 HTTP/HTTPS 协议。

1. HTTP 协议:网络通信的 “通用普通话”

HTTP 协议是基于 TCP 的应用层协议,就像客户端和服务器之间约定好的普通话,定义了双方怎么说话、怎么传数据。它的发展也经历了好几个版本,每一次升级都在解决前一个版本的痛点,堪称 “持续优化的典范”,这边我给到一个夯 😄。

image.png

HTTP/0.9:最原始的版本,主打一个 “简单”,只能传输小小的 HTML 文件,没有请求头、响应头,就像两个人说话只说核心内容,没有任何客套话
HTTP/1.0:随着图片、视频、JS 文件等资源需要传输,这个版本新增了请求头、响应头,还加入了状态码、缓存机制,就像说话时加上了 “敬语” 和 “补充说明”,能传递更多信息,满足多种文件的传输需求
HTTP/1.1:实现了持久连接,一次 TCP 连接能传多个请求和响应,不用每次请求都重新握手,效率大大提升,还加入了 host 字段指定目标主机,但也存在队头阻塞的问题,一个请求卡壳,后面的请求都得等着
HTTP/2.0:针对 1.1 的痛点升级,只保留一个 TCP 连接,把多个请求切成小片段,还能给片段打加急标签,服务器可以优先处理重要请求,解决了队头阻塞,还加入了头部压缩,减少数据传输量,让通信更高效
HTTP/3.0:发现 TCP 协议本身还是有队头阻塞问题,干脆 “换了赛道”,基于 UDP 协议打造了 QUIC 协议,既保留了 TCP 的可靠传输、流量控制优势,又解决了队头阻塞,还实现了 TLS 加密、快速握手,堪称目前最完美的版本。

2. HTTPS:给 HTTP 加个 “加密保险箱”

我们平时访问的百度是HTTPS开头,而非HTTP,多出来的这个 S,就是 SSL/TLS 加密协议,相当于给 HTTP 通信加了一个 “加密保险箱”,防止数据在传输过程中被窃取、篡改。

image.png

它的加密方式很巧妙,结合了对称加密和非对称加密:

  • 客户端先生成一个密钥,服务器生成一对公钥和私钥
  • 服务器把公钥发给客户端,客户端用公钥给密钥加密后传给服务器,
  • 只有服务器的私钥能解开这个加密的密钥
  • 之后双方就用这个密钥进行对称加密通信,既保证了加密的安全性,又兼顾了传输的效率

image.png

简单来说,浏览器通过 HTTP/HTTPS 协议向服务器发送请求,告诉服务器:“我需要百度首页的 HTML、CSS、JS 等资源”,服务器收到请求后,会根据请求内容准备好对应的资源。

四、第四步:服务器响应,把资源 “送过来”

百度服务器收到浏览器的 合法请求 后,会立刻开始 “备货”,把首页的 HTML 文件、图片资源、样式文件、脚本文件等整理好,通过已经建立的 TCP 连接,再借助 HTTP/HTTPS 协议,将这些资源一步步传 输回浏览器

在传输过程中,TCP 协议会全程保驾护航

  • 把大的资源分成一个个小数据包,给每个数据包标上序列号,确保数据有序传输
  • 接收端收到数据包后,会发回确认号,要是某个数据包丢失,发送端会重新传输,这就是 TCP 的可靠传输
  • 如果是 HTTP/2.0 或 3.0,还会通过分块传输、多路复用等方式,让资源传输更快、更顺畅

五、第五步:浏览器渲染,让网页 “活起来”

当浏览器拿到服务器传来的所有资源后,就到了最后一步 —— 页面渲染,这也是让百度首页从一堆代码变成我们看到的精美页面的关键。

1. 解析 HTML,构建 DOM 树

浏览器会逐行读取 HTML 代码,将每个标签、属性、文本转换成 DOM(文档对象模型)节点,最终形成一棵层级分明的 DOM 树。DOM 树是页面结构的 “骨架”,记录了所有元素的层级关系和基本信息。

<!-- 原始HTML -->
<!DOCTYPE html>
<html>
  <head>
    <title>测试页面</title>
  </head>
  <body>
    <div class="box">
      <p>Hello DOM!</p>
    </div>
  </body>
</html>

对应的简化 DOM 树结构:

html
├─ head
│  └─ title (文本:测试页面)
└─ body
   └─ div (class="box")
      └─ p (文本:Hello DOM!)

你也可以在浏览器控制台输入 documentconsole.dir(document),直接查看当前页面的完整 DOM 树结构。

image.png

2. 解析 CSS,构建 CSSOM 树

浏览器读取所有 CSS(内联、内嵌、外部 CSS),解析样式规则,生成 CSSOM(CSS 对象模型)树。CSSOM 树是样式的 “规则集”,记录了每个元素该应用的样式(如颜色、大小、位置等),且会考虑样式的优先级(如行内样式 > ID 选择器 > 类选择器)。

/* 原始CSS */
.box {
  width: 200px;
  background: #f0f0f0;
}
.box p {
  color: red;
  font-size: 16px;
}

对应的简化 CSSOM 树结构:

.box
├─ width: 200px
├─ background: #f0f0f0
└─ .box p
   ├─ color: red
   └─ font-size: 16px

image.png

:CSSOM 树会自动处理样式继承和优先级,比如<p>会继承<div>的部分样式(如字体),但优先应用自身的样式规则

3. 合并 DOM 树和 CSSOM 树,生成渲染树

浏览器会将 DOM 树CSSOM 树 合并,只保留 “需要显示的元素”(如<body>内的可见元素,排除<head>display: none的元素),并为每个元素绑定对应的样式规则,最终形成渲染树。渲染树是 “带样式的骨架”,既包含结构,又包含样式。

4. 布局(Layout / 回流)

基于渲染树,浏览器计算每个元素的精确位置(如 top、left)、大小(width、height)、行高、间距等,这个过程也叫 “回流”。比如计算.box的宽 200px,<p>的字体大小 16px,以及它们在页面中的坐标。

5. 绘制(Paint)

浏览器根据布局结果,将元素的视觉属性(颜色、背景、边框、阴影、图片等)逐个画在屏幕上,最终形成我们看到的可视化页面。

image.png

6. 执行 JS 脚本(穿插在渲染过程中)

JS 脚本的执行会穿插在上述步骤中:

  • 如果 JS 写在<head>且没有defer/async,浏览器会暂停 HTML 解析,先执行 JS(此时 DOM 树可能未构建完成)
  • 如果 JS 操作 DOM/CSS(如document.querySelector('.box').style.color = 'blue'),会触发 DOM/CSSOM 更新,甚至重新布局 / 绘制,这也是为什么频繁操作 DOM 会影响页面性能
// 等待DOM加载完成后执行
document.addEventListener('DOMContentLoaded', function() {
  // 获取DOM节点
  const pTag = document.querySelector('.box p');
  
  // 修改文本(更新DOM)
  pTag.textContent = 'Hello JS + DOM!';
  
  // 修改样式(更新CSSOM,可能触发重绘)
  pTag.style.color = 'blue';
});

六、通信结束:TCP 四次挥手,“友好告别”

如果我们关闭百度页面,客户端和服务器的通信就结束了,这时候 TCP 协议会进行四次挥手,主打一个 “好聚好散,清理资源”

第一步:客户端发起 “分手请求”(FIN 包)

客户端主动发送带有FIN(结束)标志位的数据包,告诉服务器:“我这边已经没有数据要发给你了,准备断开连接啦!”,发送完成后,客户端进入FIN_WAIT_1(等待结束)状态。

第二步:服务器 “收到通知,先回应”(ACK 包)

服务器收到客户端的 FIN 包后,立即回传带有ACK(确认)标志位的数据包,意思是:“我收到你的断开请求了,你先等一等,我这边可能还有剩余数据要处理 / 传输!”,服务器此时进入CLOSE_WAIT(关闭等待)状态,客户端收到 ACK 包后,进入FIN_WAIT_2状态,等待服务器的最终通知。

第三步:服务器 “处理完收尾工作,正式提分手”(FIN 包)

服务器把剩余未传输完的数据全部发送给客户端,确认自身无数据需要传输后,向客户端发送带有FIN标志位的数据包,告知:“我这边数据也都发完了,我也准备好断开连接了!”,发送完成后,服务器进入LAST_ACK(最后确认)状态。

第四步:客户端 “确认收尾,正式断开”(ACK 包)

客户端收到服务器的 FIN 包后,回传带有ACK标志位的数据包,告诉服务器:“收到你的断开确认了,咱们的连接可以彻底断了!”,客户端发送完 ACK 包后会短暂进入TIME_WAIT(时间等待)状态(防止延迟的数据包干扰新连接),服务器收到 ACK 包后立即进入CLOSED(已关闭)状态,释放占用的网络资源;客户端等待一段时间后也进入CLOSED状态,至此 TCP 连接完全断开。

image.png

面试考点:为什么挥手需要四次?

TCP 是全双工通信(简单说就是客户端和服务器能同时向对方发数据),断开连接需要分别关闭 “客户端→服务器” 和 “服务器→客户端” 两个方向的通信。

如果只做三次挥手,服务器就得在收到 FIN 包后,立刻同时发 ACK(确认)和 FIN(关闭)包,但这会导致服务器来不及传输剩余数据,大概率造成数据丢失 —— 毕竟服务器收到断开请求时,可能还囤着要发给客户端的 “尾款数据”,必须先处理完,才能真正说 “分手”

总结:面试该怎么简要概括?

DNS 解析:浏览器通过 DNS 服务器(本地→根→顶级→目标)将域名(如www.baidu.com)解析为对应 IP 地址,拿到服务器的 “网络地址”

TCP 连接:客户端与服务器通过 TCP 三次握手建立可靠连接,确保双向通信的基础

HTTP/HTTPS 请求:浏览器基于 TCP 连接,通过 HTTP/HTTPS 协议向服务器发送资源请求(HTTPS 额外通过 SSL/TLS 加密保障安全)

服务器响应:服务器处理请求后,将 HTML/CSS/JS 等资源通过 TCP 连接回传给浏览器

页面渲染:浏览器先解析 HTML 生成 DOM 树、解析 CSS 生成 CSSOM 树,合并为渲染树后完成布局和绘制,若有 JS 则穿插执行并动态修改页面

连接断开 :通信完成后,通过 TCP 四次挥手断开连接(适配全双工特性,确保数据传输完整)

如果面试的时候需要详细说的话,比如三次握手、四次挥手等,那就看上面的详细解析😊

结语

其实我们总在互联网的协议里寻找答案,以为丢包是意外,延迟是考验,超时是暂时。可直到走过完整的 URL 流程才明白,有些连接从 DNS 解析就注定无果,有些请求再怎么三次握手、四次挥手,也换不来一次响应。就像输入网址后,服务器可以拒绝连接,可以返回错误,可以断开链路,却唯独不会沉默到底。而人生里最真实的道理,从来都藏在这些冰冷的协议中:不必再为一个不愿与你建立连接的人耗尽握手,也别在一段没有响应的关系里反复重传。放过自己,不是停止发送,而是主动挥手,断开这段本就不存在的连接,去遇见那个愿意与你完整完成三次握手、稳稳相伴、好好告别的人。

你的 Vue 3 TS 类型声明,VuReact 会处理成什么样的 React?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:VuReact 如何自动分析 Vue 3 中的响应式依赖,精准生成 React Hooks 的依赖数组

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 和 React 的响应式与依赖追踪机制。

编译对照

Vue 自动依赖分析 → React Hook 依赖数组生成

VuReact 编译器内置了自动依赖分析能力,遵循 React 规则,智能分析顶层箭头函数顶层变量声明中的响应式访问,并生成准确的依赖数组。

  • Vue 代码:
<script setup lang="ts">
  import { reactive, ref } from 'vue';

  const count = ref(0);
  const foo = ref(0);
  const state = reactive({ foo: 'bar', bar: { c: 1 } });

  const fn1 = () => {
    count.value += state.bar.c;
    console.log(count.value);
  };

  const fn = () => {};

  const fn2 = () => {
    const c = foo.value;
    fn();

    const fn4 = () => {
      state.bar.c--;
      c + count.value;
    };
  };

  const fn3 = () => {
    foo.value++;

    const state = ref('fake');
    const count = state.value + 'yoxi';
    count.charAt(1);
  };
</script>
  • VuReact 编译后 React 代码:
const count = useVRef(0);
const foo = useVRef(0);
const state = useReactive({ foo: 'bar', bar: { c: 1 } });

const fn1 = useCallback(() => {
  count.value += state.bar.c;
  console.log(count.value);
}, [count.value, state.bar?.c]);

const fn = () => {};

const fn2 = useCallback(() => {
  const c = foo.value;
  fn();

  const fn4 = () => {
    state.bar.c--;
    c + count.value;
  };
}, [foo.value, state.bar?.c, count.value]);

const fn3 = useCallback(() => {
  foo.value++;

  const state = useVRef('fake');
  const count = state.value + 'yoxi';
  count.charAt(1);
}, [foo.value]);

这段对比展示了:

  • fn1 会被识别为顶层箭头函数并收集 count.valuestate.bar.c
  • fn2 会溯源 c 并忽略局部函数 fn4
  • fn3 会忽略函数内部新建的响应式变量,只收集外部依赖 foo.value

Vue 组合访问与别名追踪

VuReact 也会对复杂别名链和解构访问进行溯源。

  • Vue 代码:
<script setup lang="ts">
  const objRef = ref({ a: 1, b: { c: 1 } });
  const listRef = ref([1, 2, 3]);
  const aliasA = state.foo;
  const aliasB = aliasA;
  const aliasC = aliasB;
  const { foo: stateFoo } = state;
  const [first] = listRef.value;

  const traceFn = () => {
    aliasC;
  };

  const destructureFn = () => {
    stateFoo;
    first;
  };
</script>
  • VuReact 编译后 React 代码:
const objRef = useVRef({ a: 1, b: { c: 1 } });
const listRef = useVRef([1, 2, 3]);
const aliasA = useMemo(() => state.foo, [state.foo]);
const aliasB = useMemo(() => aliasA, [aliasA]);
const aliasC = useMemo(() => aliasB, [aliasB]);
const { foo: stateFoo } = useMemo(() => state, [state]);
const [first] = useMemo(() => listRef.value, [listRef.value]);

const traceFn = useCallback(() => {
  aliasC;
}, [aliasC]);

const destructureFn = useCallback(() => {
  stateFoo;
  first;
}, [stateFoo, first]);

这样可见:

  • alias 链会被逐层溯源到真实响应式来源;
  • 解构后的变量也会通过 useMemo 转换为可追踪依赖。

Vue 顶层变量声明 → React useMemo 依赖数组生成

  • Vue 代码:
<script setup lang="ts">
  const fooRef = ref(0);
  const reactiveState = reactive({ foo: 'bar', bar: { c: 1 } });

  const memoizedObj = {
    title: 'test',
    bar: fooRef.value,
    add: () => {
      reactiveState.bar.c++;
    },
  };

  let staticObj = {
    foo: 1,
    state: { bar: { c: 1 } },
  };

  const reactiveList = [fooRef.value, 1, 2];

  const mixedList = [
    { name: reactiveState.foo, age: fooRef.value },
    { name: 'A', age: 20 },
  ];

  const nestedObj = {
    a: {
      b: {
        c: reactiveList[0],
        d: () => {
          return memoizedObj.bar;
        },
      },
      e: mixedList,
    },
  };
</script>
  • VuReact 编译后 React 代码:
const memoizedObj = useMemo(
  () => ({
    title: 'test',
    bar: fooRef.value,
    add: () => {
      reactiveState.bar.c++;
    },
  }),
  [fooRef.value, reactiveState.bar?.c],
);

let staticObj = {
  foo: 1,
  state: {
    bar: {
      c: 1,
    },
  },
};

const reactiveList = useMemo(() => [fooRef.value, 1, 2], [fooRef.value]);

const mixedList = useMemo(
  () => [
    { name: reactiveState.foo, age: fooRef.value },
    { name: 'A', age: 20 },
  ],
  [reactiveState.foo, fooRef.value],
);

const nestedObj = useMemo(
  () => ({
    a: {
      b: {
        c: reactiveList[0],
        d: () => {
          return memoizedObj.bar;
        },
      },
      e: mixedList,
    },
  }),
  [reactiveList[0], memoizedObj.bar, mixedList],
);

这里的核心对比是:

  • memoizedObj 会收集对象内部的响应式字段与方法依赖;
  • staticObj 因为不含响应式访问,不会被优化为 useMemo
  • reactiveListmixedListnestedObj 会根据结构递归补齐依赖数组。

自动依赖分析的三大原则

  1. 仅分析顶层可优化表达式:局部函数、嵌套作用域不纳入顶层 Hook 自动优化;
  2. 遵循 React 依赖规则:只收集函数/变量外部的响应式访问,而非内部局部变量;
  3. 避免过度优化:无外部响应式依赖的顶层箭头函数和变量不会被强制转换为 Hook。

为什么这很关键?

在 React 中,函数组件每次渲染会重新创建顶层函数与变量。如果这些顶层表达式依赖响应式状态且未获得稳定性处理,会带来:

  • 不必要的子组件重新渲染;
  • 频繁的 Hook 重新计算;
  • 性能不可控的回调变化。

VuReact 在编译阶段自动生成准确依赖数组,既保留了 Vue 写法的简洁性,又实现了 React 端的性能优化。

相关资源


如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

「JS全栈AI学习」九、Multi-Agent 系统设计:架构与编排

📌 系列简介:「JS全栈AI Agent学习」系统学习 21 个 Agent 设计模式,篇数随学习进度持续更新。

📖 原书地址adp.xindoo.xyz

前端转 JS 全栈,正在学 AI,理解难免有偏差,欢迎批评指正 ~


往期系列导航

主题
第一篇 提示链 · 路由 · 并行化
第二篇 反思 · 工具使用 · 规划
第三篇 多智能体 · 记忆管理 · 学习适应
第四篇 MCP:给AI工具世界造一个USB接口
第五篇 目标设定与监控 · 异常处理与恢复
第六篇 Human-in-the-Loop 设计
第七篇 深入理解 RAG(检索增强生成)技术
第八篇 A2A 协议完全指南:理解 Agent 协作体系
本篇 Multi-Agent 系统设计:架构与编排

写在前面

这个系列前几篇,我们从 RAG 开始——把简历切片、向量化、检索,让 AI 能"翻书再答题"。 再到 A2A 协议——搞清楚多个 Agent 之间怎么互相发现、互相协作、互相信任。

但学到这里,我意识到还有一个问题没解决:

Agent 之间协作,谁来指挥?谁来编排?出了问题谁来兜底?

这就是 Multi-Agent 系统设计要回答的问题。

这篇是这个话题的第一篇,聚焦在三件事:架构选择、动态编排、状态管理。 用"旅行规划"这个场景贯穿始终——不是因为它特别,而是因为它足够复杂,能把问题说清楚。

九、十、十一 3篇对应学习的 第15章:Multi-Agent 系统架构、第16章:工作流编排与规划、第17章:成本优化与执行策略 很多孤立起来说没意义,加上 multi-agent 比较重要就放一起了


目录

  1. 为什么需要 Multi-Agent?
  2. 架构设计:中心化 vs 去中心化
  3. 动态工作流编排
  4. 上下文管理
  5. 状态管理与一致性
  6. Human-in-the-loop
  7. 完整流程串联
  8. 总结

Multi-Agent 系统设计


1. 为什么需要 Multi-Agent?

单 Agent 的局限

假设用户说:"帮我规划一次去三亚的旅行,预算 5000 元。"

如果用单个 Agent 处理,它需要同时具备:理解意图、查航班、查酒店、查景点、规划路线、计算预算……

这些能力混在一起,代码会变得臃肿且难以维护。更重要的是,每个环节都有专业知识和外部 API,单个 Agent 很难做到精通所有领域。

分工协作的思路

借鉴现实世界的分工,我们可以设计多个专业的 Agent,各司其职:

NLU Agent      → 理解用户意图
Profile Agent  → 分析用户偏好
Planner Agent  → 制定整体计划
Flight Agent   → 查询航班信息
Hotel Agent    → 查询酒店信息

好处很直接:

  • 职责清晰:每个 Agent 只做一件事,做好一件事
  • 易于维护:修改航班查询逻辑,只需要改 Flight Agent
  • 可复用:Flight Agent 可以用在其他旅行相关场景

这和前面学 A2A 时的思路是一脉相承的——A2A 解决的是 Agent 之间"怎么通信",Multi-Agent 设计解决的是"怎么协作"。


2. 架构设计:中心化 vs 去中心化

Multi-Agent 系统有两种常见的架构模式,选哪个,取决于场景。

中心化架构(Coordinator 模式)

有一个中心协调者(Coordinator)负责调度所有 Agent:

class Coordinator {
  async execute(userInput: string): Promise<Result> {
    // 串行:理解意图 → 分析画像 → 制定计划
    const intent   = await this.agents.nlu.execute({ userInput });
    const profile  = await this.agents.profile.execute({ intent });
    const plan     = await this.agents.planner.execute({ intent, profile });

    // 并行:同时查询航班、酒店、景点
    const [flights, hotels, attractions] = await Promise.all([
      this.agents.flight.execute({ plan }),
      this.agents.hotel.execute({ plan }),
      this.agents.attraction.execute({ plan }),
    ]);

    return this.integrate({ flights, hotels, attractions });
  }
}

流程清晰,统一的错误处理和状态管理,便于调试——代价是 Coordinator 是单点,压力大。

去中心化架构(P2P 模式)

Agent 之间通过消息总线直接通信,没有中心协调者:

// Flight Agent 完成后,发布事件,其他 Agent 自行订阅响应
class FlightAgent {
  async execute(context: Context): Promise<Result> {
    const result = await this.queryFlights(context);
    this.messageBus.publish({ type: 'flights_ready', data: result });
    return result;
  }
}

没有单点瓶颈,扩展性好——代价是流程不直观,调试困难。

我的选择

对于旅行规划这种有明确步骤的场景,我选择了中心化架构

原因很简单:旅行规划有清晰的先后顺序(理解意图 → 制定计划 → 查询信息 → 整合结果),需要强一致性(预算控制不能各个 Agent 各自为政),也需要便于调试。

如果是实时监控、事件驱动的场景,去中心化可能更合适。架构没有对错,只有合不合适。


3. 动态工作流编排

有了架构,接下来的问题是:如何编排这些 Agent 的执行顺序?

静态编排的问题

最简单的方式是写死流程——但太死板了:

  • 如果用户直接说"帮我订明天去北京的机票",还需要分析画像吗?
  • 如果用户已经订好了酒店,还需要查询酒店吗?

动态主导权转移

我想到一个思路:让 Agent 自己决定下一步该谁执行

就像接力赛,当前跑的人决定把棒交给谁——流程就灵活了:

class Agent {
  async execute(context: Context): Promise<ExecutionResult> {
    const result = await this.doWork(context);

    // 根据当前状态,决定把主导权交给谁
    const nextAgent = this.decideNextAgent(context, result);

    return { result, nextAgent, context: this.updateContext(context, result) };
  }

  private decideNextAgent(context: Context, result: any): string | null {
    if (context.needsFlightInfo && !context.hasFlightInfo) return 'flight_agent';
    if (context.needsHotelInfo  && !context.hasHotelInfo)  return 'hotel_agent';
    return null; // 没有下一步了
  }
}

Coordinator 只需要不断传递主导权,直到没有下一步:

class DynamicCoordinator {
  async execute(userInput: string): Promise<Result> {
    let context     = this.initContext(userInput);
    let currentAgent = 'nlu_agent';

    while (currentAgent) {
      const { result, nextAgent, context: newContext } =
        await this.agents.get(currentAgent).execute(context);

      context      = newContext;
      currentAgent = nextAgent; // 主导权转移
    }

    return context.finalResult;
  }
}

这个思路让我想起乾卦的"时乘六龙以御天"——不是死守固定的步骤,而是顺应时机,动态调整。

充分条件原则

动态编排带来一个新问题:Agent 怎么知道自己能不能执行?

我的答案是:定义每个 Agent 的前置条件,条件不满足就反向补全

class FlightAgent {
  canExecute(context: Context): boolean {
    return context.has('destination') &&
           context.has('departureCity') &&
           context.has('travelDate');
  }

  async execute(context: Context): Promise<Result> {
    const missing = this.checkMissing(context);

    if (missing.length > 0) {
      // 反向传播:请求 NLU Agent 补全缺失信息
      context.requestInfo(missing);
      return { status: 'pending', nextAgent: 'nlu_agent' };
    }

    return await this.queryFlights(context);
  }
}

这就像神经网络的反向传播:从目标反推需要什么输入,然后向前传播补全信息。


4. 上下文管理

多个 Agent 协作,必然涉及信息共享。Context 的设计很关键。

上下文的结构

interface Context {
  requestId: string;
  traceId: string;       // 链路追踪

  user: { id: string; preferences: UserPreferences };

  intent: Intent;
  destination: string;
  budget: number;

  completedAgents: string[];
  results: Map<string, any>;
}

只传递必要的信息

不是所有信息都需要传递。我的原则是:每个 Agent 只提取自己需要的,只返回必要的结果

class FlightAgent {
  async execute(context: Context): Promise<Result> {
    // 只提取需要的字段
    const { destination, departureCity, travelDate, budget } = context;

    const flights = await this.queryFlights({
      destination, departureCity, travelDate,
      maxPrice: budget * 0.4,  // 航班预算占总预算 40%
    });

    // 只返回必要的结果,不把原始数据全部往下传
    return {
      flights: flights.slice(0, 5),
      cheapestPrice: flights[0].price,
      recommendedFlight: this.selectBest(flights),
    };
  }
}

信息过载和信息不足一样危险——这是做 RAG 时就踩过的坑,在 Multi-Agent 里同样成立。


5. 状态管理与一致性

当多个 Agent 并行执行时,会遇到状态一致性问题。

问题场景

t0: 用户说"预算 5000 元"
t1: Flight Agent  Hotel Agent 同时开始查询(基于 5000 元)
t2: 用户说"我想把预算改成 8000 元"
问题:Flight Agent 已经查完了,结果还有效吗?

版本控制

解决方案:给上下文加版本号

class StateManager {
  private version = 0;

  // 更新上下文时,版本号递增
  updateContext(updates: Partial<Context>): void {
    this.version++;
    this.context = { ...this.context, ...updates, version: this.version };
  }

  // Agent 开始执行时,创建快照(记录当前版本)
  createSnapshot(agentId: string): ContextSnapshot {
    return { version: this.version, context: { ...this.context }, agentId };
  }

  // Agent 提交结果时,检查版本是否一致
  submitResult(agentId: string, result: any, snapshotVersion: number): boolean {
    if (snapshotVersion < this.version) {
      console.log(`${agentId} 的结果已过期,需要重新执行`);
      return false;
    }
    return true;
  }
}

乐观锁 vs 悲观锁

对于状态冲突,有两种策略:

  • 乐观锁:先执行,提交时检查版本——适合读多写少的场景(查询航班)
  • 悲观锁:先加锁,执行完再释放——适合写操作(预订机票)

我的选择是混合策略:查询用乐观锁,性能高;预订用悲观锁,保证一致性。

这个思路和数据库事务设计是一回事——底层的逻辑,跨越了层次,是相通的。


6. Human-in-the-loop

完全自动化不一定是最好的。有时候,让用户参与决策反而更好。

最小干预原则

我的原则是:只在关键决策点询问用户,其他信息能推断就推断,能用默认值就用默认值

class ProgressiveConfirmation {
  async execute(userInput: string): Promise<Result> {
    const intent = await this.nluAgent.execute({ userInput });

    // 只问缺失的关键信息
    if (!intent.destination) {
      intent.destination = await this.askUser("您想去哪里?");
    }

    // 非关键信息:推断或使用默认值
    intent.budget     = intent.budget     || this.inferBudget(intent);
    intent.travelDate = intent.travelDate || this.getDefaultDate();

    // 非关键信息在后续流程中再问,不要一次性问完
    return this.continueExecution(intent);
  }
}

一次性问用户十个问题,用户会直接关掉。逐步确认,每次只问最关键的那一个。

何时必须让用户介入?

回顾前面学 A2A 时总结的四种情况,在 Multi-Agent 编排里同样适用:

  1. 权限/能力边界:Agent 遇到了自己无权处理的事
  2. 死锁/僵局:系统自己解不开
  3. 高风险不可逆操作:预订、付款、发送——做了就很难撤回
  4. 置信度低于阈值:Agent 不够确定,不该自己做主

7. 完整流程串联

把上面的思路串起来,看一个完整的执行流程:

class TravelPlanningSystem {
  async plan(userInput: string): Promise<TravelPlan> {
    // 1. 初始化上下文
    const context = { traceId: generateId(), version: 0, userInput, results: new Map() };

    // 2. NLU → 补全缺失信息 → Profile → Planner(串行)
    const intent = await this.nluAgent.execute(context);
    if (!intent.destination) {
      intent.destination = await this.askUser("您想去哪里?");
    }
    context.intent  = intent;
    context.profile = await this.profileAgent.execute(context);
    context.plan    = await this.plannerAgent.execute(context);

    // 3. 并行查询(带版本快照)
    const snapshot = this.stateManager.createSnapshot('parallel_query');
    const [flights, hotels, attractions] = await Promise.all([
      this.flightAgent.execute(snapshot.context),
      this.hotelAgent.execute(snapshot.context),
      this.attractionAgent.execute(snapshot.context),
    ]);

    // 4. 检查版本冲突(用户可能中途修改了预算)
    if (snapshot.version < this.stateManager.currentVersion) {
      return this.plan(userInput); // 重新规划
    }

    // 5. 整合结果
    return this.integrate({ flights, hotels, attractions, plan: context.plan });
  }
}

流程里有几个细节值得注意:

  • 串行和并行混用——有依赖关系的步骤串行,独立的步骤并行
  • 版本快照在并行开始前创建,不是在结束后
  • 版本冲突时直接重新规划,不是尝试修补

8. 总结

这篇学到的几个判断

架构选择没有对错,只有合不合适。 有明确流程的场景用中心化,事件驱动的场景用去中心化。

动态编排比静态编排更灵活,但更难调试。 主导权转移的思路很好,但要做好链路追踪,不然出了问题很难定位。

充分条件原则是 Multi-Agent 设计的基础。 每个 Agent 都应该知道自己需要什么、能做什么、做不了的时候该怎么办。

状态一致性是并行执行的核心挑战。 版本控制 + 混合锁策略,是目前我觉得最实用的解法。

和前面内容的关系

回头看这个系列走过的路:

RAG          → 让 Agent 能"翻书再答题"(知识检索)
A2A 协议     → 让 Agent 之间能互相发现、协作、信任(通信协议)
Multi-Agent  → 让多个 Agent 能有序地协同完成复杂任务(编排调度)

每一层都在解决上一层留下的问题。


写在最后

学这一章的时候,有一个细节让我停下来想了一下。

动态主导权转移那里,每个 Agent 在执行完之后,都要做一个判断:下一步该谁?

不是由外部强行指定,而是由当前执行者根据现状来决定。

这让我想起易经里的一个说法:"知几其神乎"——几,是事物变化的苗头,是时机的信号。 真正懂得顺势而为的人,不是按计划行事,而是在每一个当下,感知现状,做出最合适的那个判断。

Multi-Agent 的动态编排,其实是在用代码实现这件事: 不是写死流程,而是让每个节点都有感知、有判断、有选择。

系统如此,人也如此。


昇哥 · 2026年4月 学 Multi-Agent 系统设计途中,把想清楚的事写下来

Vue3 KeepAlive 深度揭秘:组件缓存的魔法是如何实现的?

Vue3 KeepAlive 深度揭秘:组件缓存的魔法是如何实现的?

本文将带你深入 Vue3 内核,从源码层面彻底搞懂 KeepAlive 组件的缓存机制、LRU 淘汰策略以及组件"失活"与"激活"的底层实现原理。

📋 文章导航


1. 为什么需要 KeepAlive?

1.1 实际业务场景

在开发后台管理系统或多标签页应用时,我们经常会遇到这样的需求:

  • 表单页面:用户填写了一半的表单,切换到其他页面查看资料,返回时期望表单数据还在
  • 列表页面:滚动到第 N 页,查看详情后返回,期望回到原来的滚动位置
  • 地图应用:地图已经缩放和平移到特定位置,切换页面后返回保持原状

1.2 没有 KeepAlive 的问题

<template>
  <button @click="currentView = 'A'">页面A</button>
  <button @click="currentView = 'B'">页面B</button>

  <!-- 普通动态组件切换 -->
  <component :is="currentView" />
</template>

<script setup>
import { ref } from "vue";
import ViewA from "./ViewA.vue";
import ViewB from "./ViewB.vue";

const currentView = ref("ViewA");
</script>

问题:当从 A 切换到 B 时,A 组件会被完全销毁(触发 onUnmounted),状态全部丢失。再切回 A 时,组件重新创建,所有数据重置。

1.3 KeepAlive 的解决方案

KeepAlive 通过组件级缓存完美解决这个问题:

  • 组件切换时不会销毁,而是进入"失活"状态
  • 组件实例、响应式数据、DOM 状态全部保留
  • 切换回来时"激活",瞬间恢复,无需重新渲染

2. KeepAlive 基础使用

2.1 基本用法

<template>
  <button
    v-for="tab in tabs"
    :key="tab"
    @click="currentTab = tab"
    :class="{ active: currentTab === tab }"
  >
    {{ tab }}
  </button>

  <!-- 使用 KeepAlive 包裹动态组件 -->
  <KeepAlive>
    <component :is="currentTab" />
  </KeepAlive>
</template>

<script setup>
import { ref } from "vue";
import Home from "./Home.vue";
import Posts from "./Posts.vue";
import Archive from "./Archive.vue";

const currentTab = ref("Home");
const tabs = ["Home", "Posts", "Archive"];
</script>

2.2 重要限制

⚠️ KeepAlive 只能缓存单个直接子节点

<!-- ❌ 错误:多个根节点 -->
<KeepAlive>
  <CompA />
  <CompB />
</KeepAlive>

<!-- ✅ 正确:使用动态组件包裹 -->
<KeepAlive>
  <component :is="activeComponent" />
</KeepAlive>

<!-- ✅ 正确:使用 v-if 切换单个组件 -->
<KeepAlive>
  <CompA v-if="showA" />
  <CompB v-else />
</KeepAlive>

3. 核心属性详解

3.1 属性一览表

属性 类型 说明
include string | RegExp | Array 只有名称匹配的组件会被缓存
exclude string | RegExp | Array 任何名称匹配的组件都不会被缓存
max number | string 最多可以缓存多少组件实例

3.2 include - 白名单缓存

<!-- 字符串形式(逗号分隔) -->
<KeepAlive include="Home,Posts">
  <component :is="currentTab" />
</KeepAlive>

<!-- 数组形式 -->
<KeepAlive :include="['Home', 'Posts']">
  <component :is="currentTab" />
</KeepAlive>

<!-- 正则表达式 -->
<KeepAlive :include="/^User/">
  <component :is="currentTab" />
</KeepAlive>

匹配规则:与组件的 name 选项进行匹配

<script>
export default {
  name: "Home", // 这个名字用于 include/exclude 匹配
  // ...
};
</script>

<!-- 或者使用 script setup -->
<script setup>
defineOptions({
  name: "Home",
});
</script>

3.3 exclude - 黑名单排除

<!-- 不缓存 Archive 组件 -->
<KeepAlive exclude="Archive">
  <component :is="currentTab" />
</KeepAlive>

<!-- 排除多个 -->
<KeepAlive :exclude="['Archive', 'Settings']">
  <component :is="currentTab" />
</KeepAlive>

3.4 max - LRU 缓存淘汰

<KeepAlive :max="5">
  <component :is="currentTab" />
</KeepAlive>

LRU (Least Recently Used) 算法

  1. 设置最大缓存数为 5
  2. 依次访问 A → B → C → D → E,全部缓存
  3. 访问 F 时,缓存已满,淘汰最久未使用的 A
  4. 访问 B,B 变为最近使用
  5. 访问 G,淘汰 C(现在 C 是最久未使用的)
缓存状态变化示意:

初始: []
访问A: [A]
访问B: [A, B]
访问C: [A, B, C]
访问D: [A, B, C, D]
访问E: [A, B, C, D, E]  ← 达到 max
访问F: [B, C, D, E, F]A 被淘汰
访问B: [C, D, E, F, B]B 移到最近使用
访问G: [D, E, F, B, G]C 被淘汰

4. 专属生命周期钩子

被 KeepAlive 缓存的组件会新增两个生命周期钩子:

4.1 生命周期对比

普通组件:          KeepAlive 缓存组件:
   onMounted           onMounted (首次)
       ↓                   ↓
   onUnmounted      onActivated (每次激活)
                          ↓
                     onDeactivated (失活)
                          ↓
                     onActivated (再次激活)
                          ↓
                     onDeactivated
                          ↓
                     onUnmounted (真正销毁时)

4.2 钩子函数详解

<script setup>
import { onMounted, onUnmounted, onActivated, onDeactivated } from "vue";

// 首次挂载时触发(仅一次)
onMounted(() => {
  console.log("组件首次挂载");
  // 适合执行一次性初始化:建立 WebSocket 连接、获取基础配置等
});

// 每次从缓存激活时触发
onActivated(() => {
  console.log("组件被激活");
  // 适合执行:恢复定时器、重新获取最新数据、恢复滚动位置等
});

// 组件被缓存时触发
onDeactivated(() => {
  console.log("组件被失活(进入缓存)");
  // 适合执行:暂停定时器、保存临时状态等
});

// 组件真正被销毁时触发(仅一次)
onUnmounted(() => {
  console.log("组件被销毁");
  // 清理工作:关闭 WebSocket、清除全局事件监听等
});
</script>

4.3 实际应用示例

<script setup>
import { ref, onActivated, onDeactivated } from "vue";

const scrollTop = ref(0);
const timer = ref(null);
const listData = ref([]);

// 激活时恢复状态
onActivated(() => {
  // 恢复滚动位置
  const container = document.querySelector(".list-container");
  if (container) {
    container.scrollTop = scrollTop.value;
  }

  // 重启定时刷新
  timer.value = setInterval(fetchLatestData, 5000);

  // 重新获取最新数据(可选)
  fetchLatestData();
});

// 失活时保存状态
onDeactivated(() => {
  // 保存滚动位置
  const container = document.querySelector(".list-container");
  if (container) {
    scrollTop.value = container.scrollTop;
  }

  // 暂停定时刷新
  if (timer.value) {
    clearInterval(timer.value);
    timer.value = null;
  }
});

async function fetchLatestData() {
  // 获取最新数据...
}
</script>

5. 底层实现原理

5.1 核心问题拆解

KeepAlive 要实现组件缓存,必须解决三个核心问题:

问题 解决方案
如何保存组件状态? 使用 Map 缓存组件的 VNode
如何识别缓存组件? 通过 shapeFlag 标记组件状态
如何让组件"隐藏"而不是销毁? 使用 move 函数将 DOM 移入隐藏容器

5.2 组件状态标记

Vue3 使用 shapeFlag 来标记 VNode 的类型和状态:

// 组件需要被缓存(进入缓存流程)
const COMPONENT_SHOULD_KEEP_ALIVE = 1 << 8; // 256

// 组件已被缓存(从缓存恢复)
const COMPONENT_KEPT_ALIVE = 1 << 9; // 512

标记的作用

  1. COMPONENT_SHOULD_KEEP_ALIVE:告诉渲染器这个组件不应该被销毁,而是执行失活流程
  2. COMPONENT_KEPT_ALIVE:告诉渲染器这个组件来自缓存,不需要重新创建实例

5.3 缓存与隐藏机制

┌─────────────────────────────────────────────────────────────┐
│                      KeepAlive 组件                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────────────┐      ┌──────────────────────────┐    │
│   │   cache (Map)   │      │   storageContainer       │    │
│   │                 │      │   (隐藏的 div 容器)       │    │
│   │  key → VNode    │      │                          │    │
│   │  key → VNode    │      │  ┌──────────────────┐    │    │
│   │  key → VNode    │      │  │  被缓存的 DOM    │    │    │
│   │                 │      │  │  ┌──┐ ┌──┐ ┌──┐  │    │    │
│   └─────────────────┘      │  │  │A │ │B │ │C │  │    │    │
│                            │  │  └──┘ └──┘ └──┘  │    │    │
│   ┌─────────────────┐      │  └──────────────────┘    │    │
│   │   keys (Set)    │      │                          │    │
│   │                 │      └──────────────────────────┘    │
│   │  [A, B, C]      │                                      │
│   │  ↑  LRU 顺序    │                                      │
│   └─────────────────┘                                      │
│                                                             │
└─────────────────────────────────────────────────────────────┘

5.4 流程图解

首次渲染组件 A:
    │
    ▼
┌─────────────────┐
│  检查 cache     │
│  是否已有 A?   │
└────────┬────────┘
         │ 否
         ▼
┌─────────────────┐
│  正常创建组件 A  │
│  渲染 DOM        │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  存入 cache     │
│  A → VNode      │
│  keys.add(A)    │
└─────────────────┘

切换到组件 B:
    │
    ▼
┌─────────────────┐
│  组件 A 失活     │
│  (不是销毁!)    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  调用 _deActivate│
│  将 A 的 DOM    │
│  移入隐藏容器    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  渲染组件 B      │
└─────────────────┘

切回组件 A:
    │
    ▼
┌─────────────────┐
│  检查 cache     │
│  是否已有 A?   │
└────────┬────────┘
         │ 是
         ▼
┌─────────────────┐
│  命中缓存!      │
│  复用 VNode     │
│  复用组件实例    │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  调用 _activate │
│  将 A 的 DOM    │
│  从隐藏容器移出  │
│  插入页面        │
└─────────────────┘
         │
         ▼
┌─────────────────┐
│  触发 onActivated│
└─────────────────┘

6. 源码深度解析

6.1 完整源码注释版

// packages/runtime-core/src/components/KeepAlive.ts

import {
  type VNode,
  type ComponentInternalInstance,
  type SetupContext,
  type RendererInternals,
  type RendererElement,
  type RendererNode,
  ShapeFlags,
  currentInstance,
  unmountComponent,
  callWithAsyncErrorHandling,
  onBeforeUnmount,
  type Slots,
  type FunctionalComponent,
  type Component,
  type ComponentOptions,
  type VNodeNormalizedChildren,
  type VNodeChild,
  setTransitionHooks,
  type TransitionHooks,
} from "@vue/runtime-core";

export interface KeepAliveProps {
  include?: MatchPattern;
  exclude?: MatchPattern;
  max?: number | string;
}

type MatchPattern = string | RegExp | (string | RegExp)[];

export const KeepAliveImpl: ComponentOptions = {
  name: `KeepAlive`,

  // 标记这是一个 KeepAlive 组件
  __isKeepAlive: true,

  props: {
    include: [String, RegExp, Array] as PropType<MatchPattern>,
    exclude: [String, RegExp, Array] as PropType<MatchPattern>,
    max: [String, Number],
  },

  setup(props: KeepAliveProps, { slots }: SetupContext) {
    // ==================== 1. 获取组件实例和渲染器方法 ====================
    const instance = currentInstance!;

    // 从组件实例中获取渲染器注入的方法
    // move: 移动 DOM 节点
    // createElement: 创建 DOM 元素
    const { move, createElement } = instance.ctx.renderer as RendererInternals<
      RendererNode,
      RendererElement
    >;

    // ==================== 2. 创建存储容器 ====================
    // storageContainer 是一个普通的 div,用于存放被失活的组件 DOM
    const storageContainer = createElement("div");

    // ==================== 3. 定义激活/失活方法 ====================

    /**
     * 失活组件:将组件的 DOM 移动到隐藏容器
     * @param vnode 被失活的组件 VNode
     * @param container 当前容器(未使用,保持一致性)
     * @param anchor 锚点(未使用)
     */
    instance.ctx.deactivate = (vnode: VNode) => {
      move(vnode, storageContainer, null, MoveType.LEAVE);
    };

    /**
     * 激活组件:将组件的 DOM 从隐藏容器移回页面
     * @param vnode 被激活的组件 VNode
     * @param container 目标容器
     * @param anchor 锚点位置
     * @param isSVG 是否是 SVG
     * @param optimized 是否优化模式
     */
    instance.ctx.activate = (
      vnode: VNode,
      container: RendererElement,
      anchor: RendererNode | null,
      isSVG: boolean,
      optimized: boolean,
    ) => {
      const vnodeComponent = vnode.component!;

      // 将 DOM 移回页面
      move(vnode, container, anchor, MoveType.ENTER, isSVG);

      // 处理过渡动画
      if (vnodeComponent.da) {
        // 延迟激活(等待延迟显示动画完成)
        queuePostRenderEffect(() => {
          vnodeComponent.da!(vnodeComponent.vnode);
        }, instance.suspense);
      }
    };

    // ==================== 4. 缓存相关变量 ====================
    const cache: Map<string, VNode> = new Map(); // 缓存容器:key -> VNode
    const keys: Set<string> = new Set(); // 记录缓存顺序,用于 LRU
    let current: VNode | null = null; // 当前正在渲染的组件
    let pendingCacheKey: string | null = null; // 待缓存的 key

    // ==================== 5. 缓存清理函数 ====================

    /**
     * 根据 key 淘汰缓存条目
     * 当缓存超过 max 时,淘汰最久未使用的组件
     */
    function pruneCacheEntry(key: string) {
      const cached = cache.get(key);
      if (!cached) return;

      // 如果当前正在渲染的组件不是要淘汰的,触发 deactivated 钩子
      if (current !== cached) {
        const comp = cached.component!;
        if (!comp.isDeactivated) {
          // 调用 deactivated 生命周期钩子
          callWithAsyncErrorHandling(
            comp.type.deactivated,
            comp,
            ErrorCodes.COMPONENT_DEACTIVATED,
          );
          comp.isDeactivated = true;
        }
      }

      // 从缓存中移除
      cache.delete(key);
      keys.delete(key);
    }

    /**
     * 清空所有缓存
     */
    function pruneCache() {
      cache.forEach((cached, key) => {
        pruneCacheEntry(key);
      });
    }

    // ==================== 6. 监听 props 变化 ====================

    // 当 include/exclude 变化时,清理不再匹配的缓存
    watch(
      () => [props.include, props.exclude],
      ([include, exclude]) => {
        // 清理不再满足 include/exclude 条件的缓存
        cache.forEach((vnode, key) => {
          const name = getName(vnode);
          if (
            name &&
            (!include || !matches(include, name)) &&
            exclude &&
            matches(exclude, name)
          ) {
            pruneCacheEntry(key);
          }
        });
      },
      { flush: "post", deep: true },
    );

    // ==================== 7. 组件卸载时清理 ====================

    onBeforeUnmount(() => {
      cache.forEach((vnode) => {
        const { shapeFlag, component } = vnode;
        // 如果组件还在激活状态,需要手动卸载
        if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
          unmountComponent(component!);
        }
      });
    });

    // ==================== 8. 核心渲染逻辑 ====================

    return () => {
      // 获取默认插槽的第一个子节点
      const rawVNode = slots.default && slots.default();

      // 如果没有子节点,直接返回
      if (!rawVNode || rawVNode.length !== 1) {
        if (__DEV__ && rawVNode && rawVNode.length > 1) {
          warn(`KeepAlive should contain exactly one component child.`);
        }
        current = null;
        return rawVNode;
      }

      // 获取内部真实组件(处理 Teleport 等包裹情况)
      const vnode = getInnerChild(rawVNode[0]);
      const comp = vnode.type as Component;

      // 获取组件名称用于 include/exclude 匹配
      const name = getName(vnode);

      // 检查是否应该缓存
      const shouldCache = !(
        name &&
        ((props.include && !matches(props.include, name)) ||
          (props.exclude && matches(props.exclude, name)))
      );

      // 获取缓存 key
      const key = vnode.key == null ? comp : vnode.key;
      const cachedVNode = cache.get(key);

      // ==================== 8.1 命中缓存 ====================
      if (cachedVNode) {
        // 复用缓存的组件实例
        vnode.component = cachedVNode.component;
        vnode.shapeFlag |= ShapeFlags.COMPONENT_KEPT_ALIVE;

        // 更新 LRU 顺序:先删除再添加,确保在 Set 末尾(最近使用)
        keys.delete(key);
        keys.add(key);
      }
      // ==================== 8.2 未命中缓存 ====================
      else if (shouldCache) {
        // 存入新缓存
        cache.set(key, vnode);
        keys.add(key);

        // LRU 淘汰:如果超过 max,删除最久未使用的
        if (props.max && keys.size > parseInt(props.max as string, 10)) {
          pruneCacheEntry(keys.values().next().value);
        }
      }

      // 标记组件需要被缓存(影响卸载流程)
      vnode.shapeFlag |= ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE;
      current = vnode;

      return rawVNode;
    };
  },
};

// 辅助函数:获取组件名称
function getName(vnode: VNode): string | undefined {
  return (
    (vnode.type as ComponentOptions).name ||
    (vnode.type as ComponentOptions).__name ||
    (typeof vnode.type === "function" &&
      (vnode.type as FunctionalComponent).name)
  );
}

// 辅助函数:匹配模式
function matches(pattern: MatchPattern, name: string): boolean {
  if (isArray(pattern)) {
    return pattern.some((p) => matches(p, name));
  } else if (isString(pattern)) {
    return pattern.split(",").includes(name);
  } else if (isRegExp(pattern)) {
    return pattern.test(name);
  }
  return false;
}

6.2 关键逻辑解析

6.2.1 为什么使用 Map 和 Set?
const cache: Map<string, VNode> = new Map(); // 快速查找:O(1)
const keys: Set<string> = new Set(); // 保持插入顺序,支持 LRU
  • Map:提供 O(1) 的查找效率,适合频繁读取缓存
  • Set:保持插入顺序,且可以方便地获取"第一个"元素(最久未使用)
6.2.2 LRU 淘汰实现
// 更新 LRU 顺序
keys.delete(key); // 先删除旧位置
keys.add(key); // 再添加到末尾(最近使用)

// 淘汰最久未使用的
if (max && keys.size > max) {
  pruneCacheEntry(keys.values().next().value); // 获取并删除第一个
}
6.2.3 渲染器如何配合 KeepAlive?
// packages/runtime-core/src/renderer.ts

// 在组件卸载流程中
function unmountComponent(instance) {
  const { shapeFlag } = instance.vnode;

  // 检查是否是 KeepAlive 缓存的组件
  if (shapeFlag & ShapeFlags.COMPONENT_SHOULD_KEEP_ALIVE) {
    // 不销毁,而是调用 deactivate
    const { deactivate } = instance.parent?.ctx || {};
    if (deactivate) {
      deactivate(instance.vnode);
    }
    return;
  }

  // 普通组件:正常销毁流程
  // ...
}

// 在组件挂载流程中
function mountComponent(vnode, container, anchor) {
  // 检查是否来自缓存
  if (vnode.shapeFlag & ShapeFlags.COMPONENT_KEPT_ALIVE) {
    // 复用已有实例,不需要重新创建
    const instance = vnode.component;

    // 调用 activate 将 DOM 移回页面
    const { activate } = instance.parent?.ctx || {};
    if (activate) {
      activate(vnode, container, anchor);
    }
    return;
  }

  // 普通组件:正常创建流程
  // ...
}

7. 实战应用场景

7.1 多标签页缓存

<template>
  <div class="tabs">
    <div
      v-for="tab in tabs"
      :key="tab.name"
      class="tab-item"
      :class="{ active: currentTab === tab.name }"
      @click="currentTab = tab.name"
    >
      {{ tab.label }}
      <span class="close" @click.stop="closeTab(tab.name)">×</span>
    </div>
  </div>

  <div class="tab-content">
    <KeepAlive :include="cachedTabs" :max="10">
      <component :is="currentTabComponent" :key="currentTab" />
    </KeepAlive>
  </div>
</template>

<script setup>
import { ref, computed, watch } from "vue";
import UserList from "./UserList.vue";
import OrderList from "./OrderList.vue";
import Settings from "./Settings.vue";

const tabs = ref([
  { name: "UserList", label: "用户管理", component: UserList },
  { name: "OrderList", label: "订单管理", component: OrderList },
  { name: "Settings", label: "系统设置", component: Settings },
]);

const currentTab = ref("UserList");
const cachedTabs = ref(["UserList", "OrderList"]); // 只缓存特定标签

const currentTabComponent = computed(() => {
  const tab = tabs.value.find((t) => t.name === currentTab.value);
  return tab?.component;
});

function closeTab(tabName) {
  // 关闭标签时从缓存列表移除
  const index = cachedTabs.value.indexOf(tabName);
  if (index > -1) {
    cachedTabs.value.splice(index, 1);
  }
  // 切换到其他标签...
}
</script>

7.2 表单数据保持

<template>
  <KeepAlive :include="['UserForm']">
    <UserForm v-if="showForm" @submit="handleSubmit" />
    <UserDetail v-else :user="currentUser" @edit="showForm = true" />
  </KeepAlive>
</template>

<script setup>
import { ref } from "vue";
import UserForm from "./UserForm.vue";
import UserDetail from "./UserDetail.vue";

const showForm = ref(true);
const currentUser = ref(null);

function handleSubmit(userData) {
  // 提交表单后切换到详情页
  currentUser.value = userData;
  showForm.value = false;
}
</script>

7.3 列表页状态保持

<!-- ListPage.vue -->
<template>
  <div class="list-page">
    <!-- 搜索条件 -->
    <SearchForm v-model="searchParams" @search="handleSearch" />

    <!-- 列表 -->
    <div class="list-container" ref="listRef">
      <div
        v-for="item in listData"
        :key="item.id"
        class="list-item"
        @click="goToDetail(item)"
      >
        {{ item.name }}
      </div>
    </div>

    <!-- 分页 -->
    <Pagination v-model:page="page" v-model:size="pageSize" :total="total" />
  </div>
</template>

<script setup>
import { ref, onActivated, onDeactivated } from "vue";
import { useRouter } from "vue-router";

const router = useRouter();
const listRef = ref(null);

// 状态数据
const searchParams = ref({});
const listData = ref([]);
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
const scrollTop = ref(0);

// 激活时恢复状态
onActivated(() => {
  // 恢复滚动位置
  if (listRef.value) {
    listRef.value.scrollTop = scrollTop.value;
  }

  // 可选:刷新数据(如果需要保持最新)
  // fetchData()
});

// 失活时保存状态
onDeactivated(() => {
  if (listRef.value) {
    scrollTop.value = listRef.value.scrollTop;
  }
});

function goToDetail(item) {
  router.push(`/detail/${item.id}`);
}

async function handleSearch() {
  // 搜索逻辑...
}
</script>

8. 性能优化建议

8.1 合理设置 max

<!-- ❌ 不设置 max,可能无限增长导致内存泄漏 -->
<KeepAlive>
  <component :is="currentTab" />
</KeepAlive>

<!-- ✅ 根据业务场景设置合理的 max -->
<KeepAlive :max="5">
  <component :is="currentTab" />
</KeepAlive>

8.2 使用 include/exclude 精确控制

<!-- 只缓存必要的组件,减少内存占用 -->
<KeepAlive :include="['UserList', 'OrderList']" :max="5">
  <component :is="currentTab" />
</KeepAlive>

8.3 避免缓存大型组件

<script setup>
// 对于包含大量数据或复杂图表的组件,考虑不缓存
defineOptions({
  name: "HeavyDataChart", // 在 exclude 中排除
});
</script>

8.4 及时清理缓存

<script setup>
import { ref, nextTick } from "vue";

const includeList = ref(["TabA", "TabB", "TabC"]);
const currentTab = ref("TabA");
const keepAliveRef = ref(null);

// 方法1:通过修改 include 排除特定组件
function clearCache(componentName) {
  const index = includeList.value.indexOf(componentName);
  if (index > -1) {
    includeList.value.splice(index, 1);
  }
}

// 方法2:使用 v-if 强制重新创建 KeepAlive(清空所有缓存)
async function clearAllCache() {
  keepAliveRef.value = false;
  await nextTick();
  keepAliveRef.value = true;
}
</script>

<template>
  <KeepAlive v-if="keepAliveRef" :include="includeList">
    <component :is="currentTab" />
  </KeepAlive>
</template>

9. 常见问题与避坑指南

9.1 组件 name 未设置导致缓存失效

<script setup>
// ❌ 错误:没有设置 name,include/exclude 无法匹配
// 组件会被缓存,但无法通过 include/exclude 控制

// ✅ 正确:显式设置 name
defineOptions({
  name: "MyComponent",
});
</script>

9.2 动态组件 key 问题

<template>
  <!-- ❌ 错误:key 变化会导致缓存失效 -->
  <KeepAlive>
    <component :is="currentTab" :key="Date.now()" />
  </KeepAlive>

  <!-- ✅ 正确:使用稳定的 key 或组件名作为 key -->
  <KeepAlive>
    <component :is="currentTab" :key="currentTab" />
  </KeepAlive>
</template>

9.3 异步组件的缓存

<script setup>
import { defineAsyncComponent } from "vue";

const AsyncComp = defineAsyncComponent(() => import("./AsyncComp.vue"));
</script>

<template>
  <!-- ✅ 异步组件也可以被缓存 -->
  <KeepAlive>
    <AsyncComp />
  </KeepAlive>
</template>

9.4 与 Transition 一起使用

<template>
  <!-- ✅ KeepAlive 应该包裹在 Transition 内部 -->
  <Transition name="fade" mode="out-in">
    <KeepAlive>
      <component :is="currentTab" />
    </KeepAlive>
  </Transition>

  <!-- ❌ 不要这样:KeepAlive 包裹 Transition -->
</template>

9.5 缓存后数据不更新问题

<script setup>
import { onActivated, ref } from "vue";

const data = ref([]);

// ✅ 在 onActivated 中刷新数据
onActivated(() => {
  // 组件从缓存激活时,重新获取最新数据
  fetchLatestData();
});

// 或者使用 watch 监听路由参数变化
import { watch } from "vue";
import { useRoute } from "vue-router";

const route = useRoute();

watch(
  () => route.params.id,
  (newId) => {
    if (newId) {
      fetchData(newId);
    }
  },
  { immediate: true },
);
</script>

10. 总结与思考

10.1 核心要点回顾

要点 说明
缓存机制 使用 Map 存储 VNode,Set 管理 LRU 顺序
状态标记 COMPONENT_SHOULD_KEEP_ALIVECOMPONENT_KEPT_ALIVE shapeFlag
隐藏实现 通过 move 函数将 DOM 移入隐藏的 div 容器
生命周期 onActivated / onDeactivated 用于状态恢复和保存
淘汰策略 LRU 算法,当缓存超过 max 时淘汰最久未使用的组件

10.2 设计思想

KeepAlive 的设计体现了 Vue3 的几个重要思想:

  1. 声明式编程:开发者只需声明要缓存的组件,无需关心实现细节
  2. 可组合性:与动态组件、Transition、异步组件无缝配合
  3. 性能优先:LRU 策略防止内存无限增长,DOM 移动而非重建保证性能
  4. 扩展性:通过 include / exclude 提供精细的控制能力

10.3 思考题

  1. 为什么 KeepAlive 使用 DOM 移动而不是 display: none

    • 提示:考虑 CSS 样式继承、布局计算、内存占用等因素
  2. 如何实现一个自定义的缓存策略(如 FIFO)?

    • 提示:研究 KeepAlive 的源码结构,尝试扩展
  3. KeepAlive 与 Pinia/Vuex 状态管理如何配合?

    • 思考:什么时候用 KeepAlive 缓存状态,什么时候用全局状态管理?
  4. 在 SSR 场景下,KeepAlive 会有什么问题?

    • 提示:服务端没有 DOM,组件如何"失活"?

📚 扩展阅读

  1. Vue3 官方文档 - KeepAlive
  2. Vue3 源码解读 - KeepAlive 实现
  3. LRU 缓存算法详解
  4. Vue3 渲染器原理

💡 如果本文对你有帮助,欢迎点赞、收藏、转发!有任何问题可以在评论区留言讨论。

Vue 3 defineOptions 宏,用 VuReact 编译成 React 长什么样?

VuReact 是一个语义感知、约定驱动、支持渐进迁移的编译器,能把 Vue 3 代码一键转成标准可维护的 React 18+ 代码。

今天我们继续拆解核心 API:Vue 3 <script setup> 里的 defineOptions 宏,经过 VuReact 编译后在 React 中如何呈现?

前置约定

为了示例清爽、理解无歧义,先统一两个规则:

  1. 只保留核心逻辑,省略外层包裹与无关配置;
  2. 默认你已熟悉 Vue 3 defineOptions 的用法与语义。

编译对照:Vue defineOptions → React

1. Vue defineOptions({ name }) → React 组件命名

defineOptions 是 Vue 3 用于组件额外配置的宏,最常用就是指定组件 name。 在 React 中没有完全对应的宏,VuReact 会把 name 直接映射为组件函数名,保持语义一致。

Vue 代码

<script setup lang="ts">
  defineOptions({
    name: 'MyComponent'
  })
</script>

VuReact 编译后 React 代码

const MyComponent = () => {
  return <></>
}

export default MyComponent

defineOptions({ name }) 不会生成任何运行时 Hook,仅作为编译期信息,用来给 React 组件“起名字”,让 DevTools、调用栈保持和 Vue 一致。


2. Vue defineOptions 其他配置 → React 忽略/编译提示

defineOptions 还支持 inheritAttrscustomOptions 等配置。 由于 React 组件机制与 Vue 不同,无法直接映射,VuReact 会做保守处理:

  • inheritAttrs:React 无对应概念,直接忽略
  • customOptions:非标准配置,忽略并可在编译期提示
  • 其他扩展选项:统一忽略

Vue 代码

<script setup lang="ts">
  defineOptions({
    name: 'MyComponent',
    inheritAttrs: false
  })
</script>

VuReact 编译后 React 代码

const MyComponent = () => {
  return <></>
}

export default MyComponent
// inheritAttrs 在 React 中无直接对应,已忽略

这样处理的好处:不向 React 注入无用运行时代码,保持产物干净、符合 React 最佳实践。


3. 最佳实践:用 @vr-name 显式指定组件名

如果你希望100% 保留组件名语义,推荐使用 VuReact 官方推荐的注释约定:

<script setup lang="ts">
// @vr-name: MyComponent
</script>

编译后会稳定生成对应名称的 React 组件,比 defineOptions({ name }) 更可靠、更符合编译约定。

核心总结

  • defineOptions({ name }) → 编译为 React 组件名,无运行时开销
  • inheritAttrs 等 → React 无对应,直接安全忽略
  • 推荐用 // @vr-name: 组件名 替代,更稳定、更标准

VuReact 始终遵循:保留语义、不造多余运行时、符合 React 规范

相关资源


✨ 对你有帮助的话,欢迎 点赞 + 收藏 + 关注,持续更新 VuReact 编译原理实战~

你的 Vue 3 defineEmits(),VuReact 会编译成什么样的 React?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的 defineEmits 宏经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中 defineEmits 的 API 用法与核心行为。

编译对照

Vue defineEmits → React props 事件回调映射

defineEmits 是 Vue 3 <script setup> 中用于声明组件自定义事件的宏,它会把事件名称和参数类型定义为函数签名。VuReact 会将它编译为 React props 的事件回调形式,并对事件名做驼峰映射。

  • Vue 代码:
<script setup lang="ts">
  defineProps<{ name?: string }>();

  const emit = defineEmits<{
    (e: 'save-item', payload: { id: string }): void;
    (e: 'update:name', value: string): void;
  }>();

  const submit = () => {
    emit('save-item', { id: '1' });
    emit('update:name', 'next');
  };
</script>
  • VuReact 编译后 React 代码:
type ICompProps = {
  name?: string;
  onSaveItem?: (payload: { id: string }) => void;
  onUpdateName?: (value: string) => void;
};

const submit = useCallback(() => {
  props.onSaveItem?.({ id: '1' });
  props.onUpdateName?.('next');
}, [props.onSaveItem, props.onUpdateName]);

从示例可以看到:Vue 的 defineEmits 不会直接编译为运行时 Hook,而是转换为 React 组件 props 中的回调函数。VuReact 会将事件名 save-item / update:name 映射为 onSaveItem / onUpdateName,并保留参数类型定义,实现了事件签名与 React props 回调的无缝对接


Vue v-model:xxx → React 双向绑定 props + 事件映射

此外,子组件中定义的 update:xxx 这类事件,通常用于实现 Vue 中父子组件的双向数据绑定,父组件会以 v-model:xxx="value" 的形式使用。VuReact 充分考虑了这种模式,能够精准地进行转换:

  • 父组件 Vue 代码:
<template>
  <Child v-model:name="current" />
</template>

<script setup>
  // @vr-name: Parent
  const current = ref('');
</script>
  • VuReact 编译后 React 代码:
const Parent = memo(() => {
  const current = useVRef('');
  return <Child name={current.value} onUpdateName={value => current.value = value} />
});

Vue emit 调用 → React props 回调调用

在 Vue 中,emit('event-name', payload) 触发组件自定义事件;在 React 中,VuReact 会把它编译为 props.onEventName?.(payload) 的调用形式。

  • Vue 代码:
<script setup lang="ts">
  const emit = defineEmits<{
    (e: 'submit', value: string): void;
  }>();

  const handleSubmit = () => {
    emit('submit', 'ok');
  };
</script>
  • VuReact 编译后 React 代码:
type ICompProps = {
  onSubmit?: (value: string) => void;
};

const handleSubmit = useCallback(() => {
  props.onSubmit?.('ok');
}, [props.onSubmit]);

VuReact 会对 emit 的事件名和参数进行类型映射,并在必要时自动为 useCallback 生成依赖数组,让 React 端的回调引用保持稳定,同时避免开发者手动维护依赖


Vue defineEmits 兼容事件名映射规则

VuReact 支持将 Vue 的短横线事件名、冒号事件名等映射为 React 的驼峰命名回调:

  • save-itemonSaveItem
  • update:nameonUpdateName
  • closeonClose

这种映射方式与 React 事件 props 习惯一致,也保持了 Vue 事件声明的语义。

🔗 相关资源


✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

「性能优化」虚拟列表极致优化实战:从原理到源码,打造丝滑滚动体验

前言

大家好,我是elk。

上篇文章我们聊了大文件的切片上传,这次再来看看另一个高频性能优化场景 —— 虚拟列表(Virtual List)

什么是虚拟列表?

虚拟列表「Virtual List」是一种前端性能优化技术,用于解决"长列表渲染"场景下,因DOM节点过多导致的页面卡顿,内存占用率高,首屏加载缓慢等问题。

核心思想是:只渲染当前视口可见的列表项,而非渲染全部列表数据。通过动态计算视口位置,复用DOM节点,实现"无限列表"的流畅渲染。

为什么需要虚拟列表?

在处理大数据量列表时,传统的渲染方式会面临两大瓶颈:

  1. DOM 节点过载:浏览器渲染 10,000 个复杂的 DOM 节点,内存消耗巨大。
  2. 布局与重绘:滚动时,大量的 DOM 节点重绘会导致帧率下降,产生明显的掉帧(Jank)。

适用业务场景

  • 大数据量列表渲染:后台管理系统的用户列表、日志列表、权限列表、数据报表等,数据量超1000条,全量渲染直接导致页面卡死、操作无响应。
  • 无限滚动场景:移动端信息流、商品列表、评论区、下拉选择器,用户持续下拉加载数据,DOM节点无限累加,最终引发页面崩溃。
  • 固定容器滚动列表:所有需要在固定高度容器内展示超长列表的业务场景。

核心原理

  • 视口计算:获取容器的可视高度,滚动距离,确定当前"可见区域"的范围
  • 数据截取:根据可见范围,计算需要渲染的列表项的起始索引和结束索引,从全部数据中截取范围内的数据,仅渲染截取后的可视数据
  • 偏移量计算:通过定位设置渲染区域的偏移量,让截取的数据精准的显现在视口内,模拟"滚动到指定位置的效果"
  • DOM复用:当滚动时,动态改变起始索引和结束索引,截取新的可视化数据,复用已渲染的DOM节点,减少DOM操作的开销

核心基础概念

  • 视口容器:用于展示列表的容器,用户的可见区域,通常设置为固定高度和overflow: auto
  • 列表项高度:单个列表项的高度,通常分为:"固定高度"和"动态高度"
  • 可见数量:可见区域中要展示的列表数量总个数,计算公式:Math.cell(视口高度 / 列表项高度)
  • 缓冲数量:在可见区域上下额外多渲染的数量,用于解决滚动时的"空白闪烁"问题。
  • 总高度:所有列表项的总高度,用于撑开容器,模拟长列表滚动(不设置,容器无法滚动)

核心知识点

主要是涉及到事件监听以及基础数据的计算和更新

基础知识点

滚动事件监听

通过监听容器的scoll事件,获取滚动距离(scrollTop),触发可见区域、起始索引、结束索引、可见列表、偏移量距离的计算

避免频繁触发滚动事件,需使用节流进行优化,避免过量计算损失性能

尺寸计算

  • 视口高度:可通过容器的「clientHeight」获得,一般定义固定高度
  • 滚动距离:通过容器滚动事件触发获得「scrollTop属性」
  • 固定高度:无需计算,自行设置的高度「itemHeight」
  • 动态高度:当容器滚动时,动态计算列表项的高度「clientHeight」,并列入缓存中

索引计算

起始索引「startIndex」

固定高度

index = Math.floor(scrollTop / ITEM_HEIGHT) 「滚动距离 / 固定单个项高度」

startIndex = Math.max(0, index - bufferCount) 「 减去缓冲个数获取真实起始索引 」

动态高度:需通过"累计高度"计算startIndex「遍历缓存的高度列表,通过二分法查找到大于等于scrollTop滚动距离的索引」

结束索引「endIndex」

index = startIndex + visibiliItemsCount + bufferCount 「起始索引 + 可见区域列表数量 + 缓冲量」

endIndex = Math.min( list.length, index )

偏移量计算

固定高度

    top = startIndex * ITEM_HEIGHT 「起始索引 * 单个项固定高度」

动态高度

top = prefixSumCache[startIndex] 「从高度缓存列表中获取当前起始索引的数据」

进阶知识点

在基础知识点上进行的优化措施,提升列表性能,优化用户体验

缓冲机制

当用户快速滚动时,如果是仅渲染可见区域内的数据,会出现"空白区域",数据未及时渲染

  • 缓存量设置1-5个,过多会增加DOM数量,削弱优化效果
  • 上方偏移量计算 startIndex + bufferCount , endIndex - bufferCount,就是确保上下都有缓冲

动态高度缓存与更新

在动态高度场景下,初始化时不知道每一项的真实高度,常见优化策略:

  • 先进行预估高度的渲染,渲染后通过nextTick获取真实高度
  • 将真实高度写入缓存,并重新计算前缀和
  • 后续滚动时,当实际高度和初始化缓存高度不匹配的时候才重新计算一次高度缓存

滚动事件节流

在滚动事件 handelScroll中使用了ticking锁和requestAnimationFrame

  • 滚动事件触发非常频繁,使用RAF可以确保浏览器在下一帧重绘前执行计算逻辑,避免掉帧,使滚动更平滑

二分查找优化索引定位

在动态高度场景下,需要根据 scrollTop 找到起始索引。如果每次都线性查找,时间复杂度 O(n)。利用 前缀和数组的单调递增特性,使用二分查找可将复杂度降至 O(log n)。

整体代码 —— 组件封装(Vue 3 + TypeScript)

以下是一个支持 动态高度缓冲区高度缓存二分查找 的完整虚拟列表组件。

<template>
  <div
    @scroll="handleScroll"
    ref="containerRef"
    :style="{ height: `${height}px` }"
    class="w-full position-relative top-0 left-0 overflow-auto"
  >
    <!-- 空状态 -->
    <div v-if="data.length === 0" class="w-full h-full flex items-center justify-center">
      <slot name="empty" />
    </div>
    <!-- 占位撑高容器 -->
    <template v-else>
      <div
        :style="{ height: `${containerHeight}px` }"
        class="w-full position-absolute top-0 left-0"
      ></div>
      <!-- 可视化容器 -->
      <div
        :style="{ transform: `translateY(${offset}px)` }"
        class="w-full position-absolute top-0 left-0"
      >
        <div
          v-for="(item, index) in visibleList"
          :key="item.id || index"
          ref="itemRef"
          :style="{ height: `${itemHeight}px` }"
          class="w-full flex items-center justify-center"
        >
          <slot name="default" :item="item" :index="index + startIndex" />
        </div>
      </div>
    </template>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted, computed, nextTick, watchEffect } from 'vue'
import type { PropType } from 'vue'

interface ListItem {
  id: number | string
  name: string
}

interface PropsParams {
  // 列表数据
  data: ListItem[]
  // 容器高度
  height: number
  // 项高度-预估高度
  itemHeight: number
  // 缓冲区数量
  bufferCount: number
}
const props: PropsParams = defineProps({
  data: {
    type: Array as PropType<ListItem[]>,
    default: () => [],
    required: true,
  },
  height: {
    type: Number,
    default: 250,
  },
  itemHeight: {
    type: Number,
    default: 50,
  },
  bufferCount: {
    type: Number,
    default: 5,
  },
})

// 容器ref
const containerRef = ref<HTMLDivElement>()
// 项ref
const itemRef = ref<HTMLDivElement[]>([])
// 滚动距离
const scrollTop = ref(0)

// 项高度-缓存集合
const itemHeightCache = ref<number[]>([])
// 前缀和-缓存集合
const prefixSumCache = ref<number[]>([])

// 可视化容器-开始索引
const startIndex = computed(() => {
  const index = getStartIndex(scrollTop.value)
  return Math.max(0, index - props.bufferCount)
})

// 可视化容器-结束索引
const endIndex = computed(() => {
  const index = startIndex.value + visibleCount.value + props.bufferCount * 2
  return Math.min(props.data.length, index)
})

// 撑开容器-高度
const containerHeight = computed(() => {
  return prefixSumCache.value[prefixSumCache.value.length - 1]
})
// 可视化容器-列表数量
const visibleCount = computed(() => {
  return Math.ceil(props.height / props.itemHeight)
})

// 可视化容器-渲染列表
const visibleList = computed(() => {
  return props.data.slice(startIndex.value, endIndex.value)
})

// 偏移量-计算
const offset = computed(() => {
  return prefixSumCache.value[startIndex.value]
})

/**
 * @description: 二分法-计算初始索引
 * @return {*}
 */
const getStartIndex = (scrollTop: number) => {
  let left = 0
  let right = prefixSumCache.value.length - 1
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    if (prefixSumCache.value[mid] === scrollTop) return mid
    if (prefixSumCache.value[mid] > scrollTop) {
      right = mid - 1
    } else {
      left = mid + 1
    }
  }
  return left
}

/**
 * @description: 初始化高度
 * @return {*}
 */
const initHeight = () => {
  try {
    // 初始化项高度缓存集合
    itemHeightCache.value = props.data.map(() => props.itemHeight)
    // 初始化前缀和缓存集合
    initPrefixSum()
  } catch (error) {
    console.error('初始化高度失败:', error)
  }
}

/**
 * @description: 初始化|修改 前缀和缓存集合
 * @return {*}
 */
const initPrefixSum = (index: number = 0) => {
  try {
    prefixSumCache.value = []
    let sum = 0
    // 计算前缀和缓存集合,从索引开始计算,直到列表结束
    itemHeightCache.value.forEach((item, i) => {
      if (i >= index) {
        prefixSumCache.value.push(sum)
        sum += item
      }
    })
  } catch (error) {
    console.error('初始化前缀和缓存集合失败:', error)
  }
}

/**
 * @description: 修改项的真实高度-当高度发生变化时才更新
 * @return {*}
 */
const updateItemHeight = async () => {
  try {
    await nextTick()
    const visibleItems = itemRef.value
    if (visibleItems.length === 0) return
    let hasHeightChanged = false
    visibleItems.forEach((el, index) => {
      if (el) {
        const itemIndex = index + startIndex.value
        const itemHeight = el.clientHeight
        // const itemHeight = el.getBoundingClientRect().height
        // 只有高度变化的时候才更新缓存
        if (itemHeight !== itemHeightCache.value[itemIndex]) {
          itemHeightCache.value[itemIndex] = itemHeight
          hasHeightChanged = true
        }
        if (hasHeightChanged) {
          initPrefixSum(itemIndex)
        }
      }
    })
  } catch (error) {
    console.error('更新项目高度失败:', error)
  }
}

/**
 * @description: 处理滚动事件
 * @return {*}
 */
let ticking = false
const handleScroll = () => {
  console.log('🚀 ~ handleScroll ~ containerRef: 触发了滚动事件')
  if (!ticking) {
    requestAnimationFrame(() => {
      if (containerRef.value) {
        scrollTop.value = containerRef.value?.scrollTop || 0
        updateItemHeight()
      }
      ticking = false
    })
    ticking = true
  }
}

// 监听数据变化-更新项高度
watchEffect(() => {
  if (props.data.length > 0) {
    initHeight()
    updateItemHeight()
  }
})

// 初始化-更新项高度
onMounted(() => {
  initHeight()
  updateItemHeight()
})
</script>

<style lang="css" scoped></style>

常见问题 & 最佳实践

Q1:为什么我的虚拟列表在快速滚动时还是会白屏?

  • 缓冲区太小:适当增加 bufferCount(比如从 2 提升到 5)。
  • 动态高度更新不及时:确保在 nextTick 后获取真实高度,并重新计算前缀和。
  • 未使用 requestAnimationFrame:滚动回调中的 DOM 操作可能被延迟,导致渲染跟不上。

Q2:动态高度组件中,prefixSum 的维护很容易出错,有什么建议?

推荐使用 长度 = n+1 的前缀和数组,其中 prefixSum[0] = 0prefixSum[i] 表示前 i 项的总高度。这样:

  • 第 i 项的偏移量 = prefixSum[i]
  • 总高度 = prefixSum[n]
  • 查找 scrollTop 对应索引时,二分查找第一个大于 scrollTop 的 prefixSum[i],然后 i-1 即为起始索引。

Q3:如何支持列表项内容动态变化(比如展开/收起)?

  • 监听内容变化,调用 updateRealHeights 重新测量受影响的项。
  • 如果是通过用户交互(如点击展开),可以手动触发更新并重新构建前缀和。

Q4:除了 transform 偏移,还有别的方案吗?

也可以使用 padding-top 偏移,但 transform 性能更好(不触发重排)。推荐使用 translateY

总结

虚拟列表是前端性能优化中 性价比极高 的一类技术 —— 实现成本可控,却能将万级列表的渲染性能从秒级降到毫秒级。本文从原理到代码,覆盖了固定高度、动态高度、缓冲区、二分查找、滚动节流等关键点。

优化永无止境,如果你还想更进一步,可以探索:

  • 使用 ResizeObserver 监听每一项的尺寸变化,自动更新高度缓存。
  • 结合 IntersectionObserver 实现可视区外图片懒加载。
  • 将虚拟列表与 分页 / 懒加载数据 结合,实现真正意义上的“无限滚动”。

希望这篇文章能帮你彻底掌握虚拟列表,写出更流畅的 Web 应用。如果觉得有帮助,欢迎点赞、评论、转发~

你的 Vue 3 生命周期,VuReact 会编译成什么样的 React?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中常见的生命周期钩子经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

为避免示例代码冗余导致理解偏差,先明确两个小约定:

  1. 文中 Vue / React 代码均为核心逻辑简写,省略完整组件包裹、无关配置等内容;
  2. 默认读者已熟悉 Vue 3 中生命周期钩子例如 onMounted、onBeforeMount、onUpdated、onBeforeUpdate、onBeforeUnmount、onUnmounted 的 API 用法与核心行为。

编译对照

Vue onMounted() → React useMounted()

onMounted 是 Vue 3 中用于组件首次挂载后执行逻辑的生命周期钩子,适合放初始化请求、订阅启动、DOM 相关准备等操作。VuReact 会将它编译为 useMounted,让 React 端也能在组件挂载后执行一次性副作用。

  • Vue 代码:
<script setup>
  import { onMounted } from 'vue';

  onMounted(() => {
    console.log('组件已挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useMounted } from '@vureact/runtime-core';

useMounted(() => {
  console.log('组件已挂载');
});

从示例可以看到:Vue 的 onMounted() 被翻译为 useMounted。VuReact 提供的 useMountedonMounted 的适配 API完全模拟 Vue onMounted 的首次挂载后执行时机

Vue onBeforeMount() → React useBeforeMount()

onBeforeMount 是 Vue 3 中用于组件挂载前执行逻辑的钩子,适合放需要在布局阶段之前准备的内容。VuReact 会将它编译为 useBeforeMount,基于 React 的布局效果在挂载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeMount } from 'vue';

  onBeforeMount(() => {
    console.log('组件即将挂载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeMount } from '@vureact/runtime-core';

useBeforeMount(() => {
  console.log('组件即将挂载');
});

VuReact 提供的 useBeforeMountonBeforeMount 的适配 API完全模拟 Vue onBeforeMount 的首次挂载前时机

Vue onBeforeUpdate() → React useBeforeUpdate()

onBeforeUpdate 是 Vue 3 中用于跳过首次挂载,仅在组件更新前执行的钩子,适合放变更前校验、记录旧值、提前准备等逻辑。VuReact 会将它编译为 useBeforeUpdate,并支持依赖数组以控制触发时机。

  • Vue 代码:
<script setup>
  import { reactive, onBeforeUpdate } from 'vue';

  const state = reactive({ count: 0 });

  onBeforeUpdate(() => {
    console.log('更新前,当前 count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useBeforeUpdate } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useBeforeUpdate(
  () => {
    console.log('更新前,当前 count:', state.count);
  },
  [state.count],
);

从示例可以看到:Vue 的 onBeforeUpdate() 被翻译为 useBeforeUpdate。VuReact 提供的 useBeforeUpdateonBeforeUpdate 的适配 API完全模拟 Vue onBeforeUpdate 的更新前触发时机。当 React 对应 API 需要依赖数组时,deps 数组可用于只在指定值变化时触发,VuReact 会在编译阶段自动分析依赖并映射到对应依赖数组,避免开发者手动管理依赖

Vue onUpdated() → React useUpdated()

onUpdated 是 Vue 3 中用于组件更新后执行逻辑的钩子,适合放读取最新渲染结果、执行后续同步等操作。VuReact 会将它编译为 useUpdated,并支持可选依赖数组来精确控制触发条件。

  • Vue 代码:
<script setup>
  import { reactive, onUpdated } from 'vue';

  const state = reactive({ count: 0 });

  onUpdated(() => {
    console.log('组件更新后,count:', state.count);
  });
</script>
  • VuReact 编译后 React 代码:
import { useReactive, useUpdated } from '@vureact/runtime-core';

const state = useReactive({ count: 0 });

useUpdated(
  () => {
    console.log('组件更新后,count:', state.count);
  },
  [state.count],
);

VuReact 提供的 useUpdatedonUpdated 的适配 API完全模拟 Vue onUpdated 的更新后执行时机。如果 React API 使用 deps 数组,VuReact 会自动分析依赖并生成对应的数组,无需开发者手动维护依赖

Vue onBeforeUnmount() → React useBeforeUnMount()

onBeforeUnmount 是 Vue 3 中用于组件卸载前执行的钩子,适合放动画停止、资源解绑、日志上报等清理前逻辑。VuReact 会将它编译为 useBeforeUnMount,在卸载前执行。

  • Vue 代码:
<script setup>
  import { onBeforeUnmount } from 'vue';

  onBeforeUnmount(() => {
    console.log('组件即将卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useBeforeUnMount } from '@vureact/runtime-core';

useBeforeUnMount(() => {
  console.log('组件即将卸载');
});

VuReact 提供的 useBeforeUnMountonBeforeUnmount 的适配 API完全模拟 Vue onBeforeUnmount 的卸载前时机

Vue onUnmounted() → React useUnmounted()

onUnmounted 是 Vue 3 中用于组件卸载时执行逻辑的钩子,适合放最终资源释放、异步取消、上报日志等收尾逻辑。VuReact 会将它编译为 useUnmounted,在组件卸载时执行。

  • Vue 代码:
<script setup>
  import { onUnmounted } from 'vue';

  onUnmounted(() => {
    console.log('组件已卸载');
  });
</script>
  • VuReact 编译后 React 代码:
import { useUnmounted } from '@vureact/runtime-core';

useUnmounted(() => {
  console.log('组件已卸载');
});

VuReact 提供的 useUnmountedonUnmounted 的适配 API完全模拟 Vue onUnmounted 的卸载时机

🔗 相关资源

✨ 如果你觉得本文对你理解 VuReact 有帮助,欢迎点赞、收藏、关注!

面试官视角:TypeScript Pick 工具类型深度解析与手写实现

在字节、阿里等大厂的 TypeScript 面试中,考察工具类型(Utility Types)是一个非常经典的环节。面试官并不只是想看你背诵 Pick 或 Omit 的用法,而是想通过“手写 MyPick”这道题,考察你对泛型(Generics)、索引类型查询(keyof)、映射类型(Mapped Types)以及类型约束(extends)的深度理解。

这篇文章将带你从“知其然”到“知其所以然”,用幽默且硬核的方式彻底拿下这个知识点。


为什么我们需要 Pick?(面试官的潜台词)

在写代码时,我们经常会遇到这种情况:后端定义了一个巨大的 User 对象,包含 idnameagepasswordcreatedAt 等十几个字段。但在前端的一个小卡片组件里,我只需要展示 name 和 avatar

如果不使用 Pick,你可能需要重新定义一个接口,或者手动去 extends 然后重写属性。这不仅啰嗦,而且一旦后端改了字段,你的代码维护起来就是灾难。

Pick 的本质:它就像是一个“类型级的过滤器”。你给它一个完整的对象类型,再给它几个你想要的字段名,它就能给你吐出一个全新的、精简的类型。


庖丁解牛:手写 MyPick 的三步走战略

面试官让你在 type MyPick<T, K> = any 的 any 处填空,你该如何思考?我们可以把这个过程拆解为三个步骤:

第一步:明确原材料(泛型参数)

我们需要两个参数:

  • T:原始的、完整的对象类型(比如 User)。
  • K:我们想要挑选出来的属性名(比如 'name' | 'age')。

第二步:加上安全锁(类型约束)

这是面试中最容易丢分的地方。如果用户传了一个 T 中不存在的属性怎么办?比如 Pick<User, 'nonExistentField'>
为了防止这种情况,我们必须限制 KK 必须是 T 中所有键的集合的子集。

这就引入了 keyof T 和 extends

  • keyof T:获取 T 所有属性名组成的联合类型(例如 'id' | 'name' | 'age')。
  • K extends keyof T:这句话的意思是,“K 必须是 keyof T 的一部分”。如果传了不存在的属性,TypeScript 会直接报错,这就是类型安全。

第三步:加工生产(映射类型)

拿到了合法的 K,我们需要构建新对象。这里要用到映射类型
语法结构是:{ [P in K]: ... }
这就像是一个 for...in 循环,遍历 K 中的每一个属性 P,然后去原始类型 T 中查找 P 对应的类型(即 T[P],这叫索引访问类型)。


核心代码实现与逐行精讲

结合上述思路,我们可以写出以下完美的实现代码:

1// 1. 定义原始类型
2interface User {
3    id: number;
4    age: number;
5    name: string;
6    password: string; // 敏感字段
7}
8
9// 2. 手写 MyPick
10// T: 源类型
11// K: 需要挑选的键,且 K 必须受限于 keyof T (即 K 必须是 T 中存在的属性)
12type MyPick<T, K extends keyof T> = {
13    // 映射类型:遍历 K 中的每一个属性 P
14    [P in K]: T[P]; // T[P] 表示取出 T 中 P 属性对应的类型
15}
16
17// 3. 测试
18type UserName = MyPick<User, 'name'>; 
19// 结果:{ name: string }
20
21type UserPublicInfo = MyPick<User, 'id' | 'name'>;
22// 结果:{ id: number; name: string; }
23
24// 4. 错误测试 (TypeScript 会报错,因为 'hack' 不在 User 中)
25// type ErrorCase = MyPick<User, 'hack'>; 

关键知识点深度解析

为了在面试中对答如流,你需要理解以下几个核心概念:

keyof 操作符
它的作用是“取键”。对于一个对象类型,keyof 会返回它所有属性名的联合类型。

  • 例子:keyof User 得到 'id' | 'age' | 'name' | 'password'

索引访问类型
语法是 T[P]。它的作用是“取值”。

  • 例子:如果 P 是 'name',那么 User['name'] 就是 string

映射类型
语法是 { [P in K]: ... }。它允许你将一个联合类型转换为一个新的对象类型。

  • 在 MyPick 中,我们遍历的是 K(用户想要的键),而不是 keyof T(所有的键),这就是“挑选”的精髓。

extends 关键字
在这里它不是“继承”,而是“约束”。K extends keyof T 保证了传入的键是合法的。


举一反三:Omit 与 Partial

面试官通常会接着问:“那你能手写一下 Omit 吗?”
其实 Omit 就是 Pick 的反面。Omit 是“排除”某些字段。
它的实现思路是:先利用 Exclude 工具类型从 keyof T 中剔除掉 K,剩下的就是我们要保留的,然后再用 Pick 的逻辑。

1// 手写 Omit
2// Exclude<UnionType, ExcludedMembers> 用于从联合类型中排除某项
3type MyOmit<T, K extends keyof T> = MyPick<T, Exclude<keyof T, K>>;

Partial
Partial 则是将所有属性变为可选。

1type MyPartial<T> = {
2    [P in keyof T]?: T[P];
3}

总结

在面试中回答这道题,建议遵循以下逻辑流:

  1. 定义泛型:声明 T 和 K
  2. 添加约束:使用 K extends keyof T 确保类型安全。
  3. 构建映射:使用 { [P in K]: T[P] } 完成类型的重组。

掌握了这个模板,你不仅搞定了 Pick,也顺手拿下了 OmitReadonly 和 Partial,它们是 TypeScript 高级类型编程的基石。

从浏览器到 Node.js,这一次彻底搞懂 Event Loop 与异步模型

引言

很多前端同学在向全栈(BFF层)或者 Node.js 进阶时,都会遇到一个绕不开的坎——Event Loop(事件循环)

面试时,面对一段穿插着 setTimeoutPromiseasync/await 甚至 process.nextTick 的代码,往往容易被绕晕。更别提浏览器和 Node.js 在事件循环的底层实现上还有着本质的区别。

本文将结合我个人的工程经验,带你从零开始,由浅入深地拆解 Event Loop。我们不仅要会做面试题,更要知道这种异步非阻塞的模型,为什么能让 Node.js 在服务器端扛住成千上万的并发。

一、 为什么我们需要 Event Loop?

JavaScript 诞生之初是作为浏览器的脚本语言,为了避免复杂的 DOM 渲染冲突,它被设计成了单线程。也就是说,同一时间只能干一件事。

但是,网页中有大量需要等待的任务:网络请求(Ajax)、定时器、图片加载。如果所有的操作都是同步阻塞的,用户点一个按钮发起请求,整个页面就会卡死,直到请求返回。

为了解决这个问题,消息队列(Message Queue) + Event Loop 诞生了。

它的核心思想是:把耗时的任务先扔到一边(交给宿主环境如浏览器或操作系统的其他线程处理),主线程继续飞速往下跑。等那些耗时任务有了结果,再通知主线程来执行回调。

二、 浏览器的 Event Loop:宏任务与微任务的交响乐

在浏览器的一次工作中,JS 的执行是从一个 script 宏任务开始的。当同步代码执行完后,会产生两种不同的异步任务:宏任务(Macrotask)微任务(Microtask)

1. 任务分类

  • 宏任务队列setTimeoutsetInterval、事件绑定回调、Ajax 回调等。
  • 微任务队列Promise.then/catch/finallyasync/await 的后续代码、queueMicrotask、以及前端特有的 DOM 监听类微任务 MutationObserver

2. 执行机制(核心运转规律)

浏览器的 Event Loop 遵循以下严格的顺序:

  1. 执行并清空当前宏任务(一开始是整个 script 标签内的同步代码)。
  2. 清空整个微任务队列(如果执行微任务时又产生了新的微任务,会继续在当前阶段清空)。
  3. 检查是否需要进行页面渲染(GUI 渲染线程介入,重排重绘)。
  4. 开始下一轮 Event Loop,取出一个新的宏任务执行。

3. 终极实战拆解

来看一段经典的测试代码:

console.log('同步代码 1');

setTimeout(() => {
    console.log('setTimeout 1');
    Promise.resolve().then(() => {
        console.log('setTimeout 1 内部微任务');
    });
}, 0);

const promise1 = new Promise((resolve) => {
    console.log('Promise 构造函数');
    resolve();
    console.log('Promise 构造函数内 resolve 后');
});

promise1.then(() => {
    console.log('Promise.then 1');
    setTimeout(() => {
        console.log('Promise.then 1 内部 setTimeout');
    }, 0);
});

async function asyncFn() {
    console.log('async 函数同步部分');
    await Promise.resolve(); // 异步变同步的语法糖
    console.log('await 后微任务');
}

asyncFn();

console.log('同步代码 2');

queueMicrotask(() => {
    console.log('queueMicrotask 微任务');
});

// 前端特有微任务
const observer = new MutationObserver(() => {
    console.log('MutationObserver 微任务');
});
const div = document.createElement('div');
observer.observe(div, { attributes: true });
div.setAttribute('data-test', '1'); 

执行脉络分析:

  1. 同步代码一路推平

    先打印 同步代码 1。遇到 setTimeout 放入宏任务队列。遇到 new Promise(注意:构造函数内部是同步执行的),依次打印 Promise 构造函数Promise 构造函数内 resolve 后,并将它的 .then 推入微任务队列。遇到 asyncFn 执行,打印 async 函数同步部分,并将 await 后的代码推入微任务队列。接着打印 同步代码 2。最后触发 MutationObserver 进入微任务队列。

  2. 第一波微任务清空

    依次打印 Promise.then 1await 后微任务queueMicrotask 微任务MutationObserver 微任务。需要注意的是,在此执行期间,Promise.then 1 内部产生了一个新的 setTimeout,它会被放入宏任务队列等待。

  3. 开启下一轮宏任务

    拿出首个宏任务 setTimeout 1 执行并打印,同时将其内部的 Promise 推入微任务队列。当前宏任务结束后,立刻清空刚刚产生的微任务,打印 setTimeout 1 内部微任务

  4. 最后的宏任务

    执行剩余的宏任务,打印 Promise.then 1 内部 setTimeout

三、 Node.js 的 Event Loop:更复杂的阶段调度

如果你觉得浏览器的 Event Loop 已经懂了,那来到 Node.js 的世界,你需要暂时放下前面的“偏见”。

相比于浏览器主要处理 DOM 和交互,Node.js 运行在服务器端,需要处理大量的文件 I/O、网络请求、数据库连接。因此,Node.js 的事件循环基于 libuv 库,被划分为多个阶段(Phases)

1. Node.js 事件循环的 6 大阶段

在每次循环中,Node.js 会按顺序经过以下核心阶段(我们主要关注标粗的三个):

  1. Timers(定时器阶段) :执行 setTimeoutsetInterval 的回调。
  2. Pending Callbacks:执行系统级别操作的回调(如 TCP 错误)。
  3. Idle, Prepare:内部使用。
  4. Poll(轮询阶段) :检索新的 I/O 事件,执行与 I/O 相关的回调(比如读取文件、网络请求返回)。这是 Node.js 最重要的阶段。
  5. Check(检查阶段) :专门执行 setImmediate 的回调。
  6. Close Callbacks:执行关闭资源的回调。

2. Node 中的“特权”微任务

在 Node.js 中,微任务不仅有 Promise,还有一个拥有绝对特权的 VIP:process.nextTick

  • 触发时机:同步代码执行完后、或者每个阶段完成后、甚至在 Node 11+ 版本中每个回调执行完后,都会立刻去检查并清空微任务队列。
  • 优先级process.nextTick 的优先级永远高于 Promise

3. 核心实战:I/O 内部的执行顺序反转

这是面试中最容易挂掉的一道题,也是理解 Node.js 调度的分水岭:

const fs = require('fs')

console.log('start')

setTimeout(() => {
  console.log('timeout')
}, 0)

setImmediate(() => {
  console.log('immediate')
})

fs.readFile(__filename, () => {
  console.log('readFile')
  
  setTimeout(() => {
    console.log('timeout in I/O')
  }, 0)

  setImmediate(() => {
    console.log('immediate in I/O')
  })
})

Promise.resolve().then(() => { console.log('promise') })
process.nextTick(() => { console.log('nextTick') })
console.log('end')

深度拆解:为什么在 I/O 里 setImmediate 永远比 setTimeout 先执行?

  1. 同步先行:打印 startend。注册各个异步任务。

  2. 清空首次微任务:先看 VIP,打印 nextTick,再看 Promise,打印 promise

  3. 进入事件循环

    • Timers 阶段setTimeout(..., 0) 到期,打印 timeout
    • Poll 阶段:此时文件可能还没读完,跳过。
    • Check 阶段:执行外层的 setImmediate,打印 immediate
  4. I/O 改变战局

    • fs.readFile 完成,它的回调会在 Poll 阶段执行!打印 readFile
    • 在回调内部,又注册了一个 setTimeout 和一个 setImmediate
    • 划重点:我们现在处于 Poll 阶段!Event Loop 顺时针往下转,下一个阶段是谁?是 Check 阶段
    • 所以,刚刚注册的 setImmediate 会在接下来的 Check 阶段被立刻执行(打印 immediate in I/O)。
    • 而那个 setTimeout 怎么办?它只能苦苦等待这一轮循环跑完,在下一轮的 Timers 阶段才能被执行(打印 timeout in I/O)。

四、 核心对比:浏览器 vs Node.js

特性 浏览器 (HTML5标准) Node.js (基于 libuv)
底层驱动 浏览器内核 (V8 + GUI等) V8引擎 + libuv
任务模型 宏任务 -> 微任务 -> 渲染 划分为 6 个阶段,按阶段推进
微任务清空时机 每个宏任务结束后 早期为每个阶段结束,Node 11+ 后与浏览器一致,每个回调结束后
特有 API MutationObserver, requestAnimationFrame process.nextTick, setImmediate
微任务优先级 正常队列 (Promise, queueMicrotask) process.nextTick 绝对优先于 Promise

六、 总结

1. 单线程高并发的秘密

相比于 Java、Go 传统的多线程阻塞模型,Node.js 借助事件循环实现了异步非阻塞 I/O。这意味着,当 Node.js 处理网络请求、查询 MySQL/PostgreSQL 数据库、或者读写文件时,线程不会卡在那里等待。它会把任务扔给底层,立刻切回去处理下一个用户的 HTTP 请求。

这种特性,使得服务器开销极低,少量线程就能扛住成千上万的并发连接。

无论你是沉浸在 Vue/React 的前端开发者,还是在使用 Nestjs 探索后端的全栈工程师,深刻理解 Event Loop 都是一次思维的跨越:

  1. 在前端,你要关注宏任务和微任务的交替,警惕长任务阻塞渲染导致的页面掉帧。
  2. 在 Node.js,你要关注各个阶段(Timers、Poll、Check)的流转,善用异步流和缓冲,发挥其高并发 I/O 的优势。

AI聊天界面的布局细节和打字跟随方法

AI 问答界面如何布局?

在豆包的AI问答聊天界面,为什么输入框总是会跟随在最底部?左边有导航栏,无论怎么缩小放大屏幕都会在当前问答展示界面的水平线中间?

难道是通过 position: fixed; 来实现的?但是它怎么能够解决第二个问题呢?先打开控制台看看。

在问答界面,输入框是一直被挤在最下方的,通过检查控制台会发现输入框好像会一直跟随在屏幕最下方?

image.png

但是随着控制台一直向上拉长,输入框又会被控制台覆盖?

image.png

说明根本不是通过固定定位来实现的效果。

下面来实现一下它的这种效果:这里展示的是最外层容器的布局。

<!-- 根容器 -->
<div class="chat">
    <!-- 展示容器 -->
    <div v-show='!isChat' class="chat-content">
    </div>
    <!-- 对话界面 -->
    <div v-show='isChat' class="chat-scroll-container">
    </div>
    <!-- 输入框 -->
    <div class="input-section">
    </div>
</div>

.chat {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}
.chat-container {
  width: 100%;
  max-width: 1000px;
  flex: 1;
  overflow-y: auto;
}
.chat-scroll-container {
  height: 100%;
  width: 100%;
  overflow-y: auto;
  flex: 1;
  /* 隐藏滚动条但保留功能 */
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* IE/Edge */
}
.input-section {
  width: 100%;
  height: 150px;
  flex-shrink: 0;
}

可以看到核心的实现,其实就是让输入框的兄弟容器将剩余空间全部占据,而 input-section 本身只需要不压缩自身的高度就可以,当屏幕缩小后,flex:1;能占据的空间变小,而输入框高度不变将一直在外层容器最下方,当空间展示不下时会出现的可视区域外。

flex-shrink

  • 父容器必须是弹性容器。
  • 默认表示子元素固定宽度,不被压缩。
  • 在父元素使用 flex-direction: column; 改变了弹性方向后,表示子元素固定高度,不被压缩。
  • 为 1 时,表示容器会适应父容器高度被压缩。

如何让视线跟随 AI 生成的内容

下方父容器为滚动容器,子元素为主要内容展示容器。以下介绍两种 AI 打字跟随的监听方法,控制跟随与用户操作停止跟随。

<div class="chat-scroll-container" ref="scrollContainerRef" @scroll="handleScroll">
    <div class="chat-messages" ref="chatMessagesRef">
    </div>
</div>

const chatMessagesRef = ref(null);
// 滚动容器引用
const scrollContainerRef = ref(null);
// 是否启用自动滚动跟随
const enableAutoScroll = ref(true);

// 上次滚动位置
let lastScrollTop = 0;

const handleScroll = throttle(() => {
  const el = scrollContainerRef.value;
  if (!el) return;
  const { scrollTop, scrollHeight, clientHeight } = el;
  // 判断当前是否已经在底部(留20px容差)
  const isAtBottom = scrollTop + clientHeight >= scrollHeight - 20;
  // 如果用户在向上滚动超过阈值,取消自动跟随
  if (isAtBottom === false && scrollTop < lastScrollTop) {
    const upDistance = lastScrollTop - scrollTop;
    if (upDistance > 10) {
      enableAutoScroll.value = false;
    }
  }
  // 如果滚动到底部,重新开启自动跟随
  if (isAtBottom) {
    enableAutoScroll.value = true;
  }

  // 记录本次滚动位置
  lastScrollTop = scrollTop;
}, 100);

.chat-scroll-container{
    height: 1000px; 
    .chat-messages{
    // 高度由内容支撑
    }
 }

MutationObserver

监听容器的变化,包括高度、内容变化、DOM的操作等等。大多都抛弃该做法

  • 触发次数极多

  • 性能开销

  • 容易抖动、重复触发

  • 性能不如ResizeObserver

let observer = null;
onMounted(() => {
  initObserver();
});

// 初始化 MutationObserver
const initObserver = () => {
  if (!scrollContainerRef.value) return;
  // 断开旧的 observer
  if (observer) {
    observer.disconnect();
  }
  observer = new MutationObserver(() => {
    if (!enableAutoScroll.value) return;
    // 内容变化时,自动滚动到底部
    scrollToBottom();
  });
  observer.observe(scrollContainerRef.value, {
    childList: true,
    subtree: true,
    characterData: true,
  });
};
// 滚动操作
const scrollToBottom = (smooth = true) => {
  nextTick(() => {
    if (!scrollContainerRef.value) return;
    scrollContainerRef.value.scrollTo({
      top: scrollContainerRef.value.scrollHeight,
      behavior: smooth ? 'smooth' : 'instant',
    });
  });
};

ResizeObserver

监听容器是否发生尺寸变化,而不是滚动容器。操作跟随需要操作滚动容器。

// 启用监听
onMounted(() => {
  const ro = new ResizeObserver(() => {
    if (enableAutoScroll.value) {
      scrollToBottom();
    }
  });
  ro.observe(chatMessagesRef.value); // 监听高度变化容器
});
// 滚动操作,操作滚动容器
const scrollToBottom = (smooth = true) => {
  nextTick(() => {
    if (!scrollContainerRef.value) return;
    scrollContainerRef.value.scrollTo({
      top: scrollContainerRef.value.scrollHeight,
      behavior: smooth ? 'smooth' : 'instant',
    });
  });
};

5 分钟用 Vite SSR 搭建一个全栈 React 应用

Vite 是 JavaScript 生态中最快的开发服务器。但用它做 SSR 一直意味着自己接 renderToPipeableStream、配置 client/server 构建、处理 hydration。

Pareto 是基于 Vite 7 的 React SSR 框架,帮你处理好这一切。文件路由、流式 SSR、loader、状态管理、62 KB 的客户端包——零配置。

5 分钟,从零到一个全栈 React 应用。

1. 创建项目(30 秒)

npx create-pareto@latest my-app
cd my-app
npm install
npm run dev

打开 http://localhost:3000。编辑 app/page.tsx,通过 Vite 的 HMR 即时热更新。

2. 理解项目结构(30 秒)

my-app/
  app/
    layout.tsx        # 根布局(header、nav、footer)
    page.tsx          # 首页 (/)
    head.tsx          # 根 <title> 和 meta 标签
    not-found.tsx     # 404 页面
    globals.css       # 全局样式
  pareto.config.ts    # 框架配置(可选)
  package.json
  tsconfig.json

app/ 下任何包含 page.tsx 的目录就是一个路由。嵌套目录创建嵌套路由。就这样。

3. 创建带服务端数据的页面(1 分钟)

/posts 创建新路由:

// app/posts/loader.ts
import type { LoaderContext } from '@paretojs/core'

export function loader(ctx: LoaderContext) {
  // 只在服务端运行
  return {
    posts: [
      { id: 1, title: 'Hello World', body: '第一篇文章' },
      { id: 2, title: 'Vite SSR', body: '真的很快' },
    ],
  }
}
// app/posts/page.tsx
import { useLoaderData } from '@paretojs/core'

interface Post {
  id: number
  title: string
  body: string
}

export default function PostsPage() {
  const { posts } = useLoaderData<{ posts: Post[] }>()

  return (
    <div>
      <h1>文章列表</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.body}</p>
          </li>
        ))}
      </ul>
    </div>
  )
}
// app/posts/head.tsx
export default function Head() {
  return (
    <>
      <title>文章 — My App</title>
      <meta name="description" content="所有博客文章" />
    </>
  )
}

访问 http://localhost:3000/posts。Loader 在服务端运行,HTML 是服务端渲染的,客户端 hydrate。查看源码——文章内容就在 HTML 里。

4. 为慢数据添加流式渲染(1 分钟)

真实应用需要查数据库、调 API。有些快,有些慢。用 defer() 流式传输慢数据,不阻塞页面:

// app/dashboard/loader.ts
import { defer } from '@paretojs/core'

async function getQuickStats() {
  return { users: 1_234, pageViews: 56_789 }
}

async function getSlowAnalytics() {
  // 模拟一个慢 API 调用
  await new Promise((r) => setTimeout(r, 2000))
  return { topPage: '/posts', bounceRate: 0.42 }
}

export async function loader() {
  const stats = await getQuickStats()  // 先解析快数据
  return defer({
    stats,                               // 已解析——包含在初始 HTML
    analytics: getSlowAnalytics(),       // Promise——后续流式传输
  })
}
// app/dashboard/page.tsx
import { useLoaderData, Await } from '@paretojs/core'

export default function DashboardPage() {
  const { stats, analytics } = useLoaderData()

  return (
    <div>
      <h1>仪表板</h1>
      <p>{stats.users} 用户 · {stats.pageViews} 页面浏览</p>

      <Await resolve={analytics} fallback={<p>加载分析数据...</p>}>
        {(data) => (
          <div>
            <p>热门页面:{data.topPage}</p>
            <p>跳出率:{(data.bounceRate * 100).toFixed(0)}%</p>
          </div>
        )}
      </Await>
    </div>
  )
}

访问 http://localhost:3000/dashboard。统计数据立即显示。分析数据 2 秒后流入。页面从不阻塞。

5. 添加客户端导航(30 秒)

<Link> 实现 SPA 风格的导航:

// app/layout.tsx
import type { PropsWithChildren } from 'react'
import { Link } from '@paretojs/core'

export default function RootLayout({ children }: PropsWithChildren) {
  return (
    <>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/posts">文章</Link>
        <Link to="/dashboard">仪表板</Link>
      </nav>
      <main>{children}</main>
    </>
  )
}

点击即时导航。Loader 数据通过 NDJSON 流式获取——延迟数据逐步流入,和初始 SSR 渲染行为一致。

6. 添加状态管理(30 秒)

Pareto 内置 defineStore(),集成 Immer——不需要额外依赖:

// app/stores/theme.ts
import { defineStore } from '@paretojs/core/store'

export const themeStore = defineStore((set) => ({
  mode: 'light' as 'light' | 'dark',
  toggle: () => set((d) => {
    d.mode = d.mode === 'light' ? 'dark' : 'light'
  }),
}))
// 在任何组件中使用
import { themeStore } from '../stores/theme'

function ThemeToggle() {
  const { mode, toggle } = themeStore.useStore()
  return <button onClick={toggle}>主题:{mode}</button>
}

状态在 SSR 期间自动序列化,客户端自动 hydrate。零样板代码。

7. 添加 API 端点(30 秒)

创建 route.ts 文件来定义 JSON API 端点:

// app/api/time/route.ts
import type { LoaderContext } from '@paretojs/core'

export function loader(ctx: LoaderContext) {
  return { time: new Date().toISOString() }
}

GET http://localhost:3000/api/time 返回 {"time":"2026-04-03T..."}。标准 REST 端点,无需额外配置。

8. 构建和部署(1 分钟)

npm run build
npm run start

你的生产服务器是标准 Node.js 进程,跑 Express + Vite 优化后的构建产物。部署到任何地方:Docker、Fly.io、Railway、VPS、Kubernetes。

不需要特殊托管。不需要 serverless 运行时兼容。不锁定供应商。

你刚刚构建了什么

5 分钟内,你拥有了:

  • 文件路由 — 目录映射为路由
  • 服务端渲染 — 首次加载完整 HTML,利好 SEO
  • 流式 SSR — 慢数据不阻塞页面
  • 客户端导航 — SPA 体验 + NDJSON 流式传输
  • Head 管理 — 每个路由独立的 title 和 meta 标签
  • 状态管理 — Immer 驱动的 store,SSR hydration 全自动
  • API 端点 — JSON 路由和页面共存
  • TypeScript — 全链路类型安全
  • 62 KB 客户端包 — 比 Next.js 小 73%

全部基于 Vite 7——即时启动开发服务器、React Fast Refresh、原生 ESM。

为什么选 Vite 做 SSR?

Vite 的原生 ESM 开发服务器意味着开发时零打包。你的 100 个路由的应用启动速度和 1 个路由一样快。对比基于 Webpack 的框架,开发服务器启动时间随项目规模线性增长。

插件生态是另一个优势——PostCSS、Tailwind、MDX 以及数百个 Rollup/Vite 插件开箱即用,不需要框架包装层。

下一步

npx create-pareto@latest my-app
cd my-app && npm install && npm run dev

Pareto — 轻量级流式 React SSR 框架 | 文档

React 常用知识点整理

前言:本文总结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 风险。

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

在当今的大前端与全栈开发中,实时通信技术已经成为了不可或缺的技能。特别是随着 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 的全双工广播聊天室。最后,我们补齐了长连接应用走向生产环境的最后一块拼图——心跳机制。

【面试复盘】前端底层原理与 React 核心机制深度梳理

写在前面

相信很多前端同学都有过这种绝望的时刻:明明八股文背得滚瓜烂熟,源码也看了几套,结果一上战场,面试官顺着你的回答随便追问一个‘为什么’,瞬间就哑火了。
‘为什么不能用 index 做 key?’ ‘为什么箭头函数不能 new?’ ‘useEffect 空数组到底闭包了什么?’
这些问题看似基础,但考察的绝对不是记忆力,而是你对 JavaScript 引擎机制和框架设计哲学的‘第一性原理’理解
最近经历了一次深度的技术面试,我尝试换一种思路去答题——不谈表象,只谈本质。从 RAG 业务场景到 React 协调机制,从 JS 词法作用域到现代构建工具的范式转变。整理出这份近 5000 字的复盘,希望能帮大家把零散的知识点,串成一张坚不可摧的底层网。

项目部分

Rag 如何减少模型的幻觉?

RAG(检索增强生成)减少模型幻觉的核心逻辑,可以简单概括为四个字: “开卷考试”

大模型产生幻觉,根本原因在于它是“闭卷考试”——它只能依靠训练时记忆在神经网络里的权重来“猜测”下一个词,当记忆模糊或知识不足时,它就会一本正经地胡说八道。

引入RAG后,机制发生了根本变化,具体是如何减少幻觉的:

1. 提供事实“锚点”
在RAG流程中,模型在回答问题前,会先去外部知识库检索出相关的真实文档片段。模型生成回答时,是被要求严格基于这些检索到的片段来进行的。这就把模型从“凭空捏造”变成了“阅读理解”,大大降低了脱离事实乱编的概率。

2. 划定知识的边界
没有RAG时,模型很容易“越界”,比如用A领域的知识错误地回答B领域的问题。有了RAG,检索到的文档片段就像是给模型划定了范围,模型只需要在这个小范围内做总结和归纳,减少了发散性幻觉。

3. 增加了“拒绝回答”的能力
纯大模型往往有一种“迎合用户”的倾向,即使不知道也硬编。而在优秀的RAG设计中,如果检索系统发现没有找到与问题相关度足够高的文档(比如相似度得分低于某个阈值),系统可以直接拦截,返回“我没有找到相关资料”,从源头上掐断了幻觉。

4. 结果可追溯
RAG的输出通常可以附带信息来源(比如引用了哪篇文档的第几段)。这不仅让用户可以自己去核实真伪,这种“被监督”的机制在工程上也会倒逼模型更谨慎地对待检索到的内容。

前端部分

React组件信息传递

1. 父传子:直接通过 Props 传递数据

父组件在渲染子组件时,将数据作为属性传入,子组件通过 props 接收。

// 父组件
function Parent() {
  const message = "Hello from Parent";
  return <Child text={message} />;
}

// 子组件
function Child({ text }) {
  return <div>{text}</div>;
}

2. 子传父:通过回调函数

父组件传递一个函数给子组件,子组件在适当的时候调用这个函数,将数据作为参数传回去。

// 父组件
function Parent() {
  const [childData, setChildData] = useState("");

  const handleReceiveData = (data) => {
    setChildData(data);
  };

  return (
    <div>
      <p>子组件传来的数据: {childData}</p>
      <Child onSendData={handleReceiveData} />
    </div>
  );
}

// 子组件
function Child({ onSendData }) {
  const handleClick = () => {
    onSendData("Hello from Child!");
  };
  return <button onClick={handleClick}>发送数据给父组件</button>;
}

二、 兄弟组件通信

兄弟组件之间没有直接的连接,必须借助它们的共同父组件作为中转。这种方式叫做状态提升

原理:  将共享的状态提升到最近的共同父组件中,然后通过“父传子”把状态传给需要显示的兄弟,通过“子传父(回调)”让另一个兄弟修改状态。

三、 跨层级组件通信(祖孙组件)

如果组件层级很深(比如 A -> B -> C -> D),使用 Props 逐层传递会非常繁琐,这就是所谓的 Props Drilling(逐层透传) 。解决方法有两种:

1. Context API(React 内置方案)

Context 提供了一种在组件树中共享数据的方式,无需手动传递 props。

步骤:  创建 Context -> 提供 Provider -> 消费 Context。

2. 使用第三方状态管理库(Redux / Zustand)

当跨层级的组件非常多,或者状态逻辑非常复杂时,Context 可能会导致不必要的重渲染。这时通常会引入状态管理库(如目前最流行的 Zustand 或传统的 Redux),它们将状态独立于组件树之外进行管理。

说一下什么是闭包

闭包就是一个‘随身携带记忆的函数’
从学术角度讲,它是一个函数以及其捆绑的周围环境(词法环境)的引用的组合。简单来说,就是一个内部函数,记住了并能够访问它外部函数的变量,即使外部函数已经执行完毕了。

产生闭包的根本原因在于 JavaScript 的词法作用域
词法作用域意味着,一个函数在定义的时候,就已经决定了它能访问哪些变量,而不是在调用的时候决定的。
正常情况下,函数执行完毕后,它内部的局部变量会被垃圾回收机制(GC)销毁,释放内存。但是,如果内部函数被返回到了外部,并且在外部被调用,由于内部函数还保持着对外部变量的引用,垃圾回收机制就不会销毁这些变量。这就形成了闭包。

闭包常见的场景?

1. 防抖和 节流

这是闭包最经典的应用。它们的目的是限制函数的执行频率,核心逻辑就是利用闭包缓存一个定时器(timer)变量

  • 场景:  搜索框输入联想、滚动条事件监听、窗口 resize。
  • 闭包体现:  外部函数接收你要执行的函数和等待时间,返回一个内部函数。内部函数每次触发时,都会去闭包里检查那个唯一的 timer 存不存在,以此决定是清除重新计时,还是直接跳过。

2. 函数柯里化

柯里化是把一个多参数函数,转换成多个单参数函数的过程。

  • 场景:  比如有一个通用的日志打印函数 log(level, date, message),你可以柯里化成 logError = log('error'),以后直接调用 logError('出错了')
  • 闭包体现:  内层函数记住了外层函数传入的 level 这个参数,形成了一个定制化的新函数。

React Hooks 的基石

如果你面 React,这条必说。Hooks 能在函数组件里“保存状态”,完全依赖闭包。

  • useStateconst [count, setCount] = useState(0)。React 底层通过链表或者数组存了一个真实的 count 值。你每次调用的 setCount 和渲染出来的 UI,其实都是闭包,它们通过引用关联到了那个被 React 托管的内存地址。
  • useEffect / useCallback:它们的依赖数组机制,本质上就是在控制“我这个闭包要捕获哪一次渲染时的变量”。
  • 场景:  解决 React 中的 Stale Closure(闭包陷阱)问题,是高级前端必备技能。

箭头函数和普通函数的对比 ?哪个能用作构造函数?

  • 普通函数的 this 是动态的:它取决于函数是怎么被调用的。谁调用它,this 就指向谁(默认绑定、隐式绑定、显式绑定、new 绑定)。如果在严格模式下没调用者,this 就是 undefined

  • 箭头函数的 this 是静态的(词法作用域) :它没有自己的 this,它里面的 this 继承自它定义时所在的外层作用域。而且一旦定义,就永远不会变,你用 callapplybind 去强行修改也没用。

只有普通函数可以作为构造函数,箭头函数不能。  如果你尝试用 new 关键字去调用一个箭头函数,JavaScript 引擎会直接抛出 TypeError 报错。”

【核心:解释为什么不能?(展现底层原理)】
“要理解为什么不能,我们需要拆解一下 new 操作符在底层到底做了哪些事情。当执行 new Foo() 时,引擎会做四步:

  1. 创建一个空的内存对象。
  2. 将这个对象的 __proto__ 指向构造函数的 prototype
  3. 将构造函数内部的 this 绑定到这个新对象上。
  4. 如果构造函数没有显式返回其他对象,则返回这个新对象。

而箭头函数的设计初衷,恰恰与第 3 步水火不容。箭头函数最大的特点就是没有自己的 this,它的 this 是静态的,继承自外层词法作用域。

既然箭头函数连自己的 this 都没有,new 操作符就找不到目标去绑定这个新对象,所以 JS 规范在底层就直接禁止了这种行为,连尝试的机会都不给。”

说一下 React Key

关于 React 的 key,的本质并不是为了提升性能,而是为了身份标识。它是 React 在虚拟 DOM 树中进行节点比对时,用来判断‘这个节点还是不是上次那个节点’的唯一凭证。

【第一层:底层运行机制(展现原理深度)】
“当组件状态更新触发重新渲染时,React 会生成新的虚拟 DOM 树,然后拿着新树和旧树进行 Diff 算法比对。
在没有 key 的情况下,React 只能采用‘按顺序盲目对比’顺序对比。
一旦我们给列表项加上了 key,React 的比对策略就会变成‘按 key 查找’ 。React 会发现拥有某个 key 的元素在前后两次渲染中都存在,它就会认为这是同一个组件实例**,进而去复用这个实例,只更新它发生变化的属性。这就避免了组件的销毁和重建。”

什么是虚拟DOM?

不能用简单的一句‘JS 对象’来概括虚拟 DOM。从本质上讲,虚拟 DOM 是前端在状态(数据)和真实 DOM 之间,建立的一层‘缓冲层’或‘抽象层’。它是 React 等现代框架实现‘状态驱动视图’的核心基石。”

【第一层:为什么要发明虚拟 DOM?(讲透痛点)】
“在以前用 jQuery 时代,我们是‘命令式’开发,状态一变,就要手动去操作 DOM(比如 document.createElementappendChild)。但操作真实 DOM 的代价是非常昂贵的,因为它会触发浏览器的重排和重绘,甚至牵一发而动全身。
现代框架是‘声明式’开发,我们只关心状态 state 变成什么样,不关心 DOM 怎么变。虚拟 DOM 就是为了填补这中间的鸿沟。当状态改变时,框架生成一棵新的虚拟 DOM 树,然后跟旧的树进行比对,计算出最小差异,最后再一次性批量去操作真实 DOM。”

【第二层:它到底长什么样?(具象化展示)】
“从代码层面看,它确实是用普通 JS 对象来描述 DOM 节点的。比如一段 JSX:<div class="app"><h1>Hello</h1></div>,经过 Babel 转换后,在底层其实调用了 React.createElement,最终生成的大概是这样的一个对象树:

{
  type: "div",
  props: { className: "app", children: [
    { type: "h1", props: { children: "Hello" } }
  ]}
}

它把原本极其复杂的真实 DOM 节点上的几百个属性和 API,精简成了我们真正关心的 type(类型)、keyprops(属性和子节点)。因为是纯 JS 对象,所以操作它的速度比操作真实 DOM 快几个数量级。”

【第三层:核心价值大反转(展现高级认知,极其加分!!!)】
“很多人(包括以前的我)以为虚拟 DOM 的最大优势是‘比直接操作 DOM 快’,其实这是一个常见的误区
JS 操作虚拟 DOM 的确快,但最终你还是要调用浏览器 API 去更新真实 DOM。如果你手动优化的足够好,原生 JS 操作 DOM 肯定是最快的。

虚拟 DOM 真正不可替代的价值在于:

  1. 为我们提供了批量更新和异步更新的能力:有了这层缓冲,React 就可以把多次状态更新合并成一次虚拟 DOM 计算,最后只打一次补丁,极大优化了性能。
  2. 抹平了环境差异,实现了跨平台:这是最牛逼的一点。既然 divspan 只是 JS 对象里的一个 type: 'div' 字符串,那只要我写不同的“渲染器”,告诉它遇到 'div' 在浏览器里怎么画,在移动端遇到 'View' 怎么画,不就能跨平台了吗?(这就是 React Native 和 React DOM 的底层原理)。如果没有虚拟 DOM 这个中间层,React 根本做不到一套代码多端运行。

【第四层:最新技术视野(防坑)】
“当然,虚拟 DOM 也不是万能的,它也有劣势,比如内存占用大(要维护两棵树),Diff 计算也有时间开销。所以现在像 Vue 3 引入了 Compiler-informed(编译时提示) ,SolidJS 甚至直接放弃了虚拟 DOM 走编译时,都是为了绕开虚拟 DOM 的运行时开销。但在 React 当前基于运行时的架构下,虚拟 DOM 依然是最优解。”

16. 为什么使用vite?

17. 为什么vite更快一些?

18. vite在开发的时候是基于什么构建的?

【回答 Q16 & Q17:为什么用 Vite?为什么这么快?——核心在于“范式转变”】
“Vite 之所以快,并不是因为它用了什么黑魔法,而是因为它改变了开发阶段的构建范式

传统工具是 ‘先打包,再服务’
而 Vite 在开发阶段是 ‘先服务,按需编译’

具体快在两个维度:

  1. 极速冷启动:Vite 启动时,绝对不会去打包你的业务代码。它直接启动一个静态服务器,利用浏览器原生的 ES Module(ESM)支持。当浏览器请求某个组件时,Vite 才去编译那个组件并返回。启动时间从跟项目体积成正比,变成了常数级(通常几百毫秒)。
  2. 极速热更新(HMR) :当你修改了一个 Vue/React 组件,Vite 只会精确地去重新编译这个模块,然后通过 ESM 的热替换机制让浏览器更新。它不需要像 Webpack 那样去重新构建整条依赖链,所以无论项目多大,HMR 都能保持在毫秒级。”

【回答 Q18:开发时基于什么构建?——亮出底层武器】
“为了支撑上面说的‘按需编译’,Vite 在开发阶段主要基于两个核心东西:

第一个,就是刚才说的浏览器原生 ESM,这是 Vite 快的机制基础。

第二个,就是预构建工具 Esbuild(基于 Go 语言编写)。
这里有个细节,Vite 的源码业务代码是按需编译的,但是对于 node_modules 里的第三方依赖(比如 React、Lodash),Vite 会在启动时用 Esbuild 把它们预先打包成 ESM 格式
为什么要多此一举?因为第三方依赖可能有几百上千个细碎的文件,如果让浏览器去发几千个 ESM 请求会直接卡死。而且,很多老一点的 npm 包还是 CommonJS 格式,浏览器不认识。用 Esbuild 预构建,既能把 CJS 转成 ESM,又能把几百个文件合并成几个大文件,极大地减少了网络请求。

Esbuild 为什么快?因为它用 Go 写的,去掉了 AST(抽象语法树)的解析过程,直接把代码转成机器码,速度比用 JS 写的 Webpack/Babel 快 10 到 100 倍。”

React

【回答 Q22:useRef 在哪些操作时会用到?】
useRef 的核心特征就一句话:它的改变不会触发组件重新渲染。基于这个特性,我主要在以下三个场景使用它:

  1. 获取 DOM 元素的引用:这是最基础的用法。比如页面加载后,需要让一个输入框自动聚焦,或者获取一个 canvas 节点来绘制图表,这时候就用 const inputRef = useRef(null) 绑定上去,然后通过 inputRef.current.focus() 命令式地操作 DOM。
  2. 存储不参与视图渲染的‘可变值’:这是很多初学者容易忽略的。比如我在用定时器(setInterval)或者发请求时,需要保存一个 timerID 以便在组件卸载时清除;或者我想记录上一次的某个状态值用来做对比。如果用 useState 存 timerID,每次存都会导致组件无意义的重渲染,而用 useRef 就完美解决了这个问题,它相当于一个贯穿组件整个生命周期的‘全局变量’。
  3. 跨组件命令式通信:结合 forwardRef 和 useImperativeHandle,父组件可以通过 ref 直接调用子组件内部暴露出来的方法(比如让子组件弹窗强制打开),打破常规的 props 数据流。”

【回答 Q23:useEffect 什么时候执行?】
“关于 useEffect 的执行时机,很多新手会把它和类组件的 componentDidMount 完全等同起来,其实不完全准确。它的精确执行时机是:在浏览器完成布局与绘制(即 DOM 更新完毕)之后,异步执行的

具体来说分三种情况:

  1. 不传依赖数组:组件每一次渲染(无论是初始化还是状态更新导致的重渲染),DOM 更新完之后,它都会执行。
  2. 传入依赖数组(比如 [count] :组件初次渲染会执行一次;之后,只有当依赖数组里的变量发生改变,导致重渲染完毕后,它才会再次执行。
  3. 清理函数的执行时机useEffect 里面 return 的函数,会在组件卸载前执行,或者在下一次 Effect 执行前执行(用来清除上一次的定时器或解绑事件)。”

【回答 Q24:useEffect 依赖数组为空时,什么时候执行?(核心考点)】
“这里需要纠正一个小概念, ‘渲染’和 ‘Effect 执行’是两回事。当依赖数组为空 [] 时:

  1. 执行时机:它仅仅在组件初次挂载、完成第一次真实的 DOM 渲染之后,执行一次。之后无论组件因为什么原因(父组件传值变了、自己的其他 state 变了)重渲染多少次,这个 Effect 都绝不会再执行。
  2. 最大的坑:闭包陷阱
    正因为空数组让 Effect 只执行一次,这就意味着它内部形成了一个永远闭包住初次渲染状态的闭包。
    比如,如果我在 useEffect([]) 里写了一个 setInterval,里面去读取外部的某个 state,那么这个定时器读到的 state 永远是初始值,永远不会更新,这就是 React 中臭名昭著的‘闭包陷阱’。
  3. 如何解决:如果你在空数组的 Effect 里要用到最新的值,要么把该值加入依赖数组(但要注意可能会引发多次执行和清理),要么使用 useRef 把最新值存起来(因为 ref 的修改不依赖渲染,Effect 里面读 ref.current 总能拿到最新值)。”

AI全栈入门指南:NestJs 中的 DTO 和数据校验

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

前面几篇里,控制器、服务、模块的关系已经铺开了。接下来是一个很现实的问题:参数一进控制器,能不能直接往服务层传。

技术上可以。@Body()@Query()@Param() 拿到的都是未经你类声明约束的原始形态,类型上也往往是宽松的。

真做项目时,这种写法会很快变成隐患。请求来自外部,外部输入不能默认可信:字段可能缺失、类型可能串了、字符串里可能塞了根本转不成数字的内容,甚至还可能多带几个你从未在文档里写过的键。

这就是 DTO 要解决的问题。

DTOData Transfer Object 的缩写。先把它想成"接口层的数据契约"。它不承载业务过程,只回答这几件事:

  • 这次请求允许出现哪些字段
  • 每个字段期望的类型是什么
  • 哪些是必填
  • 除类型以外还要满足哪些约束

拿"创建用户"来说,若没有契约,你很容易遇到:

  • name 是空字符串
  • email 根本不像邮箱
  • age 传成了 "abc"
  • 客户端悄悄带上 role: "admin"

脏数据一旦进了服务层或持久层,再排查就要沿着整条调用链往回找,成本很高。

所以 DTO 的价值不只是给参数"加个类型标注",而是把接口边界写死,让不合法的东西尽量在进门时被拦下。

下面是一个最基础的入参契约,字段上的装饰器来自 class-validator,后面接上 ValidationPipe 后才会真正生效:

import { IsEmail, IsInt, IsString, Min, MinLength } from "class-validator";

/** 创建用户接口允许的请求体形状 */
export class CreateUserDto {
  @IsString()
  @MinLength(2)
  name: string;

  @IsEmail()
  email: string;

  @IsInt()
  @Min(0)
  age: number;
}

这个类既不是表结构,也不是领域实体,它只是说:创建用户这条接口,合法请求体至少长这样。

class-validatorclass-transformer

NestJS 里,DTO 通常和两个库成对出现:

  • class-validator 管规则,字段对不对、满不满足约束
  • class-transformer 管形态,把普通对象转成类实例,并在需要时做类型转换

一句话分工:class-validator 问"对不对",class-transformer 问"怎么变成声明里的那种形状"。

查询字符串里的数字、嵌套对象里的子对象,往往都要靠转换配合校验,否则你会一直在和业务代码里多余的 Number()parseInt 打交道。

下面这个查询 DTO 同时用到了两边:@Type(() => Number) 先把 page 尽量变成数字,再用 @IsInt()@Min(1) 收紧范围。

import { Type } from "class-transformer";
import { IsEmail, IsInt, IsOptional, IsString, Min } from "class-validator";

/** 用户列表查询:关键词可选,页码可选且至少为 1 */
export class QueryUsersDto {
  @IsOptional()
  @IsString()
  keyword?: string;

  @IsOptional()
  @Type(() => Number)
  @IsInt()
  @Min(1)
  page?: number;
}

为什么查询参数特别需要 @Type。因为从 HTTP 进应用时,查询串几乎都是字符串。?page=2 在多数时候先是 "2",不转一把,@IsInt() 很容易和你的直觉拧着。

全局开启 ValidationPipe 且设置 transform: true 时,还可以再配合 transformOptions.enableImplicitConversion,对部分简单类型做隐式转换。嵌套结构、联合形态仍然更推荐显式写 @Type,可读性更好,也少踩坑。

依赖若尚未安装,在项目根目录执行:

pnpm add class-validator class-transformer

装好后,DTO 上的装饰器才有运行时意义。

ValidationPipe 的用法

光定义 DTO 类,请求进来并不会自动校验。真正把契约接进管道的是 ValidationPipe

把它想成控制器前的一道闸:参数先按 DTO 规则过一遍,过了才进方法体,不过则直接短路成错误响应。

默认情况下,校验失败会抛出 BadRequestException,HTTP 状态码一般是 400。响应体里常见 message 字段,内容多为字符串数组,逐项列出哪条规则没通过,便于联调。

最常见的做法是在 main.ts 里全局挂上管道:

import { ValidationPipe } from "@nestjs/common";
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap(): Promise<void> {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
    }),
  );

  await app.listen(3000);
}

void bootstrap();

全局启用之后,只要在参数位置写了具体的 DTO 类型(而不是泛泛的 object),Nest 就会尝试按类做转换和校验。

import { Body, Controller, Post } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UsersController {
  @Post()
  create(@Body() body: CreateUserDto): CreateUserDto {
    // 能执行到这里时,body 已通过校验并按 DTO 做过转换
    return body;
  }
}

不满足 CreateUserDto 时,create() 不会执行,客户端会先收到校验错误。服务层就可以少写一层重复的"字段是不是 string"式的防御代码。

如果某个路由要临时关掉转换或换一套规则,可以用控制器级或方法级管道覆盖默认行为,不必动全局配置:

import { Body, Controller, Post, UsePipes, ValidationPipe } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";

@Controller("users")
export class UsersController {
  @Post("draft")
  @UsePipes(
    new ValidationPipe({
      whitelist: true,
      transform: true,
      forbidNonWhitelisted: false,
    }),
  )
  saveDraft(@Body() body: CreateUserDto): CreateUserDto {
    return body;
  }
}

对多数业务项目,全局一套偏严格的默认值,再在少数路径上放宽,往往比完全不用全局管道省心。

白名单、转换与多余字段

ValidationPipe 的价值不止于报错。whitelistforbidNonWhitelistedtransform 三个开关配合起来,可以把入口擦得很干净。

app.useGlobalPipes(
  new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }),
);

whitelist

whitelist: true 时,只有 DTO 上声明过的属性会留在对象上。多出来的键会被剥掉。

DTO 只有 nameemail,客户端却带了 roleisAdmin,这些多余字段不会跟着进控制器方法。很多风险来自"多传了不该收的字段",而不只是字段值写错。

forbidNonWhitelisted

forbidNonWhitelisted: true 再收紧一档:只要出现未声明字段,直接判失败,而不是悄悄删掉。

公开 API、对接第三方、强契约场景更适合打开它。

transform

transform: true 会启用 class-transformer,把原始负载转成类实例,并按装饰器做类型转换。

例如查询串里的 page=2 可以变成数字 2,避免整份业务代码里到处是手动的 Number()

实际顺序可以粗略理解成:先尽量转成 DTO 实例并做类型转换,再跑 class-validator,最后按白名单剥掉多余属性。校验失败会在进入控制器之前返回,不会混进半合法对象。

20260328102554

参数并不是原样流进控制器,而是先被整理成契约允许的形状。收益不只是少报错,而是入口这一圈边界可控、可测、可讲清楚。

嵌套对象与数组

请求体里常有嵌套结构,例如地址、标签列表。外层 DTO 校验到了,内层仍是普通对象,规则不会自动往下传。

常见写法是对嵌套属性再声明一个 DTO 类,在外层加上 @ValidateNested(),并用 @Type(() => InnerDto) 指明怎么实例化内层。数组则配合 @IsArray()@ArrayMinSize() 等与集合相关的装饰器。

import { Type } from "class-transformer";
import {
  IsArray,
  IsString,
  MinLength,
  ValidateNested,
} from "class-validator";

export class AddressDto {
  @IsString()
  @MinLength(1)
  city: string;
}

export class CreateOrderDto {
  @ValidateNested()
  @Type(() => AddressDto)
  address: AddressDto;

  @IsArray()
  @IsString({ each: true })
  tags: string[];
}

嵌套越深,越要在类型和装饰器上写清楚,否则很容易出现"外层过了、内层仍是任意 JSON"的假象。

从已有 DTO 派生

更新接口常常和创建接口只差"全部可选"。手写两份几乎相同的类容易漂移,可以用 @nestjs/mapped-types 里的 PartialType 从创建 DTO 派生更新 DTO,装饰器会一并变成可选校验。

import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDto } from "./create-user.dto";

/** 更新用户:字段与创建一致,但均可选 */
export class UpdateUserDto extends PartialType(CreateUserDto) {}

安装依赖:

pnpm add @nestjs/mapped-types

还有 PickTypeOmitType 等,用在"只要子集字段"的场景,思路相同:一份源契约,多份视图,而不是复制粘贴改几个字母。

DTOEntityVO 不要混用

后期常见的大坑,是把长得差不多的类来回复用。数据库实体直接当入参 DTO 用,或把带密码哈希的实体原样返回给前端,短期省事,长期边界全糊。

DTOEntityVO 都可以是一组字段,但站位不同:

  • DTO 对准接口进出的契约
  • Entity 对准持久化与领域状态
  • VO 对准对外展示或某次响应的裁剪结果

同一张用户表在三层里的切片往往不一样。

UserEntity 里可能有 idnameemailpasswordHashcreatedAtupdatedAt。创建用户的 CreateUserDto 只要 nameemailpassword。返回前端的 UserProfileVo 可能只给 idnameemail。看起来都在描述用户,语义并不相同。

混用会带来:入参与存储绑死、内部字段意外暴露、一个类为了兼容多种场景不断长歪、改一处字段牵动所有层。

/** 创建接口入参 */
export class CreateUserDto {
  name: string;
  email: string;
  password: string;
}

/** 与数据库表或 ORM 实体对齐 */
export class UserEntity {
  id: string;
  name: string;
  email: string;
  passwordHash: string;
  createdAt: Date;
}

/** 返回给前端的公开资料,不含密码类敏感字段 */
export class UserProfileVo {
  id: string;
  name: string;
  email: string;
}

即便字段重叠,也不要因为"看着像"就合成一个类。习惯上可以记:DTO 站在门口,Entity 站在存储与领域内部,VO 站在对外可见的应答形状。

小结

这一篇想建立的,不局限于"会贴几个校验装饰器",而是这条判断:

接口参数不能默认可信。

DTO 把边界写清楚,class-validator 写规则,class-transformer 做实例化与转换,ValidationPipe 把它们嵌进请求生命周期。白名单和严格拒绝多余字段,则是在契约之上再加一层安全习惯。

若下面这些已经变成你的默认思路,这一章就到位了:

  • 控制器拿到的外部数据不要裸用
  • 入参用 DTO 声明,并配合管道校验与转换
  • 嵌套与数组要有对应的嵌套 DTO 与集合装饰器
  • 需要时用 PartialType 等工具派生,避免复制粘贴
  • DTOEntityVO 各司其职,不因字段相似就混成一类

下一节会看配置与环境变量。除了 HTTP 负载,运行时的开关和密钥同样需要被约束和管理。

AI 全栈指南:NestJs 中的 Service Provider 和 Module

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、Agent、长期记忆、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

上一节里,Controller 负责接请求、取参数、返回结果。真正撑起接口价值的,多半不是"把请求接进来",而是背后的业务逻辑。

这段逻辑默认放在 Service 里。

先把 Service 想成"业务处理层"。它不太关心路由怎么对齐,也不太关心这次是 GET 还是 POST,更常琢磨的是下面这些:

  • 数据怎么查、怎么写
  • 规则怎么判定
  • 结果怎么拼装
  • 同一套逻辑别处还要不要复用

拿创建用户来说,麻烦往往不在收参数,而在查重、密码策略、默认状态、要不要发欢迎邮件。这些都更适合收紧 Service,而不是摊在控制器里。

下面的 UsersService 只在内存里摆个数组示意,重点看职责怎么收拢:

import { Injectable } from "@nestjs/common";

/** 内存里的用户结构,仅作示意 */
interface User {
  id: string;
  name: string;
}

@Injectable()
export class UsersService {
  private readonly users: User[] = [
    { id: "1", name: "汤姆" },
    { id: "2", name: "杰瑞" },
  ];

  /** 返回全部用户 */
  findAll(): User[] {
    return this.users;
  }

  /** 按主键查找,没有则 undefined */
  findById(id: string): User | undefined {
    return this.users.find((user) => user.id === id);
  }
}

数组只是替身,要紧的是"查全部"、"按 id 查"已经归进 UsersService。控制器只管调方法,不必过问细节。

Service 带来的直接好处主要是两条:

  • 控制器变薄,一层里不塞满所有事
  • 业务逻辑方便复用、写测试、以后改实现

习惯可以记得很短:控制器对齐请求,Service 扛起业务。

Provider 的本质

不少人初学时会把 ProviderService 混着说,其实分清也不难:Service 是很常见的一种 ProviderProvider 这个词包住的是所有"可注入实现"。

凡是能交给 NestJS 容器创建、保管,再注入给别的类的,都归在这一类里。常见例子包括:

  • 业务服务,例如 UsersService
  • 仓储或数据访问类,例如 UsersRepository
  • 横切能力,例如 MailService
  • 配置对象、工厂返回值、自定义 token 绑定的实例,也都算

框架把它们统称 Provider,并不是纠结类名该叫 Service 还是 Repository,而是在管三件事:

  • 要不要由容器负责实例化
  • 能不能被别人注入
  • 生命周期怎么配合作用域

写进模块的 providers 数组,就是在向容器挂号。只有挂上的实现才会按作用域被实例化,并有机会出现在别人的构造函数里。类名是服务还是仓储,只影响阅读,不影响这条规则。

下面两个类分工不同,在容器眼里却一视同仁,都是 Provider

import { Injectable } from "@nestjs/common";

@Injectable()
export class UsersService {
  findAll(): string[] {
    return ["汤姆", "杰瑞"];
  }
}

@Injectable()
export class MailService {
  sendWelcomeMail(email: string): string {
    return `已向 ${email} 发送欢迎邮件(示意)`;
  }
}

命名上你仍可以一个叫用户服务、一个叫邮件服务,登记方式没有区别。

记关系时只要两句就够:Provider 是框架侧的通用身份,Service 是业务里最常见的实现形态。以后遇到 Repository、工厂型 Provider 或自定义 token,仍然在同一个注入体系里处理。

Module 是什么

Service 扛业务,Provider 被容器托管,Module 则要再往上管一层:划清功能边界,把同一领域的控制器、Provider、对外约定装进一个盒子里。

NestJS 里,模块不是摆设,而是结构的基本单元,应用多半就是许多模块拼起来的东西。

用户、订单、认证可以各自落在 UsersModuleOrdersModuleAuthModule 上,每个模块维护自己的控制器、内部 Provider、以及愿意被别人用到的出口。

最小模块长这样:

import { Module } from "@nestjs/common";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";

/** 用户领域:对外入口 + 可注入服务 */
@Module({
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

行数不多,信息量不小:这几个人同属于一块业务边界;控制器对外接请求,UsersService 在本模块内可注入,再往下还可以继续挂别的 Provider

从结构上看,可以先扫一眼下面这张图。

20260328102242

节点不是漂在全局,而是先归进各自模块,再由 AppModule 一类根模块把业务模块接起来。

别把 Module 当成应付编译器的样板,它就是在替你划"这块功能从哪开始、到哪结束"。

imports 等四个字段各管什么

第一次看 @Module() 里的配置,最容易缠在一起的是 importsproviderscontrollersexports。拆开看就顺了。

下面在有用户模块的基础上多接了一个 DatabaseModule,并把 UsersService 对外导出,方便别的模块注入:

import { Module } from "@nestjs/common";
import { DatabaseModule } from "../database/database.module";
import { UsersController } from "./users.controller";
import { UsersService } from "./users.service";

/** 依赖数据库模块,并把用户服务暴露给 import 本模块的一方 */
@Module({
  imports: [DatabaseModule],
  controllers: [UsersController],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

四个键可以先记成功能分工:

  • imports 本模块依赖哪些别的模块已经 exports 出来的能力
  • providers 本模块自己要注册、仅供内部(默认可注入范围)使用的 Provider
  • controllers 本模块声明哪些 HTTP 入口
  • exports 本模块对外放行哪些 Provider,供在别处 imports 了本模块的代码继续注入

最常绊脚的一对是 providersexports

  • providers 是"家里有哪些实现"
  • exports 是"门口挂牌、准许邻居借用的有哪些"

留在 providers 里但没进 exports 的,别模块默认看不见。只有当别人也要注入这份实现,才需要把它写进 exports

这有点像团队分工:内部实现可以多,对外接口要收束;别人要用,只能走你声明过的模块边界。

分文件夹只是把文件挪个地方,模块是在声明"谁允许依赖谁、谁对外可见"。

为什么业务逻辑不能全写在 Controller

新手很容易图省事,把业务全堆进 Controller:参数在手,就地校验、拼装、返回,看起来一气呵成。

项目一大,这样最容易长胖的是控制器。

下面这个例子能跑,但已经在兼职干 Service 的活:

import { Body, Controller, Post } from "@nestjs/common";

/** 创建用户时客户端传入的字段 */
interface CreateUserDto {
  name: string;
  email: string;
}

@Controller("users")
export class UsersController {
  @Post()
  create(@Body() body: CreateUserDto): { message: string } {
    const exists = body.email === "tom@example.com";

    if (exists) {
      return { message: "该邮箱已存在" };
    }

    const user = {
      id: Date.now().toString(),
      name: body.name,
      email: body.email,
      status: "正常",
    };

    return { message: `已创建用户:${user.name}` };
  }
}

收参、判重、造对象、定响应格式挤在同一层,后面要复用、单测、接库、发信、上事务,只能继续往控制器里糊。

把规则挪进 Service,控制器只做转发,形态会干净很多:

import { Body, Controller, Post } from "@nestjs/common";
import { UsersService } from "./users.service";

interface CreateUserDto {
  name: string;
  email: string;
}

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body() body: CreateUserDto): { message: string } {
    // 业务规则交给服务层
    return this.usersService.create(body);
  }
}

改完后的控制器基本只做四件事:接请求、拿参数、喊 Service、把结果交出去。

收益不只是顺眼,而是业务落在更容易复用和测试的一层,项目越复杂越省劲。

为什么 ModuleNestJS 里最核心的那一层边界

Controller 管入口,Service 管业务落地,Module 管的是再底下那层:系统边界哪里画、依赖往哪收敛。

维护噩梦常常不是少写了类,而是边界糊掉:模块互相穿透实现细节,调用网越织越密。

NestJSModule 摆得这么重,是要你把应用想成"多模块协作",而不是"一大撮控制器加一大撮服务"。

边界划清楚以后,好处很实在:

  • 用户、订单、支付、认证各自有落脚模块
  • 依赖不容易随便渗透到别的模块内部
  • 拆分、复用、补测试都更顺手
  • 新人找功能时有目录感
  • 大重构可以按模块切块推进

反过来,模块若只是分文件夹,ServiceController 再多也可能是一盘散沙。

所以 Module 不只是凑齐装饰器清单,而是在体积涨上去之前,逼你先想清楚谁能见谁、谁能用谁。

顺口溜可以记成 "Controller 开门口,Service 做生意,Module 砌围墙"。

这三层站稳以后,依赖注入、模块导入导出、动态模块、可插拔架构都会沿同一套边界往下长。

小结

这篇的重点不是多记几个词,而是把三条线拧到一根绳上:

  • Service 承接大部分业务
  • Provider 是容器能注入的那类东西的统称
  • Module 划边界、装箱、再决定对外露什么

判断习惯可以压成四句:入口给控制器,规则给服务,可注入项进 Provider 列表,单元边界交给模块。

下一节讲 DTO 和校验。你会看到,光靠"拿到字段就用"在真实项目里往往不够。

当前端开始做 Agent 后,我才知道 LangGraph 有多重要❗❗❗

大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于 Tiptap 的富文本编辑、NestJS 后端服务、实时协作与智能化工作流等核心模块。

在这个项目的持续打磨过程中,我积累了不少实战经验,不只是 Tiptap 的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。

如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信 yunmz777 一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐

image.png

在之前的内容里面我们一直在用 LangChain 写链、写 Agent,从最简单的模型调用到工具绑定、路由分发、自定义工作流,走了一整套流程。到这里自然会遇到一个问题:随着应用逻辑越来越复杂,LangChain 原有的编排方式开始显得吃力。链是线性的,Agent 是循环的,但真实世界里的流程往往是图状的,有分支、有合并、有回环、有需要等待人工确认的节点。LangGraph 就是为了解决这个问题而出现的。

为什么需要 LangGraph

LangChainAgentExecutor 时,底层逻辑是一个简单循环:调模型、看要不要用工具、用完工具再回来、再调模型。这个模型对于简单的工具调用场景足够用,但一旦遇到以下几种情况,就开始捉襟见肘。

第一种是多步骤分支。假设需要先判断用户意图,然后根据意图走完全不同的子流程,子流程结束后还需要汇总结果再回复用户。AgentExecutor 的循环模型表达这类逻辑,需要把分支全部塞进提示词,或者用条件回调硬写,代码很快就乱成一团。

第二种是状态持久化。用户和 Agent 聊了几十轮,中途关掉了页面,下次再打开希望从上次停下的地方继续。LangChain 本身没有原生的持久化机制,记忆模块只是把消息列表临时存在内存里,进程一停就没了。

第三种是人机协同。工作流执行到某个敏感节点,需要暂停下来等人类审核,审核通过后才能继续往下跑。这种"执行中途打断、人工介入、再恢复"的场景,在 AgentExecutor 里几乎无法干净地实现。

LangGraph 把上面这些问题都纳入了核心设计。它的思路是把整个 Agent 或工作流建模成一张图,节点是计算步骤,边是流转路径,状态是在整张图上流动的数据。图可以有条件边,可以有回边,可以在任意节点打断并恢复,状态可以持久化到数据库。

LangGraph 的核心思路

理解 LangGraph 最好的方式是先搞清楚它的三个基本概念:状态、节点和边。

状态是图执行过程中一直流动的数据对象,可以把它想象成贯穿整个流程的"共享变量包",每个节点都可以读取里面的内容,也可以往里写新的内容。最常用的状态定义是 MessagesAnnotation,它把状态简化为一个消息列表,非常适合对话类应用。如果需要追踪工具调用次数、用户身份、中间计算结果等自定义字段,也可以用 Annotation 自己定义状态结构。

节点是图里的计算单元,每个节点就是一个普通的异步函数,接收当前状态作为参数,执行完后返回需要更新的状态字段。节点可以承担调用模型、执行工具、查询数据库、等待人工审核等任何有意义的计算步骤。

边是节点之间的连接。普通的边直接指向下一个节点,条件边则根据当前状态的内容动态决定下一跳,类似代码里的 if/else。图的执行从特殊的 __start__ 节点开始,到 __end__ 节点结束。

执行时,用户消息随状态流入 callModel 节点,模型回复追加到消息列表后随状态流出,整个过程一进一出,结构极其简单。如需在代码里取出结果,用 result.messages.at(-1) 拿最后一条即可。

下面这张图把五个关键步骤画在一条主线上,如下图所示。

20260317073347

用户发消息进入状态,callModel 节点读取、调用模型、追加回复,状态带着结果流到终点。

再复杂一点,加上工具调用和条件路由,图就具备了循环能力,如下图所示。

20260316231826

加入工具节点和条件边后,调用模型、执行工具、再次调模型形成完整的回路,整个逻辑一眼就能读懂。

LangGraph 和 LangChain 怎么分工

LangGraph 负责"流程怎么跑",它本身不绑定任何模型供应商,也不提供工具的具体实现,只管图的执行调度、状态的流转与持久化。LangChain 负责"工具和模型是什么",它提供的 ChatOpenAItoolHumanMessage、提示模板、检索器这些组件,是节点函数里真正要调用的东西。

两者的关系是分层叠加,而不是二选一,如下图所示。

20260317073508

LangGraph 在上层负责调度与状态,LangChain 在下层提供模型与工具,两者分工明确、协同运作。

如果不确定自己的场景该用哪个,可以对照下面这张表。

场景 推荐
单次问答、简单链式调用 LangChain
一个模型加几个工具的轻量 Agent LangChain
多步骤、有明确分支的工作流 LangGraph
需要持久化对话或状态可回溯 LangGraph
多 Agent 协作、任务拆解 LangGraph
人机协同、需要中途暂停等待审核 LangGraph

LangGraph 的官方文档自己也在说,如果你的 Agent 只是一个简单的"模型加工具循环",用 LangChaincreateReactAgent 快速搞定就好,没必要一开始就引入图的概念。但凡流程复杂到需要明确画出来才能讲清楚,就是 LangGraph 发力的时候了。

最小可运行的骨架

先把三个依赖装好。

pnpm add @langchain/langgraph @langchain/core @langchain/openai

然后搭出下面三个文件的骨架,后面章节的示例都会在这个基础上扩展。

src/model.ts 负责模型初始化,集中管理密钥与接口地址,方便在多个图文件里复用。

// src/model.ts
import { ChatOpenAI } from "@langchain/openai";

export const model = new ChatOpenAI({
  model: "deepseek-chat",
  apiKey: "sk-60816d9be57f4189b658f1eaee52382e",
  configuration: { baseURL: "https://api.deepseek.com" },
});

src/graph.ts 定义图的结构,目前只有一个调用模型的节点。

// src/graph.ts
import { StateGraph } from "@langchain/langgraph";
import { MessagesAnnotation } from "@langchain/core/messages";
import { model } from "./model";

async function callModel(state: typeof MessagesAnnotation.State) {
  const response = await model.invoke(state.messages);
  return { messages: [response] };
}

const graph = new StateGraph(MessagesAnnotation)
  .addNode("callModel", callModel)
  .addEdge("__start__", "callModel")
  .addEdge("callModel", "__end__");

export const app = graph.compile();

src/index.ts 是入口,执行一次图并打印模型回复。

// src/index.ts
import { HumanMessage } from "@langchain/core/messages";
import { app } from "./graph";

const result = await app.invoke({
  messages: [new HumanMessage("你好,介绍一下 LangGraph")],
});

console.log(result.messages.at(-1)?.content);

现在这个骨架已经是真正可以运行的 LangGraph 应用了:输入一条用户消息,callModel 节点调用模型后把响应追加到状态里,图执行完后取出最后一条消息打印。下一章的 Quickstart 会在这个基础上加入工具绑定、条件边和 checkpointer 持久化,让图逐渐"活"起来。

小结

LangGraph 出现是因为 LangChain 的链式和循环模型在多分支、持久化、人机协同这类复杂场景下力不从心,它用状态、节点、边三个概念把工作流建模成图,状态贯穿全图流动,节点负责处理状态,边决定下一跳的走向。LangChainLangGraph 不是竞争关系,前者提供模型与工具,后者负责编排与调度,两者叠加才是完整的应用架构。后面所有章节的示例都会在 model.tsgraph.tsindex.ts 这三个文件的骨架上扩展。

你的 Vue 3 reactive(),VuReact 会编译成什么样的 React?

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的工具。今天就带大家直击核心:Vue 中高频使用的 reactive()shallowReactive(),经过 VuReact 编译后会变成什么样的 React 代码?

前置约定

  1. 示例只保留核心逻辑,省略完整组件包裹
  2. 你已熟悉 Vue 3 reactive / shallowReactive 用法

一、Vue reactive() → React useReactive()

reactive 是 Vue 3 最核心的对象响应式 API,在 VuReact 中会被精准映射。

基础编译对照

Vue 输入

<script setup>
  import { reactive } from 'vue';

  const state = reactive({
    count: 0,
    title: 'VuReact',
  });
</script>

VuReact 输出(React)

import { useReactive } from '@vureact/runtime-core';

const state = useReactive({
  count: 0,
  title: 'VuReact',
});

reactive 直接编译为 useReactive Hook:

  • 完全保留 Vue 响应式语义
  • 直接修改属性自动触发视图更新
  • 深层对象、数组、Map/Set 全部支持
  • 和 React 生命周期完美协同

TypeScript 场景:类型完整保留

Vue 输入(TS)

<script lang="ts" setup>
  import { reactive } from 'vue';

  interface User {
    id: number;
    name: string;
  }

  const state = reactive<{
    loading: boolean;
    users: User[];
    config: Record<string, any>;
  }>({
    loading: false,
    users: [],
    config: { theme: 'dark' },
  });
</script>

VuReact 输出(TS)

import { useReactive } from '@vureact/runtime-core';

interface User {
  id: number;
  name: string;
}

const state = useReactive<{
  loading: boolean;
  users: User[];
  config: Record<string, any>;
}>({
  loading: false,
  users: [],
  config: { theme: 'dark' },
});

接口、泛型、类型约束完全迁移
React 侧智能提示、类型检查全部正常
不用改一行类型逻辑


二、Vue shallowReactive() → React useShallowReactive()

浅层响应式用于性能优化,只监听顶层属性变化,VuReact 同样完美对齐。

基础编译对照

Vue 输入

<script setup>
  import { shallowReactive } from 'vue';

  const state = shallowReactive({
    nested: { count: 0 },
  });
</script>

VuReact 输出(React)

import { useShallowReactive } from '@vureact/runtime-core';

const state = useShallowReactive({
  nested: { count: 0 },
});

useShallowReactive 行为完全对齐 Vue:

  • 修改顶层属性 → 触发更新
  • 修改深层嵌套属性 → 不触发更新
  • 替换整个对象 → 触发更新
  • 适合大型列表、复杂状态、第三方数据等性能场景

总结一句话

  • Vue reactive → React useReactive
  • Vue shallowReactive → React useShallowReactive
  • 响应式行为一致
  • TypeScript 类型一致
  • 开发心智完全一致

用 VuReact,你可以:

  • 继续用 Vue 3 舒服的写法
  • 直接产出可维护的 React 代码
  • 无痛渐进迁移,不用一次性重构

🔗 相关资源

❌