阅读视图

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

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

前言

大家好,我是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 代码
  • 无痛渐进迁移,不用一次性重构

🔗 相关资源

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

VuReact 是一个能将 Vue 3 代码编译为标准、可维护 React 代码的编译工具,并非运行时混合框架。今天我们直接看核心:Vue 高频使用的 ref() / shallowRef(),经过 VuReact 编译后会对应 React 的哪些代码?

前置约定

  1. 文中 Vue/React 代码均为核心逻辑简写,省略完整组件与冗余结构
  2. 你已熟悉 Vue 3 refshallowRef 的用法与行为

一、Vue ref() → React useVRef()

ref 是 Vue 3 最基础的响应式 API,在 VuReact 中会被直接编译为 React Hook。

基础编译对照

Vue 输入

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

VuReact 输出(React)

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

ref 会被编译成 useVRef,它是 Vue ref 在 React 里的语义完全对齐的适配 API,保留 .value 访问与响应式更新行为。

带 TypeScript 类型场景

Vue 输入(TS)

<script lang="ts" setup>
  const title = ref<string>('');
  const isLoading = ref<boolean>(false);
  const userList = ref<Array<{ id: number; name: string }>>([]);
  const config = ref<Record<string, any>>({ theme: 'dark' });
</script>

VuReact 输出(TS)

const title = useVRef<string>('');
const isLoading = useVRef<boolean>(false);
const userList = useVRef<Array<{ id: number; name: string }>>([]);
const config = useVRef<Record<string, any>>({ theme: 'dark' });

TS 泛型、类型注解完整保留,React 侧类型提示完全可用。


二、Vue shallowRef() → React useShallowVRef()

shallowRef 是浅层响应式 API,只监听顶层引用变化,适合大对象性能优化。

基础编译对照

Vue 输入

<script setup>
  import { shallowRef } from 'vue';
  const count = shallowRef({ a: { b: 1, c: { d: 2 } } });
</script>

VuReact 输出(React)

import { useShallowVRef } from '@vureact/runtime-core';
const count = useShallowVRef({ a: { b: 1, c: { d: 2 } } });

useShallowVRef 完全对齐 shallowRef 行为:

  • 修改嵌套属性 → 不触发更新
  • 直接替换 .value触发更新

🔗 相关资源

vue3中静态提升和patchflag实现

1. 更快的 Virtual DOM (VDOM) - 具体体现

Vue 3 在虚拟 DOM 方面的改进是多方面的,旨在提高渲染效率和减少不必要的计算。

A. 编译时优化 (Compile-time Optimizations)

这是 Vue 3 与 Vue 2 最大的区别之一。Vue 2 的 VDOM diff 过程是在运行时进行的,它需要逐个比较节点和属性。而 Vue 3 的编译器在构建阶段就能分析模板,生成包含“优化提示”的渲染函数。

  • 静态提升 (Hoisting/Diff Skipping): 编译器会识别出模板中的静态节点(即内容不会改变的节点),并将它们提取到渲染函数之外。在后续更新时,Vue 完全跳过对这些节点的比较,因为它们永远不会变。
<!-- 模板 -->
<div>
  <h1>This is static</h1> <!-- 静态节点 -->
  <p>{{ dynamicValue }}</p> <!-- 动态节点 -->
  <span>Another static content</span> <!-- 静态节点 -->
</div>

在 Vue 2 中,每次更新 dynamicValue 时,都会对整个 <div> 的所有子节点进行 diff。在 Vue 3 中,<h1><span> 会被提升,只对 <p> 进行比较,大大减少了工作量。

  • Block Tree (块树): Vue 3 会将动态节点组织成一棵“块树”。更新时,只需要遍历这棵更小的动态节点树,而不是整个 VDOM 树。
  • Patch Flags (补丁标志): 编译器会给动态节点打上标记(flag),标明该节点哪些部分可能会变化(如文本、class、props、事件监听器等)。在 diff 阶段,Vue 可以根据这些标志跳过不必要的比较,直接执行特定的更新操作。
<!-- 模板 -->
<p :class="className">{{ message }}</p>

编译器会知道这个 <p> 元素可能变化的部分是 class 和文本内容,并打上相应的 flag。更新时就不会去检查它的 id 或其他不变的属性。

B. 更高效的 Diff 算法

虽然核心思想仍是双端 Diff,但 Vue 3 的实现更加优化,尤其是在处理列表更新时。

  • 快速路径 (Fast Paths) for List Updates: 对于一些常见的列表更新模式(如在末尾添加元素、替换整个列表等),Vue 3 提供了专门的快速路径算法,避免了复杂的最长递增子序列计算。
  • 更精确的移动策略: 在处理列表项顺序改变时,Vue 3 的算法能更精确地判断哪些元素需要移动,哪些可以就地复用,从而减少 DOM 操作次数。

总结 VDOM 性能提升体现:

  • 更快的初始渲染: 静态节点提升和块树优化减少了首次渲染的计算量。
  • 更快的状态更新: Patch flags 和优化的 Diff 算法减少了状态变更时的比较和更新开销。
  • 更少的内存占用: Block tree 结构和静态提升减少了运行时需要跟踪的节点数量。

2.静态提升和pathflag例子

<template>
  <div id="app">
    <h1 class="title">Welcome to My App</h1>
    <p>{{ greeting }}</p>
    <ul>
      <li>Static Item 1</li>
      <li>Static Item 2</li>
      <li>{{ dynamicItem }}</li> <!-- 这一项是动态的 -->
    </ul>
    <button @click="changeGreeting">Change Greeting</button>
  </div>
</template>

<script setup>
import { ref } from 'vue';

const greeting = ref('Hello Vue 3!');
const dynamicItem = ref('Dynamic Item 3');

const changeGreeting = () => {
  greeting.value = 'Greetings from Vue 3!';
};
</script>

编译器分析和优化过程:

  1. 识别静态节点:
    • <h1>Welcome to My App</h1>:标签名、内容、class 属性都不变,是静态节点
    • <li>Static Item 1</li><li>Static Item 2</li>:标签名和内容都不变,是静态节点
    • <button>:标签名、内容和事件处理器(@click)都不变,是静态节点
  1. 识别动态节点:
    • <p>{{ greeting }}</p>:内容 {{ greeting }} 是动态的。
    • <li>{{ dynamicItem }}</li>:内容 {{ dynamicItem }} 是动态的。
    • <div id="app">:虽然 id 是静态的,但它包含了动态子节点,因此自身是动态的。
  1. 执行静态提升:
    • 编译器会将上面识别出的静态节点的 VNode 对象创建代码提取出来,放在渲染函数外面,通常赋值给一个变量(比如 _hoisted_1, _hoisted_2 等)。这样它们只会被创建一次。
  1. 添加 Patch Flags:
    • <p> 节点:它的内容是动态的,编译器会为其 VNode 添加 patchFlag: Text (数值通常是 1)。这告诉运行时,只需要比较和更新它的文本内容。
    • <li>{{ dynamicItem }}</li> 节点:它的内容是动态的,同样会添加 patchFlag: Text (数值通常是 1)。
    • <ul> 节点:它的子节点列表是动态的(因为包含动态的 <li>),编译器会为其添加 patchFlag: Children (数值通常是 8 或更复杂的组合)。这告诉运行时,需要对其子节点进行 diff。

编译后生成的渲染函数(简化示意):

import { createElementVNode as _createElementVNode, createTextVNode as _createTextVNode, Fragment as _Fragment, openBlock as _openBlock, createBlock as _createBlock, toDisplayString as _toDisplayString } from 'vue'

// --- 静态提升的 VNodes ---
// 这些 VNodes 只在模块加载时创建一次,后续渲染直接复用
const _hoisted_1 = /*#__PURE__*/_createElementVNode("h1", { class: "title" }, "Welcome to My App", -1 /* HOISTED */)
const _hoisted_2 = /*#__PURE__*/_createTextVNode("Static Item 1")
const _hoisted_3 = /*#__PURE__*/_createTextVNode("Static Item 2")
const _hoisted_4 = /*#__PURE__*/_createElementVNode("button", { onClick: "changeGreeting" }, "Change Greeting", -1 /* HOISTED */)
// -----------------------------------

function render(_ctx, _cache, $props, $setup, $data, $options) {
  // `_ctx` 通常包含 `greeting` 和 `dynamicItem` 等响应式数据
  return (_openBlock(), _createBlock("div", { id: "app" },
    [
      _hoisted_1, // 直接复用,无需 diff
      _createElementVNode("p", null, _toDisplayString($setup.greeting), 1 /* TEXT */), // patchFlag: 1
      _createElementVNode("ul", null, [
        _hoisted_2, // 直接复用,无需 diff
        _hoisted_3, // 直接复用,无需 diff
        _createElementVNode("li", null, _toDisplayString($setup.dynamicItem), 1 /* TEXT */) // patchFlag: 1
      ], 16 /* FULL_PROPS */), // patchFlag: 16 (这里可能表示子节点是动态的,需要 diff)
      _hoisted_4  // 直接复用,无需 diff
    ]
  ))
}

关键点解读:

  • _hoisted_1, _hoisted_2, _hoisted_3, _hoisted_4:这些都是在编译时创建好的静态 VNode 对象。/* HOISTED */ 注释表明它们被提升了。在运行时,渲染函数直接使用这些对象,而不必每次都重新创建。
  • _createElementVNode("p", ...)_createElementVNode("li", ...):这些是动态节点,每次渲染时都需要重新创建 VNode。
  • 1 /* TEXT */:这就是 patchFlag。它告诉运行时,这个 VNode 只需要关心文本内容的变化。当 $setup.greeting$setup.dynamicItem 改变时,运行时只需比较新旧文本字符串,然后更新真实 DOM 的 textContent,而不需要比较 classid 等其他属性。
  • 16 /* FULL_PROPS */ (或类似的数值):<ul>patchFlag 表明其子节点是动态的,需要进行子节点的 diff。

