阅读视图

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

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 + 部署上线,进行中)

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

重排、重绘与合成——浏览器渲染性能的底层逻辑

有一段时间我一直搞不明白一件事:同样是"移动一个元素",用 transform: translateX() 就很丝滑,用 left 就会掉帧——明明做的是同一件事,为什么差这么多?后来真正把浏览器渲染的这三个概念搞清楚之后,才发现这不是玄学,是完全可以用机制解释的。这篇是我的学习笔记。


一、先厘清一个容易混淆的概念

在进入正题前,必须先把这两件事分开:React re-render浏览器重排/重绘

它们经常被放在一起讨论,但其实是两个不同层的事情:

筛选条件变化
    ↓
React re-render(React 层)
→ 组件函数重新执行,生成新的虚拟 DOM
→ Diff 算出最小变更
→ 更新真实 DOM
    ↓
浏览器重排 / 重绘(浏览器层)
→ 浏览器感知到 DOM 变化,重新计算布局/绘制

React re-render 可能触发浏览器重排/重绘,但两者不是同一回事。React re-render 是 JS 层面的虚拟 DOM 计算,浏览器重排/重绘是渲染引擎层面的像素计算。优化方向也不同:useMemo/React.memo 减少的是 React re-render,transform 替代 top 优化的是浏览器渲染层。


二、浏览器渲染流程回顾

在"从 URL 到页面"的完整链路里,最后一段是浏览器拿到 DOM + CSSOM 之后的渲染工作:

DOM + CSSOM
    ↓
Render Tree(渲染树)
    ↓
Layout(重排)   ← 计算每个元素的位置和大小
    ↓
Paint(重绘)    ← 填充颜色、边框、阴影……
    ↓
Composite(合成)← 合并图层,输出到屏幕

重排、重绘、合成,是这条流水线的最后三步。理解它们的代价差异,是理解所有 CSS 性能优化的基础。


三、重排(Reflow):最贵的一步

什么是重排?

当元素的几何属性(位置、大小)发生变化,浏览器需要重新计算所有受影响元素的布局信息——这个过程叫重排,也叫 Reflow。

典型触发场景:

// 修改几何属性
element.style.width = '200px';
element.style.height = '100px';
element.style.margin = '20px';
element.style.padding = '10px';

// 改变元素显示状态
element.style.display = 'none';   // 从文档流移除,触发重排
element.style.display = 'block';  // 重新加入文档流,触发重排

// DOM 结构变化
document.body.appendChild(newElement);
parent.removeChild(child);

// 窗口大小变化
window.addEventListener('resize', handler);

为什么代价大?

重排的代价在于连锁反应。HTML 元素的布局是相互影响的——一个元素的宽度变了,它的兄弟元素可能需要重新排列,父元素的高度可能随之变化,父元素的父元素又可能受影响……

浏览器需要从受影响的节点开始,向上向下重新计算整棵子树的几何信息。如果变化发生在页面顶层,几乎等于重算整个页面布局。


四、重绘(Repaint):比重排轻,但不是没有代价

什么是重绘?

当元素的外观发生变化,但位置和大小没变,浏览器只需要重新绘制受影响区域的像素——这叫重绘,也叫 Repaint。

典型触发场景:

// 颜色类变化
element.style.color = '#333';
element.style.backgroundColor = '#f5f5f5';

// 装饰性变化
element.style.boxShadow = '0 2px 8px rgba(0,0,0,0.1)';
element.style.borderColor = 'red';
element.style.outline = '2px solid blue';

// 可见性(注意:visibility 不触发重排,display 触发)
element.style.visibility = 'hidden';

重绘不需要重新计算布局,只需要重新"上色"——所以比重排轻得多,但仍然有开销,不是免费的。


五、关键规律:三者的包含关系

重排 ⊃ 重绘 ⊃ 合成

重排一定触发重绘(几何变了,外观也要重画)
重绘不一定触发重排(外观变了,位置不一定变)
合成不触发重排和重绘(完全跳过前两步)

开销排序:重排 > 重绘 > 合成


六、合成层与 transform 为什么快

这是整篇文章最关键的部分。

三种操作的完整流程对比

操作 触发流程 性能
width / height / top / left 重排 → 重绘 → 合成 最差
color / background-color 重绘 → 合成 中等
transform / opacity 只合成 最好

transform 的工作原理

当浏览器发现一个元素使用了 transformopacity 动画,它会把这个元素提升到独立的合成层(Compositing Layer) ,交给 GPU 处理。

普通元素动画(left/top):
    修改样式
        ↓
    重新 Layout(计算位置)    ← CPU,影响其他元素
        ↓
    重新 Paint(绘制像素)     ← CPU,绘制整个区域
        ↓
    Composite(合成)          ← GPU

transform/opacity 动画:
    修改样式
        ↓
    Composite(合成)          ← GPU 直接处理
    (跳过 Layout 和 Paint)

关键在于:transform 是在已经绘制好的图层上做变换(平移、缩放、旋转),不改变元素在文档流中的实际位置,所以浏览器不需要重新计算布局,也不需要重新绘制像素——只需要 GPU 把这个图层的矩阵变换一下,直接合成输出。

实际代码对比

/* 触发重排 + 重绘,动画掉帧 */
.box-bad {
  position: absolute;
  left: 0;
  transition: left 0.3s ease;
}
.box-bad:hover {
  left: 200px; /* 每一帧都触发重排 */
}

/* 只触发合成,动画丝滑 */
.box-good {
  position: absolute;
  transform: translateX(0);
  transition: transform 0.3s ease;
}
.box-good:hover {
  transform: translateX(200px); /* 每一帧只触发合成,GPU 处理 */
}

视觉效果完全一样,但渲染代价天壤之别。这就是为什么 CSS 动画优先推荐使用 transform

主动触发合成层提升

除了 transformopacity,还可以通过 will-change 提示浏览器提前创建合成层:

/* 告诉浏览器:这个元素即将发生 transform 变化,提前准备合成层 */
.animated-card {
  will-change: transform;
}
// 动画结束后,记得移除(合成层有内存开销)
element.addEventListener('animationend', () => {
  element.style.willChange = 'auto';
});

will-change 不是越多越好——每个合成层都占用 GPU 内存,滥用反而会导致内存压力和性能下降。只在真正需要优化的动画元素上使用。


七、实际开发陷阱:循环里交替读写 DOM

这是一个在真实项目里很容易踩的坑,也是面试的高频考题。

为什么读取布局属性会触发强制重排?

当你读取 offsetHeightclientWidthgetBoundingClientRect() 等属性时,浏览器必须给你一个当前准确的值

如果在读取之前你刚刚写入了一些样式变化,而浏览器还没来得及执行重排,它就必须立即同步执行重排,才能返回准确数值。这叫强制同步重排(Forced Synchronous Layout)。

