阅读视图

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

拥抱PostgreSQL支持UI配置化

前言

前阵子写的日志分析工具NginxPulse,自开源以来,已过去2周时间,目前GitHub已收获1.5k的star。收到了不少用户的反馈建议,花了点时间将这些问题都处理了下。

本文就跟大家分享下新版本都解决了哪些问题,优化了哪些内容,欢迎各位感兴趣的开发者阅读本文。

抛弃SQLite

有不少用户反馈说日志文件很大的时候(10G+),解析速度非常慢,需要解析好几个小时,解析完成之后数据看板的查询也比较慢(接口响应在5秒左右)。

于是,我重写了日志解析策略(解析阶段不做IP归属地查询,仅入库其他数据,将日志中IP记录起来),日志解析完毕后,将记录的IP做去重处理,随后去做归属地的查询处理(优先本地的ip2region库,远程的API调用查询做兜底),最后将解析到的归属地回填至对应的数据库表中,这样一套下来就可以大大提升日志的解析速度。

数据库的数据量大了之后,SQLite的表现就有点差强人意了,请教了一些后端朋友,他们给了我一些方案,结合我自身的实际场景后,最后选定了PostgreSQL作为新的数据库选型。

这套方案落地后,用户群的好兄弟说:他原先需要解析1个小时的日志,新版只需要10多分钟。

6c1c8781ddb810d57c9f508fdaf47025

UI配置可视化使用

有一部分用户反馈说他非专业人士,这些晦涩的配置对他来说使用门槛太高了,希望能有一个UI配置页面,他只需要点一点、敲敲键盘,就能完成这些配置。

我将整个配置流程做成了4步,同时也准备一个演示视频 - www.bilibili.com/video/BV1hq…

  • 配置站点
  • 配置数据库
  • 配置运行参数
  • 确认最终配置

image-20260125235847464

新增wiki文档

因为配置过于庞大,仓库主页浏览README.md比较费劲,希望能整理一份wiki文档发上去。

花了点时间,简化了下README,整理了一份:github.com/likaia/ngin…

image-20260126000555725

访问明细模块优化

有部分用户反馈说希望增加更多的筛选条件以及导出Excel功能,现在它来了:

image-20260126001010068

概况模块优化

概况页面的日期筛选之前放在趋势分析卡片的上方,但是他的切换影响的维度还包含了指标,于是我就调整了下它的位置,新版如下图所示:

image-20260126001325265

项目地址

写在最后

至此,文章就分享完毕了。

我是神奇的程序员,一位前端开发工程师。

如果你对我感兴趣,请移步我的个人网站,进一步了解。

Button Pattern 详解

Button Pattern 详解:构建无障碍按钮组件

按钮是 Web 界面中最基础的交互元素之一,它让用户能够触发特定的操作或事件,如提交表单、打开对话框、取消操作或执行删除操作。根据 W3C WAI-ARIA Button Pattern 规范,正确实现的按钮组件不仅要具备良好的视觉效果,更需要确保所有用户都能顺利使用,包括依赖屏幕阅读器等辅助技术的用户。本文将深入探讨 Button Pattern 的核心概念、实现要点以及最佳实践。

一、按钮的定义与核心功能

按钮是一个允许用户触发动作或事件的界面组件。从功能角度来看,按钮执行的是动作而非导航,这是按钮与链接的本质区别。常见的按钮功能包括:提交表单数据、打开对话框窗口、取消正在进行的操作、删除特定内容等。一个设计良好的按钮应当让用户清晰地感知到点击它将产生什么效果,这种可预期性是良好用户体验的重要组成部分。

在实际开发中,有一个广为接受的约定值得注意:如果按钮的操作会打开一个对话框或其他需要进一步交互的界面,应该在按钮标签后加上省略号(...)来提示用户。例如,**保存为...**这样的标签能够告诉用户,点击这个按钮后会弹出额外的对话框需要填写。这种细节虽然看似微小,却能显著提升用户对界面行为的理解。

二、按钮的三种类型

WAI-ARIA 规范支持三种类型的按钮,每种类型都有其特定的用途和实现要求。理解这三种类型的区别对于构建正确无障碍的界面至关重要。

2.1 普通按钮

普通按钮是最常见的按钮类型,它执行单一的操作而不涉及状态的切换。提交表单的提交按钮、触发某个动作的执行按钮都属于这一类别。普通按钮在激活时会执行预定义的操作,操作完成后通常会根据操作的性质决定焦点的移动位置。例如,打开对话框的按钮在激活后,焦点应移动到对话框内部;而执行原地操作的按钮则可能保持焦点在原位。

<button type="submit">提交表单</button>

2.2 切换按钮

切换按钮是一种具有两种状态的按钮,可以处于未按下已按下的状态。这种按钮通过 aria-pressed 属性向辅助技术传达其当前状态。例如,音频播放器中的静音按钮就可以实现为切换按钮:当声音处于静音状态时,按钮的 aria-pressed 值为 true;当声音正常播放时,该值为 false。

实现切换按钮时有一个关键原则需要牢记:按钮的标签在状态改变时不应发生变化。无论按钮是处于按下还是未按下状态,其可访问名称应该保持一致。屏幕阅读器用户依赖这个稳定的标签来理解按钮的功能。如果设计要求在状态改变时显示不同的文本,那么就不应使用 aria-pressed 属性,而是应该通过其他方式传达状态变化。

<button
  type="button"
  aria-pressed="false"
  id="muteButton">
  静音
</button>

<script>
  muteButton.addEventListener('click', function () {
    const isMuted = this.getAttribute('aria-pressed') === 'true';
    this.setAttribute('aria-pressed', !isMuted);
  });
</script>

2.3 菜单按钮

菜单按钮是一种特殊的按钮,点击后会展开一个菜单或其他弹出式界面。根据 WAI-ARIA 规范,通过将 aria-haspopup 属性设置为 menu 或 true,可以将按钮向辅助技术揭示为菜单按钮。这种按钮在用户界面中非常常见,例如许多应用中的文件菜单、编辑菜单等。

菜单按钮的实现需要遵循菜单模式的相关规范,确保用户能够通过键盘导航菜单项,屏幕阅读器能够正确播报菜单状态,视觉用户能够清晰地看到菜单的展开和收起状态。正确实现的菜单按钮应当提供平滑的用户体验,无论用户使用何种输入方式或辅助技术。

<button
  type="button"
  aria-haspopup="menu"
  id="fileMenu">
  文件
</button>

三、键盘交互规范

键盘可访问性是 Web 无障碍设计的核心要素之一。按钮组件必须支持完整的键盘交互,确保无法使用鼠标的用户也能顺利操作。根据 Button Pattern 规范,当按钮获得焦点时,用户应能通过以下按键与按钮交互:

空格键和回车键是激活按钮的主要方式。当用户按下空格键或回车键时,按钮被触发执行其预定义的操作。这个设计遵循了用户对表单控件的既有认知,与传统桌面应用的交互模式保持一致。

按钮激活后焦点的处理需要根据具体情境来决定,这是实现良好键盘体验的关键。如果按钮打开了一个对话框,焦点应移动到对话框内部,通常是对话框的第一个可聚焦元素或默认焦点元素。如果按钮关闭了对话框,焦点通常应返回到打开该对话框的按钮,除非对话框中的操作逻辑上应该导致焦点移动到其他位置。例如,在确认删除操作的对话框中点击确认后,焦点可能会移动到页面上的其他相关元素。

对于不会关闭当前上下文的按钮(如应用按钮、重新计算按钮),激活后焦点通常应保持在原位。如果按钮的操作表示上下文将要发生变化(如向导中的下一步),则应将焦点移动到该操作的起始位置。对于通过快捷键触发的按钮,焦点通常应保持在触发快捷键时的上下文中。

四、WAI-ARIA 角色、状态和属性

正确使用 WAI-ARIA 属性是构建无障碍按钮组件的技术基础。虽然语义化的 HTML 按钮元素(button)本身已经具备正确的角色和基本行为,但在某些情况下需要使用自定义实现或 ARIA 属性来增强可访问性。

角色声明是基础要求。按钮元素的 role 属性应设置为 button,向辅助技术表明这是一个按钮组件。对于使用 button 这样的原生 HTML 元素,浏览器会自动处理角色声明,无需开发者手动添加。

示例:使用 div 元素模拟按钮时需要添加 role="button":

<div
  role="button"
  tabindex="0"
  onclick="handleClick()">
  提交
</div>

可访问名称是按钮最重要的可访问性特征之一。按钮必须有可访问的名称,这个名称可以通过多种方式提供:按钮内部的文本内容是最常见的来源;在某些情况下,可以使用 aria-labelledby 引用页面上的其他元素作为标签;或者使用 aria-label 直接提供标签文本。屏幕阅读器用户主要依赖这个名称来理解按钮的功能。

示例 1:使用 aria-labelledby 引用其他元素作为标签:

<h2 id="save-heading">保存设置</h2>
<button
  role="button"
  aria-labelledby="save-heading">
  图标
</button>

示例 2:使用 aria-label 直接提供标签文本:

<button
  aria-label="关闭对话框"
  onclick="closeDialog()">
  ×
</button>

描述信息可以通过 aria-describedby 属性关联。如果页面上存在对按钮功能的详细描述说明,应将描述元素的 ID 赋给这个属性,辅助技术会在播报按钮名称后继续播报描述内容。

示例:使用 aria-describedby 提供详细描述:

<button aria-describedby="delete-warning">删除</button>
<p id="delete-warning">此操作无法撤销,将永久删除所选数据。</p>

禁用状态需要正确使用 aria-disabled 属性。当按钮的关联操作不可用时,应设置 aria-disabled="true"。这个属性向辅助技术传达按钮当前处于禁用状态,用户无法与之交互。需要注意的是,对于原生 HTML button 元素,应使用 disabled 属性而非 aria-disabled。

示例:使用 aria-disabled 禁用非原生按钮:

<div
  role="button"
  tabindex="-1"
  aria-disabled="true"
  aria-label="保存">
  保存
</div>

切换状态使用 aria-pressed 属性来传达,这个属性只用于实现为切换按钮的组件。属性值应为 true(按下状态)、false(未按下状态)或 mixed(部分选中状态,用于三态树节点等场景)。

示例:使用 aria-pressed 实现切换按钮:

<button
  type="button"
  aria-pressed="false"
  id="toggleBtn"
  onclick="toggleState()">
  夜间模式
</button>

五、按钮与链接的区别

在 Web 开发中,一个常见的混淆点是何时应该使用按钮,何时应该使用链接。这两者的功能定位有着本质的区别,理解这个区别对于构建语义正确的页面至关重要。

按钮用于触发动作,如提交表单、打开对话框、执行计算、删除数据等。这些操作会产生副作用,改变应用的状态或数据。链接用于导航,将用户带到另一个页面、页面的不同位置或不同的应用状态。链接的本质是超文本引用,它告诉用户这里有你可能感兴趣的另一个资源

从技术实现角度,这个区别直接影响了可访问性。屏幕阅读器对按钮和链接的播报方式不同,用户会根据这些提示形成对界面功能的预期。如果一个元素看起来像链接(蓝色下划线文本)但点击后执行的是按钮的动作(提交表单),会给用户造成困惑。即使出于设计考虑必须使用这种视觉与功能的组合,也应通过 role="button" 属性明确告诉辅助技术这个元素的真实功能,避免给依赖辅助技术的用户带来困惑。

更好的做法是调整视觉设计,使其与功能保持一致。如果某个元素执行的是动作,就应该看起来像一个按钮;如果用户需要被导航到新页面,就应该使用标准的链接样式。这种设计上的统一能够减少所有用户的认知负担。

六、其他示例

以下是一个常见按钮场景的实现示例——打开对话框的按钮,展示了如何正确应用 Button Pattern 规范。

使用 HTML 原生 <dialog> 元素配合按钮实现对话框功能:

<button
  type="button"
  aria-haspopup="dialog"
  aria-expanded="false"
  id="openDialog">
  设置...
</button>

<dialog id="settingsDialog">
  <form method="dialog">
    <label> <input type="checkbox" /> 启用通知 </label>
    <button value="confirm">确定</button>
  </form>
</dialog>

<script>
  const dialog = document.getElementById('settingsDialog');
  const openBtn = document.getElementById('openDialog');

  openBtn.addEventListener('click', () => {
    dialog.showModal();
    openBtn.setAttribute('aria-expanded', 'true');
  });

  dialog.addEventListener('close', () => {
    openBtn.setAttribute('aria-expanded', 'false');
  });
</script>

当按钮会打开对话框时,使用省略号提示用户后面还有额外交互。aria-haspopup 表明按钮会弹出内容,aria-expanded 用于传达弹出内容的当前状态。

七、CSS 伪类与交互样式

以下 CSS 伪类可用于增强按钮的键盘交互体验:

/* Tab 键导航到按钮时显示焦点框 */
button:focus {
  outline: 2px solid blue;
  outline-offset: 2px;
}

/* 仅键盘焦点显示样式,鼠标点击不显示 */
button:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

/* 空格键或回车键按下时的样式 */
button:active {
  transform: scale(0.98);
}

/* 鼠标悬停效果(可选,增强视觉反馈) */
button:hover {
  opacity: 0.9;
}

/* Tab + Space 组合键激活样式(需 JS 添加类) */
button.keyboard-active {
  transform: scale(0.95);
  background-color: oklch(from currentColor 0.8);
}

/* Tab + Enter 组合键激活样式(需 JS 添加类) */
button.keyboard-enter {
  transform: scale(0.95);
  background-color: oklch(from currentColor 0.8);
}

各伪类说明:

伪类 触发方式 用途
:focus Tab 键/鼠标点击 元素获得焦点时
:focus-visible 仅键盘 Tab 仅键盘焦点显示,避免鼠标点击时出现框
:active 按下空格/回车/鼠标 元素被激活时
:hover 鼠标悬停 鼠标悬停时的视觉反馈

7.1 组合键交互示例

CSS 本身无法直接检测组合键,但可以通过 JavaScript 增强体验:

<button id="submitBtn">提交</button>

<style>
  /* Tab + Space 激活状态 */
  button.space-pressed {
    transform: scale(0.95);
    box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
  }

  /* Tab + Enter 激活状态 */
  button.enter-pressed {
    transform: scale(0.95);
    background-color: oklch(from var(--btn-bg, currentColor) 0.8);
  }
</style>

<script>
  const btn = document.getElementById('submitBtn');

  // Tab + Space 组合键
  btn.addEventListener('keydown', (e) => {
    if (e.key === ' ' && e.target === document.activeElement) {
      btn.classList.add('space-pressed');
    }
    if (e.key === 'Enter' && e.target === document.activeElement) {
      btn.classList.add('enter-pressed');
    }
  });

  btn.addEventListener('keyup', (e) => {
    if (e.key === ' ') {
      btn.classList.remove('space-pressed');
    }
    if (e.key === 'Enter') {
      btn.classList.remove('enter-pressed');
    }
  });
</script>

组合键说明:

组合键 效果 触发元素
Tab + Space 聚焦并激活按钮 <button>
Tab + Enter 聚焦并触发按钮 <button><div role="button">

原生 HTML 按钮的行为:

  • <button>:Tab 聚焦后按 Space/Enter 都会触发点击
  • <div role="button">:需要额外 JS 处理 Space 键

八、总结

构建无障碍的按钮组件需要关注多个层面的细节。从视觉设计角度,按钮应该让用户清晰地感知到它是一个可交互的元素;从键盘交互角度,必须支持空格键和回车键的激活操作;从 ARIA 属性角度,需要正确使用角色、状态和属性来传达组件的语义和当前状态。

按钮与链接的功能区分是 Web 语义化的基础之一,遵循这个原则不仅有助于辅助技术用户理解页面结构,也能提升所有用户的使用体验。在实际开发中,优先使用语义化的原生 HTML 元素,只有在必要时才考虑使用自定义实现,并确保为这些实现添加完整的无障碍支持。

WAI-ARIA Button Pattern 为我们提供了清晰的指导方针,将这些规范内化为开发习惯,能够帮助我们创建更加包容和易用的 Web 应用。每一个正确实现的按钮组件,都是构建无障碍网络环境的重要一步。

JavaScript对象的精髓与哲学思考——解析《JavaScript语言精粹》第三章

JavaScript对象的精髓与哲学思考——解析《JavaScript语言精粹》第三章

引言:为什么对象是JavaScript的灵魂?

在编程语言的世界中,JavaScript的对象系统独树一帜。它既不像Java/C++基于严格的类继承,也不像纯函数式语言完全依赖不可变数据。Douglas Crockford在《JavaScript语言精粹》第三章开篇即指出:“JavaScript的简单类型包括数字、字符串、布尔值、null和undefined,其他所有值都是对象。”这一论断揭示了对象在JavaScript中的核心地位。本文将深入解析JavaScript对象的本质特性、设计哲学及实践应用,并结合现代前端开发场景进行延伸思考。


一、对象字面量:JavaScript最优雅的创造

1.1 无类别的对象系统

与传统面向对象语言不同,JavaScript的对象系统是“无类别”(class-free)的。这意味着对象可以直接从其他对象继承属性,无需通过类作为中介。这种设计带来了极大的灵活性:

javascript
javascript
下载
复制
// 直接通过对象字面量创建对象
const person = {
  name: '张三',
  age: 30,
  greet() {
    return `你好,我是${this.name}`;
  }
};

这种简洁的语法不仅减少了代码量,更符合人类对现实世界的直观认知。当我们描述一个“人”时,自然会想到他具有姓名、年龄等属性,以及打招呼等行为,而无需先定义“人类”这个抽象概念。

1.2 动态性的双刃剑

JavaScript对象在运行时可以动态添加或修改属性:

javascript
javascript
下载
复制
const obj = {};
obj.newProperty = '动态添加的属性'; // 合法的JavaScript代码

这种动态性既是优势也是陷阱。优势在于它允许灵活的数据结构演化,特别适合处理JSON API响应等不确定数据结构;陷阱在于它可能导致难以追踪的bug,比如属性名拼写错误不会抛出异常,而是静默创建新属性。


二、原型链:JavaScript的继承哲学

2.1 原型继承的本质

每个JavaScript对象都连接到一个原型对象,并从中继承属性。这种机制不同于类继承的复制模式,而是通过委托(delegation)实现:

javascript
javascript
下载
复制
// 原型继承示例
const animal = { eats: true };
const rabbit = Object.create(animal);
console.log(rabbit.eats); // true,通过原型链访问

Crockford强调:“原型连接在更新时不起作用。”这意味着对子对象的修改不会影响原型,这种设计保证了数据的隔离性,避免意外的副作用。

2.2 原型链的实践应用

在现代JavaScript开发中,原型链的理解至关重要:

  • 性能优化:通过原型共享方法,减少内存占用
  • polyfill实现:通过修改内置对象的原型提供新功能
  • 框架设计:Vue/React等框架利用原型机制实现组件继承

三、反射与枚举:探索对象的内在结构

3.1 安全的类型检查策略

JavaScript的typeof操作符在对象检测上存在局限:

javascript
javascript
下载
复制
typeof null // "object" (语言设计缺陷)
typeof [] // "object" (无法区分数组)

更可靠的做法是组合使用Object.prototype.toString

javascript
javascript
下载
复制
Object.prototype.toString.call(null) // "[object Null]"
Object.prototype.toString.call([]) // "[object Array]"

3.2 属性枚举的最佳实践

for...in循环会遍历原型链上的可枚举属性,这可能导致意外行为:

javascript
javascript
下载
复制
const obj = { a: 1, b: 2 };
Object.prototype.customMethod = function() {};

for (let key in obj) {
  console.log(key); // 输出 a, b, customMethod(不符合预期)
}

安全的做法是使用hasOwnProperty过滤:

javascript
javascript
下载
复制
for (let key in obj) {
  if (obj.hasOwnProperty(key)) {
    console.log(key); // 只输出 a, b
  }
}

四、减少全局污染:模块化思维的早期实践

4.1 命名空间模式

在ES6模块化标准之前,Crockford就提出了通过单一全局变量避免命名冲突:

javascript
javascript
下载
复制
// 创建应用命名空间
const MYAPP = {
  utils: {},
  models: {},
  views: {}
};

这种模式虽然简单,却体现了模块化思维的核心——通过作用域隔离减少耦合。

4.2 与现代模块化的对比

当今的ES6模块系统可以看作这种思想的官方实现:

javascript
javascript
下载
复制
// 现代模块化
import { utils } from './myapp.js';

理解命名空间模式有助于我们更好地理解模块化的本质,即使在webpack等工具普及的今天,这种基础思维仍然有价值。


五、对象设计的哲学思考

5.1 数据与行为的统一

JavaScript对象将数据和行为统一封装,这与现实世界的物体特性相符。一个“杯子”既有颜色(数据属性),又能盛水(方法),这种统一性使得代码更易于理解。

5.2 最小接口原则

Crockford提倡的对象设计遵循最小接口原则——只暴露必要的属性和方法。这种设计减少耦合,提高可维护性,与后来的接口隔离原则(ISP)不谋而合。


六、现代JavaScript中的对象演进

6.1 ES6后的语法增强

虽然Crockford的著作基于ES3,但现代JavaScript的对象语法已大幅进化:

javascript
javascript
下载
复制
// 属性简写
const name = '李四';
const person = { name }; // 等同于 { name: name }

// 方法简写
const obj = {
  method() { /* ... */ } // 优于 method: function() {}
};

// 计算属性名
const propName = 'age';
const person = {
  [propName]: 30 // 动态属性名
};

6.2 不可变数据趋势

随着函数式编程的兴起,不可变对象模式日益重要:

javascript
javascript
下载
复制
// 通过Object.freeze实现浅不可变
const immutableObj = Object.freeze({ value: 1 });

虽然这超出了原书范围,但体现了JavaScript对象系统的发展方向。


结论:对象的艺术与科学

JavaScript对象系统既是科学也是艺术。其科学体现在严谨的原型机制和语言规范,艺术则体现在它给予开发者的创造自由。Crockford在第三章中传递的核心思想是:理解对象的本质比掌握特定语法更重要。

在当今复杂的前端应用中,对象仍然是构建复杂系统的基石。无论是React的组件状态、Vue的响应式数据,还是Node.js的模块系统,都建立在JavaScript对象系统之上。掌握对象的精髓,意味着我们不仅学会了使用一种语法特性,更获得了一种组织代码的思维方式。

正如Crockford所言:“对象适合用于收集和管理数据。”但更重要的是,对象是我们表达业务逻辑、构建软件架构的基本语言。在微服务、云原生等技术变革的背景下,这种基础能力显得愈发珍贵。