通过这种方式,Vue 3 在编译时就对模板进行了深度优化,使得运行时的渲染和更新过程更加高效

卷AI、卷算法、2026 年的前端工程师到底在卷什么?

1_jXusXvCfxECPU_Jh9S_E3w.jpg

最近是 2026 年的春招季,前几周密集面了大概快二十个前端。

翻开这批简历,我有一种极其魔幻的感觉:满屏都是 AI,满屏都是算法。

四五年前,大家简历上的高频词还是 精通 Vue3 响应式原理、熟练掌握 Webpack 性能调优

现在呢?十个候选人里,有九个写着熟练掌握 LLM 接入、深入理解 RAG(检索增强生成)、精通 Prompt 工程、参与过大模型 Agent 平台建设,剩下那个没写 AI 的,简历里赫然写着LeetCode 刷题 150+,精通动态规划与图论。

前端这个圈子,仿佛在一夜之间得了严重的技术焦虑并发症。大家都在拼命往简历里塞最高大上的词,生怕在 2026 年这个节点,因为不懂 AI 而被直接淘汰。

但现实是什么?

上周我淘汰了一个简历写得极其华丽、号称 主导过公司核心 AI 助手前端架构 的候选人。

我没问他大模型底层原理,也没让他手撕红黑树,我只问了他一个极其真实的业务场景: 在一个 AI 流式输出(Streaming)的对话场景里,如果大模型返回的是一个极其复杂的、带有代码块和多步工具调用(Tool Call)的 JSON 块。在流式传输还没结束、JSON 还是残缺状态的时候,你的前端是怎么保证 UI 不崩溃,并且能平滑渲染中间状态的?

他愣了半分钟,支支吾吾地说:我们用的是 Vercel AI SDK,它内部封装好了,直接拿 useChat 里的 messages 渲染就行……

😖😖😖...

我叹了口气,在面试评价上默默写下:只会调用 API,缺乏处理复杂工程能力。

这就是 2026 年前端圈最大的悲哀:大家都在卷 AI,但 90% 的人卷的只是如何发送一个带 API Key 的 HTTP 请求。


别把调用 API 包装成核心竞争力 🤷‍♂️

现在很多前端对懂 AI的理解极其肤浅。

以为在项目里接个 OpenAI 或者 Claude 的接口,搞个对话框,把输入框的字传过去,把返回的字用 Markdown 渲染出来,就叫AI 前端工程师了😖。

兄弟,那不叫 AI 开发,那叫表单提交。这种活儿,三年前刚培训班毕业的实习生也会干。

大模型时代,前端真正的难点根本不是发送请求,而是 应对大模型带来的复杂性。

以前我们写业务代码,接口返回的数据结构是确定的,是后端的 Swagger 定义好的。你只需要 if (res.code === 200) 然后按部就班地渲染。

但在 2026 年,大模型吐出来的东西是不可控的。 真实的高阶 AI 前端工程,每天要面对的是这些破事:

流式返回进行到一半,JSON 连个闭合的括号都没有,你的界面怎么解析?怎么渲染正在打字的生成式 UI?

一个 Agent 在后台疯狂调用工具(查天气、查数据库、画图),这个过程中产生的大量异步中间状态,如何在 React/Vue 中做防抖、状态合并和打断(Abort)?

大模型突然抽风,返回了完全不符合预期的组件协议,你的前端系统能不能做沙盒隔离,保证不引发整个页面的白屏崩溃?

这些问题,根本不是你背几个 Prompt 模板就能解决的。它考验的是你对数据流处理、AST(抽象语法树)解析、复杂状态机设计以及防御性编程的底层功底。

你卷了半天 Vercel AI SDK 的用法,一旦业务场景超出了 SDK 的默认配置,你立马就抓瞎了。


为什么面试官越来越爱考算法?

说完了 AI,再聊聊算法。这也是现在前端同行疯狂吐槽的点:我特么一个画页面的,凭什么让我手写动态规划?🤔

其实这是个很残酷的信号。

作为面试官,我跟你交个底:因为那些常规的、套路化的前端业务代码,现在 AI 真的能写了,而且写得比你快。

2026 年了,如果你只会写个增删改查的表格,只会封装个按钮组件,我在面试里连问你的兴趣都没有。既然基础的搬砖工作被 AI 大幅压缩了,那公司招人,过滤标准自然就要往上提。

考算法,本质上考的不是你对某道题的背诵能力,而是考你的复杂逻辑拆解能力和极限思维

特别是在做 AI 工具链的前端时:

  • 当你要在浏览器端用 WebAssembly 跑一个轻量级的向量数据库(Vector DB)进行本地 RAG 检索时,不懂数据结构你连原理都看不懂。
  • 当你要处理大模型返回的超大文档树,做精确的 DOM 节点比对和替换时,树的遍历算法就是你的基本功。

大家不是在卷算法,而是在抢夺那些AI 无法轻易替代的深水区岗位🤔。


没必要那么焦虑

前天面试结束,跟几个同组的技术老炮抽烟。大家感慨,其实这十年来,前端圈的焦虑从来没停过。

当年 jQueryReact 淘汰时,大家在卷;后来小程序大爆发时,大家也在卷;现在大模型来了,大家不过是换了个名词继续卷。

别被那种 AI 要干掉前端的鬼话吓倒了,也别为了迎合面试官去死记硬背什么 RAG 架构图。

潮水退去的时候,企业最终留下的,永远不是那个会背时髦名词的人,而是那个懂 HTTP 协议、懂浏览器底层、能在复杂的异步环境里把一个烂摊子稳稳托住的前端。

在这个越发喧嚣的 2026 年,少去追逐那些虚幻的词汇,多去打磨你手里的基本功吧🤷‍♂️

共勉🙌

加油加油加油.gif

React 文件处理:上传、拖放区与对象 URL

任何稍有规模的应用最终都要处理文件。个人资料编辑页要传头像。笔记应用要附加图片。CSV 导入器要拖放区。相册要在客户端生成缩略图。而每一个这样的功能都要从零开始重做一遍——因为 React 里的文件处理同时涉及三套浏览器 API(<input type="file">、Drag and Drop API、URL.createObjectURL),再加上 React 本身的 ref 和 effect 机制——大多数开发者每次都从头把它们拼一遍。

本文将带你过一遍每个 React 应用迟早都会遇到的四个文件处理基本能力:一个不需要在 DOM 里渲染隐藏 <input> 的文件选择器、一个能接收拖入文件的拖放区、一个不会泄漏内存的对象 URL 助手,以及一个按需加载第三方库的脚本标签加载器。每一个我们都会先写出手动实现,让你看清底层在做什么,然后再换成 ReactUse 里专门的 Hook。最后我们会把四个 Hook 组合成一个完整的照片上传组件,集挑选、拖放、预览和按需加载图片库于一身。

1. 不用隐藏 input 也能选文件

手动实现

React 中传统的文件选择写法看起来人畜无害,但暗藏不少坑:

import { useRef, useState } from "react";

function ManualFilePicker() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [files, setFiles] = useState<FileList | null>(null);

  return (
    <div>
      <input
        ref={inputRef}
        type="file"
        multiple
        accept="image/*"
        style={{ display: "none" }}
        onChange={(e) => setFiles(e.target.files)}
      />
      <button onClick={() => inputRef.current?.click()}>
        选择图片
      </button>
      {files && <p>已选 {files.length} 个文件</p>}
    </div>
  );
}

它能跑,但只要你想用第二次,缝合的痕迹就藏不住了。隐藏的 <input> 仍然在你的渲染树里,你的样式重置必须考虑它的存在。重置选中状态需要写 inputRef.current.value = ""——这种命令式的副作用,React 的 lint 规则会跳出来警告你。要是你想在异步处理逻辑里 await 用户的选择(比如想在一个处理文件的 async handler 里),你还得自己造一个一次性的 promise。

而且你没法在同一个页面上重复使用同一个组件两次而不让 ref 互相打架。如果用户连续选择同一个文件,第二次 change 事件根本不会触发——这是历代 React 开发者都踩过的著名陷阱。

ReactUse 的方式:useFileDialog

useFileDialog 把整个 input 元素从渲染树里抬出去,交给你一个 [files, open, reset] 的元组:

import { useFileDialog } from "@reactuses/core";