问题代码:循环内交替读写

// 每次循环都触发一次强制重排——100 次循环 = 100 次重排
const boxes = document.querySelectorAll('.box');

for (let i = 0; i < boxes.length; i++) {
  const height = boxes[i].offsetHeight;        // 读:强制触发重排,获取准确值
  boxes[i].style.height = height + 10 + 'px'; // 写:标记待重排
  // 下一次循环读 offsetHeight,又强制清算上面的标记
}

浏览器原本会把多次样式修改批量处理(一次重排),但读写交替打破了这个批处理——每次读取都迫使浏览器立即清算之前积累的修改。

修复:先批量读,再批量写

// 先读完所有值,再批量写——只触发 1 次重排
const boxes = document.querySelectorAll('.box');

// 第一步:批量读取(此时触发 1 次重排)
const heights = Array.from(boxes).map(box => box.offsetHeight);

// 第二步:批量写入(浏览器合并成 1 次重排处理)
boxes.forEach((box, i) => {
  box.style.height = heights[i] + 10 + 'px';
});

本质是:把读操作和写操作分离,让浏览器能够合批处理写操作。

如果修改逻辑更复杂,可以借助 requestAnimationFrame 把写操作推到下一帧的开头执行:

// 环境:浏览器
// 场景:确保在下一帧开始时批量执行所有 DOM 写操作
const heights = Array.from(boxes).map(box => box.offsetHeight);

requestAnimationFrame(() => {
  boxes.forEach((box, i) => {
    box.style.height = heights[i] + 10 + 'px';
  });
});

八、浏览器完整渲染流程总图

把前面所有内容串起来,完整看一遍:

URL 输入 → DNS → TCP → TLS → HTTP 请求/响应
                                    ↓
                              解析 HTML → DOM 树
                              解析 CSS  → CSSOM 树
                                    ↓
                              Render Tree(去掉不可见节点)
                                    ↓
┌───────────────────────────────────────────────────────────┐
│                    浏览器渲染流水线                         │
│                                                           │
│  Layout(重排)                                            │
│  触发条件:width/height/top/left/margin/display 等改变      │
│       ↓                                                   │
│  Paint(重绘)                                             │
│  触发条件:color/background/shadow/visibility 等改变        │
│       ↓                                                   │
│  Composite(合成)                                         │
│  所有操作最终都到这一步                                       │
│                                                           │
│  ✦ transform / opacity                                    │
│    → 元素提升为独立合成层,GPU 直接处理                        │
│    → 跳过 Layout 和 Paint,直达 Composite                   │
└───────────────────────────────────────────────────────────┘
                                    ↓
                               屏幕显示 🎉

延伸思考

梳理完这些,还有几个问题没完全搞清楚:

  1. 合成层的内存代价怎么量化? 什么情况下合成层的开销会超过它带来的性能收益,Chrome DevTools 里怎么观测?
  2. React 的批量更新(Batching)和浏览器的批量渲染是什么关系? React 18 的自动批处理,是不是某种程度上也在减少强制同步重排?
  3. CSS contain 属性是什么? 据说它可以把一个元素声明为"独立的渲染作用域",让重排影响范围收敛到局部——这个机制是怎么运作的?

🧠 面试常问版(核心记忆点)

5 条浓缩,面试前快速过一遍:

  1. React re-render ≠ 浏览器重排:React re-render 是 JS 层虚拟 DOM 的重新计算,浏览器重排是渲染引擎的布局重算。前者可能触发后者,但优化手段不同,不要混淆。
  2. 三层开销排序:重排(Reflow)> 重绘(Repaint)> 合成(Composite)。重排必触发重绘,重绘不必触发重排,合成跳过前两步。
  3. transform 快的原因:浏览器把 transform/opacity 的元素提升到独立合成层,由 GPU 直接处理矩阵变换,完全跳过 Layout 和 Paint。left/top 每帧都触发重排,transform 每帧只做合成——这是动画性能差异的根源。
  4. 强制同步重排陷阱:读取 offsetHeightgetBoundingClientRect() 等属性会强制浏览器立即执行重排。循环内交替读写 DOM = 每次循环触发一次重排。解决:先批量读,再批量写。
  5. will-change 的正确用法:提前声明元素将发生 transform 变化,让浏览器预先创建合成层。但合成层有内存开销,不要滥用,动画结束后用 will-change: auto 释放。

参考资料

虚拟 DOM 与 Diff 算法——React 性能优化的底层逻辑

用了两三年 React,我一直对"虚拟 DOM 更快"这个说法半信半疑。直到有一次优化一个长列表卡顿问题,才真正逼着自己把这套底层逻辑摸清楚。这篇是我的学习笔记,试图用具体例子把"为什么"和"怎么做"说清楚,而不是把概念堆在一起。


一、为什么需要虚拟 DOM?

先从"直接操作真实 DOM 有什么问题"聊起。

真实 DOM 操作慢在哪?

上一篇聊浏览器渲染时提到过,每次修改 DOM,浏览器都要重跑一遍渲染流水线:

修改 DOM → 重新计算样式 → Layout(重排)→ Paint(重绘)→ Composite

这个流水线本身没问题,问题在于频率。如果你有一个复杂页面,状态变化触发了 100 次 DOM 修改,流水线就要跑 100 次。每次都是真实的浏览器渲染工作,代价不低。

那"每次重新渲染整个页面"呢?

你可能会想:干脆每次状态变化,把整个页面 innerHTML 全部重写,不就省事了?

理论上是"最简单"的方案,但问题是:

  1. :重建整个 DOM 树,触发全量 Layout + Paint,比局部更新慢得多
  2. 丢失用户状态:用户正在输入的文本框内容会被清空、滚动位置跳回顶部、当前 focus 的元素失焦——体验直接崩掉

虚拟 DOM 要解决的,正是这两个问题之间的矛盾:既不想每次手动挑出要更新的 DOM 节点,又不想粗暴地全量重建。


二、虚拟 DOM 是什么?

虚拟 DOM(Virtual DOM)本质上就是用普通 JS 对象来描述 DOM 结构

操作真实 DOM 慢,但操作 JS 对象快得多(快几百倍)。所以 React 的思路是:先在内存里用 JS 对象"演练"要做的改动,算出最小改动集,再一次性更新到真实 DOM。

来看一个具体的对应关系:

<!-- 真实 DOM -->
<div class="card">
  <h1>标题</h1>
  <p>描述内容</p>
</div>
// 对应的虚拟 DOM(JS 对象)
{
  type: 'div',
  props: { className: 'card' },
  children: [
    {
      type: 'h1',
      props: {},
      children: ['标题']
    },
    {
      type: 'p',
      props: {},
      children: ['描述内容']
    }
  ]
}

React 的 JSX 语法,本质上就是在写这样的对象描述,只是换了一套更好看的语法糖。