【节点】[NormalVector节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

在Unity的Shader Graph中,NormalVector节点是一个基础且重要的工具,它允许着色器访问网格的法线矢量信息。法线矢量在计算机图形学中扮演着关键角色,它定义了表面的朝向,是光照计算、材质表现和各种视觉效果的基础。

节点概述

NormalVector节点为着色器编写者提供了获取网格法线数据的便捷途径。无论是顶点法线还是片元法线,这个节点都能让开发者轻松地在不同的坐标空间中操作这些数据。通过简单的参数设置,就可以将法线矢量转换到所需的坐标空间,大大简化了复杂着色器的开发过程。

法线矢量的本质是垂直于表面的单位向量,在三维空间中表示为(x, y, z)坐标。在Shader Graph中,这些数据通常来自3D模型的顶点数据,或者通过法线贴图等技术进行修改和增强。

参数详解

Space参数

Space参数决定了法线矢量输出的坐标空间,这是NormalVector节点最核心的功能。不同的坐标空间适用于不同的着色场景和计算需求。

  • Object空间:也称为模型空间,这是法线数据最原始的存储空间。在Object空间中,法线相对于模型本身的坐标系定义,不考虑模型的旋转、缩放或平移变换。当模型发生变换时,Object空间中的法线不会自动更新,需要手动进行相应的变换计算。
  • View空间:也称为相机空间或眼睛空间,在这个空间中,所有坐标都是相对于相机的位置和方向定义的。View空间的原点通常是相机的位置,Z轴指向相机的观察方向。这个空间特别适合与视角相关的效果,如边缘光、反射和折射。
  • World空间:World空间中的坐标是相对于场景的世界坐标系定义的。无论模型如何移动或旋转,World空间提供了统一的参考框架。这个空间常用于光照计算、阴影生成和全局效果。
  • Tangent空间:这是一个特殊的局部空间,主要用于法线贴图。在Tangent空间中,法线是相对于表面本身定义的,Z轴与表面法线对齐,X轴与切向量对齐,Y轴与副法线对齐。这种表示方法使得法线贴图可以在不同朝向的表面上重复使用。

选择正确的坐标空间对着色器的正确性和性能至关重要。错误的空间选择可能导致光照计算错误、视觉效果异常或性能下降。

端口信息

NormalVector节点只有一个输出端口:

  • Out:输出类型为Vector 3,表示三维矢量。这个端口输出的是根据Space参数选择在对应坐标空间中的法线矢量。输出值通常是归一化的单位矢量,但在某些情况下(如使用非统一缩放时)可能需要重新归一化。

使用场景与示例

基础光照计算

法线矢量的一个主要应用是光照计算。在Lambert光照模型中,表面亮度取决于光线方向与表面法线之间的夹角。

HLSL

// 简化的Lambert光照计算
float3 lightDir = normalize(_WorldSpaceLightPos0.xyz);
float3 worldNormal = NormalVector节点输出(World空间);
float NdotL = max(0, dot(worldNormal, lightDir));
float3 diffuse = _LightColor0 * NdotL;

在这个示例中,我们首先获取世界空间中的法线矢量和光线方向,然后计算它们的点积。点积结果决定了表面接收到的光照强度,这是大多数基础光照模型的核心计算。

法线贴图应用

法线贴图是现代实时渲染中增强表面细节的关键技术。NormalVector节点在应用法线贴图时起着桥梁作用。

HLSL

// 法线贴图应用流程
float3 tangentNormal = tex2D(_NormalMap, uv).xyz * 2 - 1; // 从[0,1]转换到[-1,1]
float3 worldNormal = NormalVector节点输出(World空间);
// 使用TBN矩阵将切线空间法线转换到世界空间
float3x3 TBN = float3x3(
    IN.tangent.xyz,
    cross(IN.normal, IN.tangent.xyz) * IN.tangent.w,
    IN.normal
);
float3 mappedNormal = mul(TBN, tangentNormal);

这个示例展示了如何将切线空间中的法线贴图数据转换到世界空间。首先从法线贴图中采样并调整数值范围,然后使用TBN(切线-副切线-法线)矩阵进行空间转换。

边缘检测与轮廓光

利用View空间中的法线可以创建各种与视角相关的效果,如边缘光和轮廓检测。

HLSL

// 边缘光效果
float3 viewNormal = normalize(mul((float3x3)UNITY_MATRIX_V, NormalVector节点输出(World空间)));
float3 viewDir = normalize(UnityWorldToViewPos(IN.worldPos));
float rim = 1 - abs(dot(viewNormal, viewDir));
float rimLight = pow(rim, _RimPower) * _RimIntensity;

在这个示例中,我们首先将世界空间法线转换到View空间,然后计算法线与视角方向的点积。当表面几乎垂直于视角方向时(即边缘处),点积接近0,从而产生边缘光效果。

环境遮挡与全局光照

法线信息对于环境遮挡和全局光照计算也至关重要。

HLSL

// 简化的环境遮挡
float3 worldNormal = NormalVector节点输出(World空间);
float ambientOcclusion = 1.0;

// 基于法线方向的简单环境光遮蔽
// 这里可以使用更复杂的算法,如SSAO或烘焙的AO贴图
ambientOcclusion *= (worldNormal.y * 0.5 + 0.5); // 模拟顶部光照更多

// 应用环境光
float3 ambient = UNITY_LIGHTMODEL_AMBIENT * ambientOcclusion;

这个简单的示例展示了如何用法线方向来模拟环境光遮蔽效果。在实际项目中,通常会结合更复杂的算法或预计算的数据。

高级应用技巧

法线重定向与混合

在某些情况下,需要将法线从一个表面重定向到另一个表面,或者在不同法线源之间进行混合。

HLSL

// 法线混合示例
float3 normalA = tex2D(_NormalMapA, uv).xyz;
float3 normalB = tex2D(_NormalMapB, uv).xyz;
float blendFactor = _BlendFactor;

// 使用线性插值混合法线
float3 blendedNormal = lerp(normalA, normalB, blendFactor);

// 或者使用更精确的球面线性插值
// float3 blendedNormal = normalize(lerp(normalA, normalB, blendFactor));

法线混合是一个复杂的话题,因为简单的线性插值可能不会保持法线的单位长度。在实际应用中,可能需要重新归一化或使用更高级的插值方法。

法线空间转换优化

在性能关键的场景中,法线空间转换可能需要优化。

HLSL

// 优化的世界空间法线计算
// 传统方法
float3 worldNormal = normalize(mul(IN.normal, (float3x3)unity_WorldToObject));

// 优化方法 - 使用逆转置矩阵(处理非统一缩放)
float3 worldNormal = normalize(mul(transpose((float3x3)unity_WorldToObject), IN.normal));

当模型应用了非统一缩放时,直接使用模型矩阵变换法线会导致错误的结果。在这种情况下,需要使用模型矩阵的逆转置矩阵来正确变换法线。

法线可视化与调试

在开发过程中,可视化法线矢量对于调试着色器非常有用。

HLSL

// 法线可视化
float3 worldNormal = NormalVector节点输出(World空间);
// 将法线从[-1,1]范围映射到[0,1]范围以便可视化
float3 normalColor = worldNormal * 0.5 + 0.5;
return float4(normalColor, 1.0);

这个简单的着色器将法线矢量的各个分量映射到颜色通道,从而可以直观地查看法线的方向和分布。

常见问题与解决方案

法线不连续问题

当使用低多边形模型或不当的UV展开时,可能会遇到法线不连续的问题。

  • 问题表现:表面出现不自然的硬边或接缝
  • 解决方案
    • 确保模型有适当的平滑组设置
    • 检查UV展开是否导致法线贴图采样错误
    • 考虑使用更高精度的模型或细分表面

性能考量

法线计算可能会成为性能瓶颈,特别是在移动设备或复杂场景中。

  • 优化策略
    • 在顶点着色器中计算法线,而不是片元着色器
    • 使用更简单的法线计算,如省略归一化步骤(如果对视觉效果影响不大)
    • 考虑使用法线贴图的压缩格式以减少内存带宽

法线精度问题

在特定情况下,法线计算可能会遇到精度问题,导致视觉瑕疵。

  • 问题表现:闪烁的表面、带状伪影或不准确的光照
  • 解决方案
    • 使用更高精度的数据类型(如half改为float)
    • 确保法线贴图使用适当的格式和压缩
    • 检查法线变换矩阵的精度和正确性

与其他节点的配合使用

NormalVector节点很少单独使用,通常与其他Shader Graph节点结合以实现复杂的效果。

  • 与Dot Product节点结合:用于计算光照强度、菲涅尔效应等
  • 与Transform节点结合:在不同坐标空间之间转换法线
  • 与Normalize节点结合:确保法线保持单位长度
  • 与Sample Texture 2D节点结合:应用法线贴图
  • 与Fresnel Effect节点结合:创建基于视角的效果

最佳实践

为了确保NormalVector节点的正确使用和最佳性能,建议遵循以下最佳实践:

  • 始终考虑法线是否需要归一化,特别是在进行数学运算或空间变换后
  • 选择最适合当前计算任务的坐标空间,避免不必要的空间转换
  • 在性能敏感的场景中,尽可能在顶点着色器中计算法线相关数据
  • 使用适当的数据类型平衡精度和性能
  • 定期验证法线计算的正确性,特别是在使用复杂变换或混合时

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

JavaScript 函数入门:从基础调用到闭包与模块化

在 JavaScript 的世界里,函数不仅是代码的执行单元,更是构建复杂应用的核心积木。正如你的笔记所言:“函数包含一组语句,它们是 JavaScript 的基础模块单元,用于代码复用、信息隐藏和组合调用。” 本文将系统梳理 JavaScript 函数的核心特性——从其对象本质、四种调用模式,到闭包、模块化等高级概念,助你真正掌握这门语言的灵魂。


一、函数即对象:一切皆可赋值

JavaScript 中最颠覆传统编程认知的一点是:函数是头等对象(First-class Object)

function add(a, b) {
    return a + b;
  }
  
  // 函数可以像普通变量一样被赋值、传递、存储
  var myFunc = add;
  console.log(myFunc(2, 3)); // 5
  
  // 甚至可以作为对象属性(方法)
  var calculator = { operate: add };

✅ 函数对象的特殊性:

  • 它拥有普通对象的所有能力(可添加属性、可作为参数传递);
  • 唯一区别:它可以通过 () 被调用(invoked)
  • 其原型链为:add → Function.prototype → Object.prototype

💡 正因函数是对象,我们才能实现回调、高阶函数、闭包等强大模式。


二、函数字面量:声明的四种方式

最常用的是函数字面量(Function Literal)

// 命名函数(推荐,便于调试)
function greet(name) {
    return "Hello, " + name;
  }
  
  // 匿名函数(常用于回调)
  setTimeout(function() {
    console.log("Delayed!");
  }, 1000);

此外还有:

  • 函数表达式const fn = function() {}
  • 箭头函数(ES6+)const fn = () => {}

📌 命名建议:除非作为简短回调,否则优先使用命名函数,提升堆栈可读性。


三、四大调用模式:this 的命运由谁决定?

函数调用时,会自动获得两个“免费”参数:this 和 arguments。而 this 的指向,取决于调用方式

1. 方法调用模式(Method Invocation)

var obj = {
    name: "Alice",
    sayHi: function() {
      console.log(this.name); // "Alice" —— this 指向 obj
    }
  };
  obj.sayHi();

✅ this 绑定到调用对象,这是面向对象编程的基础。

2. 函数调用模式(Function Invocation)

function sayName() {
    console.log(this); // 非严格模式:window;严格模式:undefined
  }
  sayName(); // 直接调用

⚠️ 危险!  在非严格模式下,this 意外指向全局对象,易引发 bug。

3. 构造器调用模式(Constructor Invocation)

function Person(name) {
    this.name = name; // this 指向新创建的实例
  }
  var p = new Person("Bob");

✅ 使用 new 时:

  • 创建新对象;
  • this 绑定到该对象;
  • 若无显式 return 对象,则返回 this

4. Apply/Call 调用模式(Explicit Invocation)

function introduce() {
    console.log("I'm " + this.name);
  }
  
  var user = { name: "Carol" };
  introduce.call(user);   // "I'm Carol"
  introduce.apply(user);  // 同上(参数以数组形式传入)

✅ 显式指定 this,是实现函数借用、绑定上下文的关键。

🌟 现代替代:ES5+ 的 bind() 可创建永久绑定 this 的新函数。


四、参数与返回:灵活但需谨慎

参数:arguments 对象

function sum() {
    let total = 0;
    for (let i = 0; i < arguments.length; i++) {
      total += arguments[i];
    }
    return total;
  }
  sum(1, 2, 3); // 6
  • arguments 是类数组对象,无 map/forEach 等方法;
  • 现代替代:使用 Rest 参数(...args  获取真数组:
function sum(...numbers) {
    return numbers.reduce((a, b) => a + b, 0);
  }

返回值规则

  • 无 return → 返回 undefined
  • 构造函数中若 return 非对象 → 忽略,仍返回 this
  • 若 return 对象 → 返回该对象(覆盖 this)。

五、闭包:函数的“记忆”能力

闭包 = 内部函数 + 外部作用域的引用

function counter() {
    var count = 0;
    return function() {
      count++;
      return count;
    };
  }
  
  var c = counter();
  console.log(c()); // 1
  console.log(c()); // 2 —— count 被“记住”了!

✅ 闭包的价值:

  • 数据私有化count 外部无法直接访问;
  • 状态保持:函数“记住”了创建时的环境;
  • 模块化基础:实现信息隐藏。

六、模块模式:告别全局污染

利用闭包,可构建模块(Module) ——提供接口但隐藏内部状态:

var MyModule = (function() {
    var privateVar = "secret";
  
    function privateMethod() {
      console.log(privateVar);
    }
  
    return {
      publicMethod: function() {
        privateMethod();
      }
    };
  })();
  
  MyModule.publicMethod(); // "secret"
  // MyModule.privateVar → undefined(无法访问)

✅ 优势

  • 避免全局变量冲突;
  • 实现封装与解耦;
  • 是现代 ES6 模块(import/export)的思想前身。

七、高级技巧:记忆化、套用与级联

1. 记忆化(Memoization)

缓存计算结果,避免重复运算:

function fibonacci(n, memo = {}) {
    if (n in memo) return memo[n];
    if (n <= 1) return n;
    memo[n] = fibonacci(n - 1, memo) + fibonacci(n - 2, memo);
    return memo[n];
  }

2. 套用(Currying)

将多参数函数转换为一系列单参数函数:

function add(a) {
    return function(b) {
      return a + b;
    };
  }
  var add5 = add(5);
  add5(3); // 8

3. 级联(Chaining)

方法返回对象自身,支持链式调用:

var obj = {
    value: 0,
    add: function(x) {
      this.value += x;
      return this; // 关键:返回 this
    },
    log: function() {
      console.log(this.value);
      return this;
    }
  };
  
  obj.add(2).add(3).log(); // 5

八、异常处理:优雅应对错误

try {
    throw new Error("Something went wrong!");
  } catch (e) {
    console.error(e.message);
  } finally {
    console.log("Cleanup");
  }
  • throw 抛出异常对象(建议用 Error 实例);
  • catch 捕获并处理;
  • finally 无论是否出错都会执行。

结语:函数是 JavaScript 的灵魂

从简单的代码复用,到复杂的闭包、模块、高阶函数,JavaScript 的函数机制赋予了开发者极大的表达力。理解其对象本质、this 绑定规则、作用域链与闭包原理,是写出健壮、可维护代码的前提。

正如 Douglas Crockford 所言: “JavaScript 的精华,就在于它的函数。”  掌握函数,你就掌握了这门语言的钥匙。

Server Components vs Client Components:Next.js 开发者的选择指南

Server Components vs Client Components:Next.js 开发者的选择指南

在 Next.js 的世界里,理解这两种组件的区别,就像掌握武术中的“刚柔并济”

大家好!今天我们来深入探讨 Next.js 13+ 中最重要的架构变革:Server Components(服务端组件)Client Components(客户端组件)。这两者的选择不仅影响性能,更关乎应用架构的根本决策。

📊 快速对比:一图看懂核心差异

先来个直观对比,让大家有个整体概念:

特性维度 Server Components Client Components
渲染位置 服务端 客户端
Bundle大小 零打包,不发送到客户端 需要打包并发送到客户端
数据获取 直接访问数据库/API 通过API端点获取
交互性 无(纯展示) 完全交互式
生命周期 无(每次请求重新渲染) 完整React生命周期
DOM API 不可用 完全可用
状态管理 无状态 useState、useReducer等
第三方库 需兼容服务端渲染 无限制

🔍 深入解析:它们到底做了什么?

Server Components:服务端的“魔法”

// app/products/page.js - 默认就是Server Component
import { db } from '@/lib/db'

// 服务端组件可以直接访问数据库!
export default async function ProductsPage() {
  // 直接读取数据库,不需要API路由
  const products = await db.products.findMany({
    where: { isPublished: true }
  })
  
  return (
    <div>
      <h1>产品列表</h1>
      {/* 数据直接嵌入HTML,对SEO友好 */}
      <ul>
        {products.map(product => (
          <li key={product.id}>
            <h2>{product.name}</h2>
            <p>{product.description}</p>
            {/* 注意:这里不能有事件处理器 */}
          </li>
        ))}
      </ul>
    </div>
  )
}

Server Components的优势:

  • 零客户端Bundle:代码永远不会发送到浏览器
  • 直接数据访问:减少客户端-服务器往返
  • 自动代码分割:只发送当前路由需要的代码
  • 敏感信息安全:API密钥、数据库凭证安全保留在服务端

Client Components:客户端的“灵魂”

'use client' // 这个指令至关重要!

import { useState, useEffect } from 'react'
import { addToCart } from '@/actions/cart'
import { LikeButton } from './LikeButton'

export default function ProductCard({ initialProduct }) {
  const [product, setProduct] = useState(initialProduct)
  const [isLiked, setIsLiked] = useState(false)
  
  // 客户端特有的生命周期
  useEffect(() => {
    // 可以访问浏览器API
    const viewed = localStorage.getItem(`viewed_${product.id}`)
    if (!viewed) {
      localStorage.setItem(`viewed_${product.id}`, 'true')
      // 发送浏览记录到分析服务
      analytics.track('product_view', { id: product.id })
    }
  }, [product.id])
  
  // 交互事件处理
  const handleAddToCart = async () => {
    await addToCart(product.id)
    // 显示动画反馈
    // 更新购物车图标数量
  }
  
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>{product.price}元</p>
      
      {/* 交互式组件 */}
      <button 
        onClick={handleAddToCart}
        className="add-to-cart-btn"
      >
        加入购物车
      </button>
      
      {/* 使用第三方UI库 */}
      <LikeButton 
        isLiked={isLiked}
        onChange={setIsLiked}
      />
      
      {/* 使用状态驱动的UI */}
      <div className={`stock-status ${product.stock < 10 ? 'low' : ''}`}>
        库存: {product.stock}
      </div>
    </div>
  )
}

🎯 黄金选择法则:什么时候用什么?

默认选择 Server Component 当:

  • ✅ 纯数据展示,无需交互
  • ✅ 访问后端资源(数据库、文件系统)
  • ✅ 需要减少客户端JavaScript体积
  • ✅ 包含敏感逻辑或数据
  • ✅ SEO是关键考虑因素
  • ✅ 内容基本静态,变化不频繁
// ✅ 应该用 Server Component
// 博客文章页面
export default async function BlogPost({ slug }) {
  const post = await db.posts.findUnique({ where: { slug } })
  const relatedPosts = await db.posts.findMany({
    where: { category: post.category },
    take: 3
  })
  
  return <Article content={post.content} related={relatedPosts} />
}

必须使用 Client Component 当:

  • ✅ 需要用户交互(点击、输入、拖拽)
  • ✅ 使用浏览器API(localStorage、geolocation)
  • ✅ 需要状态管理(useState、useReducer)
  • ✅ 使用第三方交互式库(地图、图表、富文本编辑器)
  • ✅ 需要生命周期效果(useEffect)
  • ✅ 实现动画或过渡效果
'use client'
// ✅ 必须用 Client Component
// 实时搜索组件
export default function SearchBox() {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState([])
  const [isSearching, setIsSearching] = useState(false)
  
  // 防抖搜索
  useEffect(() => {
    if (!query.trim()) return
    
    const timer = setTimeout(async () => {
      setIsSearching(true)
      const res = await fetch(`/api/search?q=${query}`)
      const data = await res.json()
      setResults(data)
      setIsSearching(false)
    }, 300)
    
    return () => clearTimeout(timer)
  }, [query])
  
  return (
    <div>
      <input 
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="搜索..."
      />
      {isSearching && <Spinner />}
      <SearchResults results={results} />
    </div>
  )
}

🛠️ 混合使用:现实世界的案例

真正的应用往往是混合使用的,下面看一个电商产品页面的例子:

// app/product/[id]/page.js - Server Component
import { db } from '@/lib/db'
import ProductDetails from './ProductDetails' // Client Component
import ProductReviews from './ProductReviews' // Server Component
import AddToCartButton from '@/components/AddToCartButton' // Client Component

export default async function ProductPage({ params }) {
  // 服务端:获取核心数据
  const product = await db.products.findUnique({
    where: { id: params.id },
    include: { category: true }
  })
  
  // 服务端:获取评论(SEO重要)
  const reviews = await db.reviews.findMany({
    where: { productId: params.id, isVerified: true },
    take: 10
  })
  
  // 服务端:获取推荐(个性化)
  const recommendations = await getRecommendations(product.id)
  
  return (
    <div className="product-page">
      {/* 服务器组件传递数据到客户端组件 */}
      <ProductDetails 
        product={product} 
        // 客户端交互收藏分享放大图片
      />
      
      {/* 服务器组件:纯展示评论 */}
      <ProductReviews 
        reviews={reviews}
        // 客户端交互点赞回复评论嵌套的客户端组件)
      />
      
      {/* 客户端组件:购物车交互 */}
      <AddToCartButton 
        productId={product.id}
        stock={product.stock}
      />
      
      {/* 服务端组件:推荐列表 */}
      <RecommendationList 
        products={recommendations}
        // 每个推荐项内部可能有客户端交互
      />
    </div>
  )
}

💡 高级模式与最佳实践

1. 组件边界优化

// 不好的做法:整个页面都是客户端组件
'use client' // ❌ 不要轻易在顶层加这个

// 好的做法:精确控制客户端边界
export default function UserDashboard() {
  return (
    <div>
      {/* 服务端组件:用户信息(静态) */}
      <UserProfile />
      
      {/* 服务端组件:统计数据 */}
      <AnalyticsSummary />
      
      {/* 精确的客户端边界:交互式图表 */}
      <div className="interactive-section">
        <RealTimeChart />
        <FilterControls />
      </div>
    </div>
  )
}

2. 数据传递模式

// ✅ 模式:服务端获取数据,传递给客户端
// Server Component
export default async function Dashboard() {
  const initialData = await fetchDashboardData()
  
  return <InteractiveDashboard initialData={initialData} />
}

// Client Component
'use client'
function InteractiveDashboard({ initialData }) {
  const [data, setData] = useState(initialData)
  
  // 客户端更新数据
  const refreshData = async () => {
    const newData = await fetch('/api/dashboard')
    setData(newData)
  }
  
  return (
    <>
      <DashboardUI data={data} />
      <button onClick={refreshData}>刷新</button>
    </>
  )
}

3. 性能优化策略

// 策略:代码分割 + 懒加载客户端组件
import dynamic from 'next/dynamic'

// 重交互组件动态导入
const HeavyChart = dynamic(
  () => import('@/components/HeavyChart'),
  { 
    ssr: false, // 不在服务端渲染
    loading: () => <ChartSkeleton /> 
  }
)

export default function AnalyticsPage() {
  return (
    <div>
      <h1>数据分析</h1>
      {/* 这个组件只在客户端加载 */}
      <HeavyChart />
    </div>
  )
}

🚨 常见陷阱与解决方案

陷阱1:在Server Component中使用客户端特性

// ❌ 错误:在服务端组件中使用useState
export default function ServerComponent() {
  const [count, setCount] = useState(0) // 编译错误!
  return <div>{count}</div>
}

// ✅ 解决方案:提取为客户端组件
'use client'
function Counter() {
  const [count, setCount] = useState(0)
  return <div>{count}</div>
}

export default function Page() {
  return <Counter />
}

陷阱2:不必要的客户端边界

// ❌ 不必要的客户端标记
'use client'
export default function Page() {
  // 这个组件没有任何交互,却标记为客户端!
  return <div>静态内容</div>
}

// ✅ 保持为服务端组件
export default function Page() {
  return <div>静态内容</div>
}

陷阱3:过度嵌套导致的序列化问题

// ❌ 传递无法序列化的数据
export default async function Page() {
  const data = await fetchData()
  // 函数、Date对象等无法序列化
  return <ClientComponent data={data} callback={() => {}} />
}

// ✅ 仅传递可序列化数据
export default async function Page() {
  const data = await fetchData()
  // 清理数据,确保可序列化
  const serializableData = JSON.parse(JSON.stringify(data))
  return <ClientComponent data={serializableData} />
}

📈 性能影响:真实数据对比

根据Vercel的测试数据:

场景 纯客户端渲染 混合渲染(推荐) 纯服务端组件
首屏加载时间 2.8s 1.2s ⭐ 1.0s
可交互时间 2.8s 1.4s ⭐ N/A
Bundle大小 245KB 78KB ⭐ 12KB
SEO友好度 高 ⭐

结论:混合方案在绝大多数场景下是最佳选择!

🔮 未来趋势

  1. Partial Prerendering(部分预渲染):Next.js 14+ 的新特性,自动混合静态和动态内容
  2. Server Actions:更深度集成服务端逻辑
  3. Edge Runtime优化:组件级别的边缘计算部署

🎓 总结:决策流程图

这里给你一个快速决策流程图:

开始
  ↓
组件需要交互吗?
  ↓
是 → 需要浏览器API吗? → 是 → Client Component ✅
  ↓                ↓
否               否 → 有状态吗? → 是 → Client Component ✅
  ↓                ↓           ↓
Server Component ← 否 ← 否 ← 仅展示数据?
  ↓
需要考虑Bundle大小吗? → 是 → Server Component ✅
  ↓
否
↓
Client Component ✅

💬 互动讨论

话题讨论

  1. 你在项目中最大的 Server/Client Component 挑战是什么?
  2. 有没有遇到性能大幅提升的成功案例?
  3. 你如何向团队成员解释这两种组件的区别?

欢迎在评论区分享你的经验和见解!

React 状态管理的架构演进:为什么你不再需要把所有数据塞进全局 Store

React 状态管理的架构演进:为什么你不再需要把所有数据塞进全局 Store

引言:状态管理的困境

如果你写过中大型 React 应用,一定遇到过这样的场景:

// 你的 Redux Store 或 Zustand Store 长这样
const useAppStore = create((set) => ({
  // 用户信息
  currentUser: null,
  setCurrentUser: (user) => set({ currentUser: user }),

  // 简历列表
  resumes: [],
  setResumes: (resumes) => set({ resumes }),

  // 加载状态
  isLoadingUser: false,
  isLoadingResumes: false,

  // 侧边栏状态
  isSidebarOpen: true,
  toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),

  // 主题
  theme: 'light',
  setTheme: (theme) => set({ theme }),
}));

