普通视图
GDAL 实现数据空间查询
^ 关注我,带你一起学GIS ^
前言
❝
在GIS开发中,空间查询和属性查询都是常见的基础操作,是每一个GISer都要掌握的必备技能。实现高效的数据查询功能可以提升用户体验,提升数据可视化效率。
在之前的文章中讲了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,本篇教程在之前一系列文章的基础上讲解如何使用GDAL实现数据空间查询功能。
如果你还没有看过,建议从以上内容开始。
1. 开发环境
本文使用如下开发环境,以供参考。
时间:2025年
系统:Windows 11
Python:3.11.7
GDAL:3.11.1
2. 空间查询
在GDAL中,有两个图层方法可以用于实现空间查询,分别是SetSpatialFilter和SetSpatialFilterRect。此种查询方式为直接在源数据上操作,返回结果为查询图层。
"""
参数
-geom:用于几何查询的几何对象
"""
SetSpatialFilter(geom)
"""
参数:
-minx:最小x坐标
-miny:最小y坐标
-maxx:最大x坐标
-maxy:最大y坐标
"""
SetSpatialFilterRect(minx,miny,maxx,maxy)
传入None值时重置查询。
sourceLayer.SetSpatialFilter(None)
在进行正式查询前和以前的文章一样,首先获取数据驱动,添加数据源,获取图层数据。定义一个方法SpatialFilter用于实现空间查询,该方法接受两个参数,一个sourcePath传递源数据图层路径,另一个selectPath用于定义查询图层路径。
"""
说明:图层属性过滤
参数:
-sourcePath:待查询 Shp 文件路径
-selectPath:查询 Shp 文件路径
"""
def SpatialFilter(sourcePath,selectPath):
在以下代码中完成图层数据的读取操作。
# 注册所有驱动
ogr.RegisterAll()
# 添加数据驱动
shpDriver = ogr.GetDriverByName("ESRI Shapefile")
checkFilePath(sourcePath,shpDriver)
checkFilePath(selectPath,shpDriver)
# 打开数据源
sourceDs = shpDriver.Open(sourcePath)
selectDs = shpDriver.Open(selectPath)
hasDs = sourceDs and selectDs
if hasDs is None:
print("数据源打开异常,请检查路径!")
return False
# 获取图层
sourceLayer = sourceDs.GetLayer(0)
selectLayer = selectDs.GetLayer(0)
下面对几何查询以及矩形查询两种实现方式进行介绍。
2.1. 几何查询
通过图层方法GetNextFeature获取第一个要素对象。
# 获取查询几何对象
queryFeat = selectLayer.GetNextFeature()
queryGeom = queryFeat.GetGeometryRef()
print(f"查询要素id:{queryFeat.GetField('Id')}")
print(f"查询几何对象:{queryGeom}")
将几何对象传入SetSpatialFilter方法进行空间过滤。
# 空间过滤
sourceLayer.SetSpatialFilter(queryGeom)
查询完成之后传入None值结束查询。
# 结束查询
sourceLayer.SetSpatialFilter(None)
以下为几何查询部分代码。
# 获取查询几何对象
queryFeat = selectLayer.GetNextFeature()
queryGeom = queryFeat.GetGeometryRef()
print(f"查询要素id:{queryFeat.GetField('Id')}")
print(f"查询几何对象:{queryGeom}")
# 获取要素数量
featureCount = sourceLayer.GetFeatureCount()
print(f"所有要素数量:{featureCount}")
# 空间过滤
sourceLayer.SetSpatialFilter(queryGeom)
queryFeatCount = sourceLayer.GetFeatureCount()
print(f"查询要素数量:{queryFeatCount}")
# 结束查询
sourceLayer.SetSpatialFilter(None)
finalFeatCount = sourceLayer.GetFeatureCount()
print(f"重置查询后要素数量:{finalFeatCount}")
print("n~~~~~~~方式一:结束几何查询~~~~~~~")
若有兴趣,还可以创建自定义几何对象进行空间查询。
# 自定义Geometry查询对象
customGeom = ogr.Geometry(ogr.wkbPolygon)
ringGeom = ogr.Geometry(ogr.wkbLinearRing)
ringGeom.AddPoint(102.884350,32.501570)
ringGeom.AddPoint(105.025865,74.974949)
ringGeom.AddPoint(50.417235,55.701314)
ringGeom.AddPoint(50.417235,32.501570)
ringGeom.CloseRings()
customGeom.AddGeometry(ringGeom)
# 获取要素数量
featureCount = sourceLayer.GetFeatureCount()
print(f"所有要素数量:{featureCount}")
# 空间过滤
sourceLayer.SetSpatialFilter(customGeom)
queryFeatCount = sourceLayer.GetFeatureCount()
print(f"查询要素数量:{queryFeatCount}")
# 结束查询
sourceLayer.SetSpatialFilter(None)
finalFeatCount = sourceLayer.GetFeatureCount()
print(f"重置查询后要素数量:{finalFeatCount}")
print("n~~~~~~~方式一:结束几何查询~~~~~~~")
如下为几何查询输出结果:![]()
如下为该要素在ArcGIS中查询显示结果。![]()
2.2. 矩形查询
矩形查询主要调用SetSpatialFilterRect方法,传入x、y坐标即可。
# 获取要素数量
featureCount2 = sourceLayer.GetFeatureCount()
print(f"所有要素数量:{featureCount2}")
# 空间过滤
sourceLayer.SetSpatialFilterRect(-16.9994,23.1235,42.9111,49.5852,)
queryFeatCount2 = sourceLayer.GetFeatureCount()
如下为矩形查询输出结果:![]()
3. 注意事项
在windows开发环境中同时安装GDAL与PostGIS,其中投影库PROJ的环境变量指向PostGIS的安装路径,在运行GDAL程序时,涉及到要素、几何与投影操作时会导致异常。![]()
具体意思为GDAL不支持PostGIS插件中的投影库版本,需要更换投影库或者升级版本。
RuntimeError: PROJ: proj_identify: D:Program FilesPostgreSQL13sharecontribpostgis-3.5projproj.db contains DATABASE.LAYOUT.VERSION.MINOR = 2 whereas a number >= 5 is expected. It comes from another PROJ installation.![]()
解决办法为修改PROJ的环境变量到GDAL支持的版本或者在GDAL程序开头添加以下代码:
os.environ['PROJ_LIB'] = r'D:\Programs\Python\Python311\Libsite-packages\osgeo\data\proj'
![]()
❝
OpenLayers示例数据下载,请在公众号后台回复:ol数据
全国信息化工程师-GIS 应用水平考试资料,请在公众号后台回复:GIS考试
❝
GIS之路 公众号已经接入了智能 助手,可以在对话框进行提问,也可以直接搜索历史文章进行查看。
都看到这了,不要忘记点赞、收藏 + 关注 哦 !
本号不定时更新有关 GIS开发 相关内容,欢迎关注 ![]()
![]()
![]()
别再被 TS 类型冲突折磨了!一文搞懂类型合并规则
之前学习了TypeScript的类型定义,我们都知道开发语言中的变量会有覆盖声明的情况,那么对于类型定义是不是也会有这种情况,那么该如何正确利用这种合并规则?在遇到多个类型定义的时候,我们又该如何处理?
了解类型合并规则,有助于我们定义类型,避免类型冲突。可以利用这种合并规则,更灵活的定义类型。
不同版本TypeScript,不同配置可能会导致合并差异,这里说明使用的版本为"typescript": "^5.9.3",开启了配置"strict": true,测试文件后缀为.ts。.d.ts文件可能更为宽松。
同名同类型合并
TypeScript仅支持接口interface、命名空间namespace、函数声明function同名合并。在声明解析阶段即完成合并。早于其他类型引用处理,比如交叉/联合类型。
对于type、 class 、enum等定义的同名类型都不能合并。
interface 接口声明合并
同一作用域下的同名接口会自动完成合并,无需额外语法。
合并特性:
- 属性、方法、索引签名均可合并。
- 同名属性、方法必须类型兼容,否则编译器报错。
- 同一作用域/模块下。
interface Animal {
name: string;
}
interface Animal {
age: number;
}
// 实例必须包含name和age属性
const dog: Animal = {
name: "",
age: 0,
};
同属性,不同类型不兼容,编译器报错。
interface Animal {
name: string;
}
// ❌ 后续属性声明必须属于同一类型。属性“name”的类型必须为“string”,但此处却为类型“number”。
interface Animal {
name: number;
}
同属性同类型,不同修饰符不兼容,编译器报错。
interface Animal {
name: string;
}
// ❌ 不同修饰符不兼容,编译器报错
interface Animal {
name?: string;
}
namespace 命名空间合并
同名的命名空间会自动合并内部导出的成员。仅export导出的成员会合并。
namespace Utils {
export function getName() {
return "hboot";
}
}
namespace Utils {
export function getAge() {
return 18;
}
}
Utils.getName();
Utils.getAge();
不允许同名成员导出。
namespace Utils {
export function getName() {
return "hboot";
}
}
namespace Utils {
// ❌ 成员已存在,不允许重复定义
export const getName = () => {
return 18;
};
}
function 函数声明合并
函数同名合并,我们称之为函数重载。TS编译器会按照倒序匹配,也就是后声明函数重载优先级高。
函数重载最后一个函数声明必须实现内部逻辑,并且参数数量、参数类型和返回值类型必须兼容。
仅使用
function声明的函数支持
function getName(name: string): string;
function getName(age: number): number;
function getName(nameOrAge: string | number): string | number {
return nameOrAge;
}
getName("hboot");
getName(18);
interface接口中定义的方法也会形成重载。但是和普通的函数重载最后的函数实现参数、返回值类型定义有些不同。
interface Animal {
getVal(name: string): string;
}
interface Animal {
getVal(age: number): number;
}
// ❌ 这样写编译器会直接报错,提示不能将类型 "string | number" 无法分配给类型 "string"。
const dog: Animal = {
getVal(value: string | number): string | number {
return value;
},
};
普通函数重载是满足其一即可;而接口中方法重载是必须精准匹配类型每一个类型。利用TS类型推导通过泛型参数锁定类型。
// 利用了类型的自动推导,通过泛型参数 T 锁定输入类型及返回类型
const dog: Animal = {
getVal<T extends string | number>(value: T): T {
return value;
}
};
同名不同类型合并
同名不同类型的合并,主要是命名空间namespace + interface/class/function的合并,namespace可以提供静态属性、方法。
namespace+interface 合并
同名的namespace命名空间为interface接口扩展静态成员;接口提供类型约束。
interface Animal {
name: string;
}
namespace Animal {
export const age = 18;
export function getName() {
return "hboot";
}
}
const dog: Animal = {
name: "hboot",
};
Animal.getName();
同名的属性、方法不会冲突,因为Animal命名空间直接通过空间名访问;interface接口需要实例化后的实例访问。它们之间实际上只有名称是相同的,属性之间没有合并。
namespace+class 合并
同名的namespace命名空间为class类扩展静态成员;类声明必须在命名空间的声明必之前,命名空间不能声明类已有的成员。
class Animal {
name: string;
static age: number;
constructor(name: string) {
this.name = name;
}
}
namespace Animal {
export const name = "hboot";
// ❌ 此处扩展静态成员 age 报错,类中已存在 age 静态成员
export const age = 18;
export function getName() {
return "hboot";
}
}
const dog: Animal = new Animal("admin");
Animal.getName();
// hboot
Animal.name;
// admin
dog.name;
namespace+function 合并
同名的namespace命名空间为function函数扩展静态成员。函数保持自身的可调用能力。
function speak(name: string) {
return "Hello World! " + name;
}
namespace speak {
// ❌ 此处无法覆盖 函数的 name 属性;name 是只读属性
// ❌ 类型校验没有报错,但运行时因为只读而报错
export const name = "hboot";
export function getName() {
return "hboot";
}
}
speak("hboot");
speak.name;
speak.getName();
扩展的静态成员最好不要覆盖函数本身的属性,比如name、length等。这些只读属性无法被覆盖,在运行时会报错。
interface+class 合并
同名的interface接口为class类扩展实例成员。类继承接口的属性、方法,实例必须同时满足接口和类的约束。
合并特性:
- 接口的必选属性,在类中必须显式实现,否则执行报错。
- 同名属性,必须类型兼容,否则执行报错。
interface Animal {
name: string;
getName(): string;
}
class Animal {
age: number;
constructor(age: number, name: string) {
this.age = age;
this.name = name;
}
// 必须显示实现 接口 的方法成员
getName() {
return this.name;
}
}
const dog: Animal = new Animal(18, "hboot");
dog.age;
// 类中需通过构造函数
dog.name;
// ❌ 如果类没有显示实现;智能提示存在方法,实际调用会报错。
dog.getName();
除了手动赋值扩展属性外,可以通过public修饰符自动生成
class Animal {
constructor(age: number, public name: string) {
this.age = age;
// 无需手动赋值
// this.name = name;
}
const dog: Animal = new Animal(18, "hboot");
// ...
}
显示类型合并
上述的同名同类型、同名不同类型合并实际最终也是属性的合并。对于非同名属性合并则是扩展;同名属性则有一些合并规则。
对于不同类型之间的同名属性合并都有自己的规则,比如:interface+class 合并要求属性类型兼容;namespace+class 合并要求命名空间不能包含类已有的成员。
通过手动将一些类型合并到一个类型中,例如交叉类型&和联合类型|
交叉类型& 关系合并
交叉类型将多个类型合并为一个新类型。新类型必须满足所有类型约束。
合并特性:
- 不同名属性合并为属性并集。保留属性修饰符。
- 同名属性取兼容类型,对于修饰符
?,存在必选时则属性必选;修饰符readonly,存在属性可修改时则属性可修改。
// 不兼容类型 never
type A = string & number;
// 不同名属性并集
type B = { name: string } & { age: number };
// 可选 ? 修饰符, 兼容类型 name 为必选属性
type C = { name?: string } & { name: string };
// 只读 readonly 修饰符, 兼容类型 age 为可修改
type D = { readonly age: number } & { age: number };
联合类型| 关系合并
将多个类型组合为一个新类型。新类型只需要满足其中一个类型约束。
合并特性:
- 仅能访问公共属性。需通过类型守卫收窄类型后才能访问非公共属性。
- 完全一致的类型自动去重。
type A = {
name: string;
age: number;
};
type B = {
name: string;
address: string;
};
type C = A | B;
// 满足其中一个类型约束
const c: C = {
name: "hboot",
age: 18,
};
// 访问非公共属性
function viewC(data: C) {
if ("age" in data) {
return data.age;
}
return data.address;
}
.d.ts中的声明合并
.d.ts文件和.ts文件的合并核心规则一致。在执行时机、作用域、编译行为上有些不一样。
-
.d.ts文件中仅有类型声明,无实际代码实现,仅用于TS类型检验。所以不同于.ts文件。它会在类型校验阶段早期执行,优先合并全局/模块类型;而.ts文件是在编译阶段执行。 -
.d.ts类型优先级低,能被.ts文件显示类型声明覆盖。 -
.d.ts跨文件同名声明合并(无import/export)。.ts仅在同一个文件中同名声明合并。
declare
declare 主要作用存在性声明,告诉TypeScript编译器无需生成对应的代码。
- 全局变量/函数声明,比如:外部加载的
js文件,挂载到window上的变量。 - 扩展已有类型(与同名接口/命名空间合并)
- 声明模块(非TS模块,比如
.css或.png等静态资源)
declare 扩展已有类型合并规则与.ts文件的合并核心规则一致.
interface Animal {
name: string;
getName(): string;
}
declare interface Animal {
age: number;
}
.ts 模块中没有import/export时,通过declare声明为全局作用域。如果存在import/export则需要通过declare global扩展全局作用域。
js中的using声明
一、using 声明简介
using 是 ECMAScript 2023(ES14) 引入的一项新语法,用于自动管理资源的生命周期。
它的主要目标是简化“资源使用完后自动释放”的场景,例如文件句柄、数据库连接、锁等。
相关提案可见:github.com/tc39/propos…
📜 语法结构:
using 变量名 = 表达式;
await using 变量名 = 异步表达式;
using 声明的变量必须是一个实现了 Symbol.dispose 或 Symbol.asyncDispose 方法的对象。
当作用域退出(无论是正常返回还是异常)时,JS 引擎会自动调用该方法释放资源。
二、基本使用示例
同步版本
class File {
constructor(name) {
this.name = name;
console.log(`打开文件: ${name}`);
}
[Symbol.dispose]() {
console.log(`关闭文件: ${this.name}`);
}
}
function main() {
using f = new File("data.txt");
console.log("读取文件中...");
}
main();
// 输出:
// 打开文件: data.txt
// 读取文件中...
// 关闭文件: data.txt
⚡ 当
main()执行完毕时,f离开作用域,系统会自动调用f[Symbol.dispose]()。
异步版本
class AsyncResource {
async [Symbol.asyncDispose]() {
console.log("异步释放资源...");
await new Promise(res => setTimeout(res, 1000));
console.log("资源已释放");
}
}
async function run() {
await using r = new AsyncResource();
console.log("使用异步资源中...");
}
run();
三、内部机制简析
-
using是 词法作用域绑定(和let/const类似)。 -
离开作用域时:
- 如果是同步资源 → 调用
[Symbol.dispose]() - 如果是异步资源 → 调用
[Symbol.asyncDispose]()
- 如果是同步资源 → 调用
-
它可以和
try、catch、finally一起安全使用。
四、使用场景
| 场景 | 传统做法 | 新写法(using) |
|---|---|---|
| 文件操作 | try/finally 手动关闭 | 自动调用 dispose |
| 数据库连接 | 手动断开 | 自动释放连接 |
| 临时锁 | try/finally 释放锁 | 离开作用域自动释放 |
五、注意事项
-
using只能在模块或函数作用域中使用,不能在全局作用域直接声明。 - 不能与
var共用。 - 不会影响 GC(垃圾回收),它只是提供结构化释放资源的机制。
六、实践demo——简化revokeObjectURL
在图片回显/文件下载等场景中,开发很容易忘记revokeObjectURL,我们可以利用using特性进行封装,消除revokeObjectURL的心智负担。
就比如上传图片后回显的操作:
console.log('🚀 using demo 启动...');
function objectURLResource(blob) {
const url = URL.createObjectURL(blob);
console.log('🆕 创建 URL:', url);
return {
url,
[Symbol.dispose]() {
console.log("释放资源...");
URL.revokeObjectURL(url);
console.log('🧹 自动释放 URL:', url);
},
// [Symbol.dispose]() {
// console.log("释放资源...");
// new Promise(resolve => setTimeout(resolve, 1000)).then(()=>{
// URL.revokeObjectURL(url);
// console.log('🧹 自动释放 URL:', url);
// })
// },
async [Symbol.asyncDispose]() {
console.log("异步释放资源...");
await new Promise(resolve => setTimeout(resolve, 3000)); // 模拟异步操作
URL.revokeObjectURL(url);
console.log('🧹 自动释放 URL:', url);
},
};
}
async function showImage(blob) {
// await using resource = objectURLResource(blob);
using resource = objectURLResource(blob);
const img = document.createElement('img');
img.src = resource.url;
img.alt = 'demo';
const { resolve, promise } = Promise.withResolvers()
img.onload = () => {
resolve()
}
document.body.appendChild(img);
await promise;
console.log('✅ 演示完毕,URL 将被自动 revoke');
}
document.querySelector('#file').addEventListener('change',async (e) => {
const f = e.target.files[0]
await showImage(f);
console.log('do others... ')
})
![]()
revokeObjectURL的注意事项
当img加载后,执行revokeObjectURL并不会影响内容显示,但如果img未加载完就执行了revokeObjectURL,则无法显示图片。因此以上demo中增加了await promise的处理,确保图片被显示出来。
![]()
那么可能会有人问,此处能否用Symbol.asyncDispose来解决该问题?答案是不能。但这是一个很好的问题,触及到了
Symbol.asyncDispose与Symbol.dispose的根本区别.
七、Symbol.asyncDispose与Symbol.dispose的区别
表面上看,以上demo中如果写成await using resource = objectURLResource(blob);,释放资源用的是Symbol.asyncDispose,如果不加await,则调用Symbol.dispose。
异步释放:
![]()
那么这两个api的使用场景分别是啥呢?我们不妨在同步释放中写下如下代码:
[Symbol.dispose]() {
console.log("释放资源...");
new Promise(resolve => setTimeout(resolve, 1000)).then(()=>{
URL.revokeObjectURL(url);
console.log('🧹 自动释放 URL:', url);
})
},
可以看到,do others...在释放完成前就执行了。
因此可以理解:Symbol.asyncDispose是为了让后续的代码等待异步释放完成后再执行,因为有些释放场景,可能需要进行io或其他异步校验,而Symbol.dispose释放过程是同步的,后续代码执行时可以认为资源已经被释放了。如果后续代码执行时并不关心该资源是否已经释放了,那使用Symbol.dispose即可。
后端问前端:我的接口请求花了多少秒?为啥那么慢,是你慢还是我慢?
![]()
不好意思,你说你看不懂接口 用了多少秒?我来告诉大家,因为我也要写记录📝。
我先把答案说出来,总耗时8.54秒。
- 排队(
Queued):24.52ms - 连接(
Connection Start):0.86ms - 请求发送(
Request sent):0.23ms - 等待服务器响应(
Waiting for server response):8.39(这部分时间最长) - 内容下载(
Content Download):126.61ms
Explanation在最后一行,给出是8.54秒,也就是请求接口加起来总时间是8.54秒。
“Queued at 1.1 min” 和 “Started at 1.1 min” 是在网络性能分析工具(比如 Chrome DevTools 的性能瀑布图)中常见的描述,表示这次请求在整个页面加载过程中的出现时间点,而不是请求本身的实际时长。
具体含义:
-
Queued at 1.1 min: 表示在页面开始加载后的1.1分钟,这个请求被放入浏览器的队列中等待排队。
可能是因为:
- 浏览器对同一域名有连接数限制(例如6个并发),所以后续请求需要排队。
- 资源优先级较低,需要等其他更高优先级请求先处理。
Started at 1.1 min
表示在页面加载开始后的1.1分钟,这个请求开始真正被处理(例如开始建立连接)。
这两个时间相同(都是1.1分钟),说明这个请求在进入队列后几乎没有排队,很快就开始了实际请求阶段。
换句话说:
-
1.1分钟时它被加入队列。 -
同样在1.1分钟时它就开始了连接阶段(Stalled、Request/Response等)。
所以,"Queued"和"Started at"是对整个页面加载时间线中的时间点标记,帮助你理解该请求在页面声明周期中何时发生,而不影响请求自身的8.54秒耗时。
JavaScript算法 - 冒泡排序
冒泡排序(从小到大)
![]()
/**
* 文字描述,毕竟不如口语表达,所以,这里有一些帮助阅读的方法
* 释义:
* 项:"数组的 0 项",指的是数组 index 为 0 的值
*
* tips:
* 1. 将会用到的数组,都是复杂度最高的数组,也就是原始排序为 [3,2,1] 这样从大到小的数组,有助于直观理解
* 2. 如果觉得有点乱,就关注函数返回的结果。上一步结果,就是我们下一步的根本
*/
如果数组一共有两项
const myArr = [9, 8]
const mySort = (arr) => {
if (arr[0] > arr[1]) {
;[arr[0], arr[1]] = [arr[1], arr[0]]
}
return arr
}
const result = mySort(myArr)
console.log(result, 'result') // [ 8, 9 ]
此时数组变为:[ 8, 9 ] 总结:以上是最简单的排序。数组只有两项,0 项和 1 项。比大小、换位,没了
如果有三个呢
const myArr = [9, 8, 7]
const mySort = (arr) => {
if (arr[0] > arr[1]) {
;[arr[0], arr[1]] = [arr[1], arr[0]]
}
return arr
}
const result = mySort(myArr)
console.log(result, 'result')// [ 8, 9, 7 ]
此时的数组变为:[ 8, 9, 7 ]
看着这个数组,总结:预料之中,0 项和 1 项换位。接下来呢?
排序后数组的含义可以解释为:
可以确定:数组的 1 项,是数组 0 项、1 项中的的最大值
不能确定:数组的 2 项,更大还是更小
!那么,让数组的 1、2 项比大小,就确定了数组中最大的是谁
代码延申
const myArr = [9, 8, 7]
const mySort = (arr) => {
// 第一步
if (arr[0] > arr[1]) {
;[arr[0], arr[1]] = [arr[1], arr[0]]
}
// 走到此处,arr 的值为 [ 8, 9, 7 ]
// 第二步
if (arr[1] > arr[2]) {
;[arr[1], arr[2]] = [arr[2], arr[1]]
}
// 走到此处,arr 的值为 [ 8, 7, 9 ]
return arr
}
const result = mySort(myArr)
console.log(result, 'result') // [ 8, 7, 9 ]
此时的数组变为:[ 8, 7, 9 ]
看着这个数组,总结:延申后,我们确定了最大值,且移动到了最右边
希望这里,你会感受到思路在变清晰
排序后的数组含义可以解释为
可以确定:0 项和 1 项,都比 2 项小。2 项,是数组的最大值
不能确定:数组的 0 项、1 项,谁大
!那么,让数组的 0、1 项比大小。(因为已经确定了 2 项最大,那么,确定了 0、1 的大小关系,就确定了全部的大小关系)
代码再延申
const mySort = (arr) => {
// 第一步
if (arr[0] > arr[1]) {
;[arr[0], arr[1]] = [arr[1], arr[0]]
}
// 走到此处,arr 的值为 [ 8, 9, 7 ]
// 第二步
if (arr[1] > arr[2]) {
;[arr[1], arr[2]] = [arr[2], arr[1]]
}
// 走到此处,arr 的值为 [ 8, 7, 9 ]
// 第三步(与 第一步 完全一致)
if (arr[0] > arr[1]) {
;[arr[0], arr[1]] = [arr[1], arr[0]]
}
// 走到此处,arr 的值为[ 7, 8, 9 ]
return arr
}
const result = mySort(myArr)
console.log(result, 'result') // [ 7, 8, 9 ]
此时的数组变为:[ 7, 8, 9 ],是我们要的结果
思路总结:
我们排序的方式为:
0、1 对比换位,1、2 对比换位,确定 2
0、1 对比换位,确定 0、1
思路扩展:
尝试想象一下,当数组的数量为四个
0、1 对比换位,1、2 对比换位,2、3 对比换位,确定 3
然后再重复一下刚刚实现的经过
0、1 对比换位,1、2 对比换位,确定 2
0、1 对比换位,确定 0、1
至此,一种算法入门的排序思路已经出现,它被称为:冒泡排序 ~咕嘟咕嘟
根据思路扩展,尝试实现算法:
// 首先,写出第一轮循环对比:得到数组中最大值并移动到最右边
const arr = [9, 8, 7, 6]
const bubbleSort = (arr) => {
// 先把之前的对比的判断,把索引改成动态的
// if (arr[i] > arr[i + 1]) {
// ;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
// }
// 首先想到for循环。能写出来什么样呢
// for()
// 我们需要用一些数字。跟循环的次数有关
// 一般而言,都会拿数组的长度来用。这里,数组的长度有什么用处
// arr.length // 4
// for (let i = 0; i < ???; i++) {
// if (arr[i] > arr[i + 1]) {
// ;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
// }
// }
// 关键是,i 要小于几呢?那就先要确定,我们要对比多少次,然后再给出限制
// 这个对比的次数,要依据 < 思路扩展 > 中的第一轮对比!其中第一轮,经过了 3 次对比换位
// 又因为我们是从 0 开始的。所以,要拿到的数字是0、1、2,所以要小于3
// 数组的长度跟这个数字的关系就是 length - 1,这就是我们要小于的值
for (let i = 0; i < arr.length - 1; i++) {
if (arr[i] > arr[i + 1]) {
;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
}
}
return arr
// 如果从 1 开始,那就另一回事了
}
console.log(bubbleSort(arr)) // [ 8, 7, 6, 9 ]
这时候我们已经把最大的移动到了最右边,第一步完成了
然后呢?接下来,我们要对比前三个,不对比第四个了。这个怎么写啊
我们要让这个循环再来一遍。但是,循环次数要少一次。重要!!!
让循环再来一次,也就是说,让这个循环循环
const bubbleSort = (arr) => {
// for()
// 外边这个循环怎么加呢,要让里面的循环,走几轮呢?跟里面的循环有什么关系呢?
// 再一次,依据 < 思路扩展 > ,要让里面循环 3 轮,就能得到结果了
// 里面循环对比的次数不断减 1 的,那让外面的循环不断减 1,并让这个数字同步到里面对 i 的限制
// for(let j = ???; j >= 0; j--){
// for (let i = 0; i < arr.length - 1; i++) {
// if (arr[i] > arr[i + 1]) {
// ;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
// }
// }
// }
// 让外面的 j ,设定为里面的 i 的初始值,然后逐渐变小,最终等于 0,然后结束
for (let j = arr.length - 1; j >= 0; j--) {
for (let i = 0; i < j; i++) {
if (arr[i] > arr[i + 1]) {
;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
}
}
}
return arr
}
console.log(bubbleSort(arr)) // [ 6, 7, 8, 9 ]
主线代码完成
const bubbleSort = (arr) => {
for (let j = arr.length - 1; j >= 0; j--) {
for (let i = 0; i < j; i++) {
if (arr[i] > arr[i + 1]) {
;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
}
}
}
return arr
}
// 随便输入一些,看看效果
console.log(bubbleSort([543, 4, 76, 524, 24, 65, 23, 235, 3245])) // [ 4, 23, 24, 65, 76, 235, 524, 543, 3245 ]
后续优化:隔离数据源、减少非必要性能开销
const bubbleSort = (array) => {
const arr = [...array]
for (let j = arr.length - 1; j >= 0; j--) {
let swapped = false
for (let i = 0; i < j; i++) {
if (arr[i] > arr[i + 1]) {
;[arr[i], arr[i + 1]] = [arr[i + 1], arr[i]]
swapped = true
}
}
if (!swapped) break
}
return arr
}
完成。
提升工作效率的Utils
总结一些工作中常用到的utils,希望能帮助到大家,增加摸鱼时间
getHidePhone
获取脱敏号码
/**
* @description 隐藏手机号
* @param {String} content 内容
* @param {Number} hideLen 要隐藏的长度,默认为4
* @param {String} symbol 符号,默认为*
* @param {String} padStartOrEnd 如果平分的长度为奇数,多出的一位填充的位置,默认为end
* @param {Boolean} removeNan 是否移除非数字,默认为true
* @returns {String} 隐藏后的内容
*/
export const getHidePhone = (
content: string,
hideLen = 4,
symbol = '*',
padStartOrEnd: 'start' | 'end' = 'end',
removeNan = true,
) => {
// 如果需要先移除非数字
if (removeNan) {
content = content.replace(/[^\d]/g, '')
}
const contentLen = content.length
// 不是字符串、空字符串、要隐藏的长度为0直接返回原始字符串
if (getTypeOf(content) !== 'String' || !contentLen || !hideLen) return content
// 隐藏长度大于等于内容长度,直接返回原始字符串长度的符号
if (contentLen <= hideLen)
return content.replace(new RegExp(`\\d{1}`, 'g'), '*')
const remainingLen = contentLen - hideLen
const splitLen = Math.floor(remainingLen / 2)
let start = splitLen
let end = splitLen
if (remainingLen % 2 === 1) {
if (padStartOrEnd === 'start') {
start += 1
} else {
end += 1
}
}
return content.replace(
new RegExp(`^(\\d{${start}})\\d{${hideLen}}(\\d{${end}})$`),
`$1${symbol.repeat(hideLen)}$2`,
)
}
console.log(getHidePhone('15108324289')) // 151****4289
console.log(getHidePhone('151')) // ***
console.log(getHidePhone('1510')) // ***0
console.log(getHidePhone('')) // ''
console.log(getHidePhone('15108324289', 6)) // 15******289
console.log(getHidePhone('15108324289', 40)) // ***********
console.log(getHidePhone('15108324289', undefined, '-')) // 151----4289
console.log(getHidePhone('15108324289', undefined, undefined, 'start')) // 1510****289
console.log(getHidePhone('15108324289', undefined, undefined, 'end')) // 151****4289
console.log(getHidePhone('151-083%#2 4289', undefined, undefined)) // 151****4289
console.log(
getHidePhone('151-083%#2 4289', undefined, undefined, undefined, false),
) // 151-083%#2 4289
formateContentBySymbol
格式化内容,根据符号进行格式化,常用于千分位分割
/**
* @description 格式化内容,根据符号进行格式化
* @param {String} content 内容
* @param {String} symbol 符号
* @param {Number} gap 符号之间的间距,默认3个空格
* @returns {String} 格式化后的内容
*/
export const formateContentBySymbol = (
content: string,
symbol: string,
gap = 3,
) => {
return content.replace(new RegExp(`(\\d{${gap}})(?=\\d)`, 'g'), `$1${symbol}`)
}
/**
* @description 格式化内容,根据符号进行格式化
* @param {String} content 内容
* @param {String} symbol 符号
* @param {Number} gap 符号之间的间距,默认3个空格
* @returns {String} 格式化后的内容
*/
export const formateContentBySymbol2 = (
content: string,
symbol: string,
gap = 3,
) => {
return content.replace(new RegExp(`\\B(?=(\\d{${gap}})+$)`, 'g'), symbol)
}
/**
* @description 格式化内容,根据符号进行格式化
* @param {String} content 内容
* @param {String} symbol 符号
* @param {Number} gap 符号之间的间距,默认3个空格
* @returns {String} 格式化后的内容
*/
export const formateContentBySymbol3 = (
content: string,
symbol: string,
gap = 3,
) => {
return content.replace(
new RegExp(`(\\d)(?=(\\d{${gap}})+$)`, 'g'),
`$1${symbol}`,
)
}
console.log(formateContentBySymbol('235789075433254321', ',')) // 235,789,075,433,254,321
console.log(formateContentBySymbol2('235789075433254321', ',', 7)) // 2357,8907543,3254321
console.log(formateContentBySymbol3('235789075433254321', ',', 2)) // 23,57,89,07,54,33,25,43,21
zeroNDigitMDecimalReg
0或者n位的数字,最多m位小数正则
/**
* @desc 0或者n位的数字,最多m位小数
* @param n n位的数字
* @param m 最多m位小数
* @returns {RegExp} 返回正则表达式
*/
export const zeroNDigitMDecimalReg = (n = 4, m = 2): RegExp => {
if (!m) {
// 没有小数位的情况
return new RegExp(`^(0|([1-9][0-9]{0,${n - 1}}))$`)
}
// 一位小数位的情况
if (m === 1) {
return new RegExp(`^(0(\\.[1-9])?|([1-9][0-9]{0,${n - 1}}(\\.[1-9])?))$`)
}
// 二位小数位的情况
if (m === 2) {
return new RegExp(
`^(0(\\.(([1-9]{1,2})|0[1-9]))?|([1-9][0-9]{0,${
n - 1
}}(\\.(([1-9]{1,2})|0[1-9]))?))$`,
)
}
// 二位以上小数位的情况
return new RegExp(
`^(0(\\.(([1-9]{1,${m - 1}})|([0-9]{1,${m - 1}}[1-9]?)))?|([1-9][0-9]{0,${
n - 1
}}(\\.(([1-9]{1,${m - 1}})|([0-9]{1,${m - 1}}[1-9]?)))?))$`,
)
}
console.log(zeroNDigitMDecimalReg(4, 2).test('123456789.123456789')) // false
console.log(zeroNDigitMDecimalReg(4, 10).test('1232.123456789')) // true
console.log(zeroNDigitMDecimalReg(4, 2).test('1234.1')) // true
console.log(zeroNDigitMDecimalReg(8, 6).test('13323234.000001')) // true
console.log(zeroNDigitMDecimalReg(8, 6).test('13323234.0000001')) // false
nDigitReg
0或者n位的整数正则
/**
* @desc 0或者n位的整数正则
* @param n 最多n位的数字
* @param with0 是否包含0
* @returns {RegExp} 返回正则表达式
*/
export const nDigitReg = (n = 4, with0?: boolean) => {
if (with0) {
// 包含0的情况
return new RegExp(`^(0|([1-9][0-9]{0,${n - 1}}))$`)
}
// 不包含0的情况
return new RegExp(`^[1-9][0-9]{0,${n - 1}}$`)
}
console.log(nDigitReg(4).test('1234')) // true
console.log(nDigitReg(6).test('1234')) // true
console.log(nDigitReg(6).test('123456789')) // false
console.log(nDigitReg().test('1234.56789')) // false
console.log(nDigitReg(undefined, true).test('0')) // true
console.log(nDigitReg(undefined).test('0')) // false
onetonnine
1-9xxxx n个9
/**
* @desc 1-9xxxx n个9
* @param n n位,一共多少位数字
*/
export const onetonnine = (n = 3) => {
return new RegExp(`^[1-9][0-9]{0,${n - 1}}$`)
}
console.log(onetonnine(4).test('1234')) // true
console.log(onetonnine(4).test('123')) // true
console.log(onetonnine(4).test('12343')) // false
zerotonnine
0-9xxxx n个9
/**
* @desc 0-9xxxx n个9
* @param n n位,一共多少位数字
*/
export const zerotonnine = (n = 3) => {
return new RegExp(`^(0|([1-9][0-9]{0,${n - 1}}))$`)
}
console.log(zerotonnine(4).test('1234')) // true
console.log(zerotonnine(4).test('123')) // true
console.log(zerotonnine(4).test('12343')) // false
console.log(zerotonnine().test('0')) // true
zerotonnine2Decimal
0-9xxxx n个9, 最多两位小数
/**
* @description 0-9xxxx n个9, 最多两位小数
* @param {Number} n n位,一共多少位数字,默认4位数
* @returns {RegExp} 正则
*/
export const zerotonnine2Decimal = (n = 4) => {
return new RegExp(
`^(0(\\.(([1-9]{1,2})|0[1-9]))?|([1-9][0-9]{0,${
n - 1
}}(\\.(([1-9]{1,2})|0[1-9]))?))$`,
)
}
console.log(zerotonnine2Decimal(4).test('1234')) // true
console.log(zerotonnine2Decimal().test('1234')) // true
console.log(zerotonnine2Decimal().test('1234.00')) // false
console.log(zerotonnine2Decimal().test('1234.01')) // true
console.log(zerotonnine2Decimal().test('1234.1')) // true
console.log(zerotonnine2Decimal().test('1234.10')) // false
console.log(zerotonnine2Decimal().test('1234.001')) // false
console.log(zerotonnine2Decimal(6).test('123456789')) // false
console.log(zerotonnine2Decimal(6).test('0')) // true
console.log(zerotonnine2Decimal(6).test('0.01')) // true
onetonnine2Decimal
1-9xxxx n个9, 最多两位小数
/**
* @description 1-9xxxx n个9, 最多两位小数
* @param {Number} n n位,一共多少位数字,默认4位数
* @returns {RegExp} 正则
*/
export const onetonnine2Decimal = (n = 4) => {
return new RegExp(`^[1-9][0-9]{0,${n - 1}}(\\.(([1-9]{1,2})|0[1-9]))?$`)
}
console.log(onetonnine2Decimal(4).test('1234')) // true
console.log(onetonnine2Decimal().test('1234')) // true
console.log(onetonnine2Decimal().test('1234.00')) // false
console.log(onetonnine2Decimal().test('1234.01')) // true
console.log(onetonnine2Decimal().test('1234.1')) // true
console.log(onetonnine2Decimal().test('1234.10')) // false
console.log(onetonnine2Decimal().test('1234.001')) // false
console.log(onetonnine2Decimal(6).test('123456789')) // false
console.log(onetonnine2Decimal(6).test('0')) // false
console.log(onetonnine2Decimal(6).test('0.01')) // false
setCliboardContent
复制文本的通用函数
/**
* @description 复制文本的通用函数
* @param {String} content 要复制的内容
*/
export function setCliboardContent(content?: string) {
if (!content) return
const selection = window.getSelection()
if (selection?.rangeCount) {
selection?.removeAllRanges()
}
const el = document.createElement('textarea')
el.value = content || ''
el.setAttribute('readonly', '')
el.style.position = 'absolute'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
document.execCommand('copy')
el.remove()
}
getCliboardValue
获取剪切板中的内容
/**
* @description 获取剪切板中的内容
* @returns 剪切板内容
*/
export function getCliboardValue() {
const el = document.createElement('input')
el.style.position = 'absolute'
el.style.left = '-9999px'
document.body.appendChild(el)
el.select()
document.execCommand('paste')
// 获取文本输入框中的值
const clipboardValue = el.value
el.remove()
return clipboardValue
}
delay
延迟执行
/**
* @description 延迟执行
* @param wait 延迟时间
* @returns
*/
export function delay(wait = 1000) {
return new Promise((resolve) => setTimeout(resolve, wait))
}
getTypeOf
获取数据类型
/**
* @description 获取数据类型
* @param data
* @returns {String} 获取到的数据类型
*/
export function getTypeOf(data: any) {
return Object.prototype.toString.call(data).slice(8, -1)
}
trimStart
去除字符串开头的空格
/**
* @description 去除字符串开头的空格
* @param {String} str 要处理的字符串
* @returns {String} 去除空格后的字符串
*/
export const trimStart = (str: string) => {
if (getTypeOf(str) !== 'String') {
return str
}
return str?.replace(/^(\s+)(.*)$/g, '$2')
}
trimEnd
去除字符串结尾的空格
/**
* @description 去除字符串结尾的空格
* @param {String} str 要处理的字符串
* @returns {String} 去除空格后的字符串
*/
export const trimEnd = (str: string) => {
if (getTypeOf(str) !== 'String') {
return str
}
return str?.replace(/^(.*)(\s+)$/g, '$1')
}
trimAll
去除字符串中的所有空格
/**
* @description 去除字符串中的所有空格
* @param {String} str 要处理的字符串
* @returns {String} 去除空格后的字符串
*/
export const trimAll = (str: string) => {
if (getTypeOf(str) !== 'String') {
return str
}
return str?.replace(/\s+/g, '')
}
compressPic
压缩图片
/**
* @description 压缩图片
* @param {File} file 要处理的图片文件
* @param {Number} quality 压缩质量
* @returns {Promise<File | Blob>} 压缩后的图片文件
*/
export async function compressPic(
file: File,
quality = 0.6,
): Promise<File | Blob> {
return new Promise((resolve) => {
try {
const reads = new FileReader()
reads.readAsDataURL(file)
reads.onload = ({ target }) => {
// 这里quality的范围是(0-1)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')!
const img = new Image()
img.src = (target as any)?.result
img.onload = function () {
const width = img.width
const height = img.height
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
// 转换成base64格式 quality为图片压缩质量 0-1之间 值越小压缩的越大 图片质量越差
canvas.toBlob(
(blob) => {
resolve(blob!)
},
file.type,
quality,
)
}
}
reads.onerror = () => {
resolve(file)
}
} catch {
resolve(file)
}
})
}
randomString
生成指定长度的随机字符串
/**
* @description 生成指定长度的随机字符串
* @param {Number} length 输出字符串的长度
* @param {Number} radix 字符串的基数,默认为36(包括0-36)
* @returns 返回指定长度的随机字符串,全部为大写
*/
export const randomString = (length: number, radix = 36) => {
// 生成一个随机字符串,基数为radix,并去除前两位"0."
let str = Math.random().toString(radix).substring(2)
// 如果生成的字符串长度大于等于所需长度,则截取前length个字符并转为大写
if (str.length >= length) {
return str.substring(0, length).toLocaleUpperCase()
}
// 如果字符串长度不足,递归调用自身以生成剩余长度的字符串,并拼接到原字符串上
str += randomString(length - str.length, radix)
// 将最终字符串转为大写并返回
return str.toLocaleUpperCase()
}
scrollToBottom
滚动到底部
/**
* @description 滚动到底部
* @param {String} selector 类名
*/
export const scrollToBottom = (selector?: string) => {
const domWrapper = selector
? document.querySelector(selector)
: document.documentElement || document.body // 外层容器 出现滚动条的dom
if (domWrapper) {
domWrapper.scrollTo({ top: domWrapper.scrollHeight, behavior: 'smooth' })
}
}
scrollToTop
滚动到顶部
/**
* @description 滚动到顶部
* @param {String} selector 类名
*/
export const scrollToTop = (selector?: string) => {
const domWrapper = selector
? document.querySelector(selector)
: document.documentElement || document.body // 外层容器 出现滚动条的dom
if (domWrapper) {
domWrapper.scrollTo({ top: 0, behavior: 'smooth' })
}
}
isJSON
判断是否为JSON字符串
/**
* @description 判断是否为JSON字符串
* @param {String} str 字符串
* @returns {Boolean} 是否为JSON字符串
*/
export function isJSON(str: string) {
if (typeof str !== 'string') {
// 1、传入值必须是 字符串
return false
}
try {
const obj = JSON.parse(str) // 2、仅仅通过 JSON.parse(str),不能完全检验一个字符串是JSON格式的字符串
if (typeof obj === 'object' && obj) {
//3、还必须是 object 类型
return true
}
return false
} catch {
return false
}
}
getRandomIntInclusive
生成指定范围内的随机整数(包含最小值和最大值)
/**
* 生成指定范围内的随机整数(包含最小值和最大值)
* @param min 最小值(包含)
* @param max 最大值(包含)
* @returns 指定范围内的随机整数
*/
export function getRandomIntInclusive(
min: number = 1,
max: number = 100,
): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
记一次Vue 2主应用集成Vue 3子项目的Monorepo迁移踩坑指南
前言
最近在进行Monorepo架构调整,需要将一个现有的Vue 3(Vite)项目作为一个子应用 (apps/wj) 迁移到由Vue 2(Webpack)主导的大仓中。本以为只是简单的“文件夹移动”,结果在依赖管理、网络代理和端口映射上踩了一圈坑。
本文记录了从迁移到跑通全流程遇到的4个典型问题及解决方案。
坑点一:pnpm 严格模式下的“幽灵依赖”
💥 现象
将项目移入大仓后,执行 dev 脚本报错:
'vite' 不是内部或外部命令,也不是可运行的程序
或者启动后报错找不到 unplugin-auto-import、vue-request 等插件。
🔍 原因
原项目可能使用 npm/yarn,存在依赖提升 (Hoisting) ,即 devDependencies 即使没写在 package.json 里,依靠根目录 node_modules 也能跑。 但迁移到 pnpm Monorepo 后,pnpm 的严格机制要求所有使用的包必须显式声明。
✅ 解决
在根目录通过 --filter 为子应用补全依赖:
# 补全构建工具
pnpm add vite @vitejs/plugin-vue vue-tsc -D --filter wj
# 补全缺失的业务/构建插件
pnpm add unplugin-auto-import unplugin-vue-components -D --filter wj
pnpm add vue-request --filter wj
坑点二:Workspace 内部包的正确引用
💥 现象
Vite 启动报错:
Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'configs' imported from ...
子应用试图引用大仓共享的配置包(packages/configs),但找不到模块。
🔍 原因
子应用虽然物理上在 monorepo 里,但 package.json 里没有声明对内部包的依赖,导致软链接未建立。
✅ 解决
使用 --workspace 协议建立软链:
# 将内部包链接给子应用
pnpm add configs --workspace --filter wj
注意:如果共享包内部也用了某些插件(如 @vitejs/plugin-vue),共享包自己也必须安装该插件,否则会报“父级依赖缺失”。
坑点三:Node 高版本 localhost 解析陷阱 (IPv6)
💥 现象
主应用配置了代理转发到子应用,但在浏览器访问时报 HTTP 500,终端报错:
Error: connect EACCES ::1:5192
🔍 原因
- 环境: Node.js v17+
-
机制: 主应用代理配置写了
target: 'http://localhost:5192'。Node 默认将localhost解析为 IPv6 地址::1。 -
冲突: 子应用 (Vite) 默认只监听 IPv4 (
127.0.0.1)。主应用去 IPv6 端口找人,自然连不上。
✅ 解决
方案A(推荐): 修改主应用代理配置,强制使用 IPv4 IP。
// 主应用 vite.config.ts / vue.config.js
proxy: {
'/wj': {
target: 'http://127.0.0.1:5192', // 👈 不要写 localhost
changeOrigin: true
}
}
方案B: 让子应用监听所有地址。启动命令改为 vite --host。
坑点四:主应用代理“漏气” (接口返回 HTML)
💥 现象
页面加载成功,但业务接口(如 /cmisp/api/xxx)报 304 或 200,查看 Response 内容竟然是 index.html 的代码,导致 JSON 解析失败。
🔍 原因
主应用只代理了页面路由 /wj,但子应用发出的 API 请求是 /cmisp 开头的。 主应用不认识 /cmisp,将其当成了前端路由处理,直接返回了 index.html。
✅ 解决
在主应用中补全 API 的代理转发规则:
// 主应用 vite.config.ts
server: {
proxy: {
// 1. 子应用页面资源
'/wj': {
target: 'http://127.0.0.1:5192',
changeOrigin: true
},
// 2. 子应用 API 请求 (新增)
'/cmisp': {
target: 'http://127.0.0.1:5192', // 如果是 mock 数据走这里;如果是真实后端填后端 IP
changeOrigin: true
}
}
}
总结
Monorepo 迁移不仅仅是文件搬运,核心在于:
- 依赖边界:pnpm 下必须“谁用谁装”。
-
网络互通:Node 高版本下
localhost的 IPv6 坑需要格外注意。 - 路由接管:主应用作为网关,必须接管子应用的所有请求(包括静态资源和 API)。
从弹窗变胖到 npm 依赖管理:一次完整的问题排查记录
踩坑记录:从弹窗变胖到 npm 依赖管理的深度排查
2025 年 12 月 26 日
一、问题发现
接手一个老项目,第一天就遇到问题:
npm install 装不上,要么卡死要么报错。同事让我用 yarn,还给了个 yarn.lock 文件,倒是装上了。
本来以为没事了,结果开发的时候发现:弹窗怎么胖了一圈?
所有用到 el-dialog 的地方,视觉上都比设计稿大了一点。
二、问题排查
第一步:定位样式来源
打开 DevTools 看样式,发现 Element Plus 的 el-dialog 有个 padding。
我心想:这 padding 不是一直都有的吗?为啥以前正常现在不正常?
先试着把这个 padding 覆盖掉,弹窗确实恢复正常了。但这不是根本解决方案,得搞清楚为啥会这样。
第二步:对比版本差异
我去翻 Element Plus 的 changelog 和源码,发现:
从 2.5.4 版本开始,el-dialog 被强制加了 16px 的 padding。
那问题来了:我本地装的版本为啥比 package.json 里定义的高?
第三步:追溯依赖变化
package.json 里写的是:
"element-plus": "^2.3.2"
这个 ^ 表示接受 2.x.x 的任何版本。
回想一下我做了什么:因为 npm install 报错,我删了 package-lock.json 重新装。
真相大白:删掉 lock 文件后,npm 会装 2.x 下最新的版本(比如 2.13.1),然后记录到新的 package-lock.json 里。依赖就这么"偷偷"升级了。
第四步:追查 npm install 报错原因
那为啥一开始 npm install 会报错?
我去看了原来的 package-lock.json,发现里面锁定的包镜像地址有问题:
| 镜像源 | 问题描述 |
|---|---|
| 阿里语雀镜像 | SSL 证书已过期 |
| 旧淘宝镜像 | 2024 年淘宝官方已迁移至新域名,旧地址已停服 |
上网一搜"npm install 报错",都让删 lock 文件。删了确实能装上,但版本就不对了,这就是个坑。
第五步:Node 版本冲突
还有个坑:项目要求用 Node 16.14.0,但只是口头说,没有任何强制措施。
- 我用 16.14.0 装,报错说某个包需要 Node >= 18.12.0
- 换到 18.12.0,又报错说另一个包需要 Node 16.x
原因是之前有人用高版本 Node 装了某些包,这些包对 Node 版本有强依赖,然后提交了 lock 文件。
三、根因分析
整个问题链条:
旧镜像地址失效
↓
npm install 报错
↓
删除 package-lock.json
↓
依赖版本偷偷升级(^2.3.2 → 2.13.1)
↓
引入 Element Plus 2.5.4+ 的破坏性变更
↓
弹窗多了 16px padding
本质问题:
- 镜像地址没有统一管理,过期了没人更新
- Node 版本没有强制约束,各自为战
- 包管理器没有锁定,npm/yarn 混用
- Element Plus 在次版本搞破坏性变更(这个是他们的锅)
四、解决方案
4.1 统一镜像源配置
把 lock 文件里的旧镜像地址全部替换成新的淘宝镜像。
创建 .npmrc 文件锁定镜像地址(npm 和 pnpm 都读这个文件):
registry=https://registry.npmmirror.com
创建 .yarnrc 文件锁定 yarn 的镜像:
registry "https://registry.npmmirror.com"
4.2 锁定 Node 版本
加了 .nvmrc 和 .nvmdrc 文件:
16.14.0
这样用 nvm 或 nvmd 的人切到项目目录会自动切换版本。
4.3 锁定包管理器
方法一:packageManager 字段 + Corepack
在 package.json 里加:
{
"packageManager": "npm@8.5.0"
}
支持的写法:
| 写法 | 说明 |
|---|---|
"npm@8.5.0" |
使用 npm 8.5.0 |
"yarn@1.22.19" |
使用 yarn classic |
"yarn@3.6.0" |
使用 yarn berry (v2+) |
"pnpm@8.6.0" |
使用 pnpm 8.6.0 |
注意:只支持精确版本号,不能写
^8.5.0。
但这个字段单独写没用,得配合 Corepack 才能生效。Corepack 是 Node.js 16.9+ 内置的,但默认是禁用的:
# 启用 Corepack(需要管理员权限)
corepack enable
# Windows 用管理员终端,Mac/Linux 加 sudo
sudo corepack enable
启用后的效果:
- 进入项目目录时,Corepack 读取
packageManager字段 - 如果本地没有对应版本,自动下载
- 用错包管理器直接报错
- 版本不对也报错
方法二:preinstall 脚本
{
"scripts": {
"preinstall": "npx only-allow npm"
}
}
只允许用 npm,用 yarn 或 pnpm 装就报错。
注意:
only-allow只能限制包管理器类型,不能限制 Node 版本。
方法三:engines 字段限制 Node 版本
{
"engines": {
"node": ">=16.14.0 <17.0.0",
"npm": ">=8.0.0"
}
}
engines 支持多种写法:
| 写法 | 含义 |
|---|---|
"16.14.0" |
精确版本 |
">=16.14.0 <17.0.0" |
范围版本 |
"~16.14.0" |
允许 16.14.x |
"^16.14.0" |
允许 16.x.x |
"16.x || 18.x" |
多版本支持 |
配合 .npmrc 开启严格模式才能真正生效:
engine-strict=true
启用后版本不对直接报错。
4.4 配置依赖版本前缀策略
npm 默认用 ^ 前缀,风险太大。可以在 .npmrc 里改:
| 配置值 | 效果 | 示例 |
|---|---|---|
save-prefix=^ |
允许次版本升级 | ^2.3.2 |
save-prefix=~ |
仅允许修订版本升级 | ~2.3.2 |
save-exact=true |
精确版本,无前缀 | 2.3.2 |
推荐用 ~:
npm/pnpm 配置(.npmrc):
save-prefix=~
yarn 配置(.yarnrc):
save-prefix "~"
为啥选 ~?
| 方案 | 优点 | 缺点 |
|---|---|---|
| 精确版本 | 完全锁定,零风险 | 无法自动获取 bug 修复 |
~ 波浪号 |
自动获取修订版本,风险可控 | 极小概率遇到修订版本引入问题 |
^ 脱字符 |
自动获取新功能和修复 | 风险较高,如本次 Element Plus 问题 |
修订版本按 SemVer 规范只包含 bug 修复,向下兼容。配合 lock 文件提交,实际安装版本还是锁定的,只有删 lock 文件重装才会升级。
敏感依赖单独处理:
UI 组件库这种核心依赖,建议直接在 package.json 里用精确版本:
{
"dependencies": {
"element-plus": "2.3.2",
"vue": "~3.3.4"
}
}
4.5 Element Plus 样式修复
当前方案:
- 把 Element Plus 版本固定为精确版本(去掉
^),防止后续静默升级 - 对已受影响的弹窗组件,单独进行样式覆盖:
.affected-dialog .el-dialog {
--el-dialog-padding-primary: 0;
}
其他可选方案:
- 全局样式覆盖(影响范围大,需充分测试)
- 回退 Element Plus 版本至 2.5.3 或更低
五、Element Plus 的问题
这事本质上是 Element Plus 的锅。
在 2.5.4 这个次版本里加了个强制 padding,这是破坏性变更。按 SemVer 语义化版本规范,破坏性变更应该放到大版本(3.x)里。
六、知识点总结
版本号前缀
| 写法 | 含义 |
|---|---|
^2.3.2 |
2.x.x 都行,最低 2.3.2 |
~2.3.2 |
2.3.x 都行,最低 2.3.2 |
2.3.2 |
精确版本,就要 2.3.2 |
SemVer 语义化版本规范
主版本.次版本.修订版本
│ │ └── bug 修复,向下兼容
│ └───────── 新功能,向下兼容
└──────────────── 破坏性变更,不兼容
依赖管理配置文件
| 文件 | 作用 |
|---|---|
| .npmrc | npm/pnpm 配置,镜像地址、严格模式等 |
| .yarnrc | yarn 配置 |
| .nvmrc | nvm 的 Node 版本 |
| .nvmdrc | nvmd 的 Node 版本 |
| package-lock.json | npm 的依赖锁定 |
| yarn.lock | yarn 的依赖锁定 |
| pnpm-lock.yaml | pnpm 的依赖锁定 |
七、最佳实践
| 优先级 | 措施 | 说明 |
|---|---|---|
| 高 | 提交 lock 文件 | 防止版本漂移的核心 |
| 高 | 配置 .npmrc / .yarnrc
|
统一镜像源 |
| 高 | 配置 .nvmrc / .nvmdrc
|
本地开发版本提示 |
| 高 | 敏感依赖精确版本 | UI 库等去掉 ^ 前缀 |
| 中 | 配置 save-prefix=~
|
控制新依赖版本范围 |
| 中 | 配置 engines + engine-strict
|
强制 Node 版本检查 |
| 中 |
only-allow 脚本 |
限制包管理器类型 |
| 低 |
packageManager + Corepack |
锁定包管理器版本 |
八、经验教训
- lock 文件必须提交,别让依赖偷偷升级
- 镜像地址要统一管理,用 .npmrc/.yarnrc 锁定
- Node 版本要强制约束,用 .nvmrc + engines + engine-strict
- 包管理器也要锁,packageManager + Corepack 或 only-allow
-
敏感依赖用精确版本,UI 库这种别用
^ -
新依赖用
~前缀,比^安全,比精确版本灵活 - 删 lock 文件要谨慎,可能引入版本漂移
- 遇到问题要追根溯源,不能只解决表面现象
浅谈 import.meta.env 和 process.env 的区别
这是一个前端构建环境里非常核心、也非常容易混淆的问题。下面我们从来源、使用场景、编译时机、安全性四个维度来谈谈 import.meta.env 和 process.env 的区别。
一句话结论
process.env是 Node.js 的环境变量接口import.meta.env是 Vite(ESM)在构建期注入的前端环境变量
一、process.env 是什么?
1️⃣ 本质
- 来自 Node.js
- 运行时读取 服务器 / 构建机的系统环境变量
- 本身 浏览器里不存在
console.log(process.env.NODE_ENV);
2️⃣ 使用场景
- Node 服务
- 构建工具(Webpack / Vite / Rollup)
- SSR(Node 端)
3️⃣ 前端能不能用?
👉 不能直接用
浏览器里没有 process:
// 浏览器原生环境 ❌
Uncaught ReferenceError: process is not defined
4️⃣ 为什么 Webpack 项目里能用?
因为 Webpack 帮你“编译期替换”了
process.env.NODE_ENV
// ⬇️ 构建时被替换成
"production"
本质是 字符串替换,不是运行时读取。
二、import.meta.env 是什么?
1️⃣ 本质
- Vite 提供
- 基于 ES Module 的
import.meta - 构建期 + 运行期可用(但值是构建期确定的)
console.log(import.meta.env.MODE);
2️⃣ 特点
- 浏览器里 原生支持
- 不依赖 Node 的
process - 更符合现代 ESM 规范
三、两者核心区别对比(重点)
| 维度 | process.env | import.meta.env |
|---|---|---|
| 来源 | Node.js | Vite |
| 标准 | Node API | ESM 标准扩展 |
| 浏览器可用 | ❌(需编译替换) | ✅ |
| 注入时机 | 构建期 | 构建期 |
| 是否运行时读取 | ❌ | ❌ |
| 推荐前端使用 | ❌ | ✅ |
⚠️ 两者都不是“前端运行时读取服务器环境变量”
四、Vite 中为什么不用 process.env?
1️⃣ 因为 Vite 不再默认注入 process
// Vite 项目中 ❌
process.env.API_URL
会直接报错。
2️⃣ 官方设计选择
- 避免 Node 全局污染
- 更贴近浏览器真实环境
- 更利于 Tree Shaking
五、Vite 环境变量的正确用法(非常重要)
1️⃣ 必须以 VITE_ 开头
# .env
VITE_API_URL=https://api.example.com
console.log(import.meta.env.VITE_API_URL);
❌ 否则 不会注入到前端
2️⃣ 内置变量
import.meta.env.MODE // development / production
import.meta.env.DEV // true / false
import.meta.env.PROD // true / false
import.meta.env.BASE_URL
六、安全性
⚠️ 重要警告
import.meta.env里的变量 ≠ 私密
它们会:
- 被 打进 JS Bundle
- 可在 DevTools 直接看到
❌ 不要这样做
VITE_SECRET_KEY=xxxx
✅ 正确做法
- 前端:只放“公开配置”(API 域名、开关)
- 私密变量:只放在 Node / 服务端
七、SSR / 全栈项目里怎么区分?
在 Vite + SSR(如 Nuxt / 自建 SSR):
Node 端
process.env.DB_PASSWORD
浏览器端
import.meta.env.VITE_API_URL
两套环境变量是刻意分开的。
-
为什么必须分成两套?(设计原因)
1️⃣ 执行环境不同(这是根因)
| 位置 | 运行在哪 | 能访问什么 |
|---|---|---|
| SSR Server | Node.js | process.env |
| Client Bundle | 浏览器 | import.meta.env |
浏览器里 永远不可能安全地访问服务器环境变量。
2️⃣ SSR ≠ 浏览器
很多人误解:
“SSR 是不是浏览器代码先在 Node 跑一遍?”
❌ 不完全对
SSR 实际是:
Node.js 先跑一份 → 生成 HTML
浏览器再跑一份 → hydrate
这两次执行:
- 环境不同
- 变量来源不同
- 安全级别不同
-
在 Vite + SSR 中,变量的“真实流向”
1️⃣ Node 端(SSR Server)
// server.ts / entry-server.ts
const dbPassword = process.env.DB_PASSWORD;
✔️ 真实运行时读取
✔️ 不会进 bundle
✔️ 只存在于服务器内存
2️⃣ Client 端(浏览器)
// entry-client.ts / React/Vue 组件
const apiUrl = import.meta.env.VITE_API_URL;
✔️ 构建期注入
✔️ 会打进 JS
✔️ 用户可见
3️⃣ 中间那条“禁止通道”
// ❌ 绝对禁止
process.env.DB_PASSWORD → 浏览器
SSR 不会、也不允许,自动帮你“透传”环境变量
-
SSR 中最容易踩的 3 个坑(重点)
❌ 坑 1:在“共享代码”里直接用 process.env
// utils/config.ts(被 server + client 共用)
export const API = process.env.API_URL; // ❌
问题:
- Server OK
- Client 直接炸(或被错误替换)
✅ 正确方式:
export const API = import.meta.env.VITE_API_URL;
或者:
export const API =typeof window === 'undefined'
? process.env.INTERNAL_API
: import.meta.env.VITE_API_URL;
❌ 坑 2:误以为 SSR 可以“顺手用数据库变量”
// Vue/React 组件里
console.log(process.env.DB_PASSWORD); // ❌
哪怕你在 SSR 模式下,这段代码:
- 最终仍会跑在浏览器
- 会被打包
- 是严重安全漏洞
❌ 坑 3:把“环境变量”当成“运行时配置”
// ❌ 想通过部署切换 API
import.meta.env.VITE_API_URL
🚨 这是 构建期值:
build 时确定
→ CDN 缓存
→ 所有用户共享
想运行期切换?只能:
- 接口返回配置
- HTML 注入 window.CONFIG
- 拉 JSON 配置文件
-
SSR 项目里“正确的分层模型”(工程视角)
┌──────────────────────────┐
│ 浏览器 Client │
│ import.meta.env.VITE_* │ ← 公开配置
└───────────▲──────────────┘
│
HTTP / HTML
│
┌───────────┴──────────────┐
│ Node SSR Server │
│ process.env.* │ ← 私密配置
└───────────▲──────────────┘
│
内部访问
│
┌───────────┴──────────────┐
│ DB / Redis / OSS │
└──────────────────────────┘
这是一条 单向、安全的数据流。
-
Nuxt / 自建 SSR 的对应关系
| 类型 | 用途 |
|---|---|
| runtimeConfig | Server-only |
| runtimeConfig.public | Client 可见 |
| process.env | 仅 server |
👉 Nuxt 本质也是在帮你维护这条边界
八、常见误区总结
❌ 误区 1
import.meta.env是运行时读取
❌ 错,仍是构建期注入
❌ 误区 2
可以用它动态切换环境
❌ 不行,想动态只能:
- 接口返回配置
- 或运行时请求 JSON
❌ 误区 3
Vite 里还能继续用
process.env
❌ 除非你手动 polyfill(不推荐)
九、总结
- 前端(Vite)只认
import.meta.env.VITE_* - 服务端(Node)只认
process.env - 永远不要把秘密放进前端 env
用心写好一个登录页:代码、体验与细节的平衡
写在前面
今天,我们将使用 React + Vite + Tailwind CSS + Lucide React,快速搭建一个简洁、响应式且注重细节的登录页面,并顺手拆解几个提升用户体验的小技巧。
为什么登录页面非常重要?
别小看这个看似简单的页面——它往往是用户对产品的第一印象。
登录页远不止是一个表单,更是整个产品体验的入口:设计得当,用户顺畅进入;处理草率,可能直接导致流失。
![]()
用tindwindcss完成一个登录页面。
借助 Tailwind CSS 的原子化类名体系,我们能够高效构建出美观、响应式且高度可定制的登录界面。
无需传统 CSS,仅通过组合语义清晰的工具类,即可实现精致的布局、柔和的阴影、流畅的过渡动画以及跨设备的自适应表现。
配合 React 的状态管理与 Lucide React 的简洁图标,整个登录页不仅视觉清爽,交互也细腻自然——从密码可见性切换到聚焦态反馈,每一处细节都服务于用户体验。
这不仅是“完成一个表单”,更是用代码传递信任与温度的过程。
这里用到的一些技术栈
这个小项目基于现代前端工程化理念构建,选用了以下轻量的技术组合:
React:作为核心 UI 库,利用其声明式语法和组件化思想,将登录表单拆解为可维护、可复用的逻辑单元。通过 useState 等 Hooks 管理状态,实现数据驱动的交互体验。
Tailwind CSS:采用 Utility-First(原子化)开发模式,摒弃传统 CSS 的命名负担与样式冗余。所有样式直接通过语义清晰的类名在 JSX 中组合而成,极大提升开发效率与设计一致性,同时天然支持响应式布局和主题扩展。
Lucide React:一个轻量、开源且风格统一的图标库,提供简洁优雅的 SVG 图标组件。项目中使用了 <Mail />、<Lock />、<Eye /> 和 <EyeOff /> 等图标,增强界面视觉引导,且无需额外配置即可与 Tailwind 样式无缝融合。
这套技术栈兼顾开发体验与运行性能,既适合快速原型验证,也具备良好的可维护性与扩展能力,是构建现代化登录界面的理想选择。
这里用到的tindwind 类名的解释:
-
min-h-screen— 设置元素最小高度为视口高度 -
bg-slate-50— 设置背景色为浅 slate 灰(非常淡的灰色) -
flex items-center justify-center— 使用 Flex 布局,垂直和水平居中子元素 -
p-4— 内边距为 1rem(16px) -
max-w-md— 最大宽度为中等尺寸(默认 28rem / 448px) -
bg-white— 背景色为纯白色 -
rounded-3xl— 圆角非常大(默认 1.5rem / 24px) -
shadow-xl— 添加超大阴影,增强浮层感 -
border-slate-100— 边框颜色为极浅 slate 灰 -
space-y-6— 子元素之间垂直间距为 1.5rem(24px)
实现登录页面的一些关键逻辑:
const [formData,setFormData] = useState({
email:'',
password:'',
rememberMe:false
})
这里通过 useState 定义了 formData 状态,用于统一管理用户输入的数据,包括email、password以及rememberMe
const [showPassword,setShowPassword] = useState(false);
const [isLoading,setLoading] = useState(false);
使用另一个状态 showPassword 来控制密码字段的可见性。当该值为 false 时,密码以密文形式显示;切换为 true 时,则以明文展示,提升用户体验,尤其在移动端输入复杂密码时非常实用。
此外,还定义了 isLoading 状态,用于表示登录请求是否正在进行中。虽然当前代码中尚未接入实际的 API 调用,但这一状态为未来防止重复提交、显示加载指示器等交互提供了基础支持。
const handleChange = (e) => {
const {name,value,type,checked} = e.target;//input
setFormData((prev) => ({
...prev,
[name]:type === "checkbox" ? checked : value
}));
}
表单的输入变化由 handleChange 函数统一处理。
它通过解构事件对象的 name、value、type 和 checked 属性,智能判断当前元素类型:若是复选框(如“记住我”),则取 checked 值;否则取 value。随后,利用函数式更新方式安全地合并新值到 formData 中,确保状态更新的准确性和可维护性。
const handleSubmit = async(e) => {
e.preventDefault();
}
表单提交由 handleSubmit 函数接管,其首要任务是调用 e.preventDefault() 阻止浏览器默认的页面跳转或刷新行为
我们在输入框中键入内容时,handleChange 会实时捕获并更新对应状态;点击“登录”按钮时,handleSubmit 被触发,准备发起认证请求;而点击密码框右侧的眼睛图标,则会切换 showPassword 状态,动态改变密码输入框的 type 属性,实现密码的显示与隐藏。
整个流程结构清晰、状态集中、扩展性强,为构建健壮的登录界面打下了良好基础。
![]()
为什么这个登录页“可维护”?
这份代码之所以易于迭代和调试,并非偶然。所有表单数据被统一收纳在 formData 对象中结构清晰,便于追踪状态变化
输入处理逻辑被抽象为通用的 handleChange 函数,无论面对文本输入、密码框还是复选框,都能自动判断类型并更新对应字段,彻底避免了重复代码
UI 层面完全由 Tailwind 的语义化类名描述外观,而 React 状态则专注表达交互行为,两者职责分明、互不耦合。
正因如此,未来的扩展变得异常轻松:若需新增“验证码”字段,只需在状态对象中添加一个属性并绑定到新输入框;若想加入“微信登录”或“Apple 登录”等第三方选项,也只需在现有的 space-y-6 容器中插入一行即可。
这种结构天然支持灵活演进,而非牵一发而动全身。
响应式:使用场景的切换,始终优雅
界面的优雅不仅在于视觉美感,更在于它如何从容应对不同屏幕尺寸。
借助 Tailwind CSS 的响应式断点系统,我们仅用一行 p-8 md:p-10 就实现了内边距的智能适配:
在手机上保持紧凑,在中等及以上屏幕则适度舒展。整个登录卡片采用居中布局,搭配柔和的 rounded-3xl 圆角与克制的 shadow-xl 阴影,在 小屏设备上不显拥挤,在 电脑大屏显示器上也依然得体。
而容器宽度 max-w-md 的设定并非随意为之——它落在人眼阅读最舒适的“黄金区间”:太宽会让视线左右扫视疲劳,太窄又显得局促不安。
这个经过验证的尺寸,是功能与美学平衡的结果。
总结
通过这个登录页的实现,我们不仅完成了一个功能完整的 UI 组件,更实践了现代前端开发的核心理念:以用户为中心,用工程化思维打造有温度的体验。
借助 React 的状态管理,我们让数据流清晰可控;
利用 Tailwind CSS 的原子化样式,快速构建出响应式、一致且美观的界面;
通过 Lucide React 引入轻量图标,提升视觉引导;而像密码可见性切换、聚焦反馈、加载状态预留等细节,则体现了对用户体验的细致考量。
这不仅仅是一个登录表单——它是产品信任感的起点,是技术与设计的交汇点,也是我们作为开发者传递用心的方式。
代码可以简洁,但体验不能将就。
附录:参考文章以及源码
参考文章
关于如何在 React 项目中安装和配置 Tailwind CSS,可以参考这篇文章: Tailwind CSS 入门指南:从传统 CSS 到原子化开发的高效跃迁
我的源码:
// esm React 代表默认引入
// useState hooks 引入 部分引入
// esm cjs 优秀的地方 懒加载
import {
useState
} from 'react';
import {
Eye,
EyeOff,
Lock,
Mail
} from 'lucide-react';
export default function App () {
const [formData,setFormData] = useState({
email:'',
password:'',
rememberMe:false
})
// 密码显示隐藏
const [showPassword,setShowPassword] = useState(false);
// 登录api等状态
const [isLoading,setLoading] = useState(false);
// 抽象的事件处理函数
// input type="text|password|checkbox"
// name email|password|rememberMe
// value 数据状态
// checked 选中状态
const handleChange = (e) => {
// e.target
const {name,value,type,checked} = e.target;//input
setFormData((prev) => ({
// 传一个函数比较合适
...prev,
[name]:type === "checkbox" ? checked : value
}));
}
const handleSubmit = async(e) => {
e.preventDefault();
}
return (
<div
className="min-h-screen bg-slate-50 flex items-center justify-center p-4">
<div className="relative z-10 w-full max-w-md bg-white rounded-3xl shadow-xl shadow-slate-200/60 border-slate-100 p-8 md:p-10">
<div className="text-center mb-10">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-xl bg-indigo-600 text-white mb-4 shadow-lg shadow-indigo-200">
<Lock size={24}/>
</div>
<h1 className="text-2xl font-bold text-slate-900">欢迎回来</h1>
<p className="text-slate-500 mt-2">请登录你的账号</p>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* 邮箱输入框 */}
<div className='space-y-2'>
<label className='text-sm font-medium text-slate-700 ml-1'>Email:</label>
<div className='relative group'>
<div className="absolute inset-y-0 left-0 pl-4
flex items-center pointer-events-none
text-slate-400 group-focus-within:text-indigo-600 transition-colors
">
<Mail size={18}/>
</div>
<input
type="email"
name="email"
required
value={formData.email}
onChange={handleChange}
placeholder='name@company.com'
className='block w-full pl-11 pr-4 py-3 bg-slate-50
border border-slate-200 rounded-xl text-slate-900
placeholder:text-slate-400 focus:outline-none
focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600
transition-all'/>
</div>
</div>
{/* 密码输入框 */}
<div className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-sm font-medium text-slate-700">密码</label>
<a href="#"
className="text-sm font-medium text-indigo-600 hover:text-indigo-500
transition-colors">忘记密码?</a>
</div>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4
flex items-center pointer-events-none
text-slate-400 group-focus-within:text-indigo-600 transition-colors
"
>
<Lock size={18} />
</div>
<input
type={showPassword ? "text" : "password"}
name="password"
required
value={formData.password}
onChange={handleChange}
placeholder='*******'
className="block w-full pl-11 pr-4 py-3 bg-slate-50
border border-slate-200 rounded-xl text-slate-900
placeholder:text-slate-400 focus:outline-none
focus:ring-2 focus:ring-indigo-600/20 focus:border-indigo-600
transition-all
"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-slate-400 hover:text-slate-600 transition-colors"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
</form>
</div>
</div>
)
}
聊聊我对 React Hook 不一样的理解
什么是 React Hook
React Hook 是 React 16.8 版本推出的特性,核心作用是让函数组件也能使用状态(State)、生命周期等原本只有类组件才能拥有的 React 特性。它通过一系列预定义的钩子函数(如 useState、useEffect),让开发者无需编写类组件,就能更简洁、灵活地管理组件逻辑,同时也便于逻辑的复用与拆分。
网上有大量的总结文章教会你如何使用 react hook,包括一些诸如取代 mixin 、hoc、类组件继承,所以这不是我想讲的重点。
两面性
Hook的出现不仅是React语法层面的优化,更重塑了函数组件的能力边界与代码组织方式,但也随之引入了新的认知与实践门槛。从核心能力来看,其价值主要体现在三个维度:
1. 逻辑复用的革命性突破:相比类组件时代mixins的命名冲突、HOC的嵌套地狱,Hook通过自定义Hook实现了“逻辑抽取-复用”的极简路径。开发者可将分散在不同生命周期的关联逻辑(如数据请求+加载状态+异常处理)抽离为独立Hook,在多个组件中直接复用,且不存在属性透传或嵌套冗余的问题。
2. 状态与副作用的集中管控:类组件中需分散在componentDidMount、componentDidUpdate、componentWillUnmount的副作用逻辑,在Hook中可通过useEffect统一管理,配合返回函数完成资源清理,实现“关联逻辑聚合”,大幅提升代码可读性。同时,useState、useReducer让函数组件无需依赖this即可实现灵活的状态管理,摆脱了类组件中this指向的诸多陷阱。
3. 更友好的工程化适配:Hook天然契合函数式编程理念,与TypeScript的类型推导无缝兼容,能显著降低强类型项目的开发成本。此外,React 18后续推出的useTransition、useDeferredValue等新Hook,进一步拓展了并发渲染场景下的能力,让函数组件能更好地适配现代前端复杂的性能需求。
但能力的拓展也伴随着新的痛点,这些问题往往源于对Hook设计理念的理解偏差,而非特性本身:
1. 依赖管理的“隐形门槛” :useEffect、useCallback等Hook的依赖数组是最易踩坑的环节。依赖缺失会导致副作用不触发更新,依赖冗余(如未缓存的函数、每次渲染新建的对象)则会引发频繁重渲染,更隐蔽的是“依赖闭环”导致的无限循环(如useEffect中更新state却依赖该state),排查成本极高。
2. 闭包陷阱的高频踩坑:函数组件每次渲染都会创建新的作用域,异步操作(定时器、Promise回调)极易捕获旧作用域的“过期状态”。例如依赖数组为空的useEffect中,定时器始终获取初始state值,这类问题因表象与逻辑预期背离,新手往往难以定位。
3. 副作用清理的隐蔽风险:useEffect的清理函数(返回函数)是避免内存泄漏的关键,但实际开发中常被遗漏(如window事件监听、WebSocket连接未解绑)。尤其在复杂组件中,多个副作用叠加时,清理逻辑的顺序与完整性更难把控,容易引发隐性bug。
4. 复杂场景下的性能优化难题:Hook简化了代码编写,但也容易催生“胖Hook”——一个useEffect包含多个无关副作用逻辑,导致组件耦合度升高。同时,新手常忽视useMemo、useCallback的合理使用,在大数据渲染、深层组件传递函数时,易出现不必要的重渲染,且性能瓶颈难以定位。
限制与规则
React Hook 并非可以随意使用,其设计遵循严格的规则与限制,这些规则是 React 能够稳定管理 Hook 状态关联的核心保障,违反规则可能导致组件渲染异常、状态错乱等难以排查的问题。核心规则与限制主要包括以下几点:
1. 只能在函数组件或自定义 Hook 的顶层调用:这是最核心的规则。Hook 不能嵌套在循环、条件语句(if/else)、switch 语句或嵌套函数内部调用。原因是 React 依靠 Hook 的调用顺序来建立状态与组件的关联,若调用顺序不固定(如条件判断导致某些 Hook 有时执行有时不执行),会破坏 React 对状态的追踪,导致状态错乱。例如:不能在 if (isShow) { useState(0) } 中调用 Hook。
2. 只能在 React 函数中调用 Hook:Hook 仅能用于 React 函数组件(包括箭头函数组件)和自定义 Hook 中,不能在普通的 JavaScript 函数中调用。这是因为 Hook 依赖 React 的内部机制来管理状态和副作用,普通 JS 函数不具备这样的运行环境,调用后无法正常工作。
3. 自定义 Hook 必须以 “use” 开头命名:这是 React 约定的命名规范,并非语法强制要求,但遵循该规范能让 React 识别自定义 Hook,同时让开发者快速区分普通函数与 Hook,避免误用。例如:useRequest(数据请求 Hook)、useWindowSize(监听窗口大小 Hook),若命名为 requestHook 则无法被 React 正确识别为 Hook,也不便于团队协作维护。
4. 状态更新的不可变性限制:使用 useState 或 useReducer 管理引用类型状态(对象、数组)时,必须遵循不可变性原则,不能直接修改原始状态对象(如 state.obj.name = 'new'),而应创建新的对象/数组来更新状态。因为 React 通过浅比较引用是否变化来判断是否需要重新渲染,直接修改原始状态不会改变引用,导致组件无法触发重渲染。
5. 副作用清理的必要性限制:使用 useEffect 管理副作用(如事件监听、定时器、网络连接)时,若副作用会产生内存泄漏风险(如组件卸载后仍执行回调),必须在 useEffect 的返回函数中编写清理逻辑(如移除事件监听、清除定时器、关闭连接)。这是保障组件性能和稳定性的重要限制,忽略清理可能导致内存泄漏、多次触发副作用等问题。
不一样的想法
某些规则是可以打破的
- 只能在函数顶部使用 hook
- 条件 hook
- 类组件内使用 hook
类组件完全放弃了吗?代价是什么?
在新的项目中,几乎已经看不到类组件被使用(除了手搓 ErrorBoundary)。
但在享受 hook 带来函数式组件魔法的过程中,也引入了许多的问题
- 为了防止子组件重渲染,需要对回调函数、数据做 memo(useCallback、useMemo)
- 少传个 dep,导致闭包问题、子组件不更新问题
- 然后又引入了 React Compiler 、useEventEffect
这就有点为了填一个坑,挖了另一个坑的感觉
类组件是有可取之处的,比如
-
回调方法通过 this.state 是可以取到最新的状态的,因此不需要那么多 useCallback useMemo,减少了性能优化的心智负担;
-
ref 可以直接使用组件的属性,无需像函数组件那样借助 useRef 再手动关联,操作更简洁;
-
生命周期逻辑时序更直观:类组件通过 componentDidMount、componentDidUpdate、componentWillUnmount 等明确的钩子划分生命周期阶段,复杂副作用(如多轮数据请求、时序依赖的资源操作)的执行时机更易把控,无需像 useEffect 那样通过依赖数组间接控制;
-
状态更新支持自动合并:类组件中 setState 会自动合并对象类型状态的部分属性(如 this.setState({ name: 'new' }) 不会覆盖其他未修改的状态字段),而函数组件 useState 需手动通过扩展运算符(...)实现合并,降低了状态更新的代码复杂度。
但 Hook 在逻辑注入、复用方面相比类组件有绝对的优势。
所以有没有人想过在类组件里面使用 Hook,将两者的优势结合一下? juejin.cn/post/758429…
Hook 作为状态管理的一种方式,却依赖于组件生命周期
想必 React 开发者最头疼的就是状态管理方案了,但是一旦引入了状态管理方案如 redux、zustand,你会直接失去 Hook 的能力。 juejin.cn/post/759172…
原本可以使用 ahooks 的 useRequest 发起请求,迁移到 zustand 直接就是一坨。
没有对比,就真没有伤害。
如果你用过 vue 生态中的 pinia pinia.vuejs.org/zh/cookbook… ,就会知道 pinia 是可以直接复用 vue 的 composition api 以及 VueUse 相关的能力的。
针对这个课题,我也进行了尝试。 juejin.cn/post/759120…
总结
综上,React Hook 绝非完美的“银弹”,而是一把兼具强大能力与使用门槛的“双刃剑”。它以革命性的逻辑复用方式、集中化的状态与副作用管控,以及友好的工程化适配性,重塑了React函数组件的开发模式,成为现代React项目的主流选择。但与此同时,依赖管理难题、闭包陷阱、副作用清理风险等痛点,也让开发者面临更高的认知与实践成本。
关于Hook的规则,并非绝对不可突破,在特定场景下通过合理封装实现动态Hook调用、类组件间接使用Hook等探索,为特殊需求(如旧项目迁移)提供了更多可能,但需警惕代码复杂度提升的风险。而类组件与Hook的取舍之争,本质是开发效率、可维护性与性能之间的权衡——类组件在状态获取、生命周期直观性等方面的优势仍不可忽视,完全放弃可能陷入“为填坑而挖新坑”的循环。
此外,Hook依赖组件生命周期的特性,使其在状态管理场景中存在天然局限,相比Vue Pinia对组合式API的无缝复用能力,仍有优化空间。这也提示我们,不应盲目迷信Hook的“魔法”,而应回归开发本质:既要充分发挥其逻辑复用的核心优势,也要理性看待其不足,结合项目场景(新旧项目、复杂度、团队习惯)灵活选择技术方案,甚至探索类组件与Hook的优势融合路径。最终,技术的价值在于解决问题,对Hook的理解不应局限于“规范用法”,而应基于对其底层逻辑的深刻认知,实现灵活、高效且稳定的开发实践。
React 自定义 Hooks 生存指南:7 个让你少加班的"偷懒"神器
摘要:都 2026 年了,还在写重复代码?还在 useEffect 里疯狂 copy-paste?醒醒,自定义 Hooks 才是现代 React 开发者的"摸鱼"神器。本文手把手教你封装 7 个超实用的自定义 Hooks,从此告别 996,拥抱 WLB。代码即拿即用,CV 工程师狂喜。
引言:一个关于"偷懒"的故事
场景一: 产品经理:"这个搜索框要做防抖。" 你:"好的。"(打开 Google,搜索 "react debounce") 产品经理:"那个页面也要。" 你:"好的。"(再次 copy-paste) 产品经理:"还有这 10 个页面..." 你:(开始怀疑人生)
场景二: 你:"这个表单状态管理写得真优雅。" (三个月后) 你:"这 TM 是谁写的?!" Git blame:"是你自己。" 你:(沉默)
场景三: Code Review 时—— 同事:"这段逻辑我在另外 5 个文件里见过。" 你:"那个...我准备重构的..." 同事:"你三个月前也是这么说的。" 你:(想找个地缝钻进去)
如果你也有类似经历,恭喜你,这篇文章就是为你准备的。
今天,我要分享 7 个超实用的自定义 Hooks,让你:
- 代码复用率提升 300%
- 每天少写 200 行重复代码
- 准时下班不是梦
第一章:自定义 Hooks 的"道"与"术"
1.1 什么是自定义 Hook?
简单说,自定义 Hook 就是一个以 use 开头的函数,里面可以调用其他 Hooks。
// 这就是一个最简单的自定义 Hook
function useMyHook() {
const [state, setState] = useState(null)
useEffect(() => {
// 做一些事情
}, [])
return state
}
为什么要用自定义 Hook?
- 复用逻辑:同样的逻辑写一次,到处用
- 关注点分离:组件只管渲染,逻辑交给 Hook
- 更好测试:Hook 可以单独测试
- 代码更清晰:组件代码从 500 行变成 50 行
1.2 自定义 Hook 的命名规范
// ✅ 正确:以 use 开头
useLocalStorage()
useDebounce()
useFetch()
// ❌ 错误:不以 use 开头(React 不会识别为 Hook)
getLocalStorage()
debounceValue()
fetchData()
记住: 以 use 开头不是装逼,是 React 识别 Hook 的方式。不这么写,React 的 Hooks 规则检查会失效。
第二章:7 个让你少加班的自定义 Hooks
Hook #1:useLocalStorage —— 本地存储の优雅姿势
痛点: 每次用 localStorage 都要 JSON.parse、JSON.stringify,还要处理 SSR 报错。
解决方案:
import { useState, useEffect, useCallback } from "react"
/**
* 将状态同步到 localStorage 的 Hook
* @param {string} key - localStorage 的键名
* @param {any} initialValue - 初始值
* @returns {[any, Function, Function]} [存储的值, 设置函数, 删除函数]
*/
function useLocalStorage(key, initialValue) {
// 获取初始值(惰性初始化)
const [storedValue, setStoredValue] = useState(() => {
// SSR 环境下 window 不存在
if (typeof window === "undefined") {
return initialValue
}
try {
const item = window.localStorage.getItem(key)
// 如果存在则解析,否则返回初始值
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.warn(`Error reading localStorage key "${key}":`, error)
return initialValue
}
})
// 设置值的函数
const setValue = useCallback(
(value) => {
try {
// 支持函数式更新
const valueToStore =
value instanceof Function ? value(storedValue) : value
setStoredValue(valueToStore)
if (typeof window !== "undefined") {
window.localStorage.setItem(key, JSON.stringify(valueToStore))
}
} catch (error) {
console.warn(`Error setting localStorage key "${key}":`, error)
}
},
[key, storedValue]
)
// 删除值的函数
const removeValue = useCallback(() => {
try {
setStoredValue(initialValue)
if (typeof window !== "undefined") {
window.localStorage.removeItem(key)
}
} catch (error) {
console.warn(`Error removing localStorage key "${key}":`, error)
}
}, [key, initialValue])
return [storedValue, setValue, removeValue]
}
export default useLocalStorage
使用示例:
function App() {
// 就像 useState 一样简单!
const [theme, setTheme, removeTheme] = useLocalStorage("theme", "light")
const [user, setUser] = useLocalStorage("user", null)
return (
<div className={`app ${theme}`}>
<button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
切换主题:{theme}
</button>
<button onClick={() => setUser({ name: "张三", age: 25 })}>登录</button>
<button onClick={removeTheme}>重置主题</button>
{user && <p>欢迎,{user.name}!</p>}
</div>
)
}
为什么这个 Hook 香?
- 自动处理 JSON 序列化/反序列化
- 支持 SSR(不会报 window is not defined)
- 支持函数式更新(和 useState 一样)
- 提供删除功能
Hook #2:useDebounce —— 防抖の终极方案
痛点: 搜索框输入时,每敲一个字就发请求,服务器直接被你打爆。
解决方案:
import { useState, useEffect } from "react"
/**
* 防抖 Hook:延迟更新值,避免频繁触发
* @param {any} value - 需要防抖的值
* @param {number} delay - 延迟时间(毫秒)
* @returns {any} 防抖后的值
*/
function useDebounce(value, delay = 500) {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
// 设置定时器
const timer = setTimeout(() => {
setDebouncedValue(value)
}, delay)
// 清理函数:值变化时清除上一个定时器
return () => {
clearTimeout(timer)
}
}, [value, delay])
return debouncedValue
}
export default useDebounce
使用示例:
function SearchComponent() {
const [searchTerm, setSearchTerm] = useState("")
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
// 防抖处理:用户停止输入 500ms 后才触发
const debouncedSearchTerm = useDebounce(searchTerm, 500)
useEffect(() => {
if (debouncedSearchTerm) {
setLoading(true)
// 模拟 API 请求
fetch(`/api/search?q=${debouncedSearchTerm}`)
.then((res) => res.json())
.then((data) => {
setResults(data)
setLoading(false)
})
} else {
setResults([])
}
}, [debouncedSearchTerm]) // 只在防抖值变化时触发
return (
<div>
<input
type='text'
placeholder='搜索...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{loading && <p>搜索中...</p>}
<ul>
{results.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
)
}
进阶版:带回调的防抖
import { useCallback, useRef, useEffect } from "react"
/**
* 防抖函数 Hook:返回一个防抖处理后的函数
* @param {Function} callback - 需要防抖的回调函数
* @param {number} delay - 延迟时间(毫秒)
* @returns {Function} 防抖后的函数
*/
function useDebouncedCallback(callback, delay = 500) {
const timeoutRef = useRef(null)
const callbackRef = useRef(callback)
// 保持 callback 最新
useEffect(() => {
callbackRef.current = callback
}, [callback])
// 清理定时器
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
}
}, [])
const debouncedCallback = useCallback(
(...args) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
callbackRef.current(...args)
}, delay)
},
[delay]
)
return debouncedCallback
}
// 使用示例
function SearchWithCallback() {
const [results, setResults] = useState([])
const handleSearch = useDebouncedCallback((term) => {
console.log("搜索:", term)
// 发起请求...
}, 500)
return (
<input
type='text'
onChange={(e) => handleSearch(e.target.value)}
placeholder='输入搜索...'
/>
)
}
Hook #3:useFetch —— 数据请求の瑞士军刀
痛点: 每个组件都要写 loading、error、data 三件套,烦死了。
解决方案:
import { useState, useEffect, useCallback, useRef } from "react"
/**
* 数据请求 Hook
* @param {string} url - 请求地址
* @param {object} options - fetch 选项
* @returns {object} { data, loading, error, refetch }
*/
function useFetch(url, options = {}) {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
// 用 ref 存储 options,避免无限循环
const optionsRef = useRef(options)
optionsRef.current = options
const fetchData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const response = await fetch(url, optionsRef.current)
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const result = await response.json()
setData(result)
} catch (err) {
setError(err.message || "请求失败")
} finally {
setLoading(false)
}
}, [url])
useEffect(() => {
fetchData()
}, [fetchData])
// 手动重新请求
const refetch = useCallback(() => {
fetchData()
}, [fetchData])
return { data, loading, error, refetch }
}
export default useFetch
使用示例:
function UserProfile({ userId }) {
const {
data: user,
loading,
error,
refetch,
} = useFetch(`https://jsonplaceholder.typicode.com/users/${userId}`)
if (loading) return <div className='skeleton'>加载中...</div>
if (error) return <div className='error'>错误:{error}</div>
if (!user) return null
return (
<div className='user-profile'>
<h2>{user.name}</h2>
<p>📧 {user.email}</p>
<p>📱 {user.phone}</p>
<p>🏢 {user.company?.name}</p>
<button onClick={refetch}>刷新数据</button>
</div>
)
}
进阶版:支持缓存和自动重试
import { useState, useEffect, useCallback, useRef } from "react"
// 简单的内存缓存
const cache = new Map()
/**
* 增强版数据请求 Hook
* @param {string} url - 请求地址
* @param {object} config - 配置项
*/
function useFetchAdvanced(url, config = {}) {
const {
enabled = true, // 是否启用请求
cacheTime = 5 * 60 * 1000, // 缓存时间(默认 5 分钟)
retry = 3, // 重试次数
retryDelay = 1000, // 重试延迟
onSuccess, // 成功回调
onError, // 失败回调
} = config
const [state, setState] = useState({
data: null,
loading: enabled,
error: null,
})
const retryCountRef = useRef(0)
const fetchData = useCallback(async () => {
// 检查缓存
const cached = cache.get(url)
if (cached && Date.now() - cached.timestamp < cacheTime) {
setState({ data: cached.data, loading: false, error: null })
return
}
setState((prev) => ({ ...prev, loading: true, error: null }))
try {
const response = await fetch(url)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
// 存入缓存
cache.set(url, { data, timestamp: Date.now() })
setState({ data, loading: false, error: null })
retryCountRef.current = 0
onSuccess?.(data)
} catch (err) {
// 重试逻辑
if (retryCountRef.current < retry) {
retryCountRef.current++
console.log(
`请求失败,${retryDelay}ms 后重试 (${retryCountRef.current}/${retry})`
)
setTimeout(fetchData, retryDelay)
return
}
setState({ data: null, loading: false, error: err.message })
onError?.(err)
}
}, [url, cacheTime, retry, retryDelay, onSuccess, onError])
useEffect(() => {
if (enabled) {
fetchData()
}
}, [enabled, fetchData])
return { ...state, refetch: fetchData }
}
Hook #4:useToggle —— 布尔值の优雅切换
痛点: setIsOpen(!isOpen) 写了 100 遍,手都酸了。
解决方案:
import { useState, useCallback } from "react"
/**
* 布尔值切换 Hook
* @param {boolean} initialValue - 初始值
* @returns {[boolean, Function, Function, Function]} [值, 切换, 设为true, 设为false]
*/
function useToggle(initialValue = false) {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue((v) => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
return [value, toggle, setTrue, setFalse]
}
export default useToggle
使用示例:
function Modal() {
const [isOpen, toggle, open, close] = useToggle(false)
const [isDarkMode, toggleDarkMode] = useToggle(false)
return (
<div className={isDarkMode ? "dark" : "light"}>
<button onClick={toggleDarkMode}>
{isDarkMode ? "🌙" : "☀️"} 切换主题
</button>
<button onClick={open}>打开弹窗</button>
{isOpen && (
<div className='modal-overlay' onClick={close}>
<div className='modal' onClick={(e) => e.stopPropagation()}>
<h2>我是弹窗</h2>
<p>点击遮罩层或按钮关闭</p>
<button onClick={close}>关闭</button>
</div>
</div>
)}
</div>
)
}
Hook #5:useClickOutside —— 点击外部关闭の神器
痛点: 下拉菜单、弹窗点击外部关闭,每次都要写一堆事件监听。
解决方案:
import { useEffect, useRef } from "react"
/**
* 点击元素外部时触发回调
* @param {Function} callback - 点击外部时的回调函数
* @returns {React.RefObject} 需要绑定到目标元素的 ref
*/
function useClickOutside(callback) {
const ref = useRef(null)
useEffect(() => {
const handleClick = (event) => {
// 如果点击的不是 ref 元素内部,则触发回调
if (ref.current && !ref.current.contains(event.target)) {
callback(event)
}
}
// 使用 mousedown 而不是 click,响应更快
document.addEventListener("mousedown", handleClick)
document.addEventListener("touchstart", handleClick)
return () => {
document.removeEventListener("mousedown", handleClick)
document.removeEventListener("touchstart", handleClick)
}
}, [callback])
return ref
}
export default useClickOutside
使用示例:
function Dropdown() {
const [isOpen, setIsOpen] = useState(false)
// 点击下拉菜单外部时关闭
const dropdownRef = useClickOutside(() => {
setIsOpen(false)
})
return (
<div className='dropdown-container' ref={dropdownRef}>
<button onClick={() => setIsOpen(!isOpen)}>
选择选项 {isOpen ? "▲" : "▼"}
</button>
{isOpen && (
<ul className='dropdown-menu'>
<li onClick={() => setIsOpen(false)}>选项 1</li>
<li onClick={() => setIsOpen(false)}>选项 2</li>
<li onClick={() => setIsOpen(false)}>选项 3</li>
</ul>
)}
</div>
)
}
进阶:支持多个 ref
import { useEffect, useRef, useCallback } from "react"
/**
* 支持多个元素的点击外部检测
* @param {Function} callback - 点击外部时的回调
* @returns {Function} 返回一个函数,调用它获取 ref
*/
function useClickOutsideMultiple(callback) {
const refs = useRef([])
const addRef = useCallback((element) => {
if (element && !refs.current.includes(element)) {
refs.current.push(element)
}
}, [])
useEffect(() => {
const handleClick = (event) => {
const isOutside = refs.current.every(
(ref) => ref && !ref.contains(event.target)
)
if (isOutside) {
callback(event)
}
}
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [callback])
return addRef
}
// 使用示例:弹窗 + 触发按钮都不算"外部"
function PopoverWithTrigger() {
const [isOpen, setIsOpen] = useState(false)
const addRef = useClickOutsideMultiple(() => setIsOpen(false))
return (
<>
<button ref={addRef} onClick={() => setIsOpen(!isOpen)}>
触发按钮
</button>
{isOpen && (
<div ref={addRef} className='popover'>
点击这里不会关闭
</div>
)}
</>
)
}
Hook #6:usePrevious —— 获取上一次的值
痛点: 想对比新旧值做一些操作,但 React 不给你上一次的值。
解决方案:
import { useRef, useEffect } from "react"
/**
* 获取上一次渲染时的值
* @param {any} value - 当前值
* @returns {any} 上一次的值
*/
function usePrevious(value) {
const ref = useRef()
useEffect(() => {
ref.current = value
}, [value])
// 返回上一次的值(在 useEffect 更新之前)
return ref.current
}
export default usePrevious
使用示例:
function Counter() {
const [count, setCount] = useState(0)
const prevCount = usePrevious(count)
return (
<div>
<p>当前值:{count}</p>
<p>上一次:{prevCount ?? "无"}</p>
<p>
变化趋势:
{prevCount !== undefined &&
(count > prevCount
? "📈 上升"
: count < prevCount
? "📉 下降"
: "➡️ 不变")}
</p>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
<button onClick={() => setCount((c) => c - 1)}>-1</button>
</div>
)
}
实际应用:检测 props 变化
function UserProfile({ userId }) {
const prevUserId = usePrevious(userId)
const [user, setUser] = useState(null)
useEffect(() => {
// 只有当 userId 真正变化时才重新请求
if (userId !== prevUserId) {
console.log(`用户 ID 从 ${prevUserId} 变为 ${userId}`)
fetchUser(userId).then(setUser)
}
}, [userId, prevUserId])
return <div>{user?.name}</div>
}
Hook #7:useMediaQuery —— 响应式の优雅方案
痛点: CSS 媒体查询很方便,但 JS 里想根据屏幕尺寸做逻辑判断就麻烦了。
解决方案:
import { useState, useEffect } from "react"
/**
* 媒体查询 Hook
* @param {string} query - CSS 媒体查询字符串
* @returns {boolean} 是否匹配
*/
function useMediaQuery(query) {
const [matches, setMatches] = useState(() => {
// SSR 环境下返回 false
if (typeof window === "undefined") return false
return window.matchMedia(query).matches
})
useEffect(() => {
if (typeof window === "undefined") return
const mediaQuery = window.matchMedia(query)
// 初始化
setMatches(mediaQuery.matches)
// 监听变化
const handler = (event) => setMatches(event.matches)
// 现代浏览器用 addEventListener
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener("change", handler)
return () => mediaQuery.removeEventListener("change", handler)
} else {
// 兼容旧浏览器
mediaQuery.addListener(handler)
return () => mediaQuery.removeListener(handler)
}
}, [query])
return matches
}
export default useMediaQuery
使用示例:
function ResponsiveComponent() {
const isMobile = useMediaQuery("(max-width: 768px)")
const isTablet = useMediaQuery("(min-width: 769px) and (max-width: 1024px)")
const isDesktop = useMediaQuery("(min-width: 1025px)")
const prefersDark = useMediaQuery("(prefers-color-scheme: dark)")
return (
<div className={prefersDark ? "dark-theme" : "light-theme"}>
{isMobile && <MobileNav />}
{isTablet && <TabletNav />}
{isDesktop && <DesktopNav />}
<main>
<p>
当前设备:{isMobile ? "📱 手机" : isTablet ? "📱 平板" : "💻 桌面"}
</p>
<p>主题偏好:{prefersDark ? "🌙 深色" : "☀️ 浅色"}</p>
</main>
</div>
)
}
封装常用断点:
// hooks/useBreakpoint.js
import useMediaQuery from "./useMediaQuery"
export function useBreakpoint() {
const breakpoints = {
xs: useMediaQuery("(max-width: 575px)"),
sm: useMediaQuery("(min-width: 576px) and (max-width: 767px)"),
md: useMediaQuery("(min-width: 768px) and (max-width: 991px)"),
lg: useMediaQuery("(min-width: 992px) and (max-width: 1199px)"),
xl: useMediaQuery("(min-width: 1200px)"),
}
// 返回当前断点名称
const current =
Object.entries(breakpoints).find(([, matches]) => matches)?.[0] || "xs"
return {
...breakpoints,
current,
isMobile: breakpoints.xs || breakpoints.sm,
isTablet: breakpoints.md,
isDesktop: breakpoints.lg || breakpoints.xl,
}
}
// 使用
function App() {
const { isMobile, isDesktop, current } = useBreakpoint()
return (
<div>
<p>当前断点:{current}</p>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
</div>
)
}
第三章:Hooks 组合の艺术
3.1 组合多个 Hooks 解决复杂问题
场景: 一个带搜索、分页、缓存的列表组件
import { useState, useEffect, useMemo } from "react"
// 组合使用多个自定义 Hooks
function useSearchableList(fetchFn, options = {}) {
const { pageSize = 10, debounceMs = 300 } = options
// 搜索关键词
const [searchTerm, setSearchTerm] = useState("")
const debouncedSearch = useDebounce(searchTerm, debounceMs)
// 分页
const [page, setPage] = useState(1)
// 数据请求
const { data, loading, error, refetch } = useFetch(
`${fetchFn}?search=${debouncedSearch}&page=${page}&pageSize=${pageSize}`
)
// 搜索时重置页码
const prevSearch = usePrevious(debouncedSearch)
useEffect(() => {
if (prevSearch !== undefined && prevSearch !== debouncedSearch) {
setPage(1)
}
}, [debouncedSearch, prevSearch])
// 计算总页数
const totalPages = useMemo(() => {
return data?.total ? Math.ceil(data.total / pageSize) : 0
}, [data?.total, pageSize])
return {
// 数据
items: data?.items || [],
total: data?.total || 0,
loading,
error,
// 搜索
searchTerm,
setSearchTerm,
// 分页
page,
setPage,
totalPages,
hasNextPage: page < totalPages,
hasPrevPage: page > 1,
// 操作
refetch,
nextPage: () => setPage((p) => Math.min(p + 1, totalPages)),
prevPage: () => setPage((p) => Math.max(p - 1, 1)),
}
}
// 使用示例
function UserList() {
const {
items,
loading,
error,
searchTerm,
setSearchTerm,
page,
totalPages,
hasNextPage,
hasPrevPage,
nextPage,
prevPage,
} = useSearchableList("/api/users", { pageSize: 20 })
return (
<div className='user-list'>
<input
type='text'
placeholder='搜索用户...'
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
{loading && <div className='loading'>加载中...</div>}
{error && <div className='error'>{error}</div>}
<ul>
{items.map((user) => (
<li key={user.id}>
{user.name} - {user.email}
</li>
))}
</ul>
<div className='pagination'>
<button onClick={prevPage} disabled={!hasPrevPage}>
上一页
</button>
<span>
{page} / {totalPages}
</span>
<button onClick={nextPage} disabled={!hasNextPage}>
下一页
</button>
</div>
</div>
)
}
3.2 创建 Hook 工厂
场景: 多个表单都需要类似的验证逻辑
/**
* 表单验证 Hook 工厂
* @param {object} validationRules - 验证规则
* @returns {Function} 返回一个自定义 Hook
*/
function createFormValidation(validationRules) {
return function useFormValidation(initialValues = {}) {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState({})
const [touched, setTouched] = useState({})
// 验证单个字段
const validateField = (name, value) => {
const rules = validationRules[name]
if (!rules) return ""
for (const rule of rules) {
if (rule.required && !value) {
return rule.message || "此字段必填"
}
if (rule.minLength && value.length < rule.minLength) {
return rule.message || `最少 ${rule.minLength} 个字符`
}
if (rule.maxLength && value.length > rule.maxLength) {
return rule.message || `最多 ${rule.maxLength} 个字符`
}
if (rule.pattern && !rule.pattern.test(value)) {
return rule.message || "格式不正确"
}
if (rule.validate && !rule.validate(value, values)) {
return rule.message || "验证失败"
}
}
return ""
}
// 验证所有字段
const validateAll = () => {
const newErrors = {}
let isValid = true
Object.keys(validationRules).forEach((name) => {
const error = validateField(name, values[name] || "")
if (error) {
newErrors[name] = error
isValid = false
}
})
setErrors(newErrors)
return isValid
}
// 处理输入变化
const handleChange = (name) => (e) => {
const value = e.target ? e.target.value : e
setValues((prev) => ({ ...prev, [name]: value }))
// 实时验证已触碰的字段
if (touched[name]) {
const error = validateField(name, value)
setErrors((prev) => ({ ...prev, [name]: error }))
}
}
// 处理失焦
const handleBlur = (name) => () => {
setTouched((prev) => ({ ...prev, [name]: true }))
const error = validateField(name, values[name] || "")
setErrors((prev) => ({ ...prev, [name]: error }))
}
// 重置表单
const reset = () => {
setValues(initialValues)
setErrors({})
setTouched({})
}
return {
values,
errors,
touched,
handleChange,
handleBlur,
validateAll,
reset,
isValid: Object.keys(errors).length === 0,
getFieldProps: (name) => ({
value: values[name] || "",
onChange: handleChange(name),
onBlur: handleBlur(name),
}),
}
}
}
// 创建登录表单验证 Hook
const useLoginForm = createFormValidation({
email: [
{ required: true, message: "请输入邮箱" },
{ pattern: /^[^\s@]+@[^\s@]+.[^\s@]+$/, message: "邮箱格式不正确" },
],
password: [
{ required: true, message: "请输入密码" },
{ minLength: 6, message: "密码至少 6 位" },
],
})
// 创建注册表单验证 Hook
const useRegisterForm = createFormValidation({
username: [
{ required: true, message: "请输入用户名" },
{ minLength: 3, message: "用户名至少 3 个字符" },
{ maxLength: 20, message: "用户名最多 20 个字符" },
],
email: [
{ required: true, message: "请输入邮箱" },
{ pattern: /^[^\s@]+@[^\s@]+.[^\s@]+$/, message: "邮箱格式不正确" },
],
password: [
{ required: true, message: "请输入密码" },
{ minLength: 8, message: "密码至少 8 位" },
{
pattern: /(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/,
message: "需包含大小写字母和数字",
},
],
confirmPassword: [
{ required: true, message: "请确认密码" },
{
validate: (value, values) => value === values.password,
message: "两次密码不一致",
},
],
})
// 使用示例
function LoginForm() {
const { values, errors, touched, getFieldProps, validateAll } = useLoginForm()
const handleSubmit = (e) => {
e.preventDefault()
if (validateAll()) {
console.log("提交:", values)
// 发起登录请求...
}
}
return (
<form onSubmit={handleSubmit}>
<div className='form-group'>
<input type='email' placeholder='邮箱' {...getFieldProps("email")} />
{touched.email && errors.email && (
<span className='error'>{errors.email}</span>
)}
</div>
<div className='form-group'>
<input
type='password'
placeholder='密码'
{...getFieldProps("password")}
/>
{touched.password && errors.password && (
<span className='error'>{errors.password}</span>
)}
</div>
<button type='submit'>登录</button>
</form>
)
}
第四章:避坑指南
4.1 常见错误 #1:在条件语句中调用 Hook
// ❌ 错误:条件调用 Hook
function BadComponent({ shouldFetch }) {
if (shouldFetch) {
const data = useFetch("/api/data") // 💥 报错!
}
return <div>...</div>
}
// ✅ 正确:Hook 始终调用,用参数控制行为
function GoodComponent({ shouldFetch }) {
const { data } = useFetch("/api/data", { enabled: shouldFetch })
return <div>...</div>
}
4.2 常见错误 #2:忘记依赖项
// ❌ 错误:缺少依赖项,callback 永远是旧的
function BadHook(callback) {
useEffect(() => {
window.addEventListener("resize", callback)
return () => window.removeEventListener("resize", callback)
}, []) // callback 变了也不会更新!
}
// ✅ 正确:使用 ref 保持最新引用
function GoodHook(callback) {
const callbackRef = useRef(callback)
useEffect(() => {
callbackRef.current = callback
}, [callback])
useEffect(() => {
const handler = (...args) => callbackRef.current(...args)
window.addEventListener("resize", handler)
return () => window.removeEventListener("resize", handler)
}, [])
}
4.3 常见错误 #3:闭包陷阱
// ❌ 错误:count 永远是 0
function BadCounter() {
const [count, setCount] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
console.log(count) // 永远打印 0
setCount(count + 1) // 永远设置为 1
}, 1000)
return () => clearInterval(timer)
}, []) // 空依赖,count 被闭包捕获
return <div>{count}</div>
}
// ✅ 正确:使用函数式更新
function GoodCounter() {
const [count, setCount] = useState(0)
useEffect(() => {
const timer = setInterval(() => {
setCount((c) => c + 1) // 函数式更新,不依赖外部 count
}, 1000)
return () => clearInterval(timer)
}, [])
return <div>{count}</div>
}
4.4 常见错误 #4:无限循环
// ❌ 错误:每次渲染都创建新对象,导致无限循环
function BadComponent() {
const [data, setData] = useState(null)
useEffect(() => {
fetch("/api/data")
.then((res) => res.json())
.then(setData)
}, [{ page: 1 }]) // 每次都是新对象!无限循环!
return <div>{data}</div>
}
// ✅ 正确:使用原始值或 useMemo
function GoodComponent() {
const [data, setData] = useState(null)
const page = 1
useEffect(() => {
fetch(`/api/data?page=${page}`)
.then((res) => res.json())
.then(setData)
}, [page]) // 原始值,不会无限循环
return <div>{data}</div>
}
写在最后:Hook 的哲学
自定义 Hooks 不只是代码复用的工具,更是一种思维方式:
1. 关注点分离
- 组件负责"长什么样"(UI)
- Hook 负责"怎么工作"(逻辑)
2. 组合优于继承
- 小而专注的 Hook 可以自由组合
- 比 HOC 和 Render Props 更灵活
3. 声明式思维
- 描述"要什么",而不是"怎么做"
-
useDebounce(value, 500)比手写 setTimeout 清晰 100 倍
最后,送你一句话:
"好的代码不是写出来的,是删出来的。"
当你发现自己在 copy-paste 时,就是该写自定义 Hook 的时候了。
💬 互动时间:你在项目中封装过哪些好用的自定义 Hooks?评论区分享一下,让大家一起"偷懒"!
觉得这篇文章有用?点赞 + 在看 + 转发,让更多 React 开发者早点下班~
本文作者是一个靠自定义 Hooks 实现准时下班的前端开发。关注我,一起用更少的代码,写更好的应用。
AI 流式对话该怎么做?SSE、fetch、axios 一次讲清楚
在做 AI 对话产品 时,很多人都会遇到一个问题:
为什么有的实现能像 ChatGPT 一样逐字输出,而有的只能“等半天一次性返回”?
问题的核心,往往不在模型,而在 前后端的流式通信方式。
本文从实战出发,系统讲清楚 SSE、fetch、axios 在 AI 流式对话中的本质区别与选型建议。
先给结论(重要)
AI 流式对话的正确打开方式:
- ✅ 首选:
fetch + ReadableStream- ✅ 可选:
SSE(EventSource)- ❌ 不推荐:
axios
如果你现在用的是 axios,还在纠结“为什么没有逐 token 输出”,可以直接往下看结论部分。
AI 流式对话的本质需求
在传统接口中,请求和响应通常是这样的:
请求 → 等待 → 返回完整结果
但 AI 对话不是。
AI 流式对话的真实需求是:
- 模型 逐 token 生成
- 前端 边接收、边渲染
- 连接可持续数十秒
- 用户能感知“正在思考 / 正在输出”
这决定了:必须支持真正的 HTTP 流式响应
SSE、fetch、axios 的本质区别
在对比之前,先明确一个容易混淆的点:
1、SSE 是「协议能力」
SSE(Server-Sent Events) 是一种 基于 HTTP 的流式推送协议
Content-Type: text/event-stream- 服务端可以不断向客户端推送数据
- 浏览器原生支持
EventSource
它解决的是:“服务端如何持续推送数据”
2、fetch / axios 是「请求工具」
| 工具 | 本质 |
|---|---|
| fetch | 浏览器原生 HTTP API |
| axios | 对 XHR / fetch 的封装库 |
它们解决的是:“前端如何发请求、拿响应”
常用流式方案
SSE:最简单的流式方案
const es = new EventSource('/api/chat/stream')
es.onmessage = (e) => {
console.log(e.data)
}
优点
- ✅ 原生支持流式
- ✅ 自动重连
- ✅ 心跳、事件类型清晰
- ✅ 非常适合 AI 单向输出
缺点(关键)
- ❌ 只支持
GET - ❌ 不能自定义 Header(鉴权不友好)
- ❌ 只能 服务端 → 客户端
适合场景:AI 回答输出、推理过程 / 日志流、实时通知类数据。
fetch + ReadableStream(推荐)
这是目前 AI 产品中最主流、最灵活的方案。
const res = await fetch('/api/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ prompt })
})
const reader = res.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { value, done } = await reader.read()
if (done) break
const chunk = decoder.decode(value)
console.log(chunk)
}
为什么它是首选?
- ✅ 支持 POST(可传 prompt、上下文)
- ✅ 可自定义 Header(token、traceId)
- ✅ 真正的 chunk / token 级流式
- ✅ 与 OpenAI / Claude 接口完全一致
- ✅ Web / Node / Edge Runtime 通用
一句话总结:fetch + stream 是目前 AI 流式对话的标准
axios:为什么不适合 AI 流式?
这是很多人踩坑最多的地方。
常见误解
axios.post('/api/chat', data, {
onDownloadProgress(e) {
console.log(e)
}
})
看起来像“流式”,但实际上 axios 的真实问题:
- 浏览器端基于 XHR
- 响应会被 缓冲
-
onDownloadProgress不是 token 级回调 - 延迟明显、体验差
结论:axios 在浏览器端 不支持真正的流式响应
它更适合普通 REST API、表单提交、数据请求,但 不适合 AI 流式输出。
总结
| 方案 | 真流式 | POST | Header | 推荐度 |
|---|---|---|---|---|
| SSE (EventSource) | ✅ | ❌ | ❌ | ⭐⭐⭐ |
| fetch + stream | ✅ | ✅ | ✅ | ⭐⭐⭐⭐⭐ |
| axios | ❌ | ✅ | ✅ | ⭐ |
- SSE 是流式协议
- fetch 是流式容器
- axios 是传统请求工具
如果你正在做 AI 产品,通信层选错,后面再怎么优化模型和前端体验,都会事倍功半。
React 性能优化的“卧龙凤雏”:useMemo 与 useCallback 到底该怎么用
在 React 的世界里,组件的渲染就像一场“牵一发而动全身”的多米诺骨牌。父组件打个喷嚏(State 变了),底下的子组件全得跟着感冒(重新渲染)。
虽然 React 够快,但如果你的组件里住着一只“吞金兽”(昂贵的计算逻辑),或者你的子组件是个“强迫症”(非要 Props 完全没变才肯不渲染),那你就得请出 React 性能优化的两尊大神了:useMemo 和 useCallback。
很多人分不清它俩,其实很简单:
-
useMemo缓存的是结果(脑子转完产出的东西)。 -
useCallback缓存的是函数本身(干活的工具)。
今天咱们就拿一段真实的代码,扒一扒这俩货到底怎么帮我们省资源。
useMemo:给你的组件装个“缓存大脑”
想象一下,你有一个超级复杂的数学题要算(比如从 0 加到 1000 万)。
优化前:笨笨的复读机
看这段代码,我们有一个 slowSum 函数,它模拟了一个耗时的计算过程:
JavaScript
// 昂贵的计算:模拟 CPU 密集型任务
function slowSum(n) {
console.log('🔥 疯狂计算中...');
let sum = 0;
// 假装这里跑了很久
for (let i = 0; i < n * 10000000; i++) {
sum += i;
}
return sum;
}
export default function App() {
const [count, setCount] = useState(0); // 这个 state 和计算毫无关系
const [num, setNum] = useState(1); // 这个 state 才是计算需要的
// 😱 灾难现场:
// 只要组件重新渲染(比如你点击了 count+1),这行代码就会重新跑一遍!
const result = slowSum(num);
return (
<>
<p>计算结果:{result}</p>
{/* 点击这里,slowSum 居然也会执行?! */}
<button onClick={() => setCount(count + 1)}>Count + 1 (无辜路人)</button>
<button onClick={() => setNum(num + 1)}>Num + 1 (正主)</button>
</>
)
}
痛点:当你点击 Count + 1 时,明明 num 没变,结果也没变,但 React 重新执行组件函数,slowSum 又傻乎乎地跑了一遍。页面卡顿随之而来。
优化后:学会“偷懒”
这时候 useMemo 就登场了。它像一个记性很好的会计,只有当依赖项(账本)变了,它才重新算。
JavaScript
// ✅ 智能缓存
const result = useMemo(() => {
return slowSum(num);
}, [num]); // 👈 只有当 num 变了,才重新跑里面的函数
现在你再疯狂点击 Count + 1,控制台不会再打印“计算中...”,页面丝般顺滑。
场景二:代替 Vue 的 Computed
除了昂贵计算,useMemo 也是处理派生状态的神器,类似于 Vue 里的 computed。
比如这里有一个过滤列表的需求:
JavaScript
const [keyword, setKeyword] = useState('');
const list = ['apple', 'banana', 'orange', 'pear'];
// 如果不用 useMemo:
// 每次组件渲染(比如 count 变了),filter 都会重新遍历数组。
// 虽然这里数组小看不出性能损耗,但如果是大数据列表,这就是性能杀手。
const filterList = useMemo(() => {
// 只有关键词变了,我才重新过滤
return list.filter(item => item.includes(keyword));
}, [keyword]);
(注:includes('') 默认为 true,所以初始状态会显示所有水果,完美符合搜索逻辑。)
useCallback + memo:父子组件的“定情信物”
接下来聊聊 useCallback。很多人觉得:“我不就传个函数给子组件吗,为啥要包一层?”
这得从 JavaScript 的特性说起。
优化前:最熟悉的陌生人
父组件给子组件传 Props,子组件用 React.memo 包裹,本来是想做性能优化(Props 不变就不重新渲染)。但是...
JavaScript
// 子组件:使用了 memo,理论上 Props 不变我就不渲染
const Child = memo(({ handleClick }) => {
console.log('👶 Child 重新渲染了 (我不想这样)');
return <div onClick={handleClick}>子组件</div>
});
export default function App() {
const [count, setCount] = useState(0);
// 😱 问题出在这里:
// 每次 App 重新渲染,handleClick 都会被重新定义!
// 在 JS 里,function A() {} !== function A() {}
// 引用地址变了 -> memo 认为 Props 变了 -> 子组件被迫渲染
const handleClick = () => {
console.log('click');
}
return (
<div>
{/* 我改了 count,跟 Child 半毛钱关系没有,但 Child 还是渲染了 */}
<button onClick={() => setCount(count + 1)}>Count + 1</button>
<Child handleClick={handleClick} />
</div>
)
}
痛点:React.memo 就像一个严格的保安,它对比 Props 是否变化用的是“浅比较”(引用对比)。因为父组件每次渲染都生成一个新的函数地址,保安觉得:“这函数换人了!” 于是放行,导致子组件无意义渲染。
优化后:给函数发个“身份证”
useCallback 的作用就是把这个函数“固化”下来。
JavaScript
// ✅ 保持函数引用稳定
const handleClick = useCallback(() => {
console.log('click');
}, []); // 依赖为空,说明这个函数永远是同一个引用地址
现在,当你点击 Count + 1 时,父组件重渲染了,但 handleClick 还是原来那个 handleClick。Child 组件发现 Props 没变,就安心地躺平不渲染了。
注意:如果你需要在回调里用到 count,记得把它加进依赖数组:
JavaScript
const handleClick = useCallback(() => {
// 如果依赖数组里没写 count,这里永远打印 0 (闭包陷阱)
console.log('click', count);
}, [count]);
// 👆 一旦 count 变了,函数引用还是会变,Child 还是会渲染。
// 这是为了保证逻辑正确性必须付出的代价。
总结
别为了优化而优化。useMemo 和 useCallback 也是有成本的(它们本身也需要消耗内存来做依赖对比)。
请遵循这套“心法”:
-
useMemo:
-
昂贵计算:当你看到
for循环次数巨大,或者复杂的递归时。 -
引用稳定:当你计算出的对象/数组,要作为
useEffect的依赖项,或者传给被memo包裹的子组件时。
-
昂贵计算:当你看到
-
useCallback:
-
配合 React.memo:当你的函数需要传给一个“很重”的子组件,且该子组件被
memo包裹时。 -
作为 Hooks 依赖:当这个函数要被用作
useEffect的依赖项时。
-
配合 React.memo:当你的函数需要传给一个“很重”的子组件,且该子组件被
聊聊那个让 React 新手抓狂的“闭包陷阱”:Count 为什么永远是 0?
写 React Hooks 的时候,你有没有遇到过这种“灵异事件”:
明明天在这个组件里 setCount 已经加到飞起了,界面上的数字也在跳动,但是 setInterval 或者是 useEffect 里的 console.log 打印出来的,却永远是初始值 0?
这时候你会怀疑人生:“是我眼花了,还是 React 坏了?”
其实 React 没坏,你只是掉进了**“闭包陷阱” (Stale Closure)**。今天咱们就借一段简单的代码,扒一扒这个坑的底裤,顺便看看怎么优雅地爬出来。
案发现场:诡异的“时间冻结”
让我们先看看这段经典的“受害者”代码。这是很多同学(包括刚开始写 Hooks 的我)都会写出的逻辑:
JavaScript
import { useEffect, useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
// ❌ 这是一个典型的闭包陷阱现场
useEffect(() => {
const timer = setInterval(() => {
// 这里的 count 永远是 0,仿佛时间被冻结了
console.log('Current count:', count);
}, 1000);
return () => clearInterval(timer);
}, []); // 👈 罪魁祸首在这里:空依赖数组
return (
<>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>count + 1</button>
</>
);
}
现象描述
当你运行这段代码,点击按钮让 count 增加时:
-
界面(UI) :显示
1, 2, 3...(正常更新,说明 State 确实变了)。 -
控制台(Console) :
Current count: 0...Current count: 0... (像复读机一样)。
![]()
为什么会这样?
要理解这个问题,首先要修正一个心智模型:每一次渲染(Render),都是一次独立的“快照”。
-
第一次渲染 (Mount) :
- React 创建了组件,此时
count = 0。 -
useEffect执行。因为它依赖是[],所以它只在第一次渲染时执行。 -
setInterval被创建。关键点来了: 这个定时器的回调函数是在count为0的那个闭包作用域里定义的。它捕获了那一刻的count(也就是 0)。
- React 创建了组件,此时
-
第二次渲染 (点击按钮后) :
- React 再次执行组件函数,
count变成了1。 -
但是!
useEffect的依赖数组是空的,React 认为“没必要重新运行这个 Effect”。 - 于是,那个旧的定时器(Mount 时创建的)依然在坚强地活着。它手里紧紧攥着的,依然是第一次渲染时的旧变量
0。
- React 再次执行组件函数,
简单来说:你的组件 UI 已经活在 2026 年了,但那个定时器还活在 2023 年,它根本不知道外面的世界变了。这就是 JS 词法作用域与 React Hooks 机制碰撞出的“火花”。
怎么爬出陷阱?
既然知道了是因为“引用了旧变量”,那想要实现如下图片效果,思路就很清晰了:要么让 Effect 重新执行,要么用某种方式穿透闭包。
![]()
方法一:诚实地告诉 React 你的依赖(官方推荐)
这就是修复后的代码逻辑,也是最符合 React 数据流直觉的写法:
JavaScript
useEffect(() => {
const timer = setInterval(() => {
// ✅ 此时能读到最新的 count
console.log('Current count:', count);
}, 1000);
// 每次 effect 重新执行之前 都会执行上一次的清理函数
return () => clearInterval(timer);
}, [count]); // 👈 把 count 加入依赖数组
原理分析: 一旦把 [count] 加入依赖数组,逻辑就变了:
-
count变了 ->useEffect发现依赖变了。 - React 先执行
cleanup函数(clearInterval),杀掉旧的定时器。 - React 执行新的
useEffect,创建一个新的定时器。 - 这个新定时器是在当前渲染闭包里创建的,所以它捕获的是最新的
count。
潜在问题: 虽然 Bug 修好了,但带来了性能抖动。如果 count 变化很快(比如动画),定时器会被频繁地 创建 -> 销毁 -> 创建。如果定时器间隔很短,这可能会导致计时不准。
方法二:函数式更新
如果你只是想让 count 加 1,而不关心在 setInterval 里打印日志,可以用函数式更新:
JavaScript
useEffect(() => {
const timer = setInterval(() => {
// ✅ prev 永远是 React 内部拿到的最新状态,不需要依赖 count
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
这能解决 UI 更新问题,但解决不了“在定时器里获取最新值打印”的问题。
方法三:终极大法 useRef
如果你既不想让定时器频繁重启(保持依赖为 []),又想在回调里拿到最新的值,useRef 是最佳选择。
为什么? 因为 useRef 返回的 ref 对象在组件的整个生命周期内保持引用不变,但它的 .current 属性是可变的。这就像一个挂在墙上的白板,无论房间(闭包)怎么换,白板还是那一块,上面的字随时能改。
JavaScript
// 1. 创建一个 ref
const countRef = useRef(count);
// 2. 每次渲染都把最新的 count 写入 ref
// 这一步确保 ref.current 永远是最新的
countRef.current = count;
useEffect(() => {
const timer = setInterval(() => {
// 3. ✅ 永远读取 ref 里的最新值
// 这里的闭包引用的是 countRef 对象本身,这个对象是永远不变的
console.log('Current count:', countRef.current);
}, 1000);
return () => clearInterval(timer);
}, []); // 👈 依赖依然是空,定时器稳如泰山,不会重启!
这也是知名 Hooks 库 ahooks 中 useInterval 的核心实现原理。
总结
React 闭包陷阱本质上是 JavaScript 闭包机制 与 React 声明式编程 之间的一种“沟通误会”。
-
陷阱成因:
useEffect、useCallback等 Hooks 的依赖数组写少了,导致内部函数引用了旧的渲染闭包中的变量。 - 基础解法:补全依赖数组(但要注意副作用的频繁执行)。
-
进阶解法:使用
useRef作为“逃生舱”,在不重启 Effect 的情况下,透过闭包读取最新状态。
历时1年,TinyEditor v4.0 正式发布!
本文由体验技术团队Kagol原创。
TinyEditor 是一个基于 Quill 2.0 的富文本编辑器,在 Quill 基础上扩展了丰富的模块和格式,框架无关、功能强大、开箱即用。
去年1月2日,我们发布了 v3.25 版本,功能基本已经完备,之后 v3.x 版本进入了维护期,同时开启了漫长的 v4.0 版本的开发,v4.0 的核心目标是体验优化和稳定性提升,并支持多人协同编辑。
在长达1年的开发和打磨后,我们荣幸地宣布 TinyEditor v4.0 正式发布!这个版本汇聚了团队的心血,带来了激动人心多人协同编辑新功能、以及大量体验优化和稳定性改进。
重点特性:
- 支持多人协同编辑:一起在编辑器写(玩)文档(贪吃蛇游戏摸鱼)🐶
- 基于 quill-table-up 的新表格方案:表格操作体验++⚡️
- 基于
emoji-mart的 Emoji 表情:表情党最爱😍 - 支持斜杆菜单和丰富的快捷键:键盘流的福音😄
- 图片/视频/文件上传体验优化🌄
详细的 Release Notes 请参考:github.com/opentiny/ti…
欢迎安装 v4.0 版本体验:
npm i @opentiny/fluent-editor@4.0.0
1 亮点特性
1.1 多人协作编辑
v4.0 最重磅的功能之一是引入了完整的协作编辑能力。我们集成了 quill-cursor 模块,支持多人实时协作编辑,并提供了独立的 npm 包供开发者集成。无论是需要离线支持还是云端协作,TinyEditor 都能胜任。
你可以在我们的演示项目中进行体验:opentiny.github.io/tiny-editor…
效果如下:
![]()
关于协同编辑更详细的介绍,参考:如何使用 TinyEditor 快速部署一个多人协同富文本编辑器?
1.2 表格能力升级
集成了 table-up 模块,大幅提升了表格编辑和操作能力,支持更复杂的表格场景。
体验地址:opentiny.github.io/tiny-editor…
效果如下:
![]()
详细介绍可以参考之前的文章: TinyEditor v4.0 alpha:表格更强大,表情更丰富,上传体验超乎想象!
1.3 更丰富的 Emoji 表情😘
- 集成 emoji-mart,提供丰富的表情选择
- 修复了插入表情后的光标位置问题
- 完善了表情插入的交互体验
体验地址:opentiny.github.io/tiny-editor…
效果如下:
![]()
详细介绍可以参考之前的文章:TinyEditor v4.0 alpha:表格更强大,表情更丰富,上传体验超乎想象!
1.4 快捷键和快速菜单
新增了强大的快捷键系统和快速菜单功能,让高级用户能够更高效地操作编辑器。
体验地址:opentiny.github.io/tiny-editor…
效果如下:
![]()
1.5 颜色选择器升级
自定义颜色选择器现在能保存当前选择,并支持添加更多颜色。
效果如下:
![]()
1.6 文本模板与国际化
- 支持 i18n 文本模板替换
- 完善了国际化翻译(header、picker 等组件)
- 更好的多语言支持体验
1.7 图片和文件增强
- 图片工具栏:选中图片时显示专门的操作工具栏
-
自定义上传:增加
allowInvalidUrl选项,支持 Electron 等特定场景 - 改进的上传逻辑:优化了失败状态的处理
2 技术改进
2.1 构建和工程化
- 修复了 SSR 构建问题
- 优化了 Vite 配置,解决了 PostCSS 和 Tailwind 的兼容性问题
- 改进了 SCSS 文件引入方式
- 输出文件名称优化
2.2 依赖管理
- 外部化 emoji-mart 和 floating-ui 依赖,减少包体积
- 移除了 better-table 和 lodash-es,优化依赖树
2.3 代码质量
- 完整的测试覆盖率提升
- 重构优化:移除冗余代码
- API 标准化:
scrollIntoView→scrollSelectionIntoView - 示例代码
async/await改造,代码现代化
2.4 类型安全
- 修复了因 TypeScript 类型导致的编译错误
- 改进了类型定义
2.5 API 导出增强
v4.0 导出了工具栏配置常量,方便开发者定制:
-
DEFAULT_TOOLBAR:默认工具栏配置 -
FULL_TOOLBAR:完整工具栏配置
2.6 增加自动发包工作流
- 增加 auto-publish / auto-deploy 等自动化工作流,支持打 tag 之后自动发版本、生成 Release Notes
- PR 门禁在单元测试基础上增加 npm 包和网站构建,确保合入 PR 之前,npm 包构建和网站构建是正常的,通过自动化方式保障版本质量。
3 问题修复
v4.0 修复了大量已知问题,包括:
- 工具栏选择器不跟随光标变化的问题
- 行高作用域问题
- 列表样式显示不正确
- 背景色 SVG 图标问题
- VitePress 默认样式影响的问题
- 自定义上传失败时表格数据结构破坏的问题
- 多项文档和国际化翻译问题
4 社区贡献
感谢所有为 v4.0 做出贡献的开发者!你们的辛勤付出让 TinyEditor 变得更好!
- @chenxi-20
- @GaoNeng-wWw
- @jany55555
- @qwangry
- @shenyaofeng
- @vaebe
- @wuyiping0628
- @Yinlin124
- @zzxming
注:排名不分先后,按名字首字母排序。
如果你有任何建议或反馈,欢迎通过 GitHub Issues 与我们联系。
往期推荐文章
- 👍TinyEditor:一个基于 Quill 2.0 的富文本编辑器,功能强大、开箱即用!
- 🎈TinyEditor 富文本开源2个月的总结:增加格式刷、截屏、TypeScript 类型声明等新特性
- 🥳重磅更新!TinyEditor 开源富文本支持 LaTeX 可编辑公式啦~
- 🎉喜报!TinyEditor 开源富文本迎来了第一位贡献者
- 👏让我们一起来建设 TinyEditor 开源富文本编辑器吧!
- ✨TinyEditor v3.25.0 正式发布!2025年第一个版本,增加标题列表导航、分隔线等实用特性
- ⚡️TinyEditor v4.0 alpha 版本发布:更强大的表格、更丰富的表情、更好的上传体验
关于OpenTiny
欢迎加入 OpenTiny 开源社区。添加微信小助手:opentiny-official 一起参与交流前端技术~
OpenTiny 官网:opentiny.design
OpenTiny 代码仓库:github.com/opentiny
TinyVue 源码:github.com/opentiny/ti…
TinyEngine 源码: github.com/opentiny/ti…
欢迎进入代码仓库 Star🌟TinyEngine、TinyVue、TinyNG、TinyCLI、TinyEditor~
如果你也想要共建,可以进入代码仓库,找到 good first issue标签,一起参与开源贡献~
深入理解react——1. jsx与虚拟dom
通过课程和博客学习react,能够应付平时的开发工作。但到了面试等环节,对于fiber,setState的同步异步问题,说是知道,但往往朝深处一问,结合实际做一些输出题,脑袋里往往没有清晰的脉络,所以我决定自己实现一份miniReact,提升自己对react的理解。
本文大部分内容都是从历史好文build your own react中参考借鉴,这确实是我看到的最好的学习react的文章,在这里表示感谢。
准备工作
首先新启一个项目
npm init
npm i vite
简单配置vite.config.js
![]()
新建入口文件index.html,引入index.js
![]()
现在我们的准备工作就完成了。
一,jsx
jsx是一个语法糖,在编译后其实是使用了createElement函数。所以我们第一步就是实现createElement用于创建虚拟dom。我们miniReact只关心部分使用到的属性,不做完全详尽的处理。
(面试点,为什么老版本的react需要在顶部引入react,而新版本不需要)
现在,我们先从hello world开始
const rootDOM = document.getElementById("root");
const element = createElement("div", null, "hello world");
render(element, rootDOM);
接下来我们需要依次实现createElement用于创建虚拟dom,以及render用于将虚拟dom渲染到界面上。
简化版的虚拟dom需要三个参数,分别是type,props,以及children。
1.1 createElement
const createElement = (type, props, ...children) => {
return {
type,
props: {
...props,
children: children.map((child) => (typeof child === "object" ? child : createTextElement(child))),
},
};
};
function createTextElement(text) {
return {
type: "TEXT_ELEMENT",
props: {
nodeValue: text,
children: [],
},
};
}
1.2 render
function render(element, container) {
const dom =
element.type == "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(element.type)
const isProperty = key => key !== "children"
Object.keys(element.props)
.filter(isProperty)
.forEach(name => {
dom[name] = element.props[name]
})
element.props.children.forEach(child =>
render(child, dom)
)
container.appendChild(dom)
}
恭喜我们完成了第一步,成功的将一个虚拟dom渲染到了界面上,虽然简单,但是开始比什么都重要!
![]()
1.3 测试
接下来让我们做一些简单的测试
const elementList = Array.from({ length: 100 }, (_, i) => {
const key = `Item-${i}`;
return createElement("li", { key }, key);
});
const element = createElement("ul", null, ...elementList);
render(element, rootDOM);
将渲染的dom给得多一些,就可以看到很明显的卡顿,在此期间界面没法操作,这就是react fiber架构要解决的主要问题。
![]()
React 性能优化之道:useMemo、useCallback 与闭包陷阱的深度剖析
React 性能优化之道:useMemo、useCallback 与闭包陷阱的深度剖析
大家好,今天,我们来聊聊 React 中那些让人又爱又恨的性能优化工具——useMemo 和 useCallback,以及隐藏在背后的闭包陷阱。作为一名 React 开发者,你是否曾经遇到过这样的场景:组件明明只改了一个状态,却导致整个页面重新渲染,性能像漏气的轮胎一样瘪了下去?或者,你在 useEffect 里设置了一个定时器,结果它永远捕捉不到最新的状态值,像个固执的守门员,只认旧球不认新球?这篇文章将基于 React 的核心机制,带你一步步拆解这些问题。
第一部分:React 性能优化的痛点与必要性
想象一下,你在建造一座摩天大楼(你的 React 应用)。每当大楼里的一个房间(组件)需要装修时,整个大楼都要停工重刷一遍油漆?这听起来多荒谬!但在 React 的默认行为中,这就是现实:组件函数每次渲染都会重新执行,导致不必要的计算和子组件重绘。为什么会这样?因为 React 的渲染是“响应式”的——状态变化触发重新渲染,以确保 UI 与数据同步。但这种“全量渲染”在复杂应用中会带来性能开销,比如昂贵的计算重复执行,或子组件无谓刷新。
性能优化的核心在于“惰性”:只在必要时计算,只在 props 变化时重绘。React Hooks 提供了 useMemo 和 useCallback 来实现这一点,它们就像大楼的“智能电梯”,只在特定楼层停靠,避免无谓的上下奔波。同时,我们还要警惕闭包陷阱——它像大楼里的“幽灵通道”,悄无声息地捕捉旧值,导致逻辑出错。
第二部分:useMemo —— 缓存计算结果的“懒汉守护者”
useMemo 的诞生背景与核心概念
在 Vue 中,我们有 computed 计算属性,它像个聪明的管家,只在依赖变化时重新计算。React 没有内置 computed,但 useMemo 就是它的“DIY 版”。useMemo 的本质是“记忆化”(Memoization):缓存昂贵计算的结果,避免重复劳动。
为什么需要它?考虑一个场景:你有一个列表,需要根据搜索关键词过滤。每次状态变化(哪怕无关),过滤函数都会重跑。如果列表有上万项,那就太浪费了!
useMemo 的 API 很简单:
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
- 第一个参数:一个返回计算结果的函数。
- 第二个参数:依赖数组。只有数组中的值变化时,函数才会重执行。
底层逻辑:useMemo 利用 Hooks 的内部存储(fiber.hooks),在渲染间存储上次的计算结果和依赖。如果依赖浅比较(===)不变,就直接返回缓存值。这避免了组件重渲染时的重复计算。
扩展知识点:useMemo 不是“防抖”或“节流”,它针对纯计算。昂贵计算的例子包括:大数据排序、复杂数学运算(如斐波那契数列递归)、或处理 API 数据(如聚合统计)。
实战示例:从痛点到优化
来看一个示例。我们有一个列表 ['apple', 'banana', 'orange', 'pear'],需要根据 keyword 过滤。同时,有一个 count 状态和一个昂贵的 slowSum 计算。
原始代码(痛点版):
const filterList = list.filter(item => item.includes(keyword)); // 每次渲染都重跑
const result = slowSum(num); // 模拟昂贵计算,每次都 console.log('计算中...')
问题:count 变化时,filterList 和 slowSum 都会重执行,尽管它们不依赖 count。这导致性能浪费,尤其 slowSum 循环上百万次!
优化版(使用 useMemo):
import { useState, useMemo } from "react";
function slowSum(n) {
console.log('计算中...');
let sum = 0;
for (let i = 0; i < n * 1000000; i++) {
sum += i;
}
return sum;
}
export default function App() {
const [count, setCount] = useState(0);
const [keyword, setKeyword] = useState('');
const [num, setNum] = useState(0);
const list = ['apple', 'banana', 'orange', 'pear'];
const filterList = useMemo(() => {
console.log('filter 执行'); // 只在 keyword 变时执行
return list.filter(item => item.includes(keyword));
}, [keyword]); // 依赖 keyword
const result = useMemo(() => slowSum(num), [num]); // 只在 num 变时重算
return (
<div>
<p>结果: {result}</p>
<button onClick={() => setNum(num + 1)}>num+1</button>
<input type="text" value={keyword} onChange={e => setKeyword(e.target.value)} />
{count}
<button onClick={() => setCount(count + 1)}>count+1</button>
{filterList.map(item => <li key={item}>{item}</li>)}
</div>
);
}
现在,点击 count+1 时,filterList 和 result 不会重跑!控制台只在 keyword 或 num 变时打印日志。
易错提醒:
- 依赖数组漏写:如果忘了 [keyword],useMemo 只跑一次,keyword 变也不会更新——像个“失忆的管家”。
- 过度依赖:数组中放对象/数组时,浅比较失效(因为新对象 !== 旧对象)。解决:用 useMemo 缓存对象,或用 lodash 的 deepEqual(但不推荐,增加开销)。
- 返回值类型:useMemo 可以缓存任何值,包括 JSX!如
const memoizedJSX = useMemo(() => <HeavyComponent />, [deps]);用于优化虚拟 DOM 生成。 - 性能陷阱:useMemo 本身有开销(比较依赖 + 存储)。只用于真正昂贵的计算。测试工具:用 React DevTools 的 Profiler 测量渲染时间。
扩展:useMemo vs useEffect。useEffect 是“副作用钩子”,适合异步操作;useMemo 是同步计算钩子。useMemo 返回值直接用在渲染中,而 useEffect 不返回。
第三部分:useCallback —— 缓存函数的“稳定器”
useCallback 的核心与 React 渲染机制
React 的数据流是单向的:父组件持数据,子组件渲染。子组件用 React.memo 包裹时,会浅比较 props。如果 props 不变,子组件跳过渲染。但问题来了:函数 props(如 onClick)每次渲染都是新函数(因为组件函数重执行),导致 === 失败,子组件总重绘!
useCallback 解决这个:缓存函数引用。只有依赖变时,才返回新函数。
API:
const memoizedCallback = useCallback(() => { doSomething(a, b); }, [a, b]);
底层逻辑:类似 useMemo,但专为函数。Hooks 存储上次的函数和依赖,依赖不变时返回相同引用。
扩展:为什么函数引用重要?因为 JavaScript 函数是对象,每次定义都是新实例。React.memo 的浅比较依赖 ===,新函数总触发重绘。
实战示例:父子组件优化
原始痛点:父组件有 count 和 num,子组件依赖 count 和 handleClick。但 handleClick 每次新生成,导致 Child 总重绘。
优化版:
import { useState, memo, useCallback } from "react";
const Child = memo(({ count, handleClick }) => {
console.log('child重新渲染'); // 只在 count 或依赖变时打印
return (
<div onClick={handleClick}>
子组件 {count}
</div>
);
});
export default function App() {
const [count, setCount] = useState(0);
const [num, setNum] = useState(0);
const handleClick = useCallback(() => {
console.log('click');
}, [count]); // 如果依赖 count,count 变时返回新函数
return (
<div>
{count}
<button onClick={() => setCount(count + 1)}>count+1</button>
<button onClick={() => setNum(num + 1)}>num+1</button>
<Child count={count} handleClick={handleClick} />
</div>
);
}
点击 num+1 时,Child 不重绘!因为 handleClick 引用稳定。
易错提醒:
- 空依赖 []:函数永不更新,但如果函数内用闭包捕获变量,会导致“陈旧值”问题(详见闭包陷阱)。
- 过度使用:useCallback 缓存函数,但如果子组件不 memo,就没必要。记住:优化是针对瓶颈的。
- 与 useMemo 的区别:useCallback 是 useMemo 的特化版,等价于
useMemo(() => fn, deps)。但 useCallback 更语义化。 - 事件处理:onClick 等常依赖状态。如果不放依赖,函数捕获旧状态;放了,函数引用变,子组件重绘。权衡:如果子组件不昂贵,优先正确性。
扩展:高级用法——useCallback 在列表渲染中缓存 item 的 onClick,避免每个 item 新函数。结合 useImperativeHandle,可优化 ref 转发。
第四部分:React 闭包陷阱 —— 隐藏的“幽灵捕手”
闭包的形成与 React 中的陷阱
闭包是 JavaScript 的核心:函数记住其词法作用域。即使外部函数结束,闭包仍持有变量引用。
在 React,Hooks 如 useEffect、useCallback、useMemo 会形成闭包。因为它们在组件函数(外部作用域)中定义,捕获当前渲染的状态。
陷阱场景:useEffect([]) 只跑一次,捕获初始状态。后续状态变,effect 内看不到——像个“时间胶囊”,永远封存旧值。
为什么?因为依赖数组决定“ freshness”:空数组意味着“永不更新闭包”。
实战示例:定时器中的闭包陷阱
痛点版:
useEffect(() => {
const timer = setInterval(() => {
console.log('current count', count); // 永远打印 0
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖,只捕获初始 count=0
问题:定时器闭包捕获初始 count,状态变也不更新。
优化版:
useEffect(() => {
const timer = setInterval(() => {
console.log('current count', count);
}, 1000);
return () => clearInterval(timer); // 每次 count 变,清旧定时器,新建
}, [count]); // 依赖 count,闭包更新
现在,count 变时,effect 重跑,闭包捕获新值。但注意:这会创建多个定时器?不!返回函数先清旧的。
易错提醒:
- 忘记依赖: ESLint 的 react-hooks/exhaustive-deps 会警告,但别盲目加——理解后再加。
- 无限循环:如果 effect 内 setState,且依赖该 state,会循环。解决:用函数式更新
setCount(c => c + 1),不依赖当前值。 - useRef 逃脱陷阱:用 ref.current 存储可变值,不受闭包影响。如
const countRef = useRef(count);在 effect 内更新 ref。 - 事件处理函数:onClick 内用状态,如果是 useCallback([]),捕获旧值。解决:依赖状态,或用 ref。
扩展底层逻辑:React Hooks 用链表存储(fiber.hooks)。每个 Hook 有 memoizedState 和 updateQueue。依赖比较用 Object.is(类似 ===)。闭包陷阱本质是词法作用域 + 渲染隔离:每个渲染是独立的“快照”。
高级避免:用 useReducer 集中状态逻辑,或自定义 Hooks 封装闭包。
第五部分:彻底分清“三兄弟”
用最直白的话总结它们的区别:
| 名字 | 本质是什么 | 缓存的是什么 | 主要解决什么问题 | 使用位置 |
|---|---|---|---|---|
| useMemo | Hook | 任意值的计算结果(数字、字符串、对象、数组、甚至 JSX) | 避免重复执行昂贵的计算 | 组件函数内部 |
| useCallback | Hook | 函数本身(函数引用) | 避免每次渲染都创建一个新函数 | 组件函数内部 |
| React.memo | 高阶组件(HOC) | 整个组件的渲染结果 | 避免 props 没变时子组件无谓重渲染 | 组件定义外面(包裹组件) |
虽然它们为什么“长得像”,但其实干的活完全不一样。
1. useMemo:缓存“值”的计算结果
核心目的:我有一个很贵的计算,只想在它真正依赖的东西变化时才重新算一遍。
const expensiveValue = useMemo(() => {
console.log('我在做很贵的计算...');
return heavyComputation(a, b); // 比如大数据过滤、排序、数学运算
}, [a, b]); // 只有 a 或 b 变了,才重新算
- 缓存的是 heavyComputation 的返回值(一个值)。
- 每次渲染时,如果 [a, b] 没变,就直接返回上次的缓存值,不执行函数。
- 典型场景:过滤列表、计算衍生数据、处理复杂对象。
记住:useMemo 是“懒汉”,它懒得重复算值。
2. useCallback:缓存“函数”本身
核心目的:我定义了一个函数,每次渲染都会重新创建一个新函数,但我不想这样,因为新函数会导致子组件误以为 props 变了而重渲染。
const handleClick = useCallback(() => {
console.log('点击了', count);
// 做点事
}, [count]); // 只有 count 变了,才返回一个新函数
-
缓存的是 函数引用(也就是 handleClick 这个变量本身)。
-
如果依赖 [count] 没变,它永远返回同一个函数实例(=== 相同)。
-
为什么需要这个?因为 JavaScript 里这样写:
jsx
const handleClick = () => { ... }每次组件渲染都会创建一个全新的函数对象,即使代码一模一样。
典型场景:把函数作为 props 传给子组件,尤其是子组件被 React.memo 包裹时。
记住:useCallback 是“稳定器”,它稳定函数的引用,防止子组件误以为 props 变了。
小知识:useCallback 其实是 useMemo 的特例!它等价于:
jsx
const handleClick = useMemo(() => () => { ... }, [count]);
React 单独给它起了个名字,就是因为这个场景太常见了。
3. React.memo:缓存“整个组件”的渲染
核心目的:这个子组件渲染很贵,但它的 props 经常没变,父组件重渲染时我不想让它也跟着重渲染。
const Child = React.memo(function Child({ data, onClick }) {
console.log('Child 渲染了'); // 只有 props 真的变了才会打印
return <div>复杂的 UI</div>;
});
- 缓存的是 组件上一次的渲染结果(虚拟 DOM 树)。
- React 会自动浅比较新旧 props,如果完全一样(===),就直接复用上次渲染的结果,完全跳过这个组件的函数执行。
- 它不关心你里面用了什么 Hook,只看 props。
记住:React.memo 是“门卫”,它守着子组件的大门,只有 props 真正变了才放行渲染。
为什么感觉他们“太像了”?
因为它们都用了“记忆化”(memoization)这个思想: “如果输入没变,就别重新干活,直接用上次的结果。”
- useMemo:输入是依赖数组,输出是值 → 记忆值
- useCallback:输入是依赖数组,输出是函数 → 记忆函数
- React.memo:输入是 props,输出是渲染结果 → 记忆组件渲染
经典组合拳
// 1. 子组件用 memo 包裹,防止无谓渲染
const Child = React.memo(function Child({ data, onClick }) {
return <ExpensiveUI data={data} onClick={onClick} />;
});
// 2. 父组件里
function Parent() {
const [count, setCount] = useState(0);
const [filter, setFilter] = useState('');
// 用 useMemo 缓存计算结果(稳定 data 对象引用)
const filteredData = useMemo(() => {
return bigList.filter(item => item.includes(filter));
}, [filter]);
// 用 useCallback 缓存函数(稳定 onClick 引用)
const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []); // 用函数式更新避免依赖 count
return (
<div>
<Child data={filteredData} onClick={handleClick} />
</div>
);
}
这样:
- filteredData 引用稳定 → Child 的 data props 稳定
- handleClick 引用稳定 → Child 的 onClick props 稳定
- Child 被 memo 包裹 → props 没变就不渲染 完美优化!
总结口诀(背下来就行)
- useMemo:缓存值,防重复计算
- useCallback:缓存函数,防引用变化
- React.memo:缓存组件,防无谓渲染
三兄弟各司其职,配合起来天下无敌!
结语:从优化到 mastery
通过 useMemo 和 useCallback,我们让 React 像精密仪器一样高效;避开闭包陷阱,则让逻辑如丝般顺滑。记住,React 的美在于响应式,但优化是艺术——平衡正确性和性能。