function ImagePicker() {
  const [files, open, reset] = useFileDialog({
    multiple: true,
    accept: "image/*",
  });

  return (
    <div>
      <button onClick={() => open()}>选择图片</button>
      <button onClick={reset} disabled={!files}>
        重置
      </button>
      {files && (
        <ul>
          {Array.from(files).map((file) => (
            <li key={file.name}>
              {file.name} —— {(file.size / 1024).toFixed(1)} KB
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

手动版本忽略的三件小事,但都很重要:

  1. 没有隐藏 DOM。input 在内存里创建,不在你的渲染树里。组件输出就是按钮本身。
  2. 每次调用都能传参。在 open() 上直接传选项,可以覆盖 Hook 级别的默认值。想让同一个选择器既能选文档又能选图片?调用时再传 accept 就行。
  3. 真正的重置reset() 同时清空 React state 和底层 input,所以同一个文件可以再选一次。

open() 函数还会返回一个 promise,resolve 时给你已选的文件。这让异步流程清爽得多:

const handleUpload = async () => {
  const picked = await open();
  if (!picked) return;
  await uploadAll(Array.from(picked));
};

你不再需要把逻辑切分到 onChange 和按钮的点击处理函数之间。选择器就是一个可以 await 的函数。

2. 拖放文件区

手动实现

拖放是那种"教程里看着简单,生产环境里裂得稀碎"的 API。最直白的版本:

function ManualDropZone({ onFiles }: { onFiles: (f: File[]) => void }) {
  const [over, setOver] = useState(false);

  return (
    <div
      onDragOver={(e) => {
        e.preventDefault();
        setOver(true);
      }}
      onDragLeave={() => setOver(false)}
      onDrop={(e) => {
        e.preventDefault();
        setOver(false);
        onFiles(Array.from(e.dataTransfer.files));
      }}
      style={{
        border: over ? "2px solid blue" : "2px dashed gray",
        padding: 40,
      }}
    >
      把文件拖到这里
    </div>
  );
}

这个版本看似没问题,直到用户拖到子元素上时一切都崩了。光标一踏进子元素,浏览器就在父元素上触发 dragleave,尽管从逻辑上看文件还在区域内。你的边框开始闪烁,over state 变成谎言。要正确修复它,你得用计数器跟踪 dragenterdragleave,每次离开就减一,只有当计数器归零时才认定文件"离开"了。还得记得在 dragover 上调 preventDefault——否则 drop 根本不会触发——并且记住 dataTransfer.filesFileList 而不是数组。

大多数生产环境里的拖放区都做错了。闪烁就是破绽。

ReactUse 的方式:useDropZone

useDropZone 替你跳完了这套计数器舞蹈:

import { useRef } from "react";
import { useDropZone } from "@reactuses/core";

function CsvDropZone() {
  const dropRef = useRef<HTMLDivElement>(null);
  const isOver = useDropZone(dropRef, (files) => {
    if (!files) return;
    const csvs = files.filter((f) => f.name.endsWith(".csv"));
    console.log("拖入的 CSV:", csvs);
  });

  return (
    <div
      ref={dropRef}
      style={{
        border: isOver ? "2px solid #3b82f6" : "2px dashed #cbd5e1",
        background: isOver ? "#eff6ff" : "transparent",
        padding: 60,
        borderRadius: 12,
        textAlign: "center",
        transition: "all 120ms ease",
      }}
    >
      <p style={{ margin: 0 }}>
        {isOver ? "松开以上传" : "把 CSV 文件拖到这里"}
      </p>
    </div>
  );
}

注意 API 本质上就是 (target, onDrop) => isOver。就这么简单。Hook 内部处理 dragenter/dragover/dragleave/drop,维护进入/离开计数器,让子元素不会破坏高亮,阻止浏览器默认的"在新标签页打开"行为,最后把一个 boolean 还给你来驱动样式。

回调收到的是 File[] | null——null 代表一次空拖放(没错,某些浏览器在用户拖入非文件内容时确实会触发)。你的处理函数可以一次判断后就干净地退出。

3. 用对象 URL 预览文件

手动实现

拿到 File 之后,你通常想把它展示给用户看。浏览器给了你 URL.createObjectURL(blob),可以把任何 blob 变成一个临时 URL,扔进 <img><video> 就能用。代价是:你创建的每一个 URL 都会占内存,必须记得用完调 URL.revokeObjectURL——否则就泄漏了。在 React 里,"用完"通常意味着"组件卸载或文件变化时",这正是 effect 存在的意义,也正是开发者最容易忘记的事情:

function ManualImagePreview({ file }: { file: File | null }) {
  const [url, setUrl] = useState<string>();

  useEffect(() => {
    if (!file) {
      setUrl(undefined);
      return;
    }
    const next = URL.createObjectURL(file);
    setUrl(next);
    return () => URL.revokeObjectURL(next);
  }, [file]);

  if (!url) return null;
  return <img src={url} alt={file?.name} />;
}

这是对的,但是那种"再不小心改一笔就漏的对"。清理函数和 createObjectURL 调用要永远成对存在。多加一个条件 return 或者忘了一个依赖,就会出现一个只有在长会话里才暴露的 bug。

ReactUse 的方式:useObjectUrl

useObjectUrl 是那段 effect 的单行版:

import { useObjectUrl } from "@reactuses/core";

function ImagePreview({ file }: { file: File }) {
  const url = useObjectUrl(file);
  if (!url) return null;
  return (
    <img
      src={url}
      alt={file.name}
      style={{ maxWidth: 200, borderRadius: 8 }}
    />
  );
}

Hook 接管了生命周期。当 file prop 变化时,它会回收旧 URL 并创建新 URL。组件卸载时,它会回收最后一个。你不可能忘记清理,因为你压根就没写过它。

4. 按需加载第三方脚本

手动实现

有时候你想处理的文件,对应的库太大或太冷门,不值得放进主包。图片裁剪库、PDF 解析器、OCR 引擎、视频转码器——它们都是几十 MB 的体积,对那些从不上传文件的用户来说一文不值。你只想在第一个文件到来之后才付出这个代价。

在 React 里手动加载脚本标签本身就是一道菜谱:

function loadScript(src: string): Promise<void> {
  return new Promise((resolve, reject) => {
    if (document.querySelector(`script[src="${src}"]`)) {
      resolve();
      return;
    }
    const el = document.createElement("script");
    el.src = src;
    el.async = true;
    el.onload = () => resolve();
    el.onerror = () => reject(new Error(`加载失败 ${src}`));
    document.head.appendChild(el);
  });
}

function ManualImageProcessor() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    loadScript("https://cdn.example.com/heavy-image-lib.js")
      .then(() => setReady(true))
      .catch(console.error);
    // 没有清理 —— 一旦加载就保留
  }, []);

  return ready ? <Editor /> : <p>正在加载编辑器...</p>;
}

这覆盖了正常路径,但忽略了乱七八糟的情况:如果两个组件同时请求同一个脚本(竞态条件)怎么办?如果脚本加载失败你想重试怎么办?如果你想在组件消失时主动卸载它怎么办?

ReactUse 的方式:useScriptTag

useScriptTag 给你的就是你本来要写的那些原语,但边界情况都已经处理好:

import { useScriptTag } from "@reactuses/core";

function HeavyImageEditor() {
  const [, status, , unload] = useScriptTag(
    "https://cdn.example.com/image-editor.js",
    () => console.log("编辑器库已就绪"),
    { manual: false, async: true },
  );

  if (status === "loading") return <p>正在下载编辑器...</p>;
  if (status === "error") return <p>编辑器加载失败</p>;
  if (status !== "ready") return null;

  return <ImageEditorComponent onClose={unload} />;
}

四样白送的好处:

  1. 单例行为。同一个脚本 URL 被请求两次,Hook 会去重——没有竞态,没有重复加载。
  2. 状态机idle/loading/ready/error 让你在每一步都能渲染恰当的内容。
  3. 手动控制。设置 manual: true,脚本要等你显式调用返回的 load() 才会加载——非常适合"首次交互时再加载"的模式。
  4. 卸载。调用 unload() 可以把 script 标签从 document 里移除。如果你想在用户关闭编辑器后把那个庞大的库从内存里清掉,这就派上用场了。

全部组合:照片上传组件

现在我们把四个 Hook 组合成一个组件:一个允许用户挑选或拖入图片、即时预览、并在第一次需要时延迟加载一个假想的客户端图片缩放库的照片上传组件。

import { useRef, useState } from "react";
import {
  useFileDialog,
  useDropZone,
  useObjectUrl,
  useScriptTag,
} from "@reactuses/core";

interface QueuedImage {
  file: File;
  id: string;
}

function Thumbnail({ image }: { image: QueuedImage }) {
  const url = useObjectUrl(image.file);
  return (
    <figure
      style={{
        margin: 0,
        padding: 8,
        background: "#f8fafc",
        borderRadius: 8,
        textAlign: "center",
      }}
    >
      {url && (
        <img
          src={url}
          alt={image.file.name}
          style={{
            width: 120,
            height: 120,
            objectFit: "cover",
            borderRadius: 4,
          }}
        />
      )}
      <figcaption
        style={{
          marginTop: 6,
          fontSize: 12,
          maxWidth: 120,
          overflow: "hidden",
          textOverflow: "ellipsis",
          whiteSpace: "nowrap",
        }}
      >
        {image.file.name}
      </figcaption>
    </figure>
  );
}

function PhotoUploadWidget() {
  const [queue, setQueue] = useState<QueuedImage[]>([]);
  const [shouldLoadResizer, setShouldLoadResizer] = useState(false);
  const dropRef = useRef<HTMLDivElement>(null);

  const [, openPicker, resetPicker] = useFileDialog({
    multiple: true,
    accept: "image/*",
  });

  const isOver = useDropZone(dropRef, (files) => {
    if (!files) return;
    addFiles(files);
  });

  const [, resizerStatus] = useScriptTag(
    "https://cdn.example.com/image-resize.js",
    () => console.log("缩放器已就绪"),
    { manual: !shouldLoadResizer },
  );

  const addFiles = (files: File[]) => {
    const newImages = files
      .filter((f) => f.type.startsWith("image/"))
      .map((file) => ({
        file,
        id: `${file.name}-${file.lastModified}-${Math.random()}`,
      }));
    setQueue((prev) => [...prev, ...newImages]);
    if (newImages.length > 0) setShouldLoadResizer(true);
  };

  const handlePick = async () => {
    const picked = await openPicker();
    if (picked) addFiles(Array.from(picked));
  };

  const clearAll = () => {
    setQueue([]);
    resetPicker();
  };

  return (
    <div style={{ maxWidth: 720, fontFamily: "system-ui, sans-serif" }}>
      <div
        ref={dropRef}
        style={{
          border: isOver ? "2px solid #3b82f6" : "2px dashed #cbd5e1",
          background: isOver ? "#eff6ff" : "#ffffff",
          padding: 48,
          borderRadius: 16,
          textAlign: "center",
          transition: "all 120ms ease",
        }}
      >
        <p style={{ marginTop: 0, fontSize: 18 }}>
          {isOver ? "松开即可上传" : "把照片拖到这里"}
        </p>
        <button
          onClick={handlePick}
          style={{
            padding: "8px 16px",
            borderRadius: 8,
            border: "1px solid #3b82f6",
            background: "#3b82f6",
            color: "white",
            cursor: "pointer",
          }}
        >
          或从设备中选择
        </button>
      </div>

      <div
        style={{
          marginTop: 16,
          display: "flex",
          justifyContent: "space-between",
          alignItems: "center",
        }}
      >
        <span style={{ fontSize: 14, color: "#64748b" }}>
          已排队 {queue.length} 张图片
          {shouldLoadResizer && ` —— 缩放器:${resizerStatus}`}
        </span>
        {queue.length > 0 && (
          <button
            onClick={clearAll}
            style={{
              padding: "6px 12px",
              borderRadius: 6,
              border: "1px solid #cbd5e1",
              background: "white",
              cursor: "pointer",
            }}
          >
            全部清空
          </button>
        )}
      </div>

      {queue.length > 0 && (
        <div
          style={{
            marginTop: 16,
            display: "grid",
            gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))",
            gap: 12,
          }}
        >
          {queue.map((image) => (
            <Thumbnail key={image.id} image={image} />
          ))}
        </div>
      )}
    </div>
  );
}

四个 Hook,四个职责,互不重叠:

  • useFileDialog 负责"点击挑选"流程,并提供可 await 的 promise
  • useDropZone 处理拖放,并解决子元素引发的边框闪烁
  • useObjectUrl 为每个缩略图生成并回收预览 URL,绑定到组件生命周期
  • useScriptTag 只在第一张图片到来后延迟加载缩放库,并且整个会话只加载一次

组合很自然,因为每个 Hook 只做一件事。Hook 之间不共享 ref,effect 不会级联。你最终发布的组件大概 100 行,大部分是标签和样式,那些棘手的浏览器底层活计被藏在已经经过测试和 SSR 加固的 Hook 里。

安装

npm i @reactuses/core

相关 Hook

  • useFileDialog —— 打开文件选择器,无需在 DOM 中渲染隐藏的 input
  • useDropZone —— 跟踪文件拖入元素的状态,正确处理子元素事件
  • useObjectUrl —— 为 File 和 Blob 创建并自动回收 URL
  • useScriptTag —— 动态加载外部脚本,带状态跟踪和卸载支持
  • useEventListener —— 声明式地附加事件监听器,可用于自定义上传进度事件
  • useSupported —— 响应式地检查浏览器是否支持某个 API

ReactUse 提供了 100+ 个 React Hook。全部探索 →

JS手撕:手写Koa中间件与Promise核心特性

在前端开发中,Koa框架的洋葱模型、Promise的各类静态方法以及异步流程控制,是每个开发者必须掌握的核心知识点。它们看似独立,实则底层逻辑高度关联——都是为了解决异步代码的可读性、可维护性问题。本文将从实战出发,手把手拆解核心代码,用“通俗解释+专业剖析”的方式,让你不仅能看懂手写代码,更能理解背后的设计思想。

一、手写Koa中间件调用(洋葱模型):理解“层层嵌套,反向回流”

用过Koa的同学都知道,它的中间件执行机制被称为“洋葱模型”——就像剥洋葱一样,中间件会从外到内依次执行,执行到最内层后,再从内到外反向执行。这种机制的核心价值的是:让中间件既能处理请求进入时的逻辑(如日志记录、权限校验),也能处理响应返回时的逻辑(如统一异常处理、响应格式化)。

1.1 核心代码实现(可直接运行)

function koa() {
  // 存放所有通过 app.use() 注册的中间件函数
  const middlewares = []
  const app = async (ctx) => {
    // 从第0个中间件开始执行调度
    await dispatch(0, ctx)
  }

  // 注册中间件的方法:将中间件存入数组
  app.use = (middleware) => {
    middlewares.push(middleware)
  }

  // 核心调度函数:递归执行中间件
  const dispatch = async (index, ctx) => {
    // 终止条件:所有中间件都执行完毕,直接返回
    if (index === middlewares.length) return

    // 获取当前索引对应的中间件
    const middleware = middlewares[index]
    // 执行中间件:第二个参数是next函数,调用next()即执行下一个中间件
    await middleware(ctx, () => dispatch(index + 1, ctx))
  }
  return app
}

// 1. 创建 app 实例
const app = koa()

// 2. 注册 3 个中间件(模拟真实开发中的分层逻辑)
app.use(async (ctx, next) => {
  console.log('【中间件 1 开始】—— 日志记录:请求进入')
  console.log('请求URL:', ctx.req.url)
  
  await next() // 放行,执行下一个中间件(核心:交出执行权)
  
  console.log('【中间件 1 结束】—— 日志记录:响应返回')
  console.log(6)
})

app.use(async (ctx, next) => {
  console.log('  【中间件 2 开始】—— 权限校验:通过')
  console.log(2)
  
  await next() // 放行,执行下一个中间件
  
  console.log(5)
  console.log('  【中间件 2 结束】—— 响应处理:添加响应头')
})

app.use(async (ctx, next) => {
  console.log('    【中间件 3 开始】—— 业务逻辑:处理请求')
  console.log(3)
  
  await next() // 没有更多中间件,直接返回(执行终止)
  
  console.log(4)
  console.log('    【中间件 3 结束】—— 业务逻辑:返回结果')
})

// 3. 模拟请求上下文(ctx:Koa的核心,封装请求和响应信息)
const ctx = {
  req: { url: '/' },
  res: {}
}

// 4. 启动执行
app(ctx).then(() => {
  console.log('\n所有中间件执行完毕!')
})

1.2 核心原理拆解(通俗+专业)

通俗理解

把每个中间件想象成一个“关卡”,请求要经过所有关卡才能到达最核心的业务逻辑(中间件3);处理完业务逻辑后,响应要再反向经过所有关卡,才能返回给客户端。比如:

请求进入 → 中间件1(记录日志)→ 中间件2(权限校验)→ 中间件3(处理业务)→ 中间件2(处理响应)→ 中间件1(记录响应日志)→ 响应返回

专业剖析

  • 中间件存储:用数组middlewares存储所有通过app.use()注册的中间件,保证执行顺序与注册顺序一致。

  • 调度函数dispatch:递归实现中间件的依次执行,index参数控制当前执行的中间件索引,当index等于中间件数组长度时,递归终止(最内层执行完毕)。

  • next函数:本质是dispatch(index+1, ctx)的封装,调用next()就相当于“交出执行权”,让下一个中间件执行;await next()则保证“下一个中间件执行完毕后,再继续执行当前中间件的后续逻辑”,这是洋葱模型反向回流的关键。

  • ctx上下文:统一封装请求(req)和响应(res)信息,所有中间件共享同一个ctx,实现数据传递(比如中间件1存储的用户信息,中间件3可以直接使用)。

1.3 执行结果与验证

运行上述代码,控制台输出如下(完美匹配洋葱模型):

【中间件 1 开始】—— 日志记录:请求进入
请求URL: /
2
  【中间件 2 开始】—— 权限校验:通过
3
    【中间件 3 开始】—— 业务逻辑:处理请求
4
    【中间件 3 结束】—— 业务逻辑:返回结果
5
  【中间件 2 结束】—— 响应处理:添加响应头
6
【中间件 1 结束】—— 日志记录:响应返回

所有中间件执行完毕!

二、手写简易co模块:自动执行Generator函数(告别手动.next())

在async/await出现之前,Generator函数是解决异步回调地狱的重要方案,但它有一个痛点:需要手动调用.next()方法才能逐步执行,非常繁琐。co模块的核心作用就是“自动执行Generator函数”,它会自动遍历Generator的迭代器,直到执行完毕。

核心逻辑:Generator函数中,yield后面通常跟Promise(异步操作),co模块会等待Promise完成,将结果传给Generator,再自动执行下一步,直到迭代结束。

2.1 核心代码实现(可直接运行)

// 手写co模块核心函数:自动执行带Promise的Generator
function run(generatorFunc) {
  // 1. 生成Generator迭代器(Generator函数执行后返回迭代器)
  let it = generatorFunc()

  // 2. 第一次启动Generator,获取第一个yield的结果(通常是Promise)
  let result = it.next()

  // 3. 用Promise包装自动执行流程,最终返回一个Promise(方便外部使用.then())
  return new Promise((resolve, reject) => {
    // 递归函数:自动执行下一个yield
    const next = function (result) {
      // 终止条件:Generator执行完毕(done为true),resolve最终返回值
      if (result.done) {
        resolve(result.value)
        return
      }

      // 核心:result.value是yield后面的Promise,等待它完成
      result.value
        .then((res) => {
          // Promise成功:将结果传给Generator(it.next(res)),并继续执行下一步
          let nextResult = it.next(res)
          next(nextResult)
        })
        .catch((err) => reject(err)) // 捕获异步错误,终止执行
    }

    // 启动自动执行流程
    next(result)
  })
}

// 模拟异步请求(真实开发中可能是接口请求、文件读取等)
function fetchData(data) {
  return new Promise(resolve => {
    setTimeout(() => resolve(data), 500) // 延迟500ms模拟异步
  })
}

// 定义一个Generator函数(包含多个异步操作)
function* gen() {
  console.log('开始执行Generator,发起第一个异步请求')
  
  let res1 = yield fetchData('数据1') // 第一个异步请求,等待完成后赋值给res1
  console.log('第一个请求结果:', res1)
  
  let res2 = yield fetchData('数据2') // 第二个异步请求,依赖第一个请求完成
  console.log('第二个请求结果:', res2)
  
  let res3 = yield fetchData('数据3') // 第三个异步请求,依赖第二个请求完成
  console.log('第三个请求结果:', res3)

  return '全部异步请求完成' // Generator最终返回值
}

// 自动执行Generator函数(无需手动调用.next())
run(gen).then(finalVal => {
  console.log('Generator执行完毕,最终返回:', finalVal)
})

2.2 核心原理拆解(通俗+专业)

通俗理解

把Generator函数想象成一个“异步任务清单”,co模块(这里的run函数)就是一个“自动执行者”:它会先拿出清单上的第一个任务(第一个yield),等待任务完成后,把结果记下来,再自动拿出下一个任务,直到所有任务都完成,最后把清单的最终结果返回给你。

专业剖析

  • Generator迭代器:Generator函数(function*)执行后会返回一个迭代器(it),迭代器的next()方法会返回一个对象{ value: ..., done: ... },value是yield后面的值(这里是Promise),done表示Generator是否执行完毕。

  • 自动迭代逻辑:next函数是核心,它接收上一个yield的执行结果,调用it.next(res)将结果传入Generator(赋值给res1、res2等),同时获取下一个yield的结果,递归执行自身,实现自动迭代。

  • Promise封装:run函数最终返回一个Promise,这样外部可以通过.then()获取Generator的最终返回值,也能通过.catch()捕获异步错误,符合异步编程的统一规范。

  • 异步依赖处理:由于每次yield的Promise完成后才会执行下一个yield,因此可以轻松实现异步操作的顺序执行(比如先获取数据1,再用数据1获取数据2)。

2.3 执行结果与验证

运行代码后,控制台每隔500ms输出一次结果,最终输出如下:

开始执行Generator,发起第一个异步请求
第一个请求结果: 数据1
第二个请求结果: 数据2
第三个请求结果: 数据3
Generator执行完毕,最终返回: 全部异步请求完成

三、异步串行/并行加法:理解异步流程控制的核心

异步加法看似简单,却能完美体现“串行”和“并行”两种异步流程控制的差异:

  • 串行:多个异步操作按顺序执行,前一个操作完成后,再执行下一个(适合有依赖关系的场景);

  • 并行:多个异步操作同时执行,无需等待前一个完成(适合无依赖关系的场景,能提升效率)。

我们先实现一个基础的异步加法函数,再基于它分别实现串行和并行求和。

3.1 基础准备:异步加法函数与Promise包装

// 1. 基础异步加法函数(基于回调函数,模拟真实异步场景)
// 接收 a, b 两个数字,callback 是回调函数(错误优先原则:第一个参数是错误,第二个是结果)
const asyncAdd = (a, b, callback) => {
  // 模拟异步操作(延迟 500ms,比如接口请求、计算密集型操作)
  setTimeout(() => {
    // 这里简化处理,不模拟错误,直接返回结果 a+b
    callback(null, a + b);
  }, 500);
};

// 2. 包装函数:将 callback 风格的异步方法,转成 Promise 风格
// 目的:方便在 async/await、Promise 链式调用中使用(更符合现代异步编程规范)
const promiseAdd = (a, b, index) => {
  console.log(`第 ${index} 次计算,参数 ${a}, ${b}`);
  return new Promise((resolve, reject) => {
    // 调用原来的 callback 异步加法
    asyncAdd(a, b, (err, res) => {
      if (err) {
        reject(err); // 出错时,抛出错误
      } else {
        resolve(res); // 成功时,返回计算结果
      }
    });
  });
};

3.2 方式一:异步串行求和(reduce实现)

核心逻辑:用数组的reduce方法,将前一次的计算结果(Promise)作为下一次计算的输入,实现“一步一步按顺序执行”。

// 串行求和:reduce + Promise 链式,实现异步累加
const add1 = (arr) => {
  // reduce参数说明:
  // acc:上一次的Promise结果(累加和),初始值为0
  // val:当前数组要加的数
  // index:当前索引(用于打印日志)
  return arr.reduce((acc, val, index) => {
    // Promise.resolve(acc):确保acc始终是Promise(兼容初始值0)
    return Promise.resolve(acc).then((value) => {
      // 等待上一步累加完成,再和当前值 val 相加
      return promiseAdd(value, val, index + 1); // index+1 是因为索引从0开始
    });
  }, 0); // 初始值 acc = 0(第一次计算:0 + arr[0])
};

// 执行串行求和:1+2+3+...+9,一步一步按顺序执行
add1([1, 2, 3, 4, 5, 6, 7, 8, 9]).then((sum) =>
  console.log("异步串行加法结果", sum)
);

3.3 方式二:异步并行求和(递归+Promise.all实现)

核心逻辑:采用“二叉树式”分组,将数组两两分组,每组同时执行加法(并行),再将每组的结果递归分组,直到得到最终总和。这种方式比“所有数字同时相加”更高效(避免过多并发任务)。

// 并行求和:递归 + Promise.all 实现并行归约求和(二叉树式计算)
async function parallelSum(arr) {
  // 递归终止条件:数组只剩一个数,直接返回(无需再计算)
  if (arr.length === 1) return arr[0];

  const tasks = []; // 存放所有并行执行的异步任务

  // 步长为2,将数组两两分组:[1,2] [3,4] [5,6] ... [9,0](奇数长度时,最后一个补0)
  for (let i = 0; i < arr.length; i += 2) {
    // arr[i+1] || 0:处理奇数长度数组(比如最后一个元素9,没有i+1,补0)
    tasks.push(promiseAdd(arr[i], arr[i + 1] || 0));
  }

  // Promise.all:并行执行所有任务,等待所有任务完成后,返回结果数组
  const results = await Promise.all(tasks);

  // 递归:将上一轮的计算结果,继续两两分组并行计算
  return parallelSum(results);
}

// 执行并行求和:速度比串行快(无需等待上一步完成)
parallelSum([1, 2, 3, 4, 5, 6, 7, 8, 9]).then((sum) =>
  console.log("异步并行加法结果", sum)
);

3.4 核心差异对比(通俗+专业)

对比维度 异步串行 异步并行
执行顺序 按顺序执行,前一个完成再执行下一个 所有任务同时执行,无顺序依赖
执行时间 总时间 = 所有任务时间之和(本例:9*500ms=4500ms) 总时间 = 最长任务时间 * 递归次数(本例:3*500ms=1500ms)
适用场景 任务有依赖(比如下一个任务需要上一个任务的结果) 任务无依赖(比如多个独立的接口请求、计算任务)
实现核心 Promise链式调用 + reduce Promise.all + 递归归约

3.5 执行结果与验证

串行求和会依次打印每次计算的参数,总耗时约4500ms;并行求和会同时打印多组计算参数,总耗时约1500ms,最终两者的求和结果均为45。

四、手写Promise核心静态方法:理解Promise的底层逻辑

Promise的静态方法(all、race、allSettled、any)是异步流程控制的常用工具,它们的底层逻辑都基于Promise的核心特性——状态不可逆(pending→fulfilled/rejected)。下面我们逐个手写实现,拆解它们的核心规则。

4.1 手写Promise.all:“全部成功才成功,一个失败就失败”

核心规则:接收一个Promise数组,只有所有Promise都成功(fulfilled),才返回所有结果的数组;只要有一个Promise失败(rejected),就立即返回该失败原因,终止执行。

// 手写实现 Promise.all 核心方法
function myPromiseAll(promiseArr) {
  // 返回一个新的 Promise(外部可以通过.then()/.catch()获取结果)
  return new Promise((resolve, reject) => {
    const len = promiseArr.length;    // 传入的 Promise 数组长度
    const result = [];                // 存放所有成功结果的数组(按原数组顺序)
    let count = 0;                    // 记录已经成功完成的任务数量

    // 边界处理:如果传入空数组,直接resolve空结果
    if (!len) {
      resolve(result);
      return; // 必须加return,防止后续代码继续执行
    };

    // 遍历所有promise(用entries()获取索引,保证结果顺序与输入一致)
    for (const [i, p] of promiseArr.entries()) {
      // Promise.resolve(p):包装非Promise值(比如普通数字、字符串),统一处理成Promise
      Promise.resolve(p).then(
        (value) => {
          // 成功:按原数组索引存入结果(确保顺序正确)
          result[i] = value;
          count++; // 成功数 +1

          // 所有任务都成功 → 调用resolve,返回结果数组
          if (count === len) {
            resolve(result);
          }
        },
        (reason) => {
          // 任何一个任务失败 → 立刻reject,终止所有任务(失败优先)
          reject(reason);
        }
      );
    }
  });
}

// 测试用例
const p1 = Promise.resolve(1);
const p2 = Promise.resolve(2);
const p3 = Promise.reject(new Error('失败'));

// 测试1:全部成功
myPromiseAll([p1, p2]).then(res => console.log('all成功:', res)).catch(err => console.log('all失败:', err.message));
// 测试2:有一个失败
myPromiseAll([p1, p3]).then(res => console.log('all成功:', res)).catch(err => console.log('all失败:', err.message));

4.2 手写Promise.race:“谁先完成,就返回谁”

核心规则:接收一个Promise数组,不管是成功还是失败,只要有一个Promise先完成(状态变为fulfilled或rejected),就立即返回该结果,其他任务继续执行,但结果会被忽略。

// 规则:谁最先完成(成功/失败),就返回谁
function myPromiseRace(promiseArr) {
  // 返回一个新的 Promise
  return new Promise((resolve, reject) => {
    // 遍历所有传入的promise
    for (const p of promiseArr) {
      // 统一包装成Promise(处理普通值)
      Promise.resolve(p).then(
        (value) => {
          // 任何一个成功 → 立刻resolve(状态不可逆,后续结果不会覆盖)
          resolve(value);
        },
        (reason) => {
          // 任何一个失败 → 立刻reject(状态不可逆,后续结果不会覆盖)
          reject(reason);
        }
      );
    }
  });
}

// 测试用例:模拟快慢不同的Promise
const fastPromise = new Promise((resolve) => setTimeout(() => resolve('快的Promise'), 100));
const slowPromise = new Promise((resolve) => setTimeout(() => resolve('慢的Promise'), 1000));
const errorPromise = new Promise((_, reject) => setTimeout(() => reject('失败的Promise'), 500));

// 测试1:成功的Promise更快
myPromiseRace([fastPromise, slowPromise]).then(res => console.log('race结果:', res));
// 测试2:失败的Promise更快
myPromiseRace([errorPromise, fastPromise]).then(res => console.log('race结果:', res)).catch(err => console.log('race失败:', err));

4.3 手写Promise.allSettled:“无论成败,都返回所有结果”

核心规则:接收一个Promise数组,等待所有Promise都完成(无论成功还是失败),返回一个包含所有任务结果的数组,每个结果对象包含状态(fulfilled/rejected)和对应的值/原因,不会因为某个任务失败而终止。

// 规则:无论成功/失败,都返回所有结果,不会中断
Promise.allSettled = function (promiseArr) {
  return new Promise(function (resolve) {
    const len = promiseArr.length;  // 数组长度
    const result = [];              // 存放所有结果
    let count = 0;                  // 已完成的promise数量

    // 空数组直接返回空
    if (!len) {
      resolve(result);
      return; // 必须加return!
    }

    // 遍历所有promise
    for (let [i, p] of promiseArr.entries()) {
      Promise.resolve(p).then(
        (value) => {
          // 成功:按标准格式存入(status为fulfilled,value为成功结果)
          result[i] = { status: "fulfilled", value };
          count++;
          if (count === len) { // 全部完成就resolve
            resolve(result);
          }
        },
        (reason) => {
          // 失败:按标准格式存入(status为rejected,reason为失败原因)
          result[i] = { status: "rejected", reason };
          count++;
          if (count === len) { // 失败也要计数,确保所有任务都完成
            resolve(result);
          }
        }
      );
    }
  });
};

// 测试用例
const p1 = Promise.resolve(1);
const p2 = Promise.reject(new Error('失败'));
Promise.allSettled([p1, p2]).then(res => {
  console.log('allSettled结果:', res);
  // 输出:[ {status: 'fulfilled', value: 1}, {status: 'rejected', reason: Error} ]
});

4.4 手写Promise.any:“只要有一个成功就成功,全部失败才失败”

核心规则:接收一个Promise数组,只要有一个Promise成功(fulfilled),就立即返回该成功结果;如果所有Promise都失败(rejected),则抛出一个AggregateError(包含所有失败原因)。

注意:与Promise.race的区别——any只关注成功,只有全部失败才会失败;race不管成功失败,谁先完成就返回谁。

// 规则:
// 1. 只要有一个成功,就返回这个成功结果
// 2. 全部失败 → 抛出 AggregateError 错误
function myPromiseAny(promiseArr) {
  return new Promise(function (resolve, reject) {
    const len = promiseArr.length;
    const errors = []; // 收集所有失败原因(全部失败时使用)
    let count = 0;

    // 空数组:标准规定返回 AggregateError
    if (len === 0) {
      return reject(new AggregateError([], "All promises were rejected"));
    }

    // 遍历所有promise
    for (let [i, p] of promiseArr.entries()) {
      Promise.resolve(p).then(
        (value) => {
          // ✅ 任何一个成功 → 直接返回成功结果(状态不可逆)
          resolve(value);
        },
        (reason) => {
          // ❌ 失败:记录错误,计数+1
          errors[i] = reason;
          count++;

          // 全部都失败了 → 抛出 AggregateError(包含所有失败原因)
          if (count === len) {
            reject(new AggregateError(errors, "All promises were rejected"));
          }
        }
      );
    }
  });
}

// 测试用例
const p1 = Promise.reject(new Error('失败1'));
const p2 = Promise.resolve(2);
const p3 = Promise.reject(new Error('失败2'));

// 测试1:有一个成功
myPromiseAny([p1, p2, p3]).then(res => console.log('any成功:', res)); // 输出2
// 测试2:全部失败
myPromiseAny([p1, p3]).then(res => console.log('any成功:', res)).catch(err => {
  console.log('any失败:', err.message); // 输出"All promises were rejected"
  console.log('所有失败原因:', err.errors); // 输出[Error('失败1'), Error('失败2')]
});

五、Promise并发控制(带超时、重传、失败收集):实战级封装

在真实开发中,我们经常会遇到“大量异步任务需要并发执行,但不能无限制并发”(比如同时调用100个接口,会导致服务器压力过大),同时还需要处理“任务超时”“失败重试”“收集失败任务”等需求。下面我们封装一个实战级的Promise并发控制器,满足这些核心需求。

5.1 核心代码实现(可直接复用)

/**
 * Promise 并发控制器(带 并发限制 + 超时 + 自动重试)
 * @param {Array} tasks - 任务数组,每一项是 () => Promise 的函数(必须是函数,确保懒执行)
 * @param {Object} options - 配置参数(均有默认值)
 * @param {number} options.limit - 最大并发数,默认5
 * @param {number} options.timeout - 单个任务超时时间,默认3000ms
 * @param {number} options.maxRetries - 最大重试次数,默认3次
 * @returns {Promise} 最终返回【所有失败的任务列表】(方便后续重试或排查问题)
 */
function promiseConcurrencyControl(tasks, {
  limit = 5,
  timeout = 3000,
  maxRetries = 3
} = {}) {
  return new Promise((resolve) => {
    const results = [];          // 存储所有任务最终结果(成功/失败)
    const failedTasks = [];      // 存储【最终彻底失败】的任务(重试后仍失败)
    let taskIndex = 0;           // 下一个要执行的任务下标(控制任务顺序)
    let runningCount = 0;        // 当前正在运行的任务数量(控制并发数)

    // ==========================================
    // 核心函数:启动下一个任务(调度器)
    // 只要有任务未执行、且当前并发数未达上限,就持续启动任务
    // ==========================================
    function runNextTask() {
      // 终止条件:所有任务都执行完毕(taskIndex >= 任务总数),且没有正在运行的任务
      if (taskIndex >= tasks.length && runningCount === 0) {
        return resolve(failedTasks); // 返回最终失败的任务列表
      }

      // 循环启动任务:只要还有任务,且并发数未达上限
      while (taskIndex < tasks.length && runningCount < limit) {
        const currentIndex = taskIndex++; // 取当前任务下标(避免并发时下标混乱)
        const task = tasks[currentIndex]; // 取出当前任务(函数)
        runningCount++;                   // 正在运行的任务数 +1

        // 执行任务(带超时、重试逻辑)
        executeTaskWithRetry(task, currentIndex, 0);
      }
    }

    // ==========================================
    // 带【超时】和【自动重试】的任务执行器
    // @param task - 任务函数 () => Promise
    // @param index - 任务下标(用于定位任务)
    // @param retryCount - 当前已经重试的次数(初始为0)
    // ==========================================
    function executeTaskWithRetry(task, index, retryCount) {
      // 1. 创建超时Promise:超过指定时间未完成,直接reject(超时错误)
      const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => {
          reject(new Error(`Task ${index} timed out after ${timeout}ms`));
        }, timeout);
      });

      // 2. 竞速:任务执行 和 超时监控 谁先完成
      Promise.race([
        task(),                  // 执行真实任务(懒执行,避免提前启动)
        timeoutPromise           // 超时监控
      ])
      .then(result => {
        // ======================
        // 任务执行成功
        // ======================
        results[index] = {
          success: true,
          result,
          retries: retryCount // 记录重试次数(0表示未重试)
        };
        runningCount--; // 正在运行的任务数 -1
        runNextTask();   // 启动下一个任务(维持并发数)
      })
      .catch(error => {
        // ======================
        // 任务失败 / 超时
        // ======================
        if (retryCount < maxRetries) {
          // 还有重试次数 → 立即重试,重试次数+1
          console.log(`Task ${index} 失败(原因:${error.message}),重试 ${retryCount + 1}/${maxRetries}`);
          executeTaskWithRetry(task, index, retryCount + 1);
        } else {
          // 重试次数用完 → 标记为彻底失败,存入失败列表
          const failureInfo = {
            taskIndex: index,       // 任务下标(方便定位)
            error: error.message,   // 失败原因
            retries: maxRetries     // 已重试次数
          };
          failedTasks.push(failureInfo);

          results[index] = {
            success: false,
            ...failureInfo
          };

          runningCount--;
          runNextTask(); // 继续启动下一个任务
        }
      });
    }

    // 启动并发控制(入口)
    runNextTask();
  });
}

