普通视图

发现新文章,点击刷新页面。
今天 — 2026年1月7日技术

GDAL 实现数据空间查询

作者 GIS之路
2026年1月7日 21:14

^ 关注我,带你一起学GIS ^

前言

在GIS开发中,空间查询和属性查询都是常见的基础操作,是每一个GISer都要掌握的必备技能。实现高效的数据查询功能可以提升用户体验,提升数据可视化效率。

在之前的文章中讲了如何使用GDAL或者ogr2ogr工具将txt以及csv文本数据转换为Shp格式,本篇教程在之前一系列文章的基础上讲解如何使用GDAL实现数据空间查询功能

如果你还没有看过,建议从以上内容开始。

1. 开发环境

本文使用如下开发环境,以供参考。

时间:2025年

系统:Windows 11

Python:3.11.7

GDAL:3.11.1

2. 空间查询

GDAL中,有两个图层方法可以用于实现空间查询,分别是SetSpatialFilterSetSpatialFilterRect。此种查询方式为直接在源数据上操作,返回结果为查询图层。

"""
参数
    -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开发环境中同时安装GDALPostGIS,其中投影库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开发 相关内容,欢迎关注 


    

GeoTools 开发合集(全)

OpenLayers 开发合集

GDAL 实现数据属性查询

GDAL 实现创建几何对象

GDAL 实现自定义数据坐标系

GDAL 实现矢量数据读写

GDAL 数据类型大全

GDAL 实现 GIS 数据读取转换(全)

别再被 TS 类型冲突折磨了!一文搞懂类型合并规则

作者 hboot
2026年1月7日 18:40

之前学习了TypeScript的类型定义,我们都知道开发语言中的变量会有覆盖声明的情况,那么对于类型定义是不是也会有这种情况,那么该如何正确利用这种合并规则?在遇到多个类型定义的时候,我们又该如何处理?

了解类型合并规则,有助于我们定义类型,避免类型冲突。可以利用这种合并规则,更灵活的定义类型。

不同版本TypeScript,不同配置可能会导致合并差异,这里说明使用的版本为"typescript": "^5.9.3",开启了配置"strict": true,测试文件后缀为.ts.d.ts文件可能更为宽松。

同名同类型合并

TypeScript仅支持接口interface、命名空间namespace、函数声明function同名合并。在声明解析阶段即完成合并。早于其他类型引用处理,比如交叉/联合类型。

对于typeclassenum等定义的同名类型都不能合并。

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();

扩展的静态成员最好不要覆盖函数本身的属性,比如namelength等。这些只读属性无法被覆盖,在运行时会报错。

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声明

2026年1月7日 18:25

一、using 声明简介

usingECMAScript 2023(ES14) 引入的一项新语法,用于自动管理资源的生命周期
它的主要目标是简化“资源使用完后自动释放”的场景,例如文件句柄、数据库连接、锁等。 相关提案可见:github.com/tc39/propos…

📜 语法结构:

using 变量名 = 表达式;
await using 变量名 = 异步表达式;

using 声明的变量必须是一个实现了 Symbol.disposeSymbol.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]()
  • 它可以和 trycatchfinally 一起安全使用。


四、使用场景

场景 传统做法 新写法(using)
文件操作 try/finally 手动关闭 自动调用 dispose
数据库连接 手动断开 自动释放连接
临时锁 try/finally 释放锁 离开作用域自动释放

五、注意事项

  1. using 只能在模块或函数作用域中使用,不能在全局作用域直接声明。
  2. 不能与 var 共用。
  3. 不会影响 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...    ')

})

image.png

revokeObjectURL的注意事项

当img加载后,执行revokeObjectURL并不会影响内容显示,但如果img未加载完就执行了revokeObjectURL,则无法显示图片。因此以上demo中增加了await promise的处理,确保图片被显示出来。 image.png

那么可能会有人问,此处能否用Symbol.asyncDispose来解决该问题?答案是不能。但这是一个很好的问题,触及到了 Symbol.asyncDisposeSymbol.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);
    })
},

image.png 可以看到,do others...在释放完成前就执行了。

因此可以理解:Symbol.asyncDispose是为了让后续的代码等待异步释放完成后再执行,因为有些释放场景,可能需要进行io或其他异步校验,而Symbol.dispose释放过程是同步的,后续代码执行时可以认为资源已经被释放了。如果后续代码执行时并不关心该资源是否已经释放了,那使用Symbol.dispose即可。

后端问前端:我的接口请求花了多少秒?为啥那么慢,是你慢还是我慢?

作者 江湖文人
2026年1月7日 18:10

image.png

不好意思,你说你看不懂接口 用了多少秒?我来告诉大家,因为我也要写记录📝。

我先把答案说出来,总耗时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分钟时它就开始了连接阶段(StalledRequest/Response等)。

所以,"Queued"和"Started at"是对整个页面加载时间线中的时间点标记,帮助你理解该请求在页面声明周期中何时发生,而不影响请求自身的8.54秒耗时。

JavaScript算法 - 冒泡排序

作者 梅川_酷子
2026年1月7日 18:03

冒泡排序(从小到大)

生成水面冒泡图片.png

/**
* 文字描述,毕竟不如口语表达,所以,这里有一些帮助阅读的方法
* 释义:
*    项:"数组的 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

2026年1月7日 17:46

总结一些工作中常用到的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迁移踩坑指南

2026年1月7日 17:35

前言

最近在进行Monorepo架构调整,需要将一个现有的Vue 3(Vite)项目作为一个子应用 (apps/wj) 迁移到由Vue 2(Webpack)主导的大仓中。本以为只是简单的“文件夹移动”,结果在依赖管理、网络代理和端口映射上踩了一圈坑。

本文记录了从迁移到跑通全流程遇到的4个典型问题及解决方案。

坑点一:pnpm 严格模式下的“幽灵依赖”

💥 现象

将项目移入大仓后,执行 dev 脚本报错:

'vite' 不是内部或外部命令,也不是可运行的程序

或者启动后报错找不到 unplugin-auto-importvue-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)报 304200,查看 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 迁移不仅仅是文件搬运,核心在于:

  1. 依赖边界:pnpm 下必须“谁用谁装”。
  2. 网络互通:Node 高版本下 localhost 的 IPv6 坑需要格外注意。
  3. 路由接管:主应用作为网关,必须接管子应用的所有请求(包括静态资源和 API)。

从弹窗变胖到 npm 依赖管理:一次完整的问题排查记录

作者 鹏北海
2026年1月7日 17:18

踩坑记录:从弹窗变胖到 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.22.13.1)
    ↓
引入 Element Plus 2.5.4+ 的破坏性变更
    ↓
弹窗多了 16px padding

本质问题

  1. 镜像地址没有统一管理,过期了没人更新
  2. Node 版本没有强制约束,各自为战
  3. 包管理器没有锁定,npm/yarn 混用
  4. 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

启用后的效果:

  1. 进入项目目录时,Corepack 读取 packageManager 字段
  2. 如果本地没有对应版本,自动下载
  3. 用错包管理器直接报错
  4. 版本不对也报错

方法二: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 样式修复

当前方案

  1. 把 Element Plus 版本固定为精确版本(去掉 ^),防止后续静默升级
  2. 对已受影响的弹窗组件,单独进行样式覆盖:
.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 锁定包管理器版本

八、经验教训

  1. lock 文件必须提交,别让依赖偷偷升级
  2. 镜像地址要统一管理,用 .npmrc/.yarnrc 锁定
  3. Node 版本要强制约束,用 .nvmrc + engines + engine-strict
  4. 包管理器也要锁,packageManager + Corepack 或 only-allow
  5. 敏感依赖用精确版本,UI 库这种别用 ^
  6. 新依赖用 ~ 前缀,比 ^ 安全,比精确版本灵活
  7. 删 lock 文件要谨慎,可能引入版本漂移
  8. 遇到问题要追根溯源,不能只解决表面现象

浅谈 import.meta.env 和 process.env 的区别

2026年1月6日 22:40

这是一个前端构建环境里非常核心、也非常容易混淆的问题。下面我们从来源、使用场景、编译时机、安全性四个维度来谈谈 import.meta.envprocess.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. 为什么必须分成两套?(设计原因)

1️⃣ 执行环境不同(这是根因)

位置 运行在哪 能访问什么
SSR Server Node.js process.env
Client Bundle 浏览器 import.meta.env

浏览器里 永远不可能安全地访问服务器环境变量


2️⃣ SSR ≠ 浏览器

很多人误解:

“SSR 是不是浏览器代码先在 Node 跑一遍?”

不完全对

SSR 实际是:

Node.js 先跑一份 → 生成 HTML
浏览器再跑一份 → hydrate

这两次执行:

  • 环境不同
  • 变量来源不同
  • 安全级别不同

  1. 在 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 不会、也不允许,自动帮你“透传”环境变量


  1. 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 配置文件

  1. SSR 项目里“正确的分层模型”(工程视角)

┌──────────────────────────┐
│        浏览器 Client       │
│  import.meta.env.VITE_*   │ ← 公开配置
└───────────▲──────────────┘
            │
        HTTP / HTML
            │
┌───────────┴──────────────┐
│        Node SSR Server     │
│      process.env.*        │ ← 私密配置
└───────────▲──────────────┘
            │
        内部访问
            │
┌───────────┴──────────────┐
│        DB / Redis / OSS    │
└──────────────────────────┘

这是一条 单向、安全的数据流


  1. 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

用心写好一个登录页:代码、体验与细节的平衡

作者 有意义
2026年1月7日 17:09

写在前面

今天,我们将使用 React + Vite + Tailwind CSS + Lucide React,快速搭建一个简洁、响应式且注重细节的登录页面,并顺手拆解几个提升用户体验的小技巧。

为什么登录页面非常重要?

别小看这个看似简单的页面——它往往是用户对产品的第一印象
登录页远不止是一个表单,更是整个产品体验的入口:设计得当,用户顺畅进入;处理草率,可能直接导致流失。

image.png


用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 类名的解释:

  1. min-h-screen — 设置元素最小高度为视口高度
  2. bg-slate-50 — 设置背景色为浅 slate 灰(非常淡的灰色)
  3. flex items-center justify-center — 使用 Flex 布局,垂直和水平居中子元素
  4. p-4 — 内边距为 1rem(16px)
  5. max-w-md — 最大宽度为中等尺寸(默认 28rem / 448px)
  6. bg-white — 背景色为纯白色
  7. rounded-3xl — 圆角非常大(默认 1.5rem / 24px)
  8. shadow-xl — 添加超大阴影,增强浮层感
  9. border-slate-100 — 边框颜色为极浅 slate 灰
  10. 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 属性,实现密码的显示与隐藏。

整个流程结构清晰、状态集中、扩展性强,为构建健壮的登录界面打下了良好基础。

image.png


为什么这个登录页“可维护”?

这份代码之所以易于迭代和调试,并非偶然。所有表单数据被统一收纳在 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 不一样的理解

作者 fe小陈
2026年1月7日 17:05

什么是 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 的返回函数中编写清理逻辑(如移除事件监听、清除定时器、关闭连接)。这是保障组件性能和稳定性的重要限制,忽略清理可能导致内存泄漏、多次触发副作用等问题。

不一样的想法

某些规则是可以打破的

juejin.cn/post/758429…

  1. 只能在函数顶部使用 hook
  2. 条件 hook
  3. 类组件内使用 hook

类组件完全放弃了吗?代价是什么?

在新的项目中,几乎已经看不到类组件被使用(除了手搓 ErrorBoundary)。

但在享受 hook 带来函数式组件魔法的过程中,也引入了许多的问题

  1. 为了防止子组件重渲染,需要对回调函数、数据做 memo(useCallback、useMemo)
  2. 少传个 dep,导致闭包问题、子组件不更新问题
  3. 然后又引入了 React Compiler 、useEventEffect

这就有点为了填一个坑,挖了另一个坑的感觉

类组件是有可取之处的,比如

  1. 回调方法通过 this.state 是可以取到最新的状态的,因此不需要那么多 useCallback useMemo,减少了性能优化的心智负担;

  2. ref 可以直接使用组件的属性,无需像函数组件那样借助 useRef 再手动关联,操作更简洁;

  3. 生命周期逻辑时序更直观:类组件通过 componentDidMount、componentDidUpdate、componentWillUnmount 等明确的钩子划分生命周期阶段,复杂副作用(如多轮数据请求、时序依赖的资源操作)的执行时机更易把控,无需像 useEffect 那样通过依赖数组间接控制;

  4. 状态更新支持自动合并:类组件中 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年1月7日 17:00

摘要:都 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?

  1. 复用逻辑:同样的逻辑写一次,到处用
  2. 关注点分离:组件只管渲染,逻辑交给 Hook
  3. 更好测试:Hook 可以单独测试
  4. 代码更清晰:组件代码从 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 一次讲清楚

作者 Sailing
2026年1月7日 16:56

在做 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 到底该怎么用

2026年1月7日 16:55

在 React 的世界里,组件的渲染就像一场“牵一发而动全身”的多米诺骨牌。父组件打个喷嚏(State 变了),底下的子组件全得跟着感冒(重新渲染)。

虽然 React 够快,但如果你的组件里住着一只“吞金兽”(昂贵的计算逻辑),或者你的子组件是个“强迫症”(非要 Props 完全没变才肯不渲染),那你就得请出 React 性能优化的两尊大神了:useMemouseCallback

很多人分不清它俩,其实很简单:

  • 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 还是原来那个 handleClickChild 组件发现 Props 没变,就安心地躺平不渲染了。

注意:如果你需要在回调里用到 count,记得把它加进依赖数组:

JavaScript

const handleClick = useCallback(() => {
  // 如果依赖数组里没写 count,这里永远打印 0 (闭包陷阱)
  console.log('click', count); 
}, [count]); 
// 👆 一旦 count 变了,函数引用还是会变,Child 还是会渲染。
// 这是为了保证逻辑正确性必须付出的代价。

总结

别为了优化而优化。useMemouseCallback 也是有成本的(它们本身也需要消耗内存来做依赖对比)。

请遵循这套“心法”:

  1. useMemo

    • 昂贵计算:当你看到 for 循环次数巨大,或者复杂的递归时。
    • 引用稳定:当你计算出的对象/数组,要作为 useEffect 的依赖项,或者传给被 memo 包裹的子组件时。
  2. useCallback

    • 配合 React.memo:当你的函数需要传给一个“很重”的子组件,且该子组件被 memo 包裹时。
    • 作为 Hooks 依赖:当这个函数要被用作 useEffect 的依赖项时。

聊聊那个让 React 新手抓狂的“闭包陷阱”:Count 为什么永远是 0?

2026年1月7日 16:48

写 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 增加时:

  1. 界面(UI) :显示 1, 2, 3... (正常更新,说明 State 确实变了)。
  2. 控制台(Console)Current count: 0 ... Current count: 0 ... (像复读机一样)。

image.png

为什么会这样?

要理解这个问题,首先要修正一个心智模型:每一次渲染(Render),都是一次独立的“快照”。

  1. 第一次渲染 (Mount)

    • React 创建了组件,此时 count = 0
    • useEffect 执行。因为它依赖是 [],所以它只在第一次渲染时执行
    • setInterval 被创建。关键点来了: 这个定时器的回调函数是在 count0 的那个闭包作用域里定义的。它捕获了那一刻的 count(也就是 0)。
  2. 第二次渲染 (点击按钮后)

    • React 再次执行组件函数,count 变成了 1
    • 但是! useEffect 的依赖数组是空的,React 认为“没必要重新运行这个 Effect”。
    • 于是,那个旧的定时器(Mount 时创建的)依然在坚强地活着。它手里紧紧攥着的,依然是第一次渲染时的旧变量 0

简单来说:你的组件 UI 已经活在 2026 年了,但那个定时器还活在 2023 年,它根本不知道外面的世界变了。这就是 JS 词法作用域与 React Hooks 机制碰撞出的“火花”。


怎么爬出陷阱?

既然知道了是因为“引用了旧变量”,那想要实现如下图片效果,思路就很清晰了:要么让 Effect 重新执行,要么用某种方式穿透闭包。

image.png

方法一:诚实地告诉 React 你的依赖(官方推荐)

这就是修复后的代码逻辑,也是最符合 React 数据流直觉的写法:

JavaScript

useEffect(() => {
  const timer = setInterval(() => {
    // ✅ 此时能读到最新的 count
    console.log('Current count:', count);
  }, 1000);

  // 每次 effect 重新执行之前 都会执行上一次的清理函数
  return () => clearInterval(timer);
}, [count]); // 👈 把 count 加入依赖数组

原理分析: 一旦把 [count] 加入依赖数组,逻辑就变了:

  1. count 变了 -> useEffect 发现依赖变了。
  2. React 先执行 cleanup 函数(clearInterval),杀掉旧的定时器。
  3. React 执行新的 useEffect,创建一个的定时器。
  4. 这个定时器是在当前渲染闭包里创建的,所以它捕获的是最新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 库 ahooksuseInterval 的核心实现原理。


总结

React 闭包陷阱本质上是 JavaScript 闭包机制React 声明式编程 之间的一种“沟通误会”。

  • 陷阱成因useEffectuseCallback 等 Hooks 的依赖数组写少了,导致内部函数引用了旧的渲染闭包中的变量。
  • 基础解法:补全依赖数组(但要注意副作用的频繁执行)。
  • 进阶解法:使用 useRef 作为“逃生舱”,在不重启 Effect 的情况下,透过闭包读取最新状态。

历时1年,TinyEditor v4.0 正式发布!

2026年1月7日 16:32

本文由体验技术团队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…

效果如下:

1.JPG

关于协同编辑更详细的介绍,参考:如何使用 TinyEditor 快速部署一个多人协同富文本编辑器?

1.2 表格能力升级

集成了 table-up 模块,大幅提升了表格编辑和操作能力,支持更复杂的表格场景。

体验地址:opentiny.github.io/tiny-editor…

效果如下:

2.gif

详细介绍可以参考之前的文章: TinyEditor v4.0 alpha:表格更强大,表情更丰富,上传体验超乎想象!

1.3 更丰富的 Emoji 表情😘

  • 集成 emoji-mart,提供丰富的表情选择
  • 修复了插入表情后的光标位置问题
  • 完善了表情插入的交互体验

体验地址:opentiny.github.io/tiny-editor…

效果如下:

3.gif

详细介绍可以参考之前的文章:TinyEditor v4.0 alpha:表格更强大,表情更丰富,上传体验超乎想象!

1.4 快捷键和快速菜单

新增了强大的快捷键系统和快速菜单功能,让高级用户能够更高效地操作编辑器。

体验地址:opentiny.github.io/tiny-editor…

效果如下:

4.png

1.5 颜色选择器升级

自定义颜色选择器现在能保存当前选择,并支持添加更多颜色。

效果如下:

5.png

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 与我们联系。

往期推荐文章

关于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

作者 time_rg
2026年1月7日 16:10

通过课程和博客学习react,能够应付平时的开发工作。但到了面试等环节,对于fiber,setState的同步异步问题,说是知道,但往往朝深处一问,结合实际做一些输出题,脑袋里往往没有清晰的脉络,所以我决定自己实现一份miniReact,提升自己对react的理解。

本文大部分内容都是从历史好文build your own react中参考借鉴,这确实是我看到的最好的学习react的文章,在这里表示感谢。

地址:pomb.us/build-your-…

准备工作

首先新启一个项目

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 与闭包陷阱的深度剖析

作者 不会js
2026年1月7日 16:01

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 变时打印日志。

易错提醒:

  1. 依赖数组漏写:如果忘了 [keyword],useMemo 只跑一次,keyword 变也不会更新——像个“失忆的管家”。
  2. 过度依赖:数组中放对象/数组时,浅比较失效(因为新对象 !== 旧对象)。解决:用 useMemo 缓存对象,或用 lodash 的 deepEqual(但不推荐,增加开销)。
  3. 返回值类型:useMemo 可以缓存任何值,包括 JSX!如 const memoizedJSX = useMemo(() => <HeavyComponent />, [deps]); 用于优化虚拟 DOM 生成。
  4. 性能陷阱: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 引用稳定。

易错提醒:

  1. 空依赖 []:函数永不更新,但如果函数内用闭包捕获变量,会导致“陈旧值”问题(详见闭包陷阱)。
  2. 过度使用:useCallback 缓存函数,但如果子组件不 memo,就没必要。记住:优化是针对瓶颈的。
  3. 与 useMemo 的区别:useCallback 是 useMemo 的特化版,等价于 useMemo(() => fn, deps)。但 useCallback 更语义化。
  4. 事件处理: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 重跑,闭包捕获新值。但注意:这会创建多个定时器?不!返回函数先清旧的。

易错提醒:

  1. 忘记依赖: ESLint 的 react-hooks/exhaustive-deps 会警告,但别盲目加——理解后再加。
  2. 无限循环:如果 effect 内 setState,且依赖该 state,会循环。解决:用函数式更新 setCount(c => c + 1),不依赖当前值。
  3. useRef 逃脱陷阱:用 ref.current 存储可变值,不受闭包影响。如 const countRef = useRef(count); 在 effect 内更新 ref。
  4. 事件处理函数: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 的美在于响应式,但优化是艺术——平衡正确性和性能。

Three.js 高性能天气效果实现:下雨与下雪的 GPU 粒子系统

作者 niconicoC
2026年1月7日 15:55

本文介绍如何使用 Three.js 的 InstancedMesh + 自定义 Shader 实现高性能的下雨和下雪效果,支持数万级粒子、无限循环、广告牌效果等特性。

效果预览

20260107_154756.gif

实现的天气效果具有以下特性:

  • 高性能 - 基于 GPU Instancing,支持 30000+ 雪花 / 50000+ 雨滴
  • 🔄 无限循环 - 粒子围绕相机循环,无视觉边界
  • 📷 广告牌效果 - 粒子始终面向相机
  • 🎨 可配置 - 支持数量、速度、大小、颜色等参数动态调节

核心技术选型

为什么选择 InstancedMesh?

传统粒子系统(Points)的问题:

  • 粒子只能是正方形点
  • 无法实现复杂形状(如拉长的雨滴)
  • 难以实现自定义着色

InstancedMesh 优势

  • 单次 Draw Call 渲染数万实例
  • 每个实例可以有独立的位置、缩放、旋转
  • 支持完全自定义的 Shader
// 创建 InstancedMesh
const geometry = new THREE.PlaneGeometry(0.2, 0.2);
const material = new THREE.ShaderMaterial({ /* 自定义 Shader */ });
const mesh = new THREE.InstancedMesh(geometry, material, MAX_COUNT);

下雪效果实现 ❄️

1. 实例属性设计

每个雪花需要独立的属性:

// 为每个雪花实例分配随机属性
const aOffset = new Float32Array(MAX_COUNT * 3);  // 初始位置偏移
const aSpeed = new Float32Array(MAX_COUNT);       // 下落速度
const aSwayFreq = new Float32Array(MAX_COUNT);    // 摇摆频率
const aSwayAmp = new Float32Array(MAX_COUNT);     // 摇摆幅度
const aScale = new Float32Array(MAX_COUNT);       // 大小缩放

for (let i = 0; i < MAX_COUNT; i++) {
  // 随机分布在 100x50x100 的空间内
  aOffset[i * 3] = (Math.random() - 0.5) * 100;     // X
  aOffset[i * 3 + 1] = (Math.random() - 0.5) * 50;  // Y
  aOffset[i * 3 + 2] = (Math.random() - 0.5) * 100; // Z
  
  aSpeed[i] = 2.0 + Math.random() * 3.0;
  aSwayFreq[i] = 1.0 + Math.random() * 2.0;
  aSwayAmp[i] = 0.5 + Math.random() * 1.0;
  aScale[i] = 0.5 + Math.random() * 1.0;
}

// 绑定为实例属性
geometry.setAttribute('aOffset', new THREE.InstancedBufferAttribute(aOffset, 3));
geometry.setAttribute('aSpeed', new THREE.InstancedBufferAttribute(aSpeed, 1));
// ...

2. 顶点着色器:无限循环

核心逻辑在顶点着色器中实现:

uniform float uTime;
uniform float uHeight;        // 垂直范围
uniform float uRange;         // 水平范围
uniform vec3 uCameraPosition; // 相机位置
uniform float uSizeScale;
uniform float uSpeedScale;

attribute float aSpeed;
attribute float aSwayFreq;
attribute float aSwayAmp;
attribute vec3 aOffset;
attribute float aScale;

void main() {
  vec3 pos = aOffset;
  
  // 1. 动态下落(Y轴)
  float timeOffsetY = uTime * aSpeed * uSpeedScale;
  
  // 2. 无限循环:使用 mod 让粒子围绕相机循环
  pos.x = mod(aOffset.x - uCameraPosition.x, uRange) - uRange * 0.5 + uCameraPosition.x;
  pos.z = mod(aOffset.z - uCameraPosition.z, uRange) - uRange * 0.5 + uCameraPosition.z;
  pos.y = mod(aOffset.y - timeOffsetY - uCameraPosition.y, uHeight) - uHeight * 0.5 + uCameraPosition.y;

  // 3. 水平摇摆(模拟风吹雪花飘动)
  pos.x += sin(uTime * aSwayFreq + aOffset.y) * aSwayAmp;
  pos.z += cos(uTime * aSwayFreq + aOffset.x) * aSwayAmp;

  // 4. 广告牌效果:让平面始终面向相机
  vec4 mvPosition = viewMatrix * modelMatrix * vec4(pos, 1.0);
  mvPosition.xyz += position * aScale * uSizeScale;  // position 是平面的局部坐标

  gl_Position = projectionMatrix * mvPosition;
}

关键技巧解读

  1. mod 取模运算 - 让粒子在固定范围内循环,超出边界自动回到另一侧
  2. 相机位置跟随 - 循环范围始终以相机为中心,玩家移动时雪花跟随
  3. viewMatrix 应用 - 直接在视图空间中偏移顶点,实现广告牌效果

3. 片元着色器:圆形雪花 + 边缘渐隐

uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vUv;
varying float vAlpha;

void main() {
  // 计算到中心的距离,生成圆形
  float dist = distance(vUv, vec2(0.5));
  float alpha = smoothstep(0.5, 0.3, dist);

  if (alpha < 0.01) discard;

  // 应用边缘渐隐和全局透明度
  gl_FragColor = vec4(uColor, alpha * vAlpha * uOpacity);
}

边缘渐隐在顶点着色器中计算:

// 距离相机越远,alpha 越低
float fadeLimit = uRange * 0.45;
vAlpha = smoothstep(fadeLimit, fadeLimit * 0.8, abs(pos.x - uCameraPosition.x));
vAlpha *= smoothstep(fadeLimit, fadeLimit * 0.8, abs(pos.z - uCameraPosition.z));

下雨效果实现 🌧️

下雨效果在雪的基础上增加了折射效果,让雨滴更真实。

1. 雨滴形状

雨滴是细长的矩形,通过缩放实现:

dummy.scale.set(0.02, THREE.MathUtils.randFloat(0.4, 0.8), 0.02);

2. 背景折射(FBO 技术)

要实现雨滴的透明折射效果,需要:

  1. 先渲染背景到 RenderTarget(FBO)
  2. 雨滴采样 FBO 并偏移 UV 模拟折射
// 创建低分辨率 FBO(性能优化)
this.bgFBO = new THREE.WebGLRenderTarget(
  canvas.width * 0.1,  // 10% 分辨率
  canvas.height * 0.1
);

// 渲染循环中
preRender() {
  // 1. 隐藏雨滴
  this.instancedMesh.visible = false;
  
  // 2. 渲染场景到 FBO
  this.renderer.setRenderTarget(this.bgFBO);
  this.renderer.render(this.scene, this.camera);
  this.renderer.setRenderTarget(null);
  
  // 3. 恢复雨滴,并传入 FBO 纹理
  this.instancedMesh.visible = true;
  this.rainMaterial.uniforms.uBgRt.value = this.bgFBO.texture;
}

3. 雨滴 Shader 中的折射计算

// 顶点着色器:计算屏幕空间坐标
vec2 screenspace(mat4 proj, mat4 mv, vec3 pos) {
  vec4 temp = proj * mv * vec4(pos, 1.0);
  temp.xyz /= temp.w;
  temp.xy = 0.5 + temp.xy * 0.5;  // [-1,1] -> [0,1]
  return temp.xy;
}

varying vec2 vScreenspace;  // 传递给片元着色器
vScreenspace = screenspace(projectionMatrix, viewMatrix, finalPos);
// 片元着色器:采样背景并偏移
uniform sampler2D uBgRt;
uniform float uRefraction;

void main() {
  // 计算雨滴法线(模拟圆柱形水滴)
  vec3 normal = vec3((vUv.x - 0.5) * 2.0, 0.0, 0.5);
  normal = normalize(normal);
  
  // 根据法线偏移 UV,模拟折射
  vec2 bgUv = vScreenspace + normal.xy * uRefraction;
  vec4 bgColor = texture2D(uBgRt, bgUv);
  
  // 添加高光和蓝色调
  float brightness = 0.8 * pow(max(0.0, normal.z), 4.0);
  vec3 col = bgColor.rgb + vec3(brightness);
  col += vec3(0.0, 0.05, 0.1) * alpha;  // 蓝色调
  
  gl_FragColor = vec4(col, alpha);
}

性能优化要点

1. InstancedMesh 复用

预分配最大数量的实例,通过 mesh.count 控制实际渲染数量:

const mesh = new THREE.InstancedMesh(geometry, material, MAX_COUNT);
mesh.count = currentCount;  // 动态调整,无需重建

2. 低分辨率 FBO

雨效折射只需要模糊的背景信息,使用 10% 分辨率的 FBO 大幅降低性能开销。

3. 禁用视锥剔除

粒子系统的包围盒难以准确计算,直接禁用避免闪烁:

mesh.frustumCulled = false;

4. GPU 端计算

所有位置更新、循环判断都在 Shader 中完成,CPU 只传递时间和相机位置。


封装为可复用模块

最终将天气效果封装为独立的管理器类:

class SnowManager {
  constructor(scene, camera) {
    this.scene = scene;
    this.camera = camera;
    this._init();
  }
  
  setEnabled(enabled, config) { /* ... */ }
  updateConfig(config) { /* ... */ }
  update(time) {
    this.material.uniforms.uTime.value = time * 0.001;
    this.material.uniforms.uCameraPosition.value.copy(this.camera.position);
  }
  dispose() { /* 清理资源 */ }
}

使用方式:

const snowManager = new SnowManager(scene, camera);

// 开启下雪
snowManager.setEnabled(true, { count: 10000, speed: 1.0 });

// 在动画循环中更新
function animate(time) {
  snowManager.update(time);
  renderer.render(scene, camera);
}

总结

技术点 实现方式
高性能渲染 InstancedMesh + GPU Instancing
无限循环 mod 取模 + 相机位置跟随
广告牌效果 viewMatrix 空间中偏移顶点
边缘渐隐 smoothstep + 距离衰减
雨滴折射 FBO 背景采样 + UV 偏移

通过这套方案,可以在 单个 Draw Call 内渲染数万级粒子,同时保持 60fps 的流畅体验。


源码地址

GitHub: Meteor3D

查看效果:meteor3d.cn

欢迎 Star ⭐ 和 Fork 🍴!


如果这篇文章对你有帮助,请点个赞 👍

❌
❌