// 你写的 JSX
const element = (
  <div className="card">
    <h1>标题</h1>
    <p>描述内容</p>
  </div>
);

// Babel 编译后,等价于
const element = React.createElement(
  'div',
  { className: 'card' },
  React.createElement('h1', null, '标题'),
  React.createElement('p', null, '描述内容')
);

三、虚拟 DOM 的工作流程

有了虚拟 DOM,React 的渲染流程变成了这样:

状态变化(setState / useState)
        ↓
生成新的虚拟 DOM 树
        ↓
与上一次的旧虚拟 DOM 树做 Diff(对比)
        ↓
找出差异部分(patch)
        ↓
只把差异更新到真实 DOM

核心价值只有一句话:最小化真实 DOM 操作次数

举个例子——一个有 1000 个节点的页面,某次状态变化只影响了其中 3 个节点。

方案 真实 DOM 操作次数
全量重建 1000 次
手动精准更新 3 次(但需要你自己写逻辑)
虚拟 DOM + Diff 3 次(自动计算)

虚拟 DOM 让你享受到了"手动精准更新"的性能,但不需要你自己写那些繁琐的 DOM 操作逻辑。


四、Diff 算法:如何高效比较两棵树?

现在问题来了:比较两棵树,算出最小改动,怎么做?

理论最优解有多慢?

计算机科学中,对比两棵树的最优算法复杂度是 O(n³)

100 个节点?10⁶ = 100 万次计算。 1000 个节点?10⁹ = 10 亿次计算

每次状态更新都跑 10 亿次操作,页面直接冻住。这个路走不通。

React 的解法:三个假设,换来 O(n)

React 选择了一个工程上的妥协:基于三个在实际开发中几乎总是成立的假设,把复杂度降到 O(n)。


假设 1:不同类型的节点,直接替换

如果一个节点从 <div> 变成了 <p>,React 不会试图比较它们的内部差异——直接销毁整棵旧树,重建新树。

// 旧的虚拟 DOM
<div>
  <input value="用户输入的内容" />
  <span>子元素</span>
</div>

// 新的虚拟 DOM(根节点类型变了)
<p>
  <input value="用户输入的内容" />
  <span>子元素</span>
</p>

这种情况下,React 会:

  1. 卸载整个 <div> 及其所有子节点(包括 input 里用户输入的内容)
  2. 重新挂载整个 <p>

所以如果你的根节点类型频繁切换,会造成不必要的子组件销毁重建。这个假设告诉我们:组件的根节点类型,能稳定就稳定


假设 2:只比较同层节点,不跨层级

React 的 Diff 是逐层对比的,不会尝试找跨层移动的节点。

旧树                    新树

    A                       A
   / \                     / \
  B   C        →          B   C
 / \                           \
D   E                           E

如果你把节点 D 从 B 的子节点移动到了 C 的子节点下,React 看到的是:

  • B 层:少了 D → 删除 D
  • C 层:多了 D → 新建 D

它不会识别出"这是同一个节点在移动",而是执行一次删除 + 一次创建。

这意味着:跨层级移动 DOM 节点,在 React 里代价比你想象的高。在实际组件设计中,尽量避免通过条件渲染在不同层级之间"搬运"同一个组件。


假设 3:用 key 识别列表节点

这是三个假设里和日常开发最紧密的一个。

当对比一组子节点(列表)时,如果没有 key,React 只能按顺序逐一对比:

// 旧列表
<ul>
  <li>张三</li>   // 位置 0
  <li>李四</li>   // 位置 1
  <li>王五</li>   // 位置 2
</ul>

// 在开头插入"赵六"后的新列表
<ul>
  <li>赵六</li>   // 位置 0
  <li>张三</li>   // 位置 1
  <li>李四</li>   // 位置 2
  <li>王五</li>   // 位置 3
</ul>

没有 key,React 按位置对比:位置 0 内容变了(张三→赵六)→ 更新;位置 1 内容变了 → 更新;位置 2 内容变了 → 更新;位置 3 是新增 → 新建。改了 4 个节点,实际上只是新增了 1 个。

有了 key,React 能识别出哪些节点是"同一个",从而准确复用:

<ul>
  <li key="zhaoliu">赵六</li>   // 新增
  <li key="zhangsan">张三</li>  // 复用,不更新
  <li key="lisi">李四</li>      // 复用,不更新
  <li key="wangwu">王五</li>    // 复用,不更新
</ul>

只做 1 次插入操作,剩下三个节点直接复用。


五、为什么不能用 index 做 key?

这是 React 开发中最经典的"坑"之一,我觉得有必要把例子说完整。

场景:删除列表项

初始列表 [张三, 李四, 王五],用 index 做 key:

// 初始状态
<ul>
  <li key={0}>张三</li>
  <li key={1}>李四</li>
  <li key={2}>王五</li>
</ul>

现在删除张三,列表变成 [李四, 王五]

// 删除后
<ul>
  <li key={0}>李四</li>  // key=0,内容从"张三"变成了"李四"
  <li key={1}>王五</li>  // key=1,内容从"李四"变成了"王五"
                          // key=2 消失 → 删除
</ul>

React 看到的是:

  • key=0:内容变了 → 更新
  • key=1:内容变了 → 更新
  • key=2:消失了 → 删除

结果:3 次 DOM 操作。但我们实际上只删了 1 个元素,只需要 1 次 DOM 操作


改用唯一 ID 做 key:

// 初始状态
<ul>
  <li key="zhangsan">张三</li>
  <li key="lisi">李四</li>
  <li key="wangwu">王五</li>
</ul>

// 删除后
<ul>
  <li key="lisi">李四</li>   // key 没变,内容没变 → 跳过
  <li key="wangwu">王五</li> // key 没变,内容没变 → 跳过
                              // key="zhangsan" 消失 → 删除
</ul>

React 准确识别出只有"zhangsan"消失了:1 次 DOM 操作,完全正确。


更严重的 bug:输入框状态错乱

上面的例子只是性能问题,但下面这个是功能 bug

场景:列表每一项有一个输入框,用户在第一项(张三)的输入框里填了内容,然后删除第一项。

// 每一项带输入框的组件
function ListItem({ name }) {
  return (
    <li>
      <span>{name}</span>
      <input placeholder={`备注 ${name}`} />
    </li>
  );
}

// 用 index 做 key
{list.map((item, index) => (
  <ListItem key={index} name={item.name} />
))}

删除"张三"后,React 对 key=0 做的是更新(把 name prop 改成"李四"),而不是销毁重建。

React 复用了原来"张三"那个 DOM 节点,只更新了 name 属性——但输入框是非受控的,它的内部状态(用户输入的内容)跟着 DOM 节点走,不跟着数据走。

结果:删掉张三之后,李四的输入框里还显示着刚才给张三写的备注内容。数据删了,UI 状态还留着