// ------------------------------
// 测试工具函数(模拟真实场景中的异步任务)
// ------------------------------

/**
 * 创建测试任务(随机成功/失败,可模拟接口请求)
 * @param {number} id - 任务ID(用于区分)
 * @param {number} successProbability - 成功率(0~1,默认0.7)
 * @param {number} delay - 任务执行延迟(默认1000ms)
 */
function createTestTask(id, successProbability = 0.7, delay = 1000) {
  return () => new Promise((resolve, reject) => {
    setTimeout(() => {
      // 随机成功/失败(模拟接口请求的不确定性)
      if (Math.random() < successProbability) {
        resolve(`Task ${id} 成功`);
      } else {
        reject(new Error(`Task ${id} 执行失败`));
      }
    }, delay);
  });
}

// 生成 10 个测试任务(成功率70%,延迟800ms)
const testTasks = Array.from({ length: 10 }, (_, i) =>
  createTestTask(i + 1, 0.7, 800)
);

// 启动并发控制(配置:最大并发3个,超时1500ms,最多重试2次)
promiseConcurrencyControl(testTasks, {
  limit: 3,         // 最多同时运行3个任务
  timeout: 1500,    // 单个任务超过1.5秒超时
  maxRetries: 2     // 每个任务最多重试2次
})
.then(failedTasks => {
  console.log('\n=== 全部执行完成 ==');
  console.log('最终失败的任务:', failedTasks);
});