然后在组件里,你需要这样使用:

function UserProfile() {
  const { currentUser, isLoadingUser, setCurrentUser } = useAppStore();

  useEffect(() => {
    // 每次组件挂载都要手动获取数据
    fetchUser().then(setCurrentUser);
  }, []);

  if (isLoadingUser) return <Spinner />;
  return <div>{currentUser?.name}</div>;
}

看起来一切正常,但随着应用复杂度增长,问题开始暴露:

  1. 数据重复获取:多个组件都需要 currentUser,每个组件都要写 useEffect 去获取
  2. 缓存失效难题:用户在 A 页面更新了信息,B 页面的数据如何同步?
  3. 加载状态爆炸:每个 API 都要手动维护 isLoadingerrordata 三件套
  4. 数据新鲜度迷失:这份数据是 1 秒前的还是 10 分钟前的?该不该重新获取?

更严重的是,你把所有状态都塞进了同一个 Store,Server State(来自 API 的数据)和 Client State(UI 状态)混在一起,导致:

  • 不知道哪些数据该持久化、哪些该丢弃
  • 不知道哪些数据该自动刷新、哪些不需要
  • 状态更新逻辑越来越复杂,reducer 越写越长

本文将展示一种全新的架构思维:不再把所有数据塞进全局 Store,而是根据数据的本质特征,选择专门的工具来管理。这不是工具的选型问题,而是架构理念的升级。

第一部分:重新认识状态的本质

核心原则:Server State ≠ Client State

在 React 应用中,状态只有两种本质:

状态类型 定义 特征 最佳工具 示例
Server State 来自服务器的数据,你不拥有它 异步、需要缓存、会过期、可能被别人修改 TanStack Query (React Query) 用户信息、简历列表、文章数据、商品详情
Client State 仅存在于浏览器中的 UI 状态,你完全拥有它 同步、瞬态、不需要缓存、只你能改 Zustand / Context 侧边栏开关、弹窗显示、视图切换、暗黑模式

这个分类看起来简单,但它决定了你的架构方向。

为什么要分离?

Server State 的本质是远程缓存,它的核心问题是:

  • 如何避免重复请求?
  • 如何知道数据是否过期?
  • 如何在多个组件间共享同一份数据?
  • 如何在后台自动更新数据?

Client State 的本质是本地变量,它的核心问题是:

  • 如何跨组件共享?
  • 是否需要持久化?
  • 如何避免不必要的重渲染?

这两类问题完全不同,用同一套工具(Redux/Zustand)解决,只会让代码越来越乱。

思维转变:从「状态容器」到「数据同步」

传统思维(Redux/Zustand):

API → fetch → dispatch(setData) → Global Store → Component

新思维(React Query):

API ← Component (通过 useQuery 直接订阅)
      ↑
      └─ 自动缓存、去重、更新、共享

核心区别:不再是"获取数据后存到 Store",而是"组件直接订阅数据源"。React Query 会帮你处理所有脏活累活。

第二部分:反模式警示 - 那些年我们踩过的坑

在深入最佳实践之前,先看看哪些做法是必须避免的。

反模式 1:把 API 数据存进 Zustand/Redux

错误示例

//  不要这样做
const useUserStore = create((set) => ({
  currentUser: null,
  isLoading: false,
  error: null,

  fetchUser: async () => {
    set({ isLoading: true });
    try {
      const user = await api.fetchUser();
      set({ currentUser: user, isLoading: false });
    } catch (error) {
      set({ error, isLoading: false });
    }
  },
}));

// 组件 A
function Header() {
  const { currentUser, fetchUser } = useUserStore();
  useEffect(() => { fetchUser(); }, []);
  return <div>{currentUser?.name}</div>;
}

// 组件 B
function Sidebar() {
  const { currentUser, fetchUser } = useUserStore();
  useEffect(() => { fetchUser(); }, []); // 又请求一次?
  return <Avatar src={currentUser?.avatar} />;
}

问题诊断

  1. 每个组件都要手动触发 fetchUser,容易重复请求
  2. 数据新鲜度无法保证(用户信息可能在服务器被修改了)
  3. 缓存逻辑需要手写(如何判断数据是否过期?)
  4. 跨页面共享困难(A 页面获取的数据,B 页面能用吗?)

正确做法(后面会详细展开):

// 使用 React Query
function useCurrentUser() {
  return useQuery({
    queryKey: ['currentUser'],
    queryFn: api.fetchUser,
    staleTime: 5 * 60 * 1000, // 5分钟内认为数据新鲜
  });
}

// 组件 A 和 B 都这样用
function Header() {
  const { data: user } = useCurrentUser(); // 自动共享、自动去重
  return <div>{user?.name}</div>;
}

反模式 2:useEffect 同步 Query 数据到 Store

错误示例

//  双重数据源:既有 Query 又有 Store
function UserProfile() {
  const { data: user } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchUser,
  });

  const setUser = useUserStore((s) => s.setUser);

  //  危险!监听 Query 数据变化,手动同步到 Store
  useEffect(() => {
    if (user) {
      setUser(user); // 为什么要这样做?
    }
  }, [user, setUser]);

  // 现在数据有两份:Query 缓存 + Store
  const storeUser = useUserStore((s) => s.currentUser);
  return <div>{storeUser?.name}</div>;
}

问题诊断

  1. 双重数据源:同一份数据既在 Query 缓存又在 Store,哪个是真相?
  2. 同步 Bug:如果 Query 数据更新但 useEffect 没触发怎么办?
  3. 无意义的复杂度:为什么不直接用 user 而要绕一圈存到 Store?

正确做法

// 直接使用 Query 数据,不要同步到 Store
function UserProfile() {
  const { data: user } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchUser,
  });

  return <div>{user?.name}</div>; // 就这么简单
}

// 如果其他组件需要,直接用同样的 Hook
function Sidebar() {
  const { data: user } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchUser,
  });
  // React Query 会自动共享缓存,不会重复请求
  return <Avatar src={user?.avatar} />;
}

核心原则:Pull, Don't Push

  • 组件主动"拉取"数据(useQuery
  • 获取数据后"推送"到 Store(setStore

反模式 3:把 UI 状态存进 React Query

错误示例

//  React Query 不是万能的,不要滥用
function useSidebarState() {
  return useQuery({
    queryKey: ['sidebarOpen'],
    queryFn: () => true, // 这根本不是异步数据!
    initialData: true,
  });
}

// 更新侧边栏状态
function toggleSidebar() {
  queryClient.setQueryData(['sidebarOpen'], (old) => !old);
}

问题诊断

  1. React Query 是为异步数据设计的,不是通用状态管理器
  2. 浪费了 Query 的缓存、过期、重试等特性(UI 状态不需要这些)
  3. 语义混乱:侧边栏状态不是"查询"出来的

正确做法

// 纯 UI 状态用 Zustand 或 Context
const useUIStore = create((set) => ({
  isSidebarOpen: true,
  toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),
}));

反模式 4:Mutation 后手动更新 Store 而不是 Invalidate

错误示例

//  手动维护数据一致性
const updateUserMutation = useMutation({
  mutationFn: updateUser,
  onSuccess: (newUser) => {
    // 手动更新 Store
    useUserStore.setState({ currentUser: newUser });

    // 还要手动更新用户列表
    useUserStore.setState((s) => ({
      users: s.users.map((u) => (u.id === newUser.id ? newUser : u)),
    }));

    // 哪里还有用户数据?都要手动更新...
  },
});

问题诊断

  1. 手动同步容易遗漏(还有多少地方用了这个用户数据?)
  2. 数据不一致风险(服务器返回的数据可能和你想的不一样)
  3. 代码维护噩梦(每个 Mutation 都要写一堆同步逻辑)

正确做法

// 让 React Query 重新获取,服务器是唯一真相
const updateUserMutation = useMutation({
  mutationFn: updateUser,
  onSuccess: () => {
    // 标记数据为"过期",React Query 会自动重新获取
    queryClient.invalidateQueries({ queryKey: ['currentUser'] });
    queryClient.invalidateQueries({ queryKey: ['users'] });
  },
});

核心原则:Server is the source of truth

  • Mutation 后告诉 Query "数据脏了,重新拉取"
  • Mutation 后手动同步本地数据(容易出错)

这些反模式的共同点是:试图用通用状态管理工具(Redux/Zustand)解决异步数据管理问题。接下来,我们看看正确的架构应该是什么样。

第三部分:Server State 的正确打开方式 (React Query)

核心认知:QueryKey 就是全局 Store ID

这是最重要的思维转变:在 React Query 中,queryKey 就是状态的唯一标识符,类似于 Redux 中的 Store Key。

// 传统思维:手动创建全局 Store
const useUserStore = create(() => ({
  currentUser: null, // <-- Store Key
}));

// React Query 思维:QueryKey 就是隐式的 Store ID
const { data: currentUser } = useQuery({
  queryKey: ['currentUser'], // <-- 这就是 Store Key
  queryFn: fetchUser,
});

自动共享的魔法

// 组件 A
function Header() {
  const { data } = useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchUser,
  });
  return <div>{data?.name}</div>;
}

// 组件 B(完全独立的组件树)
function Sidebar() {
  const { data } = useQuery({
    queryKey: ['currentUser'], // 相同的 queryKey
    queryFn: fetchUser,
  });
  return <Avatar src={data?.avatar} />;
}

// 结果:
// 1. 只会发送一次请求(自动去重)
// 2. 两个组件共享同一份缓存数据
// 3. 数据更新时,两个组件自动同步

不再需要显式的全局 Store,QueryKey 就是隐式的 Store ID。


最佳实践 1:使用 Factory Pattern 管理 QueryKey

随着应用增长,QueryKey 会越来越多,使用常量对象统一管理:

// src/queries/queryKeys.ts
export const USER_KEYS = {
  all: ['users'] as const,
  currentUser: ['currentUser'] as const,
  byId: (id: string) => ['users', id] as const,
  posts: (userId: string) => ['users', userId, 'posts'] as const,
};

export const RESUME_KEYS = {
  all: ['resumes'] as const,
  byId: (id: string) => ['resumes', id] as const,
  templates: ['resume-templates'] as const,
};

好处

  1. 避免 Key 拼写错误
  2. 方便批量 invalidate(如 invalidateQueries({ queryKey: USER_KEYS.all })
  3. 清晰的数据模型索引

最佳实践 2:封装自定义 Hook(Pull, Don't Push)

不要在组件里直接写 useQuery,封装成语义化的 Hook:

// src/queries/useUserQueries.ts
export function useCurrentUser() {
  return useQuery({
    queryKey: USER_KEYS.currentUser,
    queryFn: api.fetchUserProfile,
    staleTime: 1000 * 60 * 5, // 5分钟内认为数据新鲜,不重复请求
  });
}

export function useUserPosts(userId: string) {
  return useQuery({
    queryKey: USER_KEYS.posts(userId),
    queryFn: () => api.fetchUserPosts(userId),
    enabled: !!userId, // 只有 userId 存在时才执行查询
  });
}

组件使用

function UserProfile() {
  const { data: user, isLoading, error } = useCurrentUser();

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return (
    <div>
      <h1>{user.name}</h1>
      <UserPosts userId={user.id} />
    </div>
  );
}

function UserPosts({ userId }: { userId: string }) {
  const { data: posts } = useUserPosts(userId);
  // 如果 Header 组件也需要用户信息,直接调用 useCurrentUser()
  // React Query 会自动共享缓存,不会重复请求
  return <PostList posts={posts} />;
}

核心原则:组件主动"拉取"所需数据,而不是等待数据"推送"过来。


最佳实践 3:理解 staleTime vs cacheTime

这是新手最容易混淆的概念:

useQuery({
  queryKey: ['data'],
  queryFn: fetchData,
  staleTime: 5 * 60 * 1000, // 数据在 5 分钟内认为是"新鲜"的
  cacheTime: 10 * 60 * 1000, // 数据在 10 分钟后从缓存中删除
});
配置 含义 触发行为
staleTime 数据多久后变"陈旧" 陈旧数据会在组件重新挂载/窗口重新聚焦时自动重新获取
cacheTime 数据多久后从缓存删除 删除后下次查询会发送新请求

典型配置

// 用户信息:不常变,可以缓存久一点
export function useCurrentUser() {
  return useQuery({
    queryKey: USER_KEYS.currentUser,
    queryFn: fetchUser,
    staleTime: 5 * 60 * 1000, // 5分钟
    cacheTime: 10 * 60 * 1000, // 10分钟
  });
}

// 实时数据:需要频繁更新
export function useRealtimeStats() {
  return useQuery({
    queryKey: ['stats'],
    queryFn: fetchStats,
    staleTime: 0, // 总是认为数据陈旧,会频繁刷新
    refetchInterval: 10000, // 每 10 秒自动刷新
  });
}

最佳实践 4:避免手动同步到 Zustand

再次强调:如果你发现自己在写这种代码,立刻停下来重新审视架构:

//  绝对不要这样做
function UserProfile() {
  const { data } = useQuery({ queryKey: ['user'], queryFn: fetchUser });
  const setUser = useUserStore((s) => s.setUser);

  useEffect(() => {
    if (data) setUser(data); //  双重数据源警告!
  }, [data, setUser]);
}

如果你需要在多个组件访问同一份 Query 数据,正确做法是

  1. 封装自定义 Hook(推荐):
// 任何组件都可以直接调用
export function useCurrentUser() {
  return useQuery({ queryKey: ['currentUser'], queryFn: fetchUser });
}
  1. 如果真的需要 Zustand 存储衍生状态(极少数场景):
// 只存储"选择"或"临时标记",不存储 API 数据本身
const useSelectionStore = create((set) => ({
  selectedUserId: null, // 只存 ID,不存整个 user 对象
  setSelectedUserId: (id) => set({ selectedUserId: id }),
}));

function UserList() {
  const { data: users } = useQuery({ queryKey: ['users'], queryFn: fetchUsers });
  const setSelectedUserId = useSelectionStore((s) => s.setSelectedUserId);

  return users.map((user) => (
    <UserCard
      key={user.id}
      user={user}
      onClick={() => setSelectedUserId(user.id)} // 只存 ID
    />
  ));
}

function UserDetail() {
  const selectedUserId = useSelectionStore((s) => s.selectedUserId);
  // 根据 ID 重新查询完整数据
  const { data: user } = useQuery({
    queryKey: ['users', selectedUserId],
    queryFn: () => fetchUser(selectedUserId),
    enabled: !!selectedUserId,
  });
}

原则:Zustand 可以存"引用"(ID、索引),但不要存 API 数据本身。

第四部分:Client State 的克制使用 (Zustand)

在 React Query 接管了所有 Server State 后,Zustand 应该变得非常轻量。如果你的 Zustand Store 还是很庞大,说明架构可能有问题。

Zustand 的正确定位:纯 UI 状态

Zustand 只应该用于两类场景:

1. 纯 UI 控制状态
// src/store/useUIStore.ts
interface UIState {
  // 侧边栏
  isSidebarOpen: boolean;
  toggleSidebar: () => void;

  // 主题
  theme: 'light' | 'dark';
  setTheme: (theme: 'light' | 'dark') => void;

  // 视图模式
  viewMode: 'grid' | 'list';
  setViewMode: (mode: 'grid' | 'list') => void;

  // 弹窗
  activeModal: string | null;
  openModal: (id: string) => void;
  closeModal: () => void;
}

export const useUIStore = create<UIState>((set) => ({
  isSidebarOpen: true,
  toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),

  theme: 'light',
  setTheme: (theme) => set({ theme }),

  viewMode: 'grid',
  setViewMode: (mode) => set({ viewMode: mode }),

  activeModal: null,
  openModal: (id) => set({ activeModal: id }),
  closeModal: () => set({ activeModal: null }),
}));

使用示例

function Header() {
  const { isSidebarOpen, toggleSidebar } = useUIStore();
  return <IconButton onClick={toggleSidebar} icon={isSidebarOpen ? 'close' : 'menu'} />;
}

function Sidebar() {
  const isSidebarOpen = useUIStore((s) => s.isSidebarOpen);
  return <aside className={isSidebarOpen ? 'open' : 'closed'}>...</aside>;
}

特点

  • 100% 同步
  • 不涉及 API 调用
  • 不需要加载状态
  • 完全由客户端控制

2. 多步操作的临时 Session 数据

典型场景:多步表单(Wizard),第一步的数据还没提交到服务器,但后续步骤需要用到。

// src/store/useResumeWizardStore.ts
interface ResumeWizardState {
  // 临时数据(还没提交到服务器)
  draftBasicInfo: { name: string; email: string } | null;
  draftExperience: Experience[] | null;
  currentStep: number;

  // Actions
  saveDraftBasicInfo: (data: BasicInfo) => void;
  saveDraftExperience: (data: Experience[]) => void;
  nextStep: () => void;
  reset: () => void;
}

export const useResumeWizardStore = create<ResumeWizardState>((set) => ({
  draftBasicInfo: null,
  draftExperience: null,
  currentStep: 1,

  saveDraftBasicInfo: (data) => set({ draftBasicInfo: data }),
  saveDraftExperience: (data) => set({ draftExperience: data }),
  nextStep: () => set((s) => ({ currentStep: s.currentStep + 1 })),
  reset: () => set({ draftBasicInfo: null, draftExperience: null, currentStep: 1 }),
}));

使用示例

// 第一步:填写基本信息
function Step1BasicInfo() {
  const { saveDraftBasicInfo, nextStep } = useResumeWizardStore();

  const handleNext = (formData: BasicInfo) => {
    saveDraftBasicInfo(formData); // 暂存到 Zustand
    nextStep();
  };

  return <BasicInfoForm onSubmit={handleNext} />;
}

// 第二步:填写工作经验(需要用到第一步的数据)
function Step2Experience() {
  const { draftBasicInfo, saveDraftExperience, nextStep } = useResumeWizardStore();

  const handleNext = (formData: Experience[]) => {
    saveDraftExperience(formData);
    nextStep();
  };

  return (
    <div>
      <p>为 {draftBasicInfo?.name} 添加工作经验</p>
      <ExperienceForm onSubmit={handleNext} />
    </div>
  );
}

// 最后一步:提交到服务器
function Step3Submit() {
  const { draftBasicInfo, draftExperience, reset } = useResumeWizardStore();

  const createResumeMutation = useMutation({
    mutationFn: (data: CreateResumeDTO) => api.createResume(data),
    onSuccess: () => {
      reset(); // 清空临时数据
      queryClient.invalidateQueries({ queryKey: RESUME_KEYS.all }); // 刷新简历列表
      navigate('/resumes');
    },
  });

  const handleSubmit = () => {
    createResumeMutation.mutate({
      basicInfo: draftBasicInfo,
      experience: draftExperience,
    });
  };

  return <SubmitButton onClick={handleSubmit} loading={createResumeMutation.isPending} />;
}

核心原则

  • Zustand 存储临时数据(还未提交的表单)
  • Mutation 成功后立即清空临时数据
  • Mutation 成功后 invalidate Query,让列表自动刷新

何时不应该用 Zustand?

不要存储 API 数据
//  错误:把用户信息存在 Zustand
const useUserStore = create(() => ({
  currentUser: null,
  setCurrentUser: (user) => set({ currentUser: user }),
}));

// 正确:用 React Query
function useCurrentUser() {
  return useQuery({ queryKey: ['currentUser'], queryFn: fetchUser });
}
不要存储可以计算出来的数据
//  错误:存储派生状态
const useCartStore = create(() => ({
  items: [],
  totalPrice: 0, // 可以从 items 计算出来!
  setItems: (items) => {
    const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
    set({ items, totalPrice });
  },
}));

// 正确:存原始数据,派生数据用 selector 计算
const useCartStore = create(() => ({
  items: [],
  setItems: (items) => set({ items }),
}));

function useCartTotal() {
  return useCartStore((s) => s.items.reduce((sum, item) => sum + item.price, 0));
}
不要滥用持久化
//  错误:持久化所有东西
const useStore = create(
  persist(
    (set) => ({
      currentUser: null, // API 数据不要持久化!
      resumes: [], // API 数据不要持久化!
      theme: 'light', // 这个可以持久化
      isSidebarOpen: true, // ❓ 这个需要持久化吗?
    }),
    { name: 'app-storage' }
  )
);

// 正确:只持久化用户偏好
const useUIStore = create(
  persist(
    (set) => ({
      theme: 'light',
      viewMode: 'grid',
      setTheme: (theme) => set({ theme }),
      setViewMode: (mode) => set({ viewMode: mode }),
    }),
    {
      name: 'ui-preferences',
      // 只持久化这两个字段
      partialize: (state) => ({ theme: state.theme, viewMode: state.viewMode }),
    }
  )
);

Zustand 的理想状态

在正确使用 React Query 后,你的 Zustand Store 应该非常小

// 整个应用可能只需要一个 UI Store
export const useUIStore = create(
  persist(
    (set) => ({
      // 主题
      theme: 'light' as 'light' | 'dark',
      setTheme: (theme: 'light' | 'dark') => set({ theme }),

      // 侧边栏
      isSidebarCollapsed: false,
      toggleSidebar: () => set((s) => ({ isSidebarCollapsed: !s.isSidebarCollapsed })),

      // 视图偏好
      resumeViewMode: 'grid' as 'grid' | 'list',
      setResumeViewMode: (mode: 'grid' | 'list') => set({ resumeViewMode: mode }),
    }),
    {
      name: 'ui-preferences',
      partialize: (state) => ({
        theme: state.theme,
        resumeViewMode: state.resumeViewMode,
      }),
    }
  )
);

如果你的 Zustand Store 超过 100 行代码,很可能有架构问题。

第五部分:Mutation - 连接两个世界的桥梁

useMutation 虽然不缓存数据,但它是连接 Server State 和 Client State 的关键桥梁。

标准模式:Mutation + Invalidation

这是最常见、最安全的模式:

// 更新用户信息
function useUpdateUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: UpdateUserDTO) => api.updateUser(data),

    onSuccess: () => {
      // 告诉 React Query:"用户数据过期了,重新拉取"
      queryClient.invalidateQueries({ queryKey: USER_KEYS.currentUser });
      queryClient.invalidateQueries({ queryKey: USER_KEYS.all });
    },

    onError: (error) => {
      toast.error(`更新失败: ${error.message}`);
    },
  });
}

// 组件使用
function UserProfileForm() {
  const { data: user } = useCurrentUser();
  const updateUserMutation = useUpdateUser();

  const handleSubmit = (formData: UpdateUserDTO) => {
    updateUserMutation.mutate(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input defaultValue={user?.name} name="name" />
      <button type="submit" disabled={updateUserMutation.isPending}>
        {updateUserMutation.isPending ? '保存中...' : '保存'}
      </button>
    </form>
  );
}

核心原则:Server is the source of truth

  • Mutation 成功后,让服务器告诉我们最新数据是什么(通过重新查询)
  • 不要自己猜测服务器返回什么数据(手动更新 Store)

高级模式:乐观更新 (Optimistic Update)

对于用户体验要求高的场景(如点赞、收藏),可以先更新 UI,失败后再回滚:

function useLikePost() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (postId: string) => api.likePost(postId),

    // 在请求发送前立即更新 UI
    onMutate: async (postId) => {
      // 1. 取消正在进行的查询(避免覆盖乐观更新)
      await queryClient.cancelQueries({ queryKey: ['posts', postId] });

      // 2. 保存当前数据(用于回滚)
      const previousPost = queryClient.getQueryData(['posts', postId]);

      // 3. 乐观更新
      queryClient.setQueryData(['posts', postId], (old: Post) => ({
        ...old,
        isLiked: true,
        likeCount: old.likeCount + 1,
      }));

      // 返回回滚上下文
      return { previousPost };
    },

    // 如果失败,回滚
    onError: (error, postId, context) => {
      queryClient.setQueryData(['posts', postId], context.previousPost);
      toast.error('点赞失败');
    },

    // 成功后,重新获取确保数据一致
    onSettled: (data, error, postId) => {
      queryClient.invalidateQueries({ queryKey: ['posts', postId] });
    },
  });
}

使用场景

  • 用户交互频繁(点赞、收藏、切换状态)
  • 操作成功率高(网络正常时几乎不会失败)
  • 复杂业务逻辑(服务器可能返回完全不同的数据)
  • 金钱交易(必须等服务器确认)