这种 bug 在测试环境容易被漏掉,到了生产环境才被用户发现,排查起来也很头疼。


结论

✅ 用数据的唯一 ID 做 key(数据库主键、UUID 等)
❌ 不用 index 做 key(除非列表永远不会增删排序)
❌ 不用随机数做 key(每次渲染都会强制重建,比没有 key 更差)

六、整体流程回顾

用户交互 / 数据请求
        ↓
setState / useState 触发更新
        ↓
React 调用 render,生成新的虚拟 DOM 树
        ↓
┌─────────────────────────────────────┐
│           Diff 算法(O(n))          │
│                                     │
│  类型不同?→ 直接替换                  │
│  只比同层  → 不跨层                   │
│  有 key?  → 精准识别复用              │
└─────────────────────────────────────┘
        ↓
生成最小 patch(差异集合)
        ↓
批量更新到真实 DOM
        ↓
浏览器渲染(只有变化的部分触发重排/重绘)

延伸思考

梳理完这些,我产生了几个新问题,暂时还没完全搞清楚:

  1. React Fiber 和虚拟 DOM 是什么关系? Fiber 架构是 React 16 引入的,它把虚拟 DOM 的 Diff 过程变成了可中断的,这对长列表渲染有什么具体影响?
  2. Vue 的 Diff 和 React 的 Diff 有什么区别? 听说 Vue 3 的双端对比算法在某些场景下效率更高,是什么原理?
  3. React.memouseMemo 和虚拟 DOM 的 Diff 是什么关系? 它们是在 Diff 之前就跳过了,还是 Diff 之后的优化?

这些可能是下一篇的方向,也欢迎有研究的朋友交流。


🧠 面试常问版(核心记忆点)

5 条浓缩,面试前快速过一遍:

  1. 虚拟 DOM 的本质:用 JS 对象描述 DOM 结构,在内存中做 Diff,最小化真实 DOM 操作次数,解决"全量重建"导致的慢和状态丢失问题。
  2. Diff 算法的三个假设:① 不同类型节点直接替换;② 只对比同层节点;③ 用 key 识别列表节点。三个假设把复杂度从 O(n³) 降到 O(n)。
  3. key 的作用:帮助 React 识别哪些节点是"同一个",从而在列表更新时准确复用,避免不必要的 DOM 操作。
  4. index 做 key 的两种问题:性能问题(删除头部节点会触发全量更新)+ 功能 bug(非受控组件的状态跟 DOM 节点走,不跟数据走,导致状态错乱)。
  5. key 的正确选择:用数据的唯一 ID(数据库主键、UUID 等),不用 index,不用随机数。

参考资料

从输入 URL 到页面显示——浏览器工作原理全解析

这篇文章的起因很朴素:被面试官问到"浏览器输入 URL 后发生了什么",我当时答得磕磕绊绊。事后复盘,发现自己其实每天都在和这条链路打交道,却从没认真梳理过它。所以这篇更多是我的学习笔记——不追求教科书式的完整,而是希望用对话感把每个概念说清楚。如果你也对这条链路模糊,欢迎一起往下读。


一、为什么要理解这条链路?

先说面试:这是前端面试里的"经典送命题"。问法很宽,可以从 DNS 聊到渲染,每个环节都能展开一个小时。

但比面试更重要的是:理解浏览器在帮你做什么

为什么 <script> 放底部?为什么 transformtop 流畅?为什么 HTTPS 比 HTTP 安全?这些问题的答案,都藏在这条链路里。

完整流程如下,我们逐段拆解:

URL 输入
  ↓
DNS 解析(域名 → IP)
  ↓
TCP 三次握手(建立连接)
  ↓
TLS 握手(HTTPS 加密,若有)
  ↓
HTTP 请求 / 响应
  ↓
浏览器解析渲染(HTML → 像素)
  ↓
页面显示

二、DNS 解析:找到服务器地址

域名是个"电话簿"

你输入的是 www.example.com,但网络层面真正认的是 IP 地址(比如 93.184.216.34)。域名只是给人看的别名。

DNS(Domain Name System)就是把域名翻译成 IP 的"电话簿"。

查询顺序:从近到远

浏览器不会每次都跑去问根服务器,它有一套缓存优先的查询链:

浏览器缓存
  ↓(没有?)
操作系统缓存(hosts 文件 / 系统 DNS 缓存)
  ↓(没有?)
路由器缓存
  ↓(没有?)
ISP(运营商)DNS 服务器
  ↓(没有?)
根域名服务器 → 顶级域服务器(.com)→ 权威域名服务器
  ↓
返回 IP 地址,逐层缓存

类比一下:你想找某个老同学的电话,你会先翻自己的手机通讯录,再问共同朋友,最后才去翻毕业纪念册。每一层都比下一层"近"。

TTL:为什么不能永久缓存?

DNS 记录带有 TTL(Time To Live,缓存有效期),过期后必须重新查询。

原因很简单:映射关系会变。比如网站迁移服务器,IP 换了,如果客户端永久缓存旧 IP,就再也找不到新服务器了。TTL 的存在,是在"缓存命中率"和"数据新鲜度"之间做权衡。


三、TCP 三次握手:建立可靠连接

找到 IP 之后,浏览器需要和服务器建立连接。HTTP 跑在 TCP 之上,而 TCP 是面向连接的协议——发数据之前,双方必须先"握手"确认线路通畅。

为什么是三次,不是两次或四次?

这是个很好的问题。我的理解是,三次握手需要确认三件事:

次序 方向 目的
第一次 客户端 → 服务器(SYN) 确认:客户端能发
第二次 服务器 → 客户端(SYN+ACK) 确认:服务器能收、能发
第三次 客户端 → 服务器(ACK) 确认:客户端能收

三次之后,双方都知道对方能收能发,通信信道建立完毕。

少一次(两次握手)的问题:客户端能收这件事没人确认,存在单向通道风险,且会引发"历史连接"问题(旧的延迟 SYN 包触发服务器建立无效连接)。

多一次没必要:四次就是冗余了,三次已经能确认所有需要确认的状态。

类比:打电话前的确认——"喂,你能听到我吗?"→"能,你能听到我吗?"→"能"。三句话,线路通畅,开始正式通话。


四、TLS 握手:加密 + 身份验证(HTTPS)

TCP 建好连接后,如果是 HTTPS,还要多一步:TLS 握手。

为什么需要它?

HTTP 是明文传输的。你发出去的每一个请求,路径上的任何节点(路由器、运营商、同一 WiFi 下的其他人)理论上都能看到完整内容。用户密码、信用卡号……全部裸奔。

TLS 解决了两个问题:

  • 身份验证:你连接的是真的 example.com,不是被人劫持的钓鱼站
  • 加密传输:内容只有你和服务器能读

握手流程(简化版)

1. 浏览器 → 服务器:我支持这些加密算法 [列表],给我你的证书