5.2 核心功能拆解(实战重点)

  • 并发限制:通过runningCount(当前运行任务数)和limit(最大并发数)控制,只有runningCount < limit时,才会启动新任务,避免并发过多导致的性能问题。

  • 任务调度:runNextTask函数作为调度器,循环启动任务,确保并发数维持在limit以内,同时处理任务执行完毕后的“补位”(启动下一个任务)。

  • 超时控制:通过Promise.race将任务执行与超时监控绑定,超过指定时间未完成的任务,直接视为失败,进入重试逻辑。

  • 自动重试:任务失败后,若重试次数未用完,立即重试,重试次数用完后,标记为彻底失败,存入失败列表。

  • 失败收集:最终返回所有彻底失败的任务列表,包含任务下标、失败原因和重试次数,方便后续排查问题或重新重试。

  • 懒执行:任务数组中的每一项是一个返回Promise的函数,而非直接执行的Promise,确保任务只有在被调度时才会启动,避免提前执行导致的并发混乱。

5.3 应用场景

该并发控制器可直接用于真实开发中的场景,比如:

  • 批量接口请求(比如批量获取用户信息、批量上传文件);

  • 批量处理异步任务(比如批量处理文件、批量发送消息);

  • 需要容错的异步场景(比如部分任务失败后,无需终止全部,只需收集失败任务后续处理)。