Mutation 与 Zustand 的协作

Mutation 是更新 Zustand 临时数据的最佳时机:

// 登录场景:先登录,再存 token 到 Store
function useLogin() {
  const setAuthToken = useAuthStore((s) => s.setToken);
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (credentials: LoginDTO) => api.login(credentials),

    onSuccess: (response) => {
      // 1. 存 token 到 Zustand(临时 Session 数据)
      setAuthToken(response.accessToken);

      // 2. 触发用户信息查询
      queryClient.invalidateQueries({ queryKey: USER_KEYS.currentUser });
    },
  });
}

// 组件使用
function LoginForm() {
  const loginMutation = useLogin();
  const navigate = useNavigate();

  const handleSubmit = async (formData: LoginDTO) => {
    await loginMutation.mutateAsync(formData);
    // mutateAsync 保证 onSuccess 执行完毕后才继续
    navigate('/dashboard'); // 此时 token 已经存好,可以安全跳转
  };

  return <form onSubmit={handleSubmit}>...</form>;
}

mutateAsync vs mutate 的区别

// mutate: 触发即忘(fire-and-forget)
loginMutation.mutate(formData);
navigate('/dashboard'); //  可能 token 还没存好就跳转了

// mutateAsync: 等待完成(包括 onSuccess)
await loginMutation.mutateAsync(formData);
navigate('/dashboard'); // onSuccess 已执行,token 已存好

使用建议

  • 大部分场景用 mutate(React Query 会处理好时序)
  • 需要线性执行流程时用 mutateAsync(登录后跳转、提交后关闭弹窗)

批量 Invalidation 策略

当一个 Mutation 影响多个查询时,使用数组形式的 QueryKey 批量失效:

// QueryKey 设计:层级结构
export const RESUME_KEYS = {
  all: ['resumes'] as const, // 所有简历相关
  lists: () => [...RESUME_KEYS.all, 'list'] as const, // 简历列表
  details: () => [...RESUME_KEYS.all, 'detail'] as const, // 简历详情
  detail: (id: string) => [...RESUME_KEYS.details(), id] as const,
};

// 删除简历
function useDeleteResume() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => api.deleteResume(id),

    onSuccess: (_, deletedId) => {
      // 1. 失效列表查询
      queryClient.invalidateQueries({ queryKey: RESUME_KEYS.lists() });

      // 2. 直接移除详情缓存(不需要重新获取不存在的数据)
      queryClient.removeQueries({ queryKey: RESUME_KEYS.detail(deletedId) });
    },
  });
}

层级 QueryKey 的好处

// 失效所有简历相关查询
queryClient.invalidateQueries({ queryKey: ['resumes'] });

// 只失效简历列表
queryClient.invalidateQueries({ queryKey: ['resumes', 'list'] });

// 只失效某个简历详情
queryClient.invalidateQueries({ queryKey: ['resumes', 'detail', 'resume-123'] });

错误处理最佳实践

function useCreateResume() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (data: CreateResumeDTO) => api.createResume(data),

    onSuccess: (newResume) => {
      // 成功:刷新列表
      queryClient.invalidateQueries({ queryKey: RESUME_KEYS.lists() });

      // 可选:直接插入新数据到缓存(避免重新请求)
      queryClient.setQueryData(RESUME_KEYS.detail(newResume.id), newResume);

      toast.success('简历创建成功');
    },

    onError: (error: ApiError) => {
      // 错误处理:根据错误类型给出不同提示
      if (error.code === 'QUOTA_EXCEEDED') {
        toast.error('您的简历数量已达上限,请升级会员');
      } else if (error.code === 'VALIDATION_ERROR') {
        toast.error(`数据验证失败: ${error.message}`);
      } else {
        toast.error('创建失败,请稍后重试');
      }
    },
  });
}

原则

  • 区分业务错误和网络错误
  • 给用户明确的错误提示和行动建议
  • 不要默默吞掉错误

第六部分:实战决策树与最佳实践

当你面对一个状态时,按照以下决策树快速判断应该用什么工具:

决策流程图

开始:我需要管理一个状态
    ↓
┌───▼───────────────────────────┐
│ 这个数据来自 API 吗?            │
└───┬───────────────────────────┘
    │
    ├─ 是 → 使用 React Query
    │       ├─ 封装 useQuery Hook
    │       ├─ 定义 QueryKey 常量
    │       └─ 配置 staleTime/cacheTime
    │
    └─ 否 → 数据只是 UI 状态?
            │
            ├─ 是 → 需要跨组件共享吗?
            │       │
            │       ├─ 是 → Zustand
            │       │       └─ 只存储纯 UI 控制状态
            │       │
            │       └─ 否 → useState/useReducer
            │               └─ 组件本地状态即可
            │
            └─ 否 → 是多步操作的临时数据?
                    │
                    ├─ 是 → Zustand(临时 Session)
                    │       └─ Mutation 成功后清空
                    │
                    └─ 否 → 重新审视需求
                            └─ 可能不需要状态管理

实战案例速查表

场景 使用工具 示例代码
获取用户信息 React Query useQuery({ queryKey: ['user'], queryFn: fetchUser })
获取简历列表 React Query useQuery({ queryKey: ['resumes'], queryFn: fetchResumes })
主题切换(dark/light) Zustand + persist create(persist(...))
侧边栏展开/收起 Zustand { isSidebarOpen, toggleSidebar }
表单输入值 useState const [value, setValue] = useState('')
多步表单向导 Zustand (临时) { draftData, saveDraft, reset }
当前路由参数 React Router useParams() / useSearchParams()
用户登录状态 React Query useQuery({ queryKey: ['session'] })
Token 存储 Zustand (不持久化) { token, setToken }
弹窗显示/隐藏 Zustand 或 useState 取决于是否跨组件

常见场景深度解析

场景 1:用户认证状态
// 正确:Session 数据用 Query,Token 用 Zustand
export const useAuthStore = create<AuthState>((set) => ({
  accessToken: null,
  setToken: (token) => set({ accessToken: token }),
  clearToken: () => set({ accessToken: null }),
}));

// 用户信息用 Query
export function useCurrentUser() {
  const token = useAuthStore((s) => s.accessToken);

  return useQuery({
    queryKey: USER_KEYS.currentUser,
    queryFn: fetchCurrentUser,
    enabled: !!token, // 只有 token 存在时才查询
  });
}

// 登录
function useLogin() {
  const setToken = useAuthStore((s) => s.setToken);
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: api.login,
    onSuccess: (response) => {
      setToken(response.accessToken); // Token 存 Zustand
      queryClient.invalidateQueries({ queryKey: USER_KEYS.currentUser }); // 触发用户信息查询
    },
  });
}

// 登出
function useLogout() {
  const clearToken = useAuthStore((s) => s.clearToken);
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: api.logout,
    onSuccess: () => {
      clearToken(); // 清除 token
      queryClient.clear(); // 清空所有 Query 缓存
    },
  });
}

原则

  • Token(凭证)→ Zustand(临时 Session)
  • 用户信息(数据)→ React Query(Server State)

场景 2:列表筛选与排序
//  错误:把筛选参数和列表数据都存在 Zustand
const useResumeStore = create(() => ({
  resumes: [],
  filters: { search: '', sortBy: 'date' },
  setFilters: (filters) => set({ filters }),
  fetchResumes: async (filters) => {
    const resumes = await api.fetchResumes(filters);
    set({ resumes });
  },
}));

// 正确:筛选参数用 URL,列表用 Query
function ResumeList() {
  const [searchParams, setSearchParams] = useSearchParams();
  const search = searchParams.get('search') || '';
  const sortBy = searchParams.get('sortBy') || 'date';

  // 根据 URL 参数查询
  const { data: resumes } = useQuery({
    queryKey: ['resumes', { search, sortBy }], // QueryKey 包含筛选参数
    queryFn: () => api.fetchResumes({ search, sortBy }),
  });

  const handleSearchChange = (newSearch: string) => {
    setSearchParams({ search: newSearch, sortBy });
  };

  return (
    <div>
      <SearchInput value={search} onChange={handleSearchChange} />
      <ResumeGrid resumes={resumes} />
    </div>
  );
}

好处

  • URL 可分享(别人打开链接就能看到相同的筛选结果)
  • 浏览器前进/后退按钮自动工作
  • 不需要额外的状态管理

场景 3:购物车
// 如果购物车存在服务器(已登录用户)
function useCart() {
  return useQuery({
    queryKey: ['cart'],
    queryFn: api.fetchCart,
  });
}

function useAddToCart() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (productId: string) => api.addToCart(productId),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['cart'] });
    },
  });
}

// 如果购物车只在浏览器本地(未登录)
const useCartStore = create(
  persist(
    (set) => ({
      items: [],
      addItem: (product) => set((s) => ({ items: [...s.items, product] })),
      removeItem: (productId) =>
        set((s) => ({ items: s.items.filter((item) => item.id !== productId) })),
    }),
    { name: 'guest-cart' }
  )
);

决策依据:数据的"真相"在哪里?

  • 服务器 → React Query
  • 浏览器 → Zustand + persist

持久化策略

React Query 持久化
import { persistQueryClient } from '@tanstack/react-query-persist-client';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

const persister = createSyncStoragePersister({
  storage: window.localStorage,
});

persistQueryClient({
  queryClient,
  persister,
  maxAge: 1000 * 60 * 60 * 24, // 24 小时
  dehydrateOptions: {
    // 只持久化特定查询
    shouldDehydrateQuery: (query) => {
      const queryKey = query.queryKey[0];
      // 只持久化用户信息和配置,不持久化列表数据
      return queryKey === 'currentUser' || queryKey === 'appConfig';
    },
  },
});

持久化原则

  • 用户信息、应用配置(低频变化)
  • 列表数据、实时数据(高频变化)
  • 敏感数据(Token 用 httpOnly cookie)
Zustand 持久化
const useUIStore = create(
  persist(
    (set) => ({
      theme: 'light',
      sidebarCollapsed: false,
      recentSearches: [],

      setTheme: (theme) => set({ theme }),
      toggleSidebar: () => set((s) => ({ sidebarCollapsed: !s.sidebarCollapsed })),
      addRecentSearch: (query) =>
        set((s) => ({
          recentSearches: [query, ...s.recentSearches].slice(0, 10),
        })),
    }),
    {
      name: 'ui-preferences',
      // 部分字段持久化
      partialize: (state) => ({
        theme: state.theme,
        recentSearches: state.recentSearches,
        // sidebarCollapsed 不持久化,每次打开都是展开状态
      }),
    }
  )
);

持久化原则

  • 用户偏好(主题、语言、视图模式)
  • 历史记录(最近搜索、最近访问)
  • 临时 UI 状态(弹窗、侧边栏)
  • 敏感数据(密码、Token)

性能优化检查清单

React Query 优化
// 合理设置 staleTime(避免过度请求)
useQuery({
  queryKey: ['user'],
  queryFn: fetchUser,
  staleTime: 5 * 60 * 1000, // 用户信息 5 分钟内不重新获取
});

// 使用 select 减少重渲染
function UserAvatar() {
  // 只订阅 avatar 字段,name 变化不会触发重渲染
  const avatar = useQuery({
    queryKey: ['user'],
    queryFn: fetchUser,
    select: (data) => data.avatar,
  });
}

// 禁用不需要的自动行为
useQuery({
  queryKey: ['static-config'],
  queryFn: fetchConfig,
  staleTime: Infinity, // 永不过期
  refetchOnWindowFocus: false, // 窗口聚焦时不刷新
  refetchOnReconnect: false, // 重连时不刷新
});
Zustand 优化
// 使用精确订阅(避免不必要的重渲染)
function ThemeToggle() {
  // 只订阅 theme,其他字段变化不会触发重渲染
  const theme = useUIStore((s) => s.theme);
  const setTheme = useUIStore((s) => s.setTheme);
}

//  错误:订阅整个 Store
function ThemeToggle() {
  const { theme, setTheme, sidebarOpen, viewMode } = useUIStore();
  // sidebarOpen 或 viewMode 变化也会触发重渲染!
}

// 使用 shallow 比较(对象或数组)
import { shallow } from 'zustand/shallow';

function UserSettings() {
  const { theme, viewMode } = useUIStore(
    (s) => ({ theme: s.theme, viewMode: s.viewMode }),
    shallow // 浅比较,避免引用变化导致重渲染
  );
}

迁移路径:从 Redux 到 React Query + Zustand

如果你有一个使用 Redux 的老项目,可以这样逐步迁移:

第 1 步:识别状态类型

// 现有 Redux Store
const store = {
  user: { ... },           // Server State → React Query
  resumes: [ ... ],        // Server State → React Query
  ui: {
    theme: 'light',        // Client State → Zustand
    sidebarOpen: true,     // Client State → Zustand
  },
};

第 2 步:迁移 Server State

// 删除 Redux Slice
- import { selectUser } from './userSlice';
+ import { useCurrentUser } from './queries/useUserQueries';

function UserProfile() {
-  const user = useSelector(selectUser);
+  const { data: user } = useCurrentUser();
}

第 3 步:迁移 Client State

// 创建 Zustand Store
const useUIStore = create((set) => ({
  theme: 'light',
  sidebarOpen: true,
  setTheme: (theme) => set({ theme }),
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));

// 替换使用
- const theme = useSelector((s) => s.ui.theme);
- const dispatch = useDispatch();
+ const { theme, setTheme } = useUIStore();

第 4 步:删除 Redux

// 当所有状态迁移完成后,删除 Redux 相关代码
- import { Provider } from 'react-redux';
- import { store } from './store';

// 只保留 React Query
+ import { QueryClientProvider } from '@tanstack/react-query';

迁移收益

  • 代码量减少 30-50%
  • 去除大量 boilerplate(actions, reducers, selectors)
  • 自动获得缓存、去重、后台刷新等能力

总结:架构的本质是分离关注点

回到开篇的问题:为什么状态管理会变得混乱?

根本原因是:我们用同一套工具(Redux/Zustand)解决了本质不同的问题。

本文提出的架构理念可以总结为:

核心原则

  1. Server State ≠ Client State

    • Server State:异步、需要缓存、会过期、可能被别人修改
    • Client State:同步、瞬态、完全由客户端控制
  2. 工具专注于各自的问题域

    • React Query:专注解决异步数据管理(缓存、去重、更新、同步)
    • Zustand:专注解决跨组件的 UI 状态共享
  3. Pull, Don't Push

    • 组件主动"拉取"所需数据(useQuery
    • 而不是获取数据后"推送"到全局 Store
  4. Server is the source of truth

    • Mutation 后让服务器告诉我们最新数据(invalidate + refetch)
    • 而不是自己猜测并手动同步本地数据

架构收益

采用这套架构后,你会发现:

代码更简洁

// 之前:Redux + 手动管理缓存
// userSlice.ts (50 行)
// userActions.ts (30 行)
// userSaga.ts (80 行)
// 总计:160 行

// 现在:React Query
// useUserQueries.ts (20 行)
export function useCurrentUser() {
  return useQuery({
    queryKey: ['currentUser'],
    queryFn: fetchUser,
    staleTime: 5 * 60 * 1000,
  });
}

Bug 自然消失

  • 不再有"数据不同步"(Query 自动处理)
  • 不再有"重复请求"(自动去重)
  • 不再有"数据过期但不知道"(staleTime 机制)

开发体验提升

  • 新功能开发时,不需要先写 action → reducer → selector
  • 直接在组件里 useQuery,数据立即可用
  • DevTools 直观展示缓存状态、请求时序

性能优化

  • 自动后台刷新(用户无感知获取最新数据)
  • 智能去重(多个组件同时请求只发一次)
  • 按需加载(只有组件挂载时才查询数据)

最后的建议

  1. 不要一次性重构

    • 新功能直接用 React Query
    • 老代码逐步迁移(从最简单的查询开始)
  2. 不要教条主义

    • 如果团队已经深度使用 Redux 且运转良好,不一定要迁移
    • 但新项目强烈建议采用 React Query + Zustand
  3. 不要过度设计

    • 小型项目甚至可以只用 React Query + Context
    • Zustand 只在确实需要时引入
  4. 关注数据的本质,而不是工具

    • 先判断数据类型(Server State vs Client State)
    • 再选择合适的工具

扩展阅读


最后一句话:状态管理不应该是痛苦的,选对工具,架构自然清晰。


如果这篇文章对你有帮助,欢迎点赞收藏。如果你有不同看法或实践经验,也欢迎在评论区讨论。

本文示例代码基于 React 18 + TypeScript + TanStack Query v5 + Zustand v4

Hooks如何实现 去this and 去组件耦合

背景

上篇文章我们聊了Hooks诞生初心是为了解决什么问题,今天我们继续探讨 Hooks的 具体执行方案

前置:先建立「函数组件 + Hook」的全局执行流程

Hook 的所有操作都嵌套在 React 处理函数组件的完整流程中,先把这个“大框架”讲清楚,后续每个问题的解决过程都能套进这个框架里:

graph TD
    A[触发函数组件渲染] -->|比如首次挂载/setState/父组件更新| B[React 调用 updateFunctionComponent]
    B --> C[调用 renderWithHooks:搭建 Hook 运行环境]
    C --> C1[绑定当前 fiber 到全局变量]
    C --> C2[清空 fiber 旧的 Hook 链表/副作用链表]
    C --> C3[切换 Dispatcher:mount->初始化 Hook,update->更新 Hook]
    C --> D[执行函数组件本身,触发内部所有 Hook 调用包括useState/useEffect/自定义 Hook]
    D --> D1[mount 阶段:创建 Hook 节点,存入 fiber.memoizedState 链表]
    D --> D2[update 阶段:从 fiber 读取旧 Hook 节点,计算新状态/判断副作用是否执行]
    D --> E[renderWithHooks 重置 Dispatcher,返回虚拟 DOM]
    E --> F[React 进入 commit 阶段:更新真实 DOM]
    F --> F1[执行 useLayoutEffect 回调-同步]
    F --> F2[浏览器绘制页面]
    F --> F3[异步执行 useEffect 回调]
    F --> G[组件卸载时:执行 useEffect/useLayoutEffect 的 cleanup 函数] 

这个流程是 Hook 解决所有问题的“载体”,下面每个问题的解决过程,都是这个流程中某个环节的具体实现。


一、解决类组件 this 指向混乱:从“依赖实例”到“依赖 fiber + 闭包”

1. 类组件的具体痛点(带代码例子)

类组件的状态、方法都绑定在 this 上,稍不注意就会出问题,比如:

// 类组件的 this 坑
class ClassApp extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    // 必须手动 bind this,否则点击时 this 为 undefined
    this.handleClick = this.handleClick.bind(this);
  }

  handleClick() {
    // 异步 setState 中,this.state 可能拿到旧值
    this.setState({ count: this.state.count + 1 });
    console.log(this.state.count); // 输出旧值,而非更新后的值
  }
 
  render() {
    return <button onClick={this.handleClick}>{this.state.count}</button>;
  }
}

痛点本质:this 是动态的,状态依赖 this 访问,异步更新+绑定错误会导致状态读取/修改异常。

2. Hook 的解决思路 + 完整实现过程

核心逻辑:彻底抛弃 this,把状态存在 fiber 节点(持久化),用闭包保存当前渲染的状态(无需 this 访问)

步骤 1:renderWithHooks 搭建环境(绑定 fiber)

当执行 renderWithHooks 时,会把当前组件的 fiber 绑定到全局变量 currentlyRenderingFiber

// 简化版 renderWithHooks 核心代码
let currentlyRenderingFiber; // 全局:标记当前渲染的 fiber
function renderWithHooks(workInProgress, Component) {
  currentlyRenderingFiber = workInProgress; // 绑定 fiber
  workInProgress.memoizedState = null; // 清空旧 Hook 链表
  ReactCurrentDispatcher.current = HooksDispatcherOnMount; // 切换到 mount 模式
  const children = Component(); // 执行函数组件
  ReactCurrentDispatcher.current = ContextOnlyDispatcher; // 重置
  return children;
}

这里有几个概念需要跟大家科普一下

什么是 workInProgress?它是何时被创建出来的?

先厘清两个核心 fiber 变量(renderWithHooks 的入参)

renderWithHooks 的调用逻辑里,它的入参是这样的(回顾之前的简化代码):

function renderWithHooks(current, workInProgress, Component, props) {
  currentlyRenderingFiber = workInProgress; // 绑定的是 workInProgress
  // ... 其他逻辑
}

这两个 fiber 的含义和首次渲染时的状态完全不同:

fiber 变量 核心含义 首次渲染时的值 非首次渲染时的值
current 已提交到真实 DOM 的「旧 fiber」(稳定版) null 上一次渲染完成后挂载到 DOM 的 fiber
workInProgress 本次渲染正在构建的「新 fiber」(工作版) 有值(非 null) 基于 current 复制/更新的新 fiber

第一次渲染时的 fiber 完整创建流程(为什么 workInProgress 不是 null)

当你执行 ReactDOM.render(<App />, root) 触发首次渲染时,React 会先完成「workInProgress fiber 的创建」,再调用 renderWithHooks,整个流程是:

步骤 1:React 初始化根 fiber

React 会先为根节点(root)创建一个「根 fiber」,然后为 <App /> 组件创建对应的 workInProgress fiber 节点,这个节点会包含基础属性:

// 首次渲染时,React 先创建的 App 组件 workInProgress fiber
const workInProgress = {
  memoizedState: null, // 初始为空,后续存 Hook 链表
  stateNode: App,      // 指向函数组件本身(App 函数)
  type: App,           // 组件类型
  // 其他核心属性(比如 props、effectTag 等)
};

此时 workInProgress 是一个完整的 fiber 对象,绝对不是 null——如果它是 null,Hook 就没有“挂载状态的载体”,整个 Hook 机制会直接崩溃。

步骤 2:调用 renderWithHooks,绑定 workInProgress 到全局

React 调用 updateFunctionComponent(处理函数组件的核心函数),然后在内部调用 renderWithHooks,入参为:

  • current: null(首次渲染没有已提交的旧 fiber);
  • workInProgress: 步骤1创建的 fiber 对象(非 null);
  • Component: App(你的函数组件)。

所以执行 currentlyRenderingFiber = workInProgress 后,这个全局变量的值是步骤1创建的 fiber 对象,而非 null。

步骤 3:current 为 null 的影响(切换 Dispatcher)

正因为 current === nullrenderWithHooks 会把 ReactCurrentDispatcher.current 切换为 HooksDispatcherOnMount(挂载版 Dispatcher),这也是首次渲染时 useState 会执行 mountState 初始化 Hook 的原因。

为什么你会误以为“首次渲染 fiber 是 null”?

大概率是混淆了两个点:

  1. current 当成了 workInProgresscurrent 首次渲染是 null,但它只是“旧 fiber 引用”,不是 Hook 依赖的“当前工作 fiber”;
  2. workInProgress.memoizedState 当成了 fiber 本身:首次渲染时 workInProgress.memoizedState 是 null(因为还没创建 Hook 链表),但 fiber 节点本身是完整的,memoizedState 只是 fiber 的一个属性。

验证:如果 workInProgress 是 null 会怎样?

我们可以反推——如果 currentlyRenderingFiber 是 null,当你首次渲染调用 useState 时,mountState 里的这段代码会直接出错:

function mountState(initialState) {
  const hook = { /* ... */ };
  // 如果 currentlyRenderingFiber 是 null,这里会报“无法读取 memoizedState 的 undefined”
  if (!currentlyRenderingFiber.memoizedState) {
    currentlyRenderingFiber.memoizedState = hook;
  }
  // ...
}

而 React 源码中做了严格的边界处理:只有当 workInProgress 存在时,才会执行 renderWithHooks,所以首次渲染时这个全局变量必然有值。