2. 服务器 → 浏览器:用这个算法,这是我的证书(含公钥)

3. 浏览器验证证书(向 CA 机构核实真实性)
   生成随机数,用服务器公钥加密后发过去

4. 服务器用私钥解密,得到随机数

5. 双方用这个随机数生成"会话密钥"(对称密钥)

6. 后续所有通信用会话密钥加密

两个角色分开理解

初学时我一直搞混"证书"和"加密",其实它们是两件事:

角色 类比 作用
证书 身份证 + 公证处盖章 证明"我真的是 example.com"
加密 双方约定的暗语本 保证通信内容只有双方能读

证书由 CA(证书颁发机构)签发,浏览器内置了受信任的 CA 列表。如果证书是自签名的或已过期,浏览器会弹出警告。

为什么不全程用公钥加密?

这是个常被忽略的细节。非对称加密(RSA)安全,但比对称加密(AES)慢约 100 倍

所以 TLS 的设计是:非对称加密只用于握手阶段安全交换密钥,真正的通信内容用对称密钥(AES)加密。兼顾了安全性和性能。

加密类型 代表算法 速度 用途
非对称加密 RSA、ECDH 密钥交换、签名
对称加密 AES 实际数据加密

TLS 管加密,Cookie 管身份

还有一个常见混淆点:TLS 建立的是加密信道,不等于"记住了你是谁"。

服务器如何区分不同用户?那是 HTTP 层面 Cookie / session_id 的事。TLS 每次连接都会重新握手(虽然有会话恢复机制),但识别"这个请求属于哪个用户",靠的是请求头里的 Cookie。


五、HTTP 请求与响应

握手完成,浏览器发出第一个 HTTP 请求:

GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: Mozilla/5.0 ...
Accept: text/html
Cookie: session_id=abc123
Cache-Control: no-cache

几个重要的请求头:

  • Host:告诉服务器你访问的是哪个域名(一台服务器可能托管多个域名)
  • Cookie:带上本地存储的会话标识
  • Cache-Control:告诉服务器/中间缓存怎么处理这个请求的缓存

服务器返回响应:

HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
Cache-Control: max-age=3600

<!DOCTYPE html>...

常见状态码速查:

状态码 含义 常见场景
200 成功 正常响应
301 永久重定向 域名迁移
304 内容未修改 使用本地缓存
404 资源不存在 路径错误
500 服务器内部错误 后端异常

六、浏览器解析:从 HTML 到像素

拿到 HTML 之后,浏览器开始做"最后一公里"的工作:把代码变成屏幕上的像素。这个过程叫关键渲染路径(Critical Rendering Path)

Step 1:解析 HTML → DOM 树

浏览器从上到下解析 HTML,构建 DOM(Document Object Model)树。DOM 是页面结构的内存表示,每个标签对应一个节点。

<!-- 这段 HTML -->
<body>
  <div class="container">
    <p>Hello</p>
  </div>
</body>

<!-- 对应的 DOM 树(简化) -->
body
  └── div.container
        └── p
              └── "Hello"

Step 2:下载并解析 CSS → CSSOM 树

并行下载 CSS 文件,解析生成 CSSOM(CSS Object Model)树。结构和 DOM 类似,但存的是样式信息。

关键阻塞规则

这里有个绕不开的问题,很多性能优化都源于此:

资源类型 阻塞什么 原因
CSS 阻塞渲染 没 CSSOM 就没法确定元素最终样式
JS(无属性) 阻塞HTML 解析 JS 可能操作 DOM,所以得等 JS 执行完
JS(defer 不阻塞解析 延迟到 HTML 解析完才执行
JS(async 下载不阻塞,执行阻塞 下载完立刻执行

这就是为什么 <script> 推荐放在 </body> 前,或者使用 defer:避免阻塞 HTML 解析,提升首屏速度。

Step 3:DOM + CSSOM → Render Tree

合并 DOM 和 CSSOM,生成只包含可见节点的 Render Tree(渲染树)。

注意:display: none 的元素不进入 Render Tree(它不占空间、不显示);但 visibility: hidden 的元素会进入(它仍然占位)。

Step 4:Layout(重排 / Reflow)

基于 Render Tree,计算每个节点的精确位置和尺寸——相对视口的坐标、宽高、边距……

这步代价较高。任何改变元素几何属性的操作(改 widthmarginposition)都会触发 Reflow,浏览器需要重新计算布局。

Step 5:Paint(重绘 / Repaint)

按照布局结果,把每个元素"画"出来:填充颜色、绘制边框、阴影、文字……

Step 6:Composite(合成)

浏览器把不同图层合并,最终送到屏幕显示。

这里有个重要的性能优化点:

/* 只触发 Composite,性能最好 */
.card {
  transform: translateY(-4px);
  opacity: 0.9;
}

/* 触发 Layout + Paint + Composite,代价最高 */
.card {
  top: -4px; /* 改变几何属性 */
}

transformopacity 的变化不影响布局,浏览器可以直接在 GPU 层面处理,跳过 Layout 和 Paint,性能最优。这就是为什么 CSS 动画推荐优先用 transform


七、整条链路总结

用户输入 URL
        │
        ▼
┌──────────────────┐
│   DNS 解析        │  域名 → IP(电话簿查询,逐层缓存)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  TCP 三次握手     │  确认双方能收发(SYN → SYN+ACK → ACK)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  TLS 握手(HTTPS)│  证书验证 + 交换会话密钥(非对称→对称)
└──────────────────┘
        │
        ▼
┌──────────────────┐
│  HTTP 请求/响应   │  发送 Request,接收 HTML/CSS/JS
└──────────────────┘
        │
        ▼
┌──────────────────────────────────────────┐
│             浏览器渲染流水线              │
│  HTML → DOM  ┐                           │
│              ├→ Render Tree → Layout     │
│  CSS → CSSOM ┘         → Paint → 合成   │
└──────────────────────────────────────────┘
        │
        ▼
      页面显示 🎉

延伸与发散

在梳理这条链路的过程中,我产生了一些新的疑问,记录在这里:

  1. HTTP/2 和 HTTP/3 对这条链路的影响是什么? HTTP/2 的多路复用是不是意味着 TCP 握手的成本被摊薄了?HTTP/3 基于 UDP 的 QUIC 协议又是如何处理可靠性的?
  2. Service Worker 如何介入这条链路? PWA 的离线缓存是在哪个环节"截胡"请求的?
  3. 浏览器的预加载机制<link rel="preconnect"><link rel="prefetch">)是在提前做哪几步?

这些可能会是后续文章的方向,也欢迎有经验的朋友交流。


🧠 面试常问版(核心记忆点)

如果只有 5 分钟时间,记住这 5 条:

  1. DNS:域名→IP,查询链是浏览器缓存→OS→路由→ISP→根服务器,TTL 控制缓存时效。
  2. TCP 三次握手:确认双方能收发,三次刚好,少一次有安全隐患,多一次冗余。
  3. TLS:证书验证身份,非对称加密只用于交换密钥,实际内容用 AES(对称)加密,快 100 倍。
  4. 渲染阻塞:CSS 阻塞渲染,JS 阻塞 HTML 解析,所以 <script> 放底部或用 defer
  5. 渲染性能transform/opacity 只触发合成层,跳过 Layout 和 Paint,动画优先使用。

参考资料

手撕发布订阅与观察者模式:从原理到实践

前言

在JavaScript异步编程和组件通信中,发布订阅模式和观察者模式是两种至关重要的设计模式。

它们都能实现对象间的一对多依赖关系,但实现方式截然不同。

本文将通过两道手撕面试题代码,深入剖析这两种模式的核心原理、实现方式,以及它们之间的本质区别。

一、题目 FED19 发布订阅模式

描述

请补全JavaScript代码,完成"EventEmitter"类实现发布订阅模式。 注意:

  1. 同一名称事件可能有多个不同的执行函数
  2. 通过"on"函数添加事件
  3. 通过"emit"函数触发事件
<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class EventEmitter {
                // 补全代码
                
            }
        </script>
    </body>
</html>

二 、发布订阅模式

发布/订阅模式的核心思想,是实现应用中那些彼此不相干的模块之间的轻松通信。

这种模式在 jQuery 插件生态和各类前端架构设计书籍中常有深入探讨,但需要说明的是,它并非 JavaScript 语言规范的一部分,所以在 MDN 等官方文档中并不会有直接的介绍。

原理

发布-订阅模式定义了一种一对多的依赖关系,当发布者(Publisher)对象的状态发生改变时,所有依赖它的订阅者(Subscriber)对象都会得到通知。它像一个“信息中介”,将消息的发送者和接收者彻底解耦,两者不需要知道对方的存在,只需要知道共同的“频道名称”。

它的工作原理可以拆解为以下几个角色:

  • 发布者 (Publisher):负责在特定“频道”上发送消息或事件,不关心谁会接收。
  • 订阅者 (Subscriber):负责订阅感兴趣的“频道”,并在频道有消息时执行相应的回调函数。
  • 事件调度中心 (Event Bus / PubSub):这是模式的核心,负责维护所有“频道”和订阅者的关系。它提供订阅(on / subscribe)、发布(emit / publish)、取消订阅(off / unsubscribe)等核心方法。

下面是一个极简的 JavaScript 实现:

// 创建一个事件中心 (Event Bus)
const eventHub = {
    // 用于存储事件和对应的回调函数
    topics: {},
    
    // 订阅方法
    subscribe: function(topic, listener) {
        if (!this.topics[topic]) this.topics[topic] = [];
        this.topics[topic].push(listener);
        
        // 返回一个可以用于取消订阅的函数
        return () => {
            const index = this.topics[topic].indexOf(listener);
            if (index !== -1) this.topics[topic].splice(index, 1);
        };
    },
    
    // 发布方法
    publish: function(topic, data) {
        if (!this.topics[topic]) return;
        this.topics[topic].forEach(listener => {
            listener(data);
        });
    }
};

// --- 使用示例 ---
// 模块A:订阅 'user-login' 事件
const unsubscribe = eventHub.subscribe('user-login', (userInfo) => {
    console.log(`模块A收到通知,用户 ${userInfo.name} 已登录。`);
});

// 模块B:发布 'user-login' 事件
eventHub.publish('user-login', { name: '张三' }); 
// 输出: 模块A收到通知,用户 张三 已登录。

// 当不再需要时,可以取消订阅
// unsubscribe(); 

经典实现

其实我觉得这个思想类似于浏览器的 addEventListener

浏览器 API 中的 window 对象上的事件机制,是发布-订阅模式的一种经典实现

DOM 事件系统(包括 window 上的事件)就是浏览器原生实现的、基于发布-订阅模式的事件架构

DOM 事件系统如何实现发布-订阅

让我们把浏览器的事件模型和标准的发布-订阅模式做个映射:

模式角色 DOM 事件系统中的对应实现 说明
事件调度中心 windowdocumentElement 等 DOM 节点 每个 DOM 节点都内置了事件管理能力
订阅 (Subscribe) addEventListener('eventName', callback) 订阅特定事件类型
发布 (Publish) 用户交互或代码触发:dispatchEvent(event)、点击等 触发事件,执行所有订阅的回调
取消订阅 (Unsubscribe) removeEventListener('eventName', callback) 移除事件监听,避免内存泄漏
事件通道 事件类型字符串,如 'click''resize''message' 类似发布-订阅中的"topic"

window 就是典型的事件总线

// ========== window 作为事件调度中心 ==========

// 1. 订阅 (Subscribe):监听一个自定义事件
window.addEventListener('user-logged-in', (event) => {
    console.log(`收到通知,用户 ${event.detail.name} 登录了`);
    // 可以触发任何行为
});

// 2. 发布 (Publish):在任意地方触发事件
function login() {
    // ... 登录逻辑 ...
    const customEvent = new CustomEvent('user-logged-in', {
        detail: { id: 1, name: '张三' }
    });
    window.dispatchEvent(customEvent);
}

// 3. 取消订阅 (Unsubscribe)
const handler = (event) => { console.log('只会执行一次'); };
window.addEventListener('once-event', handler);
// 不再需要时移除
window.removeEventListener('once-event', handler);

理解 window 事件是发布-订阅模式,对掌握浏览器 API 和设计模式有双重价值:

  1. 解释了很多原生 API 的行为

    • window.addEventListener('resize', handler) — 订阅窗口大小变化事件
    • window.addEventListener('online', handler) — 订阅网络状态变化
    • window.addEventListener('message', handler) — 订阅跨窗口消息(iframe 通信)
    • 这些都遵循同样的"先订阅、后触发、最后取消订阅"模式。
  2. 揭示了事件委托的原理: 由于事件会冒泡,在 windowdocument 上订阅一个事件,可以接收到任何子元素触发的事件。这正是利用了"一个调度中心可以接收所有发布"的特性。

    // 事件委托:在 window 上订阅,捕获所有点击
    window.addEventListener('click', (event) => {
        if (event.target.matches('.btn-delete')) {
            console.log('删除按钮被点击');
        }
    });
    

应用场景

理解原理后,更重要的是知道它在哪些场景下能真正派上用场。

  • 跨组件通信:在大型前端应用中,用于解决没有直接关系的组件(如兄弟组件、跨层级组件)之间的通信问题,可以避免通过父组件层层传递回调函数的麻烦。
  • 异步编程:在处理AJAX请求、图片加载、脚本加载等异步操作时,可以用发布-订阅模式来管理成功、失败、完成等不同状态下的回调,让代码更清晰。
  • 模块解耦:将一个复杂系统中的不同功能模块(如购物车、用户中心、商品展示)通过事件中心进行通信,可以显著降低模块间的直接依赖,使得各个模块可以独立开发、测试和维护。
  • MV 框架的底层实现*:Vue.js 中组件间的 $on / $emit 方法,本质上就是基于发布-订阅模式的实现。

注意事项

在使用这种模式时,有几个“坑”需要特别注意:

  • 内存泄漏:当一个组件或对象被销毁时,一定要记得调用 unsubscribeoff 方法,将它之前订阅的事件从事件中心移除。否则,事件中心的回调函数依然持有对已销毁对象的引用,导致其无法被垃圾回收,从而造成内存泄漏。
  • 过度使用:虽然模式好用,但过度使用会使应用中的数据流变得非常隐蔽和难以追踪。当一个事件的触发会引发一连串不可见的连锁反应时,代码的调试和维护会变得异常困难。对于简单的父子组件通信,直接传递 props 或调用方法仍是更清晰的选择。
  • 事件命名冲突:在大型项目中,事件名称容易重复,引发非预期的行为。建议使用一套清晰的命名规范,如 模块名:动作名(例如 user:login, cart:add)。

三、解法

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class EventEmitter {
                constructor (){
                    this.events = {};
                }

                on(eventName , callback ){
                    if (!this.events[eventName]){
                        this.events[eventName] = [] ;
                    }
                    this.events[eventName].push(callback);
                }

                emit(eventName , ...args){
                    const callbacks = this.events[eventName];
                    if (callbacks && callbacks.length){
                        callbacks.forEach(callback => {
                            callback(...args);
                        });
                    }
                }
                
            }
        </script>
    </body>
</html>

根据题目要求,我们需要实现一个 EventEmitter 类,支持:

  1. 同一名称事件可以有多个不同的执行函数
  2. on 方法添加事件监听
  3. emit 方法触发事件
class EventEmitter {
    constructor() {
        // 存储事件及其对应的回调函数列表
        this.events = {};
    }
    
    // 添加事件监听
    on(eventName, callback) {
        // 如果该事件还没有对应的回调数组,则初始化一个空数组
        if (!this.events[eventName]) {
            this.events[eventName] = [];
        }
        // 将回调函数添加到数组中
        this.events[eventName].push(callback);
    }
    
    // 触发事件
    emit(eventName, ...args) {
        // 获取该事件对应的回调函数列表
        const callbacks = this.events[eventName];
        // 如果存在回调函数,则依次执行
        if (callbacks && callbacks.length) {
            callbacks.forEach(callback => {
                callback(...args);
            });
        }
    }
}

使用示例

const emitter = new EventEmitter();

// 添加多个监听同一个事件
emitter.on('click', () => console.log('clicked 1'));
emitter.on('click', (msg) => console.log('clicked 2:', msg));
emitter.on('click', (msg) => console.log('clicked 3:', msg));

// 触发事件
emitter.emit('click', 'hello');
// 输出:
// clicked 1
// clicked 2: hello
// clicked 3: hello

代码说明

  1. constructor:初始化一个空对象 events 用于存储事件名和对应的回调函数数组

  2. on(eventName, callback)

    • 检查 events 对象中是否已存在该事件名的回调数组
    • 如果不存在,创建空数组
    • 将回调函数添加到数组中
  3. emit(eventName, ...args)

    • 获取该事件对应的回调函数数组
    • 如果存在,遍历数组并依次执行每个回调函数
    • 使用扩展运算符 ...args 将传入的参数传递给每个回调函数

这个实现满足题目的所有要求:支持同一事件的多个回调函数,通过 on 添加,通过 emit 触发。

四、题目 FED20 观察者模式

描述

请补全JavaScript代码,完成"Observer"、"Observerd"类实现观察者模式。要求如下:

  1. 被观察者构造函数需要包含"name"属性和"state"属性且"state"初始值为"走路"

  2. 被观察者创建"setObserver"函数用于保存观察者们

  3. 被观察者创建"setState"函数用于设置该观察者"state"并且通知所有观察者

  4. 观察者创建"update"函数用于被观察者进行消息通知,该函数需要打印(console.log)数据,数据格式为:小明正在走路。其中"小明"为被观察者的"name"属性,"走路"为被观察者的"state"属性

注意:"Observer"为观察者,"Observerd"为被观察者

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            // 补全代码
            class Observerd {
                
            }

            class Observer {
                
            }
        </script>
    </body>
</html>

五、观察者模式

在 JavaScript 中,经常会遇到一个问题:你需要一种方法来响应特定事件,并利用这些事件提供的数据来更新页面的某些部分。

例如,用户输入后,你需要将其应用到一个或多个组件中。这会导致代码中出现大量的推送和拉取操作,以保持所有内容的同步。

观察者模式正是在这种情况下发挥作用——它支持元素之间的一对多数据绑定。

这种单向数据绑定可以由事件驱动。借助这种模式,您可以构建可重用的代码,以满足您的特定需求。

核心概念

  • 被观察者(Observable):维护一组观察者,状态变化时自动通知它们
  • 观察者(Observer):订阅被观察者,当被通知时执行相应逻辑

被观察者的三个核心部分

EventObserver
│ 
├── subscribe: adds new observable events
│ 
├── unsubscribe: removes observable events
|
└── broadcast: executes all events with bound data
部分 作用
observers 数组,存储所有观察者
subscribe() 添加观察者
unsubscribe() 移除观察者
notify(data) 通知所有观察者

基础实现(ES6 Class)

class Observable {
  constructor() {
    this.observers = [];
  }
  subscribe(func) {
    this.observers.push(func);
  }
  unsubscribe(func) {
    this.observers = this.observers.filter(observer => observer !== func);
  }
  notify(data) {
    this.observers.forEach(observer => observer(data));
  }
}

观察者模式的实际应用

例如博客字数统计演示

创建一个博客文章输入框,系统自动统计字数。用户每次按键输入,都通过观察者模式触发同步更新。

  1. 观察者模式追踪文本区域的变化
  2. 字数统计实时显示在输入框下方
  3. 箭头函数实现单行事件绑定
  4. 广播事件驱动变更给所有订阅者

字数统计函数

const getWordCount = (text) => text ? text.trim().split(/\s+/).length : 0;

单元测试示例

// 准备
const blogPost = 'This is a blog \n\n  post with a word count.     ';

// 执行
const count = getWordCount(blogPost);

// 验证
assert.strictEqual(count, 9);

注:该函数能处理多种边界情况,包括换行、多个空格等。

DOM 集成步骤

  1. HTML 结构
<textarea id="blogPost" placeholder="Enter your blog post..." class="blogPost">
</textarea>
  1. JavaScript 实现
// 创建字数显示元素
const wordCountElement = document.createElement('p');
wordCountElement.className = 'wordCount';
wordCountElement.innerHTML = 'Word Count: <strong id="blogWordCount">0</strong>';
document.body.appendChild(wordCountElement);

// 创建观察者实例
const blogObserver = new EventObserver();

// 订阅更新
blogObserver.subscribe((text) => {
  const blogCount = document.getElementById('blogWordCount');
  blogCount.textContent = getWordCount(text);
});

// 绑定事件
const blogPost = document.getElementById('blogPost');
blogPost.addEventListener('keyup', () => blogObserver.broadcast(blogPost.value));

扩展:RxJS

RxJS 是一个库,它通过使用 observable 序列来编写异步和基于事件的程序。它提供了一个核心类型 Observable,附属类型 (Observer、 Schedulers、 Subjects) 和受 [Array#extras] 启发的操作符 (map、filter、reduce、every, 等等),这些数组操作符可以把异步事件作为集合来处理。

可以把 RxJS 当做是用来处理事件的 Lodash

ReactiveX 结合了 观察者模式迭代器模式使用集合的函数式编程,以满足以一种理想方式来管理事件序列所需要的一切。

RxJS 中用来解决异步事件管理的的基本概念是:

  • Observable (可观察对象): 表示一个概念,这个概念是一个可调用的未来值或事件的集合。
  • Observer (观察者): 一个回调函数的集合,它知道如何去监听由 Observable 提供的值。
  • Subscription (订阅): 表示 Observable 的执行,主要用于取消 Observable 的执行。
  • Operators (操作符): 采用函数式编程风格的纯函数 (pure function),使用像 mapfilterconcatflatMap 等这样的操作符来处理集合。
  • Subject (主体): 相当于 EventEmitter,并且是将值或事件多路推送给多个 Observer 的唯一方式。
  • Schedulers (调度器): 用来控制并发并且是中央集权的调度员,允许我们在发生计算时进行协调,例如 setTimeoutrequestAnimationFrame 或其他。

结合观察者模式 + 迭代器模式 + 函数式编程

示例:监听鼠标拖拽事件

merge(
  fromEvent(document, "mousedown").pipe(mapTo(false)),
  fromEvent(document, "mousemove").pipe(mapTo(true))
)
.pipe(sample(fromEvent(document, "mouseup")))
.subscribe(isDragging => console.log(isDragging));

优点

  • 关注点分离
  • 符合单一职责原则
  • 观察者和被观察者低耦合

缺点

  • 观察者过多时,通知所有观察者可能带来性能问题

六、解法

答案

<!DOCTYPE html>
<html>
    <head>
        <meta charset=utf-8>
    </head>
    <body>
    
        <script type="text/javascript">
            class Observerd {
                constructor(name){
                    this.name = name ;
                    this.state = '走路' ;
                    this.observers = [];
                }

                setObserver(observer){
                    this.observers.push (observer);
                }

                setState(state){
                    this.state = state ;
                    this.notifyAllObservers();
                }     

                notifyAllObservers(){
                    this.observers.forEach(observer => {
                        observer.update(this);
                    });
                }
            }

            class Observer {
                update(observed){
                    console.log(`${observed.name}正在${observed.state}`);
                }
            }
        </script>
    </body>
</html>

根据题目要求,需要实现观察者模式。

以下是补全的代码:

class Observerd {
    constructor(name) {
        this.name = name;
        this.state = "走路";
        this.observers = [];
    }
    
    setObserver(observer) {
        this.observers.push(observer);
    }
    
    setState(state) {
        this.state = state;
        this.notifyAllObservers();
    }
    
    notifyAllObservers() {
        this.observers.forEach(observer => {
            observer.update(this);
        });
    }
}

class Observer {
    update(observed) {
        console.log(`${observed.name}正在${observed.state}`);
    }
}

代码说明:

  1. Observerd(被观察者)类

    • constructor(name):构造函数接收name参数,初始化name属性、state属性(初始值为"走路")和observers数组(存储观察者)
    • setObserver(observer):添加观察者到observers数组
    • setState(state):更新state属性,并通知所有观察者
    • notifyAllObservers():遍历所有观察者,调用每个观察者的update方法,传入当前被观察者实例
  2. Observer(观察者)类

    • update(observed):接收被观察者对象,按照指定格式打印日志(如:"小明正在走路")

使用示例:

const observed = new Observerd("小明");
const observer = new Observer();

observed.setObserver(observer);
observed.setState("跑步"); // 控制台输出:小明正在跑步

七、总结

观察者模式与发布者-订阅者模式有何不同?

虽然两种模式都涉及一对多依赖关系,但关键区别在于主体(或发布者)与其观察者(或订阅者)之间的通信方式。

  • 在观察者模式中,主体直接通知其观察者。

  • 在发布-订阅模式中,发布者将通知发送到中介(或通道),然后由中介将通知推送给订阅者。

这种额外的抽象层使得通知过程更加灵活和可定制。

原文:

How does the Observer Pattern differ from the Publisher-Subscriber Pattern?

While both patterns involve one-to-many dependencies, the key difference lies in how the subject (or publisher) communicates with its observers (or subscribers). In the Observer Pattern, the subject directly notifies its observers. In the Publisher-Subscriber Pattern, the publisher sends notifications to a mediator (or channel), which then pushes the notifications to the subscribers. This extra level of abstraction allows for greater flexibility and customization of the notification process.

发布订阅模式 观察者模式
有没有中间人 有(事件中心) 没有(直接通知)
双方知不知道对方存在 不知道(通过事件名交流) 知道(被观察者存着观察者列表)
生活类比 微信群:发消息的人不知道谁在看 你订阅了某人的微博:他更新了主动推给你

说实话,这两题面试手撕代码题实际上背负了很多抽象概念,单独的内容也都可以抽出来好好讲讲,难度并不低。

对于初学者建议按这个顺序来:

  1. 先熟悉上面的代码(应付面试)
  2. 然后自己手敲 3 遍(不要复制粘贴)
  3. 再去看本文的"应用场景"部分(这时候才有共鸣)
  4. 最后再去理解 RxJS、优缺点这些进阶内容

我也是初次深入学习一下这些概念,信息量大得有点懵。

但是回头看看我实际写过的项目代码,很多已经用到了这些思想,只是当时没有注意到这个模式。不妨现在好好回头去整理整理。

限于个人写作,文中若有疏漏,还请不吝赐教。

参考文档

发布订阅模式 vs 观察者模式:它们真的是一回事吗?本文深入解析发布订阅与观察者模式的核心差异:发布订阅通过事件中心实现 - 掘金

Node.js EventEmitter | 菜鸟教程

events 事件触发器 | Node.js v24 文档

观察者模式 - JavaScript 设计模式

JavaScript Design Patterns: The Observer Pattern — SitePoint

❌