六、总结:核心知识点串联

本文讲解的所有内容,核心都是围绕“异步流程控制”展开:

  1. Koa洋葱模型:通过递归调度中间件,实现“请求进入→业务处理→响应返回”的分层逻辑,核心是next函数的执行权移交;

  2. co模块:自动迭代Generator函数,解决手动.next()的繁琐,本质是Promise与Generator的结合;

  3. 异步串/并行:串行适合有依赖的任务,并行适合无依赖的任务,核心是Promise链式调用与Promise.all的运用;

  4. Promise静态方法:all、race、allSettled、any,分别对应不同的异步场景,底层都是基于Promise的状态不可逆特性;

  5. 并发控制:在Promise基础上,增加并发限制、超时、重试等实战功能,解决大量异步任务的高效、稳定执行问题。

掌握这些知识点,不仅能看懂框架底层代码,更能在实际开发中灵活处理各类异步场景,写出更高效、更健壮的代码。

「JS全栈AI Agent学习」六、当AI遇到矛盾,该自己决定还是问你?—— Human-in-the-Loop

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

⏱️ 预计阅读时间:15 分钟

📖 原书地址adp.xindoo.xyz

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


🗺️ 系列导航

主题 状态
第一篇 提示链 · 路由 · 并行化
第二篇 反思 · 工具使用 · 规划
第三篇 多智能体 · 记忆管理 · 学习适应
第四篇 MCP 协议
第五篇 目标设定与监控 · 异常处理与恢复
本篇 Human-in-the-Loop 设计