总结

  1. 首次渲染时,renderWithHooks 绑定的 currentlyRenderingFiber(即 workInProgress不是 null,它是 React 提前创建的、当前正在构建的 fiber 节点;也就是说在执行函数组件时提前创建的
  2. 首次渲染时 current 是 null(无旧 fiber),这是切换“挂载版 Dispatcher”的判断依据;
  3. workInProgress.memoizedState 初始为 null(无 Hook 链表),但 fiber 节点本身是完整的,后续会被 Hook 节点填充。

这个细节也印证了之前的核心结论:Hook 的状态最终挂载在 workInProgress(后续会变成 current)fiber 上,首次渲染时 React 已经为函数组件准备好了这个“状态载体”,所以 Hook 才能正常初始化。

为什么每次渲染前需要 清空hook链表?

先明确:“清空”的不是「旧状态」,是「本次渲染新 fiber 的临时链表」

首先要纠正一个认知: renderWithHooks 里清空的是 workInProgress.memoizedState(本次渲染的新 fiber),而不是 current.memoizedState(已提交的旧 fiber)——旧状态依然保存在 current fiber 中,清空只是让新 fiber 从“空白”开始重新构建链表

简化代码再强调这一点:

function renderWithHooks(current, workInProgress, Component) {
  currentlyRenderingFiber = workInProgress;
  
  // 清空的是「本次渲染的新 fiber」的链表,不是旧 fiber 的
  workInProgress.memoizedState = null; // 清空 Hook 链表(新 fiber)
  workInProgress.updateQueue = null;   // 清空副作用链表(新 fiber)
  
  // 切换 Dispatcher:mount/update
  ReactCurrentDispatcher.current = current === null ? HooksDispatcherOnMount : HooksDispatcherOnUpdate;
  
  const children = Component(); // 执行组件,重新构建新链表
  // ...
}

核心原因 1:保证 Hook 链表与「本次渲染的 Hook 调用顺序」严格一致

函数组件的核心特性是「每次渲染都会重新执行整个函数」,Hook 的调用顺序可能因逻辑变化(比如条件 Hook,虽然不推荐,但 React 要兼容)发生改变。清空旧链表,才能让新链表完全匹配本次的调用顺序。

场景:更新渲染时 Hook 调用数量变化(反例:不清空会错位)

假设首次渲染组件有 2 个 useState:

// 首次渲染(mount)
function App() {
  const [a, setA] = useState(1); // Hook1
  const [b, setB] = useState(2); // Hook2
  return <div>{a + b}</div>;
}

此时 current.memoizedState(旧 fiber)的 Hook 链表是:Hook1 → Hook2

如果更新渲染时,组件逻辑变了,只调用 1 个 useState:

// 更新渲染(update)
function App() {
  const [a, setA] = useState(1); // 仅 Hook1
  if (false) { // 条件不满足,跳过 Hook2
    const [b, setB] = useState(2);
  }
  return <div>{a}</div>;
}
情况 1:清空 workInProgress 的旧链表(正确逻辑)
  • workInProgress.memoizedState 被置为 null;
  • 执行组件,只调用 Hook1,新链表为 Hook1
  • React 从 current 读取旧 Hook1 的状态(1),赋值给新 Hook1;
  • 最终新链表与本次调用顺序一致,状态正确。
情况 2:不清空 workInProgress 的旧链表(错误逻辑)
  • workInProgress.memoizedState 还保留着上次的 Hook1 → Hook2
  • 执行组件,只调用 Hook1,React 会把新 Hook1 拼在旧链表后,新链表变成 Hook1(旧)→ Hook2(旧)→ Hook1(新)
  • 此时 Hook 顺序完全混乱,新 Hook1 会读取旧 Hook2 的状态(2),导致状态错位。

👉 本质:Hook 依赖「链表顺序」而非“变量名”关联状态,清空旧链表是为了让新链表“从零开始”,100% 匹配本次的调用顺序,避免旧链表的残留节点干扰。

核心原因 2:避免旧副作用残留,保证副作用执行逻辑符合本次渲染

副作用链表(updateQueue)存储的是 useEffect/useLayoutEffect 的执行信息(比如 callback、deps、cleanup)。如果不清空,旧的副作用会和本次的副作用混在一起,导致:

  1. 已失效的副作用被重复执行;
  2. 本次的副作用被旧副作用覆盖;
  3. cleanup 函数执行异常。
场景:useEffect 的 deps 变化(反例:不清空会重复执行旧副作用)

首次渲染的 useEffect:

// 首次渲染
function App() {
  useEffect(() => {
    console.log('旧副作用:监听 resize');
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []); // 空 deps
  return <div>App</div>;
}

此时 workInProgress.updateQueue 存储的是“监听 resize”的副作用。

更新渲染时,useEffect 逻辑变了:

// 更新渲染
function App() {
  useEffect(() => {
    console.log('新副作用:监听 scroll');
    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);
  return <div>App</div>;
}
情况 1:清空副作用链表(正确逻辑)
  • workInProgress.updateQueue 被置为 null;
  • 执行组件,收集新的“监听 scroll”副作用;
  • commit 阶段只执行新副作用,旧副作用的 cleanup 被执行(移除 resize 监听),新副作用生效。
情况 2:不清空副作用链表(错误逻辑)
  • workInProgress.updateQueue 保留旧的“resize 监听”副作用;
  • 执行组件,收集新的“scroll 监听”副作用,链表变成「旧副作用 → 新副作用」;
  • commit 阶段会同时执行两个副作用:既监听 resize 又监听 scroll,且旧副作用的 cleanup 不会被触发,导致内存泄漏。

补充:首次渲染时清空的意义(兜底)

首次渲染时,workInProgress.memoizedState 本来就是 null,清空操作看似“多余”,但其实是 React 的「防御性编程」:

  1. 避免 fiber 节点复用导致的残留(比如热更新时,fiber 可能被复用);
  2. 保证所有渲染流程的一致性(首次/更新渲染执行相同的清空逻辑,减少分支判断)。

总结

清空 fiber 旧的 Hook 链表/副作用链表的核心目的是:

  1. 保证顺序一致:让新 fiber 的 Hook 链表完全匹配本次渲染的 Hook 调用顺序,避免状态错位;
  2. 保证副作用纯净:清除旧的副作用残留,避免重复执行、内存泄漏;
  3. 流程统一:首次/更新渲染执行相同逻辑,降低代码复杂度。

本质上,这是 React 为了适配“函数组件每次重新执行”的特性,确保 Hook 状态始终和「当前渲染的逻辑」绑定,而非和「历史渲染的逻辑」绑定。

步骤 2:mount 阶段——创建 Hook 节点,存在 fiber 中(无 this)

当函数组件执行 useState(0) 时,调用的是 HooksDispatcherOnMount 中的 mountState

// 简化版 mountState:初始化状态,存在 fiber 中
function mountState(initialState) {
  // 创建 Hook 节点
  const hook = {
    memoizedState: initialState, // 保存状态值(比如 0)
    queue: { pending: null }, // 保存更新队列(setCount 触发的更新)
    next: null // 指向下一个 Hook
  };
  // 把 Hook 节点加入当前 fiber 的 memoizedState 链表
  if (!currentlyRenderingFiber.memoizedState) {
    currentlyRenderingFiber.memoizedState = hook; // 第一个 Hook,作为链表头
  } else {
    workInProgressHook.next = hook; // 后续 Hook,接在链表后面
  }
  workInProgressHook = hook; // 移动指针到下一个 Hook
  // 返回 [状态, setter],setter 绑定当前 Hook 的 queue,无需 this
  return [hook.memoizedState, dispatchAction.bind(null, hook.queue)];
}

此时,状态 0 被存在 fiber.memoizedState 中,而非 thissetCount 是绑定了当前 Hook 队列的函数,无需通过 this 调用。

步骤 3:update 阶段——从 fiber 读取 Hook 节点,闭包保存当前状态

当点击按钮调用 setCount(count + 1) 时:

  1. dispatchAction 会创建 update 对象({ action: count => count + 1 }),加入 Hook 的 queue;
  2. React 触发重新渲染,再次调用 renderWithHooks,切换到 HooksDispatcherOnUpdate
  3. 执行 updateState,从 fiber 的 Hook 链表中读取旧 Hook 节点,执行 update 队列中的 action,计算新状态:
    // 简化版 updateState:更新状态,无 this
    function updateState() {
      const hook = workInProgressHook; // 从 fiber 读取当前 Hook 节点
      workInProgressHook = hook.next; // 移动指针
      const queue = hook.queue;
      let baseState = hook.memoizedState;
      // 执行所有 update,计算新状态
      if (queue.pending) {
        const firstUpdate = queue.pending.next;
        do {
          baseState = firstUpdate.action(baseState); // 比如 0 → 1
          firstUpdate = firstUpdate.next;
        } while (firstUpdate !== queue.pending);
        queue.pending = null;
      }
      hook.memoizedState = baseState; // 更新 Hook 节点的状态
      // 返回 [新状态, setter],新状态存在当前函数作用域(闭包)
      return [baseState, dispatchAction.bind(null, queue)];
    }
    
  4. 新状态 1 被返回,存在函数组件的作用域中(const [count, setCount] = useState(0)),通过闭包直接访问,无需 this.count
步骤 4:最终效果(对比类组件)
// Hook 写法:无 this,状态存在 fiber + 闭包
function HookApp() {
  const [count, setCount] = useState(0); // count 是当前作用域的变量
  return (
    <button onClick={() => setCount(count + 1)}>
      {count} {/* 直接访问,无需 this */}
    </button>
  );
}
  • 状态存储:从“类实例 this” → “fiber 节点的 Hook 链表”;
  • 状态访问:从“动态 this.state” → “函数作用域的闭包变量”;
  • 根本解决:全程无 this,自然规避 this 绑定、异步更新导致的所有问题。

二、解决生命周期混乱:从“分散生命周期”到“Effect 收敛 + deps 控制”

1. 类组件的具体痛点(带代码例子)

一个“监听窗口大小”的逻辑,要拆在 3 个生命周期里,逻辑被割裂:

// 类组件:生命周期分散逻辑
class ClassApp extends React.Component {
  componentDidMount() {
    // 挂载时加监听
    window.addEventListener('resize', this.handleResize);
  }

  componentDidUpdate() {
    // 更新时可能需要重新监听(比如依赖 props 变化)
    window.removeEventListener('resize', this.handleResize);
    window.addEventListener('resize', this.handleResize);
  }

  componentWillUnmount() {
    // 卸载时删监听
    window.removeEventListener('resize', this.handleResize);
  }

  handleResize = () => {
    this.setState({ width: window.innerWidth });
  };

  render() {
    return <div>{this.state.width}</div>;
  }
}

痛点本质:生命周期函数是“按时机划分”,而业务逻辑是“按功能划分”,导致一个功能的代码被拆得支离破碎。

2. Hook 的解决思路 + 完整实现过程

核心逻辑:用 useEffect 把“一个功能的所有生命周期逻辑”收敛到一个函数中,通过 deps 控制执行时机,cleanup 处理卸载/更新前的清理

步骤 1:mount 阶段——收集 Effect,标记执行

函数组件执行 useEffect 时,调用 mountEffect 创建 EffectHook 节点,存在 fiber 中:

// 简化版 mountEffect:收集副作用
function mountEffect(create, deps) {
  const hook = {
    tag: 'PassiveEffect', // 标记是 useEffect(异步执行)
    create, // 你的业务逻辑(比如加监听)
    deps, // 依赖数组(比如 [])
    cleanup: null, // 清理函数(create 返回的函数)
    next: null
  };
  // 把 EffectHook 加入 fiber 的 Hook 链表(和 useState 共用一个链表)
  if (!currentlyRenderingFiber.memoizedState) {
    currentlyRenderingFiber.memoizedState = hook;
  } else {
    workInProgressHook.next = hook;
  }
  workInProgressHook = hook;
}

此时,create(加监听)、deps(空数组)被存在 Hook 节点中,标记为“需要执行”。

步骤 2:render 阶段——对比 deps,标记是否执行(update 阶段)

当组件更新时,调用 updateEffect 对比新旧 deps,判断是否需要重新执行:

// 简化版 updateEffect:对比 deps
function updateEffect(create, deps) {
  const hook = workInProgressHook; // 读取旧 Hook 节点
  workInProgressHook = hook.next;
  const prevDeps = hook.deps;
  // 浅比较 deps:如果 deps 相同,标记为无需执行
  if (deps !== null && areHookInputsEqual(deps, prevDeps)) {
    hook.tag = 'NoEffect'; // 无需执行
    return;
  }
  // deps 不同,更新 Hook 节点,标记需要执行
  hook.create = create;
  hook.deps = deps;
  hook.tag = 'PassiveEffect';
}

// 简化版 deps 浅比较
function areHookInputsEqual(nextDeps, prevDeps) {
  for (let i = 0; i < prevDeps.length; i++) {
    if (!Object.is(nextDeps[i], prevDeps[i])) {
      return false; // 有一项不同,返回 false
    }
  }
  return true;
}

比如 deps 是 [],更新时对比发现 deps 没变,就标记为 NoEffect,跳过执行。

步骤 3:commit 阶段——执行 Effect/cleanup(核心!)

React 更新完真实 DOM 后,进入 commit 阶段的 commitPassiveEffects 步骤,执行 useEffect:

// 简化版 commitPassiveEffects:执行 useEffect 回调/cleanup
function commitPassiveEffects(fiber) {
  let hook = fiber.memoizedState;
  while (hook) {
    if (hook.tag === 'PassiveEffect') {
      // 第一步:执行上一次的 cleanup(如果有)
      if (hook.cleanup) {
        hook.cleanup(); // 比如卸载前删监听
      }
      // 第二步:执行当前的 create,保存 cleanup
      hook.cleanup = hook.create(); // create 返回的函数(比如删监听)
    }
    hook = hook.next;
  }
}

执行时机对应:

  • mount 时无旧 cleanup,直接执行 create(加监听),保存 cleanup(删监听);
  • update 且 deps 变化时先执行旧 cleanup(删监听)→ 再执行新 create(加监听)→ 保存新 cleanup;
  • unmount 时遍历所有 EffectHook,执行 cleanup(删监听)。
  • 所以都是先执行useEffect,拿到返回值保存cleanup不执行,然后再下一次先执行cleanup(),然后再执行useEffect
步骤 4:最终效果(对比类组件)
// Hook 写法:逻辑完全收敛
function HookApp() {
  const [width, setWidth] = useState(window.innerWidth);
  
  useEffect(() => {
    // create:挂载/更新时执行(deps 变化)
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handleResize);
    // cleanup:卸载/更新前执行
    return () => window.removeEventListener('resize', handleResize);
  }, []); // 空 deps → 仅 mount/unmount 执行

  return <div>{width}</div>;
}
  • 逻辑收敛:加监听、删监听的逻辑都在一个 useEffect 里,按功能聚合;
  • 时机控制:deps 决定执行时机([]=仅 mount/unmount,[width]=width 变化时执行);
  • 自动清理:React 帮你管理 cleanup 的执行,无需手动在多个生命周期中处理。

三、解决逻辑耦合:从“分散在类中”到“自定义 Hook 封装 + fiber 隔离”

1. 类组件的具体痛点(带代码例子)

一个“数据请求+加载状态+取消请求”的逻辑,和其他逻辑挤在同一个类中,耦合严重:

// 类组件:多个逻辑耦合在生命周期中
class ClassApp extends React.Component {
  state = { data: null, loading: true, error: null };

  componentDidMount() {
    // 逻辑1:数据请求
    this.controller = new AbortController();
    fetch('/api/data', { signal: this.controller.signal })
      .then(res => res.json())
      .then(data => this.setState({ data, loading: false }))
      .catch(err => this.setState({ error, loading: false }));

    // 逻辑2:埋点统计(无关逻辑,却和请求挤在一起)
    trackPageView();
  }

  componentWillUnmount() {
    // 逻辑1的清理:取消请求
    this.controller.abort();
  }

  render() {
    // 逻辑1的 UI:加载/数据/错误展示
    if (this.state.loading) return <div>加载中</div>;
    if (this.state.error) return <div>错误</div>;
    return <div>{this.state.data.name}</div>;
  }
}

痛点本质:不同功能的逻辑都挤在同一个生命周期/类中,没有边界,复用困难。

2. Hook 的解决思路 + 完整实现过程

核心逻辑:用自定义 Hook 把“一个独立功能的所有逻辑(状态+副作用)”封装成一个函数,遵循 Hook 顺序规则,通过 fiber 保证不同组件的 Hook 状态隔离

步骤 1:封装自定义 Hook——聚合独立逻辑

把“数据请求”逻辑封装成 useRequest,内部调用基础 Hook:

// 自定义 Hook:封装请求逻辑(独立、内聚)
function useRequest(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // 请求逻辑
    const controller = new AbortController();
    fetch(url, { signal: controller.signal })
      .then(res => res.json())
      .then(setData)
      .catch(setError)
      .finally(() => setLoading(false));

    // 清理逻辑
    return () => controller.abort();
  }, [url]); // 依赖 url,url 变了重新请求

  // 返回状态/方法,供组件使用
  return { data, loading, error };
}
步骤 2:组件执行自定义 Hook——Hook 节点加入当前 fiber

当函数组件执行 const { data, loading } = useRequest('/api/data') 时:

  1. useRequest 内部的 useState/useEffect 会依次调用;
  2. 这些基础 Hook 会创建对应的 Hook 节点,按顺序加入当前组件的 fiber.memoizedState 链表;hook和hook之间独立互不干扰
  3. 由于每个组件有自己的 fiber,useRequest 在组件 A 中调用时,Hook 节点存在 A 的 fiber 中;在组件 B 中调用时,存在 B 的 fiber 中——状态完全隔离
步骤 3:其他逻辑单独封装——彻底解耦

把“埋点统计”也封装成自定义 Hook,和请求逻辑完全分离:

// 自定义 Hook:封装埋点逻辑
function usePageTrack() {
  useEffect(() => {
    trackPageView();
  }, []);
}

// 组件:按需引入不同的自定义 Hook,逻辑无耦合
function HookApp() {
  const { data, loading, error } = useRequest('/api/data');
  usePageTrack(); // 埋点逻辑

  if (loading) return <div>加载中</div>;
  if (error) return <div>错误</div>;
  return <div>{data.name}</div>;
}
  • 逻辑边界:请求逻辑在 useRequest,埋点逻辑在 usePageTrack,互不干扰
  • 复用性:useRequest 可以复用到任意需要请求数据的组件,只需传不同的 url
  • 隔离性:不同组件调用 useRequest,状态存在各自的 fiber 中,不会互相影响。

四、解决逻辑与 UI 耦合:从“混在类中”到“Hook 管逻辑,组件管 UI”

1. 类组件的具体痛点(带代码例子)

表单验证逻辑和 UI 渲染混在同一个类中,复用验证逻辑需要套高阶组件(HOC),增加层级:

// 类组件:验证逻辑 + UI 渲染耦合
class ClassForm extends React.Component {
  state = { name: '', phone: '', errors: {} };

  // 验证逻辑(和 UI 混在一起)
  validate = () => {
    const errors = {};
    if (!this.state.name) errors.name = '必填';
    if (!this.state.phone) errors.phone = '必填';
    this.setState({ errors });
    return Object.keys(errors).length === 0;
  };

  handleChange = (e) => {
    this.setState({ [e.target.name]: e.target.value });
  };

  handleSubmit = (e) => {
    e.preventDefault();
    if (this.validate()) alert('提交成功');
  };

  // UI 渲染(和验证逻辑在同一个类)
  render() {
    return (
      <form onSubmit={this.handleSubmit}>
        <input name="name" onChange={this.handleChange} />
        {this.state.errors.name && <span>{this.state.errors.name}</span>}
        <input name="phone" onChange={this.handleChange} />
        {this.state.errors.phone && <span>{this.state.errors.phone}</span>}
        <button type="submit">提交</button>
      </form>
    );
  }
}

// 复用验证逻辑:需要写 HOC,增加组件层级
function withFormValidate(WrappedComponent) {
  return class extends React.Component {
    // 复制上面的 validate/handleChange 逻辑...
    render() {
      return <WrappedComponent {...this.props} {...this.state} validate={this.validate} />;
    }
  };
}

痛点本质:类组件是“一个容器”,既装逻辑又装 UI,复用逻辑必须带 UI 一起,或用复杂的 HOC/Render Props 绕开。

2. Hook 的解决思路 + 完整实现过程

核心逻辑:自定义 Hook 只负责“逻辑+状态”(无 UI),组件只负责“接收状态+渲染 UI”,通过函数返回值传递状态/方法,renderWithHooks 保证逻辑与组件的绑定

步骤 1:自定义 Hook——纯逻辑,无 UI

把表单验证逻辑封装成 useFormValidate,只返回状态/方法,不返回 JSX:

// 自定义 Hook:仅处理验证逻辑(无任何 UI)
function useFormValidate(initialValues) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});

  // 验证逻辑
  const validate = () => {
    const newErrors = {};
    if (!values.name) newErrors.name = '必填';
    if (!values.phone) newErrors.phone = '必填';
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  // 输入变更逻辑
  const handleChange = (e) => {
    setValues({ ...values, [e.target.name]: e.target.value });
  };

  // 只返回状态/方法,不返回 UI
  return { values, errors, handleChange, validate };
}
步骤 2:组件——纯 UI,无核心逻辑

组件引入自定义 Hook,接收状态/方法,只负责渲染 UI:

// 组件:仅处理 UI 渲染,逻辑全靠 Hook
function HookForm() {
  // 引入验证逻辑,拿到状态/方法
  const { values, errors, handleChange, validate } = useFormValidate({
    name: '',
    phone: ''
  });

  const handleSubmit = (e) => {
    e.preventDefault();
    if (validate()) alert('提交成功');
  };

  // 只关心 UI 怎么渲染,无验证逻辑
  return (
    <form onSubmit={handleSubmit}>
      <input name="name" onChange={handleChange} value={values.name} />
      {errors.name && <span>{errors.name}</span>}
      <input name="phone" onChange={handleChange} value={values.phone} />
      {errors.phone && <span>{errors.phone}</span>}
      <button type="submit">提交</button>
    </form>
  );
}
步骤 3:底层绑定——逻辑属于组件,却不耦合 UI

HookForm 执行时,renderWithHooks 会把 HookForm 的 fiber 绑定到全局,useFormValidate 内部的 useState/useEffect 会自动关联到这个 fiber:

  • 逻辑归属:useFormValidate 的状态是 HookForm 的状态,不是全局状态;
  • 逻辑复用:把 useFormValidate 引入 RegisterForm 组件,只需改 UI 渲染部分,验证逻辑完全复用;
  • 无额外层级:相比 HOC,自定义 Hook 不会增加组件树层级,UI 结构更清晰。

自定义hook调用顺序

我用具体例子拆解,让你看得更直观:

第一步:先看代码结构(自定义 Hook 写在组件前面)

// 自定义 Hook:封装表单验证逻辑
function useFormValidate(initialValues) {
  // 自定义 Hook 内部的基础 Hook 1
  const [values, setValues] = useState(initialValues);
  // 自定义 Hook 内部的基础 Hook 2
  const [errors, setErrors] = useState({});
  // 自定义 Hook 内部的基础 Hook 3
  useEffect(() => {
    console.log("验证规则初始化");
  }, []);

  const validate = () => { /* 验证逻辑 */ };
  const handleChange = () => { /* 输入变更逻辑 */ };

  return { values, errors, validate, handleChange };
}

// 组件:使用自定义 Hook(写在自身 Hook 前面)
function HookForm() {
  // 第一步:执行自定义 Hook(内部的基础 Hook 先执行)
  const { values, errors, validate, handleChange } = useFormValidate({
    name: "",
    phone: ""
  });

  // 第二步:执行组件自身的基础 Hook(后执行)
  const [submitCount, setSubmitCount] = useState(0); // 组件自身 Hook 1
  const [isSubmitting, setIsSubmitting] = useState(false); // 组件自身 Hook 2

  const handleSubmit = () => { /* 提交逻辑 */ };

  return <form>{/* 渲染逻辑 */}</form>;
}

第二步:拆解执行顺序 + Hook 链表插入过程

HookForm 执行、触发 renderWithHooks 并绑定其 fiber 到全局后,整个 Hook 执行/链表插入流程是:

执行步骤 执行的 Hook 插入到 HookForm fiber 链表的位置
1 useFormValidate 内的 useState(values) 链表第 1 位
2 useFormValidate 内的 useState(errors) 链表第 2 位
3 useFormValidate 内的 useEffect 链表第 3 位(副作用链表同步插入)
4 HookForm 自身的 useState(submitCount) 链表第 4 位
5 HookForm 自身的 useState(isSubmitting) 链表第 5 位

最终,HookForm 的 fiber 中 memoizedState(Hook 链表)结构是:

HookForm fiber.memoizedState
  └─ 第1位:values 的 Hook 节点
  └─ 第2位:errors 的 Hook 节点
  └─ 第3位:useEffect 的 Hook 节点
  └─ 第4位:submitCount 的 Hook 节点
  └─ 第5位:isSubmitting 的 Hook 节点

第三步:关键结论(强化你的理解)

  1. 自定义 Hook 无“独立 fiber”:自定义 Hook 本身不会生成新的 fiber,它只是“批量调用基础 Hook 的函数”,所有内部基础 Hook 都归属于使用它的组件的 fiber
  2. 执行顺序 = 链表顺序:完全由代码书写顺序决定——自定义 Hook 写在组件内前面,其内部 Hook 就先执行、先入链表;写在后面则反之;
  3. 规则不变:自定义 Hook 内部的基础 Hook,依然要遵守“不能在条件/循环中调用”的规则(因为最终会融入组件的 Hook 链表,顺序乱了会导致状态错位)。

反例验证(自定义 Hook 写在组件 Hook 后面)

如果把代码改成这样:

function HookForm() {
  // 第一步:组件自身 Hook 先执行
  const [submitCount, setSubmitCount] = useState(0);
  
  // 第二步:自定义 Hook 后执行
  const { values, errors } = useFormValidate({ name: "", phone: "" });
  
  // 第三步:组件自身另一个 Hook
  const [isSubmitting, setIsSubmitting] = useState(false);
}

则链表顺序变成: submitCount(1)→ values(2)→ errors(3)→ useEffect(4)→ isSubmitting(5)

总结

  1. 自定义 Hook 内的 useState/useEffect 会 插入到使用它的组件 fiber 的 Hook 链表中,而非独立存在;
  2. 执行顺序完全遵循“代码书写顺序”:自定义 Hook 写在前面,其内部 Hook 先执行、先入链表;组件自身 Hook 后执行、后入链表;
  3. 这也是自定义 Hook 能“复用逻辑但不隔离状态”的原因——所有状态最终都存在组件的 fiber 中,不同组件调用同一个自定义 Hook,会在各自的 fiber 链表中生成独立的 Hook 节点,状态互不干扰。

简单说:自定义 Hook 就是“把组件内的一段 Hook 逻辑抽成函数”,调用它等价于把这段 Hook 逻辑“粘贴”到组件的对应位置,执行/插入顺序完全不变。

自定义hook 是不是和 vue 的mixin有点像?

一、先承认相似性:表层都是“逻辑复用”