前言

上一篇讲目标监控和异常处理,结尾提到了 Human-in-the-loop——什么时候该让人介入。

当时我给了一个简单的判断原则:影响最终结果 + 难以撤回,就介入

但这只是"要不要介入"的问题。这一章要讲的,是更难的那个问题:

在什么时候,用什么方式,把决策权交还给人?

这个问题在 my-resume 项目里非常具体。很多开源项目也有嘛,就是分析自己简历,然后提出参考意见并优化。

每一条信息都是用户的真实经历——Agent 没有权利自己"脑补",更不能随便改。

怎么在"帮用户做事"和"不越权替用户做决定"之间找到平衡?这就是 HITL 要解决的事。

PS:现在还在跟着学,代码实战的推进到这部分再一起放出来了,目前刚重构完还没还没把AI的功能串起来

image.png

后面设计的一个功能就是能帮识别下简历问题,有时手滑年份错了,可能还好,但对HR来说很致命。从实际问题出发,自己当产品,自己即是用户就好,慢慢完善,一边学习一边做。


一、一个让 Agent 卡住的问题

假设你正在用简历优化 Agent,它在扫描你的简历时,发现了这样一个问题:

  • A公司任职时间:2018年3月 — 2020年6月
  • C公司任职时间:2017年9月 — 2019年4月

两段时间有将近两年的重叠。

这时候 Agent 面临一个选择:

  • 自己改? 改哪个?改成什么?它不知道哪个才是真实的。
  • 不管它? 这个矛盾如果出现在正式简历里,会让 HR 直接质疑真实性。
  • 问用户? 问,但怎么问?问什么?

这个看似简单的问题,背后藏着 AI Agent 设计中最核心的一个命题:

在什么时候,用什么方式,把决策权交还给人?

这就是本章的主题:Human-in-the-Loop(HITL)


二、HITL 是什么?

Human-in-the-Loop,直译是"把人放在循环里"。

用三句话理解它:

模式 描述 问题
全自动 AI 自己做所有决定 遇到信息不足时,只能瞎猜
全人工 每一步都问用户 用户体验极差,跟没有 AI 一样
HITL AI 做能做的,人做该做的 ✅ 两者平衡

HITL 的核心不是"让 AI 更笨",而是:

承认有些决定本来就该人来做,AI 的职责是识别出这些时刻,并优雅地把决策权交出去。

接下来,拆解实现 HITL 的六大核心机制。


三、机制①:介入时机——Agent 先自己找答案

最容易犯的错误:发现问题就问用户。

这会导致用户被频繁打断,体验极差。正确的做法是:

Agent 先尝试自己解决,真的解决不了,才介入。

判断标准:有没有足够的上下文自行决策?

还是简历场景。Agent 看到用户写了:

"我是一个积极主动、善于沟通的人"

这句话太泛了,Agent 想把它改得更具体。这时候该问用户吗?

不该。 Agent 应该先去项目经历里找支撑证据——这件事它自己能做:

async function enrichSelfDescription(profile) {
  const { selfDescription, projects } = profile;

  // 先在项目经历里找支撑证据
  const evidence = await findSupportingEvidence(projects, selfDescription);

  if (evidence.length > 0) {
    // 找到了 → 直接补充,不打扰用户
    return {
      action: 'auto_enrich',
      result: buildEnrichedDescription(selfDescription, evidence),
    };
  } else {
    // 找不到 → 才介入
    return {
      action: 'require_human',
      reason: '自我评价缺乏具体项目支撑,需要用户补充',
    };
  }
}

这个判断逻辑用一句话总结:

能自己解决 → 不介入
不能自己解决 → 才介入

看起来简单,但它是后续所有机制的基础前提。


四、机制②:结构化选项——别问开放问题

当 Agent 决定介入时,怎么问同样重要。

开放问题 vs 结构化选项

糟糕的问法:

"您的两段工作经历时间有重叠,请问是怎么回事?"

用户看到这个问题,需要自己思考、自己组织语言、自己判断该改哪里——认知负担极高。

正确的问法:

"发现您的工作经历存在时间重叠,请选择处理方式:

  • A:A公司时间有误,应为 2019年3月 — 2020年6月
  • B:C公司时间有误,应为 2019年9月 — 2020年4月
  • C:两段经历确实重叠(如兼职),我来手动说明"

用户只需要选一个字母,认知成本降到最低。

这个设计思路,和我们做前端交互设计是一个道理——不要让用户面对空白输入框,给他选项,降低决策成本

A/B/C 选项的设计原则

function buildInterventionOptions(conflict) {
  return {
    question: conflict.description,
    options: [
      {
        key: 'A',
        label: conflict.suggestion_a,       // Agent 推断的方案A
        action: 'auto_fix_a',
      },
      {
        key: 'B',
        label: conflict.suggestion_b,       // Agent 推断的方案B
        action: 'auto_fix_b',
      },
      {
        key: 'C',
        label: '以上都不对,我来手动说明',  // 兜底选项,永远存在
        action: 'pause_for_human',          // 暂停,等用户补充
      },
    ],
  };
}