特性 自定义 Hook Vue mixin
核心目的 复用组件中的 Hook 逻辑(比如表单验证、防抖) 复用组件中的选项逻辑(比如 data、methods、生命周期)
接入方式 组件内直接调用(const { validate } = useForm() 组件内通过 mixins: [formMixin] 混入
状态归属 最终归属于使用它的组件(Hook 插入组件 fiber 链表) 最终归属于使用它的组件(mixin 的 data/methods 合并到组件实例)

简单说:二者都是“把通用逻辑抽离出来,让多个组件能复用”,这是你能直观感受到的相似性。

二、核心差异:Hook 是“显式复用”,mixin 是“隐式混入”(天差地别)

这是最关键的区别,也是 Hook 能替代 mixin 这类方案的核心原因:

维度 自定义 Hook Vue mixin
作用域 & 命名冲突 完全无冲突:
1. 自定义 Hook 内部的 useState/useEffect 按顺序插入组件 fiber 链表,状态是「显式返回」给组件(比如 const { count } = useCount());
2. 变量名由组件自己决定,哪怕多个 Hook 返回同名变量,组件可以重命名(const { count: aCount } = useCount())。
极易冲突:
1. mixin 的 data/methods 会「隐式合并」到组件实例,比如两个 mixin 都定义了 count,后混入的会覆盖先混入的;
2. 组件无法提前预知 mixin 定义了什么,调试时不知道 count 来自哪个 mixin。
逻辑溯源 清晰可追溯:
组件里调用 useForm() 的位置就是逻辑入口,点进 useForm 就能看到所有相关逻辑,Hook 之间的依赖也是显式的(比如传参)。
混乱难溯源:
多个 mixin 混入后,组件的 created 生命周期可能由 3 个 mixin + 组件自身共同构成,无法直观知道某段逻辑来自哪个 mixin。
状态隔离 天然隔离:
不同组件调用同一个自定义 Hook,会在各自的 fiber 链表中生成独立的 Hook 节点,状态互不干扰(比如 A 组件的 count 和 B 组件的 count 是两个独立的 Hook 节点)。
需手动隔离:
mixin 的 data 是「共享模板」,如果 mixin 定义了 count: 0,多个组件使用时,若不手动处理(比如用函数式 data),可能出现状态污染(极少数场景,但易踩坑)。
逻辑组合 灵活组合:
自定义 Hook 可以嵌套调用其他 Hook(useForm 里调用 useValidate),逻辑分层清晰,像“搭积木”。
组合混乱:
多个 mixin 混入后,逻辑是“平铺式”的,mixin 之间无法直接通信(需通过组件实例),复杂场景下会变成“mixin 地狱”。

三、结合你学的 Hook 底层,理解差异的本质

自定义 Hook 之所以能解决 mixin 的痛点,核心是「Hook 的状态归属于组件 fiber,且逻辑是显式暴露的」:

  1. 自定义 Hook 内部的 useState 最终会变成组件 fiber 链表中的一个节点,状态是组件“自有”的,而非“外来混入”的;
  2. 自定义 Hook 把状态/方法通过返回值暴露给组件,组件必须「显式接收」——相当于“你要什么,我给什么”,而 mixin 是“我把一堆东西塞给你,你不知道有什么”;
  3. 哪怕多个自定义 Hook 往组件 fiber 链表中插入节点,因为是「按调用顺序插入」,且返回值由组件命名,完全不会冲突(比如 useCount1 的 count 是链表第 1 位,useCount2的 count 是链表第 3 位,组件接收时可以叫count1/count2)。

而 Vue mixin 的问题在于:它是「隐式合并」到组件实例,相当于“往组件的 data/methods 里偷偷加东西”,既不知道加了什么,也容易和组件自身的逻辑冲突。

总结

  1. 表层相似:自定义 Hook 和 mixin 都是为了复用组件逻辑;
  2. 核心差异:Hook 是「显式、可控、隔离」的逻辑复用(状态归组件 fiber,返回值显式接收),mixin 是「隐式、混乱、易冲突」的逻辑混入(状态合并到组件实例,无明确边界);
  3. 本质原因:Hook 依托于 React 按顺序插入组件 fiber 链表的机制,天然和组件状态绑定,而 mixin 是“无边界的逻辑合并”,这也是 React 放弃 mixin 转而推 Hook 的核心原因。

简单说:你可以把自定义 Hook 理解为「升级版的 mixin」——保留了“逻辑复用”的核心价值,又解决了 mixin 所有的致命痛点。

pnpm install 全流程解读(Monorepo + 子包级处理)

在现代前端 monorepo 中,pnpm install 看似一句命令,背后却有一套精密的 全局视图 + 子包依赖管理 + 扁平化安装策略。以下从根目录到每个子包的完整流程拆解。

假设项目结构如下:

pnpm-workspace.yaml
packages/
  ui/
apps/
  web/
  api/

执行命令:

pnpm install

整个流程可拆解为 八大阶段


阶段 0:初始化 & Workspace 扫描

目标:建立 monorepo 全局视图,识别所有子包。

根目录处理:

  1. 读取 pnpm-workspace.yaml 配置:

    packages:
      - packages/*
      - apps/*
    
    • 标识哪些目录属于 workspace。
  2. 使用 Glob 展开匹配目录:

    • packages/*packages/ui
    • apps/*apps/web, apps/api
  3. 遍历每个目录:

    • 检查是否存在 package.json
  4. 构建 Package Map(全局内存视图):

    {
      "@my-org/ui": { dir: "packages/ui", version: "1.0.0" },
      "@my-org/web": { dir: "apps/web", version: "1.0.0" },
      "@my-org/api": { dir: "apps/api", version: "1.0.0" }
    }
    

子包处理

  • 此阶段子包仅提供 package.json 信息
  • 不做实际安装,只被 pnpm 作为依赖源解析

比喻:Package Map 就像 monorepo 的楼层导览图,每个子包办公室的位置和编号都在上面标清楚。


阶段 1:解析子包依赖

目标:明确每个依赖的来源与版本,生成解析计划。

根目录处理:

  • 遍历所有子包 package.json

  • 识别依赖协议:

    • workspace: → 本地 workspace 包
    • catalog: → catalog 管理的统一版本
    • 普通 semver → registry 第三方依赖

子包处理(以 apps/web 为例):

{
  "dependencies": {
    "@my-org/ui": "workspace:*",
    "react": "catalog:",
    "axios": "^1.6.0"
  }
}
  1. workspace 依赖:

    • 查 Package Map 找本地路径
    • 标记为 link 本地
  2. catalog 依赖:

    • 查 catalog 表确定版本,如 "^18.2.0"
  3. registry 依赖:

    • 标记为需要缓存或下载

输出:每个子包生成 依赖解析计划

apps/web:
[
  { name: "@my-org/ui", type: "workspace", dir: "packages/ui", version: "1.0.0" },
  { name: "react", type: "catalog", version: "^18.2.0" },
  { name: "axios", type: "registry", version: "^1.6.0" }
]

比喻:每个子包做自己的采购清单,明确“本地拿 / catalog 拿 / registry 拿”。


阶段 2:版本冲突解决 & 扁平化优化

目标:保证依赖版本一致,尽量共享,减少重复安装。

根目录处理:

  1. 构建 全局依赖图

    • 整合所有子包解析计划
    • 记录依赖来源、版本、子包依赖关系
  2. 版本冲突解决:

    • 相同依赖不同版本 → 尝试单一兼容版本
    • 不兼容版本 → 子包隔离安装
  3. 扁平化优化:

    • 相同版本依赖只存一份在 .pnpm
    • 子包 node_modules 通过 symlink 指向共享位置
  4. 生成最终安装计划

子包处理

  • 每个子包根据依赖解析计划和全局优化结果生成自己的 node_modules 结构:

    • workspace → symlink 本地包
    • catalog / registry → symlink 全局缓存
  • 子包内部仍未写物理文件,只是确定了“依赖最终放哪、版本是多少”

比喻:像仓库协调采购:

  • 阶段 1 → 每个子包列清单
  • 阶段 2 → 决定哪些物品共享、哪些单独存放,并画好指向箭头(symlink)

阶段 3:下载 & 缓存管理

目标:把 registry / catalog 依赖放到本地缓存。

根目录处理

  • 遍历全局依赖图
  • 检查全局缓存 ~/.pnpm-store
  • 缓存没有 → 从 npm registry 下载
  • 下载完成 → 写入缓存

子包处理

  • 子包 node_modules 不直接存文件
  • symlink 指向全局缓存 / workspace 本地包

比喻:仓库先查库存,没货再买,子包只挂指向箭头。


阶段 4:构建子包 node_modules

目标:把依赖落地到每个子包。

操作

  • 遍历每个子包安装计划:

    • workspace → link 本地包
    • catalog / registry → link 缓存目录
  • 确保扁平化结构、依赖树完整

子包内部示例

apps/web/node_modules/
  @my-org/ui -> ../../packages/ui (symlink)
  react -> ../../.pnpm/react@18.2.0/node_modules/react
  axios -> ../../.pnpm/axios@1.6.0/node_modules/axios

比喻:像把共享仓库的物品放到办公室桌上,通过 symlink 指向真实物品。


阶段 5:生命周期脚本执行

目标:执行子包初始化与构建脚本。

  • preinstall → 安装前准备
  • install → 内部安装行为
  • postinstall → 构建/生成文件/链接工具

子包处理

  • 每个子包可执行自己的 lifecycle scripts,确保安装完成即可运行

阶段 6:lockfile 更新

目标:记录依赖最终版本,保证一致性。

  • 根目录生成/更新 pnpm-lock.yaml

  • 记录:

    • 包名
    • 版本
    • 来源
    • 校验和
  • 子包无需单独操作,但 lockfile 决定未来安装一致性


阶段 7:最终检查 & 完成安装

  • 校验依赖树完整性
  • 确保 symlink、缓存、node_modules 正确
  • 每个子包可直接 import / require 所有依赖

全流程总结

pnpm install (根目录)
 ├─> 初始化 & workspace 扫描 → 构建 Package Map
 │     └─> 子包提供 package.json
 ├─> 遍历子包 → 解析依赖 (workspace / catalog / registry)
 │     └─> 每个子包生成依赖解析计划
 ├─> 版本冲突解决 & 扁平化优化 → 构建全局依赖图
 │     └─> 子包生成最终 node_modules 安装计划
 ├─> 下载缺失依赖 → 写入全局缓存
 │     └─> 子包 symlink 指向缓存 / workspace
 ├─> 构建子包 node_modules → symlink workspace + catalog/registry
 ├─> 执行生命周期脚本 (preinstall / install / postinstall)
 │     └─> 子包完成初始化和构建
 ├─> 更新 lockfile → 记录最终依赖版本与来源
 └─> 最终检查 → 子包依赖完整,安装完成

核心理解

  1. Package Map → workspace 全局视图,子包位置 + 版本
  2. workspace 协议 → 强制本地依赖,保证 link
  3. catalog 协议 → 统一第三方依赖版本
  4. node_modules 构建 → 扁平化 + symlink,每个子包看到完整依赖
  5. 缓存管理 → 下载一次,全局复用
  6. lockfile & lifecycle scripts → 保证安装一致性 + 初始化完成

一句话总结
pnpm install = 根目录扫描 → 协议解析 → 版本冲突优化 → 缓存下载 → 子包 node_modules 构建 → 生命周期脚本执行 → lockfile 更新 → 子包可直接使用。

LeetCode 6. Z 字形变换:两种解法深度解析与优化

在字符串处理类算法题中,Z 字形变换是一道经典的「规律提炼 + 代码优化」双重点题型,频繁出现在面试与算法练习中。它的核心难点不在于实现功能,而在于如何精准捕捉 Z 字形排列的周期性规律,同时兼顾代码的可读性与执行效率。本文将从题目理解出发,详细拆解两种主流解法,对比其优劣,并补充关键注意事项,帮助大家彻底掌握这道题。

一、题目核心解析

题目描述

给定一个字符串 s 和一个整数 numRows,将字符串按从上往下、从左到右的顺序排列成 Z 字形,再从左到右逐行读取字符,返回拼接后的新字符串。

示例演示

输入:s = "PAYPALISHIRING", numRows = 3

Z 字形排列效果:


P   A   H   N
A P L S I I G
Y   I   R

逐行读取结果:"PAHNAPLSIIGYIR"

核心要求

实现函数 convert(s: string, numRows: number): string,高效完成字符串变换,需重点关注边界场景与性能。

二、解法一:数学周期法(公式推导型)

核心思路

Z 字形排列的本质是「周期性重复」,我们先提炼其周期规律:

  1. 一个完整的 Z 字周期包含 2*(numRows-1) 个字符(下行阶段占 numRows 个,上行阶段占 numRows-2 个,不含首尾重复行);

  2. 首行与末行:每个周期内仅存在「垂直列」字符,无斜列字符;

  3. 中间行(非首非末):每个周期内同时存在「垂直列」和「斜列」两个字符,需分别计算位置。

基于此规律,我们可通过数学公式直接定位每个字符在结果中的位置,无需模拟完整 Z 字形排列。

代码实现与逐行解读


function convert_1(s: string, numRows: number): string {
  // 边界条件1:行数为1时,无法形成Z字,直接返回原字符串
  if (numRows === 1) return s;

  const sL = s.length;
  // 计算一个完整Z字周期的字符数(核心变量)
  const groupNum = (numRows - 1) * 2;
  let res = '';

  // 外层循环:逐行读取结果
  for (let i = 0; i < numRows; i++) {
    // 内层循环:逐周期遍历,定位当前行的字符
    for (let j = 0; j < Math.ceil(sL / groupNum); j++) {
      // 1. 定位当前周期内当前行的垂直列字符索引
      const verticalIdx = i + j * groupNum;
      if (verticalIdx >= sL) break; // 超出字符串长度,终止当前行遍历
      res += s[verticalIdx];

      // 2. 仅中间行需要补充斜列字符
      if (i > 0 && i < numRows - 1) {
        const diagonalIdx = groupNum - i + j * groupNum;
        if (diagonalIdx >= sL) break; // 斜列字符超出范围,跳过
        res += s[diagonalIdx];
      }
    }
  }
  return res;
}

关键注意事项

  • 边界处理:必须优先判断 numRows === 1,否则 groupNum会计算为 0,导致循环逻辑异常;

  • 索引合法性:每次计算垂直列、斜列索引后,需判断是否超出字符串长度,避免越界报错;

  • 斜列字符范围:仅中间行存在斜列字符,首行与末行需跳过此步骤,否则会重复拼接字符。

三、解法二:方向模拟法(直观优化型)

优化思路

数学周期法虽空间效率高,但公式推导对新手不友好,且 JavaScript 中字符串不可变,频繁使用res += 会创建大量临时字符串,存在性能损耗。方向模拟法通过「模拟字符移动轨迹」简化逻辑,同时用数组优化字符串拼接:

  1. 用数组 rows 存储每一行的字符,避免频繁字符串拼接;

  2. 定义 currentRow 跟踪当前字符所在行,step 控制移动方向(1 向下,-1 向上);

  3. 当到达首行或末行时,反转移动方向,实现 Z 字形的「下落-上升」循环。

代码实现与逐行解读


function convert_2(s: string, numRows: number): string {
  // 边界条件优化:行数为1 或 行数≥字符串长度,直接返回原字符串
  if (numRows === 1 || numRows >= s.length) return s;

  // 初始化每行存储容器,用数组避免频繁字符串拼接
  const rows: string[] = new Array(numRows).fill('');
  const cycleLen = 2 * (numRows - 1); // 周期长度(仅作逻辑参考,非必需)
  let currentRow = 0; // 当前字符所在行
  let step = -1; // 移动方向:-1向上,1向下

  // 遍历每个字符,分配到对应行
  for (const char of s) {
    rows[currentRow] += char;

    // 到达首行/末行,反转移动方向
    if (currentRow === 0 || currentRow === numRows - 1) {
      step = -step;
    }
    currentRow += step;
  }

  // 拼接所有行字符,得到最终结果
  return rows.join('');
}

关键注意事项

  • 边界条件补充:新增 numRows >= s.length 判断,此时字符串无法形成 Z 字,直接返回原字符串,减少无效计算;

  • 方向初始化:step 初始值设为 -1,是因为首次进入循环后,currentRow 为 0,会触发方向反转,使第一步移动方向为向下(step=1);

  • 数组优化:JavaScript 中数组拼接效率远高于字符串累加,尤其适合长字符串场景,这是该解法的核心性能优势。

四、两种解法对比与场景选择

维度 数学周期法 方向模拟法
时间复杂度 O(n)(每个字符仅访问一次) O(n)(每个字符仅访问一次)
空间复杂度 O(1)(仅用临时变量,无额外存储) O(n)(需数组存储每行字符)
可读性 较差,需理解公式推导逻辑 优秀,模拟过程直观易懂
性能表现 长字符串场景下,因频繁字符串拼接略逊 数组优化拼接,性能更稳定
适用场景 低内存环境、对空间效率要求极高的场景 日常开发、面试答题(兼顾可读性与性能)

五、测试验证与常见问题

测试用例覆盖


// 测试用例1:基础场景
console.log(convert_1("PAYPALISHIRING", 3)); // 输出:PAHNAPLSIIGYIR
console.log(convert_2("PAYPALISHIRING", 3)); // 输出:PAHNAPLSIIGYIR

// 测试用例2:行数为4
console.log(convert_1("PAYPALISHIRING", 4)); // 输出:PINALSIGYAHRPI
console.log(convert_2("PAYPALISHIRING", 4)); // 输出:PINALSIGYAHRPI

// 测试用例3:边界场景(行数=1)
console.log(convert_1("A", 1)); // 输出:A
console.log(convert_2("A", 1)); // 输出:A

// 测试用例4:边界场景(行数≥字符串长度)
console.log(convert_2("ABCDE", 5)); // 输出:ABCDE

常见问题排查

  1. 字符重复/缺失:大概率是斜列字符判断逻辑错误,需检查 i > 0 && i < numRows - 1 条件是否遗漏;

  2. 越界报错:未判断索引合法性,需在拼接字符前校验 verticalIdxdiagonalIdx 是否小于字符串长度;

  3. 方向异常:step 初始化错误或方向反转逻辑缺失,需确保到达首行/末行时反转方向。

六、总结

Z 字形变换的核心是「抓住周期性」,两种解法均基于这一核心规律,只是实现路径不同:数学周期法偏重于公式推导,追求极致空间效率;方向模拟法偏重于直观模拟,用合理的空间换可读性与性能。

在面试中,推荐优先使用方向模拟法,其逻辑清晰、不易出错,能快速向面试官展现思路;若面试官追问空间优化,可再补充数学周期法的思路。同时,边界条件的全面覆盖的是这道题的得分关键,需牢记行数为 1、行数≥字符串长度这两个特殊场景。

掌握这道题的思考方式,能为后续解决字符串、数组类的规律型题目提供借鉴,核心是学会从复杂排列中提炼简化规律,再通过代码优化平衡效率与可读性。

flex 布局凭什么成为前端排版的天花板?它让我告别了 float 的所有噩梦

📝 前言

还在为调个页面布局熬大夜?用 float 排个版,元素到处乱跑;用 position 定个位,稍微改点东西就全乱套。不想熬夜了😭!

别慌,今天咱们就来聊聊前端界的 “救星”Flex 弹性布局。它就像一个贴心的收纳师,不用绕弯子,不用死抠像素,几行代码就能把元素摆得整整齐齐,从此告别 “叠罗汉” 式的布局噩梦。

🔧 初识 Flex:一个容器搞定所有排列

Flex 布局的核心思想很简单:先让父容器变身成弹性容器,剩下的事情就交给它来安排。只需要一行代码:

.box {
    display: flex;
}

配上html:

<div class="box">
    <div class="item">1</div>
    <div class="item">2</div>
    <div class="item">3</div>
</div>

image.png

默认情况下,子元素会自动在水平方向上排成一行,这就是Flex默认主轴方向

🎯 主轴与交叉轴:布局的 “指挥棒”

Flex 容器有两根 “指挥棒”:主轴(默认水平方向)和交叉轴(默认垂直方向)。

  • justify-content 管主轴上的排列方式,比如 space-evenly 可以让元素之间的间距完全相等。
  • align-items 管交叉轴上的对齐方式,比如 center 可以让元素在垂直方向上居中。

如果你想把主轴改成垂直方向,只需要给容器加一句:

.box {
    flex-direction: column;
}

就像 index.html 里这样设置后,三个彩色小方块就会乖乖地垂直排列,并且在容器里均匀分布、居中对齐。

.box{
    width: 800px;
    height: 400px;
    border: 1px solid #000;
    display: flex;
    flex-direction: column;
    justify-content: space-evenly;
    align-items: center;
}

image.png

🧩 换行与多轴控制:空间不够?拆分成行!

如果子元素太多,一行放不下怎么办?别担心,flex-wrap: wrap 可以让元素自动换行,就像 index2.html 里这样:

.box {
    flex-wrap: wrap;
    align-content: space-around;
}

补充: align-content 这个属性是专门用来控制换行后,多行元素之间的间距的,比如 space-around 可以让每一行上下都有均匀的留白

image.png

📊 空间分配:谁胖谁瘦,我说了算

当容器有多余空间时,flex-grow 就会派上用场。它就像给子元素分配 “长胖” 的名额,数值越大,占的空间就越多。比如在 index3.html 里:

.item:nth-child(1){
    flex-grow: 1;
}
.item:nth-child(2){
    flex-grow: 2;
}
.item:nth-child(3){
    flex-grow: 1;
}

这就意味着,中间的绿色方块会 “长胖” 两倍,左右两个方块各 “长胖” 一倍,完美填满整个容器。

image.png

🎨 个性化定制:给某个元素开小灶

如果想让某个子元素在交叉轴上的对齐方式与众不同align-self 就是你的专属工具。就像 index4.html 里这样,只有中间的绿色方块被单独设置成了 align-self: center,在垂直方向上居中,而其他方块保持默认对齐。

.item:nth-child(2){
    align-self: center;
}

image.png

🌐 实战王者:三栏布局轻松拿捏

Flex 布局最经典的应用场景之一就是三栏布局。比如 three.html 这个例子,我们可以轻松实现 “左侧 + 主体 + 右侧” 的布局,而且主体部分会自动填充剩余空间。

.page {
    height: 200px;
    display: flex;
}
.left, .right {
    width: 200px;
    background-color: #e17a7a;
}
.main {
    background-color: aquamarine;
    flex: 1;
}
.left {
    order: -1;
}

这里的 flex: 1 让主体部分自动占满剩余空间order: -1 则把左侧栏提前,即使它在 HTML 结构里是写在主体后面的。

image.png

✨ 结语

Flex 布局就像一把万能钥匙,从简单的居中对齐到复杂的响应式布局,它都能轻松搞定。

只要掌握了 display: flexjustify-contentalign-items 这些核心属性,再加上 flex-groworder 这些 “调味剂”,你就能从布局 “小白” 秒变 “大神”。

快去让你的页面元素都乖乖听话吧!

小白理解Catalog 协议

在 monorepo 项目里,你可能见过这样的写法:

{
  "dependencies": {
    "react": "catalog:"
  }
}

很多小伙伴第一眼都会问:

  • catalog: 是什么?
  • 为什么不直接写版本号?
  • 它和 workspace: 有什么区别?

这篇文章会帮你一次性搞懂 Catalog 协议的设计初衷、使用方法和原理


一、Catalog 协议的核心结论

Catalog 协议是 pnpm 提供的一种“集中管理依赖版本号”的机制
用来统一管理 monorepo 中的第三方依赖版本,解决版本分散、升级难的问题。

用一句话总结 Catalog 的定位:

  • workspace: 决定“这个依赖从哪来”(来源)
  • catalog: 决定“这个依赖用哪个版本”(版本)

二、为什么要有 Catalog?

在大型 monorepo 中,通常有很多包依赖同一个库,比如 reactlodash

// apps/web
"react": "^18.2.0"

// apps/admin
"react": "^18.2.0"

// packages/ui
"react": "^18.1.0" // 不小心写错了

问题:

  • 多处重复写版本号,维护成本高
  • 很容易出现版本不一致
  • 升级某个依赖,需要改很多包
  • 新增包不知道该用哪个版本

Catalog 的设计初衷就是解决这些问题——
让版本号集中管理,子包只负责声明“我要用这个依赖”。


三、Catalog 的写法

1️⃣ 在 workspace 根部声明

# pnpm-workspace.yaml
packages:
  - apps/*
  - packages/*

catalog:
  react: ^18.2.0
  react-dom: ^18.2.0
  axios: ^1.6.0
  • 所有依赖的“版本来源”都在这里集中声明
  • 子包无需关心版本号

2️⃣ 在子包里使用

{
  "dependencies": {
    "react": "catalog:",
    "axios": "catalog:"
  }
}

特点:

  • 不写具体版本号
  • pnpm 会自动去 workspace catalog 中查找对应版本
  • 默认使用依赖名作为 key(也可以写成 catalog:react 显式指定)

四、Catalog 的原理(通俗理解)

可以把 Catalog 想象成一个 “依赖版本宏”

  1. pnpm 解析依赖时,看到 "catalog:"
  2. 去 workspace 的 catalog 表里查对应依赖的版本号
  3. 展开成普通 semver,比如 "^18.2.0"
  4. 后续安装流程就像普通依赖一样

⚠️ Catalog 只解决版本号,不影响依赖来源
本地包仍然用 workspace 协议管理


五、为什么写成 catalog:

  • package.json 中依赖值只能是字符串
  • pnpm 内部通过 协议前缀: 来识别依赖类型
  • 类似 workspace:*file:git:
  • catalog:延迟解析版本号的协议,不会破坏 JSON 规范

简化写法:

"react": "catalog:"   // 默认 key = react

显式写法:

"react": "catalog:react"

两者效果相同。


六、Catalog 与 workspace 的区别

维度 workspace catalog
解决的问题 依赖从哪来 版本由谁定
主要对象 本地包 第三方包
安装行为 强制使用本地包 展开成指定版本号
发布行为 workspace:* 替换成真实版本号 展开后就是普通依赖
是否指向本地路径

一句话总结:

workspace 决定“用谁”,catalog 决定“用哪个版本”。


七、什么时候使用 Catalog?

✅ 适合:

  • 多个包共享大量第三方依赖
  • 希望依赖版本统一,易于升级
  • 中大型团队协作的 monorepo

❌ 不太需要:

  • 单包项目
  • 实验性仓库
  • 每个包技术栈完全不同

八、完整示例

# pnpm-workspace.yaml
packages:
  - apps/*
  - packages/*

catalog:
  react: ^18.2.0
  axios: ^1.6.0
// apps/web/package.json
{
  "dependencies": {
    "react": "catalog:",
    "axios": "catalog:"
  }
}
  • pnpm 安装时,会把 catalog: 展开成 "^18.2.0"
  • 子包无需关心版本号,只管使用
  • 保证整个 workspace 的版本一致

九、总结

Catalog 协议不是安装机制,而是版本管理机制。
它在 monorepo 中提供了一个“单一版本源头”,
子包只负责声明依赖,版本号由根目录统一控制。
与 workspace 协议配合使用,可以实现来源锁定 + 版本统一的高效管理。

小白理解workspace 协议

在使用 pnpm 的 monorepo 项目时,你可能见过这样的写法:

{
  "dependencies": {
    "@my-org/ui": "workspace:*"
  }
}

很多初学者都会疑惑:

  • workspace: 是什么?
  • 不写它行不行?
  • 它和版本号有什么关系?

这篇文章会用最直观、最少前置知识的方式,带你一次搞懂 pnpm workspace 协议的原理和使用


一、先说结论(一句话版本)

workspace: 协议的作用是:
强制 pnpm 使用当前 workspace 里的本地包,而不是去 npm 仓库下载。

它解决的不是“版本问题”,而是一个更基础的问题:

👉 这个依赖“从哪来”?


二、没有 workspace: 会发生什么?

先看一个最常见的 monorepo 结构:

packages/
  ui        (name: @my-org/ui)
apps/
  web

apps/web/package.json 中写:

{
  "dependencies": {
    "@my-org/ui": "^1.0.0"
  }
}

这时 pnpm 的行为是:

  1. 在 workspace 中查找 @my-org/ui
  2. 如果本地存在,并且版本满足 ^1.0.0
  3. 👉 默认使用本地包

也就是说:

即使你不写 workspace:,pnpm 也“通常”会用本地包

那问题来了——
既然这样,workspace: 有什么用?


三、workspace: 到底解决了什么问题?

1️⃣ 明确表达“这是一个本地依赖”

当你写:

{
  "@my-org/ui": "workspace:*"
}

你是在向 pnpm 明确声明一件事:

这个依赖必须来自当前 workspace,本地没有就直接报错。

它的意义不是“让 pnpm 能用本地包”,
而是 防止 pnpm 用错包


2️⃣ 防止“误用远程同名包”

假设有一天:

  • 你的 workspace 里还没有 @my-org/ui
  • 但 npm 仓库里刚好有一个同名包

如果你写的是:

"@my-org/ui": "^1.0.0"

pnpm 会:

👉 去 npm 仓库下载那个包

但如果你写的是:

"@my-org/ui": "workspace:*"

pnpm 会直接:

❌ 报错:workspace 中不存在该包

👉 这就是 workspace: 的安全价值


四、workspace 协议的“真实原理”(人话版)

pnpm 在安装依赖时,会先把每个依赖解析成一种“类型”:

  • 普通 semver(^1.0.0
  • workspace 协议
  • file / git / link 等

当 pnpm 看到:

"@my-org/ui": "workspace:*"

它内部会把这个依赖标记为:

“只能从 workspace Package Map 中解析”

接下来的规则非常简单:

  • ✅ workspace 里有 → 用本地包
  • ❌ workspace 里没有 → 直接失败
  • 🚫 不会查 npm registry

所以可以这样理解:

workspace: 是一种“来源锁定协议”


五、workspace:* / workspace:^ / workspace:~ 有什么区别?

这是很多人第一次看到会懵的地方。

结论先给出来:

它们在“安装阶段没有区别”,
区别只体现在“发布阶段”。


1️⃣ 安装阶段(最重要的阶段)

无论你写:

workspace:*
workspace:^
workspace:~

pnpm 都会:

  • 忽略版本判断
  • 直接使用本地包

👉 安装行为完全一致


2️⃣ 发布阶段(publish 到 npm 时)

workspace 协议 不会被发布,而是会被替换:

假设本地包版本是 1.2.3

写法 发布后
workspace:* ^1.2.3
workspace:^ ^1.2.3
workspace:~ ~1.2.3

所以:

workspace 后面的符号,
本质是在帮你决定将来别人怎么依赖你


六、什么时候“应该”用 workspace:?

✅ 推荐使用的场景

  • monorepo 内部包互相依赖
  • 希望明确表达“这是内部依赖”
  • 不希望误用远程同名包
  • 希望发布时自动生成合理版本号

👉 团队项目 / 长期维护项目:强烈推荐


❌ 可以不写的场景

  • 个人实验项目
  • 确定不会 publish
  • 对依赖来源不敏感

👉 即使不写,pnpm 多数时候也能正常工作
不写等于放弃了一层安全约束


七、一个完整示例

packages/ui/package.json

{
  "name": "@my-org/ui",
  "version": "1.0.0"
}

apps/web/package.json

{
  "dependencies": {
    "@my-org/ui": "workspace:*"
  }
}

pnpm 的理解是:

@my-org/ui 必须来自当前 workspace,
安装时用本地包,
发布时替换成真实版本号。


八、总结

workspace: 协议不是版本控制工具,而是依赖来源控制工具。
它的作用是明确告诉 pnpm:
“这个依赖只能来自当前 workspace,而不是外部仓库。”

普通Fetch和Fetch 流式的区别?

你想弄清楚 Fetch 流式的核心定义、工作原理和实际价值,简单来说,Fetch 流式是 Fetch API 提供的「边接收、边处理」数据的能力,它让前端不再需要等待服务端返回完整的响应数据,而是能逐块读取、处理数据,是前端处理大文件、实时数据的核心能力之一。


一、Fetch 流式的核心定义

Fetch 流式(Fetch Streaming)是基于浏览器原生 Fetch API 实现的流式数据处理能力

  • 普通 Fetch 请求:服务端返回完整的响应数据,前端一次性接收所有数据后才能处理(比如 res.json()/res.text() 都是一次性解析);
  • Fetch 流式:服务端通过 HTTP Chunked(分块传输编码)返回数据,Fetch 请求的响应体 response.body 会返回 ReadableStream(可读流)对象,前端可以逐块读取服务端发送的「数据块」,边接收、边解析、边处理,无需等待整个响应完成。

核心原理(一句话讲透)

Fetch 流式的底层是「HTTP 分块传输 + 浏览器 Streams API」的组合:

  1. 服务端开启 HTTP Chunked 编码,将数据切分成多个独立的「数据块」,逐个发送给前端;
  2. 前端 Fetch 拿到响应后,response.body 暴露为 ReadableStream 可读流;
  3. 前端通过流的读取器(reader)逐块读取这些数据,实现流式处理。

二、Fetch 流式的核心用法(极简代码示例)

Fetch 流式的核心是操作 response.body(ReadableStream),以下是最常见的 2 个场景:

场景 1:流式读取文本 / JSON 数据(比如实时日志、大模型打字机效果)

javascript

运行

async function streamFetchText(url) {
  // 1. 发起 Fetch 请求
  const response = await fetch(url);
  
  // 校验:确保响应有效且支持流式(response.body 是 ReadableStream)
  if (!response.ok || !response.body) {
    throw new Error("请求失败或不支持流式响应");
  }

  // 2. 获取流的读取器
  const reader = response.body.getReader();
  // 3. 二进制流转文本的解码器(关键:stream: true 保证分块解码不乱码)
  const decoder = new TextDecoder("utf-8", { stream: true });

  try {
    // 4. 循环逐块读取数据(流式核心逻辑)
    while (true) {
      // done: 是否读取完成;value: 当前数据块(Uint8Array 二进制格式)
      const { done, value } = await reader.read();
      
      if (done) break; // 读取完成,退出循环

      // 5. 实时处理当前数据块(比如渲染到页面、解析JSON)
      const chunkText = decoder.decode(value);
      console.log("实时读取的内容块:", chunkText);
      // 示例:实时追加到页面(模拟大模型打字机效果)
      document.getElementById("content").textContent += chunkText;
    }
  } finally {
    // 6. 释放读取器(关键:避免内存泄漏)
    reader.releaseLock();
  }
}

// 调用示例(比如请求返回流式文本的接口)
streamFetchText("/api/stream-log");

场景 2:流式下载大文件(避免内存溢出)

javascript

运行

async function streamDownloadFile(url, fileName) {
  const response = await fetch(url);
  if (!response.ok || !response.body) throw new Error("下载失败");

  // 1. 将流式响应体转为 Blob(浏览器自动拼接分块数据)
  const blob = await new Response(response.body).blob();
  // 2. 创建下载链接并触发下载
  const downloadUrl = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = downloadUrl;
  a.download = fileName;
  a.click();

  // 3. 清理资源
  URL.revokeObjectURL(downloadUrl);
}

// 调用示例(下载1GB+的大文件)
streamDownloadFile("/api/download/large-file", "超大文件.zip");

三、Fetch 流式的核心使用场景

  1. 大文件下载:下载 100MB+ 的文件时,流式下载仅占用少量内存,避免一次性加载导致的页面卡顿 / 内存溢出;
  2. 实时数据接收:接收服务端推送的实时日志、后台任务进度、大模型的流式响应(打字机效果);
  3. 超大 JSON 解析:服务端返回百万条数据的 JSON 列表时,流式逐块解析,实时渲染到页面(比如表格);
  4. 二进制流处理:流式读取图片 / 视频等二进制文件,实时预览或处理(比如图片压缩)。

四、Fetch 流式 vs 普通 Fetch 的核心区别

维度 普通 Fetch Fetch 流式
数据接收方式 一次性接收完整响应数据 逐块接收服务端的分块数据
响应体类型 需通过 res.json()/res.text()一次性解析 response.body是 ReadableStream 可读流
内存占用 完整数据存入内存,大文件易溢出 仅占用当前数据块的内存,占用恒定
处理时机 必须等待响应完全返回后才能处理 边接收、边处理,实时响应
适用场景 小数据 / 小文件请求 大文件、实时数据、超大 JSON 请求

总结

  1. 核心本质:Fetch 流式是 Fetch API 结合 HTTP Chunked 分块传输,通过 response.body(ReadableStream)实现的逐块数据处理能力;
  2. 核心价值:解决大文件 / 实时数据一次性加载导致的「内存溢出、页面卡顿、等待时间长」问题;
  3. 核心用法:通过 reader.read() 循环读取数据块,配合 TextDecoder 处理文本,或直接转为 Blob 实现流式下载。

Fetch 流式是前端流式开发的基础能力,也是面试中高频问到的考点,核心记住「response.body 是 ReadableStream」「逐块读取」「释放读取器」这三个关键点即可。

AI 相关:大模型的流式响应(打字机效果)实例?结合fetch和eventSource和AbortController

大模型流式响应(打字机效果)完整实例

你需要的是结合 FetchEventSource(SSE)AbortController 实现大模型流式响应(打字机效果)的可运行实例,下面将分别提供两种方案的完整代码(前端 + 配套服务端),核心实现「逐字实时渲染」和「请求取消」功能,可直接复制落地。

一、核心说明

  1. 打字机效果本质:大模型不是一次性返回完整结果,而是分块返回文本片段,前端实时追加渲染,模拟打字机逐字输出的效果。
  2. AbortController 作用:用于手动取消流式请求(比如用户点击「停止生成」按钮),中断后续数据传输,释放资源。
  3. 两种方案对比:
    • Fetch 流式:通用灵活,支持任意数据格式,无自动重连,需手动处理分块解析;
    • EventSource (SSE):轻量简洁,自带自动重连,服务端需遵循 text/event-stream 格式,适合单向流式推送。

二、方案 1:Fetch 流式 + AbortController 实现

(一)前端代码(完整可运行)

html

预览

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>大模型流式响应(Fetch+AbortController)</title>
  <style>
    #result {
      width: 800px;
      height: 400px;
      border: 1px solid #ccc;
      padding: 16px;
      font-size: 16px;
      line-height: 1.6;
      overflow-y: auto;
      margin: 20px 0;
    }
    button {
      padding: 8px 16px;
      font-size: 16px;
      margin-right: 10px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <h3>Fetch 流式实现大模型打字机效果</h3>
  <button id="startBtn">开始生成</button>
  <button id="stopBtn" disabled>停止生成</button>
  <div id="result"></div>

  <script>
    // 1. 初始化 AbortController(用于取消请求)
    let abortController = null;
    const resultDom = document.getElementById('result');
    const startBtn = document.getElementById('startBtn');
    const stopBtn = document.getElementById('stopBtn');

    // 2. 核心:Fetch 流式获取大模型响应并实现打字机效果
    async function fetchStreamChat(prompt) {
      // 重置结果容器
      resultDom.textContent = '';
      // 创建新的 AbortController 实例(每次请求重新创建)
      abortController = new AbortController();
      const signal = abortController.signal;

      try {
        // 发起 Fetch 流式请求,传入 signal 用于取消
        const response = await fetch('/api/chat/fetch-stream', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({ prompt }),
          signal: signal, // 绑定取消信号
        });

        // 校验响应有效性
        if (!response.ok || !response.body) {
          throw new Error(`请求失败:${response.status}`);
        }

        // 获取可读流和文本解码器(stream: true 保证分块解码不乱码)
        const reader = response.body.getReader();
        const decoder = new TextDecoder('utf-8', { stream: true });

        // 循环逐块读取流式数据(打字机核心逻辑)
        while (true) {
          // 监听取消信号,若已取消则退出循环
          if (signal.aborted) {
            console.log('Fetch 流式请求已被手动取消');
            break;
          }

          // 逐块读取数据
          const { done, value } = await reader.read();
          if (done) break; // 读取完成

          // 解码二进制流为文本并实时追加到DOM(打字机效果)
          const chunkText = decoder.decode(value);
          resultDom.textContent += chunkText;
          // 自动滚动到底部
          resultDom.scrollTop = resultDom.scrollHeight;
        }

        // 释放读取器资源
        reader.releaseLock();
      } catch (err) {
        if (err.name === 'AbortError') {
          resultDom.textContent += '\n\n【请求已手动停止】';
        } else {
          console.error('流式请求异常:', err);
          resultDom.textContent = `请求失败:${err.message}`;
        }
      } finally {
        // 重置按钮状态和 AbortController
        abortController = null;
        startBtn.disabled = false;
        stopBtn.disabled = true;
      }
    }

    // 3. 按钮点击事件绑定
    startBtn.addEventListener('click', () => {
      startBtn.disabled = true;
      stopBtn.disabled = false;
      // 传入提问词
      fetchStreamChat('请用简洁的语言介绍前端流式开发的核心价值');
    });

    stopBtn.addEventListener('click', () => {
      // 手动取消流式请求
      if (abortController) {
        abortController.abort(); // 触发信号中断请求
        stopBtn.disabled = true;
      }
    });
  </script>
</body>
</html>

(二)配套服务端代码(Node.js/Express,模拟大模型分块响应)

javascript

运行

const express = require('express');
const app = express();
app.use(express.json()); // 解析JSON请求体

// Fetch 流式聊天接口(模拟大模型分块返回文本)
app.post('/api/chat/fetch-stream', (req, res) => {
  const { prompt } = req.body;
  console.log('收到提问:', prompt);

  // 核心:开启 HTTP Chunked 分块传输(无需手动设置,res.write 自动触发)
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
  res.setHeader('Cache-Control', 'no-cache');

  // 模拟大模型生成的文本(分块返回)
  const responseText = `前端流式开发的核心价值主要有三点:
1.  低内存占用:无需一次性加载完整大文件/大数据,仅处理当前数据块,避免内存溢出和页面卡顿;
2.  低延迟体验:用户无需等待完整数据返回,即可实时看到内容(如大模型打字机效果、视频边播边加载);
3.  适配实时数据:可处理无固定结束节点的实时数据流(如实时日志、股票行情、音视频流)。
这三大价值让流式开发成为处理大文件和实时场景的必备技术。`;

  // 分块切割文本(模拟逐字返回,每50毫秒返回一个字符,实现打字机效果)
  let index = 0;
  const chunkInterval = setInterval(() => {
    if (index >= responseText.length) {
      clearInterval(chunkInterval);
      res.end(); // 结束响应
      return;
    }
    // 逐字符写入响应(分块传输核心)
    res.write(responseText[index]);
    index++;
  }, 50);

  // 监听客户端取消请求(前端调用 abort() 时触发)
  req.on('close', () => {
    clearInterval(chunkInterval);
    res.end();
    console.log('客户端取消了 Fetch 流式请求');
  });
});

// 静态文件托管(前端HTML文件放在根目录即可访问)
app.use(express.static('.'));

app.listen(3000, () => {
  console.log('服务启动成功:http://localhost:3000');
});

三、方案 2:EventSource (SSE) + AbortController 实现

(一)前端代码(完整可运行)

html

预览

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>大模型流式响应(SSE+AbortController)</title>
  <style>
    #result {
      width: 800px;
      height: 400px;
      border: 1px solid #ccc;
      padding: 16px;
      font-size: 16px;
      line-height: 1.6;
      overflow-y: auto;
      margin: 20px 0;
    }
    button {
      padding: 8px 16px;
      font-size: 16px;
      margin-right: 10px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <h3>SSE(EventSource) 实现大模型打字机效果</h3>
  <button id="startBtn">开始生成</button>
  <button id="stopBtn" disabled>停止生成</button>
  <div id="result"></div>

  <script>
    // 1. 初始化变量:EventSource 实例 + AbortController
    let eventSource = null;
    let abortController = null;
    const resultDom = document.getElementById('result');
    const startBtn = document.getElementById('startBtn');
    const stopBtn = document.getElementById('stopBtn');

    // 2. 核心:SSE 流式获取大模型响应
    function sseStreamChat(prompt) {
      // 重置结果容器
      resultDom.textContent = '';
      // 创建 AbortController 实例
      abortController = new AbortController();
      const signal = abortController.signal;

      // 拼接请求参数(SSE 仅支持 GET 请求,参数通过 URL 传递)
      const encodedPrompt = encodeURIComponent(prompt);
      const sseUrl = `/api/chat/sse-stream?prompt=${encodedPrompt}`;

      // 创建 EventSource 实例(SSE 客户端核心)
      eventSource = new EventSource(sseUrl);

      // 3. 监听 SSE 消息(打字机核心逻辑)
      eventSource.onmessage = (e) => {
        // 实时追加文本到 DOM
        resultDom.textContent += e.data;
        // 自动滚动到底部
        resultDom.scrollTop = resultDom.scrollHeight;
      };

      // 4. 监听 SSE 连接打开
      eventSource.onopen = () => {
        console.log('SSE 连接已建立');
        startBtn.disabled = true;
        stopBtn.disabled = false;
      };

      // 5. 监听 SSE 错误
      eventSource.onerror = (e) => {
        if (eventSource.readyState === EventSource.CLOSED) {
          console.log('SSE 连接已关闭');
        } else {
          console.error('SSE 连接异常:', e);
          resultDom.textContent += '\n\n【SSE 连接异常】';
        }
        // 重置状态
        resetSSEState();
      };

      // 6. 绑定 AbortController 信号(监听取消事件)
      signal.addEventListener('abort', () => {
        if (eventSource) {
          eventSource.close(); // 关闭 SSE 连接
          resultDom.textContent += '\n\n【请求已手动停止】';
          console.log('SSE 流式请求已被手动取消');
        }
      });
    }

    // 7. 重置 SSE 状态
    function resetSSEState() {
      eventSource = null;
      abortController = null;
      startBtn.disabled = false;
      stopBtn.disabled = true;
    }

    // 8. 按钮点击事件绑定
    startBtn.addEventListener('click', () => {
      sseStreamChat('请用简洁的语言介绍前端流式开发的核心价值');
    });

    stopBtn.addEventListener('click', () => {
      // 手动取消 SSE 请求(通过 AbortController 触发)
      if (abortController) {
        abortController.abort();
        stopBtn.disabled = true;
      }
    });
  </script>
</body>
</html>

(二)配套服务端代码(Node.js/Express,SSE 格式响应)

javascript

运行

const express = require('express');
const app = express();
app.use(express.json());

// SSE 流式聊天接口(必须返回 text/event-stream 格式)
app.get('/api/chat/sse-stream', (req, res) => {
  const { prompt } = req.query;
  console.log('收到提问:', prompt);

  // 核心:设置 SSE 专属响应头
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
  res.setHeader('Access-Control-Allow-Origin', '*'); // 跨域需配置

  // 模拟大模型生成的文本
  const responseText = `前端流式开发的核心价值主要有三点:
1.  低内存占用:无需一次性加载完整大文件/大数据,仅处理当前数据块,避免内存溢出和页面卡顿;
2.  低延迟体验:用户无需等待完整数据返回,即可实时看到内容(如大模型打字机效果、视频边播边加载);
3.  适配实时数据:可处理无固定结束节点的实时数据流(如实时日志、股票行情、音视频流)。
这三大价值让流式开发成为处理大文件和实时场景的必备技术。`;

  // 分块返回(遵循 SSE 格式:data: 内容\n\n)
  let index = 0;
  const chunkInterval = setInterval(() => {
    if (index >= responseText.length) {
      clearInterval(chunkInterval);
      // 发送结束消息
      res.write('data: \n\n');
      res.end();
      return;
    }
    // SSE 格式:data: 单个字符\n\n(逐字返回,实现打字机效果)
    res.write(`data: ${responseText[index]}\n\n`);
    index++;
  }, 50);

  // 监听客户端断开连接
  req.on('close', () => {
    clearInterval(chunkInterval);
    res.end();
    console.log('客户端取消了 SSE 流式请求');
  });
});

// 静态文件托管
app.use(express.static('.'));

app.listen(3000, () => {
  console.log('服务启动成功:http://localhost:3000');
});

四、关键知识点总结

  1. AbortController 核心用法
    • 作用:统一取消异步请求(Fetch/SSE 均支持),避免无效数据传输和内存泄漏;
    • 流程:创建 new AbortController() → 获取 signal 信号 → 绑定到请求 → 调用 abort() 取消请求;
    • 差异:Fetch 直接通过 signal 参数绑定,SSE 通过监听 signal.abort 事件手动关闭连接。
  1. 打字机效果核心
    • 前端:实时接收分块文本 → 逐块追加到 DOM(textContent += 片段)→ 自动滚动到底部;
    • 服务端:分块返回文本(Fetch 用 res.write,SSE 用 data: 片段\n\n)→ 控制返回间隔(模拟打字速度)。
  1. 两种方案选型
    • 选 Fetch 流式:需要 POST 请求(传递大量参数)、无需自动重连、需处理二进制流;
    • 选 SSE:仅需 GET 请求、需要自动重连、追求轻量简洁(无需手动处理分块解析)。
  1. 避坑点
    • Fetch 流式:必须设置 TextDecoder({ stream: true }),否则分块解码会出现中文乱码;读取完成后需调用 reader.releaseLock() 释放资源;
    • SSE:服务端必须返回 text/event-stream 格式,且每条消息以 \n\n 结尾;仅支持 GET 请求,参数需 URL 编码。

promise-logic -- 声明式 Promise 逻辑组合

用逻辑概念替代 API 记忆
promise-logic 的设计是:开发者应专注于业务逻辑,而非 Promise API 的细节
传统 Promise 组合(如 Promise.allPromise.race)的命名与语义不够直观,尤其在复杂异步场景下,代码可读性迅速下降。
promise-logic 通过逻辑门(Logic Gate) 的方式,将异步组合抽象为 andorxor 等逻辑操作,使代码语义清晰、逻辑自解释。


相关功能

  1. 逻辑语义化

    • and:所有任务必须成功(等价于 Promise.all

    • or:至少一个任务成功(等价于 Promise.race

    • xor有且仅有一个任务成功

    • nand:所有任务均失败

    • not:反转单个 Promise 的结果

    • majority:多数任务成功

  2. 零依赖
    仅依赖原生 Promise,无额外运行时依赖。

  3. 全测试覆盖
    所有逻辑门均经过严格单元测试,确保行为符合预期。

  4. 错误分类明确

    • PromiseLogicError 统一错误类型
    • error.type 区分具体逻辑错误(如 'XOR_ERROR'

安装

npm install promise-logic

快速开始

示例:主备服务调用(XOR 场景)

import { PromiseLogic } from 'promise-logic';

// 主服务调用
const primary = fetch('https://api.main.com/data');
// 备用服务调用
const backup = fetch('https://api.backup.com/data');

// 执行 XOR 逻辑:有且仅有一个成功
PromiseLogic.xor([primary, backup])
  .then(result => {
    console.log('成功获取数据:', result);
  })
  .catch(error => {
    if (error.type === 'XOR_ERROR') {
      console.error('主备服务均成功或均失败,不符合 XOR 语义');
    } else {
      console.error('网络错误:', error);
    }
  });

示例:多数决决策(Majority 场景)

import { PromiseLogic } from 'promise-logic';

const services = [
  fetch('https://api.node1.com/vote'),
  fetch('https://api.node2.com/vote'),
  fetch('https://api.node3.com/vote')
];

PromiseLogic.majority(services)
  .then(results => {
    console.log('多数服务返回成功:', results);
  })
  .catch(error => {
    console.error('多数服务失败:', error);
  });

typescript类型断言场景

import { PromiseLogic } from 'promise-logic/typescript';

const services = [
  fetch('https://api.node1.com/vote'),
  fetch('https://api.node2.com/vote'),
  fetch('https://api.node3.com/vote')
];

//可以进行类型断言,也可以默认让PromiseLogic自动推断类型
PromiseLogic.majority<Response>(services)
  .then(results => {
    console.log('多数服务返回成功:', results);
  })
  .catch(error => {
    console.error('多数服务失败:', error);
  });

API 参考

API 说明
and 所有 Promise 成功,返回结果数组;任一失败则整体失败。
or 至少一个 Promise 成功,返回首个成功结果;全部失败则整体失败。
xor 有且仅有一个 Promise 成功,返回该结果;否则抛出 XOR_ERROR
nand 所有 Promise 均失败,返回错误数组;任一成功则整体失败。
not 反转单个 Promise 的结果
majority 超过半数 Promise 成功,返回成功结果数组;否则整体失败。

资源链接

pnpm+pnpm-workspace怎么关联本地包?

一、最核心的问题先回答

为什么我只在 pnpm-workspace.yaml 里写了 apps/*
pnpm 就能知道 apps/webapps/api 这些包,并把它们用起来?

答案只有一句话:

因为 pnpm 在启动时,
先用 Glob 规则找目录 → 再识别哪些是真正的包 → 在内存里记住它们的 name 和路径


二、什么是 Glob?(一定要先懂这个)

1️⃣ Glob 是什么

Glob 是一种“用模式匹配文件路径”的规则

你每天其实都在用,比如:

ls *.js
ls apps/*

这里的 * 就是 Glob。


2️⃣ Glob 的“全称”是啥?

  • 没有严格官方全称
  • 约定俗成理解为:Global Pattern Matching
  • 起源于 Unix Shell

👉 它不是 pnpm 发明的,是整个操作系统层面的东西。


3️⃣ Glob 能干什么?

Glob 只做一件事

👉 在磁盘上找出“路径长得像”的文件或目录

⚠️ 重要:

  • Glob 不懂什么是包
  • Glob 不看 package.json
  • 它只认路径形状

三、apps/* 到底是什么意思?

packages:
  - apps/*

这句话的真实含义是:

“请在项目根目录下,
找出所有路径形状像 apps/某个名字 的目录。”

比如磁盘上有:

apps/web
apps/api
apps/docs

Glob 匹配结果就是这三个。


四、pnpm 是怎么一步步工作的?(重点)

第一步:pnpm 判断是不是 Workspace

pnpm 启动时先看:

有没有 pnpm-workspace.yaml

  • 有 → Workspace 模式
  • 没有 → 单包模式

⚠️ pnpm 只认这个文件


第二步:Glob 展开(找目录)

pnpm 读取:

packages:
  - apps/*

然后:

  • 使用 Glob 规则
  • 遍历文件系统
  • 找到所有匹配的目录:
apps/web
apps/api
apps/docs

⚠️ 此时 pnpm 还不知道谁是包


第三步:识别“真正的包”

pnpm 接下来会逐个检查:

  • apps/web → 有 package.json
  • apps/api → 有 package.json
  • apps/docs → 没有 ❌

只有package.json 的目录才算 workspace 包。


第四步:建立 Package Map(最关键)

pnpm 会读取每个包的 package.json 里的:

{
  "name": "@my-org/web",
  "version": "1.0.0"
}

然后在内存中建立一张表

@my-org/web  -> apps/web
@my-org/api  -> apps/api
@my-org/ui   -> packages/ui

👉 这一步非常重要:

  • pnpm 认的是 name
  • 不是目录名
  • 不是路径

五、pnpm 是什么时候把包“连起来”的?

❌ 不是扫描时

✅ 是安装依赖时

比如 apps/web/package.json

{
  "dependencies": {
    "@my-org/ui": "^1.0.0"
  }
}

pnpm 在安装时会想:

  1. 我要找 @my-org/ui
  2. Workspace 里有没有同名包?
  3. 有 → 用本地的
  4. 没有 → 去 npm 仓库下载

👉 所谓“连起来”,本质是:

把依赖指向本地 workspace 包,而不是远程包


六、如果没有 pnpm-workspace.yaml 会怎样?

pnpm 会:

  • ❌ 不扫描其他目录
  • ❌ 不建立包映射表
  • ❌ 不知道本地还有同名包

结果就是:

即使你本地有 packages/ui
pnpm 也会去 npm 仓库下载一个同名包。


七、mac 上怎么自己“看到” Glob 在干嘛?

macOS 默认用的是 zsh,它天生支持 Glob。

你可以直接在终端试:

# 看 Glob 匹配了哪些目录
ls apps/*

再试:

# 看哪些是真正的包
ls apps/*/package.json

👉 这两步,和 pnpm 内部做的事情几乎一模一样。


八、最容易踩的坑(小白必看)

❌ 错误写法

packages:
  - apps

只会尝试:

apps/package.json

✅ 正确写法

packages:
  - apps/*

九、终极一句话总结(背下来就够了)

Glob 只是用来找目录的规则。
pnpm 用 Glob 找到目录后,再通过 package.json 判断哪些是包,
并把它们的 name 和路径记在内存里。
真正把包“连起来”的,是后面的依赖解析,而不是 Glob 本身。


pnpm Workspace 全流程图

┌────────────────────────────┐
│ 你运行 pnpm install        │
└─────────────┬──────────────┘
              │
              ▼
┌────────────────────────────┐
│ ① 是否存在 pnpm-workspace   │
│    .yaml ?                 │
└─────────────┬──────────────┘
      有      │        没有
      ▼       │         ▼
┌──────────────────┐   ┌──────────────────┐
│ Workspace 模式    │   │ 单包模式          │
└────────┬─────────┘   │(不扫描别的包)     │
         │             └──────────────────┘
         ▼
┌────────────────────────────┐
│ ② 读取 pnpm-workspace.yaml │
│    packages:               │
│    - apps/*                │
│    - packages/*            │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ③ Glob 展开(找目录)        │
│ apps/* →                   │
│   apps/web                 │
│   apps/api                 │
│   apps/docs                │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ④ 判断是否是包              │
│   有没有 package.json ?     │
│   web  ✅                  │
│   api  ✅                  │
│   docs ❌                  │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ⑤ 读取 package.json        │
│   name / version           │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ⑥ 建立 Package Map(内存)   │
│   @my-org/web → apps/web   │
│   @my-org/api → apps/api   │
└─────────────┬──────────────┘
              ▼
┌────────────────────────────┐
│ ⑦ 依赖解析(install 阶段)   │
│ web 依赖 @my-org/ui ?       │
│ → workspace 里有吗?         │
│ → 有 → 用本地包              │
│ → 没有 → 去 npm 仓库         │
└────────────────────────────┘

Vue 3中watch如何高效监听多数据源、计算结果与数组变化?

多数据源监听

在Vue 3中,watch 允许我们同时监听多个响应式数据源,当其中任意一个数据源发生变化时,都会触发回调函数。这在需要同步处理多个数据变化的场景中非常实用,比如表单多字段联动验证、多条件组合筛选等。

基本用法

我们可以将多个数据源(ref、reactive对象或getter函数)放入一个数组中,作为watch的第一个参数。回调函数的第一个参数是所有数据源的新值组成的数组,第二个参数是旧值组成的数组。

import { ref, watch } from 'vue'

// 定义多个响应式数据
const username = ref('')
const password = ref('')
const rememberMe = ref(false)

// 同时监听三个数据源
watch(
  [username, password, rememberMe],
  ([newUsername, newPassword, newRememberMe], [oldUsername, oldPassword, oldRememberMe]) => {
    console.log(`用户名从 ${oldUsername} 变为 ${newUsername}`)
    console.log(`密码从 ${oldPassword} 变为 ${newPassword}`)
    console.log(`记住我状态从 ${oldRememberMe} 变为 ${newRememberMe}`)
    
    // 实际场景中可以在这里进行表单验证
    if (newUsername && newPassword) {
      console.log('表单字段已填写完整')
    }
  }
)

执行流程

flowchart LR
A[定义多个响应式数据] --> B[将数据源放入数组作为watch的监听源]
B --> C[任意数据源发生变化]
C --> D[触发回调函数]
D --> E[解构新值和旧值数组,处理业务逻辑]

Getter函数监听

当我们需要监听的目标不是直接的响应式数据,而是基于响应式数据计算出的值时,可以使用getter函数作为watch的监听源。这种方式让我们能够灵活定义监听的计算逻辑。

基本用法

Getter函数需要返回我们想要监听的计算结果,当这个结果发生变化时,watch就会触发回调函数。

import { reactive, watch } from 'vue'

// 定义响应式状态对象
const cart = reactive({
  items: [
    { id: 1, name: 'Vue 3 实战教程', price: 59, quantity: 1 },
    { id: 2, name: 'Vuex 从入门到精通', price: 39, quantity: 2 }
  ]
})

// 监听购物车的总金额
watch(
  // Getter函数:计算总金额
  () => cart.items.reduce((total, item) => total + item.price * item.quantity, 0),
  (newTotal, oldTotal) => {
    console.log(`购物车总金额从 ${oldTotal} 元变为 ${newTotal} 元`)
    
    // 实际场景中可以在这里更新结算按钮状态或显示优惠信息
    if (newTotal >= 100) {
      console.log('满足满减条件,可享受10元优惠')
    }
  }
)

// 修改购物车商品数量,触发watch
cart.items[0].quantity = 2

执行流程

flowchart LR
A[定义响应式对象] --> B[创建getter函数,返回计算后的值]
B --> C[将getter函数作为watch的监听源]
C --> D[计算值发生变化]
D --> E[触发回调函数]
E --> F[处理新的计算结果]

数组监听

在Vue 3中监听数组需要注意一些细节,因为Vue的响应式系统对数组的处理和普通对象有所不同。默认情况下,watch会监听数组的引用变化和数组方法(如pushpopsplice等)的调用,但不会监听数组元素的直接索引修改。

往期文章归档
免费好用的热门在线工具

监听数组整体变化

当使用数组方法修改数组时,watch会自动触发:

import { ref, watch } from 'vue'

const todoList = ref(['学习Vue 3', '编写项目实战'])

// 监听数组整体变化
watch(todoList, (newList, oldList) => {
  console.log('待办事项列表发生变化:', newList)
})

// 使用数组方法修改数组,触发watch
todoList.value.push('优化代码性能')
todoList.value.pop()

监听数组内部元素变化

如果需要监听数组元素的直接修改(如arr[0] = '新值'),需要开启deep选项:

import { ref, watch } from 'vue'

const numbers = ref([1, 2, 3, 4])

// 开启deep选项,监听数组内部元素变化
watch(numbers, (newNumbers, oldNumbers) => {
  console.log('数组元素发生变化:', newNumbers)
}, { deep: true })

// 直接修改数组元素,触发watch
numbers.value[0] = 100

执行流程

flowchart LR
A[定义响应式数组] --> B[使用watch监听数组,可选开启deep]
B --> C[修改数组]
C --> D{修改方式?}
D -->|数组方法| E[触发watch回调]
D -->|索引修改| F{是否开启deep?}
F -->|是| E
F -->|否| G[不触发watch回调]

课后Quiz

问题1

如何在Vue 3中同时监听多个响应式数据的变化?请写出代码示例。

答案解析: 可以将多个数据源放入数组中作为watch的第一个参数,回调函数会接收新值数组和旧值数组:

import { ref, watch } from 'vue'

const name = ref('')
const age = ref(0)

watch(
  [name, age],
  ([newName, newAge], [oldName, oldAge]) => {
    console.log(`姓名从 ${oldName} 变为 ${newName}`)
    console.log(`年龄从 ${oldAge} 变为 ${newAge}`)
  }
)

问题2

当需要监听响应式对象中多个属性的计算结果时,应该使用什么方式?请写出代码示例。

答案解析: 使用getter函数作为watch的监听源,在getter函数中计算需要监听的结果:

import { reactive, watch } from 'vue'

const product = reactive({
  stock: 100,
  sales: 30
})

// 监听剩余库存
watch(
  () => product.stock - product.sales,
  (newStock, oldStock) => {
    console.log(`剩余库存从 ${oldStock} 变为 ${newStock}`)
  }
)

问题3

为什么直接修改数组的索引元素时,watch默认不会触发?如何解决这个问题?

答案解析: Vue的响应式系统默认不会监听数组的索引修改,因为这在性能上是低效的。解决方法有两种:

  1. 开启deep选项,深度监听数组内部元素变化
  2. 使用Vue提供的数组方法(如pushsplice等)来修改数组

常见报错解决方案

报错1:watch source must be a ref, reactive object, getter function, or array of these

  • 原因watch的监听源类型不正确,不是Vue支持的响应式数据源类型。
  • 解决方法:确保监听源是ref、reactive对象、getter函数或这些类型的数组。例如,如果你想监听普通变量,需要先将其转换为ref:
// 错误用法:监听普通变量
let count = 0
watch(count, () => { /* ... */ })

// 正确用法:转换为ref
const count = ref(0)
watch(count, () => { /* ... */ })

报错2:数组元素修改后watch不触发

  • 原因:直接修改数组索引元素,Vue默认不监听这种变化。
  • 解决方法:开启deep选项,或者使用数组方法修改数组:
// 方法1:开启deep选项
watch(numbers, () => { /* ... */ }, { deep: true })

// 方法2:使用数组方法
numbers.value.splice(0, 1, 100)

报错3:Cannot read property 'value' of undefined

  • 原因:在getter函数或回调函数中访问了未定义的响应式属性。
  • 解决方法:确保所有访问的属性都已正确定义,或者添加可选链操作符:
// 错误用法:访问未定义的属性
watch(() => user.address.city, () => { /* ... */ })

// 正确用法:添加可选链
watch(() => user?.address?.city, () => { /* ... */ })

参考链接

参考链接:vuejs.org/guide/essen…

从原生 Node.js 到 Koa:轻量优雅,解锁后端开发新体验

前言

每一个 Node.js 开发者都有过这样的经历:用原生 http 模块搭建服务时,要手写请求判断、处理响应头,几行代码就变得臃肿不堪。

为了告别这种低效的重复造轮子,Node.js社区涌现出了一批优秀的开发框架,比如:

  • express 框架
  • koa 框架
  • nestjs 框架

今天我们就来聊聊其中最轻巧、最具现代感的 Koa

一、为什么要用框架?优化在哪?

简单来说,就是提高我们的工作效率。比如原生http写法:

const http = require('http');
const server = http.createServer((req, res) => {
    if (req.url === '/home') {
        res.end('hello world')
    }
});
server.listen(3000, () => {
    console.log('server is running at http://localhost:3000')
});

这样当我们输入URL就成功出现了hello world

image.png

但是写法过于麻烦,太过低级,于是我们选择使用高级的框架。

二、初识 Koa:几行代码搭建完整 Web 服务

Koa 的核心优势之一就是极简,无需复杂的配置,安装后几行代码就能启动一个可运行的 Web 服务,相比原生http模块,省去了大量的基础封装工作。同时koa也是 Express 原班人马打造的下一代 Node.js Web 框架,它抛弃了冗余的内置中间件,用 async/await 让异步代码变得像同步一样清爽。

1. 基础准备与安装

首先确保你的环境装有 Node.jsnpm(直接去官网下载Node.js即可),然后新建项目文件夹,执行初始化和安装命令:

npm init -y 
npm install koa

2. 第一个 Koa 服务:从 0 到 1

配置好环境,我们就直接上手完成一个最基础的 Koa 服务实现:

// 引入Koa模块
const Koa = require('koa');
// 创建Koa应用实例
const app = new Koa();

// 定义核心业务处理函数
function main(ctx) {
    // 模拟待返回的列表数据
    const data = [
        {id: 1, name: 'henry', age: 18},
        {id: 2, name: 'harvest', age: 19},
        {id: 3, name: 'hello', age: 20}
    ]
    // 判断请求路径,返回对应数据
    if (ctx.url === '/list') {
        ctx.body = data;
    }
}

// 注册中间件,处理所有请求
app.use(main);
// 监听3000端口,启动服务
app.listen(3000, () => {
    console.log('server is running at http://localhost:3000');
});

image.png

这段代码中,ctx 是 Koa 最核心的上下文对象,它把 requestresponse 封装在一起。当访问 http://localhost:3000/list 时,就能直接拿到我们预设的 JSON 数据,无需像原生 http 那样手动设置响应头。

3. 灵活处理响应:多类型适配,一键切换

在实际开发中,我们需要返回不同类型的响应数据,比如 JSON、HTML、纯文本等,Koa 通过ctx.response.type可以轻松实现响应类型的切换,无需手动配置复杂的响应头,让响应处理变得极其灵活。

比如需要返回标准的 JSON 格式数据,只需指定响应类型为json,再给ctx.body赋值即可:

const Koa = require('koa');
const app = new Koa();

function main(ctx) {
    ctx.response.type = 'json'; // 指定响应类型为JSON
    ctx.body = '{"data": "hello koa"}'; // 赋值JSON字符串
}

app.use(main);
app.listen(3000, () => {
    console.log('server is running at http://localhost:3000');
});

image.png

当然啦,大家也可以去试试其他类型,用法是完全一样的😄。

image.png

如果需要返回 HTML 内容,只需将ctx.response.type改为htmlctx.body可以直接赋值 HTML 字符串,也可以读取本地 HTML 文件返回,适配性拉满。

4. 读取本地文件:高效返回,适配页面开发

在 Web 开发中,我们经常需要返回本地的 HTML 页面Koa 结合 Node.jsfs模块,能高效实现本地文件的读取返回,而且通过的方式读取,避免了大文件一次性加载的性能问题,兼顾效率和性能。

const Koa = require('koa');
const app = new Koa();
const fs = require('fs'); // 引入Node.js内置的文件模块

function main(ctx) {
    ctx.response.type = 'html'; // 指定响应类型为HTML
    // 以流的方式读取本地3.html文件,赋值给响应体
    ctx.body = fs.createReadStream('./3.html');
}

app.use(main);
app.listen(3000, () => {
    console.log('server is running at http://localhost:3000');
});

我们要同时创建一个html的文件,让它读取里面的内容:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>
<body>
    <h2>欢迎访问 koa 服务</h2>
</body>
</html>

image.png

这段代码运行后,访问本地 3000 端口,就能直接看到3.html文件的页面内容,完美适配前端页面的渲染需求。

5. 路由管理:清晰划分,告别杂乱代码

当项目业务逐渐复杂,单一路由已经无法满足需求,我们需要对不同的请求地址做清晰的路由划分,比如首页、关于页、数据接口等,各自对应不同的处理逻辑。Koa 本身没有内置路由功能,但可以通过koa-route中间件快速实现路由管理,让代码结构更清晰,维护更方便。

  • 首先安装koa-route中间件:
npm i koa-route -S
  • 然后通过中间件实现路由的分发和处理,不同路由对应不同函数:
const Koa = require('koa');
const app = new Koa();
const router = require('koa-route'); // 引入路由中间件

// 首页路由处理函数
const home = (ctx) => {
    ctx.body = '这是Koa的首页';
}

// 关于页路由处理函数,支持直接返回HTML标签
const about = (ctx) => {
    ctx.body = '<h2>这是Koa的关于页面</h2>';
}

// 注册GET路由,指定请求地址和对应的处理函数
app.use(router.get('/home', home));
app.use(router.get('/about', about));

app.listen(3000, () => {
    console.log('server is running at http://localhost:3000');
});

此时访问/home会返回首页文字,访问/about会渲染出 HTML 标题,不同路由的逻辑完全分离,即使后续新增路由,也只需新增处理函数并注册即可,代码始终保持整洁。

image.png

image.png

三、 Koa 的核心魅力:中间件与异步(忍不住唠嗑拓展)

聊到 Koa,就不得不提它的两大核心魅力:中间件机制完美的异步支持

Koa 的中间件采用洋葱模型执行,所有中间件会从外到内依次执行,再从内到外反向执行,这种机制让请求和响应的处理可以分层实现,各自负责独立的功能,互不干扰,还能实现功能的复用。

async/await的完美支持,让 Koa 彻底摆脱了回调地狱的困扰。在处理数据库查询、接口请求等异步操作时,无需嵌套多层回调,只需用await等待异步结果,代码的可读性和可维护性大幅提升,这也是 Koa 相比传统框架的一大优势。

四、总结

Koa 作为轻量型 Node.js 框架,以极简核心、洋葱模型中间件机制和对async/await的完美支持为亮点,可灵活处理响应、本地文件、路由等需求,搭配中间件即可按需扩展,兼顾开发效率与代码整洁度,是中小项目及快速开发场景的优选。

结语

Koa 用优雅语法简化了 Node.js 后端开发,平衡了自由度与实用性。入手 Koa,既能摆脱原生开发的冗余,也能快速搭建高效服务,解锁轻量化后端开发的便捷体验。

不允许你还不会 koa

❌