注意 C 选项永远存在。它的作用是:

保留用户的最终控制权,无论 Agent 推断得多准,用户都可以说"都不对,我自己来"。

这不是产品的妥协,而是对用户自主权的尊重——也是用户信任 Agent 的基础。


五、机制③:介入粒度——问题有大有小,介入要分级

并不是所有的介入都一样重。Agent 需要识别当前问题属于哪个粒度级别,再决定如何介入。

三个粒度级别

字段级(Field-level):缺一个具体数据,补上就好。

场景:手机号只有10位,少了一位数字。 处理:直接问"您的手机号是否为 138XXXX?",一句话解决。

段落级(Block-level):某个模块的内部逻辑有问题,需要用户理清一块内容。

场景:项目经历里有三个项目,时间线混乱,无法判断先后顺序。 处理:列出三个项目,请用户确认排序依据。

全局级(Global-level):输入内容与任务目标根本不匹配,需要重新确认方向。

场景:用户投的是前端工程师岗位,但简历通篇没有提到任何技术栈。 处理:这不是逻辑问题,而是内容本身无法支撑任务,需要从全局重新确认。

粒度判断逻辑

function classifyInterventionLevel(issue) {
  switch (issue.scope) {
    case 'single_field':
      // 缺一个字段值,补上即可
      return 'field';

    case 'block_logic':
      // 某模块内部逻辑不完整,缺少判断依据
      return 'block';

    case 'global_mismatch':
      // 整体内容与目标任务不匹配
      return 'global';
  }
}

粒度越高,用户需要做的事越多,也越容易产生疲劳感——这就引出了下一个机制。


六、机制④:批量介入——别一个一个问,打包说

用户疲劳是真实存在的

想象一下:Agent 问了你第1个问题,你回答了。问了第2个,你回答了。第3个、第4个、第5个……

到第3个问题开始,大多数用户已经开始不耐烦了。更糟糕的是,如果前3个都是小问题(字段级),第4个突然是全局级的大问题,用户早就没耐心认真回答了。

做过用户访谈或者产品测试的同学应该有体会——用户的耐心是有限的,而且消耗得比你想象的快。

解法:先做完能做的,再打包告诉用户

Agent 扫描全文
      ↓
收集所有问题,分类整理
      ↓
能自己解决的 → 先默默处理掉
      ↓
剩下不能解决的 → 打包成一份"阶段总结"
      ↓
一次性告知用户,用户一次性补充
      ↓
继续后续流程

阶段总结的模板示例

✅ 已完成优化:
  - 自我评价已结合项目经历补充了具体案例
  - 技能标签已按岗位要求重新排序
  - 教育经历格式已统一

⚠️ 需要您补充以下信息,以便继续优化:
  1. [字段级] 手机号疑似缺少一位,请确认
  2. [段落级] A公司与C公司任职时间有重叠,请选择处理方式(A/B/C)
  3. [全局级] 未发现前端相关技术栈,请确认目标岗位方向

补充完成后,我将继续为您完成剩余优化 ~
async function runBatchedIntervention(profile) {
  const issues = [];

  // 第一遍扫描:收集所有问题
  const scanResult = await scanProfile(profile);

  for (const issue of scanResult.issues) {
    if (issue.canAutoFix) {
      // 能自己解决的,直接处理
      await autoFix(profile, issue);
    } else {
      // 不能解决的,加入待询问列表
      issues.push(issue);
    }
  }

  if (issues.length === 0) return { status: 'complete' };

  // 打包成一次介入,而不是多次打断
  return {
    status: 'need_human',
    summary: buildSummaryMessage(profile, issues),
    issues,
  };
}

这个设计的核心思想:

把"打扰用户"这件事的次数压到最低,但每次打扰都要有价值、有上下文、让用户看到进度。


七、机制⑤:前后回溯——用户回答后,不是结束

用户补充完信息,Agent 不能直接继续往下走。它需要做两件事:

往后看:后续内容跟着改

用户确认了"A公司时间有误,应为2019年3月",那么:

  • 简历里所有引用了这段时间的地方,都要同步更新
  • 基于这段时间计算的"工作年限",也要重新计算

往前看:之前内容有没有新矛盾

用户的补充可能引入新的矛盾。比如:

用户把 A公司时间改成了 2019年3月 — 2020年6月 但之前已经处理好的 B公司时间是 2019年1月 — 2020年3月 现在又重叠了……

这让我想到写代码改 bug 的感受——改了一个地方,另一个地方又冒出来了。Agent 的回溯机制,就是在系统层面把这件事自动化。

async function postInterventionRevalidation(profile, updatedFields) {
  // 往后看:同步更新所有受影响的字段
  await propagateChanges(profile, updatedFields);

  // 往前看:重新扫描,检查是否引入了新矛盾
  const newIssues = await scanProfile(profile);

  if (newIssues.issues.length > 0) {
    // 发现新矛盾 → 进入升级循环
    return {
      status: 'new_conflict_found',
      issues: newIssues.issues,
    };
  }

  return { status: 'clean' };
}

八、机制⑥:升级循环——新矛盾出现,再次介入

前后回溯发现了新矛盾,怎么办?

再次进入介入流程。 这就是"升级循环(Escalation Loop)"。

但循环不能无限进行,需要一个收敛条件——这和上一篇讲反思模式时的"最多3次"是同一个道理:边际收益递减,超过上限就该人工接手,而不是让 Agent 继续转圈。

async function escalationLoop(profile, maxRounds = 3) {
  let round = 0;

  while (round < maxRounds) {
    const result = await runBatchedIntervention(profile);

    if (result.status === 'complete') {
      // 没有新问题,循环结束
      return { status: 'done', rounds: round };
    }

    // 有问题,等待用户响应
    const userResponse = await waitForUserInput(result.summary);
    await applyUserResponse(profile, userResponse);

    // 前后回溯
    const revalidation = await postInterventionRevalidation(
      profile,
      userResponse.updatedFields
    );

    if (revalidation.status === 'clean') break;

    round++;
  }

  if (round >= maxRounds) {
    // 超过最大轮次,诚实告知用户
    return {
      status: 'max_rounds_reached',
      message: '检测到复杂冲突,建议您手动检查以下内容后重新提交',
    };
  }
}

超过最大轮次的处理方式,我觉得这里有一个很重要的设计原则:

诚实地告诉用户"这个我处理不了",比假装处理完要好得多。

Agent 承认自己的边界,反而会让用户更信任它。


九、完整流程图

把六大机制串在一起,完整的 HITL 流程如下:

用户提交内容
      ↓
Agent 扫描全文,收集所有问题
      ↓
┌─────────────────────────────┐
│  对每个问题:                │
│  有足够上下文?              │
│  ├─ 是 → 自动处理(机制①)  │
│  └─ 否 → 加入待询问列表      │
└─────────────────────────────┘
      ↓
待询问列表为空?
├─ 是 → 输出结果,流程结束
└─ 否 → 按粒度分级(机制③)
            ↓
       打包成阶段总结(机制④)
            ↓
       展示给用户:A/B/C 选项(机制②)
            ↓
       用户响应
            ↓
       前后回溯(机制⑤)
            ↓
       有新矛盾?
       ├─ 有 → 升级循环,回到扫描(机制⑥)
       └─ 没有 → 输出结果,流程结束

上述是和AI讨论出来的结论,实际上,已有功能都是 已有简历 -> 反推回填内容;

这一块设计后面是想做一个Agent功能,能快速高效生成简历模版。慢慢来,边学边完善吧。


十、核心洞察总结

机制 核心思想 一句话记住
①介入时机 Agent 先自己找答案 能自己解决的,不打扰用户
②结构化选项 给选项,不问开放问题 A/B/C 选项 + 永远有兜底的 C
③介入粒度 问题分三级,介入方式不同 字段级 · 段落级 · 全局级
④批量介入 打包打扰,不零散打断 把打扰次数压到最低
⑤前后回溯 用户回答后,双向检查 往后同步,往前验证
⑥升级循环 新矛盾再次介入,有收敛条件 超过上限,诚实告知,交给人

结语

读完这一章,我最大的感受是:

HITL 不是 AI 能力不足的妥协,而是一种设计哲学。

它承认了一件事:有些决定,本来就该人来做。AI 的职责不是替代人的所有判断,而是:

  1. 识别出哪些决定超出了自己的能力范围
  2. 优雅地把这些决定交还给用户
  3. 降低用户做决定的认知成本
  4. 保护用户不被无意义的打扰淹没

这六个机制,本质上都在回答同一个问题:

怎么让 AI 和人的协作,比任何一方单独工作都更好?

对于 my-resume 的全栈改造来说,这章给了我一个很清晰的产品设计原则:

Agent 的边界感,和开发者的边界感是一回事。 知道什么该自己做,什么该交出去,什么时候该说"这个我不确定,你来决定"——这是靠谱的标志,不是能力不足的表现。

学到这里,越来越觉得:AI 工程和软件工程,底层真的是同一套思维。 边界感、容错、分层处理——工程师早就在做了,只不过现在的执行者从代码变成了模型。


下一篇预告: 第14章——RAG(检索增强生成)。Agent 有了工具、有了目标、有了人机协同,下一步是让它真正"有记忆"——从外部知识库里检索信息,而不是只靠训练数据回答问题。


💬 系列地址:持续更新中

📖 原书地址adp.xindoo.xyz

🛠️ 实战项目:my-resume(静态页面 → NestJS + 数据库 + AI + 部署上线,进行中)

如果这篇对你有帮助,欢迎点赞收藏,我们下篇见 👋

❌